From 3fd2b3c2ed677caa81fa110733366b3f5c41d2f4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 16 Oct 2026 03:00:18 +0200 Subject: [PATCH] feat(dailies): track or stop tracking anything in the strip from Edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip's Edit panel lists each game's chore, then its built-ins, then its events, each with Stop tracking, and Track · N offers everything a game could track and does not. Your own lists every built-in of the games that are on, untracked ones included and marked, so none is off with no way back. Renames Remove from dailies / Add an event to Stop tracking / Track. Co-Authored-By: Claude Opus 5.5 --- src/client/App.tsx | 48 ++++++++++++++++-- src/client/components/EditPanel.tsx | 75 ++++++++++++++++++++++------- src/client/components/YourOwn.tsx | 28 +++++++++-- test/custom-ui.test.tsx | 15 ++++++ test/edit-dailies.test.tsx | 57 +++++++++++++++++++--- 5 files changed, 193 insertions(+), 30 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 57226e3..7c8b5d6 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -25,7 +25,7 @@ import { useProgress, type ProgressMap } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { useTasks } from "./state/useTasks.ts"; import { useAmounts } from "./state/useAmounts.ts"; -import { adoptNewLanes, choreRule, firstRunPrefs, routineRule, usePrefs, type Prefs } from "./state/usePrefs.ts"; +import { adoptNewLanes, choreRule, firstRunPrefs, routineRule, trackRoutinePatch, usePrefs, type Prefs } from "./state/usePrefs.ts"; import { buildExportData, parseImportData, triggerDownload } from "./state/export.ts"; import { snapDayWidth } from "./state/zoom.ts"; import { useCustom, type EventDraft } from "./state/useCustom.ts"; @@ -39,6 +39,7 @@ import { } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; import { + dailiesId, dailyDays as runDays, dailySummary, dayKey, @@ -47,13 +48,14 @@ import { } from "../shared/daily.ts"; import { isoWeekday } from "../shared/period.ts"; import { tasksShown } from "./components/Dailies.tsx"; -import type { StripEditor, StripEvent } from "./components/EditPanel.tsx"; +import type { StripEditor, StripEvent, StripStanding } from "./components/EditPanel.tsx"; import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts"; import { taskStatus, type TaskShape } from "../shared/task.ts"; import { activeRoutines, copyRoutineState, mineFrom, + routinesFor, routineToAsk, type Routine, } from "../shared/routines.ts"; @@ -67,9 +69,11 @@ import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { metaOnTheme, useTheme } from "./state/theme.ts"; import { displayEventFor, + isCustomGameId, recordFor, type LaneId, } from "../shared/custom.ts"; +import type { GameId } from "../shared/schema.ts"; import { metaFor } from "../shared/games.ts"; /** @@ -227,8 +231,11 @@ export function App() { const copiedFrom = new Set( tasks.tasks.flatMap((t) => (t.from === undefined ? [] : [t.from])), ); + // Every built-in the reader could track, for the lists that offer them; the + // strip carries only those not stopped one by one. + const routinesHidden = new Set(prefs.routinesHidden ?? []); const routines = activeRoutines(routineRule(prefs), copiedFrom); - const stripTasks = [...tasks.tasks, ...routines]; + const stripTasks = [...tasks.tasks, ...routines.filter((r) => !routinesHidden.has(r.id))]; /** * Copy a built-in into the reader's own tasks, ticks and numbers included. @@ -506,6 +513,39 @@ export function App() { ...(patch.weekly !== undefined ? { showWeeklyTasks: patch.weekly } : {}), ...(patch.monthly !== undefined ? { showMonthlyTasks: patch.monthly } : {}), }), + standing: (focus === null ? enabled : [focus]).flatMap((game): StripStanding[] => { + if (isCustomGameId(game)) return []; + const rule = routineRule(prefs); + return [ + { + id: dailiesId(game as GameId), + game, + title: gameMeta(game).dailyTasks, + kind: "chore", + tracked: shownFor(choreRule(prefs), game), + }, + // A copy the reader made stands in for its built-in, so the original is + // not offered; the copy is theirs, in Your own. + ...routinesFor(game) + .filter((r) => !copiedFrom.has(r.id)) + .map((r): StripStanding => ({ + id: r.id, + game, + title: r.title, + kind: "routine", + tracked: shownFor(rule, game) && !routinesHidden.has(r.id), + })), + ]; + }), + onTrack: (item, track) => { + if (item.kind === "chore") { + const { on, off } = setFor(choreRule(prefs), item.game, track); + update({ choresOn: on, choresOff: off }); + return; + } + const routine = routinesFor(item.game).find((r) => r.id === item.id); + if (routine !== undefined) update(trackRoutinePatch(prefs, routine, track)); + }, }; const headline = nextToExpire(todo, HEADLINE_DEADLINES); @@ -851,6 +891,8 @@ export function App() { onRemoveTask: tasks.removeTask, routines, onMakeItMine: makeItMine, + routinesHidden, + onTrackRoutine: (routine: Routine, track: boolean) => update(trackRoutinePatch(prefs, routine, track)), now, // The index lists rules; the sheet opens rows. A repeating rule's // own id is never a row — the lists hold its occurrences — so it is diff --git a/src/client/components/EditPanel.tsx b/src/client/components/EditPanel.tsx index be1e679..0e13836 100644 --- a/src/client/components/EditPanel.tsx +++ b/src/client/components/EditPanel.tsx @@ -36,6 +36,19 @@ export interface StripEvent { maxDays: number | null; } +/** + * Something the strip carries on a game's behalf rather than from the feed: its + * standing chore (`dailies:`) or a built-in weekly or monthly task + * (`routine:`). No goal to set — only whether the reader tracks it. + */ +export interface StripStanding { + id: string; + game: LaneId; + title: string; + kind: "chore" | "routine"; + tracked: boolean; +} + /** Which parts of the strip are switched on. */ export interface StripShown { chores: boolean; @@ -58,17 +71,21 @@ export interface StripEditor { /** Whether there is any weekly or monthly task to switch: a switch with nothing behind it is not offered. */ hasTasks: { weekly: boolean; monthly: boolean }; onShown: (patch: Partial) => void; + /** Chores and built-ins of the strip's games, tracked or not; chore first in each game. */ + standing: StripStanding[]; + /** Track one, or stop. App writes the prefs that already decide it — nothing new for a chore. */ + onTrack: (item: StripStanding, track: boolean) => void; } /** - * Events by lane: the reader's order first, then any lane only an event brought + * Items by lane: the reader's order first, then any lane only an item brought * — the rule `dailyGroups` follows — and never re-sorted inside a lane. A lane * with nothing in it is dropped. Pure. */ -export function laneGroups( +export function laneGroups( games: readonly LaneId[], - events: readonly StripEvent[], -): { game: LaneId; events: StripEvent[] }[] { + events: readonly T[], +): { game: LaneId; events: T[] }[] { const lanes: LaneId[] = [...games]; for (const event of events) if (!lanes.includes(event.game)) lanes.push(event.game); return lanes @@ -98,7 +115,7 @@ export function EditPanel({ * all at once buried the strip's own events under a wall of pills. */ const [adding, setAdding] = useState(initialAdding); - const { inStrip, addable, shown, hasTasks } = editor; + const { inStrip, addable, shown, hasTasks, standing } = editor; const add = (event: StripEvent) => { editor.onDaily(event.id, dailyOverride(true, event.detected)); @@ -106,19 +123,40 @@ export function EditPanel({ setOpen(event.id); }; - const groups = laneGroups(games, [...inStrip, ...addable]); + const groups = laneGroups(games, [...standing, ...inStrip, ...addable]); + const untracked = standing.filter((s) => !s.tracked); return (
- {groups.map(({ game, events }) => { + {groups.map(({ game, events: items }) => { const meta = gameMeta(game); - const here = events.filter((e) => inStrip.includes(e)); - const more = events.filter((e) => addable.includes(e)); + const kept = standing.filter((s) => s.game === game && s.tracked); + const here = inStrip.filter((e) => items.includes(e)); + // What the game could track and does not: its chore or built-ins when + // off, then its running events — one list, so "Track" means one thing. + const more: (StripEvent | StripStanding)[] = [ + ...untracked.filter((s) => s.game === game), + ...addable.filter((e) => items.includes(e)), + ]; return (

{meta.name}

+ {/* No goal to open, so the way out sits on the row itself. */} + {kept.map((item) => ( +
+ {item.title} + +
+ ))} {here.map((event) => { const isOpen = open === event.id; return ( @@ -155,8 +193,9 @@ export function EditPanel({ > Open details - {/* The sheet's "not a daily" control and wording: the - goal goes with it, so the two never contradict. */} + {/* The sheet's "not a daily" control: the goal goes + with it, so the two never contradict. Named as the + chore's and built-ins' are, since it does the same. */}
@@ -182,7 +221,7 @@ export function EditPanel({ aria-expanded={adding === game} className="mt-1.5 text-xs text-faint transition-colors hover:text-muted" > - Add an event · {more.length} + Track · {more.length} )} {more.length > 0 && adding === game && ( @@ -191,8 +230,8 @@ export function EditPanel({
  • + {onTrackRoutine !== undefined && ( + + )}

    + {!tracked && "Not tracked · "} {taskCaption(routine)} {routine.note !== undefined && ` · ${routine.note}`}

  • - ))} + ); + })} )} diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index 2f7d1e9..43144c9 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -1337,4 +1337,19 @@ describe("built-in weekly tasks in Your own", () => { expect(html).toContain('href="https://game8.co/games/Zenless-Zone-Zero/archives/484723"'); expect(html).toContain("Make it mine"); }); + + test("each can be tracked or not, and one not tracked stays listed so it can come back", async () => { + const { routinesFor } = await import("../src/shared/routines.ts"); + const noop = () => {}; + const html = render( + ({ removed: true, blockedBy: 0 })} + onAddEvent={noop} onAddTask={noop} onEditTask={noop} onRemoveTask={noop} tasks={[]} + routines={routinesFor("zzz")} onMakeItMine={noop} + routinesHidden={new Set(["routine:zzz:ridu-weekly"])} onTrackRoutine={noop} />, + ); + expect(html).toContain("Ridu Weekly"); + expect(html).toContain("Not tracked"); + expect(html).toContain('aria-label="Track Ridu Weekly"'); + }); }); diff --git a/test/edit-dailies.test.tsx b/test/edit-dailies.test.tsx index 13cd089..38fd06f 100644 --- a/test/edit-dailies.test.tsx +++ b/test/edit-dailies.test.tsx @@ -11,6 +11,7 @@ import { laneGroups, type StripEditor, type StripEvent, + type StripStanding, } from "../src/client/components/EditPanel.tsx"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; import { metaFor } from "../src/shared/games.ts"; @@ -76,6 +77,8 @@ function editor(over: Partial = {}): StripEditor { shown: { chores: true, weekly: true, monthly: true }, hasTasks: { weekly: true, monthly: false }, onShown: () => {}, + standing: [], + onTrack: () => {}, ...over, }; } @@ -169,12 +172,12 @@ describe("EditPanel", () => { // Closed, a game says how many it could add and lists none of them: with // every game on, the open list was a hundred pills deep. const closed = render(); - expect(closed).toMatch(/Add an event · ()?1/); - expect(closed).not.toContain("Add Spiral Abyss to dailies"); + expect(closed).toMatch(/Track · ()?1/); + expect(closed).not.toContain("Track Spiral Abyss"); const open = render( , ); - expect(open).toMatch(/aria-label="Add Spiral Abyss to dailies"/); + expect(open).toMatch(/aria-label="Track Spiral Abyss"/); }); test("a goal this version cannot read is said, not shown as none", () => { @@ -194,7 +197,7 @@ describe("EditPanel", () => { expect(html).toContain("Check-in goal"); expect(html).toContain("Open details"); // Same wording as the sheet, so the reader is told what goes with it. - expect(html).toContain("Remove from dailies — removes the goal"); + expect(html).toContain("Stop tracking — removes the goal"); }); test("the switches are offered only for what exists", () => { @@ -208,8 +211,50 @@ describe("EditPanel", () => { const html = render( , ); - expect(html).not.toContain("to dailies\""); - expect(html).toContain("Every running event is already here"); + expect(html).not.toContain('aria-label="Track'); + expect(html).toContain("Everything running is tracked"); + }); + + const chore: StripStanding = { + id: "dailies:genshin", game: "genshin", title: "Commissions, resin", kind: "chore", tracked: true, + }; + const bosses: StripStanding = { + id: "routine:genshin:weekly-bosses", game: "genshin", title: "Weekly Bosses", kind: "routine", tracked: true, + }; + const bargains: StripStanding = { + id: "routine:genshin:paimons-bargains", game: "genshin", title: "Paimon's Bargains", kind: "routine", tracked: false, + }; + + test("a game's chore and built-ins are listed with its events, each with Stop tracking", () => { + const html = render( + , + ); + // Chore first, then built-ins, then the events — the order the strip draws. + const at = (t: string) => html.indexOf(t); + expect(at("Commissions, resin")).toBeLessThan(at("Weekly Bosses")); + expect(at("Weekly Bosses")).toBeLessThan(at("To Temper Thyself")); + expect(html).toContain('aria-label="Stop tracking Commissions, resin"'); + expect(html).toContain('aria-label="Stop tracking Weekly Bosses"'); + }); + + test("what a game could track and does not is counted under Track, events included", () => { + const ed = editor({ inStrip: [temper], addable: [abyss], standing: [{ ...chore, tracked: false }, bosses, bargains] }); + const closed = render(); + expect(closed).toMatch(/Track · ()?3/); + expect(closed).not.toContain("Everything running is tracked"); + const open = render(); + expect(open).toContain('aria-label="Track Commissions, resin"'); + expect(open).toContain('aria-label="Track Paimon's Bargains"'); + expect(open).toContain('aria-label="Track Spiral Abyss"'); + expect(open).not.toContain('aria-label="Track Weekly Bosses"'); + }); + + test("a game with only standing items still gets its group", () => { + const html = render( + , + ); + expect(html).toContain("Genshin Impact"); + expect(html).not.toContain("Honkai: Star Rail"); }); });