diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a1cd576 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 15065ad..b6dd09a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "@solidjs/router": "^0.15.1", "@tanstack/solid-table": "^8.20.5", "chart.js": "^4.4.7", + "chartjs-plugin-deferred": "^2.0.0", + "chartjs-plugin-zoom": "^2.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 402d0c0..67d797e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: chart.js: specifier: ^4.4.7 version: 4.4.7 + chartjs-plugin-deferred: + specifier: ^2.0.0 + version: 2.0.0(chart.js@4.4.7) + chartjs-plugin-zoom: + specifier: ^2.2.0 + version: 2.2.0(chart.js@4.4.7) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -672,6 +678,9 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -866,6 +875,16 @@ packages: resolution: {integrity: sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==} engines: {pnpm: '>=8'} + chartjs-plugin-deferred@2.0.0: + resolution: {integrity: sha512-jq6b8Wt23WS6zxiX8oVB1MXq4uaJX2KGTyiqnq6xo4ctZPgFkT/FuIEKpJjsF1WkYv7ZQrqrrRg1fLw6O5ZEfQ==} + peerDependencies: + chart.js: '>= 3.0.0' + + chartjs-plugin-zoom@2.2.0: + resolution: {integrity: sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==} + peerDependencies: + chart.js: '>=3.2.0' + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1173,6 +1192,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + hammerjs@2.0.8: + resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} + engines: {node: '>=0.8.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -2470,6 +2493,8 @@ snapshots: '@types/estree@1.0.6': {} + '@types/hammerjs@2.0.46': {} + '@types/json-schema@7.0.15': {} '@types/node@22.10.1': @@ -2693,6 +2718,16 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chartjs-plugin-deferred@2.0.0(chart.js@4.4.7): + dependencies: + chart.js: 4.4.7 + + chartjs-plugin-zoom@2.2.0(chart.js@4.4.7): + dependencies: + '@types/hammerjs': 2.0.46 + chart.js: 4.4.7 + hammerjs: 2.0.8 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -3018,6 +3053,8 @@ snapshots: graphemer@1.4.0: {} + hammerjs@2.0.8: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} diff --git a/src/App.tsx b/src/App.tsx index 7f3f55e..925f2d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ import { type Component } from "solid-js"; import { Route } from "@solidjs/router"; -import { Home, Overview } from "./pages"; +import { allThreadsOverviewQuery } from "./db"; +import { DmId, GroupId, Home, Overview } from "./pages"; import "./app.css"; @@ -16,7 +17,31 @@ const App: Component = () => { path="/overview" component={Overview} /> - + + + { + console.time("first"); + console.log(allThreadsOverviewQuery()); + void allThreadsOverviewQuery().then((result) => { + console.log(result); + console.timeEnd("first"); + console.time("second"); + void allThreadsOverviewQuery().then((result) => { + console.log(result); + console.timeEnd("second"); + }); + }); + return ""; + }} + /> ); }; diff --git a/src/components/ui/charts.tsx b/src/components/ui/charts.tsx index 7557adf..760a30a 100644 --- a/src/components/ui/charts.tsx +++ b/src/components/ui/charts.tsx @@ -36,6 +36,10 @@ import { ScatterController, Tooltip, } from "chart.js"; +import ChartDeferred from "chartjs-plugin-deferred"; +import ChartZoom from "chartjs-plugin-zoom"; + +Chart.register(ChartDeferred, ChartZoom); interface TypedChartProps { data: ChartData; diff --git a/src/db.ts b/src/db.ts index 1de4ec5..dff344b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,11 +1,12 @@ import { type Accessor, createMemo, createSignal, DEV, type Setter } from "solid-js"; -import { Kysely, type NotNull } from "kysely"; +import { Kysely, type NotNull, sql } 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; @@ -13,7 +14,8 @@ export const SQL = await initSqlJS({ locateFile: () => wasmURL, }); -let rawDb: Accessor, setRawDb: Setter; +let rawDb: Accessor = () => undefined, + setRawDb: Setter = () => undefined; if (DEV) { const file = await import("./assets/database.sqlite?url").then((result) => { @@ -22,9 +24,9 @@ if (DEV) { const testDb = new SQL.Database(new Uint8Array(file)); - [rawDb, setRawDb] = createSignal(testDb); + [rawDb, setRawDb] = createSignal(testDb); } else { - [rawDb, setRawDb] = createSignal(); + [rawDb, setRawDb] = createSignal(); } export { rawDb as db, setRawDb as setDb }; @@ -51,13 +53,13 @@ const kyselyDb = createMemo(() => { }); }); -export const threadOverviewQuery = kyselyDb() +const allThreadsOverviewQueryRaw = kyselyDb() .selectFrom("thread") .innerJoin( (eb) => eb .selectFrom("message") - .select(["thread_id", kyselyDb().fn.countAll().as("message_count")]) + .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", "")]); }) @@ -82,18 +84,67 @@ export const threadOverviewQuery = kyselyDb() thread_id: NotNull; archived: NotNull; message_count: number; - }>(); + }>() + .compile(); -console.log(threadOverviewQuery.compile()); +export const allThreadsOverviewQuery = cached(() => kyselyDb().executeQuery(allThreadsOverviewQueryRaw)); -export const overallSentMessagesQuery = (recipientId: number) => +const overallSentMessagesQueryRaw = (recipientId: number) => kyselyDb() .selectFrom("message") - .select(kyselyDb().fn.countAll().as("message_count")) + .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"]) + .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 dmOverviewQueryRaw = (dmId: number) => + kyselyDb() + .selectFrom("message") + .select((eb) => [ + sql`DATE(datetime(message.date_sent / 1000, 'unixepoch'))`.as("message_date"), + eb.fn.countAll().as("message_count"), + ]) + .groupBy("message_date") + .orderBy("message_date asc") + .where("thread_id", "=", dmId) + .execute(); + +export const dmOverviewQuery = cached(dmOverviewQueryRaw); + +const threadSentMessagesPerPersonOverviewQueryRaw = (dmId: number) => + kyselyDb() + .selectFrom("message") + .select((eb) => [ + "from_recipient_id", + sql`DATE(datetime(message.date_sent / 1000, 'unixepoch'))`.as("message_date"), + eb.fn.countAll().as("message_count"), + ]) + .groupBy(["from_recipient_id", "message_date"]) + .orderBy(["message_date"]) + .where((eb) => eb.and([eb("body", "is not", null), eb("body", "!=", ""), eb("thread_id", "=", dmId)])) + .$narrowType<{ + message_count: number; + }>() + .execute(); + +export const dmSentMessagesPerPersonOverviewQuery = cached(threadSentMessagesPerPersonOverviewQueryRaw); diff --git a/src/lib/db-cache.ts b/src/lib/db-cache.ts new file mode 100644 index 0000000..f327ec7 --- /dev/null +++ b/src/lib/db-cache.ts @@ -0,0 +1,118 @@ +import { createEffect, on } from "solid-js"; + +const DATABASE_HASH_PREFIX = "database"; + +// clear the cache on new session so that selecting a different database does not result in wrong cache entries +const clearDbCache = () => { + for (let i = 0, len = localStorage.length; i < len; i++) { + const key = localStorage.key(i); + + if (key?.startsWith(DATABASE_HASH_PREFIX)) { + localStorage.removeItem(key); + } + } +}; + +// https://stackoverflow.com/a/7616484 +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; +}; + +const HASH_STORE_KEY = "database_hash"; + +// cannot import `db` the normal way because this file is imported in ~/db.ts before the initialisation of `db` has happened +queueMicrotask(() => { + void import("~/db").then(({ db }) => { + createEffect( + on(db, (currentDb) => { + if (currentDb) { + const newHash = hashString(new TextDecoder().decode(currentDb.export())).toString(); + + const oldHash = localStorage.getItem(HASH_STORE_KEY); + + console.log(newHash, oldHash); + + if (newHash !== oldHash) { + clearDbCache(); + + localStorage.setItem(HASH_STORE_KEY, newHash); + } + } + }), + ); + }); +}); + +class LocalStorageCacheAdapter { + keys = new Set(Object.keys(localStorage).filter((key) => key.startsWith(this.prefix))); + prefix = "database"; + + #createKey(cacheName: string, key: string): string { + return `${this.prefix}-${cacheName}-${key}`; + } + + set(cacheName: string, key: string, value: unknown) { + const fullKey = this.#createKey(cacheName, key); + this.keys.add(fullKey); + + localStorage.setItem(fullKey, JSON.stringify(value)); + } + + has(cacheName: string, key: string): boolean { + return this.keys.has(this.#createKey(cacheName, key)); + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters + get(cacheName: string, key: string): R | undefined { + const item = localStorage.getItem(this.#createKey(cacheName, key)); + if (item) { + return JSON.parse(item) as R; + } + } +} + +const cache = new LocalStorageCacheAdapter(); + +export const cached = (fn: (...args: T[]) => R, self?: ThisType): ((...args: T[]) => R) => { + const cacheName = hashString(fn.toString()).toString(); + + // important to return a promise on follow-up calls even if the data is immediately available + let isPromise: boolean; + + return (...args: T[]) => { + const cacheKey = JSON.stringify(args); + + const cachedValue = cache.get(cacheName, cacheKey); + + if (cachedValue) { + return (isPromise ? Promise.resolve(cachedValue) : cachedValue) as R; + } + + let newValue: R; + + if (self) { + newValue = fn.apply(self, args); + } else { + newValue = fn(...args); + } + + const promisified = Promise.resolve(newValue); + + isPromise = promisified == newValue; + + void promisified.then((result) => { + cache.set(cacheName, cacheKey, result); + }); + + return newValue; + }; +}; diff --git a/src/lib/random.ts b/src/lib/random.ts new file mode 100644 index 0000000..8231fbe --- /dev/null +++ b/src/lib/random.ts @@ -0,0 +1,9 @@ +export const generateRandomString = (length: number) => { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length); + result += characters[randomIndex]; + } + return result; +}; diff --git a/src/lib/sql-queries.txt b/src/lib/sql-queries.txt index 231a1c8..e16a9b4 100644 --- a/src/lib/sql-queries.txt +++ b/src/lib/sql-queries.txt @@ -28,17 +28,3 @@ WHERE r._id = 4 AND message.body IS NOT NULL AND message.body != ''`; - -// select all chats / groups with details -`SELECT - thread.recipient_id, - thread.active, - recipient.profile_joined_name, - recipient.system_joined_name, - groups.title -FROM - thread - JOIN recipient ON thread.recipient_id = recipient._id - LEFT JOIN groups ON recipient._id = groups.recipient_id -WHERE (SELECT 1 FROM message WHERE message.thread_id = thread._id AND message.body IS NOT NULL) -`; diff --git a/src/pages/dm/dm-id.tsx b/src/pages/dm/dm-id.tsx new file mode 100644 index 0000000..cba3744 --- /dev/null +++ b/src/pages/dm/dm-id.tsx @@ -0,0 +1,144 @@ +import { type Accessor, type Component, createResource, Show } from "solid-js"; +import type { RouteSectionProps } from "@solidjs/router"; + +import { type ChartData } from "chart.js"; + +import { LineChart } from "~/components/ui/charts"; + +import { dmPartnerRecipientQuery, dmSentMessagesPerPersonOverviewQuery, SELF_ID } from "~/db"; + +export const DmId: Component = (props) => { + const dmId = () => Number(props.params.dmid); + + const [dmPartner] = createResource(async () => { + const dmPartner = await dmPartnerRecipientQuery(dmId()); + + if (dmPartner) { + return { + id: dmPartner._id, + name: /* can be empty string */ !dmPartner.system_joined_name + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + dmPartner.profile_joined_name! + : dmPartner.system_joined_name, + }; + } + }); + + const [dmMessagesPerPerson] = createResource(() => dmSentMessagesPerPersonOverviewQuery(dmId())); + + const dmMessages = () => { + return dmMessagesPerPerson()?.reduce< + { + date: Date; + totalMessages: number; + [key: number]: number; + }[] + >((prev, curr) => { + const existingDate = prev.find(({ date }) => date === curr.message_date); + if (existingDate) { + existingDate[curr.from_recipient_id] = curr.message_count; + + existingDate.totalMessages += curr.message_count; + } else { + prev.push({ + date: curr.message_date, + totalMessages: curr.message_count, + [curr.from_recipient_id]: curr.message_count, + }); + } + + return prev; + }, []); + }; + + const recipients = () => { + const currentDmPartner = dmPartner(); + + if (currentDmPartner) { + return [ + { recipientId: currentDmPartner.id, name: currentDmPartner.name }, + { + recipientId: SELF_ID, + name: "You", + }, + ]; + } + + return [ + { + recipientId: SELF_ID, + name: "You", + }, + ]; + }; + + const dateChartData: Accessor | undefined> = () => { + const currentDmMessages = dmMessages(); + const currentRecipients = recipients(); + + if (currentDmMessages) { + return { + labels: currentDmMessages.map((row) => row.date), + datasets: [ + { + label: "Total number of messages", + data: currentDmMessages.map((row) => row.totalMessages), + borderWidth: 2, + }, + ...currentDmMessages.reduce<{ id: number; label: string; data: number[] }[]>( + (prev, curr) => { + for (const recipient of currentRecipients) { + prev.find(({ id }) => id === recipient.recipientId)?.data.push(curr[recipient.recipientId] ?? 0); + } + + return prev; + }, + currentRecipients.map((recipient) => { + return { + id: recipient.recipientId, + label: `Number of messages from ${recipient.name.toString()}`, + data: [], + borderWidth: 2, + }; + }), + ), + ], + }; + } + }; + + return ( + + {(currentDateChartData) => ( +
+ +
+ )} +
+ ); +}; + +export default DmId; diff --git a/src/pages/group/group-id.tsx b/src/pages/group/group-id.tsx new file mode 100644 index 0000000..88adc84 --- /dev/null +++ b/src/pages/group/group-id.tsx @@ -0,0 +1,8 @@ +import type { Component } from "solid-js"; +import type { RouteSectionProps } from "@solidjs/router"; + +export const GroupId: Component = (props) => { + const groupId = () => Number(props.params.groupid); +}; + +export default GroupId; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 3cb3d9a..82c820d 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,4 +2,7 @@ import { lazy } from "solid-js"; export { Home } from "./home"; +export const GroupId = lazy(() => import("./group/group-id")); +export const DmId = lazy(() => import("./dm/dm-id")); + export const Overview = lazy(() => import("./overview")); diff --git a/src/pages/overview/index.tsx b/src/pages/overview/index.tsx index ab33959..3ceed3f 100644 --- a/src/pages/overview/index.tsx +++ b/src/pages/overview/index.tsx @@ -1,17 +1,19 @@ -import { type Component, createResource, Show } from "solid-js"; +import { type Component, createEffect, createResource, Show } from "solid-js"; import type { RouteSectionProps } from "@solidjs/router"; -import { overallSentMessagesQuery, SELF_ID, threadOverviewQuery } from "~/db"; +import { allThreadsOverviewQuery, overallSentMessagesQuery, SELF_ID } from "~/db"; import { OverviewTable, type RoomOverview } from "./overview-table"; export const Overview: Component = () => { - const [allSelfSentMessagesCount] = createResource(() => overallSentMessagesQuery(SELF_ID).executeTakeFirstOrThrow()); + const [allSelfSentMessagesCount] = createResource(() => overallSentMessagesQuery(SELF_ID)); const [roomOverview] = createResource(async () => { - return (await threadOverviewQuery.execute()).map((row) => { + return (await allThreadsOverviewQuery()).rows.map((row) => { const isGroup = row.title !== null; + console.log(row); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const name = ( isGroup @@ -33,13 +35,18 @@ export const Overview: Component = () => { }); }); + createEffect(() => { + console.log(roomOverview()); + }); + return (
-

All messages: {allSelfSentMessagesCount()?.message_count as number}

+

All messages: {allSelfSentMessagesCount()?.messageCount as number}

+ {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} ;
diff --git a/src/pages/overview/overview-table.tsx b/src/pages/overview/overview-table.tsx index 7b3a353..b6556c9 100644 --- a/src/pages/overview/overview-table.tsx +++ b/src/pages/overview/overview-table.tsx @@ -1,4 +1,5 @@ import { type Component, createSignal, For, Match, Show, Switch } from "solid-js"; +import { useNavigate } from "@solidjs/router"; import { type ColumnFiltersState, @@ -37,7 +38,7 @@ export interface RoomOverview { const columnHelper = createColumnHelper(); -const archivedFilterFn: FilterFn = (row, columnId, filterValue) => { +const archivedFilterFn: FilterFn = (row, _columnId, filterValue) => { if (filterValue === true) { return true; } @@ -241,6 +242,8 @@ export const OverviewTable = (props: OverviewTableProps) => { }, }); + const navigate = useNavigate(); + return (
@@ -304,8 +307,14 @@ export const OverviewTable = (props: OverviewTableProps) => { {(row) => ( { + const threadId = row.original.threadId; + const isGroup = row.original.isGroup; + + navigate(`/${isGroup ? "group" : "dm"}/${threadId.toString()}`); + }} > {(cell) => (