feat(tasks): set up a task that repeats every N days

Add a task gains "Every N days": a length of 2 to 366 days and the date a
cycle began. Editing one keeps its length and start, and "days needed" is
capped at the cycle's length.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-16 05:30:32 +02:00
co-authored by Claude Opus 5.5
parent a80f581d8b
commit be3c1408d2
3 changed files with 138 additions and 19 deletions
+87 -19
View File
@@ -14,7 +14,7 @@ const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Satur
export interface TaskFields {
game: string;
title: string;
unit: "week" | "month";
unit: "week" | "month" | "cycle";
weekday: number;
/** As typed, so the field can be emptied and retyped; read through `parseMonthDay`. */
monthDay: string | number;
@@ -22,6 +22,10 @@ export interface TaskFields {
required: string;
target: string;
amountUnit: string;
/** A cycle's length in days, as typed; read through `parseCycleDays`. */
cycleDays: string;
/** The game-day a cycle began, `YYYY-MM-DD` from the date input; "" until chosen. */
cycleFrom: string;
}
/** A typed day of the month, clamped to 1–31; null while empty or unreadable. */
@@ -30,6 +34,42 @@ export function parseMonthDay(raw: string): number | null {
return Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : null;
}
/** A typed cycle length: a whole number of days from 2 to 366, else null. */
export function parseCycleDays(raw: string): number | null {
const n = Number(raw.trim());
return raw.trim() !== "" && Number.isInteger(n) && n >= 2 && n <= 366 ? n : null;
}
const isDayKey = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s);
/**
* The fields for a task being edited, or the defaults for a new one. A cycle
* keeps its length and start, so saving an edit never turns it into a week.
*/
export function taskFieldsFrom(initial: TaskDraft | undefined, lanes: readonly LaneId[]): TaskFields {
const p = initial?.period;
return {
game: initial?.game ?? lanes[0] ?? "",
title: initial?.title ?? "",
unit: p?.unit ?? "week",
weekday: p?.unit === "week" ? p.startsOn : 1,
monthDay: String(p?.unit === "month" ? p.startsOn : 1),
cycleDays: String(p?.unit === "cycle" ? p.days : 14),
cycleFrom: p?.unit === "cycle" ? p.from : "",
kind: initial?.measure.kind ?? "days",
required: initial?.measure.kind === "days" ? String(initial.measure.required) : "1",
target: initial?.measure.kind === "amount" ? String(initial.measure.target) : "",
amountUnit: initial?.measure.kind === "amount" ? (initial.measure.unit ?? "") : "",
};
}
/** The most days a "tick it off" count can ask for in this period. */
function mostDays(f: TaskFields): number {
if (f.unit === "week") return 7;
if (f.unit === "month") return 31;
return Math.min(31, parseCycleDays(f.cycleDays) ?? 31);
}
/**
* The task these fields describe, or null while they do not describe one the
* store would accept. Checked against `TaskSchema` itself, so the form can
@@ -44,7 +84,9 @@ export function taskDraftFrom(f: TaskFields): TaskDraft | null {
const period =
f.unit === "week"
? ({ unit: "week", startsOn: f.weekday } as const)
: ({ unit: "month", startsOn: monthDay ?? 1 } as const);
: f.unit === "month"
? ({ unit: "month", startsOn: monthDay ?? 1 } as const)
: ({ unit: "cycle", days: parseCycleDays(f.cycleDays) ?? 0, from: f.cycleFrom } as const);
const unit = f.amountUnit.trim();
const target = parseAmount(f.target);
const measure =
@@ -76,7 +118,8 @@ export function withTaskTemplate(f: TaskFields, t: TaskTemplate): TaskFields {
/**
* What these fields still lack before they are a task, in the order the form
* asks. Empty exactly when `taskDraftFrom` would return a draft — the bounds
* here are the schema's (`AmountTarget`, 1–7 days a week, 1–31 a month).
* here are the schema's (`AmountTarget`, 1–7 days a week, 1–31 a month, a
* cycle's length up to 31).
*/
export function taskStillNeeded(f: TaskFields): string[] {
const needs: string[] = [];
@@ -84,9 +127,13 @@ export function taskStillNeeded(f: TaskFields): string[] {
if (f.unit === "month" && parseMonthDay(String(f.monthDay)) === null) {
needs.push("a day of the month");
}
if (f.unit === "cycle") {
if (parseCycleDays(f.cycleDays) === null) needs.push("a length of 2 to 366 days");
if (!isDayKey(f.cycleFrom)) needs.push("a start date");
}
if (f.kind === "days") {
const n = Number(f.required.trim());
const most = f.unit === "week" ? 7 : 31;
const most = mostDays(f);
if (f.required.trim() === "" || !Number.isInteger(n) || n < 1 || n > most) {
needs.push("how many days");
}
@@ -100,7 +147,7 @@ export function taskStillNeeded(f: TaskFields): string[] {
}
/**
* Add or change a weekly or monthly task.
* Add or change a weekly, monthly or every-N-days task.
*
* Every answer has a default a reader can accept unread — weekly, from Monday,
* ticked off once — because most of these games roll their week on Monday at
@@ -119,17 +166,7 @@ export function TaskForm({
onCancel: () => void;
}) {
const gameMeta = useGameMeta();
const [f, setF] = useState<TaskFields>(() => ({
game: initial?.game ?? lanes[0] ?? "",
title: initial?.title ?? "",
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",
required: initial?.measure.kind === "days" ? String(initial.measure.required) : "1",
target: initial?.measure.kind === "amount" ? String(initial.measure.target) : "",
amountUnit: initial?.measure.kind === "amount" ? (initial.measure.unit ?? "") : "",
}));
const [f, setF] = useState<TaskFields>(() => taskFieldsFrom(initial, lanes));
const set = (patch: Partial<TaskFields>) => setF((prev) => ({ ...prev, ...patch }));
const [chosen, setChosen] = useState<TaskTemplate | null>(null);
const draft = taskDraftFrom(f);
@@ -190,7 +227,36 @@ export function TaskForm({
<button type="button" aria-pressed={f.unit === "month"} onClick={() => set({ unit: "month" })} className={pill(f.unit === "month")}>
Every month
</button>
{f.unit === "week" ? (
<button type="button" aria-pressed={f.unit === "cycle"} onClick={() => set({ unit: "cycle" })} className={pill(f.unit === "cycle")}>
Every N days
</button>
{f.unit === "cycle" ? (
<span className="flex flex-wrap items-center gap-1.5 text-xs text-muted">
<label className="flex items-center gap-1.5">
every
<input
type="number"
aria-label="Cycle length in days"
min={2}
max={366}
value={f.cycleDays}
onChange={(e) => set({ cycleDays: e.target.value })}
className={`tnum w-16 ${field}`}
/>
days,
</label>
<label className="flex items-center gap-1.5">
from
<input
type="date"
aria-label="Cycle starts on"
value={f.cycleFrom}
onChange={(e) => set({ cycleFrom: e.target.value })}
className={field}
/>
</label>
</span>
) : f.unit === "week" ? (
<label className="flex items-center gap-1.5 text-xs text-muted">
from
<select
@@ -222,7 +288,9 @@ export function TaskForm({
)}
</div>
<p className="mt-1 text-[0.6875rem] leading-relaxed text-faint">
It rolls over at the game's daily reset on that day, on your server.
{f.unit === "cycle"
? "Pick a day a cycle began — the day after one ended. It rolls at the game's daily reset, on your server."
: "It rolls over at the game's daily reset on that day, on your server."}
</p>
<p className="mt-3 text-xs font-medium text-muted">Done when</p>
@@ -241,7 +309,7 @@ export function TaskForm({
type="number"
aria-label="Days needed"
min={1}
max={f.unit === "week" ? 7 : 31}
max={mostDays(f)}
value={f.required}
onChange={(e) => set({ required: e.target.value })}
className={`tnum w-14 ${field}`}
+1
View File
@@ -1296,6 +1296,7 @@ describe("the reader's weekly and monthly tasks in settings", () => {
const fields = {
game: "zzz", title: "Activity points", unit: "week" as const, weekday: 1, monthDay: 1,
kind: "amount" as const, required: "1", target: "2,100", amountUnit: "points",
cycleDays: "14", cycleFrom: "",
};
expect(taskDraftFrom(fields)).toEqual({
game: "zzz", title: "Activity points",
+50
View File
@@ -4,6 +4,7 @@ import { stillNeededLine } from "../src/client/components/formParts.tsx";
import {
TaskForm,
taskDraftFrom,
taskFieldsFrom,
taskStillNeeded,
withTaskTemplate,
type TaskFields,
@@ -29,6 +30,8 @@ const typed: TaskFields = {
required: "2",
target: "1300",
amountUnit: "points",
cycleDays: "14",
cycleFrom: "",
};
describe("the still-needed line", () => {
@@ -311,6 +314,10 @@ describe("review fixes: Save never sits disabled without a reason", () => {
{ ...typed, kind: "days", required: "8" },
{ ...typed, unit: "month", kind: "days", required: "31" },
{ ...typed, unit: "month", monthDay: "" },
{ ...typed, unit: "cycle", cycleFrom: "2026-10-16" },
{ ...typed, unit: "cycle", cycleFrom: "" },
{ ...typed, unit: "cycle", cycleDays: "1", cycleFrom: "2026-10-16" },
{ ...typed, unit: "cycle", kind: "days", required: "4", cycleDays: "3", cycleFrom: "2026-10-16" },
];
for (const f of cases) {
expect({ target: f.target, required: f.required, empty: taskStillNeeded(f).length === 0 })
@@ -357,3 +364,46 @@ describe("follow-up: a wait with the end set to unknown", () => {
expect(eventFormState({ ...v, endKnown: true }, null).needs).toEqual(["an end date"]);
});
});
describe("a task every N days", () => {
const shiyu: TaskFields = {
...typed, title: "Shiyu Defense", unit: "cycle", kind: "days", required: "1",
cycleDays: "14", cycleFrom: "2026-10-16",
};
test("becomes a cycle period", () => {
expect(taskDraftFrom(shiyu)?.period).toEqual({ unit: "cycle", days: 14, from: "2026-10-16" });
});
test("needs a length of 2 to 366 days and a start date", () => {
expect(taskStillNeeded({ ...shiyu, cycleDays: "1" })).toEqual(["a length of 2 to 366 days"]);
expect(taskStillNeeded({ ...shiyu, cycleDays: "367" })).toEqual(["a length of 2 to 366 days"]);
expect(taskStillNeeded({ ...shiyu, cycleDays: "" })).toEqual(["a length of 2 to 366 days"]);
expect(taskStillNeeded({ ...shiyu, cycleFrom: "" })).toEqual(["a start date"]);
});
test("days needed is bounded by the cycle's length and by 31", () => {
expect(taskStillNeeded({ ...shiyu, cycleDays: "3", required: "3" })).toEqual([]);
expect(taskStillNeeded({ ...shiyu, cycleDays: "3", required: "4" })).toEqual(["how many days"]);
expect(taskStillNeeded({ ...shiyu, cycleDays: "42", required: "32" })).toEqual(["how many days"]);
});
test("editing one keeps its length and start", () => {
const initial = {
game: "zzz", title: "Shiyu Defense",
period: { unit: "cycle" as const, days: 14, from: "2026-10-16" },
measure: { kind: "days" as const, required: 1 },
};
expect(taskDraftFrom(taskFieldsFrom(initial, ["zzz"]))).toEqual(initial);
const html = renderToStaticMarkup(
<TaskForm lanes={["zzz"]} initial={initial} onSave={noop} onCancel={noop} />,
);
expect(html).toMatch(/aria-pressed="true"[^>]*>Every N days</);
expect(html).toContain('value="14"');
expect(html).toContain('value="2026-10-16"');
});
test("a week or month task still starts with a two-week cycle on offer", () => {
expect(taskFieldsFrom(undefined, ["zzz"])).toMatchObject({ unit: "week", cycleDays: "14", cycleFrom: "" });
});
});