perf: offload long running tasks to workers, preloading dm page data
This commit is contained in:
parent
cad305aa66
commit
df218b9a56
18 changed files with 524 additions and 297 deletions
|
@ -1,6 +1,6 @@
|
|||
import { type Component } from "solid-js";
|
||||
import { Route } from "@solidjs/router";
|
||||
import { DmId, GroupId, Home, Overview } from "./pages";
|
||||
import { DmId, GroupId, Home, Overview, preloadDmId } from "./pages";
|
||||
|
||||
import "./app.css";
|
||||
|
||||
|
@ -9,7 +9,7 @@ const App: Component = () => {
|
|||
<>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/overview" component={Overview} />
|
||||
<Route path="/dm/:dmid" component={DmId} />
|
||||
<Route path="/dm/:dmid" component={DmId} preload={preloadDmId} />
|
||||
<Route path="/group/:groupid" component={GroupId} />
|
||||
</>
|
||||
);
|
||||
|
|
117
src/db-queries.ts
Normal file
117
src/db-queries.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { sql, type NotNull } from "kysely";
|
||||
import { cached } from "./lib/db-cache";
|
||||
import { kyselyDb, SELF_ID } from "./db";
|
||||
|
||||
const allThreadsOverviewQueryRaw = () =>
|
||||
kyselyDb()
|
||||
?.selectFrom("thread")
|
||||
.innerJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom("message")
|
||||
.select((eb) => ["message.thread_id", eb.fn.countAll().as("message_count")])
|
||||
.where((eb) => {
|
||||
return eb.and([eb("message.body", "is not", null), eb("message.body", "is not", "")]);
|
||||
})
|
||||
.groupBy("message.thread_id")
|
||||
.as("message"),
|
||||
(join) => join.onRef("message.thread_id", "=", "thread._id"),
|
||||
)
|
||||
.innerJoin("recipient", "thread.recipient_id", "recipient._id")
|
||||
.leftJoin("groups", "recipient._id", "groups.recipient_id")
|
||||
.select([
|
||||
"thread._id as thread_id",
|
||||
"thread.recipient_id",
|
||||
"thread.archived",
|
||||
"recipient.profile_joined_name",
|
||||
"recipient.system_joined_name",
|
||||
"groups.title",
|
||||
"message_count",
|
||||
"thread.date as last_message_date",
|
||||
"recipient.nickname_joined_name",
|
||||
])
|
||||
.where("message_count", ">", 0)
|
||||
.$narrowType<{
|
||||
thread_id: NotNull;
|
||||
archived: NotNull;
|
||||
message_count: number;
|
||||
}>()
|
||||
.execute();
|
||||
|
||||
export const allThreadsOverviewQuery = cached(allThreadsOverviewQueryRaw);
|
||||
|
||||
const overallSentMessagesQueryRaw = (recipientId: number) =>
|
||||
kyselyDb()
|
||||
?.selectFrom("message")
|
||||
.select((eb) => eb.fn.countAll().as("messageCount"))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb("message.from_recipient_id", "=", recipientId),
|
||||
eb("message.body", "is not", null),
|
||||
eb("message.body", "!=", ""),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
export const overallSentMessagesQuery = cached(overallSentMessagesQueryRaw);
|
||||
|
||||
const dmPartnerRecipientQueryRaw = (dmId: number) =>
|
||||
kyselyDb()
|
||||
?.selectFrom("recipient")
|
||||
.select([
|
||||
"recipient._id",
|
||||
"recipient.system_joined_name",
|
||||
"recipient.profile_joined_name",
|
||||
"recipient.nickname_joined_name",
|
||||
])
|
||||
.innerJoin("thread", "recipient._id", "thread.recipient_id")
|
||||
.where((eb) => eb.and([eb("thread._id", "=", dmId), eb("recipient._id", "!=", SELF_ID)]))
|
||||
.$narrowType<{
|
||||
_id: number;
|
||||
}>()
|
||||
.executeTakeFirst();
|
||||
|
||||
export const dmPartnerRecipientQuery = cached(dmPartnerRecipientQueryRaw);
|
||||
|
||||
const threadSentMessagesOverviewQueryRaw = (threadId: number) =>
|
||||
kyselyDb()
|
||||
?.selectFrom("message")
|
||||
.select(["from_recipient_id", sql<string>`datetime(date_sent / 1000, 'unixepoch')`.as("message_datetime")])
|
||||
.orderBy(["message_datetime"])
|
||||
.where((eb) => eb.and([eb("body", "is not", null), eb("body", "!=", ""), eb("thread_id", "=", threadId)]))
|
||||
.execute();
|
||||
|
||||
export const threadSentMessagesOverviewQuery = cached(threadSentMessagesOverviewQueryRaw);
|
||||
|
||||
const threadMostUsedWordsQueryRaw = (threadId: number, limit = 10) =>
|
||||
kyselyDb()
|
||||
?.withRecursive("words", (eb) => {
|
||||
return eb
|
||||
.selectFrom("message")
|
||||
.select([
|
||||
sql`LOWER(substr(body, 1, instr(body || " ", " ") - 1))`.as("word"),
|
||||
sql`(substr(body, instr(body || " ", " ") + 1))`.as("rest"),
|
||||
])
|
||||
.where((eb) => eb.and([eb("body", "is not", null), eb("thread_id", "=", threadId)]))
|
||||
.unionAll((ebInner) => {
|
||||
return ebInner
|
||||
.selectFrom("words")
|
||||
.select([
|
||||
sql`LOWER(substr(rest, 1, instr(rest || " ", " ") - 1))`.as("word"),
|
||||
sql`(substr(rest, instr(rest || " ", " ") + 1))`.as("rest"),
|
||||
])
|
||||
.where("rest", "<>", "");
|
||||
});
|
||||
})
|
||||
.selectFrom("words")
|
||||
.select((eb) => ["word", eb.fn.countAll().as("count")])
|
||||
.where("word", "<>", "")
|
||||
.groupBy("word")
|
||||
.orderBy("count desc")
|
||||
.limit(limit)
|
||||
.$narrowType<{
|
||||
count: number;
|
||||
}>()
|
||||
.execute();
|
||||
|
||||
export const threadMostUsedWordsQuery = cached(threadMostUsedWordsQueryRaw);
|
119
src/db.ts
119
src/db.ts
|
@ -1,12 +1,11 @@
|
|||
import { createEffect, createMemo, createRoot, createSignal } from "solid-js";
|
||||
|
||||
import { Kysely, sql, type NotNull } from "kysely";
|
||||
import { Kysely } from "kysely";
|
||||
import type { DB } from "kysely-codegen";
|
||||
import { SqlJsDialect } from "kysely-wasm";
|
||||
import initSqlJS, { type Database } from "sql.js";
|
||||
|
||||
import wasmURL from "./assets/sql-wasm.wasm?url";
|
||||
import { cached } from "./lib/db-cache";
|
||||
|
||||
export const SELF_ID = 2;
|
||||
|
||||
|
@ -26,7 +25,7 @@ const sqlJsDialect = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const kyselyDb = createRoot(() => {
|
||||
export const kyselyDb = createRoot(() => {
|
||||
createEffect(() => {
|
||||
const currentDb = db();
|
||||
|
||||
|
@ -49,117 +48,3 @@ const kyselyDb = createRoot(() => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
const allThreadsOverviewQueryRaw = () =>
|
||||
kyselyDb()
|
||||
?.selectFrom("thread")
|
||||
.innerJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom("message")
|
||||
.select((eb) => ["message.thread_id", eb.fn.countAll().as("message_count")])
|
||||
.where((eb) => {
|
||||
return eb.and([eb("message.body", "is not", null), eb("message.body", "is not", "")]);
|
||||
})
|
||||
.groupBy("message.thread_id")
|
||||
.as("message"),
|
||||
(join) => join.onRef("message.thread_id", "=", "thread._id"),
|
||||
)
|
||||
.innerJoin("recipient", "thread.recipient_id", "recipient._id")
|
||||
.leftJoin("groups", "recipient._id", "groups.recipient_id")
|
||||
.select([
|
||||
"thread._id as thread_id",
|
||||
"thread.recipient_id",
|
||||
"thread.archived",
|
||||
"recipient.profile_joined_name",
|
||||
"recipient.system_joined_name",
|
||||
"groups.title",
|
||||
"message_count",
|
||||
"thread.date as last_message_date",
|
||||
"recipient.nickname_joined_name",
|
||||
])
|
||||
.where("message_count", ">", 0)
|
||||
.$narrowType<{
|
||||
thread_id: NotNull;
|
||||
archived: NotNull;
|
||||
message_count: number;
|
||||
}>()
|
||||
.execute();
|
||||
|
||||
export const allThreadsOverviewQuery = cached(allThreadsOverviewQueryRaw);
|
||||
|
||||
const overallSentMessagesQueryRaw = (recipientId: number) =>
|
||||
kyselyDb()
|
||||
?.selectFrom("message")
|
||||
.select((eb) => eb.fn.countAll().as("messageCount"))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb("message.from_recipient_id", "=", recipientId),
|
||||
eb("message.body", "is not", null),
|
||||
eb("message.body", "!=", ""),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
export const overallSentMessagesQuery = cached(overallSentMessagesQueryRaw);
|
||||
|
||||
const dmPartnerRecipientQueryRaw = (dmId: number) =>
|
||||
kyselyDb()
|
||||
?.selectFrom("recipient")
|
||||
.select([
|
||||
"recipient._id",
|
||||
"recipient.system_joined_name",
|
||||
"recipient.profile_joined_name",
|
||||
"recipient.nickname_joined_name",
|
||||
])
|
||||
.innerJoin("thread", "recipient._id", "thread.recipient_id")
|
||||
.where((eb) => eb.and([eb("thread._id", "=", dmId), eb("recipient._id", "!=", SELF_ID)]))
|
||||
.$narrowType<{
|
||||
_id: number;
|
||||
}>()
|
||||
.executeTakeFirst();
|
||||
|
||||
export const dmPartnerRecipientQuery = cached(dmPartnerRecipientQueryRaw);
|
||||
|
||||
const threadSentMessagesOverviewQueryRaw = (threadId: number) =>
|
||||
kyselyDb()
|
||||
?.selectFrom("message")
|
||||
.select(["from_recipient_id", sql<string>`datetime(date_sent / 1000, 'unixepoch')`.as("message_datetime")])
|
||||
.orderBy(["message_datetime"])
|
||||
.where((eb) => eb.and([eb("body", "is not", null), eb("body", "!=", ""), eb("thread_id", "=", threadId)]))
|
||||
.execute();
|
||||
|
||||
export const threadSentMessagesOverviewQuery = cached(threadSentMessagesOverviewQueryRaw);
|
||||
|
||||
const threadMostUsedWordsQueryRaw = (threadId: number, limit = 10) =>
|
||||
kyselyDb()
|
||||
?.withRecursive("words", (eb) => {
|
||||
return eb
|
||||
.selectFrom("message")
|
||||
.select([
|
||||
sql`LOWER(substr(body, 1, instr(body || " ", " ") - 1))`.as("word"),
|
||||
sql`(substr(body, instr(body || " ", " ") + 1))`.as("rest"),
|
||||
])
|
||||
.where((eb) => eb.and([eb("body", "is not", null), eb("thread_id", "=", threadId)]))
|
||||
.unionAll((ebInner) => {
|
||||
return ebInner
|
||||
.selectFrom("words")
|
||||
.select([
|
||||
sql`LOWER(substr(rest, 1, instr(rest || " ", " ") - 1))`.as("word"),
|
||||
sql`(substr(rest, instr(rest || " ", " ") + 1))`.as("rest"),
|
||||
])
|
||||
.where("rest", "<>", "");
|
||||
});
|
||||
})
|
||||
.selectFrom("words")
|
||||
.select((eb) => ["word", eb.fn.countAll().as("count")])
|
||||
.where("word", "<>", "")
|
||||
.groupBy("word")
|
||||
.orderBy("count desc")
|
||||
.limit(limit)
|
||||
.$narrowType<{
|
||||
count: number;
|
||||
}>()
|
||||
.execute();
|
||||
|
||||
export const threadMostUsedWordsQuery = cached(threadMostUsedWordsQueryRaw);
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { Router, useNavigate } from "@solidjs/router";
|
||||
import { MetaProvider } from "@solidjs/meta";
|
||||
|
||||
import App from "./App";
|
||||
import { createEffect } from "solid-js";
|
||||
import { db } from "./db";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
|
@ -18,7 +20,20 @@ if (root) {
|
|||
() => (
|
||||
<div class="mx-auto max-w-screen-2xl">
|
||||
<MetaProvider>
|
||||
<Router>
|
||||
<Router
|
||||
root={(props) => {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = props.location;
|
||||
|
||||
createEffect(() => {
|
||||
if (!db() && pathname !== "/") {
|
||||
navigate("/");
|
||||
}
|
||||
});
|
||||
|
||||
return props.children;
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</Router>
|
||||
</MetaProvider>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { createRoot, on, createDeferred } from "solid-js";
|
||||
import { on, createSignal, createEffect, createRoot, createMemo } from "solid-js";
|
||||
import { serialize, deserialize } from "seroval";
|
||||
import { createSignaledWorker } from "@solid-primitives/workers";
|
||||
import { db } from "~/db";
|
||||
|
||||
const DATABASE_HASH_PREFIX = "database";
|
||||
|
||||
|
@ -30,31 +32,48 @@ const hashString = (str: string) => {
|
|||
|
||||
const HASH_STORE_KEY = `${DATABASE_HASH_PREFIX}_hash`;
|
||||
|
||||
// cannot import `db` the normal way because this file is imported in ~/db.ts before the initialisation of `db` has happened
|
||||
createRoot(() => {
|
||||
void import("~/db").then(({ db }) => {
|
||||
// we use create deferred because hasing can take very long and we don't want to block the mainthread
|
||||
createDeferred(
|
||||
on(db, (currentDb) => {
|
||||
if (currentDb) {
|
||||
const newHash = hashString(new TextDecoder().decode(currentDb.export())).toString();
|
||||
const [dbHash, setDbHash] = createSignal(localStorage.getItem(HASH_STORE_KEY));
|
||||
|
||||
const oldHash = localStorage.getItem(HASH_STORE_KEY);
|
||||
|
||||
if (newHash !== oldHash) {
|
||||
clearDbCache();
|
||||
|
||||
localStorage.setItem(HASH_STORE_KEY, newHash);
|
||||
}
|
||||
// offloaded because this can take a long time (>1s easily) and would block the mainthread
|
||||
createSignaledWorker({
|
||||
input: db,
|
||||
output: setDbHash,
|
||||
func: function hashDb(currentDb: ReturnType<typeof db>) {
|
||||
const hashString = (str: string) => {
|
||||
let hash = 0,
|
||||
i,
|
||||
chr;
|
||||
if (str.length === 0) return hash;
|
||||
for (i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
}),
|
||||
);
|
||||
return hash;
|
||||
};
|
||||
|
||||
if (currentDb?.export) {
|
||||
return hashString(new TextDecoder().decode(currentDb.export())).toString();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
on(dbHash, (currentDbHash) => {
|
||||
if (currentDbHash) {
|
||||
clearDbCache();
|
||||
|
||||
localStorage.setItem(HASH_STORE_KEY, currentDbHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class LocalStorageCacheAdapter {
|
||||
keys = new Set<string>(Object.keys(localStorage).filter((key) => key.startsWith(this.prefix)));
|
||||
prefix = "database";
|
||||
#dbLoaded = createMemo(() => !!db());
|
||||
|
||||
#createKey(cacheName: string, key: string): string {
|
||||
return `${this.prefix}-${cacheName}-${key}`;
|
||||
|
@ -76,14 +95,24 @@ class LocalStorageCacheAdapter {
|
|||
}
|
||||
|
||||
has(cacheName: string, key: string): boolean {
|
||||
return this.keys.has(this.#createKey(cacheName, key));
|
||||
if (this.#dbLoaded()) {
|
||||
return this.keys.has(this.#createKey(cacheName, key));
|
||||
}
|
||||
|
||||
console.info("No database loaded");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
||||
get<R>(cacheName: string, key: string): R | undefined {
|
||||
const item = localStorage.getItem(this.#createKey(cacheName, key));
|
||||
if (item) {
|
||||
return deserialize(item) as R;
|
||||
if (this.#dbLoaded()) {
|
||||
const item = localStorage.getItem(this.#createKey(cacheName, key));
|
||||
|
||||
if (item) {
|
||||
return deserialize(item) as R;
|
||||
}
|
||||
} else {
|
||||
console.info("No database loaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
109
src/lib/messages-worker.ts
Normal file
109
src/lib/messages-worker.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { getHourList, getMonthList, getWeekdayList } from "./date";
|
||||
import type { MessageOverview, MessageStats, Recipients } from "~/types";
|
||||
|
||||
const hourNames = getHourList();
|
||||
|
||||
const initialHourMap = [...hourNames.keys()];
|
||||
|
||||
const monthNames = getMonthList();
|
||||
|
||||
const initialMonthMap = [...monthNames.keys()];
|
||||
|
||||
const weekdayNames = getWeekdayList();
|
||||
|
||||
const initialWeekdayMap = [...weekdayNames.keys()];
|
||||
|
||||
function createMessageStatsSourcesRaw(messageOverview: MessageOverview, recipients: Recipients) {
|
||||
const getDateList = (startDate: Date, endDate: Date): Date[] => {
|
||||
const dateArray = new Array();
|
||||
|
||||
// end date for loop has to be one date after because we increment after adding the date in the loop
|
||||
const endDateForLoop = new Date(endDate);
|
||||
|
||||
endDateForLoop.setDate(endDateForLoop.getDate() + 1);
|
||||
|
||||
let currentDate = startDate;
|
||||
|
||||
while (currentDate <= endDateForLoop) {
|
||||
dateArray.push(new Date(currentDate));
|
||||
|
||||
const newDate = new Date(currentDate);
|
||||
newDate.setDate(newDate.getDate() + 1);
|
||||
|
||||
currentDate = newDate;
|
||||
}
|
||||
|
||||
return dateArray;
|
||||
};
|
||||
|
||||
const initialRecipientMap = () => {
|
||||
return Object.fromEntries(recipients.map(({ recipientId }) => [recipientId, 0]));
|
||||
};
|
||||
|
||||
const dateList = () => {
|
||||
const firstDate = messageOverview?.at(0)?.messageDate;
|
||||
const lastDate = messageOverview?.at(-1)?.messageDate;
|
||||
if (firstDate && lastDate) {
|
||||
return getDateList(firstDate, lastDate).map((date) => ({
|
||||
totalMessages: 0,
|
||||
date,
|
||||
...initialRecipientMap(),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const currentDateList = dateList();
|
||||
const currentInitialRecipientMap = initialRecipientMap();
|
||||
|
||||
const messageStats: MessageStats = {
|
||||
person: { ...currentInitialRecipientMap },
|
||||
month: initialMonthMap.map(() => ({ ...currentInitialRecipientMap })),
|
||||
date: currentDateList ?? [],
|
||||
weekday: initialWeekdayMap.map(() => ({ ...currentInitialRecipientMap })),
|
||||
daytime: initialHourMap.map(() => ({ ...currentInitialRecipientMap })),
|
||||
};
|
||||
|
||||
if (currentDateList) {
|
||||
const { person, month, date, weekday, daytime } = messageStats;
|
||||
|
||||
for (const message of messageOverview) {
|
||||
const { messageDate } = message;
|
||||
|
||||
// increment overall message count of a person
|
||||
person[message.fromRecipientId] += 1;
|
||||
|
||||
// increment the message count of the message's month for this recipient
|
||||
month[messageDate.getMonth()][message.fromRecipientId] += 1;
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
const dateStatsEntry = date.find(({ date }) => date.toDateString() === messageDate.toDateString())!;
|
||||
|
||||
// increment the message count of the message's date for this recipient
|
||||
dateStatsEntry[message.fromRecipientId] += 1;
|
||||
|
||||
// increment the overall message count of the message's date
|
||||
dateStatsEntry.totalMessages += 1;
|
||||
|
||||
const weekdayOfDate = messageDate.getDay();
|
||||
// we index starting with monday while the `Date` object indexes starting with Sunday
|
||||
const weekdayIndex = weekdayOfDate === 0 ? 6 : weekdayOfDate - 1;
|
||||
|
||||
// increment the message count of the message's weekday for this recipient
|
||||
weekday[weekdayIndex][message.fromRecipientId] += 1;
|
||||
|
||||
// increment the message count of the message's daytime for this recipient
|
||||
daytime[messageDate.getHours()][message.fromRecipientId] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return messageStats;
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<{ dmId: number; messageOverview: MessageOverview; recipients: Recipients }>) => {
|
||||
const result = createMessageStatsSourcesRaw(event.data.messageOverview, event.data.recipients);
|
||||
|
||||
postMessage({
|
||||
dmId: event.data.dmId,
|
||||
messageStatsSources: result,
|
||||
});
|
||||
};
|
|
@ -1,82 +1,37 @@
|
|||
import { getDateList, getHourList, getMonthList, getWeekdayList } from "./date";
|
||||
import type { MessageOverview, MessageStats, Recipients } from "~/types";
|
||||
import { isSameDay } from "date-fns";
|
||||
import { cached } from "./db-cache";
|
||||
import { getHourList, getMonthList, getWeekdayList } from "./date";
|
||||
import MessageStatsWorker from "./messages-worker?worker";
|
||||
|
||||
export const hourNames = getHourList();
|
||||
|
||||
const initialHoursMap = [...hourNames.keys()];
|
||||
|
||||
export const monthNames = getMonthList();
|
||||
|
||||
const initialMonthMap = [...monthNames.keys()];
|
||||
|
||||
export const weekdayNames = getWeekdayList();
|
||||
|
||||
const initialWeekdayMap = [...weekdayNames.keys()];
|
||||
const messageStatsWorker = new MessageStatsWorker();
|
||||
|
||||
const createMessageStatsSourcesRaw = (messageOverview: MessageOverview, recipients: Recipients) => {
|
||||
const initialRecipientMap = () => {
|
||||
return Object.fromEntries(recipients.map(({ recipientId }) => [recipientId, 0]));
|
||||
};
|
||||
export const createMessageStatsSources = cached(
|
||||
(dmId: number, messageOverview: MessageOverview, recipients: Recipients) => {
|
||||
messageStatsWorker.postMessage({
|
||||
dmId,
|
||||
messageOverview,
|
||||
recipients,
|
||||
});
|
||||
|
||||
const dateList = () => {
|
||||
const firstDate = messageOverview?.at(0)?.messageDate;
|
||||
const lastDate = messageOverview?.at(-1)?.messageDate;
|
||||
if (firstDate && lastDate) {
|
||||
return getDateList(firstDate, lastDate).map((date) => ({
|
||||
totalMessages: 0,
|
||||
date,
|
||||
...initialRecipientMap(),
|
||||
}));
|
||||
}
|
||||
};
|
||||
return new Promise<MessageStats>((resolve) => {
|
||||
const listener = (
|
||||
event: MessageEvent<{
|
||||
dmId: number;
|
||||
messageStatsSources: MessageStats;
|
||||
}>,
|
||||
) => {
|
||||
if (event.data.dmId === dmId) {
|
||||
resolve(event.data.messageStatsSources);
|
||||
}
|
||||
};
|
||||
|
||||
const currentDateList = dateList();
|
||||
const currentInitialRecipientMap = initialRecipientMap();
|
||||
|
||||
const messageStats: MessageStats = {
|
||||
person: { ...currentInitialRecipientMap },
|
||||
month: initialMonthMap.map(() => ({ ...currentInitialRecipientMap })),
|
||||
date: currentDateList ?? [],
|
||||
weekday: initialWeekdayMap.map(() => ({ ...currentInitialRecipientMap })),
|
||||
daytime: initialHoursMap.map(() => ({ ...currentInitialRecipientMap })),
|
||||
};
|
||||
|
||||
if (currentDateList) {
|
||||
const { person, month, date, weekday, daytime } = messageStats;
|
||||
|
||||
for (const message of messageOverview) {
|
||||
const { messageDate } = message;
|
||||
|
||||
// increment overall message count of a person
|
||||
person[message.fromRecipientId] += 1;
|
||||
|
||||
// increment the message count of the message's month for this recipient
|
||||
month[messageDate.getMonth()][message.fromRecipientId] += 1;
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
const dateStatsEntry = date.find(({ date }) => isSameDay(date, messageDate))!;
|
||||
|
||||
// increment the message count of the message's date for this recipient
|
||||
dateStatsEntry[message.fromRecipientId] += 1;
|
||||
|
||||
// increment the overall message count of the message's date
|
||||
dateStatsEntry.totalMessages += 1;
|
||||
|
||||
const weekdayOfDate = messageDate.getDay();
|
||||
// we index starting with monday while the `Date` object indexes starting with Sunday
|
||||
const weekdayIndex = weekdayOfDate === 0 ? 6 : weekdayOfDate - 1;
|
||||
|
||||
// increment the message count of the message's weekday for this recipient
|
||||
weekday[weekdayIndex][message.fromRecipientId] += 1;
|
||||
|
||||
// increment the message count of the message's daytime for this recipient
|
||||
daytime[messageDate.getHours()][message.fromRecipientId] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return messageStats;
|
||||
};
|
||||
|
||||
export const createMessageStatsSources = cached(createMessageStatsSourcesRaw);
|
||||
messageStatsWorker.addEventListener("message", listener);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { type Component, createMemo, createResource } from "solid-js";
|
||||
import type { RouteSectionProps } from "@solidjs/router";
|
||||
import { Suspense, type Component } from "solid-js";
|
||||
import { createAsync, type RoutePreloadFunc, type RouteSectionProps } from "@solidjs/router";
|
||||
|
||||
import { dmPartnerRecipientQuery, SELF_ID, threadMostUsedWordsQuery, threadSentMessagesOverviewQuery } from "~/db";
|
||||
import { dmPartnerRecipientQuery, threadMostUsedWordsQuery, threadSentMessagesOverviewQuery } from "~/db-queries";
|
||||
import { getNameFromRecipient } from "~/lib/get-name-from-recipient";
|
||||
import { Heading } from "~/components/ui/heading";
|
||||
import { Grid } from "~/components/ui/grid";
|
||||
|
@ -15,13 +15,13 @@ import { DmMessagesPerRecipient } from "./dm-messages-per-recipients";
|
|||
import { DmMessagesPerWeekday } from "./dm-messages-per-weekday";
|
||||
import type { MessageOverview } from "~/types";
|
||||
import { createMessageStatsSources } from "~/lib/messages";
|
||||
import { SELF_ID } from "~/db";
|
||||
import { Flex } from "~/components/ui/flex";
|
||||
|
||||
export const DmId: Component<RouteSectionProps> = (props) => {
|
||||
const dmId = () => Number(props.params.dmid);
|
||||
|
||||
const getDmIdData = (dmId: number) => {
|
||||
// the other person in the chat with name and id
|
||||
const [dmPartner] = createResource(async () => {
|
||||
const dmPartner = await dmPartnerRecipientQuery(dmId());
|
||||
const dmPartner = createAsync(async () => {
|
||||
const dmPartner = await dmPartnerRecipientQuery(dmId);
|
||||
|
||||
if (dmPartner) {
|
||||
return {
|
||||
|
@ -35,8 +35,8 @@ export const DmId: Component<RouteSectionProps> = (props) => {
|
|||
}
|
||||
});
|
||||
|
||||
const [dmMessagesOverview] = createResource<MessageOverview | undefined>(async () => {
|
||||
const dmMessageOverview = await threadSentMessagesOverviewQuery(dmId());
|
||||
const dmMessagesOverview = createAsync<MessageOverview | undefined>(async () => {
|
||||
const dmMessageOverview = await threadSentMessagesOverviewQuery(dmId);
|
||||
if (dmMessageOverview) {
|
||||
return dmMessageOverview.map((row) => {
|
||||
return {
|
||||
|
@ -47,7 +47,7 @@ export const DmId: Component<RouteSectionProps> = (props) => {
|
|||
}
|
||||
});
|
||||
|
||||
const [mostUsedWordCounts] = createResource(async () => threadMostUsedWordsQuery(dmId(), 300));
|
||||
const mostUsedWordCounts = createAsync(async () => threadMostUsedWordsQuery(dmId, 300));
|
||||
|
||||
const recipients = () => {
|
||||
const currentDmPartner = dmPartner();
|
||||
|
@ -63,45 +63,87 @@ export const DmId: Component<RouteSectionProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const dmMessageStats = createMemo(() => {
|
||||
const dmMessageStats = createAsync(async () => {
|
||||
const currentDmMessagesOverview = dmMessagesOverview();
|
||||
const currentRecipients = recipients();
|
||||
|
||||
if (currentDmMessagesOverview && currentRecipients) {
|
||||
return createMessageStatsSources(currentDmMessagesOverview, currentRecipients);
|
||||
return await createMessageStatsSources(dmId, currentDmMessagesOverview, currentRecipients);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dmPartner,
|
||||
dmMessagesOverview,
|
||||
mostUsedWordCounts,
|
||||
recipients,
|
||||
dmMessageStats,
|
||||
};
|
||||
};
|
||||
|
||||
export const preloadDmId: RoutePreloadFunc = (props) => {
|
||||
void getDmIdData(Number(props.params.dmid));
|
||||
};
|
||||
|
||||
export const DmId: Component<RouteSectionProps> = (props) => {
|
||||
const { dmPartner, dmMessagesOverview, mostUsedWordCounts, recipients, dmMessageStats } = getDmIdData(
|
||||
Number(props.params.dmid),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Dm with {dmPartner()?.name}</Title>
|
||||
<div class="flex flex-col items-center">
|
||||
<Heading level={1}>DM with {dmPartner()?.name}</Heading>
|
||||
<Heading level={2}>Chat timeline</Heading>
|
||||
<DmMessagesPerDate dateStats={dmMessageStats()?.date} recipients={recipients()} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex alignItems="center" justifyContent="center" class="h-64">
|
||||
<p class="text-4xl">Loading...</p>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<DmMessagesPerDate dateStats={dmMessageStats()?.date} recipients={recipients()} />
|
||||
</Suspense>
|
||||
<DmOverview messages={dmMessagesOverview()} />
|
||||
<Heading level={2}>Messages per</Heading>
|
||||
|
||||
<Grid cols={1} colsMd={2} class="gap-x-16 gap-y-16">
|
||||
<div>
|
||||
<Heading level={3}>Person</Heading>
|
||||
<DmMessagesPerRecipient personStats={dmMessageStats()?.person} recipients={recipients()} />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={3}>Daytime</Heading>
|
||||
<DmMessagesPerDaytime daytimeStats={dmMessageStats()?.daytime} recipients={recipients()} />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={3}>Month</Heading>
|
||||
<DmMessagesPerMonth monthStats={dmMessageStats()?.month} recipients={recipients()} />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={3}>Weekday</Heading>
|
||||
<DmMessagesPerWeekday weekdayStats={dmMessageStats()?.weekday} recipients={recipients()} />
|
||||
</div>
|
||||
</Grid>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex alignItems="center" justifyContent="center" class="h-64">
|
||||
<p class="text-4xl">Loading...</p>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Grid cols={1} colsMd={2} class="gap-x-16 gap-y-16">
|
||||
<div>
|
||||
<Heading level={3}>Person</Heading>
|
||||
<DmMessagesPerRecipient personStats={dmMessageStats()?.person} recipients={recipients()} />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={3}>Daytime</Heading>
|
||||
<DmMessagesPerDaytime daytimeStats={dmMessageStats()?.daytime} recipients={recipients()} />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={3}>Month</Heading>
|
||||
<DmMessagesPerMonth monthStats={dmMessageStats()?.month} recipients={recipients()} />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={3}>Weekday</Heading>
|
||||
<DmMessagesPerWeekday weekdayStats={dmMessageStats()?.weekday} recipients={recipients()} />
|
||||
</div>
|
||||
</Grid>
|
||||
</Suspense>
|
||||
<Heading level={2}>Word cloud</Heading>
|
||||
<DmWordCloud wordCounts={mostUsedWordCounts()} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex alignItems="center" justifyContent="center" class="h-64">
|
||||
<p class="text-4xl">Loading...</p>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<DmWordCloud wordCounts={mostUsedWordCounts()} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { ChartData } from "chart.js";
|
||||
import { Show, type Accessor, type Component } from "solid-js";
|
||||
import { WordCloudChart } from "~/components/ui/charts";
|
||||
import type { threadMostUsedWordsQuery } from "~/db";
|
||||
import type { threadMostUsedWordsQuery } from "~/db-queries";
|
||||
|
||||
const maxWordSize = 100;
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { createSignal, Show, type Component, type JSX } from "solid-js";
|
||||
import { type RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
|
||||
import { setDb, SQL } from "~/db";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { Flex } from "~/components/ui/flex";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { setDb, SQL } from "~/db";
|
||||
|
||||
export const Home: Component<RouteSectionProps> = () => {
|
||||
const [isLoadingDb, setIsLoadingDb] = createSignal(false);
|
||||
|
|
|
@ -2,6 +2,7 @@ import { lazy } from "solid-js";
|
|||
|
||||
export { Home } from "./home";
|
||||
|
||||
export { preloadDmId } from "./dm/dm-id";
|
||||
export const GroupId = lazy(() => import("./group/group-id"));
|
||||
export const DmId = lazy(() => import("./dm/dm-id"));
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { type Component, createResource, Show } from "solid-js";
|
||||
import type { RouteSectionProps } from "@solidjs/router";
|
||||
|
||||
import { allThreadsOverviewQuery, overallSentMessagesQuery, SELF_ID } from "~/db";
|
||||
import { allThreadsOverviewQuery, overallSentMessagesQuery } from "~/db-queries";
|
||||
|
||||
import { OverviewTable, type RoomOverview } from "./overview-table";
|
||||
import { getNameFromRecipient } from "~/lib/get-name-from-recipient";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { SELF_ID } from "~/db";
|
||||
|
||||
export const Overview: Component<RouteSectionProps> = () => {
|
||||
const [allSelfSentMessagesCount] = createResource(() => overallSentMessagesQuery(SELF_ID));
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { type Component, createSignal, For, Match, Show, Switch } from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useNavigate, usePreloadRoute } from "@solidjs/router";
|
||||
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
|
@ -191,6 +191,8 @@ interface OverviewTableProps {
|
|||
}
|
||||
|
||||
export const OverviewTable = (props: OverviewTableProps) => {
|
||||
const preload = usePreloadRoute();
|
||||
|
||||
const [sorting, setSorting] = createSignal<SortingState>([
|
||||
{
|
||||
id: "messageCount",
|
||||
|
@ -314,6 +316,26 @@ export const OverviewTable = (props: OverviewTableProps) => {
|
|||
<TableRow
|
||||
class="cursor-pointer [&:last-of-type>td:first-of-type]:rounded-bl-md [&:last-of-type>td:last-of-type]:rounded-br-md"
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onPointerEnter={(event) => {
|
||||
const threadId = row.original.threadId;
|
||||
const isGroup = row.original.isGroup;
|
||||
|
||||
const preloadTimeout = setTimeout(() => {
|
||||
preload(`/${isGroup ? "group" : "dm"}/${threadId.toString()}`, {
|
||||
preloadData: true,
|
||||
});
|
||||
}, 20);
|
||||
|
||||
event.currentTarget.addEventListener(
|
||||
"pointerout",
|
||||
() => {
|
||||
clearTimeout(preloadTimeout);
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
onClick={() => {
|
||||
const threadId = row.original.threadId;
|
||||
const isGroup = row.original.isGroup;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue