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:
co-authored by
Claude Opus 5.5
parent
612e0a23e4
commit
3fd2b3c2ed
+45
-3
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user