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,