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", () => {