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:
1 parent
42e3325a1f
commit
a80f581d8b
17 files changed
+286
-45
No files matched your search
+24
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 & 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 & 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("");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 & cycle");
|
||||
expect(html).toMatch(/Cycles (<!-- -->)?0(<!-- -->)?\/(<!-- -->)?1/);
|
||||
expect(html).toContain("still open this week or cycle.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AmountEditor", () => {
|
||||
|
||||
Reference in new issue
Block a user