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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-16 03:00:18 +02:00
co-authored by Claude Opus 5.5
parent 612e0a23e4
commit 3fd2b3c2ed
5 changed files with 193 additions and 30 deletions
+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 { 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
+57 -18
View File
@@ -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:<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. */
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<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
* with nothing in it is dropped. Pure.
*/
export function laneGroups(
export function laneGroups<T extends { game: LaneId }>(
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<LaneId | null>(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<StripEvent | StripStanding>(games, [...standing, ...inStrip, ...addable]);
const untracked = standing.filter((s) => !s.tracked);
return (
<div className="relative mt-3 flex flex-col gap-3">
{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 (
<div key={game}>
<p className="text-[0.6875rem] font-semibold" style={{ color: meta.hue }}>
{meta.name}
</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) => {
const isOpen = open === event.id;
return (
@@ -155,8 +193,9 @@ export function EditPanel({
>
Open details
</button>
{/* 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. */}
<button
type="button"
onClick={() => {
@@ -166,8 +205,8 @@ export function EditPanel({
className="text-faint transition-colors hover:text-muted"
>
{event.hasStoredGoal
? "Remove from dailies — removes the goal"
: "Remove from dailies"}
? "Stop tracking — removes the goal"
: "Stop tracking"}
</button>
</div>
</div>
@@ -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}
</button>
)}
{more.length > 0 && adding === game && (
@@ -191,8 +230,8 @@ export function EditPanel({
<li key={event.id}>
<button
type="button"
onClick={() => add(event)}
aria-label={`Add ${event.title} to dailies`}
onClick={() => ("kind" in event ? editor.onTrack(event, true) : add(event))}
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"
>
<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">
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.
</p>
)}
+25 -3
View File
@@ -71,6 +71,8 @@ export function YourOwn({
onRemoveTask,
routines = [],
onMakeItMine,
routinesHidden,
onTrackRoutine,
}: {
games: CustomGames;
events: CustomEvents;
@@ -92,9 +94,15 @@ export function YourOwn({
onAddTask?: ((draft: TaskDraft) => void) | undefined;
onEditTask?: ((id: string, draft: TaskDraft) => 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;
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 [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>
<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}>
<div className="flex items-baseline gap-2">
<span className="shrink-0 text-xs text-faint">{gameMeta(routine.game).short}</span>
@@ -346,13 +356,25 @@ export function YourOwn({
>
Make it mine
</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>
<p className="text-xs text-faint">
{!tracked && "Not tracked · "}
{taskCaption(routine)}
{routine.note !== undefined && ` · ${routine.note}`}
</p>
</li>
))}
);
})}
</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("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,
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> = {}): 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(<EditPanel games={["genshin", "hsr"]} editor={editor()} />);
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(
<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", () => {
@@ -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(
<EditPanel games={["genshin"]} editor={editor({ inStrip: [temper], addable: [] })} />,
);
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(
<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");
});
});