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