From dc5fe9099e6324b4b90bdc693561d5c4fe3327de Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 2 Apr 2026 18:59:27 +0200 Subject: [PATCH 01/19] tags --- frontend/src/ts/collections/tags.ts | 355 ++++++++++++++++++ frontend/src/ts/commandline/lists/tags.ts | 37 +- frontend/src/ts/constants/default-snapshot.ts | 8 - .../src/ts/controllers/preset-controller.ts | 12 +- frontend/src/ts/controllers/tag-controller.ts | 84 ----- frontend/src/ts/db.ts | 297 +-------------- .../src/ts/elements/account/result-filters.ts | 39 +- frontend/src/ts/elements/modes-notice.ts | 7 +- frontend/src/ts/event-handlers/test.ts | 4 +- frontend/src/ts/modals/edit-preset.ts | 7 +- frontend/src/ts/modals/edit-result-tags.ts | 13 +- frontend/src/ts/modals/edit-tag.ts | 45 +-- frontend/src/ts/pages/account.ts | 23 +- frontend/src/ts/pages/settings.ts | 6 +- frontend/src/ts/states/snapshot.ts | 2 +- frontend/src/ts/test/pace-caret.ts | 3 +- frontend/src/ts/test/result.ts | 33 +- frontend/src/ts/test/test-logic.ts | 8 +- 18 files changed, 454 insertions(+), 529 deletions(-) create mode 100644 frontend/src/ts/collections/tags.ts delete mode 100644 frontend/src/ts/controllers/tag-controller.ts diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts new file mode 100644 index 000000000000..756be54efab1 --- /dev/null +++ b/frontend/src/ts/collections/tags.ts @@ -0,0 +1,355 @@ +import { z } from "zod"; +import { UserTag } from "@monkeytype/schemas/users"; +import { createStore, produce, reconcile } from "solid-js/store"; +import { SnapshotResult } from "../constants/default-snapshot"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { IdSchema } from "@monkeytype/schemas/util"; +import { authEvent } from "../events/auth"; +import { + Mode, + Mode2, + PersonalBest, + PersonalBests, +} from "@monkeytype/schemas/shared"; +import { Difficulty } from "@monkeytype/schemas/configs"; +import { Language } from "@monkeytype/schemas/languages"; + +// --- Types --- + +export type TagItem = UserTag & { active: boolean; display: string }; + +// --- localStorage --- + +const activeTagsLS = new LocalStorageWithSchema({ + key: "activeTags", + schema: z.array(IdSchema), + fallback: [], +}); + +// --- Store --- + +const [tags, setTags] = createStore([]); + +// --- Seed --- + +export function seedFromUserData(userTags: UserTag[]): void { + const items: TagItem[] = userTags + .map((tag) => ({ + ...tag, + active: false, + display: tag.name.replaceAll("_", " "), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + setTags(reconcile(items, { key: "_id", merge: true })); +} + +// --- Reactive accessors (for SolidJS components) --- + +export { tags }; + +// --- Imperative accessors --- + +export function getTags(): TagItem[] { + return [...tags]; +} + +export function getTag(id: string): TagItem | undefined { + return tags.find((tag) => tag._id === id); +} + +export function getActiveTags(): TagItem[] { + return tags.filter((tag) => tag.active); +} + +// --- Active state management --- + +export function saveActiveToLocalStorage(): void { + activeTagsLS.set(tags.filter((t) => t.active).map((t) => t._id)); +} + +export function toggleTagActive(tagId: string, nosave = false): void { + setTags( + (tag) => tag._id === tagId, + produce((tag) => { + tag.active = !tag.active; + }), + ); + if (!nosave) saveActiveToLocalStorage(); +} + +export function setTagActive( + tagId: string, + state: boolean, + nosave = false, +): void { + setTags( + (tag) => tag._id === tagId, + produce((tag) => { + tag.active = state; + }), + ); + if (!nosave) saveActiveToLocalStorage(); +} + +export function clearActiveTags(nosave = false): void { + setTags( + (tag) => tag.active, + produce((tag) => { + tag.active = false; + }), + ); + if (!nosave) saveActiveToLocalStorage(); +} + +function loadActiveFromLocalStorage(): void { + const savedIds = activeTagsLS.get(); + for (const id of savedIds) { + toggleTagActive(id, true); + } + saveActiveToLocalStorage(); +} + +// --- CRUD helpers --- + +export function insertTag(tag: UserTag): void { + setTags((prev) => + [ + ...prev, + { + ...tag, + active: false, + display: tag.name.replaceAll("_", " "), + }, + ].sort((a, b) => a.name.localeCompare(b.name)), + ); +} + +export function updateTag( + tagId: string, + updater: (old: TagItem) => void, +): void { + setTags((tag) => tag._id === tagId, produce(updater)); +} + +export function deleteTag(tagId: string): void { + setTags((prev) => prev.filter((tag) => tag._id !== tagId)); +} + +// --- PB logic --- + +export function getLocalTagPB( + tagId: string, + mode: M, + mode2: Mode2, + punctuation: boolean, + numbers: boolean, + language: string, + difficulty: Difficulty, + lazyMode: boolean, +): number { + const tag = getTag(tagId); + if (tag === undefined) return 0; + + const personalBests = (tag.personalBests?.[mode]?.[mode2] ?? + []) as PersonalBest[]; + + return ( + personalBests.find( + (pb) => + (pb.punctuation ?? false) === punctuation && + (pb.numbers ?? false) === numbers && + pb.difficulty === difficulty && + pb.language === language && + (pb.lazyMode === lazyMode || (pb.lazyMode === undefined && !lazyMode)), + )?.wpm ?? 0 + ); +} + +export function saveLocalTagPB( + tagId: string, + mode: M, + mode2: Mode2, + punctuation: boolean, + numbers: boolean, + language: Language, + difficulty: Difficulty, + lazyMode: boolean, + wpm: number, + acc: number, + raw: number, + consistency: number, +): void { + if (mode === "quote") return; + + setTags( + (tag) => tag._id === tagId, + produce((tag) => { + tag.personalBests ??= { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; + + tag.personalBests[mode] ??= { + [mode2]: [], + }; + + tag.personalBests[mode][mode2] ??= + [] as unknown as PersonalBests[M][Mode2]; + + try { + let found = false; + + (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).forEach( + (pb) => { + if ( + (pb.punctuation ?? false) === punctuation && + (pb.numbers ?? false) === numbers && + pb.difficulty === difficulty && + pb.language === language && + (pb.lazyMode === lazyMode || + (pb.lazyMode === undefined && !lazyMode)) + ) { + found = true; + pb.wpm = wpm; + pb.acc = acc; + pb.raw = raw; + pb.timestamp = Date.now(); + pb.consistency = consistency; + pb.lazyMode = lazyMode; + } + }, + ); + if (!found) { + (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).push({ + language, + difficulty, + lazyMode, + punctuation, + numbers, + wpm, + acc, + raw, + timestamp: Date.now(), + consistency, + }); + } + } catch { + tag.personalBests = { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; + tag.personalBests[mode][mode2] = [ + { + language, + difficulty, + lazyMode, + punctuation, + numbers, + wpm, + acc, + raw, + timestamp: Date.now(), + consistency, + }, + ] as unknown as PersonalBests[M][Mode2]; + } + }), + ); +} + +export function updateLocalTagPB( + tagId: string, + mode: M, + mode2: Mode2, + punctuation: boolean, + numbers: boolean, + language: Language, + difficulty: Difficulty, + lazyMode: boolean, + results: SnapshotResult[], +): void { + const tag = getTag(tagId); + if (tag === undefined) return; + + const pb = { + wpm: 0, + acc: 0, + rawWpm: 0, + consistency: 0, + }; + + results.forEach((result) => { + if (result.tags.includes(tagId) && result.wpm > pb.wpm) { + if ( + result.mode === mode && + result.mode2 === mode2 && + result.punctuation === punctuation && + result.numbers === numbers && + result.language === language && + result.difficulty === difficulty && + result.lazyMode === lazyMode + ) { + pb.wpm = result.wpm; + pb.acc = result.acc; + pb.rawWpm = result.rawWpm; + pb.consistency = result.consistency; + } + } + }); + + saveLocalTagPB( + tagId, + mode, + mode2, + punctuation, + numbers, + language, + difficulty, + lazyMode, + pb.wpm, + pb.acc, + pb.rawWpm, + pb.consistency, + ); +} + +export function getActiveTagsPB( + mode: M, + mode2: Mode2, + punctuation: boolean, + numbers: boolean, + language: string, + difficulty: Difficulty, + lazyMode: boolean, +): number { + let tagPbWpm = 0; + for (const tag of getActiveTags()) { + const currTagPB = getLocalTagPB( + tag._id, + mode, + mode2, + punctuation, + numbers, + language, + difficulty, + lazyMode, + ); + if (currTagPB > tagPbWpm) tagPbWpm = currTagPB; + } + return tagPbWpm; +} + +// --- Auth integration --- + +authEvent.subscribe((event) => { + if (event.type === "snapshotUpdated" && event.data.isInitial) { + loadActiveFromLocalStorage(); + } +}); diff --git a/frontend/src/ts/commandline/lists/tags.ts b/frontend/src/ts/commandline/lists/tags.ts index 4ea7b7ea1792..e2fb468938f7 100644 --- a/frontend/src/ts/commandline/lists/tags.ts +++ b/frontend/src/ts/commandline/lists/tags.ts @@ -1,7 +1,11 @@ -import * as DB from "../../db"; import * as EditTagsPopup from "../../modals/edit-tag"; import * as ModesNotice from "../../elements/modes-notice"; -import * as TagController from "../../controllers/tag-controller"; +import { + getTags, + getTag, + clearActiveTags, + toggleTagActive, +} from "../../collections/tags"; import { Config } from "../../config/store"; import * as PaceCaret from "../../test/pace-caret"; import { isAuthenticated } from "../../states/core"; @@ -28,30 +32,17 @@ const commands: Command[] = [ ]; function update(): void { - const snapshot = DB.getSnapshot(); + const tags = getTags(); subgroup.list = []; - if ( - snapshot !== undefined && - snapshot.tags !== undefined && - snapshot.tags.length > 0 - ) { + if (tags.length > 0) { subgroup.list.push({ id: "clearTags", display: `Clear tags`, icon: "fa-times", sticky: true, exec: async (): Promise => { - const snapshot = DB.getSnapshot(); - if (!snapshot) return; - - snapshot.tags = snapshot.tags?.map((tag) => { - tag.active = false; - - return tag; - }); - - DB.setSnapshot(snapshot); + clearActiveTags(); if ( Config.paceCaret === "average" || Config.paceCaret === "tagPb" || @@ -60,23 +51,19 @@ function update(): void { await PaceCaret.init(); } void ModesNotice.update(); - TagController.saveActiveToLocalStorage(); }, }); - for (const tag of snapshot.tags) { + for (const tag of tags) { subgroup.list.push({ id: "toggleTag" + tag._id, display: tag.display, sticky: true, active: () => { - return ( - DB.getSnapshot()?.tags?.find((t) => t._id === tag._id)?.active ?? - false - ); + return getTag(tag._id)?.active ?? false; }, exec: async (): Promise => { - TagController.toggle(tag._id); + toggleTagActive(tag._id); if ( Config.paceCaret === "average" || diff --git a/frontend/src/ts/constants/default-snapshot.ts b/frontend/src/ts/constants/default-snapshot.ts index 7a0ecec16eff..0818ed4ee397 100644 --- a/frontend/src/ts/constants/default-snapshot.ts +++ b/frontend/src/ts/constants/default-snapshot.ts @@ -2,7 +2,6 @@ import { ResultFilters, User, UserProfileDetails, - UserTag, } from "@monkeytype/schemas/users"; import { getDefaultConfig } from "./default-config"; import { Mode } from "@monkeytype/schemas/shared"; @@ -16,11 +15,6 @@ import { Preset } from "@monkeytype/schemas/presets"; import { Language } from "@monkeytype/schemas/languages"; import { ConnectionStatus } from "@monkeytype/schemas/connections"; -export type SnapshotUserTag = UserTag & { - active?: boolean; - display: string; -}; - export type SnapshotResult = Omit< Result, | "_id" @@ -78,7 +72,6 @@ export type Snapshot = Omit< filterPresets: ResultFilters[]; isPremium: boolean; streakHourOffset?: number; - tags: SnapshotUserTag[]; presets: SnapshotPreset[]; results?: SnapshotResult[]; xp: number; @@ -107,7 +100,6 @@ const defaultSnap = { config: getDefaultConfig(), customThemes: [], presets: [], - tags: [], banned: undefined, verified: undefined, lbMemory: { time: { 15: { english: 0 }, 60: { english: 0 } } }, diff --git a/frontend/src/ts/controllers/preset-controller.ts b/frontend/src/ts/controllers/preset-controller.ts index fce7a6c02732..222f0b2768f4 100644 --- a/frontend/src/ts/controllers/preset-controller.ts +++ b/frontend/src/ts/controllers/preset-controller.ts @@ -8,7 +8,11 @@ import { showSuccessNotification, } from "../states/notifications"; import * as TestLogic from "../test/test-logic"; -import * as TagController from "./tag-controller"; +import { + clearActiveTags, + setTagActive, + saveActiveToLocalStorage, +} from "../collections/tags"; import { SnapshotPreset } from "../constants/default-snapshot"; import { saveFullConfigToLocalStorage } from "../config/persistence"; @@ -34,12 +38,12 @@ export async function apply(_id: string): Promise { !isPartialPreset(presetToApply) || presetToApply.settingGroups?.includes("behavior") ) { - TagController.clear(true); + clearActiveTags(true); if (presetToApply.config.tags) { for (const tagId of presetToApply.config.tags) { - TagController.set(tagId, true, false); + setTagActive(tagId, true, false); } - TagController.saveActiveToLocalStorage(); + saveActiveToLocalStorage(); } } TestLogic.restart(); diff --git a/frontend/src/ts/controllers/tag-controller.ts b/frontend/src/ts/controllers/tag-controller.ts deleted file mode 100644 index c1a414c9eba0..000000000000 --- a/frontend/src/ts/controllers/tag-controller.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { z } from "zod"; -import * as DB from "../db"; -import * as ModesNotice from "../elements/modes-notice"; -import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; -import { IdSchema } from "@monkeytype/schemas/util"; -import { authEvent } from "../events/auth"; - -const activeTagsLS = new LocalStorageWithSchema({ - key: "activeTags", - schema: z.array(IdSchema), - fallback: [], -}); - -export function saveActiveToLocalStorage(): void { - const tags: string[] = []; - - DB.getSnapshot()?.tags?.forEach((tag) => { - if (tag.active === true) { - tags.push(tag._id); - } - }); - - activeTagsLS.set(tags); -} - -export function clear(nosave = false): void { - const snapshot = DB.getSnapshot(); - if (!snapshot) return; - - snapshot.tags = snapshot.tags?.map((tag) => { - tag.active = false; - - return tag; - }); - - DB.setSnapshot(snapshot); - void ModesNotice.update(); - if (!nosave) saveActiveToLocalStorage(); -} - -export function set(tagid: string, state: boolean, nosave = false): void { - const snapshot = DB.getSnapshot(); - if (!snapshot) return; - - snapshot.tags = snapshot.tags?.map((tag) => { - if (tag._id === tagid) { - tag.active = state; - } - - return tag; - }); - - DB.setSnapshot(snapshot); - void ModesNotice.update(); - if (!nosave) saveActiveToLocalStorage(); -} - -export function toggle(tagid: string, nosave = false): void { - DB.getSnapshot()?.tags?.forEach((tag) => { - if (tag._id === tagid) { - if (tag.active === undefined) { - tag.active = true; - } else { - tag.active = !tag.active; - } - } - }); - void ModesNotice.update(); - if (!nosave) saveActiveToLocalStorage(); -} - -export function loadActiveFromLocalStorage(): void { - const newTags = activeTagsLS.get(); - for (const tag of newTags) { - toggle(tag, true); - } - saveActiveToLocalStorage(); -} - -authEvent.subscribe((event) => { - if (event.type === "snapshotUpdated" && event.data.isInitial) { - loadActiveFromLocalStorage(); - } -}); diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 5468b27c9b92..3a06e080c123 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -25,7 +25,6 @@ import { Snapshot, SnapshotPreset, SnapshotResult, - SnapshotUserTag, } from "./constants/default-snapshot"; import { getFirstDayOfTheWeek } from "./utils/date-and-time"; import { Language } from "@monkeytype/schemas/languages"; @@ -42,6 +41,7 @@ import { import { XpBreakdown } from "@monkeytype/schemas/results"; import { setXpBarData } from "./states/header"; import { FunboxMetadata } from "@monkeytype/funbox"; +import { getActiveTags, seedFromUserData } from "./collections/tags"; let dbSnapshot: Snapshot | undefined; const firstDayOfTheWeek = getFirstDayOfTheWeek(); @@ -196,40 +196,7 @@ export async function initSnapshot(): Promise { snap.customThemes = userData.customThemes ?? []; - // const userDataTags: MonkeyTypes.UserTagWithDisplay[] = userData.tags ?? []; - - // userDataTags.forEach((tag) => { - // tag.display = tag.name.replaceAll("_", " "); - // tag.personalBests ??= { - // time: {}, - // words: {}, - // quote: {}, - // zen: {}, - // custom: {}, - // }; - - // for (const mode of ["time", "words", "quote", "zen", "custom"]) { - // tag.personalBests[mode as keyof PersonalBests] ??= {}; - // } - // }); - - // snap.tags = userDataTags; - - snap.tags = - userData.tags?.map((tag) => ({ - ...tag, - display: tag.name.replaceAll("_", " "), - })) ?? []; - - snap.tags = snap.tags?.sort((a, b) => { - if (a.name > b.name) { - return 1; - } else if (a.name < b.name) { - return -1; - } else { - return 0; - } - }); + seedFromUserData(userData.tags ?? []); if (presetsData !== undefined && presetsData !== null) { const presetsWithDisplay = presetsData.map((preset) => { @@ -429,10 +396,8 @@ export async function getUserAverage10( function cont(): [number, number] { const activeTagIds: string[] = []; - snapshot?.tags?.forEach((tag) => { - if (tag.active === true) { - activeTagIds.push(tag._id); - } + getActiveTags().forEach((tag) => { + activeTagIds.push(tag._id); }); let wpmSum = 0; @@ -514,10 +479,8 @@ export async function getUserDailyBest( function cont(): number { const activeTagIds: string[] = []; - snapshot?.tags?.forEach((tag) => { - if (tag.active === true) { - activeTagIds.push(tag._id); - } + getActiveTags().forEach((tag) => { + activeTagIds.push(tag._id); }); let bestWpm = 0; @@ -564,37 +527,6 @@ export async function getUserDailyBest( return retval; } -export async function getActiveTagsPB( - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: string, - difficulty: Difficulty, - lazyMode: boolean, -): Promise { - const snapshot = getSnapshot(); - if (!snapshot) return 0; - - let tagPbWpm = 0; - for (const tag of snapshot.tags) { - if (!tag.active) continue; - const currTagPB = await getLocalTagPB( - tag._id, - mode, - mode2, - punctuation, - numbers, - language, - difficulty, - lazyMode, - ); - if (currTagPB > tagPbWpm) tagPbWpm = currTagPB; - } - - return tagPbWpm; -} - export async function getLocalPB( mode: M, mode2: Mode2, @@ -700,164 +632,7 @@ function saveLocalPB( } } -export async function getLocalTagPB( - tagId: string, - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: string, - difficulty: Difficulty, - lazyMode: boolean, -): Promise { - if (dbSnapshot === null) return 0; - - let ret = 0; - - const filteredtag = (getSnapshot()?.tags ?? []).find((t) => t._id === tagId); - - if (filteredtag === undefined) return ret; - - filteredtag.personalBests ??= { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - - filteredtag.personalBests[mode] ??= { - [mode2]: [], - }; - - filteredtag.personalBests[mode][mode2] ??= - [] as unknown as PersonalBests[M][Mode2]; - - const personalBests = (filteredtag.personalBests[mode][mode2] ?? - []) as PersonalBest[]; - - ret = - personalBests.find( - (pb) => - (pb.punctuation ?? false) === punctuation && - (pb.numbers ?? false) === numbers && - pb.difficulty === difficulty && - pb.language === language && - (pb.lazyMode === lazyMode || (pb.lazyMode === undefined && !lazyMode)), - )?.wpm ?? 0; - - return ret; -} - -export async function saveLocalTagPB( - tagId: string, - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: Language, - difficulty: Difficulty, - lazyMode: boolean, - wpm: number, - acc: number, - raw: number, - consistency: number, -): Promise { - if (!dbSnapshot) return; - if (mode === "quote") return; - function cont(): void { - const filteredtag = dbSnapshot?.tags?.find( - (t) => t._id === tagId, - ) as SnapshotUserTag; - - filteredtag.personalBests ??= { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - - filteredtag.personalBests[mode] ??= { - [mode2]: [], - }; - - filteredtag.personalBests[mode][mode2] ??= - [] as unknown as PersonalBests[M][Mode2]; - - try { - let found = false; - - ( - filteredtag.personalBests[mode][mode2] as unknown as PersonalBest[] - ).forEach((pb) => { - if ( - (pb.punctuation ?? false) === punctuation && - (pb.numbers ?? false) === numbers && - pb.difficulty === difficulty && - pb.language === language && - (pb.lazyMode === lazyMode || (pb.lazyMode === undefined && !lazyMode)) - ) { - found = true; - pb.wpm = wpm; - pb.acc = acc; - pb.raw = raw; - pb.timestamp = Date.now(); - pb.consistency = consistency; - pb.lazyMode = lazyMode; - } - }); - if (!found) { - //nothing found - ( - filteredtag.personalBests[mode][mode2] as unknown as PersonalBest[] - ).push({ - language, - difficulty, - lazyMode, - punctuation, - numbers, - wpm, - acc, - raw, - timestamp: Date.now(), - consistency, - }); - } - } catch (e) { - //that mode or mode2 is not found - filteredtag.personalBests = { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - filteredtag.personalBests[mode][mode2] = [ - { - language: language, - difficulty: difficulty, - lazyMode: lazyMode, - punctuation: punctuation, - numbers: numbers, - wpm: wpm, - acc: acc, - raw: raw, - timestamp: Date.now(), - consistency: consistency, - }, - ] as unknown as PersonalBests[M][Mode2]; - } - } - - if (dbSnapshot !== null) { - cont(); - } - - return; -} - -export function deleteLocalTag(tagId: string): void { +export function removeTagFromResults(tagId: string): void { getSnapshot()?.results?.forEach((result) => { const tagIndex = result.tags.indexOf(tagId); if (tagIndex > -1) { @@ -866,64 +641,6 @@ export function deleteLocalTag(tagId: string): void { }); } -export async function updateLocalTagPB( - tagId: string, - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: Language, - difficulty: Difficulty, - lazyMode: boolean, -): Promise { - if (dbSnapshot === null) return; - - const filteredtag = (getSnapshot()?.tags ?? []).find((t) => t._id === tagId); - - if (filteredtag === undefined) return; - - const pb = { - wpm: 0, - acc: 0, - rawWpm: 0, - consistency: 0, - }; - - getSnapshot()?.results?.forEach((result) => { - if (result.tags.includes(tagId) && result.wpm > pb.wpm) { - if ( - result.mode === mode && - result.mode2 === mode2 && - result.punctuation === punctuation && - result.numbers === numbers && - result.language === language && - result.difficulty === difficulty && - result.lazyMode === lazyMode - ) { - pb.wpm = result.wpm; - pb.acc = result.acc; - pb.rawWpm = result.rawWpm; - pb.consistency = result.consistency; - } - } - }); - - await saveLocalTagPB( - tagId, - mode, - mode2, - punctuation, - numbers, - language, - difficulty, - lazyMode, - pb.wpm, - pb.acc, - pb.rawWpm, - pb.consistency, - ); -} - export async function updateLbMemory( mode: M, mode2: Mode2 | undefined, diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index cc1a78ea7065..0d43c1523382 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -21,6 +21,7 @@ import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema"; import defaultResultFilters from "../../constants/default-result-filters"; import { getAllFunboxes } from "@monkeytype/funbox"; import { Snapshot } from "../../constants/default-snapshot"; +import { getTags, getActiveTags, getTag } from "../../collections/tags"; import { LanguageList } from "../../constants/languages"; import { authEvent } from "../../events/auth"; import { sanitize } from "../../utils/sanitize"; @@ -277,11 +278,7 @@ function setAllFilters(group: ResultFiltersGroup, value: boolean): void { } export function loadTags(): void { - const snapshot = DB.getSnapshot(); - - if (snapshot === undefined) return; - - snapshot.tags.forEach((tag) => { + getTags().forEach((tag) => { defaultResultFilters.tags[tag._id] = true; }); } @@ -432,13 +429,7 @@ export function updateActive(): void { ret += aboveChartDisplay.tags?.array ?.map((id) => { if (id === "none") return id; - const snapshot = DB.getSnapshot(); - if (snapshot === undefined) return id; - const name = snapshot.tags?.find((t) => t._id === id); - if (name !== undefined) { - return snapshot.tags?.find((t) => t._id === id)?.display; - } - return name; + return getTag(id)?.display ?? id; }) .join(", "); } else { @@ -677,11 +668,9 @@ qs(".pageAccount .topFilters button.currentConfigFilter")?.on("click", () => { filters.tags["none"] = true; - DB.getSnapshot()?.tags?.forEach((tag) => { - if (tag.active === true) { - filters.tags["none"] = false; - filters.tags[tag._id] = true; - } + getActiveTags().forEach((tag) => { + filters.tags["none"] = false; + filters.tags[tag._id] = true; }); filters.date.all = true; @@ -772,13 +761,9 @@ let selectChangeCallbackFn: () => void = () => { }; export function updateTagsDropdownOptions(): void { - const snapshot = DB.getSnapshot(); - - if (snapshot === undefined) { - return; - } + const tags = getTags(); - const newTags = snapshot.tags.filter( + const newTags = tags.filter( (it) => defaultResultFilters.tags[it._id] === undefined, ); if (newTags.length > 0) { @@ -808,7 +793,7 @@ export function updateTagsDropdownOptions(): void { html += ""; html += ""; - for (const tag of snapshot.tags) { + for (const tag of tags) { html += ``; } @@ -823,7 +808,7 @@ export async function appendDropdowns( //snapshot at this point is guaranteed to exist const snapshot = DB.getSnapshot() as Snapshot; - tagDropdownUpdate(snapshot); + tagDropdownUpdate(); if (buttonsAppended) return; @@ -896,12 +881,12 @@ export async function appendDropdowns( buttonsAppended = true; } -function tagDropdownUpdate(snapshot: Snapshot): void { +function tagDropdownUpdate(): void { const tagsSection = qs( ".pageAccount .content .filterButtons .buttonsAndTitle.tags", ); - if (snapshot.tags.length === 0) { + if (getTags().length === 0) { tagsSection?.hide(); if (groupSelects["tags"]) { groupSelects["tags"].destroy(); diff --git a/frontend/src/ts/elements/modes-notice.ts b/frontend/src/ts/elements/modes-notice.ts index eda13c5988d0..93d26d8415fc 100644 --- a/frontend/src/ts/elements/modes-notice.ts +++ b/frontend/src/ts/elements/modes-notice.ts @@ -2,6 +2,7 @@ import * as PaceCaret from "../test/pace-caret"; import * as TestState from "../test/test-state"; import * as DB from "../db"; import * as Last10Average from "../elements/last-10-average"; +import { getActiveTags } from "../collections/tags"; import { Config } from "../config/store"; import * as TestWords from "../test/test-words"; import { configEvent, type ConfigEventKey } from "../events/config"; @@ -302,10 +303,8 @@ export async function update(): Promise { let tagsString = ""; try { - DB.getSnapshot()?.tags?.forEach((tag) => { - if (tag.active === true) { - tagsString += tag.display + ", "; - } + getActiveTags().forEach((tag) => { + tagsString += tag.display + ", "; }); if (tagsString !== "") { diff --git a/frontend/src/ts/event-handlers/test.ts b/frontend/src/ts/event-handlers/test.ts index a55218f6ab3a..6c86716eaab2 100644 --- a/frontend/src/ts/event-handlers/test.ts +++ b/frontend/src/ts/event-handlers/test.ts @@ -1,7 +1,7 @@ import * as Commandline from "../commandline/commandline"; import { Config } from "../config/store"; -import * as DB from "../db"; import * as EditResultTagsModal from "../modals/edit-result-tags"; +import { getTags } from "../collections/tags"; import * as TestWords from "../test/test-words"; import { showNoticeNotification, @@ -33,7 +33,7 @@ testPage?.onChild("click", "#testModesNotice .textButton", async (event) => { }); testPage?.onChild("click", ".tags .editTagsButton", () => { - if ((DB.getSnapshot()?.tags?.length ?? 0) > 0) { + if (getTags().length > 0) { const resultid = qs(".pageTest .tags .editTagsButton")?.getAttribute("data-result-id") ?? ""; diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index de8d82d4b574..f430ec09af41 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -1,5 +1,6 @@ import Ape from "../ape"; import * as DB from "../db"; +import { getActiveTags } from "../collections/tags"; import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; import * as Settings from "../pages/settings"; import { @@ -389,11 +390,7 @@ function getConfigChanges(): Partial { state.presetType === "partial" ? getPartialConfigChanges(getConfigChangesFromConfig()) : getConfigChangesFromConfig(); - const tags = DB.getSnapshot()?.tags ?? []; - - const activeTagIds: string[] = tags - .filter((tag) => tag.active) - .map((tag) => tag._id); + const activeTagIds: string[] = getActiveTags().map((tag) => tag._id); const setTags: boolean = state.presetType === "full" || state.checkboxes.get("behavior") === true; diff --git a/frontend/src/ts/modals/edit-result-tags.ts b/frontend/src/ts/modals/edit-result-tags.ts index 2a844bb4b43d..6da97a3c3bc8 100644 --- a/frontend/src/ts/modals/edit-result-tags.ts +++ b/frontend/src/ts/modals/edit-result-tags.ts @@ -10,6 +10,7 @@ import * as AccountPage from "../pages/account"; import { areUnsortedArraysEqual } from "../utils/arrays"; import * as TestResult from "../test/result"; import AnimatedModal from "../utils/animated-modal"; +import { getTags, getTag, updateLocalTagPB } from "../collections/tags"; type State = { resultId: string; @@ -64,14 +65,11 @@ function appendButtons(): void { return; } - const tagIds = new Set([ - ...(DB.getSnapshot()?.tags.map((tag) => tag._id) ?? []), - ...state.tags, - ]); + const tagIds = new Set([...getTags().map((tag) => tag._id), ...state.tags]); buttonsEl.empty(); for (const tagId of tagIds) { - const tag = DB.getSnapshot()?.tags.find((tag) => tag._id === tagId); + const tag = getTag(tagId); const button = document.createElement("button"); button.classList.add("toggleTag"); button.setAttribute("data-tag-id", tagId); @@ -126,6 +124,8 @@ async function save(): Promise { showSuccessNotification("Tags updated", { durationMs: 2000 }); + const results = DB.getSnapshot()?.results ?? []; + DB.getSnapshot()?.results?.forEach((result) => { if (result._id === state.resultId) { const tagsToUpdate = [ @@ -134,7 +134,7 @@ async function save(): Promise { ]; result.tags = state.tags; tagsToUpdate.forEach((tag) => { - void DB.updateLocalTagPB( + updateLocalTagPB( tag, result.mode, result.mode2, @@ -143,6 +143,7 @@ async function save(): Promise { result.language, result.difficulty, result.lazyMode, + results, ); }); } diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 41bda4879a13..240d9a7e444f 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -4,12 +4,8 @@ import * as Settings from "../pages/settings"; import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import { SimpleModal, TextInput } from "../elements/simple-modal"; import { TagNameSchema } from "@monkeytype/schemas/users"; -import { SnapshotUserTag } from "../constants/default-snapshot"; import { IsValidResponse } from "../types/validation"; - -function getTagFromSnapshot(tagId: string): SnapshotUserTag | undefined { - return DB.getSnapshot()?.tags.find((tag) => tag._id === tagId); -} +import { insertTag, updateTag, deleteTag } from "../collections/tags"; const cleanTagName = (tagName: string): string => tagName.replaceAll(" ", "_"); const tagNameValidation = async (tagName: string): Promise => { @@ -45,18 +41,7 @@ const actionModals: Record = { }; } - DB.getSnapshot()?.tags?.push({ - display: propTagName, - name: response.body.data.name, - _id: response.body.data._id, - personalBests: { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }, - }); + insertTag(response.body.data); void Settings.update(); return { status: "success", message: `Tag added` }; @@ -92,12 +77,10 @@ const actionModals: Record = { }; } - const matchingTag = getTagFromSnapshot(tagId); - - if (matchingTag !== undefined) { - matchingTag.name = tagName; - matchingTag.display = propTagName; - } + updateTag(tagId, (tag) => { + tag.name = tagName; + tag.display = propTagName; + }); void Settings.update(); @@ -123,12 +106,8 @@ const actionModals: Record = { }; } - const snapshot = DB.getSnapshot(); - if (snapshot?.tags) { - snapshot.tags = snapshot.tags.filter((it) => it._id !== tagId); - } - - DB.deleteLocalTag(tagId); + deleteTag(tagId); + DB.removeTagFromResults(tagId); void Settings.update(); @@ -156,17 +135,15 @@ const actionModals: Record = { }; } - const matchingTag = getTagFromSnapshot(tagId); - - if (matchingTag !== undefined) { - matchingTag.personalBests = { + updateTag(tagId, (tag) => { + tag.personalBests = { time: {}, words: {}, quote: {}, zen: {}, custom: {}, }; - } + }); void Settings.update(); return { status: "success", message: `Tag PB cleared` }; diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 9232daddee18..39a7deaedf33 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -1,5 +1,6 @@ import * as DB from "../db"; import * as ResultFilters from "../elements/account/result-filters"; +import { getTags, getTag } from "../collections/tags"; import * as ChartController from "../controllers/chart-controller"; import { Config } from "../config/store"; @@ -123,11 +124,10 @@ function buildResultRow(result: SnapshotResult): HTMLTableRowElement { if (result.tags !== undefined && result.tags.length > 0) { tagNames = ""; result.tags.forEach((tag) => { - DB.getSnapshot()?.tags?.forEach((snaptag) => { - if (tag === snaptag._id) { - tagNames += snaptag.display + ", "; - } - }); + const snaptag = getTag(tag); + if (snaptag !== undefined) { + tagNames += snaptag.display + ", "; + } }); tagNames = tagNames.substring(0, tagNames.length - 2); } @@ -424,14 +424,14 @@ async function fillContent(): Promise { let tagHide = true; if (result.tags === undefined || result.tags.length === 0) { //no tags, show when no tag is enabled - if ((DB.getSnapshot()?.tags?.length ?? 0) > 0) { + if (getTags().length > 0) { if (ResultFilters.getFilter("tags", "none")) tagHide = false; } else { tagHide = false; } } else { //tags exist - const validTags = DB.getSnapshot()?.tags?.map((t) => t._id); + const validTags = getTags().map((t) => t._id); if (validTags === undefined) return; @@ -983,11 +983,10 @@ export function updateTagsForResult(resultId: string, tagIds: string[]): void { if (tagIds.length > 0) { for (const tag of tagIds) { - DB.getSnapshot()?.tags?.forEach((snaptag) => { - if (tag === snaptag._id) { - tagNames.push(snaptag.display); - } - }); + const snaptag = getTag(tag); + if (snaptag !== undefined) { + tagNames.push(snaptag.display); + } } } diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index 390b55f1597a..2724b9906e20 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -8,7 +8,7 @@ import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; import * as DB from "../db"; import * as Funbox from "../test/funbox/funbox"; -import * as TagController from "../controllers/tag-controller"; +import { getTags, toggleTagActive } from "../collections/tags"; import * as PresetController from "../controllers/preset-controller"; import * as ThemePicker from "../elements/settings/theme-picker"; import { @@ -519,7 +519,7 @@ function setActiveFunboxButton(): void { function refreshTagsSettingsSection(): void { if (isAuthenticated() && DB.getSnapshot()) { const tagsEl = qs(".pageSettings .section.tags .tagsList")?.empty(); - DB.getSnapshot()?.tags?.forEach((tag) => { + getTags().forEach((tag) => { // let tagPbString = "No PB found"; // if (tag.pb !== undefined && tag.pb > 0) { // tagPbString = `PB: ${tag.pb}`; @@ -786,7 +786,7 @@ qs(".pageSettings .section.tags")?.onChild( (e) => { const target = e.childTarget as HTMLElement; const tagid = target.parentElement?.getAttribute("data-id") as string; - TagController.toggle(tagid); + toggleTagActive(tagid); target.classList.toggle("active"); }, ); diff --git a/frontend/src/ts/states/snapshot.ts b/frontend/src/ts/states/snapshot.ts index 9b7eb087219c..21ccc6845ef6 100644 --- a/frontend/src/ts/states/snapshot.ts +++ b/frontend/src/ts/states/snapshot.ts @@ -5,7 +5,7 @@ import { Mode } from "@monkeytype/schemas/shared"; export type MiniSnapshot = Omit< Snapshot, - "results" | "tags" | "presets" | "filterPresets" + "results" | "presets" | "filterPresets" >; const [snapshot, updateSnapshot] = createStore<{ value: MiniSnapshot | undefined; diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index d934c71c0264..0471480d7b61 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -1,6 +1,7 @@ import * as TestWords from "./test-words"; import { Config } from "../config/store"; import * as DB from "../db"; +import { getActiveTagsPB } from "../collections/tags"; import * as Misc from "../utils/misc"; import * as TestState from "./test-state"; import { configEvent } from "../events/config"; @@ -72,7 +73,7 @@ export async function init(): Promise { ) )?.wpm ?? 0; } else if (Config.paceCaret === "tagPb") { - wpm = await DB.getActiveTagsPB( + wpm = getActiveTagsPB( Config.mode, mode2, Config.punctuation, diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 89cdf90de22a..38972475d9bc 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -45,7 +45,14 @@ import Ape from "../ape"; import { CompletedEvent } from "@monkeytype/schemas/results"; import { getActiveFunboxes, isFunboxActiveWithProperty } from "./funbox/list"; import { getFunbox } from "@monkeytype/funbox"; -import { SnapshotUserTag } from "../constants/default-snapshot"; +import { + getActiveTags, + getTags, + getTag, + getLocalTagPB, + saveLocalTagPB, + type TagItem, +} from "../collections/tags"; import { Language } from "@monkeytype/schemas/languages"; import { canQuickRestart as canQuickRestartFn } from "../utils/quick-restart"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; @@ -662,15 +669,8 @@ export function showConfetti(): void { } async function updateTags(dontSave: boolean): Promise { - const activeTags: SnapshotUserTag[] = []; - const userTagsCount = DB.getSnapshot()?.tags?.length ?? 0; - try { - DB.getSnapshot()?.tags?.forEach((tag) => { - if (tag.active === true) { - activeTags.push(tag); - } - }); - } catch (e) {} + const activeTags: TagItem[] = getActiveTags(); + const userTagsCount = getTags().length; if (userTagsCount === 0) { qs("#result .stats .tags")?.hide(); @@ -697,7 +697,7 @@ async function updateTags(dontSave: boolean): Promise { let annotationSide: LabelPosition = "start"; let labelAdjust = 15; for (const tag of activeTags) { - const tpb = await DB.getLocalTagPB( + const tpb = getLocalTagPB( tag._id, Config.mode, result.mode2, @@ -718,7 +718,7 @@ async function updateTags(dontSave: boolean): Promise { ) { if (tpb < result.wpm) { //new pb for that tag - await DB.saveLocalTagPB( + saveLocalTagPB( tag._id, Config.mode, result.mode2, @@ -1268,11 +1268,10 @@ export function updateTagsAfterEdit( if (tagIds.length > 0) { for (const tag of tagIds) { - DB.getSnapshot()?.tags?.forEach((snaptag) => { - if (tag === snaptag._id) { - tagNames.push(snaptag.display); - } - }); + const snaptag = getTag(tag); + if (snaptag !== undefined) { + tagNames.push(snaptag.display); + } } } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 3ce10c09a91a..f3cf09a5b114 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -22,6 +22,7 @@ import * as Caret from "./caret"; import * as TestTimer from "./test-timer"; import * as DB from "../db"; import * as Replay from "./replay"; +import { getActiveTags } from "../collections/tags"; import * as TodayTracker from "./today-tracker"; import * as ChallengeContoller from "../controllers/challenge-controller"; import { clearQuoteStats } from "../states/quote-rate"; @@ -810,12 +811,7 @@ function buildCompletedEvent( } //tags - const activeTagsIds: string[] = []; - for (const tag of DB.getSnapshot()?.tags ?? []) { - if (tag.active === true) { - activeTagsIds.push(tag._id); - } - } + const activeTagsIds: string[] = getActiveTags().map((tag) => tag._id); const duration = parseFloat(stats.time.toString()); const afkDuration = TestStats.calculateAfkSeconds(duration); From b089f35e0d9096f2b4d7e8e6926c5acca5bcfc91 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 2 Apr 2026 20:12:36 +0200 Subject: [PATCH 02/19] move --- frontend/src/ts/commandline/lists/tags.ts | 2 +- .../src/ts/controllers/preset-controller.ts | 2 +- frontend/src/ts/db.ts | 2 +- .../src/ts/elements/account/result-filters.ts | 2 +- frontend/src/ts/elements/modes-notice.ts | 2 +- frontend/src/ts/event-handlers/test.ts | 2 +- frontend/src/ts/features/tags/active.ts | 64 ++++++++ frontend/src/ts/features/tags/index.ts | 27 ++++ .../tags/personal-bests.ts} | 142 +----------------- frontend/src/ts/features/tags/store.ts | 54 +++++++ frontend/src/ts/modals/edit-preset.ts | 2 +- frontend/src/ts/modals/edit-result-tags.ts | 2 +- frontend/src/ts/modals/edit-tag.ts | 2 +- frontend/src/ts/pages/account.ts | 2 +- frontend/src/ts/pages/settings.ts | 2 +- frontend/src/ts/test/pace-caret.ts | 2 +- frontend/src/ts/test/result.ts | 2 +- frontend/src/ts/test/test-logic.ts | 2 +- 18 files changed, 162 insertions(+), 153 deletions(-) create mode 100644 frontend/src/ts/features/tags/active.ts create mode 100644 frontend/src/ts/features/tags/index.ts rename frontend/src/ts/{collections/tags.ts => features/tags/personal-bests.ts} (60%) create mode 100644 frontend/src/ts/features/tags/store.ts diff --git a/frontend/src/ts/commandline/lists/tags.ts b/frontend/src/ts/commandline/lists/tags.ts index e2fb468938f7..818d0b09edbe 100644 --- a/frontend/src/ts/commandline/lists/tags.ts +++ b/frontend/src/ts/commandline/lists/tags.ts @@ -5,7 +5,7 @@ import { getTag, clearActiveTags, toggleTagActive, -} from "../../collections/tags"; +} from "../../features/tags"; import { Config } from "../../config/store"; import * as PaceCaret from "../../test/pace-caret"; import { isAuthenticated } from "../../states/core"; diff --git a/frontend/src/ts/controllers/preset-controller.ts b/frontend/src/ts/controllers/preset-controller.ts index 222f0b2768f4..b6661f1886f0 100644 --- a/frontend/src/ts/controllers/preset-controller.ts +++ b/frontend/src/ts/controllers/preset-controller.ts @@ -12,7 +12,7 @@ import { clearActiveTags, setTagActive, saveActiveToLocalStorage, -} from "../collections/tags"; +} from "../features/tags"; import { SnapshotPreset } from "../constants/default-snapshot"; import { saveFullConfigToLocalStorage } from "../config/persistence"; diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 3a06e080c123..a3675e8c2281 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -41,7 +41,7 @@ import { import { XpBreakdown } from "@monkeytype/schemas/results"; import { setXpBarData } from "./states/header"; import { FunboxMetadata } from "@monkeytype/funbox"; -import { getActiveTags, seedFromUserData } from "./collections/tags"; +import { getActiveTags, seedFromUserData } from "./features/tags"; let dbSnapshot: Snapshot | undefined; const firstDayOfTheWeek = getFirstDayOfTheWeek(); diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index 0d43c1523382..8c3b01c67743 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -21,7 +21,7 @@ import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema"; import defaultResultFilters from "../../constants/default-result-filters"; import { getAllFunboxes } from "@monkeytype/funbox"; import { Snapshot } from "../../constants/default-snapshot"; -import { getTags, getActiveTags, getTag } from "../../collections/tags"; +import { getTags, getActiveTags, getTag } from "../../features/tags"; import { LanguageList } from "../../constants/languages"; import { authEvent } from "../../events/auth"; import { sanitize } from "../../utils/sanitize"; diff --git a/frontend/src/ts/elements/modes-notice.ts b/frontend/src/ts/elements/modes-notice.ts index 93d26d8415fc..3476a2644e18 100644 --- a/frontend/src/ts/elements/modes-notice.ts +++ b/frontend/src/ts/elements/modes-notice.ts @@ -2,7 +2,7 @@ import * as PaceCaret from "../test/pace-caret"; import * as TestState from "../test/test-state"; import * as DB from "../db"; import * as Last10Average from "../elements/last-10-average"; -import { getActiveTags } from "../collections/tags"; +import { getActiveTags } from "../features/tags"; import { Config } from "../config/store"; import * as TestWords from "../test/test-words"; import { configEvent, type ConfigEventKey } from "../events/config"; diff --git a/frontend/src/ts/event-handlers/test.ts b/frontend/src/ts/event-handlers/test.ts index 6c86716eaab2..96a7f8b78c95 100644 --- a/frontend/src/ts/event-handlers/test.ts +++ b/frontend/src/ts/event-handlers/test.ts @@ -1,7 +1,7 @@ import * as Commandline from "../commandline/commandline"; import { Config } from "../config/store"; import * as EditResultTagsModal from "../modals/edit-result-tags"; -import { getTags } from "../collections/tags"; +import { getTags } from "../features/tags"; import * as TestWords from "../test/test-words"; import { showNoticeNotification, diff --git a/frontend/src/ts/features/tags/active.ts b/frontend/src/ts/features/tags/active.ts new file mode 100644 index 000000000000..61866c9fa7bb --- /dev/null +++ b/frontend/src/ts/features/tags/active.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema"; +import { IdSchema } from "@monkeytype/schemas/util"; +import { authEvent } from "../../events/auth"; +import { tags, setTags } from "./store"; +import { produce } from "solid-js/store"; + +const activeTagsLS = new LocalStorageWithSchema({ + key: "activeTags", + schema: z.array(IdSchema), + fallback: [], +}); + +export function saveActiveToLocalStorage(): void { + activeTagsLS.set(tags.filter((t) => t.active).map((t) => t._id)); +} + +export function toggleTagActive(tagId: string, nosave = false): void { + setTags( + (tag) => tag._id === tagId, + produce((tag) => { + tag.active = !tag.active; + }), + ); + if (!nosave) saveActiveToLocalStorage(); +} + +export function setTagActive( + tagId: string, + state: boolean, + nosave = false, +): void { + setTags( + (tag) => tag._id === tagId, + produce((tag) => { + tag.active = state; + }), + ); + if (!nosave) saveActiveToLocalStorage(); +} + +export function clearActiveTags(nosave = false): void { + setTags( + (tag) => tag.active, + produce((tag) => { + tag.active = false; + }), + ); + if (!nosave) saveActiveToLocalStorage(); +} + +function loadActiveFromLocalStorage(): void { + const savedIds = activeTagsLS.get(); + for (const id of savedIds) { + toggleTagActive(id, true); + } + saveActiveToLocalStorage(); +} + +authEvent.subscribe((event) => { + if (event.type === "snapshotUpdated" && event.data.isInitial) { + loadActiveFromLocalStorage(); + } +}); diff --git a/frontend/src/ts/features/tags/index.ts b/frontend/src/ts/features/tags/index.ts new file mode 100644 index 000000000000..4d86cc17bbfb --- /dev/null +++ b/frontend/src/ts/features/tags/index.ts @@ -0,0 +1,27 @@ +export type { TagItem } from "./store"; +export { + seedFromUserData, + getTags, + getTag, + getActiveTags, + insertTag, + updateTag, + deleteTag, +} from "./store"; + +export { + saveActiveToLocalStorage, + toggleTagActive, + setTagActive, + clearActiveTags, +} from "./active"; + +export { + getLocalTagPB, + saveLocalTagPB, + updateLocalTagPB, + getActiveTagsPB, +} from "./personal-bests"; + +// Side-effect: registers authEvent listener for loading active tags from localStorage +import "./active"; diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/features/tags/personal-bests.ts similarity index 60% rename from frontend/src/ts/collections/tags.ts rename to frontend/src/ts/features/tags/personal-bests.ts index 756be54efab1..23e1989d611c 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/features/tags/personal-bests.ts @@ -1,10 +1,6 @@ -import { z } from "zod"; -import { UserTag } from "@monkeytype/schemas/users"; -import { createStore, produce, reconcile } from "solid-js/store"; -import { SnapshotResult } from "../constants/default-snapshot"; -import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; -import { IdSchema } from "@monkeytype/schemas/util"; -import { authEvent } from "../events/auth"; +import { SnapshotResult } from "../../constants/default-snapshot"; +import { setTags, getTag, getActiveTags } from "./store"; +import { produce } from "solid-js/store"; import { Mode, Mode2, @@ -14,130 +10,6 @@ import { import { Difficulty } from "@monkeytype/schemas/configs"; import { Language } from "@monkeytype/schemas/languages"; -// --- Types --- - -export type TagItem = UserTag & { active: boolean; display: string }; - -// --- localStorage --- - -const activeTagsLS = new LocalStorageWithSchema({ - key: "activeTags", - schema: z.array(IdSchema), - fallback: [], -}); - -// --- Store --- - -const [tags, setTags] = createStore([]); - -// --- Seed --- - -export function seedFromUserData(userTags: UserTag[]): void { - const items: TagItem[] = userTags - .map((tag) => ({ - ...tag, - active: false, - display: tag.name.replaceAll("_", " "), - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - setTags(reconcile(items, { key: "_id", merge: true })); -} - -// --- Reactive accessors (for SolidJS components) --- - -export { tags }; - -// --- Imperative accessors --- - -export function getTags(): TagItem[] { - return [...tags]; -} - -export function getTag(id: string): TagItem | undefined { - return tags.find((tag) => tag._id === id); -} - -export function getActiveTags(): TagItem[] { - return tags.filter((tag) => tag.active); -} - -// --- Active state management --- - -export function saveActiveToLocalStorage(): void { - activeTagsLS.set(tags.filter((t) => t.active).map((t) => t._id)); -} - -export function toggleTagActive(tagId: string, nosave = false): void { - setTags( - (tag) => tag._id === tagId, - produce((tag) => { - tag.active = !tag.active; - }), - ); - if (!nosave) saveActiveToLocalStorage(); -} - -export function setTagActive( - tagId: string, - state: boolean, - nosave = false, -): void { - setTags( - (tag) => tag._id === tagId, - produce((tag) => { - tag.active = state; - }), - ); - if (!nosave) saveActiveToLocalStorage(); -} - -export function clearActiveTags(nosave = false): void { - setTags( - (tag) => tag.active, - produce((tag) => { - tag.active = false; - }), - ); - if (!nosave) saveActiveToLocalStorage(); -} - -function loadActiveFromLocalStorage(): void { - const savedIds = activeTagsLS.get(); - for (const id of savedIds) { - toggleTagActive(id, true); - } - saveActiveToLocalStorage(); -} - -// --- CRUD helpers --- - -export function insertTag(tag: UserTag): void { - setTags((prev) => - [ - ...prev, - { - ...tag, - active: false, - display: tag.name.replaceAll("_", " "), - }, - ].sort((a, b) => a.name.localeCompare(b.name)), - ); -} - -export function updateTag( - tagId: string, - updater: (old: TagItem) => void, -): void { - setTags((tag) => tag._id === tagId, produce(updater)); -} - -export function deleteTag(tagId: string): void { - setTags((prev) => prev.filter((tag) => tag._id !== tagId)); -} - -// --- PB logic --- - export function getLocalTagPB( tagId: string, mode: M, @@ -345,11 +217,3 @@ export function getActiveTagsPB( } return tagPbWpm; } - -// --- Auth integration --- - -authEvent.subscribe((event) => { - if (event.type === "snapshotUpdated" && event.data.isInitial) { - loadActiveFromLocalStorage(); - } -}); diff --git a/frontend/src/ts/features/tags/store.ts b/frontend/src/ts/features/tags/store.ts new file mode 100644 index 000000000000..bffec654b90c --- /dev/null +++ b/frontend/src/ts/features/tags/store.ts @@ -0,0 +1,54 @@ +import { UserTag } from "@monkeytype/schemas/users"; +import { createStore, produce, reconcile } from "solid-js/store"; + +export type TagItem = UserTag & { active: boolean; display: string }; + +export const [tags, setTags] = createStore([]); + +export function seedFromUserData(userTags: UserTag[]): void { + const items: TagItem[] = userTags + .map((tag) => ({ + ...tag, + active: false, + display: tag.name.replaceAll("_", " "), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + setTags(reconcile(items, { key: "_id", merge: true })); +} + +export function getTags(): TagItem[] { + return tags; +} + +export function getTag(id: string): TagItem | undefined { + return tags.find((tag) => tag._id === id); +} + +export function getActiveTags(): TagItem[] { + return tags.filter((tag) => tag.active); +} + +export function insertTag(tag: UserTag): void { + setTags((prev) => + [ + ...prev, + { + ...tag, + active: false, + display: tag.name.replaceAll("_", " "), + }, + ].sort((a, b) => a.name.localeCompare(b.name)), + ); +} + +export function updateTag( + tagId: string, + updater: (old: TagItem) => void, +): void { + setTags((tag) => tag._id === tagId, produce(updater)); +} + +export function deleteTag(tagId: string): void { + setTags((prev) => prev.filter((tag) => tag._id !== tagId)); +} diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index f430ec09af41..f42674c11367 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -1,6 +1,6 @@ import Ape from "../ape"; import * as DB from "../db"; -import { getActiveTags } from "../collections/tags"; +import { getActiveTags } from "../features/tags"; import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; import * as Settings from "../pages/settings"; import { diff --git a/frontend/src/ts/modals/edit-result-tags.ts b/frontend/src/ts/modals/edit-result-tags.ts index 6da97a3c3bc8..bd148cf4bdf8 100644 --- a/frontend/src/ts/modals/edit-result-tags.ts +++ b/frontend/src/ts/modals/edit-result-tags.ts @@ -10,7 +10,7 @@ import * as AccountPage from "../pages/account"; import { areUnsortedArraysEqual } from "../utils/arrays"; import * as TestResult from "../test/result"; import AnimatedModal from "../utils/animated-modal"; -import { getTags, getTag, updateLocalTagPB } from "../collections/tags"; +import { getTags, getTag, updateLocalTagPB } from "../features/tags"; type State = { resultId: string; diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 240d9a7e444f..384b910cb72a 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -5,7 +5,7 @@ import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import { SimpleModal, TextInput } from "../elements/simple-modal"; import { TagNameSchema } from "@monkeytype/schemas/users"; import { IsValidResponse } from "../types/validation"; -import { insertTag, updateTag, deleteTag } from "../collections/tags"; +import { insertTag, updateTag, deleteTag } from "../features/tags"; const cleanTagName = (tagName: string): string => tagName.replaceAll(" ", "_"); const tagNameValidation = async (tagName: string): Promise => { diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 39a7deaedf33..7c0e3959f570 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -1,6 +1,6 @@ import * as DB from "../db"; import * as ResultFilters from "../elements/account/result-filters"; -import { getTags, getTag } from "../collections/tags"; +import { getTags, getTag } from "../features/tags"; import * as ChartController from "../controllers/chart-controller"; import { Config } from "../config/store"; diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index 2724b9906e20..d9cb798338cf 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -8,7 +8,7 @@ import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; import * as DB from "../db"; import * as Funbox from "../test/funbox/funbox"; -import { getTags, toggleTagActive } from "../collections/tags"; +import { getTags, toggleTagActive } from "../features/tags"; import * as PresetController from "../controllers/preset-controller"; import * as ThemePicker from "../elements/settings/theme-picker"; import { diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 0471480d7b61..ddd3fc19434e 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -1,7 +1,7 @@ import * as TestWords from "./test-words"; import { Config } from "../config/store"; import * as DB from "../db"; -import { getActiveTagsPB } from "../collections/tags"; +import { getActiveTagsPB } from "../features/tags"; import * as Misc from "../utils/misc"; import * as TestState from "./test-state"; import { configEvent } from "../events/config"; diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 38972475d9bc..d2382a7b327c 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -52,7 +52,7 @@ import { getLocalTagPB, saveLocalTagPB, type TagItem, -} from "../collections/tags"; +} from "../features/tags"; import { Language } from "@monkeytype/schemas/languages"; import { canQuickRestart as canQuickRestartFn } from "../utils/quick-restart"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index f3cf09a5b114..5f0699a6a2ed 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -22,7 +22,7 @@ import * as Caret from "./caret"; import * as TestTimer from "./test-timer"; import * as DB from "../db"; import * as Replay from "./replay"; -import { getActiveTags } from "../collections/tags"; +import { getActiveTags } from "../features/tags"; import * as TodayTracker from "./today-tracker"; import * as ChallengeContoller from "../controllers/challenge-controller"; import { clearQuoteStats } from "../states/quote-rate"; From f69f53255485da8c82f739a9c843fb775a0bf548 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 3 Apr 2026 19:16:39 +0200 Subject: [PATCH 03/19] rewrite --- frontend/src/ts/collections/tags.ts | 402 ++++++++++++++++++ frontend/src/ts/features/tags/active.ts | 64 --- frontend/src/ts/features/tags/index.ts | 14 +- .../src/ts/features/tags/personal-bests.ts | 219 ---------- frontend/src/ts/features/tags/store.ts | 54 --- 5 files changed, 405 insertions(+), 348 deletions(-) create mode 100644 frontend/src/ts/collections/tags.ts delete mode 100644 frontend/src/ts/features/tags/active.ts delete mode 100644 frontend/src/ts/features/tags/personal-bests.ts delete mode 100644 frontend/src/ts/features/tags/store.ts diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts new file mode 100644 index 000000000000..a80bf87494df --- /dev/null +++ b/frontend/src/ts/collections/tags.ts @@ -0,0 +1,402 @@ +import { UserTag } from "@monkeytype/schemas/users"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { createCollection, type WritableDeep } from "@tanstack/solid-db"; +import { z } from "zod"; +import Ape from "../ape"; +import { queryClient } from "../queries"; +import { baseKey } from "../queries/utils/keys"; +import { showErrorNotification } from "../states/notifications"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { IdSchema } from "@monkeytype/schemas/util"; +import { authEvent } from "../events/auth"; +import { SnapshotResult } from "../constants/default-snapshot"; +import { + Mode, + Mode2, + PersonalBest, + PersonalBests, +} from "@monkeytype/schemas/shared"; +import { Difficulty } from "@monkeytype/schemas/configs"; +import { Language } from "@monkeytype/schemas/languages"; + +export type TagItem = UserTag & { active: boolean; display: string }; + +const queryKeys = { + root: () => [...baseKey("tags", { isUserSpecific: true })], +}; + +function toTagItem(tag: UserTag): TagItem { + return { + ...tag, + active: false, + display: tag.name.replaceAll("_", " "), + }; +} + +export const tagsCollection = createCollection( + queryCollectionOptions({ + staleTime: Infinity, + queryKey: queryKeys.root(), + + queryClient, + getKey: (it) => it._id, + queryFn: async () => { + //return empty array. We load the user with the snapshot and fill the collection from there + return [] as TagItem[]; + }, + 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.createTag({ + body: { tagName: it.name }, + }); + if (response.status !== 200) { + showErrorNotification( + `Failed to add tag: ${response.body.message}`, + ); + throw new Error(`Failed to add tag: ${response.body.message}`); + } + return toTagItem(response.body.data); + }), + ); + + tagsCollection.utils.writeBatch(() => { + serverItems.forEach((it) => tagsCollection.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 (id) => { + const response = await Ape.users.deleteTag({ + params: { tagId: id }, + }); + if (response.status !== 200) { + showErrorNotification( + `Failed to delete tag: ${response.body.message}`, + ); + throw new Error(`Failed to delete tag: ${response.body.message}`); + } + }), + ); + + tagsCollection.utils.writeBatch(() => { + ids.forEach((id) => tagsCollection.utils.writeDelete(id)); + }); + return { refetch: false }; + }, + }), +); + +// --- CRUD helpers --- + +export function insertTag(tag: UserTag): void { + tagsCollection.utils.writeBatch(() => { + tagsCollection.utils.writeInsert(toTagItem(tag)); + }); +} + +export function updateTag( + tagId: string, + updater: (tag: WritableDeep) => void, +): void { + tagsCollection.update(tagId, updater); +} + +export function deleteTag(tagId: string): void { + tagsCollection.utils.writeBatch(() => { + tagsCollection.utils.writeDelete(tagId); + }); +} + +export function getTags(): TagItem[] { + return tagsCollection.map((tag) => tag); +} + +export function getTag(id: string): TagItem | undefined { + return tagsCollection.get(id); +} + +export function getActiveTags(): TagItem[] { + return tagsCollection.map((tag) => tag).filter((tag) => tag.active); +} + +export function seedFromUserData(userTags: UserTag[]): void { + const items = userTags + .map(toTagItem) + .sort((a, b) => a.name.localeCompare(b.name)); + + tagsCollection.utils.writeBatch(() => { + items.forEach((it) => tagsCollection.utils.writeInsert(it)); + }); +} + +// --- Active state --- + +const activeTagsLS = new LocalStorageWithSchema({ + key: "activeTags", + schema: z.array(IdSchema), + fallback: [], +}); + +export function saveActiveToLocalStorage(): void { + const activeIds: string[] = []; + tagsCollection.forEach((t) => { + if (t.active) activeIds.push(t._id); + }); + activeTagsLS.set(activeIds); +} + +export function toggleTagActive(tagId: string, nosave = false): void { + tagsCollection.update(tagId, (tag) => { + tag.active = !tag.active; + }); + if (!nosave) saveActiveToLocalStorage(); +} + +export function setTagActive( + tagId: string, + state: boolean, + nosave = false, +): void { + tagsCollection.update(tagId, (tag) => { + tag.active = state; + }); + if (!nosave) saveActiveToLocalStorage(); +} + +export function clearActiveTags(nosave = false): void { + tagsCollection.forEach((tag) => { + if (tag.active) { + tagsCollection.update(tag._id, (t) => { + t.active = false; + }); + } + }); + if (!nosave) saveActiveToLocalStorage(); +} + +function loadActiveFromLocalStorage(): void { + const savedIds = activeTagsLS.get(); + for (const id of savedIds) { + toggleTagActive(id, true); + } + saveActiveToLocalStorage(); +} + +authEvent.subscribe((event) => { + if (event.type === "snapshotUpdated" && event.data.isInitial) { + loadActiveFromLocalStorage(); + } +}); + +// --- Personal bests --- + +export function getLocalTagPB( + tagId: string, + mode: M, + mode2: Mode2, + punctuation: boolean, + numbers: boolean, + language: string, + difficulty: Difficulty, + lazyMode: boolean, +): number { + const tag = getTag(tagId); + if (tag === undefined) return 0; + + const personalBests = (tag.personalBests?.[mode]?.[mode2] ?? + []) as PersonalBest[]; + + return ( + personalBests.find( + (pb) => + (pb.punctuation ?? false) === punctuation && + (pb.numbers ?? false) === numbers && + pb.difficulty === difficulty && + pb.language === language && + (pb.lazyMode === lazyMode || (pb.lazyMode === undefined && !lazyMode)), + )?.wpm ?? 0 + ); +} + +export function saveLocalTagPB( + tagId: string, + mode: M, + mode2: Mode2, + punctuation: boolean, + numbers: boolean, + language: Language, + difficulty: Difficulty, + lazyMode: boolean, + wpm: number, + acc: number, + raw: number, + consistency: number, +): void { + if (mode === "quote") return; + + tagsCollection.update(tagId, (tag) => { + tag.personalBests ??= { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; + + tag.personalBests[mode] ??= { + [mode2]: [], + }; + + tag.personalBests[mode][mode2] ??= + [] as unknown as PersonalBests[M][Mode2]; + + try { + let found = false; + + (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).forEach( + (pb) => { + if ( + (pb.punctuation ?? false) === punctuation && + (pb.numbers ?? false) === numbers && + pb.difficulty === difficulty && + pb.language === language && + (pb.lazyMode === lazyMode || + (pb.lazyMode === undefined && !lazyMode)) + ) { + found = true; + pb.wpm = wpm; + pb.acc = acc; + pb.raw = raw; + pb.timestamp = Date.now(); + pb.consistency = consistency; + pb.lazyMode = lazyMode; + } + }, + ); + if (!found) { + (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).push({ + language, + difficulty, + lazyMode, + punctuation, + numbers, + wpm, + acc, + raw, + timestamp: Date.now(), + consistency, + }); + } + } catch { + tag.personalBests = { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; + tag.personalBests[mode][mode2] = [ + { + language, + difficulty, + lazyMode, + punctuation, + numbers, + wpm, + acc, + raw, + timestamp: Date.now(), + consistency, + }, + ] as unknown as PersonalBests[M][Mode2]; + } + }); +} + +export function updateLocalTagPB( + tagId: string, + mode: M, + mode2: Mode2, + punctuation: boolean, + numbers: boolean, + language: Language, + difficulty: Difficulty, + lazyMode: boolean, + results: SnapshotResult[], +): void { + const tag = getTag(tagId); + if (tag === undefined) return; + + const pb = { + wpm: 0, + acc: 0, + rawWpm: 0, + consistency: 0, + }; + + results.forEach((result) => { + if (result.tags.includes(tagId) && result.wpm > pb.wpm) { + if ( + result.mode === mode && + result.mode2 === mode2 && + result.punctuation === punctuation && + result.numbers === numbers && + result.language === language && + result.difficulty === difficulty && + result.lazyMode === lazyMode + ) { + pb.wpm = result.wpm; + pb.acc = result.acc; + pb.rawWpm = result.rawWpm; + pb.consistency = result.consistency; + } + } + }); + + saveLocalTagPB( + tagId, + mode, + mode2, + punctuation, + numbers, + language, + difficulty, + lazyMode, + pb.wpm, + pb.acc, + pb.rawWpm, + pb.consistency, + ); +} + +export function getActiveTagsPB( + mode: M, + mode2: Mode2, + punctuation: boolean, + numbers: boolean, + language: string, + difficulty: Difficulty, + lazyMode: boolean, +): number { + let tagPbWpm = 0; + for (const tag of getActiveTags()) { + const currTagPB = getLocalTagPB( + tag._id, + mode, + mode2, + punctuation, + numbers, + language, + difficulty, + lazyMode, + ); + if (currTagPB > tagPbWpm) tagPbWpm = currTagPB; + } + return tagPbWpm; +} diff --git a/frontend/src/ts/features/tags/active.ts b/frontend/src/ts/features/tags/active.ts deleted file mode 100644 index 61866c9fa7bb..000000000000 --- a/frontend/src/ts/features/tags/active.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { z } from "zod"; -import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema"; -import { IdSchema } from "@monkeytype/schemas/util"; -import { authEvent } from "../../events/auth"; -import { tags, setTags } from "./store"; -import { produce } from "solid-js/store"; - -const activeTagsLS = new LocalStorageWithSchema({ - key: "activeTags", - schema: z.array(IdSchema), - fallback: [], -}); - -export function saveActiveToLocalStorage(): void { - activeTagsLS.set(tags.filter((t) => t.active).map((t) => t._id)); -} - -export function toggleTagActive(tagId: string, nosave = false): void { - setTags( - (tag) => tag._id === tagId, - produce((tag) => { - tag.active = !tag.active; - }), - ); - if (!nosave) saveActiveToLocalStorage(); -} - -export function setTagActive( - tagId: string, - state: boolean, - nosave = false, -): void { - setTags( - (tag) => tag._id === tagId, - produce((tag) => { - tag.active = state; - }), - ); - if (!nosave) saveActiveToLocalStorage(); -} - -export function clearActiveTags(nosave = false): void { - setTags( - (tag) => tag.active, - produce((tag) => { - tag.active = false; - }), - ); - if (!nosave) saveActiveToLocalStorage(); -} - -function loadActiveFromLocalStorage(): void { - const savedIds = activeTagsLS.get(); - for (const id of savedIds) { - toggleTagActive(id, true); - } - saveActiveToLocalStorage(); -} - -authEvent.subscribe((event) => { - if (event.type === "snapshotUpdated" && event.data.isInitial) { - loadActiveFromLocalStorage(); - } -}); diff --git a/frontend/src/ts/features/tags/index.ts b/frontend/src/ts/features/tags/index.ts index 4d86cc17bbfb..d75237679f02 100644 --- a/frontend/src/ts/features/tags/index.ts +++ b/frontend/src/ts/features/tags/index.ts @@ -1,5 +1,6 @@ -export type { TagItem } from "./store"; +export type { TagItem } from "../../collections/tags"; export { + tagsCollection, seedFromUserData, getTags, getTag, @@ -7,21 +8,12 @@ export { insertTag, updateTag, deleteTag, -} from "./store"; - -export { saveActiveToLocalStorage, toggleTagActive, setTagActive, clearActiveTags, -} from "./active"; - -export { getLocalTagPB, saveLocalTagPB, updateLocalTagPB, getActiveTagsPB, -} from "./personal-bests"; - -// Side-effect: registers authEvent listener for loading active tags from localStorage -import "./active"; +} from "../../collections/tags"; diff --git a/frontend/src/ts/features/tags/personal-bests.ts b/frontend/src/ts/features/tags/personal-bests.ts deleted file mode 100644 index 23e1989d611c..000000000000 --- a/frontend/src/ts/features/tags/personal-bests.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { SnapshotResult } from "../../constants/default-snapshot"; -import { setTags, getTag, getActiveTags } from "./store"; -import { produce } from "solid-js/store"; -import { - Mode, - Mode2, - PersonalBest, - PersonalBests, -} from "@monkeytype/schemas/shared"; -import { Difficulty } from "@monkeytype/schemas/configs"; -import { Language } from "@monkeytype/schemas/languages"; - -export function getLocalTagPB( - tagId: string, - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: string, - difficulty: Difficulty, - lazyMode: boolean, -): number { - const tag = getTag(tagId); - if (tag === undefined) return 0; - - const personalBests = (tag.personalBests?.[mode]?.[mode2] ?? - []) as PersonalBest[]; - - return ( - personalBests.find( - (pb) => - (pb.punctuation ?? false) === punctuation && - (pb.numbers ?? false) === numbers && - pb.difficulty === difficulty && - pb.language === language && - (pb.lazyMode === lazyMode || (pb.lazyMode === undefined && !lazyMode)), - )?.wpm ?? 0 - ); -} - -export function saveLocalTagPB( - tagId: string, - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: Language, - difficulty: Difficulty, - lazyMode: boolean, - wpm: number, - acc: number, - raw: number, - consistency: number, -): void { - if (mode === "quote") return; - - setTags( - (tag) => tag._id === tagId, - produce((tag) => { - tag.personalBests ??= { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - - tag.personalBests[mode] ??= { - [mode2]: [], - }; - - tag.personalBests[mode][mode2] ??= - [] as unknown as PersonalBests[M][Mode2]; - - try { - let found = false; - - (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).forEach( - (pb) => { - if ( - (pb.punctuation ?? false) === punctuation && - (pb.numbers ?? false) === numbers && - pb.difficulty === difficulty && - pb.language === language && - (pb.lazyMode === lazyMode || - (pb.lazyMode === undefined && !lazyMode)) - ) { - found = true; - pb.wpm = wpm; - pb.acc = acc; - pb.raw = raw; - pb.timestamp = Date.now(); - pb.consistency = consistency; - pb.lazyMode = lazyMode; - } - }, - ); - if (!found) { - (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).push({ - language, - difficulty, - lazyMode, - punctuation, - numbers, - wpm, - acc, - raw, - timestamp: Date.now(), - consistency, - }); - } - } catch { - tag.personalBests = { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - tag.personalBests[mode][mode2] = [ - { - language, - difficulty, - lazyMode, - punctuation, - numbers, - wpm, - acc, - raw, - timestamp: Date.now(), - consistency, - }, - ] as unknown as PersonalBests[M][Mode2]; - } - }), - ); -} - -export function updateLocalTagPB( - tagId: string, - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: Language, - difficulty: Difficulty, - lazyMode: boolean, - results: SnapshotResult[], -): void { - const tag = getTag(tagId); - if (tag === undefined) return; - - const pb = { - wpm: 0, - acc: 0, - rawWpm: 0, - consistency: 0, - }; - - results.forEach((result) => { - if (result.tags.includes(tagId) && result.wpm > pb.wpm) { - if ( - result.mode === mode && - result.mode2 === mode2 && - result.punctuation === punctuation && - result.numbers === numbers && - result.language === language && - result.difficulty === difficulty && - result.lazyMode === lazyMode - ) { - pb.wpm = result.wpm; - pb.acc = result.acc; - pb.rawWpm = result.rawWpm; - pb.consistency = result.consistency; - } - } - }); - - saveLocalTagPB( - tagId, - mode, - mode2, - punctuation, - numbers, - language, - difficulty, - lazyMode, - pb.wpm, - pb.acc, - pb.rawWpm, - pb.consistency, - ); -} - -export function getActiveTagsPB( - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: string, - difficulty: Difficulty, - lazyMode: boolean, -): number { - let tagPbWpm = 0; - for (const tag of getActiveTags()) { - const currTagPB = getLocalTagPB( - tag._id, - mode, - mode2, - punctuation, - numbers, - language, - difficulty, - lazyMode, - ); - if (currTagPB > tagPbWpm) tagPbWpm = currTagPB; - } - return tagPbWpm; -} diff --git a/frontend/src/ts/features/tags/store.ts b/frontend/src/ts/features/tags/store.ts deleted file mode 100644 index bffec654b90c..000000000000 --- a/frontend/src/ts/features/tags/store.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { UserTag } from "@monkeytype/schemas/users"; -import { createStore, produce, reconcile } from "solid-js/store"; - -export type TagItem = UserTag & { active: boolean; display: string }; - -export const [tags, setTags] = createStore([]); - -export function seedFromUserData(userTags: UserTag[]): void { - const items: TagItem[] = userTags - .map((tag) => ({ - ...tag, - active: false, - display: tag.name.replaceAll("_", " "), - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - setTags(reconcile(items, { key: "_id", merge: true })); -} - -export function getTags(): TagItem[] { - return tags; -} - -export function getTag(id: string): TagItem | undefined { - return tags.find((tag) => tag._id === id); -} - -export function getActiveTags(): TagItem[] { - return tags.filter((tag) => tag.active); -} - -export function insertTag(tag: UserTag): void { - setTags((prev) => - [ - ...prev, - { - ...tag, - active: false, - display: tag.name.replaceAll("_", " "), - }, - ].sort((a, b) => a.name.localeCompare(b.name)), - ); -} - -export function updateTag( - tagId: string, - updater: (old: TagItem) => void, -): void { - setTags((tag) => tag._id === tagId, produce(updater)); -} - -export function deleteTag(tagId: string): void { - setTags((prev) => prev.filter((tag) => tag._id !== tagId)); -} From 0c887f9c0ed170537478e8e37d4bd86d77fb487e Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 3 Apr 2026 19:21:40 +0200 Subject: [PATCH 04/19] rework --- frontend/src/ts/commandline/lists/tags.ts | 2 +- .../src/ts/controllers/preset-controller.ts | 2 +- frontend/src/ts/db.ts | 2 +- .../src/ts/elements/account/result-filters.ts | 6 +----- frontend/src/ts/elements/modes-notice.ts | 2 +- frontend/src/ts/event-handlers/test.ts | 2 +- frontend/src/ts/features/tags/index.ts | 19 ------------------- frontend/src/ts/modals/edit-preset.ts | 2 +- frontend/src/ts/modals/edit-result-tags.ts | 2 +- frontend/src/ts/modals/edit-tag.ts | 2 +- frontend/src/ts/pages/account.ts | 2 +- frontend/src/ts/pages/settings.ts | 2 +- frontend/src/ts/test/pace-caret.ts | 2 +- frontend/src/ts/test/result.ts | 2 +- frontend/src/ts/test/test-logic.ts | 2 +- pnpm-lock.yaml | 2 +- 16 files changed, 15 insertions(+), 38 deletions(-) delete mode 100644 frontend/src/ts/features/tags/index.ts diff --git a/frontend/src/ts/commandline/lists/tags.ts b/frontend/src/ts/commandline/lists/tags.ts index 818d0b09edbe..e2fb468938f7 100644 --- a/frontend/src/ts/commandline/lists/tags.ts +++ b/frontend/src/ts/commandline/lists/tags.ts @@ -5,7 +5,7 @@ import { getTag, clearActiveTags, toggleTagActive, -} from "../../features/tags"; +} from "../../collections/tags"; import { Config } from "../../config/store"; import * as PaceCaret from "../../test/pace-caret"; import { isAuthenticated } from "../../states/core"; diff --git a/frontend/src/ts/controllers/preset-controller.ts b/frontend/src/ts/controllers/preset-controller.ts index b6661f1886f0..222f0b2768f4 100644 --- a/frontend/src/ts/controllers/preset-controller.ts +++ b/frontend/src/ts/controllers/preset-controller.ts @@ -12,7 +12,7 @@ import { clearActiveTags, setTagActive, saveActiveToLocalStorage, -} from "../features/tags"; +} from "../collections/tags"; import { SnapshotPreset } from "../constants/default-snapshot"; import { saveFullConfigToLocalStorage } from "../config/persistence"; diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index a3675e8c2281..3a06e080c123 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -41,7 +41,7 @@ import { import { XpBreakdown } from "@monkeytype/schemas/results"; import { setXpBarData } from "./states/header"; import { FunboxMetadata } from "@monkeytype/funbox"; -import { getActiveTags, seedFromUserData } from "./features/tags"; +import { getActiveTags, seedFromUserData } from "./collections/tags"; let dbSnapshot: Snapshot | undefined; const firstDayOfTheWeek = getFirstDayOfTheWeek(); diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index 8c3b01c67743..e85232d0570c 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -20,8 +20,7 @@ import { import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema"; import defaultResultFilters from "../../constants/default-result-filters"; import { getAllFunboxes } from "@monkeytype/funbox"; -import { Snapshot } from "../../constants/default-snapshot"; -import { getTags, getActiveTags, getTag } from "../../features/tags"; +import { getTags, getActiveTags, getTag } from "../../collections/tags"; import { LanguageList } from "../../constants/languages"; import { authEvent } from "../../events/auth"; import { sanitize } from "../../utils/sanitize"; @@ -805,9 +804,6 @@ let buttonsAppended = false; export async function appendDropdowns( selectChangeCallback: () => void, ): Promise { - //snapshot at this point is guaranteed to exist - const snapshot = DB.getSnapshot() as Snapshot; - tagDropdownUpdate(); if (buttonsAppended) return; diff --git a/frontend/src/ts/elements/modes-notice.ts b/frontend/src/ts/elements/modes-notice.ts index 3476a2644e18..93d26d8415fc 100644 --- a/frontend/src/ts/elements/modes-notice.ts +++ b/frontend/src/ts/elements/modes-notice.ts @@ -2,7 +2,7 @@ import * as PaceCaret from "../test/pace-caret"; import * as TestState from "../test/test-state"; import * as DB from "../db"; import * as Last10Average from "../elements/last-10-average"; -import { getActiveTags } from "../features/tags"; +import { getActiveTags } from "../collections/tags"; import { Config } from "../config/store"; import * as TestWords from "../test/test-words"; import { configEvent, type ConfigEventKey } from "../events/config"; diff --git a/frontend/src/ts/event-handlers/test.ts b/frontend/src/ts/event-handlers/test.ts index 96a7f8b78c95..6c86716eaab2 100644 --- a/frontend/src/ts/event-handlers/test.ts +++ b/frontend/src/ts/event-handlers/test.ts @@ -1,7 +1,7 @@ import * as Commandline from "../commandline/commandline"; import { Config } from "../config/store"; import * as EditResultTagsModal from "../modals/edit-result-tags"; -import { getTags } from "../features/tags"; +import { getTags } from "../collections/tags"; import * as TestWords from "../test/test-words"; import { showNoticeNotification, diff --git a/frontend/src/ts/features/tags/index.ts b/frontend/src/ts/features/tags/index.ts deleted file mode 100644 index d75237679f02..000000000000 --- a/frontend/src/ts/features/tags/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type { TagItem } from "../../collections/tags"; -export { - tagsCollection, - seedFromUserData, - getTags, - getTag, - getActiveTags, - insertTag, - updateTag, - deleteTag, - saveActiveToLocalStorage, - toggleTagActive, - setTagActive, - clearActiveTags, - getLocalTagPB, - saveLocalTagPB, - updateLocalTagPB, - getActiveTagsPB, -} from "../../collections/tags"; diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index f42674c11367..f430ec09af41 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -1,6 +1,6 @@ import Ape from "../ape"; import * as DB from "../db"; -import { getActiveTags } from "../features/tags"; +import { getActiveTags } from "../collections/tags"; import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; import * as Settings from "../pages/settings"; import { diff --git a/frontend/src/ts/modals/edit-result-tags.ts b/frontend/src/ts/modals/edit-result-tags.ts index bd148cf4bdf8..6da97a3c3bc8 100644 --- a/frontend/src/ts/modals/edit-result-tags.ts +++ b/frontend/src/ts/modals/edit-result-tags.ts @@ -10,7 +10,7 @@ import * as AccountPage from "../pages/account"; import { areUnsortedArraysEqual } from "../utils/arrays"; import * as TestResult from "../test/result"; import AnimatedModal from "../utils/animated-modal"; -import { getTags, getTag, updateLocalTagPB } from "../features/tags"; +import { getTags, getTag, updateLocalTagPB } from "../collections/tags"; type State = { resultId: string; diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 384b910cb72a..240d9a7e444f 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -5,7 +5,7 @@ import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import { SimpleModal, TextInput } from "../elements/simple-modal"; import { TagNameSchema } from "@monkeytype/schemas/users"; import { IsValidResponse } from "../types/validation"; -import { insertTag, updateTag, deleteTag } from "../features/tags"; +import { insertTag, updateTag, deleteTag } from "../collections/tags"; const cleanTagName = (tagName: string): string => tagName.replaceAll(" ", "_"); const tagNameValidation = async (tagName: string): Promise => { diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 7c0e3959f570..39a7deaedf33 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -1,6 +1,6 @@ import * as DB from "../db"; import * as ResultFilters from "../elements/account/result-filters"; -import { getTags, getTag } from "../features/tags"; +import { getTags, getTag } from "../collections/tags"; import * as ChartController from "../controllers/chart-controller"; import { Config } from "../config/store"; diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index d9cb798338cf..2724b9906e20 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -8,7 +8,7 @@ import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; import * as DB from "../db"; import * as Funbox from "../test/funbox/funbox"; -import { getTags, toggleTagActive } from "../features/tags"; +import { getTags, toggleTagActive } from "../collections/tags"; import * as PresetController from "../controllers/preset-controller"; import * as ThemePicker from "../elements/settings/theme-picker"; import { diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index ddd3fc19434e..0471480d7b61 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -1,7 +1,7 @@ import * as TestWords from "./test-words"; import { Config } from "../config/store"; import * as DB from "../db"; -import { getActiveTagsPB } from "../features/tags"; +import { getActiveTagsPB } from "../collections/tags"; import * as Misc from "../utils/misc"; import * as TestState from "./test-state"; import { configEvent } from "../events/config"; diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index d2382a7b327c..38972475d9bc 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -52,7 +52,7 @@ import { getLocalTagPB, saveLocalTagPB, type TagItem, -} from "../features/tags"; +} from "../collections/tags"; import { Language } from "@monkeytype/schemas/languages"; import { canQuickRestart as canQuickRestartFn } from "../utils/quick-restart"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 5f0699a6a2ed..f3cf09a5b114 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -22,7 +22,7 @@ import * as Caret from "./caret"; import * as TestTimer from "./test-timer"; import * as DB from "../db"; import * as Replay from "./replay"; -import { getActiveTags } from "../features/tags"; +import { getActiveTags } from "../collections/tags"; import * as TodayTracker from "./today-tracker"; import * as ChallengeContoller from "../controllers/challenge-controller"; import { clearQuoteStats } from "../states/quote-rate"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd659ee9049b..50d083bb6fe9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12003,7 +12003,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3(supports-color@5.5.0) espree: 10.4.0 globals: 14.0.0 From e5d166b784942e9b3fce3b695a6b81500b4daece Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 3 Apr 2026 19:47:28 +0200 Subject: [PATCH 05/19] rewrite --- frontend/src/ts/collections/tags.ts | 37 ++++++++++++++--------------- frontend/src/ts/modals/edit-tag.ts | 26 ++++++++++---------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index a80bf87494df..47df65df6703 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -5,7 +5,6 @@ import { z } from "zod"; import Ape from "../ape"; import { queryClient } from "../queries"; import { baseKey } from "../queries/utils/keys"; -import { showErrorNotification } from "../states/notifications"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; import { IdSchema } from "@monkeytype/schemas/util"; import { authEvent } from "../events/auth"; @@ -53,9 +52,6 @@ export const tagsCollection = createCollection( body: { tagName: it.name }, }); if (response.status !== 200) { - showErrorNotification( - `Failed to add tag: ${response.body.message}`, - ); throw new Error(`Failed to add tag: ${response.body.message}`); } return toTagItem(response.body.data); @@ -76,9 +72,6 @@ export const tagsCollection = createCollection( params: { tagId: id }, }); if (response.status !== 200) { - showErrorNotification( - `Failed to delete tag: ${response.body.message}`, - ); throw new Error(`Failed to delete tag: ${response.body.message}`); } }), @@ -94,10 +87,15 @@ export const tagsCollection = createCollection( // --- CRUD helpers --- -export function insertTag(tag: UserTag): void { - tagsCollection.utils.writeBatch(() => { - tagsCollection.utils.writeInsert(toTagItem(tag)); +export async function insertTag(tagName: string): Promise { + const transaction = tagsCollection.insert({ + _id: crypto.randomUUID(), + name: tagName, + personalBests: { time: {}, words: {}, quote: {}, zen: {}, custom: {} }, + active: false, + display: tagName.replaceAll("_", " "), }); + await transaction.isPersisted.promise; } export function updateTag( @@ -107,10 +105,9 @@ export function updateTag( tagsCollection.update(tagId, updater); } -export function deleteTag(tagId: string): void { - tagsCollection.utils.writeBatch(() => { - tagsCollection.utils.writeDelete(tagId); - }); +export async function deleteTag(tagId: string): Promise { + const transaction = tagsCollection.delete(tagId); + await transaction.isPersisted.promise; } export function getTags(): TagItem[] { @@ -170,12 +167,14 @@ export function setTagActive( } export function clearActiveTags(nosave = false): void { + const activeIds: string[] = []; tagsCollection.forEach((tag) => { - if (tag.active) { - tagsCollection.update(tag._id, (t) => { - t.active = false; - }); - } + if (tag.active) activeIds.push(tag._id); + }); + tagsCollection.update(activeIds, (tags) => { + tags.forEach((tag) => { + tag.active = false; + }); }); if (!nosave) saveActiveToLocalStorage(); } diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 240d9a7e444f..283e6d8179e1 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -7,6 +7,10 @@ import { TagNameSchema } from "@monkeytype/schemas/users"; import { IsValidResponse } from "../types/validation"; import { insertTag, updateTag, deleteTag } from "../collections/tags"; +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + const cleanTagName = (tagName: string): string => tagName.replaceAll(" ", "_"); const tagNameValidation = async (tagName: string): Promise => { const validationResult = TagNameSchema.safeParse(cleanTagName(tagName)); @@ -29,21 +33,17 @@ const actionModals: Record = { buttonText: "add", execFn: async (_thisPopup, propTagName) => { const tagName = cleanTagName(propTagName); - const response = await Ape.users.createTag({ body: { tagName } }); - if (response.status !== 200) { + try { + await insertTag(tagName); + } catch (e) { return { status: "error", - message: - "Failed to add tag: " + - response.body.message.replace(tagName, propTagName), - notificationOptions: { response }, + message: "Failed to add tag: " + errorMessage(e), }; } - insertTag(response.body.data); void Settings.update(); - return { status: "success", message: `Tag added` }; }, }), @@ -96,19 +96,17 @@ const actionModals: Record = { }, execFn: async (_thisPopup) => { const tagId = _thisPopup.parameters[1] as string; - const response = await Ape.users.deleteTag({ params: { tagId } }); - if (response.status !== 200) { + try { + await deleteTag(tagId); + } catch (e) { return { status: "error", - message: "Failed to remove tag", - notificationOptions: { response }, + message: "Failed to remove tag: " + errorMessage(e), }; } - deleteTag(tagId); DB.removeTagFromResults(tagId); - void Settings.update(); return { status: "success", message: `Tag removed` }; From a873bfd803a5fd576354ff6c718102036905ec57 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 4 Apr 2026 09:45:09 +0200 Subject: [PATCH 06/19] fix loading from ls i think --- frontend/src/ts/collections/tags.ts | 34 ++++++++++------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index 47df65df6703..0085c707338e 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -7,7 +7,6 @@ import { queryClient } from "../queries"; import { baseKey } from "../queries/utils/keys"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; import { IdSchema } from "@monkeytype/schemas/util"; -import { authEvent } from "../events/auth"; import { SnapshotResult } from "../constants/default-snapshot"; import { Mode, @@ -32,16 +31,18 @@ function toTagItem(tag: UserTag): TagItem { }; } +let seedData: TagItem[] = []; + export const tagsCollection = createCollection( queryCollectionOptions({ staleTime: Infinity, + startSync: true, queryKey: queryKeys.root(), queryClient, getKey: (it) => it._id, queryFn: async () => { - //return empty array. We load the user with the snapshot and fill the collection from there - return [] as TagItem[]; + return seedData; }, onInsert: async ({ transaction }) => { const newItems = transaction.mutations.map((m) => m.modified); @@ -123,13 +124,16 @@ export function getActiveTags(): TagItem[] { } export function seedFromUserData(userTags: UserTag[]): void { - const items = userTags - .map(toTagItem) + const activeIds = activeTagsLS.get(); + + seedData = userTags + .map((tag) => ({ + ...toTagItem(tag), + active: activeIds.includes(tag._id), + })) .sort((a, b) => a.name.localeCompare(b.name)); - tagsCollection.utils.writeBatch(() => { - items.forEach((it) => tagsCollection.utils.writeInsert(it)); - }); + void queryClient.invalidateQueries({ queryKey: queryKeys.root() }); } // --- Active state --- @@ -179,20 +183,6 @@ export function clearActiveTags(nosave = false): void { if (!nosave) saveActiveToLocalStorage(); } -function loadActiveFromLocalStorage(): void { - const savedIds = activeTagsLS.get(); - for (const id of savedIds) { - toggleTagActive(id, true); - } - saveActiveToLocalStorage(); -} - -authEvent.subscribe((event) => { - if (event.type === "snapshotUpdated" && event.data.isInitial) { - loadActiveFromLocalStorage(); - } -}); - // --- Personal bests --- export function getLocalTagPB( From ebdd7b58d76d81d3ec0855a1f5cba49b8eff1c77 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 4 Apr 2026 17:38:26 +0200 Subject: [PATCH 07/19] aaa --- frontend/src/ts/collections/tags.ts | 42 ++++++++++++++++++----------- frontend/src/ts/pages/settings.ts | 12 ++++++++- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index 0085c707338e..8bab586e712b 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -1,6 +1,6 @@ import { UserTag } from "@monkeytype/schemas/users"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import { createCollection, type WritableDeep } from "@tanstack/solid-db"; +import { createCollection, useLiveQuery } from "@tanstack/solid-db"; import { z } from "zod"; import Ape from "../ape"; import { queryClient } from "../queries"; @@ -86,6 +86,14 @@ export const tagsCollection = createCollection( }), ); +// oxlint-disable-next-line typescript/explicit-function-return-type +export const useTagsLiveQuery = () => + useLiveQuery((q) => { + return q.from({ + collection: tagsCollection, + }); + }); + // --- CRUD helpers --- export async function insertTag(tagName: string): Promise { @@ -101,9 +109,13 @@ export async function insertTag(tagName: string): Promise { export function updateTag( tagId: string, - updater: (tag: WritableDeep) => void, + updater: (tag: TagItem) => void, ): void { - tagsCollection.update(tagId, updater); + const tag = tagsCollection.get(tagId); + if (tag === undefined) return; + const copy = structuredClone(tag); + updater(copy); + tagsCollection.utils.writeUpdate(copy); } export async function deleteTag(tagId: string): Promise { @@ -153,9 +165,9 @@ export function saveActiveToLocalStorage(): void { } export function toggleTagActive(tagId: string, nosave = false): void { - tagsCollection.update(tagId, (tag) => { - tag.active = !tag.active; - }); + const tag = tagsCollection.get(tagId); + if (tag === undefined) return; + tagsCollection.utils.writeUpdate({ ...tag, active: !tag.active }); if (!nosave) saveActiveToLocalStorage(); } @@ -164,20 +176,18 @@ export function setTagActive( state: boolean, nosave = false, ): void { - tagsCollection.update(tagId, (tag) => { - tag.active = state; - }); + const tag = tagsCollection.get(tagId); + if (tag === undefined) return; + tagsCollection.utils.writeUpdate({ ...tag, active: state }); if (!nosave) saveActiveToLocalStorage(); } export function clearActiveTags(nosave = false): void { - const activeIds: string[] = []; - tagsCollection.forEach((tag) => { - if (tag.active) activeIds.push(tag._id); - }); - tagsCollection.update(activeIds, (tags) => { - tags.forEach((tag) => { - tag.active = false; + tagsCollection.utils.writeBatch(() => { + tagsCollection.forEach((tag) => { + if (tag.active) { + tagsCollection.utils.writeUpdate({ ...tag, active: false }); + } }); }); if (!nosave) saveActiveToLocalStorage(); diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index 2724b9906e20..a14abbf14b6e 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -8,7 +8,11 @@ import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; import * as DB from "../db"; import * as Funbox from "../test/funbox/funbox"; -import { getTags, toggleTagActive } from "../collections/tags"; +import { + getTags, + toggleTagActive, + useTagsLiveQuery, +} from "../collections/tags"; import * as PresetController from "../controllers/preset-controller"; import * as ThemePicker from "../elements/settings/theme-picker"; import { @@ -51,6 +55,8 @@ import { authEvent } from "../events/auth"; import * as FpsLimitSection from "../elements/settings/fps-limit-section"; import { qs, qsa, qsr, onDOMReady } from "../utils/dom"; import { showPopup } from "../modals/simple-modals-base"; +import { createEffectOn } from "../hooks/effects"; +import { createMemo } from "solid-js"; let settingsInitialized = false; @@ -516,6 +522,10 @@ function setActiveFunboxButton(): void { } } +const tagsQuery = useTagsLiveQuery(); +const activeTags = createMemo(() => tagsQuery().filter((tag) => tag.active)); +createEffectOn(activeTags, refreshTagsSettingsSection); + function refreshTagsSettingsSection(): void { if (isAuthenticated() && DB.getSnapshot()) { const tagsEl = qs(".pageSettings .section.tags .tagsList")?.empty(); From e9f8db105a2a2cd7984428b0dbb5b23a1b747f9a Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 17:24:19 +0200 Subject: [PATCH 08/19] parse --- frontend/src/ts/modals/edit-tag.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 6fca3fb52a50..ae7e85f6337d 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -33,8 +33,9 @@ const actionModals: Record = { ], buttonText: "add", execFn: async (_thisPopup, propTagName) => { - const normalized = normalizeName(propTagName); - const tagName = cleanTagName(normalized); + const tagName = TagNameSchema.parse( + cleanTagName(normalizeName(propTagName)), + ); try { await insertTag(tagName); From bdf7c2252e612b18a84367fc0edf73118fce353f Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 17:25:10 +0200 Subject: [PATCH 09/19] fix --- frontend/src/ts/modals/edit-tag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index ae7e85f6337d..d322d12d875c 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -82,7 +82,7 @@ const actionModals: Record = { updateTag(tagId, (tag) => { tag.name = tagName; - tag.display = propTagName.replace(/_/g, " "); + tag.display = tagName.replace(/_/g, " "); }); void Settings.update(); From 665a9a510cfd688087299de86244ab405869e7f8 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 19:39:15 +0200 Subject: [PATCH 10/19] pb saving --- frontend/src/ts/collections/tags.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index 8bab586e712b..672d3507977c 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -41,6 +41,13 @@ export const tagsCollection = createCollection( queryClient, getKey: (it) => it._id, + onUpdate: async ({ transaction }) => { + const updatedItems = transaction.mutations.map((m) => m.modified); + tagsCollection.utils.writeBatch(() => { + updatedItems.forEach((it) => tagsCollection.utils.writeUpdate(it)); + }); + return { refetch: false }; + }, queryFn: async () => { return seedData; }, @@ -124,7 +131,7 @@ export async function deleteTag(tagId: string): Promise { } export function getTags(): TagItem[] { - return tagsCollection.map((tag) => tag); + return [...tagsCollection.values()]; } export function getTag(id: string): TagItem | undefined { @@ -132,7 +139,7 @@ export function getTag(id: string): TagItem | undefined { } export function getActiveTags(): TagItem[] { - return tagsCollection.map((tag) => tag).filter((tag) => tag.active); + return [...tagsCollection.values()].filter((tag) => tag.active); } export function seedFromUserData(userTags: UserTag[]): void { @@ -239,7 +246,13 @@ export function saveLocalTagPB( ): void { if (mode === "quote") return; - tagsCollection.update(tagId, (tag) => { + console.log("aaa"); + console.log(tagsCollection.isReady()); + console.log(tagsCollection.values()); + + // if (!tagsCollection.isReady()) return; + + updateTag(tagId, (tag) => { tag.personalBests ??= { time: {}, words: {}, From 954cc02f2d8fa3b7d7286980a2fe18ef2ca490bb Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 22:51:45 +0200 Subject: [PATCH 11/19] use the collection correctly --- frontend/src/ts/collections/tags.ts | 216 +++++++++++++++++----------- frontend/src/ts/modals/edit-tag.ts | 44 ++---- 2 files changed, 143 insertions(+), 117 deletions(-) diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index 672d3507977c..e97737a23a68 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -33,7 +33,7 @@ function toTagItem(tag: UserTag): TagItem { let seedData: TagItem[] = []; -export const tagsCollection = createCollection( +const tagsCollection = createCollection( queryCollectionOptions({ staleTime: Infinity, startSync: true, @@ -42,10 +42,32 @@ export const tagsCollection = createCollection( queryClient, getKey: (it) => it._id, onUpdate: async ({ transaction }) => { - const updatedItems = transaction.mutations.map((m) => m.modified); - tagsCollection.utils.writeBatch(() => { - updatedItems.forEach((it) => tagsCollection.utils.writeUpdate(it)); - }); + const mutation = transaction.mutations[0]; + if (mutation === undefined) return { refetch: false }; + + const action = (mutation.metadata as Record)?.[ + "action" + ] as string; + + if (action === "updateTagName") { + const response = await Ape.users.editTag({ + body: { + tagId: mutation.key as string, + newName: mutation.modified.name, + }, + }); + if (response.status !== 200) { + throw new Error(`Failed to update tag: ${response.body.message}`); + } + } else if (action === "clearTagPBs") { + const response = await Ape.users.deleteTagPersonalBest({ + params: { tagId: mutation.key as string }, + }); + if (response.status !== 200) { + throw new Error(`Failed to clear tag PBs: ${response.body.message}`); + } + } + return { refetch: false }; }, queryFn: async () => { @@ -114,15 +136,36 @@ export async function insertTag(tagName: string): Promise { await transaction.isPersisted.promise; } -export function updateTag( +export async function updateTagName( tagId: string, - updater: (tag: TagItem) => void, -): void { - const tag = tagsCollection.get(tagId); - if (tag === undefined) return; - const copy = structuredClone(tag); - updater(copy); - tagsCollection.utils.writeUpdate(copy); + newName: string, +): Promise { + const transaction = tagsCollection.update( + tagId, + { metadata: { action: "updateTagName" } }, + (tag) => { + tag.name = newName; + tag.display = newName.replaceAll("_", " "); + }, + ); + await transaction.isPersisted.promise; +} + +export async function clearTagPBs(tagId: string): Promise { + const transaction = tagsCollection.update( + tagId, + { metadata: { action: "clearTagPBs" } }, + (tag) => { + tag.personalBests = { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; + }, + ); + await transaction.isPersisted.promise; } export async function deleteTag(tagId: string): Promise { @@ -246,89 +289,88 @@ export function saveLocalTagPB( ): void { if (mode === "quote") return; - console.log("aaa"); - console.log(tagsCollection.isReady()); - console.log(tagsCollection.values()); + const collectionTag = tagsCollection.get(tagId); + if (collectionTag === undefined) return; + const tag = structuredClone(collectionTag); + + tag.personalBests ??= { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; - // if (!tagsCollection.isReady()) return; + tag.personalBests[mode] ??= { + [mode2]: [], + }; - updateTag(tagId, (tag) => { - tag.personalBests ??= { + tag.personalBests[mode][mode2] ??= + [] as unknown as PersonalBests[M][Mode2]; + + try { + let found = false; + + (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).forEach( + (pb) => { + if ( + (pb.punctuation ?? false) === punctuation && + (pb.numbers ?? false) === numbers && + pb.difficulty === difficulty && + pb.language === language && + (pb.lazyMode === lazyMode || (pb.lazyMode === undefined && !lazyMode)) + ) { + found = true; + pb.wpm = wpm; + pb.acc = acc; + pb.raw = raw; + pb.timestamp = Date.now(); + pb.consistency = consistency; + pb.lazyMode = lazyMode; + } + }, + ); + if (!found) { + (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).push({ + language, + difficulty, + lazyMode, + punctuation, + numbers, + wpm, + acc, + raw, + timestamp: Date.now(), + consistency, + }); + } + } catch { + tag.personalBests = { time: {}, words: {}, quote: {}, zen: {}, custom: {}, }; + tag.personalBests[mode][mode2] = [ + { + language, + difficulty, + lazyMode, + punctuation, + numbers, + wpm, + acc, + raw, + timestamp: Date.now(), + consistency, + }, + ] as unknown as PersonalBests[M][Mode2]; + } - tag.personalBests[mode] ??= { - [mode2]: [], - }; - - tag.personalBests[mode][mode2] ??= - [] as unknown as PersonalBests[M][Mode2]; - - try { - let found = false; - - (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).forEach( - (pb) => { - if ( - (pb.punctuation ?? false) === punctuation && - (pb.numbers ?? false) === numbers && - pb.difficulty === difficulty && - pb.language === language && - (pb.lazyMode === lazyMode || - (pb.lazyMode === undefined && !lazyMode)) - ) { - found = true; - pb.wpm = wpm; - pb.acc = acc; - pb.raw = raw; - pb.timestamp = Date.now(); - pb.consistency = consistency; - pb.lazyMode = lazyMode; - } - }, - ); - if (!found) { - (tag.personalBests[mode][mode2] as unknown as PersonalBest[]).push({ - language, - difficulty, - lazyMode, - punctuation, - numbers, - wpm, - acc, - raw, - timestamp: Date.now(), - consistency, - }); - } - } catch { - tag.personalBests = { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - tag.personalBests[mode][mode2] = [ - { - language, - difficulty, - lazyMode, - punctuation, - numbers, - wpm, - acc, - raw, - timestamp: Date.now(), - consistency, - }, - ] as unknown as PersonalBests[M][Mode2]; - } - }); + // using utils.writeUpdate instead of collection.update because we dont need to send this to the API + // the result saving already updates the tag pb in the db + tagsCollection.utils.writeUpdate(tag); } export function updateLocalTagPB( diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index d322d12d875c..4c41d618de1b 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -1,11 +1,15 @@ -import Ape from "../ape"; import * as DB from "../db"; import * as Settings from "../pages/settings"; import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import { SimpleModal, TextInput } from "../elements/simple-modal"; import { TagNameSchema } from "@monkeytype/schemas/users"; import { IsValidResponse } from "../types/validation"; -import { insertTag, updateTag, deleteTag } from "../collections/tags"; +import { + insertTag, + deleteTag, + updateTagName, + clearTagPBs, +} from "../collections/tags"; import { normalizeName } from "../utils/strings"; function errorMessage(e: unknown): string { @@ -68,23 +72,15 @@ const actionModals: Record = { const tagName = TagNameSchema.parse(normalizeName(propTagName)); const tagId = _thisPopup.parameters[1] as string; - const response = await Ape.users.editTag({ - body: { tagId, newName: tagName }, - }); - - if (response.status !== 200) { + try { + await updateTagName(tagId, tagName); + } catch (e) { return { status: "error", - message: "Failed to edit tag", - notificationOptions: { response }, + message: "Failed to update tag: " + errorMessage(e), }; } - updateTag(tagId, (tag) => { - tag.name = tagName; - tag.display = tagName.replace(/_/g, " "); - }); - void Settings.update(); return { status: "success", message: `Tag updated` }; @@ -124,28 +120,16 @@ const actionModals: Record = { }, execFn: async (_thisPopup) => { const tagId = _thisPopup.parameters[1] as string; - const response = await Ape.users.deleteTagPersonalBest({ - params: { tagId }, - }); - if (response.status !== 200) { + try { + await clearTagPBs(tagId); + } catch (e) { return { status: "error", - message: "Failed to clear tag pb", - notificationOptions: { response }, + message: "Failed to clear tag PBs: " + errorMessage(e), }; } - updateTag(tagId, (tag) => { - tag.personalBests = { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - }); - void Settings.update(); return { status: "success", message: `Tag PB cleared` }; }, From 719deafbe35856efeb9d52d9cd4caee6539c4bbd Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 23:02:19 +0200 Subject: [PATCH 12/19] remove function --- frontend/src/ts/modals/edit-tag.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 4c41d618de1b..2a6a62bb47e0 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -16,7 +16,6 @@ function errorMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } -const cleanTagName = (tagName: string): string => tagName.replaceAll(" ", "_"); const tagNameValidation = async (tagName: string): Promise => { const validationResult = TagNameSchema.safeParse(normalizeName(tagName)); if (validationResult.success) return true; @@ -37,9 +36,7 @@ const actionModals: Record = { ], buttonText: "add", execFn: async (_thisPopup, propTagName) => { - const tagName = TagNameSchema.parse( - cleanTagName(normalizeName(propTagName)), - ); + const tagName = TagNameSchema.parse(normalizeName(propTagName)); try { await insertTag(tagName); From bee2765d82227da7af8e8e6632c9dd134591323e Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 23:03:30 +0200 Subject: [PATCH 13/19] rename --- frontend/src/ts/pages/account.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 39a7deaedf33..23e7134cc135 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -124,9 +124,9 @@ function buildResultRow(result: SnapshotResult): HTMLTableRowElement { if (result.tags !== undefined && result.tags.length > 0) { tagNames = ""; result.tags.forEach((tag) => { - const snaptag = getTag(tag); - if (snaptag !== undefined) { - tagNames += snaptag.display + ", "; + const localTag = getTag(tag); + if (localTag !== undefined) { + tagNames += localTag.display + ", "; } }); tagNames = tagNames.substring(0, tagNames.length - 2); @@ -983,9 +983,9 @@ export function updateTagsForResult(resultId: string, tagIds: string[]): void { if (tagIds.length > 0) { for (const tag of tagIds) { - const snaptag = getTag(tag); - if (snaptag !== undefined) { - tagNames.push(snaptag.display); + const localTag = getTag(tag); + if (localTag !== undefined) { + tagNames.push(localTag.display); } } } From 18e2e9befdd05b6a71ab648a3fe7cd173fad15d7 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 23:04:37 +0200 Subject: [PATCH 14/19] rename --- frontend/src/ts/test/result.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 38972475d9bc..185d6d950883 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -1268,9 +1268,9 @@ export function updateTagsAfterEdit( if (tagIds.length > 0) { for (const tag of tagIds) { - const snaptag = getTag(tag); - if (snaptag !== undefined) { - tagNames.push(snaptag.display); + const localTag = getTag(tag); + if (localTag !== undefined) { + tagNames.push(localTag.display); } } } From 8f5f52e05ab3c1b75f930e5c455feeb21010088e Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 23:11:15 +0200 Subject: [PATCH 15/19] pointless --- frontend/src/ts/pages/account.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 23e7134cc135..77c7925f7694 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -433,8 +433,6 @@ async function fillContent(): Promise { //tags exist const validTags = getTags().map((t) => t._id); - if (validTags === undefined) return; - result.tags.forEach((tag) => { //check if i even need to check tags anymore if (!tagHide) return; From b22bfa4392009e545643595d09dde5dbc68802ae Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 23:21:14 +0200 Subject: [PATCH 16/19] remove ? --- frontend/src/ts/pages/account.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 77c7925f7694..f49d7356b10d 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -437,7 +437,7 @@ async function fillContent(): Promise { //check if i even need to check tags anymore if (!tagHide) return; //check if tag is valid - if (validTags?.includes(tag)) { + if (validTags.includes(tag)) { //tag valid, check if filter is on if (ResultFilters.getFilter("tags", tag)) tagHide = false; } else { From f39e9b937ac68c6dd4852f6da33fff45fe6262d6 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 12 Apr 2026 23:38:37 +0200 Subject: [PATCH 17/19] fix test --- .../controllers/preset-controller.spec.ts | 55 ++++++------------- 1 file changed, 16 insertions(+), 39 deletions(-) diff --git a/frontend/__tests__/controllers/preset-controller.spec.ts b/frontend/__tests__/controllers/preset-controller.spec.ts index baef3596b82f..816e9edab7e3 100644 --- a/frontend/__tests__/controllers/preset-controller.spec.ts +++ b/frontend/__tests__/controllers/preset-controller.spec.ts @@ -9,7 +9,7 @@ import * as ConfigUtils from "../../src/ts/config/utils"; import * as Persistence from "../../src/ts/config/persistence"; import * as Notifications from "../../src/ts/states/notifications"; import * as TestLogic from "../../src/ts/test/test-logic"; -import * as TagController from "../../src/ts/controllers/tag-controller"; +import * as Tags from "../../src/ts/collections/tags"; describe("PresetController", () => { describe("apply", () => { @@ -34,12 +34,9 @@ describe("PresetController", () => { "showSuccessNotification", ); const testRestartMock = vi.spyOn(TestLogic, "restart"); - const tagControllerClearMock = vi.spyOn(TagController, "clear"); - const tagControllerSetMock = vi.spyOn(TagController, "set"); - const tagControllerSaveActiveMock = vi.spyOn( - TagController, - "saveActiveToLocalStorage", - ); + const tagsClearMock = vi.spyOn(Tags, "clearActiveTags"); + const tagsSetMock = vi.spyOn(Tags, "setTagActive"); + const tagsSaveActiveMock = vi.spyOn(Tags, "saveActiveToLocalStorage"); beforeEach(() => { [ @@ -49,9 +46,9 @@ describe("PresetController", () => { configGetConfigChangesMock, notificationAddMock, testRestartMock, - tagControllerClearMock, - tagControllerSetMock, - tagControllerSaveActiveMock, + tagsClearMock, + tagsSetMock, + tagsSaveActiveMock, ].forEach((it) => it.mockClear()); configApplyMock.mockResolvedValue(); @@ -66,7 +63,7 @@ describe("PresetController", () => { //THEN expect(configApplyMock).toHaveBeenCalledWith(preset.config); - expect(tagControllerClearMock).toHaveBeenCalled(); + expect(tagsClearMock).toHaveBeenCalled(); expect(testRestartMock).toHaveBeenCalled(); expect(notificationAddMock).toHaveBeenCalledWith("Preset applied", { durationMs: 2000, @@ -84,20 +81,10 @@ describe("PresetController", () => { await PresetController.apply(preset._id); //THEN - expect(tagControllerClearMock).toHaveBeenCalled(); - expect(tagControllerSetMock).toHaveBeenNthCalledWith( - 1, - "tagOne", - true, - false, - ); - expect(tagControllerSetMock).toHaveBeenNthCalledWith( - 2, - "tagTwo", - true, - false, - ); - expect(tagControllerSaveActiveMock).toHaveBeenCalled(); + expect(tagsClearMock).toHaveBeenCalled(); + expect(tagsSetMock).toHaveBeenNthCalledWith(1, "tagOne", true, false); + expect(tagsSetMock).toHaveBeenNthCalledWith(2, "tagTwo", true, false); + expect(tagsSaveActiveMock).toHaveBeenCalled(); }); it("should ignore unknown preset", async () => { @@ -145,20 +132,10 @@ describe("PresetController", () => { await PresetController.apply(preset._id); //THEN - expect(tagControllerClearMock).toHaveBeenCalled(); - expect(tagControllerSetMock).toHaveBeenNthCalledWith( - 1, - "tagOne", - true, - false, - ); - expect(tagControllerSetMock).toHaveBeenNthCalledWith( - 2, - "tagTwo", - true, - false, - ); - expect(tagControllerSaveActiveMock).toHaveBeenCalled(); + expect(tagsClearMock).toHaveBeenCalled(); + expect(tagsSetMock).toHaveBeenNthCalledWith(1, "tagOne", true, false); + expect(tagsSetMock).toHaveBeenNthCalledWith(2, "tagTwo", true, false); + expect(tagsSaveActiveMock).toHaveBeenCalled(); }); const givenPreset = (partialPreset: Partial): Preset => { From cdc663c0f77fa8fa3bd948f38e1882af8fcd0fb2 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 13 Apr 2026 09:42:10 +0200 Subject: [PATCH 18/19] write directly --- frontend/src/ts/collections/tags.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index e97737a23a68..4f9f93ad6614 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -195,7 +195,14 @@ export function seedFromUserData(userTags: UserTag[]): void { })) .sort((a, b) => a.name.localeCompare(b.name)); - void queryClient.invalidateQueries({ queryKey: queryKeys.root() }); + tagsCollection.utils.writeBatch(() => { + tagsCollection.forEach((tag) => { + tagsCollection.utils.writeDelete(tag._id); + }); + seedData.forEach((item) => { + tagsCollection.utils.writeInsert(item); + }); + }); } // --- Active state --- From ab83433e7e9a95ac106792754d525426131f3a0a Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 13 Apr 2026 09:43:34 +0200 Subject: [PATCH 19/19] update --- frontend/src/ts/controllers/preset-controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/ts/controllers/preset-controller.ts b/frontend/src/ts/controllers/preset-controller.ts index 222f0b2768f4..7475af259362 100644 --- a/frontend/src/ts/controllers/preset-controller.ts +++ b/frontend/src/ts/controllers/preset-controller.ts @@ -15,6 +15,7 @@ import { } from "../collections/tags"; import { SnapshotPreset } from "../constants/default-snapshot"; import { saveFullConfigToLocalStorage } from "../config/persistence"; +import * as ModesNotice from "../elements/modes-notice"; export async function apply(_id: string): Promise { const snapshot = DB.getSnapshot(); @@ -46,6 +47,7 @@ export async function apply(_id: string): Promise { saveActiveToLocalStorage(); } } + void ModesNotice.update(); TestLogic.restart(); showSuccessNotification("Preset applied", { durationMs: 2000 }); saveFullConfigToLocalStorage();