From a80f581d8b469ac2edec3b9443c5a9e614bb4f79 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 16 Oct 2026 05:25:14 +0200 Subject: [PATCH] feat(tasks): a task can repeat every N days from a dated start A third period, { unit: "cycle", days, from }, cut in day-key space like weeks and months, so each game's per-server reset carries over. Tasks on it are tallied and switched apart (showCycleTasks, on by default), show the days left in their chip, and the lower row's heading now names the periods it holds. Co-Authored-By: Claude Opus 5.5 --- src/client/App.tsx | 4 ++ src/client/components/Controls.tsx | 14 +++++- src/client/components/Dailies.tsx | 67 ++++++++++++++++++++++------- src/client/components/EditPanel.tsx | 12 +++++- src/client/components/TaskForm.tsx | 2 +- src/client/state/usePrefs.ts | 4 ++ src/shared/goal.ts | 8 ++-- src/shared/period.ts | 15 ++++++- src/shared/routines.ts | 10 +---- src/shared/task.ts | 14 +++++- test/controls.test.tsx | 25 ++++++++++- test/edit-dailies.test.tsx | 29 +++++++++---- test/goal.test.ts | 12 ++++++ test/period.test.ts | 35 ++++++++++++++- test/prefs.test.ts | 9 ++++ test/task.test.ts | 24 +++++++++++ test/views.test.tsx | 47 ++++++++++++++++++++ 17 files changed, 286 insertions(+), 45 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 0007b6f..03a4175 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -504,16 +504,19 @@ export function App() { chores: prefs.showChores, weekly: prefs.showWeeklyTasks, monthly: prefs.showMonthlyTasks, + cycle: prefs.showCycleTasks, }, hasTasks: { weekly: stripTasks.some((t) => t.period.unit === "week"), monthly: stripTasks.some((t) => t.period.unit === "month"), + cycle: stripTasks.some((t) => t.period.unit === "cycle"), }, onShown: (patch) => update({ ...(patch.chores !== undefined ? { showChores: patch.chores } : {}), ...(patch.weekly !== undefined ? { showWeeklyTasks: patch.weekly } : {}), ...(patch.monthly !== undefined ? { showMonthlyTasks: patch.monthly } : {}), + ...(patch.cycle !== undefined ? { showCycleTasks: patch.cycle } : {}), }), standing: (focus === null ? enabled : [focus]).flatMap((game): StripStanding[] => { if (isCustomGameId(game)) return []; @@ -749,6 +752,7 @@ export function App() { tasks={tasksShown(stripTasks, { weekly: prefs.showWeeklyTasks, monthly: prefs.showMonthlyTasks, + cycle: prefs.showCycleTasks, })} edit={stripEditor} taskStatusFor={taskStatusOf} diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 7ae4101..9e88b5e 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -114,7 +114,11 @@ export function Controls({ .filter((g) => shownFor(routines, g)) .flatMap((g) => routinesFor(g).filter((r) => offered(r, standardPulls)).map((r) => r.period.unit)), ]; - const taskPeriods = { week: periods.includes("week"), month: periods.includes("month") }; + const taskPeriods = { + week: periods.includes("week"), + month: periods.includes("month"), + cycle: periods.includes("cycle"), + }; return (
)} + {taskPeriods.cycle && ( + onUpdate({ showCycleTasks })} + label="Show cycle tasks under the dailies" + hint="Tasks that repeat every so many days, like a two-week endgame mode. Off hides them from the strip and its counts; your ticks and numbers are kept." + /> + )} {ignoredCount > 0 && ( >( tasks: readonly T[], - shown: { weekly: boolean; monthly: boolean }, + shown: { weekly: boolean; monthly: boolean; cycle: boolean }, ): T[] { - return tasks.filter((t) => (t.period.unit === "week" ? shown.weekly : shown.monthly)); + const on = { week: shown.weekly, month: shown.monthly, cycle: shown.cycle } as const; + return tasks.filter((t) => on[t.period.unit]); } /** @@ -231,7 +232,8 @@ export function splitByCadence(items: readonly DailyItem[]): { today: DailyItem[]; longer: DailyItem[]; } { - const longer = (i: DailyItem) => i.cadence === "week" || i.cadence === "month"; + const longer = (i: DailyItem) => + i.cadence === "week" || i.cadence === "month" || i.cadence === "cycle"; return { today: items.filter((i) => !longer(i)), longer: items.filter(longer) }; } @@ -240,6 +242,25 @@ interface PeriodTally { complete: number; } +type Longer = "week" | "month" | "cycle"; +const LONGER: readonly Longer[] = ["week", "month", "cycle"]; + +/** "week", "week or month", "week, month or cycle": the periods present, in order. */ +function periodList(present: ReadonlySet, conjunction: "&" | "or" | "and"): string { + const names = LONGER.filter((c) => present.has(c)); + const last = names.at(-1) ?? ""; + const head = names.slice(0, -1).join(", "); + return head === "" ? last : `${head} ${conjunction} ${last}`; +} + +/** + * The lower row's heading: the periods it holds, in order — "This week", + * "This week & month", "This week, month & cycle". + */ +export function longerHeading(present: ReadonlySet): string { + return present.size === 0 ? "" : `This ${periodList(present, "&")}`; +} + /** * How much of the strip is finished. A daily item is complete when ticked today * or when its goal is already met — nothing is ticked on its behalf. @@ -256,17 +277,19 @@ export function dailyTally( tickedToday: number; week: PeriodTally; month: PeriodTally; + cycle: PeriodTally; } { let total = 0; let complete = 0; let tickedToday = 0; const week = { total: 0, complete: 0 }; const month = { total: 0, complete: 0 }; + const cycle = { total: 0, complete: 0 }; for (const i of items) { const met = i.goal?.met === true; if (i.cadence === "event") continue; if (i.cadence !== "day") { - const tally = i.cadence === "week" ? week : month; + const tally = i.cadence === "week" ? week : i.cadence === "month" ? month : cycle; tally.total += 1; if (met) tally.complete += 1; continue; @@ -276,7 +299,7 @@ export function dailyTally( if (ticked) tickedToday += 1; if (ticked || met) complete += 1; } - return { total, complete, tickedToday, week, month }; + return { total, complete, tickedToday, week, month, cycle }; } /** The chip's accessible name: today's tick and the goal are separate facts. */ @@ -377,7 +400,7 @@ export function Dailies({ taskStatusFor, ); const items = groups.flatMap((group) => group.items); - const { total, complete, tickedToday, week, month } = dailyTally(items, daysFor); + const { total, complete, tickedToday, week, month, cycle } = dailyTally(items, daysFor); const allDone = total > 0 && complete === total; const [burst, setBurst] = useState(0); @@ -476,6 +499,12 @@ export function Dailies({ const open = items.find((i) => i.key === editing && i.goal?.measure === "amount"); const { today, longer } = splitByCadence(items); + const tallies = { week, month, cycle }; + const present = new Set(LONGER.filter((c) => tallies[c].total > 0)); + const periodCounts = ( + [["Weekly", week], ["Monthly", month], ["Cycles", cycle]] as const + ).filter(([, t]) => t.total > 0); + const longerOpen = LONGER.reduce((n, c) => n + tallies[c].total - tallies[c].complete, 0); const chip = (item: DailyItem) => (
  • {item.goal?.measure === "amount" ? ( @@ -513,11 +542,14 @@ export function Dailies({ {total > 0 ? <>Today's dailies · {complete}/{total} : "Your tasks"} {/* A line of their own: in the rail, three counts on one line wrapped the heading and pushed the reset countdown onto two. */} - {(week.total > 0 || month.total > 0) && ( + {periodCounts.length > 0 && ( - {week.total > 0 && <>Weekly {week.complete}/{week.total}} - {week.total > 0 && month.total > 0 && " · "} - {month.total > 0 && <>Monthly {month.complete}/{month.total}} + {periodCounts.map(([name, t], n) => ( + + {n > 0 && " · "} + {name} {t.complete}/{t.total} + + ))} )} @@ -554,7 +586,7 @@ export function Dailies({ {longer.length > 0 && ( <>

    - This week & month + {longerHeading(new Set(longer.map((i) => i.cadence as Longer)))}

      {longer.map(chip)} @@ -589,9 +621,12 @@ export function Dailies({ : 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 - ? "All done for this week and month." - : `${waiting(week.total + month.total - week.complete - month.complete)} still open this week or month.` + ? longerOpen === 0 + ? // An event's whole-run number is in no period; nothing to name. + present.size === 0 + ? "All done for this week and month." + : `All done for this ${periodList(present, "and")}.` + : `${waiting(longerOpen)} still open this ${periodList(present, "or")}.` : allDone ? "All done. Nothing else expires tonight." : `${waiting(total - complete)} still waiting on you today.`} diff --git a/src/client/components/EditPanel.tsx b/src/client/components/EditPanel.tsx index 0e13836..efdf5f9 100644 --- a/src/client/components/EditPanel.tsx +++ b/src/client/components/EditPanel.tsx @@ -54,6 +54,7 @@ export interface StripShown { chores: boolean; weekly: boolean; monthly: boolean; + cycle: boolean; } /** Everything the strip needs to be edited in place. Built by App. */ @@ -68,8 +69,8 @@ export interface StripEditor { /** 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 }; + /** Whether there is any task of each period to switch: a switch with nothing behind it is not offered. */ + hasTasks: { weekly: boolean; monthly: boolean; cycle: boolean }; onShown: (patch: Partial) => void; /** Chores and built-ins of the strip's games, tracked or not; chore first in each game. */ standing: StripStanding[]; @@ -282,6 +283,13 @@ export function EditPanel({ onChange={(monthly) => editor.onShown({ monthly })} /> )} + {hasTasks.cycle && ( + editor.onShown({ cycle })} + /> + )} ); diff --git a/src/client/components/TaskForm.tsx b/src/client/components/TaskForm.tsx index 4dc4d6d..a341bc9 100644 --- a/src/client/components/TaskForm.tsx +++ b/src/client/components/TaskForm.tsx @@ -122,7 +122,7 @@ export function TaskForm({ const [f, setF] = useState(() => ({ game: initial?.game ?? lanes[0] ?? "", title: initial?.title ?? "", - unit: initial?.period.unit ?? "week", + unit: initial?.period.unit === "month" ? "month" : "week", weekday: initial?.period.unit === "week" ? initial.period.startsOn : 1, monthDay: String(initial?.period.unit === "month" ? initial.period.startsOn : 1), kind: initial?.measure.kind ?? "days", diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 822ec6a..4afc72f 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -189,6 +189,8 @@ export interface Prefs { */ showWeeklyTasks: boolean; showMonthlyTasks: boolean; + /** The same for tasks that repeat every N days (`period.ts`), on by default likewise. */ + showCycleTasks: boolean; showCompleted: boolean; /** Reveal events the reader has ignored, so they can be restored. */ showIgnored: boolean; @@ -317,6 +319,7 @@ export function defaults(): Prefs { showChores: true, showWeeklyTasks: true, showMonthlyTasks: true, + showCycleTasks: true, showCompleted: true, showIgnored: false, theme: DEFAULT_THEME_CHOICE, @@ -490,6 +493,7 @@ export function restorePrefsValue(incoming: unknown): Prefs { 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.showCycleTasks = typeof result.showCycleTasks === "boolean" ? result.showCycleTasks : base.showCycleTasks; 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 417e855..804eb73 100644 --- a/src/shared/goal.ts +++ b/src/shared/goal.ts @@ -72,8 +72,8 @@ export function readGoal(stored: unknown): Goal | undefined { } export interface GoalStatus { - /** Month is a task's period (task.ts); an event's goal is a week or the event. */ - scope: "week" | "month" | "event"; + /** Month and cycle are a task's period (task.ts); an event's goal is a week or the event. */ + scope: "week" | "month" | "cycle" | "event"; measure: "days" | "amount"; /** The period's first game-day: the key an amount is stored under. */ periodStart: string; @@ -172,7 +172,7 @@ export function goalStatus(input: { const days = (n: number) => (n === 1 ? "1 day" : `${n} days`); /** Thousands grouped: 2,100 reads as a number of points, 2100 as a year. */ const num = (n: number) => n.toLocaleString("en-US"); -/** " this week", " this month", or nothing for a whole event. */ +/** " this week", " this month", " this cycle", or nothing for a whole event. */ const within = (s: GoalStatus) => (s.scope === "event" ? "" : ` this ${s.scope}`); const doneLabel = (s: GoalStatus) => (s.scope === "event" ? "goal reached" : `done this ${s.scope}`); @@ -206,6 +206,8 @@ export function goalSummary(goal: Goal | undefined): string { /** The Dailies chip's short count. */ export function goalCount(s: GoalStatus): string { + // "cy" means nothing; the days left in a cycle say where it is. + if (s.scope === "cycle") return `${num(s.count)}/${num(s.required)} · ${s.open ?? 0}d`; const unit = s.scope === "week" ? " wk" : s.scope === "month" ? " mo" : ""; return `${num(s.count)}/${num(s.required)}${unit}`; } diff --git a/src/shared/period.ts b/src/shared/period.ts index b063ec1..5a7ffa0 100644 --- a/src/shared/period.ts +++ b/src/shared/period.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import { DAY } from "./time.ts"; /** - * Periods longer than a day — a week, a month — in day-key space. + * Periods longer than a day — a week, a month, a cycle of N days — in day-key + * space. * * Every key here is a `YYYY-MM-DD` game-day that `dayKey` already cut on a * game's own reset clock for the reader's region. Working on keys rather than @@ -16,10 +17,18 @@ import { DAY } from "./time.ts"; /** ISO weekday: Monday is 1, Sunday is 7. */ export const WeekdaySchema = z.number().int().min(1).max(7); +const DayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + export const PeriodSchema = z.union([ z.object({ unit: z.literal("week"), startsOn: WeekdaySchema }), /** Day of month; 31 means "the last day" in a shorter month. */ z.object({ unit: z.literal("month"), startsOn: z.number().int().min(1).max(31) }), + /** + * Every `days` game-days, counted from `from`, a game-day on which a cycle + * began — any one, not necessarily the first: cycles run both ways from it, + * so an anchor read off this cycle's timer stays right for the ones before. + */ + z.object({ unit: z.literal("cycle"), days: z.number().int().min(2).max(366), from: DayKey }), ]); export type Period = z.infer; @@ -56,6 +65,10 @@ export function periodOf( if (period.unit === "week") { start = addDays(today, -((isoWeekday(today) - period.startsOn + 7) % 7)); next = addDays(start, 7); + } else if (period.unit === "cycle") { + const since = Math.round((msOf(today) - msOf(period.from)) / DAY); + start = addDays(period.from, Math.floor(since / period.days) * period.days); + next = addDays(start, period.days); } else { const t = new Date(msOf(today)); const y = t.getUTCFullYear(); diff --git a/src/shared/routines.ts b/src/shared/routines.ts index eee1206..6f865c1 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -4,6 +4,8 @@ import { GameId } from "./schema.ts"; import { shownFor, type PerGame } from "./perGame.ts"; import { MeasureSchema, type TaskShape } from "./task.ts"; +export { asOfLabel } from "./task.ts"; + /** * Weekly and monthly tasks the app ships for a game — the built-in counterpart of a * reader's own `mytask:` (PRD F17). @@ -549,14 +551,6 @@ export function routineToAsk( return null; } -const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - -/** `2025-12-02` → `2 Dec 2025`: the same in every locale, so it reads the same wherever it is quoted. */ -export function asOfLabel(day: string): string { - const [y, m, d] = day.split("-").map(Number); - return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`; -} - /** * A reader's own task copied from a built-in, remembering where it came from. * The copy is theirs to edit; the original stays hidden while it exists. diff --git a/src/shared/task.ts b/src/shared/task.ts index 76eda6a..9cd7102 100644 --- a/src/shared/task.ts +++ b/src/shared/task.ts @@ -6,7 +6,7 @@ import { periodOf, PeriodSchema } from "./period.ts"; import type { Region } from "./schema.ts"; /** - * Weekly and monthly chores the reader sets up per game — ZZZ's weekly + * Weekly, monthly and every-N-days chores the reader sets up per game — ZZZ's weekly * activity points, Endfield's weekly routine. * * Not events: no window, no end, no done or ignored. A task is a period that @@ -110,12 +110,22 @@ function ordinal(n: number): string { return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`; } +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** `2025-12-02` → `2 Dec 2025`: the same in every locale, so it reads the same wherever it is quoted. */ +export function asOfLabel(day: string): string { + const [y, m, d] = day.split("-").map(Number); + return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`; +} + /** One line saying when it rolls and what it wants. */ export function taskCaption(task: Pick): string { const when = task.period.unit === "week" ? `every week from ${WEEKDAYS[task.period.startsOn - 1]}` - : `every month from the ${ordinal(task.period.startsOn)}`; + : task.period.unit === "month" + ? `every month from the ${ordinal(task.period.startsOn)}` + : `every ${task.period.days} days from ${asOfLabel(task.period.from)}`; const m = task.measure; const what = m.kind === "amount" diff --git a/test/controls.test.tsx b/test/controls.test.tsx index cda0924..4453441 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import type { Task } from "../src/shared/task.ts"; import { renderToStaticMarkup } from "react-dom/server"; import { Controls, ownState, PerGameChips } from "../src/client/components/Controls.tsx"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; @@ -33,6 +34,7 @@ const PREFS: Prefs = { showChores: true, showWeeklyTasks: true, showMonthlyTasks: true, + showCycleTasks: true, showCompleted: true, showIgnored: false, theme: "dark", @@ -40,7 +42,12 @@ const PREFS: Prefs = { onboarded: true, }; -function render(prefs: Prefs, ignoredCount = 0, games = ["genshin", "hsr"]): string { +function render( + prefs: Prefs, + ignoredCount = 0, + games = ["genshin", "hsr"], + tasks?: Task[], +): string { return renderToStaticMarkup( metaFor(id, {})}> {}, onRemoveGame: () => ({ removed: true, blockedBy: 0 }), onAddEvent: () => {}, + ...(tasks === undefined ? {} : { tasks }), }} /> , @@ -415,9 +423,24 @@ describe("Controls: weekly and monthly tasks under the dailies", () => { expect(checkboxes(off).filter(Boolean).length).toBe(checkboxes(on).filter(Boolean).length - 1); }); + test("a cycle task brings its own switch, which reads showCycleTasks", () => { + const AT = "2026-10-01T00:00:00.000Z"; + const shiyu: Task = { + id: "mytask:dddddd4444", game: "hsr", title: "Shiyu Defense", + period: { unit: "cycle", days: 14, from: "2026-10-16" }, measure: { kind: "days", required: 1 }, + at: AT, updatedAt: AT, + }; + const on = render(PREFS, 0, undefined, [shiyu]); + expect(on).toContain("Show cycle tasks"); + expect(on).not.toContain("Show weekly tasks"); + const off = render({ ...PREFS, showCycleTasks: false }, 0, undefined, [shiyu]); + 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"); + expect(html).not.toContain("Show cycle tasks"); }); }); diff --git a/test/edit-dailies.test.tsx b/test/edit-dailies.test.tsx index 38fd06f..fd7865e 100644 --- a/test/edit-dailies.test.tsx +++ b/test/edit-dailies.test.tsx @@ -74,8 +74,8 @@ function editor(over: Partial = {}): StripEditor { onGoal: () => {}, onDaily: () => {}, onOpen: () => {}, - shown: { chores: true, weekly: true, monthly: true }, - hasTasks: { weekly: true, monthly: false }, + shown: { chores: true, weekly: true, monthly: true, cycle: true }, + hasTasks: { weekly: true, monthly: false, cycle: false }, onShown: () => {}, standing: [], onTrack: () => {}, @@ -135,10 +135,11 @@ describe("tasksShown", () => { 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([]); + const on = { cycle: true }; + expect(tasksShown(all, { weekly: true, monthly: true, ...on })).toEqual(all); + expect(tasksShown(all, { weekly: false, monthly: true, ...on })).toEqual([monthly]); + expect(tasksShown(all, { weekly: true, monthly: false, ...on })).toEqual([weekly]); + expect(tasksShown(all, { weekly: false, monthly: false, ...on })).toEqual([]); }); }); @@ -205,6 +206,15 @@ describe("EditPanel", () => { expect(html).toContain("Daily chores"); expect(html).toContain("Weekly tasks"); expect(html).not.toContain("Monthly tasks"); + expect(html).not.toContain("Cycle tasks"); + }); + + test("a cycle task brings the cycle switch", () => { + const html = render( + , + ); + expect(html).toContain("Cycle tasks"); + expect(html).not.toContain("Weekly tasks"); }); test("with nothing to add, it says where events come from", () => { @@ -285,7 +295,8 @@ describe("the strip with an editor", () => { daysFor={() => []} onToggleDay={() => {}} tasks={[weekly]} taskStatusFor={() => status} onSetAmount={() => {}} amountFor={() => undefined} />, ); - const heading = html.indexOf("This week & month"); + // The heading names the periods the row holds: only a week here. + const heading = html.indexOf(">This week<"); expect(heading).toBeGreaterThan(-1); expect(html.indexOf("ZZZ")).toBeLessThan(heading); expect(html.indexOf("Activity points")).toBeGreaterThan(heading); @@ -296,7 +307,7 @@ describe("the strip with an editor", () => { []} onToggleDay={() => {}} />, ); - expect(html).not.toContain("This week & month"); + expect(html).not.toContain(">This week"); }); test("an empty strip still offers a way to add an event", () => { @@ -311,7 +322,7 @@ describe("the strip with an editor", () => { const html = render( []} onToggleDay={() => {}} - edit={editor({ inStrip: [], addable: [], shown: { chores: false, weekly: true, monthly: true } })} />, + edit={editor({ inStrip: [], addable: [], shown: { chores: false, weekly: true, monthly: true, cycle: true } })} />, ); expect(html).toBe(""); }); diff --git a/test/goal.test.ts b/test/goal.test.ts index 5f2f3ee..1584d30 100644 --- a/test/goal.test.ts +++ b/test/goal.test.ts @@ -279,3 +279,15 @@ describe("copy for amounts and months", () => { expect(goalRequired({ period: "event", measure: "amount", target: 2100 })).toBe(2100); }); }); + +describe("a cycle's labels", () => { + test("it reads 'this cycle', and its short count is the days left", () => { + const s: GoalStatus = { + scope: "cycle", measure: "days", periodStart: "2026-10-16", required: 1, count: 0, + met: false, needed: 1, open: 9, shortfall: false, + }; + expect(goalCount(s)).toBe("0/1 · 9d"); + expect(goalLabel(s)).toBe("0 of 1 this cycle"); + expect(goalLabel({ ...s, count: 1, met: true, needed: 0 })).toBe("done this cycle"); + }); +}); diff --git a/test/period.test.ts b/test/period.test.ts index b62f0a8..25b76c4 100644 --- a/test/period.test.ts +++ b/test/period.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { dayKey, nextResetMs } from "../src/shared/daily.ts"; -import { addDays, isoWeekday, periodOf } from "../src/shared/period.ts"; +import { addDays, isoWeekday, periodOf, PeriodSchema } from "../src/shared/period.ts"; describe("isoWeekday", () => { test("Monday is 1 and Sunday is 7", () => { @@ -92,3 +92,36 @@ describe("periodOf: months", () => { expect(periodOf(month(30), "2027-02-28").start).toBe("2027-02-28"); }); }); + +describe("periodOf: cycles", () => { + const cycle = (days: number, from: string) => ({ unit: "cycle" as const, days, from }); + + test("the anchor starts its own cycle, which holds `days` keys", () => { + const p = periodOf(cycle(14, "2026-10-16"), "2026-10-16"); + expect(p.start).toBe("2026-10-16"); + expect(p.days.length).toBe(14); + expect(p.days.at(-1)).toBe("2026-10-29"); + }); + + test("the last day is in it; the day after starts the next", () => { + expect(periodOf(cycle(14, "2026-10-16"), "2026-10-29").start).toBe("2026-10-16"); + expect(periodOf(cycle(14, "2026-10-16"), "2026-10-30").start).toBe("2026-10-30"); + }); + + test("a day before the anchor is in the cycle before it, not the anchor's", () => { + expect(periodOf(cycle(42, "2026-11-16"), "2026-10-16").start).toBe("2026-10-05"); + expect(periodOf(cycle(14, "2026-10-16"), "2026-10-15").start).toBe("2026-10-02"); + }); + + test("lengths 2 and 366", () => { + expect(periodOf(cycle(2, "2026-01-01"), "2026-01-04").days).toEqual(["2026-01-03", "2026-01-04"]); + expect(periodOf(cycle(366, "2026-01-01"), "2027-01-01").days.length).toBe(366); + }); + + test("the schema bounds", () => { + expect(PeriodSchema.safeParse(cycle(14, "2026-10-16")).success).toBe(true); + expect(PeriodSchema.safeParse(cycle(1, "2026-01-01")).success).toBe(false); + expect(PeriodSchema.safeParse(cycle(367, "2026-01-01")).success).toBe(false); + expect(PeriodSchema.safeParse(cycle(14, "16 Oct")).success).toBe(false); + }); +}); diff --git a/test/prefs.test.ts b/test/prefs.test.ts index bcf57b2..a57f57f 100644 --- a/test/prefs.test.ts +++ b/test/prefs.test.ts @@ -139,6 +139,14 @@ describe("weekly and monthly tasks under the dailies", () => { expect(restored.showWeeklyTasks).toBe(true); expect(restored.showMonthlyTasks).toBe(false); }); + + test("cycle tasks are shown when the pref is absent or unreadable", () => { + expect(defaults().showCycleTasks).toBe(true); + expect({ ...defaults(), ...{ region: "europe" } }.showCycleTasks).toBe(true); + expect(restorePrefsValue({}).showCycleTasks).toBe(true); + expect(restorePrefsValue({ showCycleTasks: "no" }).showCycleTasks).toBe(true); + expect(restorePrefsValue({ showCycleTasks: false }).showCycleTasks).toBe(false); + }); }); describe("restorePrefsValue", () => { @@ -165,6 +173,7 @@ describe("restorePrefsValue", () => { showChores: false, showWeeklyTasks: false, showMonthlyTasks: true, + showCycleTasks: false, showCompleted: false, showIgnored: true, theme: "light" as const, diff --git a/test/task.test.ts b/test/task.test.ts index ddbc819..1a87e8f 100644 --- a/test/task.test.ts +++ b/test/task.test.ts @@ -124,3 +124,27 @@ describe("taskCaption", () => { ).toBe("every month from the 22nd · 3 days"); }); }); + +describe("a task on a cycle", () => { + test("its status is by its cycle, and its caption names the start", () => { + const t = task({ + period: { unit: "cycle", days: 14, from: "2026-10-16" }, + measure: { kind: "days", required: 5 }, + }); + expect(TaskSchema.safeParse(t).success).toBe(true); + expect(taskCaption(t)).toBe("every 14 days from 16 Oct 2026 · 5 days"); + const s = taskStatus({ + task: t, region: "europe", now: noon("2026-10-20"), + logged: ["2026-10-15", "2026-10-16", "2026-10-18"], + }); + expect(s).toMatchObject({ scope: "cycle", periodStart: "2026-10-16", count: 2, required: 5, open: 10 }); + }); + + test("a short cycle never asks for more days than it holds", () => { + const t = task({ + period: { unit: "cycle", days: 3, from: "2026-10-16" }, + measure: { kind: "days", required: 5 }, + }); + expect(taskStatus({ task: t, region: "europe", now: noon("2026-10-16"), logged: [] }).required).toBe(3); + }); +}); diff --git a/test/views.test.tsx b/test/views.test.tsx index 93c857e..bcb6201 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -14,6 +14,8 @@ import { Dailies, dailyGroups, dailyTally, + longerHeading, + tasksShown, } from "../src/client/components/Dailies.tsx"; import type { GoalStatus } from "../src/shared/goal.ts"; import { Welcome } from "../src/client/components/Welcome.tsx"; @@ -1387,6 +1389,7 @@ describe("weekly and monthly tasks in the dailies strip", () => { expect(dailyTally(group!.items, () => [])).toEqual({ total: 1, complete: 0, tickedToday: 0, week: { total: 1, complete: 0 }, month: { total: 1, complete: 1 }, + cycle: { total: 0, complete: 0 }, }); }); @@ -1418,6 +1421,50 @@ describe("weekly and monthly tasks in the dailies strip", () => { expect(chipLabel("X", false, amountStatus)).toBe("X, 1,900 of 2,100 this week"); expect(chipLabel("X", false, monthStatus)).toBe("X, done this month"); }); + + const shiyu: Task = { + id: "mytask:dddddd4444", game: "zzz", title: "Shiyu Defense", + period: { unit: "cycle", days: 14, from: "2026-10-16" }, measure: { kind: "days", required: 1 }, + at: AT, updatedAt: AT, + }; + const cycleStatus: GoalStatus = { + scope: "cycle", measure: "days", periodStart: "2026-10-02", required: 1, count: 0, + met: false, needed: 1, open: 1, shortfall: false, + }; + const anyStatus = (t: Pick) => + t.period.unit === "cycle" ? cycleStatus : statusOf(t); + + test("the lower row names the periods it holds", () => { + expect(longerHeading(new Set(["week"]))).toBe("This week"); + expect(longerHeading(new Set(["week", "month"]))).toBe("This week & month"); + expect(longerHeading(new Set(["week", "month", "cycle"]))).toBe("This week, month & cycle"); + expect(longerHeading(new Set(["cycle"]))).toBe("This cycle"); + }); + + test("cycle tasks sit in the lower row, are tallied apart and switched apart", () => { + const [group] = dailyGroups(["zzz"], [], NOW, "europe", meta, startOf, true, undefined, + [points, shiyu], anyStatus); + expect(group!.items.map((i) => i.cadence)).toEqual(["day", "week", "cycle"]); + expect(dailyTally(group!.items, () => [])).toMatchObject({ + week: { total: 1, complete: 0 }, cycle: { total: 1, complete: 0 }, + }); + expect(tasksShown([points, monthly, shiyu], { weekly: true, monthly: true, cycle: false })) + .toEqual([points, monthly]); + expect(tasksShown([points, monthly, shiyu], { weekly: false, monthly: false, cycle: true })) + .toEqual([shiyu]); + }); + + test("a cycle chip shows its days left, under a heading that names it", () => { + const html = render( + []} onToggleDay={() => {}} tasks={[points, shiyu]} + taskStatusFor={anyStatus} onSetAmount={() => {}} amountFor={() => undefined} />, + ); + expect(html).toContain("0/1 · 1d"); + expect(html).toContain("This week & cycle"); + expect(html).toMatch(/Cycles ()?0()?\/()?1/); + expect(html).toContain("still open this week or cycle."); + }); }); describe("AmountEditor", () => {