+
+
diff --git a/frontend/src/ts/collections/result-filter-presets.ts b/frontend/src/ts/collections/result-filter-presets.ts
new file mode 100644
index 000000000000..a888800bba62
--- /dev/null
+++ b/frontend/src/ts/collections/result-filter-presets.ts
@@ -0,0 +1,79 @@
+import { ResultFilters } from "@monkeytype/schemas/users";
+import { queryCollectionOptions } from "@tanstack/query-db-collection";
+import { createCollection } from "@tanstack/solid-db";
+import Ape from "../ape";
+import * as Notifications from "../elements/notifications";
+import { queryClient } from "../queries";
+import { baseKey } from "../queries/utils/keys";
+
+const queryKeys = {
+ root: () => [...baseKey("resultFilterPresets", { isUserSpecific: true })],
+};
+
+export const resultFilterPresetsCollection = createCollection(
+ queryCollectionOptions({
+ staleTime: Infinity,
+ queryKey: queryKeys.root(),
+
+ queryClient,
+ getKey: (it) => it._id,
+ queryFn: async () => {
+ //return emtpy array. We load the user with the snapshot and fill the collection from there
+ return [] as ResultFilters[];
+ },
+ onInsert: async ({ transaction }) => {
+ const newItems = transaction.mutations.map((m) => m.modified);
+
+ const serverItems = await Promise.all(
+ newItems.map(async (it) => {
+ const response = await Ape.users.addResultFilterPreset({ body: it });
+ if (response.status !== 200) {
+ Notifications.add(
+ `Failed to insert result filter presets: ${response.body.message}`,
+ -1,
+ );
+ throw new Error(
+ `Failed to insert result filter presets: ${response.body.message}`,
+ );
+ }
+ return { ...it, _id: response.body.data };
+ }),
+ );
+
+ resultFilterPresetsCollection.utils.writeBatch(() => {
+ serverItems.forEach((it) =>
+ resultFilterPresetsCollection.utils.writeInsert(it),
+ );
+ });
+ return { refetch: false };
+ },
+ onDelete: async ({ transaction }) => {
+ const ids = transaction.mutations.map((it) => it.key as string);
+
+ await Promise.all(
+ ids.map(async (it) => {
+ const response = await Ape.users.removeResultFilterPreset({
+ params: { presetId: it },
+ });
+ if (response.status !== 200) {
+ Notifications.add(
+ `Failed to delete result filter presets: ${response.body.message}`,
+ -1,
+ );
+ throw new Error(
+ `Failed to delete result filter presets: ${response.body.message}`,
+ );
+ }
+ }),
+ );
+
+ resultFilterPresetsCollection.utils.writeBatch(() => {
+ ids.forEach((it) =>
+ resultFilterPresetsCollection.utils.writeDelete(it),
+ );
+ });
+ //don't refetch
+ return { refetch: false };
+ },
+ }),
+);
diff --git a/frontend/src/ts/collections/results.ts b/frontend/src/ts/collections/results.ts
new file mode 100644
index 000000000000..0e31d5d23e7d
--- /dev/null
+++ b/frontend/src/ts/collections/results.ts
@@ -0,0 +1,375 @@
+import { ResultMinified } from "@monkeytype/schemas/results";
+import { Mode } from "@monkeytype/schemas/shared";
+import { ResultFilters } from "@monkeytype/schemas/users";
+import { queryCollectionOptions } from "@tanstack/query-db-collection";
+import {
+ avg,
+ count,
+ createCollection,
+ eq,
+ gte,
+ inArray,
+ length,
+ max,
+ not,
+ or,
+ Query,
+ sum,
+ useLiveQuery,
+} from "@tanstack/solid-db";
+import { queryOptions } from "@tanstack/solid-query";
+import { Accessor } from "solid-js";
+import Ape from "../ape";
+import { SnapshotResult } from "../constants/default-snapshot";
+import { queryClient } from "../queries";
+import { baseKey } from "../queries/utils/keys";
+
+export type ResultsQueryState = {
+ difficulty: SnapshotResult
["difficulty"][];
+ pb: SnapshotResult["isPb"][];
+ mode: SnapshotResult["mode"][];
+ words: ("10" | "25" | "50" | "100" | "custom")[];
+ time: ("15" | "30" | "60" | "120" | "custom")[];
+ punctuation: SnapshotResult["punctuation"][];
+ numbers: SnapshotResult["numbers"][];
+ timestamp: SnapshotResult["timestamp"];
+ quoteLength: SnapshotResult["quoteLength"][];
+ tags: SnapshotResult["tags"];
+ funbox: SnapshotResult["funbox"];
+ language: SnapshotResult["language"][];
+};
+
+const queryKeys = {
+ root: () => [...baseKey("results", { isUserSpecific: true })],
+ fullResult: (_id: string) => [...queryKeys.root(), _id],
+};
+
+export type ResultStats = {
+ words: number;
+ restarted: number;
+ completed: number;
+ maxWpm: number;
+ avgWpm: number;
+ maxRaw: number;
+ avgRaw: number;
+ maxAcc: number;
+ avgAcc: number;
+ maxConsistency: number;
+ avgConsistency: number;
+ timeTyping: number;
+ dayTimestamp?: number;
+};
+
+/**
+ * get aggregated statistics for the current result selection
+ * @param queryState
+ * @param options
+ * @returns
+ */
+// oxlint-disable-next-line typescript/explicit-function-return-type
+export function useResultStatsLiveQuery(
+ queryState: Accessor,
+ options?: { lastTen?: true } | { groupByDay?: true },
+) {
+ return useLiveQuery((q) => {
+ const state = queryState();
+ if (state === undefined) return undefined;
+
+ const isLastTen =
+ options !== undefined && "lastTen" in options && options.lastTen;
+ const isGroupByDay =
+ options !== undefined && "groupByDay" in options && options.groupByDay;
+
+ let query = isLastTen
+ ? //for lastTen we need a sub-query to apply the sort+limit first and then run the aggregations
+ q.from({
+ r: q
+ .from({ r: buildResultsQuery(state) })
+ .orderBy(({ r }) => r.timestamp, "desc")
+ .limit(10),
+ })
+ : q.from({ r: buildResultsQuery(state) });
+
+ if (isGroupByDay) {
+ query = query.groupBy(({ r }) => r.dayTimestamp);
+ }
+
+ return query.select(({ r }) => ({
+ dayTimeamp: isGroupByDay ? r.dayTimestamp : undefined,
+ words: sum(r.words),
+ completed: count(r._id),
+ restarted: sum(r.restartCount),
+ timeTyping: sum(r.timeTyping),
+ maxWpm: max(r.wpm),
+ avgWpm: avg(r.wpm),
+ maxRaw: max(r.rawWpm),
+ avgRaw: avg(r.rawWpm),
+ maxAcc: max(r.acc),
+ avgAcc: avg(r.acc),
+ maxConsistency: max(r.consistency),
+ avgConsistency: avg(r.consistency),
+ }));
+ });
+}
+
+/**
+ * get list of SnapshotResults for the current result selection
+ * @param queryState
+ * @returns
+ */
+// oxlint-disable-next-line typescript/explicit-function-return-type
+export function useResultsLiveQuery(options: {
+ queryState: Accessor;
+ sorting: Accessor<{
+ field: keyof SnapshotResult;
+ direction: "asc" | "desc";
+ }>;
+ limit: Accessor;
+}) {
+ return useLiveQuery((q) => {
+ const state = options.queryState();
+ const sorting = options.sorting();
+ const limit = options.limit();
+ if (state === undefined) return undefined;
+
+ return q
+ .from({ r: buildResultsQuery(state) })
+ .orderBy(({ r }) => r[sorting.field], sorting.direction)
+ .limit(limit);
+ });
+}
+
+function normalizeResult(
+ result: ResultMinified | SnapshotResult,
+): SnapshotResult {
+ const resultDate = new Date(result.timestamp);
+ resultDate.setSeconds(0);
+ resultDate.setMinutes(0);
+ resultDate.setHours(0);
+ resultDate.setMilliseconds(0);
+
+ //@ts-expect-error without this somehow the collections is missing data
+ result.id = result._id;
+ //results strip default values, add them back
+ result.bailedOut ??= false;
+ result.blindMode ??= false;
+ result.lazyMode ??= false;
+ result.difficulty ??= "normal";
+ result.funbox ??= [];
+ result.language ??= "english";
+ result.numbers ??= false;
+ result.punctuation ??= false;
+ result.numbers ??= false;
+ result.quoteLength ??= -1;
+ result.restartCount ??= 0;
+ result.incompleteTestSeconds ??= 0;
+ result.afkDuration ??= 0;
+ result.tags ??= [];
+ result.isPb ??= false;
+ return {
+ ...result,
+ timeTyping: calcTimeTyping(result),
+ words: Math.round((result.wpm / 60) * result.testDuration),
+ dayTimestamp: resultDate.getTime(),
+ } as SnapshotResult;
+}
+
+export async function insertLocalResult(
+ result: SnapshotResult,
+): Promise {
+ if (resultsCollection.isReady()) {
+ resultsCollection.insert(result);
+ }
+}
+
+const resultsCollection = createCollection(
+ queryCollectionOptions({
+ staleTime: Infinity,
+ queryKey: queryKeys.root(),
+ queryFn: async () => {
+ //const options = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions);
+
+ const response = await Ape.results.get({
+ //query: { limit: options.limit },
+ });
+
+ if (response.status !== 200) {
+ throw new Error("Error fetching results:" + response.body.message);
+ }
+
+ return response.body.data.map((result) => normalizeResult(result));
+ },
+ onInsert: async ({ transaction }) => {
+ //call to the backend to post a result is done outside, we just insert the result as we get it
+ const newItems = transaction.mutations.map((m) => m.modified);
+
+ resultsCollection.utils.writeBatch(() => {
+ newItems.forEach((item) => {
+ resultsCollection.utils.writeInsert(normalizeResult(item));
+ });
+ });
+
+ //do not refetch after insert
+ return { refetch: false };
+ },
+ queryClient,
+ getKey: (it) => it._id,
+ }),
+);
+
+// oxlint-disable-next-line typescript/explicit-function-return-type
+export function buildResultsQuery(state: ResultsQueryState) {
+ const applyMode2Filter = (
+ key: T,
+ filter: ResultsQueryState[T],
+ nonCustomValues: string[],
+ ): void => {
+ if (filter.length === 5) return;
+ const isCustom = filter.includes("custom");
+ const selected = filter.filter((it) => it !== "custom");
+ query = query.where(({ r }) =>
+ or(
+ //results not matching the mode pass
+ not(eq(r.mode, key)),
+
+ //mode2 is matching one of the selected mode2
+ inArray(r.mode2, selected),
+ //or if custom selected are not matching any non-custom value
+ isCustom ? not(inArray(r.mode2, nonCustomValues)) : false,
+ ),
+ );
+ };
+
+ let query = new Query()
+ .from({ r: resultsCollection })
+ .where(({ r }) => gte(r.timestamp, state.timestamp))
+ .where(({ r }) => inArray(r.difficulty, state.difficulty))
+ .where(({ r }) => inArray(r.isPb, state.pb))
+ .where(({ r }) => inArray(r.mode, state.mode))
+ .where(({ r }) => inArray(r.punctuation, state.punctuation))
+ .where(({ r }) => inArray(r.numbers, state.numbers))
+ .where(({ r }) => inArray(r.quoteLength, state.quoteLength))
+ .where(({ r }) => inArray(r.language, state.language))
+ .where(({ r }) =>
+ or(
+ false,
+ false,
+ ...state.tags.map((tag) =>
+ tag === "none" ? eq(length(r.tags), 0) : inArray(tag, r.tags),
+ ),
+ ),
+ )
+ .where(({ r }) =>
+ or(
+ false,
+ false,
+ ...state.funbox.map((fb) =>
+ (fb as string) === "none"
+ ? eq(length(r.funbox), 0)
+ : inArray(fb, r.funbox),
+ ),
+ ),
+ );
+ applyMode2Filter("time", state.time, ["15", "30", "60", "120"]);
+ applyMode2Filter("words", state.words, ["10", "25", "50", "100"]);
+
+ return query;
+}
+
+export function createResultsQueryState(
+ filters: ResultFilters,
+): ResultsQueryState {
+ return {
+ difficulty: valueFilter(filters.difficulty),
+ pb: boolFilter(filters.pb),
+ mode: valueFilter(filters.mode),
+ words: valueFilter(filters.words),
+ time: valueFilter(filters.time),
+ punctuation: boolFilter(filters.punctuation),
+ numbers: boolFilter(filters.numbers),
+ timestamp: timestampFilter(filters.date),
+ quoteLength: [
+ ...valueFilter(filters.quoteLength, {
+ short: 0,
+ medium: 1,
+ long: 2,
+ thicc: 3,
+ }),
+ -1, // fallback value for results without quoteLength, set in the collection
+ ],
+ tags: valueFilter(filters.tags),
+ funbox: valueFilter(filters.funbox),
+ language: valueFilter(filters.language),
+ };
+}
+
+function valueFilter(
+ val: Partial>,
+ mapping?: Record,
+): U[] {
+ return Object.entries(val)
+ .filter(([_, v]) => v as boolean) //TODO remove as?
+ .map(([k]) => k as T)
+ .map((it) => (mapping ? mapping[it] : (it as unknown as U)));
+}
+
+function boolFilter(
+ val: Record<"on" | "off", boolean> | Record<"yes" | "no", boolean>,
+): boolean[] {
+ return Object.entries(val)
+ .filter(([_, v]) => v)
+ .map(([k]) => k === "on" || k === "yes");
+}
+
+function timestampFilter(val: ResultFilters["date"]): number {
+ const seconds =
+ valueFilter(val, {
+ all: 0,
+ last_day: 24 * 60 * 60,
+ last_week: 7 * 24 * 60 * 60,
+ last_month: 30 * 24 * 60 * 60,
+ last_3months: 90 * 24 * 60 * 60,
+ })[0] ?? 0;
+
+ if (seconds === 0) return 0;
+ return Math.floor(Date.now() - seconds * 1000);
+}
+
+function calcTimeTyping(result: ResultMinified): number {
+ let tt = 0;
+ if (
+ result.testDuration === undefined &&
+ result.mode2 !== "custom" &&
+ result.mode2 !== "zen"
+ ) {
+ //test finished before testDuration field was introduced - estimate
+ if (result.mode === "time") {
+ tt = parseInt(result.mode2);
+ } else if (result.mode === "words") {
+ tt = (parseInt(result.mode2) / result.wpm) * 60;
+ }
+ } else {
+ tt = parseFloat(result.testDuration as unknown as string); //legacy results could have a string here
+ }
+ if (result.incompleteTestSeconds !== undefined) {
+ tt += result.incompleteTestSeconds;
+ } else if (result.restartCount !== undefined && result.restartCount > 0) {
+ tt += (tt / 4) * result.restartCount;
+ }
+ return tt;
+}
+
+// oxlint-disable-next-line typescript/explicit-function-return-type
+export const getSingleResultQueryOptions = (_id: string) =>
+ queryOptions({
+ queryKey: queryKeys.fullResult(_id),
+ queryFn: async () => {
+ const response = await Ape.results.getById({ params: { resultId: _id } });
+
+ if (response.status !== 200) {
+ throw new Error(`Failed to load result: ${response.body.message}`);
+ }
+ return response.body.data;
+ },
+ staleTime: Infinity,
+ });
diff --git a/frontend/src/ts/commandline/lists/result-saving.ts b/frontend/src/ts/commandline/lists/result-saving.ts
index acf1d9600fda..27180adbc29c 100644
--- a/frontend/src/ts/commandline/lists/result-saving.ts
+++ b/frontend/src/ts/commandline/lists/result-saving.ts
@@ -13,6 +13,7 @@ const subgroup: CommandsSubgroup = {
TestState.setSaving(false);
void ModesNotice.update();
},
+ active: () => !TestState.savingEnabled,
},
{
id: "setResultSavingOn",
@@ -22,6 +23,7 @@ const subgroup: CommandsSubgroup = {
TestState.setSaving(true);
void ModesNotice.update();
},
+ active: () => TestState.savingEnabled,
},
],
};
diff --git a/frontend/src/ts/components/common/AnimatedModal.tsx b/frontend/src/ts/components/common/AnimatedModal.tsx
index 678bd7e67a5a..4e9fc008a891 100644
--- a/frontend/src/ts/components/common/AnimatedModal.tsx
+++ b/frontend/src/ts/components/common/AnimatedModal.tsx
@@ -5,6 +5,7 @@ import {
ParentProps,
Show,
} from "solid-js";
+import { Portal } from "solid-js/web";
import { useRefWithUtils } from "../../hooks/useRefWithUtils";
import {
@@ -264,31 +265,33 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement {
});
return (
-