feat(dailies): track or stop tracking anything in the strip from Edit

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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-16 03:00:18 +02:00
1 parent 612e0a23e4
commit 3fd2b3c2ed
5 files changed
+193 -30

No files matched your search

+45 -3
View File
@@ -25,7 +25,7 @@ import { useProgress, type ProgressMap } from "./state/useProgress.ts";
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
import { useTasks } from "./state/useTasks.ts"; import { useTasks } from "./state/useTasks.ts";
import { useAmounts } from "./state/useAmounts.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 { buildExportData, parseImportData, triggerDownload } from "./state/export.ts";
import { snapDayWidth } from "./state/zoom.ts"; import { snapDayWidth } from "./state/zoom.ts";
import { useCustom, type EventDraft } from "./state/useCustom.ts"; import { useCustom, type EventDraft } from "./state/useCustom.ts";
@@ -39,6 +39,7 @@ import {
} from "./state/lens.ts"; } from "./state/lens.ts";
import { clockFor, formatRemaining } from "../shared/time.ts"; import { clockFor, formatRemaining } from "../shared/time.ts";
import { import {
dailiesId,
dailyDays as runDays, dailyDays as runDays,
dailySummary, dailySummary,
dayKey, dayKey,
@@ -47,13 +48,14 @@ import {
} from "../shared/daily.ts"; } from "../shared/daily.ts";
import { isoWeekday } from "../shared/period.ts"; import { isoWeekday } from "../shared/period.ts";
import { tasksShown } from "./components/Dailies.tsx"; 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 { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts";
import { taskStatus, type TaskShape } from "../shared/task.ts"; import { taskStatus, type TaskShape } from "../shared/task.ts";
import { import {
activeRoutines, activeRoutines,
copyRoutineState, copyRoutineState,
mineFrom, mineFrom,
routinesFor,
routineToAsk, routineToAsk,
type Routine, type Routine,
} from "../shared/routines.ts"; } from "../shared/routines.ts";
@@ -67,9 +69,11 @@ import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
import { metaOnTheme, useTheme } from "./state/theme.ts"; import { metaOnTheme, useTheme } from "./state/theme.ts";
import { import {
displayEventFor, displayEventFor,
isCustomGameId,
recordFor, recordFor,
type LaneId, type LaneId,
} from "../shared/custom.ts"; } from "../shared/custom.ts";
import type { GameId } from "../shared/schema.ts";
import { metaFor } from "../shared/games.ts"; import { metaFor } from "../shared/games.ts";
/** /**
@@ -227,8 +231,11 @@ export function App() {
const copiedFrom = new Set( const copiedFrom = new Set(
tasks.tasks.flatMap((t) => (t.from === undefined ? [] : [t.from])), 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 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. * 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.weekly !== undefined ? { showWeeklyTasks: patch.weekly } : {}),
...(patch.monthly !== undefined ? { showMonthlyTasks: patch.monthly } : {}), ...(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); const headline = nextToExpire(todo, HEADLINE_DEADLINES);
@@ -851,6 +891,8 @@ export function App() {
onRemoveTask: tasks.removeTask, onRemoveTask: tasks.removeTask,
routines, routines,
onMakeItMine: makeItMine, onMakeItMine: makeItMine,
routinesHidden,
onTrackRoutine: (routine: Routine, track: boolean) => update(trackRoutinePatch(prefs, routine, track)),
now, now,
// The index lists rules; the sheet opens rows. A repeating rule's // 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 // own id is never a row — the lists hold its occurrences — so it is
+57 -18
View File
@@ -36,6 +36,19 @@ export interface StripEvent {
maxDays: number | null; maxDays: number | null;
} }
/**
* Something the strip carries on a game's behalf rather than from the feed: its
* standing chore (`dailies:<game>`) 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. */ /** Which parts of the strip are switched on. */
export interface StripShown { export interface StripShown {
chores: boolean; 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. */ /** Whether there is any weekly or monthly task to switch: a switch with nothing behind it is not offered. */
hasTasks: { weekly: boolean; monthly: boolean }; hasTasks: { weekly: boolean; monthly: boolean };
onShown: (patch: Partial<StripShown>) => void; onShown: (patch: Partial<StripShown>) => 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 * — the rule `dailyGroups` follows — and never re-sorted inside a lane. A lane
* with nothing in it is dropped. Pure. * with nothing in it is dropped. Pure.
*/ */
export function laneGroups( export function laneGroups<T extends { game: LaneId }>(
games: readonly LaneId[], games: readonly LaneId[],
events: readonly StripEvent[], events: readonly T[],
): { game: LaneId; events: StripEvent[] }[] { ): { game: LaneId; events: T[] }[] {
const lanes: LaneId[] = [...games]; const lanes: LaneId[] = [...games];
for (const event of events) if (!lanes.includes(event.game)) lanes.push(event.game); for (const event of events) if (!lanes.includes(event.game)) lanes.push(event.game);
return lanes return lanes
@@ -98,7 +115,7 @@ export function EditPanel({
* all at once buried the strip's own events under a wall of pills. * all at once buried the strip's own events under a wall of pills.
*/ */
const [adding, setAdding] = useState<LaneId | null>(initialAdding); const [adding, setAdding] = useState<LaneId | null>(initialAdding);
const { inStrip, addable, shown, hasTasks } = editor; const { inStrip, addable, shown, hasTasks, standing } = editor;
const add = (event: StripEvent) => { const add = (event: StripEvent) => {
editor.onDaily(event.id, dailyOverride(true, event.detected)); editor.onDaily(event.id, dailyOverride(true, event.detected));
@@ -106,19 +123,40 @@ export function EditPanel({
setOpen(event.id); setOpen(event.id);
}; };
const groups = laneGroups(games, [...inStrip, ...addable]); const groups = laneGroups<StripEvent | StripStanding>(games, [...standing, ...inStrip, ...addable]);
const untracked = standing.filter((s) => !s.tracked);
return ( return (
<div className="relative mt-3 flex flex-col gap-3"> <div className="relative mt-3 flex flex-col gap-3">
{groups.map(({ game, events }) => { {groups.map(({ game, events: items }) => {
const meta = gameMeta(game); const meta = gameMeta(game);
const here = events.filter((e) => inStrip.includes(e)); const kept = standing.filter((s) => s.game === game && s.tracked);
const more = events.filter((e) => addable.includes(e)); 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 ( return (
<div key={game}> <div key={game}>
<p className="text-[0.6875rem] font-semibold" style={{ color: meta.hue }}> <p className="text-[0.6875rem] font-semibold" style={{ color: meta.hue }}>
{meta.name} {meta.name}
</p> </p>
{/* No goal to open, so the way out sits on the row itself. */}
{kept.map((item) => (
<div key={item.id} className="mt-1.5 flex items-baseline justify-between gap-3 text-xs">
<span className="truncate text-muted">{item.title}</span>
<button
type="button"
onClick={() => editor.onTrack(item, false)}
aria-label={`Stop tracking ${item.title}`}
className="shrink-0 text-faint transition-colors hover:text-muted"
>
Stop tracking
</button>
</div>
))}
{here.map((event) => { {here.map((event) => {
const isOpen = open === event.id; const isOpen = open === event.id;
return ( return (
@@ -155,8 +193,9 @@ export function EditPanel({
> >
Open details Open details
</button> </button>
{/* The sheet's "not a daily" control and wording: the {/* The sheet's "not a daily" control: the goal goes
goal goes with it, so the two never contradict. */} with it, so the two never contradict. Named as the
chore's and built-ins' are, since it does the same. */}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -166,8 +205,8 @@ export function EditPanel({
className="text-faint transition-colors hover:text-muted" className="text-faint transition-colors hover:text-muted"
> >
{event.hasStoredGoal {event.hasStoredGoal
? "Remove from dailies — removes the goal" ? "Stop tracking — removes the goal"
: "Remove from dailies"} : "Stop tracking"}
</button> </button>
</div> </div>
</div> </div>
@@ -182,7 +221,7 @@ export function EditPanel({
aria-expanded={adding === game} aria-expanded={adding === game}
className="mt-1.5 text-xs text-faint transition-colors hover:text-muted" className="mt-1.5 text-xs text-faint transition-colors hover:text-muted"
> >
Add an event · {more.length} Track · {more.length}
</button> </button>
)} )}
{more.length > 0 && adding === game && ( {more.length > 0 && adding === game && (
@@ -191,8 +230,8 @@ export function EditPanel({
<li key={event.id}> <li key={event.id}>
<button <button
type="button" type="button"
onClick={() => add(event)} onClick={() => ("kind" in event ? editor.onTrack(event, true) : add(event))}
aria-label={`Add ${event.title} to dailies`} aria-label={`Track ${event.title}`}
className="flex max-w-[15rem] items-center gap-1.5 rounded-full border border-dashed border-hairline px-3 py-1.5 text-xs text-faint transition-colors hover:border-faint hover:text-muted" className="flex max-w-[15rem] items-center gap-1.5 rounded-full border border-dashed border-hairline px-3 py-1.5 text-xs text-faint transition-colors hover:border-faint hover:text-muted"
> >
<svg viewBox="0 0 16 16" aria-hidden className="size-3 shrink-0"> <svg viewBox="0 0 16 16" aria-hidden className="size-3 shrink-0">
@@ -214,9 +253,9 @@ export function EditPanel({
); );
})} })}
{addable.length === 0 && ( {addable.length === 0 && untracked.length === 0 && (
<p className="text-[0.6875rem] leading-relaxed text-faint"> <p className="text-[0.6875rem] leading-relaxed text-faint">
Every running event is already here. One that hasn't started joins the list to add once Everything running is tracked. An event that hasn't started joins the list to track once
it opens. it opens.
</p> </p>
)} )}
+25 -3
View File
@@ -71,6 +71,8 @@ export function YourOwn({
onRemoveTask, onRemoveTask,
routines = [], routines = [],
onMakeItMine, onMakeItMine,
routinesHidden,
onTrackRoutine,
}: { }: {
games: CustomGames; games: CustomGames;
events: CustomEvents; events: CustomEvents;
@@ -92,9 +94,15 @@ export function YourOwn({
onAddTask?: ((draft: TaskDraft) => void) | undefined; onAddTask?: ((draft: TaskDraft) => void) | undefined;
onEditTask?: ((id: string, draft: TaskDraft) => void) | undefined; onEditTask?: ((id: string, draft: TaskDraft) => void) | undefined;
onRemoveTask?: ((id: string) => void) | undefined; onRemoveTask?: ((id: string) => void) | undefined;
/** Built-in weekly tasks the reader has on, and how to copy one into their own. */ /** Built-in tasks of the games the reader has on, and how to copy one into their own. */
routines?: readonly Routine[] | undefined; routines?: readonly Routine[] | undefined;
onMakeItMine?: ((routine: Routine) => void) | undefined; onMakeItMine?: ((routine: Routine) => void) | undefined;
/**
* The ones stopped one by one. Still listed: this is where a reader finds one
* again, and a built-in absent from here would be off with no way back on.
*/
routinesHidden?: ReadonlySet<string> | undefined;
onTrackRoutine?: ((routine: Routine, track: boolean) => void) | undefined;
}) { }) {
const gameMeta = useGameMeta(); const gameMeta = useGameMeta();
const [adding, setAdding] = useState<"game" | "event" | "task" | null>(null); const [adding, setAdding] = useState<"game" | "event" | "task" | null>(null);
@@ -326,7 +334,9 @@ export function YourOwn({
<> <>
<p className="mt-4 text-xs text-faint">Built-in weekly tasks</p> <p className="mt-4 text-xs text-faint">Built-in weekly tasks</p>
<ul className="mt-1.5 flex flex-col gap-1"> <ul className="mt-1.5 flex flex-col gap-1">
{routines.map((routine) => ( {routines.map((routine) => {
const tracked = !(routinesHidden?.has(routine.id) ?? false);
return (
<li key={routine.id}> <li key={routine.id}>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="shrink-0 text-xs text-faint">{gameMeta(routine.game).short}</span> <span className="shrink-0 text-xs text-faint">{gameMeta(routine.game).short}</span>
@@ -346,13 +356,25 @@ export function YourOwn({
> >
Make it mine Make it mine
</button> </button>
{onTrackRoutine !== undefined && (
<button
type="button"
onClick={() => onTrackRoutine(routine, !tracked)}
aria-label={`${tracked ? "Stop tracking" : "Track"} ${routine.title}`}
className="shrink-0 text-xs text-faint transition-colors hover:text-ink"
>
{tracked ? "Stop tracking" : "Track"}
</button>
)}
</div> </div>
<p className="text-xs text-faint"> <p className="text-xs text-faint">
{!tracked && "Not tracked · "}
{taskCaption(routine)} {taskCaption(routine)}
{routine.note !== undefined && ` · ${routine.note}`} {routine.note !== undefined && ` · ${routine.note}`}
</p> </p>
</li> </li>
))} );
})}
</ul> </ul>
</> </>
)} )}
+15
View File
@@ -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('href="https://game8.co/games/Zenless-Zone-Zero/archives/484723"');
expect(html).toContain("Make it mine"); 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(
<YourOwn games={GAMES} events={{}} lanes={["zzz"]} now={0} onOpen={noop}
onAddGame={noop} onEditGame={noop} onRemoveGame={() => ({ 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"');
});
}); });
+51 -6
View File
@@ -11,6 +11,7 @@ import {
laneGroups, laneGroups,
type StripEditor, type StripEditor,
type StripEvent, type StripEvent,
type StripStanding,
} from "../src/client/components/EditPanel.tsx"; } from "../src/client/components/EditPanel.tsx";
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
import { metaFor } from "../src/shared/games.ts"; import { metaFor } from "../src/shared/games.ts";
@@ -76,6 +77,8 @@ function editor(over: Partial<StripEditor> = {}): StripEditor {
shown: { chores: true, weekly: true, monthly: true }, shown: { chores: true, weekly: true, monthly: true },
hasTasks: { weekly: true, monthly: false }, hasTasks: { weekly: true, monthly: false },
onShown: () => {}, onShown: () => {},
standing: [],
onTrack: () => {},
...over, ...over,
}; };
} }
@@ -169,12 +172,12 @@ describe("EditPanel", () => {
// Closed, a game says how many it could add and lists none of them: with // 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. // every game on, the open list was a hundred pills deep.
const closed = render(<EditPanel games={["genshin", "hsr"]} editor={editor()} />); const closed = render(<EditPanel games={["genshin", "hsr"]} editor={editor()} />);
expect(closed).toMatch(/Add an event · (<!-- -->)?1/); expect(closed).toMatch(/Track · (<!-- -->)?1/);
expect(closed).not.toContain("Add Spiral Abyss to dailies"); expect(closed).not.toContain("Track Spiral Abyss");
const open = render( const open = render(
<EditPanel games={["genshin", "hsr"]} editor={editor()} initialAdding="genshin" />, <EditPanel games={["genshin", "hsr"]} editor={editor()} initialAdding="genshin" />,
); );
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", () => { 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("Check-in goal");
expect(html).toContain("Open details"); expect(html).toContain("Open details");
// Same wording as the sheet, so the reader is told what goes with it. // 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", () => { test("the switches are offered only for what exists", () => {
@@ -208,8 +211,50 @@ describe("EditPanel", () => {
const html = render( const html = render(
<EditPanel games={["genshin"]} editor={editor({ inStrip: [temper], addable: [] })} />, <EditPanel games={["genshin"]} editor={editor({ inStrip: [temper], addable: [] })} />,
); );
expect(html).not.toContain("to dailies\""); expect(html).not.toContain('aria-label="Track');
expect(html).toContain("Every running event is already here"); 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(
<EditPanel games={["genshin"]} editor={editor({ inStrip: [temper], addable: [], standing: [chore, bosses] })} />,
);
// 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(<EditPanel games={["genshin"]} editor={ed} />);
expect(closed).toMatch(/Track · (<!-- -->)?3/);
expect(closed).not.toContain("Everything running is tracked");
const open = render(<EditPanel games={["genshin"]} editor={ed} initialAdding="genshin" />);
expect(open).toContain('aria-label="Track Commissions, resin"');
expect(open).toContain('aria-label="Track Paimon&#x27;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(
<EditPanel games={["genshin", "hsr"]} editor={editor({ inStrip: [], addable: [], standing: [chore] })} />,
);
expect(html).toContain("Genshin Impact");
expect(html).not.toContain("Honkai: Star Rail");
}); });
}); });