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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-16 05:25:14 +02:00
1 parent 42e3325a1f
commit a80f581d8b
17 files changed
+286 -45

No files matched your search

+24 -1
View File
@@ -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(
<GameMetaProvider value={(id) => metaFor(id, {})}>
<Controls
@@ -63,6 +70,7 @@ function render(prefs: Prefs, ignoredCount = 0, games = ["genshin", "hsr"]): str
onEditGame: () => {},
onRemoveGame: () => ({ removed: true, blockedBy: 0 }),
onAddEvent: () => {},
...(tasks === undefined ? {} : { tasks }),
}}
/>
</GameMetaProvider>,
@@ -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");
});
});
+20 -9
View File
@@ -74,8 +74,8 @@ function editor(over: Partial<StripEditor> = {}): 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(
<EditPanel games={["genshin"]} editor={editor({ hasTasks: { weekly: false, monthly: false, cycle: true } })} />,
);
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 &amp; 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", () => {
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores
daysFor={() => []} onToggleDay={() => {}} />,
);
expect(html).not.toContain("This week &amp; 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(
<Dailies games={["genshin"]} events={[]} region="europe" now={NOW} showChores={false}
daysFor={() => []} 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("");
});
+12
View File
@@ -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");
});
});
+34 -1
View File
@@ -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);
});
});
+9
View File
@@ -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,
+24
View File
@@ -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);
});
});
+47
View File
@@ -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<Task, "period" | "measure">) =>
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(
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}
daysFor={() => []} onToggleDay={() => {}} tasks={[points, shiyu]}
taskStatusFor={anyStatus} onSetAmount={() => {}} amountFor={() => undefined} />,
);
expect(html).toContain("0/1 · 1d");
expect(html).toContain("This week &amp; cycle");
expect(html).toMatch(/Cycles (<!-- -->)?0(<!-- -->)?\/(<!-- -->)?1/);
expect(html).toContain("still open this week or cycle.");
});
});
describe("AmountEditor", () => {