feat(dm): add first / last message date, dm duration and overall messages
This commit is contained in:
parent
ad643ad862
commit
2fedbdc884
12 changed files with 495 additions and 65 deletions
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
|
@ -2,14 +2,9 @@
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"quickfix.biome": "explicit"
|
"quickfix.biome": "explicit"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript][typescriptreact][json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
},
|
"editor.formatOnSave": true
|
||||||
"[typescriptreact]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
},
|
|
||||||
"[json]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
},
|
},
|
||||||
"typescript.inlayHints.parameterNames.enabled": "all"
|
"typescript.inlayHints.parameterNames.enabled": "all"
|
||||||
}
|
}
|
|
@ -107,7 +107,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignore": ["dist/**/*.ts", "dist/**", "**/*.mjs", "eslint.config.js", "**/*.js"]
|
"ignore": [
|
||||||
|
"dist/**/*.ts",
|
||||||
|
"dist/**",
|
||||||
|
"**/*.mjs",
|
||||||
|
"eslint.config.js",
|
||||||
|
"**/*.js"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component, JSX } from "solid-js";
|
||||||
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount } from "solid-js";
|
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps } from "solid-js";
|
||||||
import { unwrap } from "solid-js/store";
|
import { unwrap } from "solid-js/store";
|
||||||
|
|
||||||
import type { Ref } from "@solid-primitives/refs";
|
import type { Ref } from "@solid-primitives/refs";
|
||||||
|
@ -51,9 +51,10 @@ interface TypedChartProps {
|
||||||
height?: number | undefined;
|
height?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChartProps = TypedChartProps & {
|
type ChartProps = JSX.CanvasHTMLAttributes<HTMLCanvasElement> &
|
||||||
type: ChartType;
|
TypedChartProps & {
|
||||||
};
|
type: ChartType;
|
||||||
|
};
|
||||||
|
|
||||||
interface ChartContext {
|
interface ChartContext {
|
||||||
chart: Chart;
|
chart: Chart;
|
||||||
|
@ -74,6 +75,8 @@ const BaseChart: Component<ChartProps> = (rawProps) => {
|
||||||
rawProps,
|
rawProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [, otherProps] = splitProps(props, ["options", "plugins", "data"]);
|
||||||
|
|
||||||
const init = () => {
|
const init = () => {
|
||||||
const ctx = canvasRef()?.getContext("2d") as ChartItem;
|
const ctx = canvasRef()?.getContext("2d") as ChartItem;
|
||||||
const config = unwrap(props);
|
const config = unwrap(props);
|
||||||
|
@ -153,7 +156,7 @@ const BaseChart: Component<ChartProps> = (rawProps) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
Chart.register(Colors, Filler, Legend, Tooltip);
|
Chart.register(Colors, Filler, Legend, Tooltip);
|
||||||
return <canvas ref={mergeRefs(props.ref, (el) => setCanvasRef(el))} height={props.height} width={props.width} />;
|
return <canvas ref={mergeRefs(props.ref, (el) => setCanvasRef(el))} {...otherProps} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
function showTooltip(context: ChartContext) {
|
function showTooltip(context: ChartContext) {
|
||||||
|
@ -202,7 +205,10 @@ function showTooltip(context: ChartContext) {
|
||||||
el.style.pointerEvents = "none";
|
el.style.pointerEvents = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTypedChart(type: ChartType, components: ChartComponent[]): Component<TypedChartProps> {
|
function createTypedChart(
|
||||||
|
type: ChartType,
|
||||||
|
components: ChartComponent[],
|
||||||
|
): Component<TypedChartProps & JSX.CanvasHTMLAttributes<HTMLCanvasElement>> {
|
||||||
const chartsWithScales: ChartType[] = ["bar", "line", "scatter"];
|
const chartsWithScales: ChartType[] = ["bar", "line", "scatter"];
|
||||||
const chartsWithLegends: ChartType[] = ["bar", "line"];
|
const chartsWithLegends: ChartType[] = ["bar", "line"];
|
||||||
|
|
||||||
|
|
67
src/components/ui/flex.tsx
Normal file
67
src/components/ui/flex.tsx
Normal file
|
@ -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<FlexProps> = (rawProps) => {
|
||||||
|
const props = mergeProps(
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "normal",
|
||||||
|
alignItems: "center",
|
||||||
|
} satisfies FlexProps,
|
||||||
|
rawProps,
|
||||||
|
);
|
||||||
|
const [local, others] = splitProps(props, ["flexDirection", "justifyContent", "alignItems", "class"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"flex",
|
||||||
|
flexDirectionClassNames[local.flexDirection],
|
||||||
|
justifyContentClassNames[local.justifyContent],
|
||||||
|
alignItemsClassNames[local.alignItems],
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
188
src/components/ui/grid.tsx
Normal file
188
src/components/ui/grid.tsx
Normal file
|
@ -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<GridProps> = (rawProps) => {
|
||||||
|
const props = mergeProps({ cols: 1 } satisfies GridProps, rawProps)
|
||||||
|
const [local, others] = splitProps(props, ["cols", "colsSm", "colsMd", "colsLg", "class"])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"grid",
|
||||||
|
gridCols[local.cols],
|
||||||
|
local.colsSm && gridColsSm[local.colsSm],
|
||||||
|
local.colsMd && gridColsMd[local.colsMd],
|
||||||
|
local.colsLg && gridColsLg[local.colsLg],
|
||||||
|
local.class
|
||||||
|
)}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColProps = ComponentProps<"div"> & {
|
||||||
|
span?: Span
|
||||||
|
spanSm?: Span
|
||||||
|
spanMd?: Span
|
||||||
|
spanLg?: Span
|
||||||
|
}
|
||||||
|
|
||||||
|
const Col: Component<ColProps> = (rawProps) => {
|
||||||
|
const props = mergeProps({ span: 1 as Span }, rawProps)
|
||||||
|
const [local, others] = splitProps(props, ["span", "spanSm", "spanMd", "spanLg", "class"])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
colSpan[local.span],
|
||||||
|
local.spanSm && colSpanSm[local.spanSm],
|
||||||
|
local.spanMd && colSpanMd[local.spanMd],
|
||||||
|
local.spanLg && colSpanLg[local.spanLg],
|
||||||
|
local.class
|
||||||
|
)}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
30
src/components/ui/heading.tsx
Normal file
30
src/components/ui/heading.tsx
Normal file
|
@ -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<HeadingProps> = (props) => {
|
||||||
|
const levelStyle = () => levelClassNames[props.level];
|
||||||
|
|
||||||
|
const [local, other] = splitProps(props, ["class", "level"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
component={`h${local.level}`}
|
||||||
|
class={cn("text-center font-bold", levelStyle(), local.class)}
|
||||||
|
{...other}
|
||||||
|
></Dynamic>
|
||||||
|
);
|
||||||
|
};
|
71
src/db.ts
71
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 { Kysely, type NotNull, sql } from "kysely";
|
||||||
import type { DB } from "kysely-codegen";
|
import type { DB } from "kysely-codegen";
|
||||||
|
@ -71,13 +79,19 @@ const allThreadsOverviewQueryRaw = kyselyDb()
|
||||||
(eb) =>
|
(eb) =>
|
||||||
eb
|
eb
|
||||||
.selectFrom("message")
|
.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) => {
|
.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")
|
.groupBy("message.thread_id")
|
||||||
.as("message"),
|
.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")
|
.innerJoin("recipient", "thread.recipient_id", "recipient._id")
|
||||||
.leftJoin("groups", "recipient._id", "groups.recipient_id")
|
.leftJoin("groups", "recipient._id", "groups.recipient_id")
|
||||||
|
@ -100,7 +114,9 @@ const allThreadsOverviewQueryRaw = kyselyDb()
|
||||||
}>()
|
}>()
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
export const allThreadsOverviewQuery = cached(() => kyselyDb().executeQuery(allThreadsOverviewQueryRaw));
|
export const allThreadsOverviewQuery = cached(() =>
|
||||||
|
kyselyDb().executeQuery(allThreadsOverviewQueryRaw)
|
||||||
|
);
|
||||||
|
|
||||||
const overallSentMessagesQueryRaw = (recipientId: number) =>
|
const overallSentMessagesQueryRaw = (recipientId: number) =>
|
||||||
kyselyDb()
|
kyselyDb()
|
||||||
|
@ -111,7 +127,7 @@ const overallSentMessagesQueryRaw = (recipientId: number) =>
|
||||||
eb("message.from_recipient_id", "=", recipientId),
|
eb("message.from_recipient_id", "=", recipientId),
|
||||||
eb("message.body", "is not", null),
|
eb("message.body", "is not", null),
|
||||||
eb("message.body", "!=", ""),
|
eb("message.body", "!=", ""),
|
||||||
]),
|
])
|
||||||
)
|
)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
@ -127,7 +143,9 @@ const dmPartnerRecipientQueryRaw = (dmId: number) =>
|
||||||
"recipient.nickname_joined_name",
|
"recipient.nickname_joined_name",
|
||||||
])
|
])
|
||||||
.innerJoin("thread", "recipient._id", "thread.recipient_id")
|
.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<{
|
.$narrowType<{
|
||||||
_id: number;
|
_id: number;
|
||||||
}>()
|
}>()
|
||||||
|
@ -139,13 +157,18 @@ const dmOverviewQueryRaw = (dmId: number) =>
|
||||||
kyselyDb()
|
kyselyDb()
|
||||||
.selectFrom("message")
|
.selectFrom("message")
|
||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
sql<Date>`DATE(datetime(message.date_sent / 1000, 'unixepoch'))`.as("message_date"),
|
|
||||||
eb.fn.countAll().as("message_count"),
|
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")
|
.where((eb) =>
|
||||||
.orderBy("message_date asc")
|
eb.and([
|
||||||
.where("thread_id", "=", dmId)
|
eb("thread_id", "=", dmId),
|
||||||
.execute();
|
eb("body", "is not", null),
|
||||||
|
eb("body", "!=", ""),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
export const dmOverviewQuery = cached(dmOverviewQueryRaw);
|
export const dmOverviewQuery = cached(dmOverviewQueryRaw);
|
||||||
|
|
||||||
|
@ -154,18 +177,28 @@ const threadSentMessagesPerPersonOverviewQueryRaw = (threadId: number) =>
|
||||||
.selectFrom("message")
|
.selectFrom("message")
|
||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
"from_recipient_id",
|
"from_recipient_id",
|
||||||
sql<Date>`DATE(datetime(message.date_sent / 1000, 'unixepoch'))`.as("message_date"),
|
sql<Date>`DATE(datetime(message.date_sent / 1000, 'unixepoch'))`.as(
|
||||||
|
"message_date"
|
||||||
|
),
|
||||||
eb.fn.countAll().as("message_count"),
|
eb.fn.countAll().as("message_count"),
|
||||||
])
|
])
|
||||||
.groupBy(["from_recipient_id", "message_date"])
|
.groupBy(["from_recipient_id", "message_date"])
|
||||||
.orderBy(["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<{
|
.$narrowType<{
|
||||||
message_count: number;
|
message_count: number;
|
||||||
}>()
|
}>()
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
export const dmSentMessagesPerPersonOverviewQuery = cached(threadSentMessagesPerPersonOverviewQueryRaw);
|
export const dmSentMessagesPerPersonOverviewQuery = cached(
|
||||||
|
threadSentMessagesPerPersonOverviewQueryRaw
|
||||||
|
);
|
||||||
|
|
||||||
const threadMostUsedWordsQueryRaw = (threadId: number, limit = 10) =>
|
const threadMostUsedWordsQueryRaw = (threadId: number, limit = 10) =>
|
||||||
kyselyDb()
|
kyselyDb()
|
||||||
|
@ -176,12 +209,16 @@ const threadMostUsedWordsQueryRaw = (threadId: number, limit = 10) =>
|
||||||
sql`LOWER(substr(body, 1, instr(body || " ", " ") - 1))`.as("word"),
|
sql`LOWER(substr(body, 1, instr(body || " ", " ") - 1))`.as("word"),
|
||||||
sql`(substr(body, instr(body || " ", " ") + 1))`.as("rest"),
|
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) => {
|
.unionAll((ebInner) => {
|
||||||
return ebInner
|
return ebInner
|
||||||
.selectFrom("words")
|
.selectFrom("words")
|
||||||
.select([
|
.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"),
|
sql`(substr(rest, instr(rest || " ", " ") + 1))`.as("rest"),
|
||||||
])
|
])
|
||||||
.where("rest", "<>", "");
|
.where("rest", "<>", "");
|
||||||
|
|
10
src/lib/date.ts
Normal file
10
src/lib/date.ts
Normal file
|
@ -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);
|
||||||
|
};
|
|
@ -1,7 +1,7 @@
|
||||||
export const getNameFromRecipient = (
|
export const getNameFromRecipient = (
|
||||||
joinedNickname: string | null,
|
joinedNickname: string | null,
|
||||||
joinedSystemName: string | null,
|
joinedSystemName: string | null,
|
||||||
joinedProfileName: string | null,
|
joinedProfileName: string | null
|
||||||
) => {
|
) => {
|
||||||
let name = "Could not determine name";
|
let name = "Could not determine name";
|
||||||
|
|
|
@ -5,12 +5,35 @@ import { type ChartData } from "chart.js";
|
||||||
|
|
||||||
import { LineChart, WordCloudChart } from "~/components/ui/charts";
|
import { LineChart, WordCloudChart } from "~/components/ui/charts";
|
||||||
|
|
||||||
import { dmPartnerRecipientQuery, dmSentMessagesPerPersonOverviewQuery, SELF_ID, threadMostUsedWordsQuery } from "~/db";
|
import {
|
||||||
import { getNameFromRecipient } from "~/lib/getNameFromRecipient";
|
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<RouteSectionProps> = (props) => {
|
export const DmId: Component<RouteSectionProps> = (props) => {
|
||||||
const dmId = () => Number(props.params.dmid);
|
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
|
// the other person in the chat with name and id
|
||||||
const [dmPartner] = createResource(async () => {
|
const [dmPartner] = createResource(async () => {
|
||||||
const dmPartner = await dmPartnerRecipientQuery(dmId());
|
const dmPartner = await dmPartnerRecipientQuery(dmId());
|
||||||
|
@ -140,43 +163,108 @@ export const DmId: Component<RouteSectionProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div class="flex flex-col items-center">
|
||||||
|
<Heading level={1}>DM with {dmPartner()?.name}</Heading>
|
||||||
|
<Heading level={2}>Chat timeline</Heading>
|
||||||
<Show when={dateChartData()}>
|
<Show when={dateChartData()}>
|
||||||
{(currentDateChartData) => (
|
{(currentDateChartData) => (
|
||||||
<div class="max-h-96">
|
<LineChart
|
||||||
<LineChart
|
options={{
|
||||||
options={{
|
normalized: true,
|
||||||
normalized: true,
|
aspectRatio: 3,
|
||||||
aspectRatio: 2,
|
plugins: {
|
||||||
plugins: {
|
zoom: {
|
||||||
|
pan: {
|
||||||
|
enabled: true,
|
||||||
|
mode: "xy",
|
||||||
|
},
|
||||||
zoom: {
|
zoom: {
|
||||||
pan: {
|
wheel: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
mode: "xy",
|
|
||||||
},
|
},
|
||||||
zoom: {
|
pinch: {
|
||||||
wheel: {
|
enabled: true,
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
pinch: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
mode: "xy",
|
|
||||||
},
|
},
|
||||||
|
mode: "xy",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
},
|
||||||
data={currentDateChartData()}
|
}}
|
||||||
/>
|
data={currentDateChartData()}
|
||||||
</div>
|
class="max-h-96"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
<Grid cols={1} colsMd={2} class="my-12 min-w-[35rem] gap-y-8 text-sm">
|
||||||
|
<Flex flexDirection="row" justifyContent="evenly" class="bg-amber-200 p-2 text-amber-900">
|
||||||
|
<Flex alignItems="center" justifyContent="center" class="min-w-16">
|
||||||
|
<CalendarArrowDown />
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection="col" justifyContent="around" class="flex-1">
|
||||||
|
<span>Your first message is from</span>
|
||||||
|
<Show when={!dmOverview.loading && dmOverview()}>
|
||||||
|
{(currentDmOverview) => (
|
||||||
|
<span class="font-semibold text-2xl">{currentDmOverview().firstMessageDate.toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection="row" justifyContent="evenly" class="bg-emerald-200 p-2 text-emerald-900">
|
||||||
|
<Flex alignItems="center" justifyContent="center" class="min-w-16">
|
||||||
|
<CalendarArrowUp />
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection="col" justifyContent="around" class="flex-1">
|
||||||
|
<span>Your last message is from</span>
|
||||||
|
<Show when={!dmOverview.loading && dmOverview()}>
|
||||||
|
{(currentDmOverview) => (
|
||||||
|
<span class="font-semibold text-2xl">{currentDmOverview().lastMessageDate.toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection="row" justifyContent="evenly" class="bg-blue-200 p-2 text-blue-900">
|
||||||
|
<Flex alignItems="center" justifyContent="center" class="min-w-16">
|
||||||
|
<CalendarClock />
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection="col" justifyContent="around" class="flex-1">
|
||||||
|
<span>You have been chatting for</span>
|
||||||
|
<Show when={!dmOverview.loading && dmOverview()}>
|
||||||
|
{(currentDmOverview) => (
|
||||||
|
<span class="font-semibold text-2xl">
|
||||||
|
{getDistanceBetweenDatesInDays(
|
||||||
|
currentDmOverview().firstMessageDate,
|
||||||
|
currentDmOverview().lastMessageDate,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<span>days</span>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection="row" justifyContent="evenly" class="bg-pink-200 p-2 text-pink-900">
|
||||||
|
<Flex alignItems="center" justifyContent="center" class="min-w-16">
|
||||||
|
<MessagesSquare class="h-8 w-8" />
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection="col" justifyContent="around" class="flex-1">
|
||||||
|
<span>You have written</span>
|
||||||
|
<Show when={!dmOverview.loading && dmOverview()}>
|
||||||
|
{(currentDmOverview) => (
|
||||||
|
<span class="font-semibold text-2xl">{currentDmOverview().messageCount.toString()}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<span>messages</span>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Grid>
|
||||||
|
<Heading level={2}>Word cloud</Heading>
|
||||||
<Show when={mostUsedWordChartData()}>
|
<Show when={mostUsedWordChartData()}>
|
||||||
{(currentMostUsedWordChartData) => (
|
{(currentMostUsedWordChartData) => (
|
||||||
<div class="max-w-3xl">
|
// without a container this will scale in height infinitely somehow
|
||||||
|
<div class="max-w-2xl">
|
||||||
<WordCloudChart
|
<WordCloudChart
|
||||||
options={{
|
options={{
|
||||||
normalized: true,
|
normalized: true,
|
||||||
|
aspectRatio: 3,
|
||||||
plugins: {
|
plugins: {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import type { RouteSectionProps } from "@solidjs/router";
|
||||||
import { allThreadsOverviewQuery, overallSentMessagesQuery, SELF_ID } from "~/db";
|
import { allThreadsOverviewQuery, overallSentMessagesQuery, SELF_ID } from "~/db";
|
||||||
|
|
||||||
import { OverviewTable, type RoomOverview } from "./overview-table";
|
import { OverviewTable, type RoomOverview } from "./overview-table";
|
||||||
import { getNameFromRecipient } from "~/lib/getNameFromRecipient";
|
import { getNameFromRecipient } from "~/lib/get-name-from-recipient";
|
||||||
|
|
||||||
export const Overview: Component<RouteSectionProps> = () => {
|
export const Overview: Component<RouteSectionProps> = () => {
|
||||||
const [allSelfSentMessagesCount] = createResource(() => overallSentMessagesQuery(SELF_ID));
|
const [allSelfSentMessagesCount] = createResource(() => overallSentMessagesQuery(SELF_ID));
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
|
||||||
import { TextField, TextFieldInput } from "~/components/ui/text-field";
|
import { TextField, TextFieldInput } from "~/components/ui/text-field";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Flex } from "~/components/ui/flex";
|
||||||
|
|
||||||
export interface RoomOverview {
|
export interface RoomOverview {
|
||||||
threadId: number;
|
threadId: number;
|
||||||
|
@ -85,10 +86,12 @@ export const columns = [
|
||||||
const isGroup = props.row.getValue("isGroup");
|
const isGroup = props.row.getValue("isGroup");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex w-full flex-row">
|
<Flex class="w-full" flexDirection="row">
|
||||||
<span class="font-bold">{props.cell.getValue()}</span>
|
<span class="max-w-2xl overflow-hidden text-ellipsis whitespace-nowrap font-bold">
|
||||||
|
{props.cell.getValue()}
|
||||||
|
</span>
|
||||||
<Show when={isArchived || isGroup}>
|
<Show when={isArchived || isGroup}>
|
||||||
<div class="ml-auto flex flex-row gap-2">
|
<Flex flexDirection="row" class="ml-auto gap-2">
|
||||||
<Show when={isArchived}>
|
<Show when={isArchived}>
|
||||||
<Badge variant="outline" class="ml-auto">
|
<Badge variant="outline" class="ml-auto">
|
||||||
Archived
|
Archived
|
||||||
|
@ -99,9 +102,9 @@ export const columns = [
|
||||||
Group
|
Group
|
||||||
</Badge>
|
</Badge>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</Flex>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</Flex>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue