From 9477ac54b61580bd506f7ef97683e1dbb6647216 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 14 Oct 2026 00:30:58 +0200 Subject: [PATCH] feat(dailies): edit the strip in place, and give weekly and monthly tasks their own row Giving Genshin's "To Temper Thyself and Journey Far" its five-of-seven goal meant finding it in the list, opening its sheet, saying it repeats and only then setting the goal. The strip now has an Edit mode beside Catch up that lists its events by game with their goals, opens the sheet's goal control in place, and adds any running event a game at a time. It calls the same setDaily/setGoal the sheet does, so it can store nothing the sheet would not. Weekly and monthly tasks are not due tonight, so they move to a row under the dailies, and each period can be switched off (showWeeklyTasks, showMonthlyTasks; default on, so nothing disappears on upgrade). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 + docs/DATA-MODEL.md | 6 +- docs/DESIGN.md | 10 + docs/PRD.md | 16 +- src/client/App.tsx | 60 +++++- src/client/components/Controls.tsx | 27 +++ src/client/components/Dailies.tsx | 170 ++++++++++++----- src/client/components/EditPanel.tsx | 271 +++++++++++++++++++++++++++ src/client/state/usePrefs.ts | 16 ++ src/shared/goal.ts | 10 + test/controls.test.tsx | 20 ++ test/edit-dailies.test.tsx | 273 ++++++++++++++++++++++++++++ test/prefs.test.ts | 19 ++ 13 files changed, 855 insertions(+), 48 deletions(-) create mode 100644 src/client/components/EditPanel.tsx create mode 100644 test/edit-dailies.test.tsx diff --git a/AGENTS.md b/AGENTS.md index efe1dd0..8e1f8b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -988,6 +988,11 @@ event's length. It adds **no schema field**, so the feed contract is untouched. want five days of seven (`progress.goal`, `src/shared/goal.ts`, PRD F12). A met week stays in the strip as done this week without a tick being invented; only the reader marks the event done. A goal this build cannot read is kept in storage and treated as none — never stripped on load. +- **The strip's Edit panel is the sheet's controls, reached from the strip** (`EditPanel.tsx`). It + calls the same `setDaily` and `setGoal` with the same `dailyOverride` arguments, so it can never + store a combination the sheet would not. Keep it that way: a second way to write `daily` or `goal` + with its own rules is two answers to one question. It is a mode of the section, like Catch up — + never a control inside a chip. ## Events the reader entered themselves diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 54bf393..aedd200 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -221,12 +221,16 @@ Namespaced, versioned, and small. Nothing here ever goes to the server. // focusGame, sort, view, // timelineDayWidth, timelineGroup, showUpcoming, // timelineSplitUpcoming, detectDaily, showChores, - // showCompleted, showIgnored, theme, regionConfirmed, + // showWeeklyTasks, showMonthlyTasks, showCompleted, showIgnored, theme, regionConfirmed, // onboarded, routineGames?, routinesAsked? } // routineGames is the games whose built-in weekly tasks // the reader turned on; routinesAsked the games already // asked about. Both absent means none — built-ins are // opt-in for every install. PRD F17. + // showWeeklyTasks / showMonthlyTasks say whether tasks of + // that period sit in the strip's row under the dailies. + // Both default true, so an install from before them keeps + // its tasks on screen; off hides and never deletes. // changelogSeen is the newest changelog entry id the // reader dismissed. Absent means unrecorded: first-run // setup sets it, so absent is an install from before the diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 254b626..7ca2d12 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -429,6 +429,16 @@ selected tab fills with `raised` and turns ink; unselected tabs are faint and go Off / Each week / Across the event — then a small number field and, for weeks, a weekday select, both on `ground` with a hairline border like the notes field. Once a goal is set, a smaller Days · Points pill pair picks its measure. +- **This week & month row** (dailies strip): weekly and monthly items leave today's chips and sit + under them, after a faint 11px `This week & month` label, in the same pills and game order. +- **Strip Edit panel** (dailies strip, from a faint **Edit** text button beside **Catch up**, which + reads **Done** while open): replaces the chips. A hue-coloured game name per lane; each event in + the strip is a full-width row — title in muted, its goal in words (`5 days each week`) in faint + — that opens the check-in goal control indented behind a hairline left rule, with **Open + details** (muted) and **Remove from dailies** (faint) under it. A faint **Add an event · N** per + game opens that game's running events as dashed hairline pills with a drawn plus; one game open + at a time, because every game's list at once is a hundred pills. A hairline rule, then small + checkboxes for what the strip carries — Daily chores, and Weekly / Monthly tasks when any exist. ### Event row (signature) The unit of the checklist. It is one full-bleed target that opens the event and does nothing else. diff --git a/docs/PRD.md b/docs/PRD.md index 7077bb4..19342a8 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -313,6 +313,13 @@ title — and a goal makes an event a daily even with detection off. event-wide goal reached offers *Mark event done*; the reader decides. - **The target is never lowered.** A short first or last week keeps it and says when too few days are left; an unknown end leaves the days left unknown. +- **Set up from the strip, not just the sheet.** Giving Temper Thyself its goal used to mean finding + it in the list, opening it, saying it repeats and then setting the goal. The strip's **Edit** — + beside *Catch up*, and like it a mode of the section rather than a control on a chip — lists the + strip's events by game with each goal in words; one opens to the sheet's goal control, *Open + details* and *Remove from dailies*. Under each game, *Add an event · N* lists its running events + that are not in the strip yet; adding one opens its goal at once. It writes exactly what the sheet + writes, so nothing about a goal or an override changes meaning. **F13 — Your own games and your own events.** No feasible adapter set covers everyone. Fourteen games were named in the first release thread and @@ -557,9 +564,12 @@ default) or **reach a number** (a target such as 2,100, with an optional unit). - **They roll on the game's clock.** A week starts at the game's daily reset on the chosen day, on the reader's server — the same clock every daily tick is cut on, so nothing new to get wrong. -- **They sit with their game's dailies, and are counted apart.** A task follows its game's chore and - events in the strip, but the header counts it under *Weekly* or *Monthly*, not "today": a chore - due by Sunday is not waiting on anyone every morning, and only today's dailies celebrate. +- **They sit under the dailies, and are counted apart.** Tasks have a row of their own, *This week & + month*, under today's chips — in game order — and the header counts them under *Weekly* or + *Monthly*, not "today": a chore due by Sunday is not waiting on anyone every morning, and only + today's dailies celebrate. **Each period can be switched off** (`showWeeklyTasks`, + `showMonthlyTasks`), in *What you see* and in the strip's Edit panel, offered only once a task of + that period exists. Off hides tasks only — an event with a goal stays — and discards nothing. - **A number is entered, not ticked.** Its chip opens an editor under the strip — the value as the game shows it, not an increment — because a chip is one target. Catch up offers last period's number the way it offers yesterday's tick. diff --git a/src/client/App.tsx b/src/client/App.tsx index bf49f88..549ad79 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -32,7 +32,16 @@ import { resolveFocus, } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; -import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; +import { + dailyDays as runDays, + dailySummary, + dayKey, + isDaily, + resolveDaily, +} 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 { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts"; import { taskStatus, type TaskShape } from "../shared/task.ts"; import { @@ -253,6 +262,21 @@ export function App() { const isIgnored = (id: string) => ignored.marks[id] !== undefined; + /** An event as the strip's Edit panel needs it. */ + const stripEvent = (row: RowEvent): StripEvent => { + const run = runDays(row.clock.startsMs, row.clock.endsMs, prefs.region, row.event.game); + return { + id: row.event.id, + game: row.event.game, + title: row.event.title, + goal: goalOf(row.event.id), + hasStoredGoal: prog.progress[row.event.id]?.goal !== undefined, + detected: prefs.detectDaily && isDaily(row.event), + defaultWeekStartsOn: isoWeekday(dayKey(row.clock.startsMs, prefs.region, row.event.game)), + maxDays: run === null ? null : run.length, + }; + }; + const toggleIgnored = (id: string, title: string) => { const wasIgnored = isIgnored(id); ignored.toggle(id); @@ -423,6 +447,34 @@ export function App() { * them on screen, not keep nagging me about them. */ const todo = outstanding(live, isDone, isIgnored); + + /** + * The strip's Edit panel: the same `daily` and `goal` writes the detail sheet + * makes, offered where the reader is ticking. What can be added is what the + * strip would show if it repeated — running, not done, not ignored. + */ + const stripEditor: StripEditor = { + inStrip: todo.filter(repeatsDaily).map(stripEvent), + addable: todo.filter((r) => !repeatsDaily(r)).map(stripEvent), + onGoal: prog.setGoal, + onDaily: prog.setDaily, + onOpen: setOpenId, + shown: { + chores: prefs.showChores, + weekly: prefs.showWeeklyTasks, + monthly: prefs.showMonthlyTasks, + }, + hasTasks: { + weekly: stripTasks.some((t) => t.period.unit === "week"), + monthly: stripTasks.some((t) => t.period.unit === "month"), + }, + onShown: (patch) => + update({ + ...(patch.chores !== undefined ? { showChores: patch.chores } : {}), + ...(patch.weekly !== undefined ? { showWeeklyTasks: patch.weekly } : {}), + ...(patch.monthly !== undefined ? { showMonthlyTasks: patch.monthly } : {}), + }), + }; const headline = nextToExpire(todo, HEADLINE_DEADLINES); // Counted across every game the reader plays, not just the focused one — a @@ -620,7 +672,11 @@ export function App() { const row = todo.find((r) => r.event.id === id); return row === undefined ? undefined : goalStatusOf(row); }} - tasks={stripTasks} + tasks={tasksShown(stripTasks, { + weekly: prefs.showWeeklyTasks, + monthly: prefs.showMonthlyTasks, + })} + edit={stripEditor} taskStatusFor={taskStatusOf} ask={(() => { const game = routineToAsk( diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index a4ea92d..0ec2846 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -96,6 +96,13 @@ export function Controls({ const shown = games.filter((g) => !prefs.hiddenGames.includes(g)).length; const ownGames = Object.keys(own.games).length; const ownEvents = Object.keys(own.events).length; + // Which periods have a task at all — the reader's own, or a built-in they + // turned on — so a switch is offered only with something behind it. + const periods = [ + ...(own.tasks ?? []).map((t) => t.period.unit), + ...(prefs.routineGames ?? []).flatMap((g) => routinesFor(g).map((r) => r.period.unit)), + ]; + const taskPeriods = { week: periods.includes("week"), month: periods.includes("month") }; return (
+ {/* Where weekly and monthly tasks sit: their own row under the + dailies. Only offered once there is a task of that period — a + switch with nothing behind it is a control that does nothing. */} + {taskPeriods.week && ( + onUpdate({ showWeeklyTasks })} + label="Show weekly tasks under the dailies" + hint="Your own and any built-ins you turned on. Off hides them from the strip and its counts; your ticks and numbers are kept." + /> + )} + {taskPeriods.month && ( + onUpdate({ showMonthlyTasks })} + label="Show monthly tasks under the dailies" + hint="Off hides them from the strip and its counts; your ticks and numbers are kept." + /> + )} + {/* Beside the chores rather than on each game's row, which already carries a handle, two arrows and a switch. Opt-in per game: these are someone else's figures, each dated by its source. */} diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index c108a4a..f03b66a 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -18,6 +18,7 @@ import { asOfLabel } from "../../shared/routines.ts"; import { AmountEditor } from "./AmountEditor.tsx"; import { formatRemaining } from "../../shared/time.ts"; import { DayPip } from "./DayPip.tsx"; +import { EditPanel, type StripEditor } from "./EditPanel.tsx"; import { Fireworks } from "./Fireworks.tsx"; /** @@ -207,6 +208,31 @@ export function dailyGroups( return groups; } +/** + * Tasks the reader has not switched off, by period. Off hides a task from the + * strip and its counts; its ticks and numbers keep their keys untouched. + */ +export function tasksShown>( + tasks: readonly T[], + shown: { weekly: boolean; monthly: boolean }, +): T[] { + return tasks.filter((t) => (t.period.unit === "week" ? shown.weekly : shown.monthly)); +} + +/** + * Today's row and the one under it. Weekly and monthly items are not due + * tonight, so they sit below the dailies rather than among them; an event is + * in today's row whatever its goal, because it is ticked a day at a time. Order + * inside each row is kept, so a game's things stay together. + */ +export function splitByCadence(items: readonly DailyItem[]): { + today: DailyItem[]; + longer: DailyItem[]; +} { + const longer = (i: DailyItem) => i.cadence === "week" || i.cadence === "month"; + return { today: items.filter((i) => !longer(i)), longer: items.filter(longer) }; +} + interface PeriodTally { total: number; complete: number; @@ -278,6 +304,7 @@ export function Dailies({ onSetAmount, amountFor, ask, + edit, }: { /** Every lane the reader is looking at, in their own order. */ games: LaneId[]; @@ -314,6 +341,8 @@ export function Dailies({ onSetAmount?: ((key: string, periodStart: string, value: number) => void) | undefined; /** The number recorded for an item's period, if any. */ amountFor?: ((key: string, periodStart: string) => number | undefined) | undefined; + /** What the Edit panel shows and does; without it there is no Edit. */ + edit?: StripEditor | undefined; }) { const gameMeta = useGameMeta(); /** @@ -324,7 +353,9 @@ export function Dailies({ * reading — "I did Tuesday, let me say so" — rather than a statement about how * the app should work. */ - const [catchUp, setCatchUp] = useState(false); + const [panel, setPanel] = useState<"catchUp" | "edit" | null>(null); + const catchUp = panel === "catchUp"; + const editingStrip = panel === "edit" && edit !== undefined; /** Which amount chip's editor is open, by item key. One at a time. */ const [editing, setEditing] = useState(null); @@ -372,7 +403,23 @@ export function Dailies({ return () => clearTimeout(id); }, [burst]); - if (items.length === 0 && ask === undefined) return null; + // An empty strip used to vanish. With something running that could be added, + // it stays as one line, so the way to add it is where the reader looks. + const canAdd = edit !== undefined && edit.addable.length > 0; + if (items.length === 0 && ask === undefined && !canAdd) return null; + + /** The Edit switch. Beside Catch up, and like it a mode of the section, never a chip's. */ + const editToggle = edit !== undefined && ( + + ); + const editPanel = editingStrip && ; const question = ask === undefined ? null : ( @@ -402,7 +449,22 @@ export function Dailies({ ); if (items.length === 0) { - return
{question}
; + return ( +
+ {question} + {canAdd && ( + <> + {editPanel} +
+

+ {editingStrip ? "Add an event to tick it off here each day." : "Nothing to tick today."} +

+ {editToggle} +
+ + )} +
+ ); } // With mixed reset clocks there is no single "resets in", so the header @@ -411,6 +473,34 @@ export function Dailies({ const mixed = new Set(items.map((i) => i.resetsIn)).size > 1; const open = items.find((i) => i.key === editing && i.goal?.measure === "amount"); + const { today, longer } = splitByCadence(items); + const chip = (item: DailyItem) => ( +
  • + {item.goal?.measure === "amount" ? ( + setEditing(editing === item.key ? null : item.key)} + /> + ) : ( + onToggleDay(item.key, item.today)} + /> + )} +
  • + ); + return (
    {burst > 0 && } @@ -445,45 +535,36 @@ export function Dailies({ onSetAmount={onSetAmount} amountFor={amountFor} /> + ) : editingStrip ? ( + editPanel ) : ( - /* One wrapping row, with each game's chore and its events adjacent. No + /* Wrapping rows, with each game's chore and its events adjacent. No per-game headings here: this is the part of the page answerable in ten seconds, and a heading per game would make it the tallest block on it, pushing "next to expire" — the answer the reader came for — down the page. The hue already says which game a chip belongs to. */ -
      - {items.map((item) => ( -
    • - {item.goal?.measure === "amount" ? ( - setEditing(editing === item.key ? null : item.key)} - /> - ) : ( - onToggleDay(item.key, item.today)} - /> - )} -
    • - ))} -
    + <> +
      + {today.map(chip)} +
    + {/* Not due tonight, so under the dailies rather than among them — + and switchable, in the Edit panel and in settings. */} + {longer.length > 0 && ( + <> +

    + This week & month +

    +
      + {longer.map(chip)} +
    + + )} + )} {/* Under the strip rather than inside the chip: a chip is one target, and a field inside it would be a second one waiting to be mis-tapped. */} - {!catchUp && open?.goal !== undefined && onSetAmount !== undefined && ( + {panel === null && open?.goal !== undefined && onSetAmount !== undefined && (

    - {catchUp + {editingStrip + ? "Add an event, give one a goal, or choose what the strip carries." + : catchUp ? `Tick a day you did but didn't record. The last ${CATCH_UP_DAYS} days, and nothing later than today.` : total === 0 ? week.complete + month.complete === week.total + month.total @@ -514,14 +597,17 @@ export function Dailies({ {/* The way back to a day you already did. It lives on the section rather than on each chip because a chip is a single tick target, and a second control inside one is a mis-tap that costs a streak. */} - +

    + + {editToggle} +
    ); diff --git a/src/client/components/EditPanel.tsx b/src/client/components/EditPanel.tsx new file mode 100644 index 0000000..be1e679 --- /dev/null +++ b/src/client/components/EditPanel.tsx @@ -0,0 +1,271 @@ +import { useState } from "react"; +import type { LaneId } from "../../shared/custom.ts"; +import { dailyOverride } from "../../shared/daily.ts"; +import { goalSummary, type Goal } from "../../shared/goal.ts"; +import { useGameMeta } from "../state/gameMeta.tsx"; +import { GoalControl } from "./GoalControl.tsx"; + +/** + * Editing Today's dailies from the strip. + * + * Giving Genshin's “To Temper Thyself and Journey Far” its five-of-seven goal + * used to mean finding the event in the list, opening its sheet, saying it + * repeats and only then setting the goal. This panel does the same things with + * the same handlers the sheet uses — `progress.daily` and `progress.goal` — + * so nothing here stores anything the sheet would not. + * + * A section-level mode, like Catch up, and never a control inside a chip: a + * chip is one tick target, and a second one inside it is a mis-tap that costs + * a streak. + */ + +/** An event as the panel needs it: what it is, its goal, and what bounds one. */ +export interface StripEvent { + id: string; + game: LaneId; + title: string; + /** The goal, when it is one this build can read. */ + goal: Goal | undefined; + /** Something is stored under `goal`, readable or not. */ + hasStoredGoal: boolean; + /** Detection is on and says this one repeats — what `dailyOverride` compares against. */ + detected: boolean; + /** The weekday of the event's first game-day: its weeks most likely start there. */ + defaultWeekStartsOn: number; + /** Claimable days in the run, or null when the end is unannounced. */ + maxDays: number | null; +} + +/** Which parts of the strip are switched on. */ +export interface StripShown { + chores: boolean; + weekly: boolean; + monthly: boolean; +} + +/** Everything the strip needs to be edited in place. Built by App. */ +export interface StripEditor { + /** Running events already in the strip. */ + inStrip: StripEvent[]; + /** Running events the reader has not finished or ignored that are not in it. */ + addable: StripEvent[]; + onGoal: (id: string, goal: Goal | undefined) => void; + /** `useProgress().setDaily`: the override, and whether the goal goes with it. */ + onDaily: (id: string, daily: boolean | undefined, clearsGoal?: boolean) => void; + /** Open the event's detail sheet. */ + onOpen: (id: string) => void; + shown: StripShown; + /** 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; +} + +/** + * Events by lane: the reader's order first, then any lane only an event brought + * — the rule `dailyGroups` follows — and never re-sorted inside a lane. A lane + * with nothing in it is dropped. Pure. + */ +export function laneGroups( + games: readonly LaneId[], + events: readonly StripEvent[], +): { game: LaneId; events: StripEvent[] }[] { + const lanes: LaneId[] = [...games]; + for (const event of events) if (!lanes.includes(event.game)) lanes.push(event.game); + return lanes + .map((game) => ({ game, events: events.filter((e) => e.game === game) })) + .filter((group) => group.events.length > 0); +} + +export function EditPanel({ + games, + editor, + initialOpen = null, + initialAdding = null, +}: { + games: readonly LaneId[]; + editor: StripEditor; + /** The row open on first render; for tests, which cannot press a button. */ + initialOpen?: string | null; + /** The game whose add list is open on first render; for tests, likewise. */ + initialAdding?: LaneId | null; +}) { + const gameMeta = useGameMeta(); + /** Which event's goal is open, by id. One at a time, as with the amount editor. */ + const [open, setOpen] = useState(initialOpen); + /** + * The game whose add list is showing. Closed by default and one at a time: + * a reader with every game on has a hundred running events, and listing them + * 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 add = (event: StripEvent) => { + editor.onDaily(event.id, dailyOverride(true, event.detected)); + // Straight to its goal: adding one is nearly always so as to give it one. + setOpen(event.id); + }; + + const groups = laneGroups(games, [...inStrip, ...addable]); + + return ( +
    + {groups.map(({ game, events }) => { + const meta = gameMeta(game); + const here = events.filter((e) => inStrip.includes(e)); + const more = events.filter((e) => addable.includes(e)); + return ( +
    +

    + {meta.name} +

    + {here.map((event) => { + const isOpen = open === event.id; + return ( +
    + + {isOpen && ( +
    + editor.onGoal(event.id, goal)} + /> +
    + + {/* The sheet's "not a daily" control and wording: the + goal goes with it, so the two never contradict. */} + +
    +
    + )} +
    + ); + })} + {more.length > 0 && ( + + )} + {more.length > 0 && adding === game && ( +
      + {more.map((event) => ( +
    • + +
    • + ))} +
    + )} +
    + ); + })} + + {addable.length === 0 && ( +

    + Every running event is already here. One that hasn't started joins the list to add once + it opens. +

    + )} + + {/* The same prefs the settings' What you see group writes: one answer, + reachable from where its effect is. */} +
    + editor.onShown({ chores })} + /> + {hasTasks.weekly && ( + editor.onShown({ weekly })} + /> + )} + {hasTasks.monthly && ( + editor.onShown({ monthly })} + /> + )} +
    +
    + ); +} + +function Switch({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index dadff42..126d354 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -168,6 +168,18 @@ export interface Prefs { * here, so switching back on restores every one of them. */ showChores: boolean; + /** + * Whether weekly tasks — the reader's own and any built-ins they turned on — + * sit in the strip's row under the dailies. `showMonthlyTasks` is the same + * for monthly ones. + * + * On by default, for `showChores`' reason: a reader who set tasks up before + * these existed must not find them gone. Off hides only tasks — an event with + * a goal is something the reader engaged with and stays either way — and, + * like the chores, it discards nothing: ticks and numbers keep their keys. + */ + showWeeklyTasks: boolean; + showMonthlyTasks: boolean; showCompleted: boolean; /** Reveal events the reader has ignored, so they can be restored. */ showIgnored: boolean; @@ -236,6 +248,8 @@ export function defaults(): Prefs { timelineSplitUpcoming: true, detectDaily: false, showChores: true, + showWeeklyTasks: true, + showMonthlyTasks: true, showCompleted: true, showIgnored: false, theme: DEFAULT_THEME_CHOICE, @@ -400,6 +414,8 @@ export function restorePrefsValue(incoming: unknown): Prefs { result.timelineSplitUpcoming = typeof result.timelineSplitUpcoming === "boolean" ? result.timelineSplitUpcoming : base.timelineSplitUpcoming; result.detectDaily = typeof result.detectDaily === "boolean" ? result.detectDaily : base.detectDaily; result.showChores = typeof result.showChores === "boolean" ? result.showChores : base.showChores; + result.showWeeklyTasks = typeof result.showWeeklyTasks === "boolean" ? result.showWeeklyTasks : base.showWeeklyTasks; + result.showMonthlyTasks = typeof result.showMonthlyTasks === "boolean" ? result.showMonthlyTasks : base.showMonthlyTasks; result.showCompleted = typeof result.showCompleted === "boolean" ? result.showCompleted : base.showCompleted; result.showIgnored = typeof result.showIgnored === "boolean" ? result.showIgnored : base.showIgnored; result.regionConfirmed = typeof result.regionConfirmed === "boolean" ? result.regionConfirmed : base.regionConfirmed; diff --git a/src/shared/goal.ts b/src/shared/goal.ts index 249ff54..417e855 100644 --- a/src/shared/goal.ts +++ b/src/shared/goal.ts @@ -194,6 +194,16 @@ export function goalCaption(s: GoalStatus): string { return `${head} · ${s.needed} needed · ${days(s.open)} left${left}`; } +/** + * What a goal asks for, in a few words — the Edit panel's line for an event + * whose goal is not open. Says nothing about progress: that is the chip's job. + */ +export function goalSummary(goal: Goal | undefined): string { + if (goal === undefined) return "No goal"; + const what = goal.measure === "days" ? days(goal.required) : `${num(goal.target)} points`; + return `${what} ${goal.period === "week" ? "each week" : "across the event"}`; +} + /** The Dailies chip's short count. */ export function goalCount(s: GoalStatus): string { const unit = s.scope === "week" ? " wk" : s.scope === "month" ? " mo" : ""; diff --git a/test/controls.test.tsx b/test/controls.test.tsx index a7e5b61..a190cc2 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -31,6 +31,8 @@ const PREFS: Prefs = { timelineSplitUpcoming: true, detectDaily: false, showChores: true, + showWeeklyTasks: true, + showMonthlyTasks: true, showCompleted: true, showIgnored: false, theme: "dark", @@ -325,3 +327,21 @@ describe("Controls: the Your own summary counts tasks", () => { expect(ownState(0, 0, 0)).toBe("none yet"); }); }); + +describe("Controls: weekly and monthly tasks under the dailies", () => { + test("each switch appears once there is a task of its period, and reads its own pref", () => { + // ZZZ's built-ins are weekly; there is no monthly task, so no monthly switch. + const prefs = { ...PREFS, routineGames: ["zzz"] }; + const on = render(prefs, 0, ["zzz"]); + expect(on).toContain("Show weekly tasks"); + expect(on).not.toContain("Show monthly tasks"); + const off = render({ ...prefs, showWeeklyTasks: false }, 0, ["zzz"]); + expect(checkboxes(off).filter(Boolean).length).toBe(checkboxes(on).filter(Boolean).length - 1); + }); + + test("with no tasks at all, neither switch is offered", () => { + const html = render(PREFS); + expect(html).not.toContain("Show weekly tasks"); + expect(html).not.toContain("Show monthly tasks"); + }); +}); diff --git a/test/edit-dailies.test.tsx b/test/edit-dailies.test.tsx new file mode 100644 index 0000000..13cd089 --- /dev/null +++ b/test/edit-dailies.test.tsx @@ -0,0 +1,273 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + Dailies, + splitByCadence, + tasksShown, + type DailyItem, +} from "../src/client/components/Dailies.tsx"; +import { + EditPanel, + laneGroups, + type StripEditor, + type StripEvent, +} from "../src/client/components/EditPanel.tsx"; +import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; +import { metaFor } from "../src/shared/games.ts"; +import { goalSummary, type GoalStatus } from "../src/shared/goal.ts"; +import type { Task } from "../src/shared/task.ts"; + +/** + * Editing the dailies strip from the strip itself. + * + * Setting up Genshin's “To Temper Thyself and Journey Far” — five days of seven, + * weeks from Monday — used to mean finding it in the list, opening its sheet, + * saying it repeats, then setting the goal. The strip's Edit panel does the + * same with the same handlers, and weekly and monthly tasks get a row of their + * own under the dailies that the reader can switch off. + */ + +const NOW = Date.parse("2026-10-14T12:00:00.000Z"); + +function render(node: React.ReactElement): string { + return renderToStaticMarkup( + metaFor(id, {})}>{node}, + ); +} + +const temper: StripEvent = { + id: "genshin:to-temper-thyself-and-journey-far:2026-08-10", + game: "genshin", + title: "To Temper Thyself and Journey Far", + goal: { period: "week", measure: "days", required: 5, weekStartsOn: 1 }, + hasStoredGoal: true, + detected: false, + defaultWeekStartsOn: 1, + maxDays: 84, +}; +const login: StripEvent = { + id: "hsr:gift-of-odyssey:2026-10-01", + game: "hsr", + title: "Gift of Odyssey", + goal: undefined, + hasStoredGoal: false, + detected: true, + defaultWeekStartsOn: 3, + maxDays: 7, +}; +const abyss: StripEvent = { + id: "genshin:spiral-abyss:2026-10-01", + game: "genshin", + title: "Spiral Abyss", + goal: undefined, + hasStoredGoal: false, + detected: false, + defaultWeekStartsOn: 3, + maxDays: null, +}; + +function editor(over: Partial = {}): StripEditor { + return { + inStrip: [temper, login], + addable: [abyss], + onGoal: () => {}, + onDaily: () => {}, + onOpen: () => {}, + shown: { chores: true, weekly: true, monthly: true }, + hasTasks: { weekly: true, monthly: false }, + onShown: () => {}, + ...over, + }; +} + +describe("goalSummary", () => { + test("says what each goal asks for in a few words", () => { + expect(goalSummary(undefined)).toBe("No goal"); + expect(goalSummary({ period: "week", measure: "days", required: 5, weekStartsOn: 1 })).toBe( + "5 days each week", + ); + expect(goalSummary({ period: "week", measure: "days", required: 1, weekStartsOn: 1 })).toBe( + "1 day each week", + ); + expect(goalSummary({ period: "event", measure: "days", required: 12 })).toBe( + "12 days across the event", + ); + expect( + goalSummary({ period: "week", measure: "amount", target: 2100, weekStartsOn: 1 }), + ).toBe("2,100 points each week"); + expect(goalSummary({ period: "event", measure: "amount", target: 500 })).toBe( + "500 points across the event", + ); + }); +}); + +describe("laneGroups", () => { + test("the reader's lane order first, then stragglers, never re-sorted inside a lane", () => { + const zzz: StripEvent = { ...abyss, id: "z", game: "zzz", title: "Z" }; + const groups = laneGroups(["hsr", "genshin"], [temper, zzz, login, abyss]); + expect(groups.map((g) => [g.game, g.events.map((e) => e.title)])).toEqual([ + ["hsr", ["Gift of Odyssey"]], + ["genshin", ["To Temper Thyself and Journey Far", "Spiral Abyss"]], + ["zzz", ["Z"]], + ]); + }); + + test("a lane with nothing in it is dropped", () => { + expect(laneGroups(["hsr", "genshin"], [temper]).map((g) => g.game)).toEqual(["genshin"]); + }); +}); + +describe("tasksShown", () => { + const AT = "2026-10-01T00:00:00.000Z"; + const weekly: Task = { + id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points", + period: { unit: "week", startsOn: 1 }, + measure: { kind: "amount", target: 2100, unit: "points" }, at: AT, updatedAt: AT, + }; + const monthly: Task = { + id: "mytask:bbbbbb2222", game: "zzz", title: "Shop reset", + period: { unit: "month", startsOn: 1 }, measure: { kind: "days", required: 1 }, + at: AT, updatedAt: AT, + }; + + test("each switch hides its own period and nothing else", () => { + const all = [weekly, monthly]; + expect(tasksShown(all, { weekly: true, monthly: true })).toEqual(all); + expect(tasksShown(all, { weekly: false, monthly: true })).toEqual([monthly]); + expect(tasksShown(all, { weekly: true, monthly: false })).toEqual([weekly]); + expect(tasksShown(all, { weekly: false, monthly: false })).toEqual([]); + }); +}); + +describe("splitByCadence", () => { + const item = (key: string, cadence: DailyItem["cadence"]): DailyItem => ({ + key, game: "zzz", label: key, title: key, ariaLabel: key, today: "2026-10-14", + resetsIn: 0, notBefore: null, cadence, + }); + + test("today's things stay on top, weekly and monthly go under, order kept", () => { + const items = [item("chore", "day"), item("wk", "week"), item("ev", "event"), + item("mo", "month"), item("ev2", "day")]; + const { today, longer } = splitByCadence(items); + expect(today.map((i) => i.key)).toEqual(["chore", "ev", "ev2"]); + expect(longer.map((i) => i.key)).toEqual(["wk", "mo"]); + }); +}); + +describe("EditPanel", () => { + test("lists the strip's events by game, each with its goal in words", () => { + const html = render(); + expect(html).toContain("To Temper Thyself and Journey Far"); + expect(html).toContain("5 days each week"); + expect(html).toContain("Gift of Odyssey"); + expect(html).toContain("No goal"); + // Genshin's heading comes before Honkai's, as the reader ordered them. + expect(html.indexOf("Genshin Impact")).toBeLessThan(html.indexOf("Honkai: Star Rail")); + }); + + test("offers running events that are not in the strip yet, a game at a time", () => { + // 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"); + const open = render( + , + ); + expect(open).toMatch(/aria-label="Add Spiral Abyss to dailies"/); + }); + + test("a goal this version cannot read is said, not shown as none", () => { + const unread: StripEvent = { ...login, hasStoredGoal: true }; + const html = render( + , + ); + expect(html).toContain("A goal this version can't read"); + expect(html).not.toContain("No goal"); + }); + + test("an open row carries the goal control, the sheet and the way out", () => { + const html = render( + , + ); + 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"); + }); + + test("the switches are offered only for what exists", () => { + const html = render(); + expect(html).toContain("Daily chores"); + expect(html).toContain("Weekly tasks"); + expect(html).not.toContain("Monthly tasks"); + }); + + test("with nothing to add, it says where events come from", () => { + const html = render( + , + ); + expect(html).not.toContain("to dailies\""); + expect(html).toContain("Every running event is already here"); + }); +}); + +describe("the strip with an editor", () => { + const AT = "2026-10-01T00:00:00.000Z"; + const weekly: Task = { + id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points", + period: { unit: "week", startsOn: 1 }, + measure: { kind: "amount", target: 2100, unit: "points" }, at: AT, updatedAt: AT, + }; + const status: GoalStatus = { + scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, count: 0, + met: false, needed: 2100, open: 5, shortfall: false, + }; + + test("offers Edit beside Catch up", () => { + const html = render( + []} onToggleDay={() => {}} edit={editor()} />, + ); + expect(html).toContain("Catch up"); + expect(html).toMatch(/>Edit { + const html = render( + []} onToggleDay={() => {}} tasks={[weekly]} + taskStatusFor={() => status} onSetAmount={() => {}} amountFor={() => undefined} />, + ); + const heading = html.indexOf("This week & month"); + expect(heading).toBeGreaterThan(-1); + expect(html.indexOf("ZZZ")).toBeLessThan(heading); + expect(html.indexOf("Activity points")).toBeGreaterThan(heading); + }); + + test("without weekly or monthly things there is no second row", () => { + const html = render( + []} onToggleDay={() => {}} />, + ); + expect(html).not.toContain("This week & month"); + }); + + test("an empty strip still offers a way to add an event", () => { + const html = render( + []} onToggleDay={() => {}} edit={editor({ inStrip: [] })} />, + ); + expect(html).toMatch(/>Edit { + const html = render( + []} onToggleDay={() => {}} + edit={editor({ inStrip: [], addable: [], shown: { chores: false, weekly: true, monthly: true } })} />, + ); + expect(html).toBe(""); + }); +}); diff --git a/test/prefs.test.ts b/test/prefs.test.ts index ddfc035..bcf57b2 100644 --- a/test/prefs.test.ts +++ b/test/prefs.test.ts @@ -124,6 +124,23 @@ describe("defaults that a reader would notice losing", () => { }); }); +describe("weekly and monthly tasks under the dailies", () => { + test("both are shown until someone says otherwise", () => { + // A reader who set up tasks before these switches existed must not find + // them gone on upgrade. + expect(defaults().showWeeklyTasks).toBe(true); + expect(defaults().showMonthlyTasks).toBe(true); + const stored = { region: "europe", showChores: false } as const; + expect({ ...defaults(), ...stored }.showWeeklyTasks).toBe(true); + }); + + test("a restored value that is not a boolean falls back to shown", () => { + const restored = restorePrefsValue({ showWeeklyTasks: "no", showMonthlyTasks: false }); + expect(restored.showWeeklyTasks).toBe(true); + expect(restored.showMonthlyTasks).toBe(false); + }); +}); + describe("restorePrefsValue", () => { test("returns defaults when passed null or non-object", () => { expect(restorePrefsValue(null)).toEqual(defaults()); @@ -146,6 +163,8 @@ describe("restorePrefsValue", () => { timelineSplitUpcoming: false, detectDaily: true, showChores: false, + showWeeklyTasks: false, + showMonthlyTasks: true, showCompleted: false, showIgnored: true, theme: "light" as const,