diff --git a/.vscode/settings.json b/.vscode/settings.json index 048de8e..a451915 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,14 +2,9 @@ "editor.codeActionsOnSave": { "quickfix.biome": "explicit" }, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[json]": { - "editor.defaultFormatter": "biomejs.biome" + "[typescript][typescriptreact][json]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true }, "typescript.inlayHints.parameterNames.enabled": "all" -} +} \ No newline at end of file diff --git a/biome.json b/biome.json index 9c9928b..2ef9167 100644 --- a/biome.json +++ b/biome.json @@ -107,7 +107,13 @@ } } }, - "ignore": ["dist/**/*.ts", "dist/**", "**/*.mjs", "eslint.config.js", "**/*.js"] + "ignore": [ + "dist/**/*.ts", + "dist/**", + "**/*.mjs", + "eslint.config.js", + "**/*.js" + ] }, "javascript": { "formatter": { diff --git a/src/components/ui/charts.tsx b/src/components/ui/charts.tsx index f6717aa..bfb31ff 100644 --- a/src/components/ui/charts.tsx +++ b/src/components/ui/charts.tsx @@ -1,5 +1,5 @@ -import type { Component } from "solid-js"; -import { createEffect, createSignal, mergeProps, on, onCleanup, onMount } from "solid-js"; +import type { Component, JSX } from "solid-js"; +import { createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps } from "solid-js"; import { unwrap } from "solid-js/store"; import type { Ref } from "@solid-primitives/refs"; @@ -51,9 +51,10 @@ interface TypedChartProps { height?: number | undefined; } -type ChartProps = TypedChartProps & { - type: ChartType; -}; +type ChartProps = JSX.CanvasHTMLAttributes & + TypedChartProps & { + type: ChartType; + }; interface ChartContext { chart: Chart; @@ -74,6 +75,8 @@ const BaseChart: Component = (rawProps) => { rawProps, ); + const [, otherProps] = splitProps(props, ["options", "plugins", "data"]); + const init = () => { const ctx = canvasRef()?.getContext("2d") as ChartItem; const config = unwrap(props); @@ -153,7 +156,7 @@ const BaseChart: Component = (rawProps) => { }); Chart.register(Colors, Filler, Legend, Tooltip); - return setCanvasRef(el))} height={props.height} width={props.width} />; + return setCanvasRef(el))} {...otherProps} />; }; function showTooltip(context: ChartContext) { @@ -202,7 +205,10 @@ function showTooltip(context: ChartContext) { el.style.pointerEvents = "none"; } -function createTypedChart(type: ChartType, components: ChartComponent[]): Component { +function createTypedChart( + type: ChartType, + components: ChartComponent[], +): Component> { const chartsWithScales: ChartType[] = ["bar", "line", "scatter"]; const chartsWithLegends: ChartType[] = ["bar", "line"]; diff --git a/src/components/ui/flex.tsx b/src/components/ui/flex.tsx new file mode 100644 index 0000000..fe68ee5 --- /dev/null +++ b/src/components/ui/flex.tsx @@ -0,0 +1,67 @@ +import type { Component, ComponentProps } from "solid-js"; +import { mergeProps, splitProps } from "solid-js"; + +import { cn } from "~/lib/utils"; + +type JustifyContent = "start" | "end" | "center" | "between" | "around" | "evenly" | "normal" | "stretch"; +type AlignItems = "start" | "end" | "center" | "baseline" | "stretch"; +type FlexDirection = "row" | "col" | "row-reverse" | "col-reverse"; + +type FlexProps = ComponentProps<"div"> & { + flexDirection?: FlexDirection; + justifyContent?: JustifyContent; + alignItems?: AlignItems; +}; + +const Flex: Component = (rawProps) => { + const props = mergeProps( + { + flexDirection: "row", + justifyContent: "normal", + alignItems: "center", + } satisfies FlexProps, + rawProps, + ); + const [local, others] = splitProps(props, ["flexDirection", "justifyContent", "alignItems", "class"]); + + return ( +
+ ); +}; + +export { Flex }; + +const justifyContentClassNames: { [key in JustifyContent]: string } = { + start: "justify-start", + end: "justify-end", + center: "justify-center", + between: "justify-between", + around: "justify-around", + evenly: "justify-evenly", + normal: "justify-normal", + stretch: "justify-stretch", +}; + +const alignItemsClassNames: { [key in AlignItems]: string } = { + start: "items-start", + end: "items-end", + center: "items-center", + baseline: "items-baseline", + stretch: "items-stretch", +}; + +const flexDirectionClassNames: { [key in FlexDirection]: string } = { + row: "flex-row", + col: "flex-col", + "row-reverse": "flex-row-reverse", + "col-reverse": "flex-col-reverse", +}; diff --git a/src/components/ui/grid.tsx b/src/components/ui/grid.tsx new file mode 100644 index 0000000..9ecd599 --- /dev/null +++ b/src/components/ui/grid.tsx @@ -0,0 +1,188 @@ +import type { Component, ComponentProps } from "solid-js" +import { mergeProps, splitProps } from "solid-js" + +import { cn } from "~/lib/utils" + +type Cols = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 +type Span = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 + +type GridProps = ComponentProps<"div"> & { + cols?: Cols + colsSm?: Cols + colsMd?: Cols + colsLg?: Cols +} + +const Grid: Component = (rawProps) => { + const props = mergeProps({ cols: 1 } satisfies GridProps, rawProps) + const [local, others] = splitProps(props, ["cols", "colsSm", "colsMd", "colsLg", "class"]) + + return ( +
+ ) +} + +type ColProps = ComponentProps<"div"> & { + span?: Span + spanSm?: Span + spanMd?: Span + spanLg?: Span +} + +const Col: Component = (rawProps) => { + const props = mergeProps({ span: 1 as Span }, rawProps) + const [local, others] = splitProps(props, ["span", "spanSm", "spanMd", "spanLg", "class"]) + + return ( +
+ ) +} + +export { Grid, Col } + +const gridCols: { [key in Cols]: string } = { + 0: "grid-cols-none", + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-4", + 5: "grid-cols-5", + 6: "grid-cols-6", + 7: "grid-cols-7", + 8: "grid-cols-8", + 9: "grid-cols-9", + 10: "grid-cols-10", + 11: "grid-cols-11", + 12: "grid-cols-12" +} + +const gridColsSm: { [key in Cols]: string } = { + 0: "sm:grid-cols-none", + 1: "sm:grid-cols-1", + 2: "sm:grid-cols-2", + 3: "sm:grid-cols-3", + 4: "sm:grid-cols-4", + 5: "sm:grid-cols-5", + 6: "sm:grid-cols-6", + 7: "sm:grid-cols-7", + 8: "sm:grid-cols-8", + 9: "sm:grid-cols-9", + 10: "sm:grid-cols-10", + 11: "sm:grid-cols-11", + 12: "sm:grid-cols-12" +} + +const gridColsMd: { [key in Cols]: string } = { + 0: "md:grid-cols-none", + 1: "md:grid-cols-1", + 2: "md:grid-cols-2", + 3: "md:grid-cols-3", + 4: "md:grid-cols-4", + 5: "md:grid-cols-5", + 6: "md:grid-cols-6", + 7: "md:grid-cols-7", + 8: "md:grid-cols-8", + 9: "md:grid-cols-9", + 10: "md:grid-cols-10", + 11: "md:grid-cols-11", + 12: "md:grid-cols-12" +} + +const gridColsLg: { [key in Cols]: string } = { + 0: "lg:grid-cols-none", + 1: "lg:grid-cols-1", + 2: "lg:grid-cols-2", + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", + 5: "lg:grid-cols-5", + 6: "lg:grid-cols-6", + 7: "lg:grid-cols-7", + 8: "lg:grid-cols-8", + 9: "lg:grid-cols-9", + 10: "lg:grid-cols-10", + 11: "lg:grid-cols-11", + 12: "lg:grid-cols-12" +} + +const colSpan: { [key in Span]: string } = { + 1: "col-span-1", + 2: "col-span-2", + 3: "col-span-3", + 4: "col-span-4", + 5: "col-span-5", + 6: "col-span-6", + 7: "col-span-7", + 8: "col-span-8", + 9: "col-span-9", + 10: "col-span-10", + 11: "col-span-11", + 12: "col-span-12", + 13: "col-span-13" +} + +const colSpanSm: { [key in Span]: string } = { + 1: "sm:col-span-1", + 2: "sm:col-span-2", + 3: "sm:col-span-3", + 4: "sm:col-span-4", + 5: "sm:col-span-5", + 6: "sm:col-span-6", + 7: "sm:col-span-7", + 8: "sm:col-span-8", + 9: "sm:col-span-9", + 10: "sm:col-span-10", + 11: "sm:col-span-11", + 12: "sm:col-span-12", + 13: "sm:col-span-13" +} + +const colSpanMd: { [key in Span]: string } = { + 1: "md:col-span-1", + 2: "md:col-span-2", + 3: "md:col-span-3", + 4: "md:col-span-4", + 5: "md:col-span-5", + 6: "md:col-span-6", + 7: "md:col-span-7", + 8: "md:col-span-8", + 9: "md:col-span-9", + 10: "md:col-span-10", + 11: "md:col-span-11", + 12: "md:col-span-12", + 13: "md:col-span-13" +} + +const colSpanLg: { [key in Span]: string } = { + 1: "lg:col-span-1", + 2: "lg:col-span-2", + 3: "lg:col-span-3", + 4: "lg:col-span-4", + 5: "lg:col-span-5", + 6: "lg:col-span-6", + 7: "lg:col-span-7", + 8: "lg:col-span-8", + 9: "lg:col-span-9", + 10: "lg:col-span-10", + 11: "lg:col-span-11", + 12: "lg:col-span-12", + 13: "lg:col-span-13" +} diff --git a/src/components/ui/heading.tsx b/src/components/ui/heading.tsx new file mode 100644 index 0000000..ea00f38 --- /dev/null +++ b/src/components/ui/heading.tsx @@ -0,0 +1,30 @@ +import { splitProps, type Component, type ComponentProps } from "solid-js"; +import { Dynamic } from "solid-js/web"; +import { cn } from "~/lib/utils"; + +type HeadingProps = ComponentProps<"h1"> & { + level: 1 | 2 | 3 | 4 | 5 | 6; +}; + +const levelClassNames: { [key in HeadingProps["level"]]: string } = { + "1": "text-3xl mb-4", + "2": "text-2xl mb-4 mt-4", + "3": "text-lg mb-2", + "4": "font-bold", + "5": "font-bold", + "6": "font-bold", +}; + +export const Heading: Component = (props) => { + const levelStyle = () => levelClassNames[props.level]; + + const [local, other] = splitProps(props, ["class", "level"]); + + return ( + + ); +}; diff --git a/src/db.ts b/src/db.ts index a919a4c..bca0a76 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,4 +1,12 @@ -import { type Accessor, createEffect, createMemo, createRoot, createSignal, DEV, type Setter } from "solid-js"; +import { + type Accessor, + createEffect, + createMemo, + createRoot, + createSignal, + DEV, + type Setter, +} from "solid-js"; import { Kysely, type NotNull, sql } from "kysely"; import type { DB } from "kysely-codegen"; @@ -71,13 +79,19 @@ const allThreadsOverviewQueryRaw = kyselyDb() (eb) => eb .selectFrom("message") - .select((eb) => ["message.thread_id", eb.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", "")]); + 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"), + (join) => join.onRef("message.thread_id", "=", "thread._id") ) .innerJoin("recipient", "thread.recipient_id", "recipient._id") .leftJoin("groups", "recipient._id", "groups.recipient_id") @@ -100,7 +114,9 @@ const allThreadsOverviewQueryRaw = kyselyDb() }>() .compile(); -export const allThreadsOverviewQuery = cached(() => kyselyDb().executeQuery(allThreadsOverviewQueryRaw)); +export const allThreadsOverviewQuery = cached(() => + kyselyDb().executeQuery(allThreadsOverviewQueryRaw) +); const overallSentMessagesQueryRaw = (recipientId: number) => kyselyDb() @@ -111,7 +127,7 @@ const overallSentMessagesQueryRaw = (recipientId: number) => eb("message.from_recipient_id", "=", recipientId), eb("message.body", "is not", null), eb("message.body", "!=", ""), - ]), + ]) ) .executeTakeFirst(); @@ -127,7 +143,9 @@ const dmPartnerRecipientQueryRaw = (dmId: number) => "recipient.nickname_joined_name", ]) .innerJoin("thread", "recipient._id", "thread.recipient_id") - .where((eb) => eb.and([eb("thread._id", "=", dmId), eb("recipient._id", "!=", SELF_ID)])) + .where((eb) => + eb.and([eb("thread._id", "=", dmId), eb("recipient._id", "!=", SELF_ID)]) + ) .$narrowType<{ _id: number; }>() @@ -139,13 +157,18 @@ 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"), + eb.fn.min("date_sent").as("first_message_date"), + eb.fn.max("date_sent").as("last_message_date"), ]) - .groupBy("message_date") - .orderBy("message_date asc") - .where("thread_id", "=", dmId) - .execute(); + .where((eb) => + eb.and([ + eb("thread_id", "=", dmId), + eb("body", "is not", null), + eb("body", "!=", ""), + ]) + ) + .executeTakeFirst(); export const dmOverviewQuery = cached(dmOverviewQueryRaw); @@ -154,18 +177,28 @@ const threadSentMessagesPerPersonOverviewQueryRaw = (threadId: number) => .selectFrom("message") .select((eb) => [ "from_recipient_id", - sql`DATE(datetime(message.date_sent / 1000, 'unixepoch'))`.as("message_date"), + 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", "=", threadId)])) + .where((eb) => + eb.and([ + eb("body", "is not", null), + eb("body", "!=", ""), + eb("thread_id", "=", threadId), + ]) + ) .$narrowType<{ message_count: number; }>() .execute(); -export const dmSentMessagesPerPersonOverviewQuery = cached(threadSentMessagesPerPersonOverviewQueryRaw); +export const dmSentMessagesPerPersonOverviewQuery = cached( + threadSentMessagesPerPersonOverviewQueryRaw +); const threadMostUsedWordsQueryRaw = (threadId: number, limit = 10) => kyselyDb() @@ -176,12 +209,16 @@ const threadMostUsedWordsQueryRaw = (threadId: number, limit = 10) => 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)])) + .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`LOWER(substr(rest, 1, instr(rest || " ", " ") - 1))`.as( + "word" + ), sql`(substr(rest, instr(rest || " ", " ") + 1))`.as("rest"), ]) .where("rest", "<>", ""); diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..14de29e --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,10 @@ +// https://stackoverflow.com/a/15289883 +export const getDistanceBetweenDatesInDays = (a: Date, b: Date) => { + const _MS_PER_DAY = 1000 * 60 * 60 * 24; + + // Discard the time and time-zone information. + const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); + const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); + + return Math.floor((utc2 - utc1) / _MS_PER_DAY); +}; diff --git a/src/lib/getNameFromRecipient.ts b/src/lib/get-name-from-recipient.ts similarity index 91% rename from src/lib/getNameFromRecipient.ts rename to src/lib/get-name-from-recipient.ts index ca3847e..b3b6eee 100644 --- a/src/lib/getNameFromRecipient.ts +++ b/src/lib/get-name-from-recipient.ts @@ -1,7 +1,7 @@ export const getNameFromRecipient = ( joinedNickname: string | null, joinedSystemName: string | null, - joinedProfileName: string | null, + joinedProfileName: string | null ) => { let name = "Could not determine name"; diff --git a/src/pages/dm/dm-id.tsx b/src/pages/dm/dm-id.tsx index 050e91d..79a7de8 100644 --- a/src/pages/dm/dm-id.tsx +++ b/src/pages/dm/dm-id.tsx @@ -5,12 +5,35 @@ import { type ChartData } from "chart.js"; import { LineChart, WordCloudChart } from "~/components/ui/charts"; -import { dmPartnerRecipientQuery, dmSentMessagesPerPersonOverviewQuery, SELF_ID, threadMostUsedWordsQuery } from "~/db"; -import { getNameFromRecipient } from "~/lib/getNameFromRecipient"; +import { + dmOverviewQuery, + dmPartnerRecipientQuery, + dmSentMessagesPerPersonOverviewQuery, + SELF_ID, + threadMostUsedWordsQuery, +} from "~/db"; +import { getNameFromRecipient } from "~/lib/get-name-from-recipient"; +import { Heading } from "~/components/ui/heading"; +import { Grid } from "~/components/ui/grid"; +import { Flex } from "~/components/ui/flex"; +import { CalendarArrowUp, CalendarArrowDown, CalendarClock, MessagesSquare } from "lucide-solid"; +import { getDistanceBetweenDatesInDays } from "~/lib/date"; export const DmId: Component = (props) => { const dmId = () => Number(props.params.dmid); + const [dmOverview] = createResource(async () => { + const dmOverview = await dmOverviewQuery(dmId()); + + if (dmOverview) { + return { + messageCount: dmOverview.message_count, + firstMessageDate: new Date(dmOverview.first_message_date), + lastMessageDate: new Date(dmOverview.last_message_date), + }; + } + }); + // the other person in the chat with name and id const [dmPartner] = createResource(async () => { const dmPartner = await dmPartnerRecipientQuery(dmId()); @@ -140,43 +163,108 @@ export const DmId: Component = (props) => { }; return ( -
+
+ DM with {dmPartner()?.name} + Chat timeline {(currentDateChartData) => ( -
- -
+ }, + }} + data={currentDateChartData()} + class="max-h-96" + /> )}
+ + + + + + + Your first message is from + + {(currentDmOverview) => ( + {currentDmOverview().firstMessageDate.toLocaleDateString()} + )} + + + + + + + + + Your last message is from + + {(currentDmOverview) => ( + {currentDmOverview().lastMessageDate.toLocaleDateString()} + )} + + + + + + + + + You have been chatting for + + {(currentDmOverview) => ( + + {getDistanceBetweenDatesInDays( + currentDmOverview().firstMessageDate, + currentDmOverview().lastMessageDate, + )} + + )} + + days + + + + + + + + You have written + + {(currentDmOverview) => ( + {currentDmOverview().messageCount.toString()} + )} + + messages + + + + Word cloud {(currentMostUsedWordChartData) => ( -
+ // without a container this will scale in height infinitely somehow +
= () => { const [allSelfSentMessagesCount] = createResource(() => overallSentMessagesQuery(SELF_ID)); diff --git a/src/pages/overview/overview-table.tsx b/src/pages/overview/overview-table.tsx index 6ad8f49..e479072 100644 --- a/src/pages/overview/overview-table.tsx +++ b/src/pages/overview/overview-table.tsx @@ -25,6 +25,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~ import { TextField, TextFieldInput } from "~/components/ui/text-field"; import { cn } from "~/lib/utils"; +import { Flex } from "~/components/ui/flex"; export interface RoomOverview { threadId: number; @@ -85,10 +86,12 @@ export const columns = [ const isGroup = props.row.getValue("isGroup"); return ( -
- {props.cell.getValue()} + + + {props.cell.getValue()} + -
+ Archived @@ -99,9 +102,9 @@ export const columns = [ Group -
+
-
+ ); }, }),