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

+4
View File
@@ -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}
+13 -1
View File
@@ -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 (
<section
@@ -344,6 +348,14 @@ export function Controls({
hint="Off hides them from the strip and its counts; your ticks and numbers are kept."
/>
)}
{taskPeriods.cycle && (
<Check
checked={prefs.showCycleTasks}
onChange={(showCycleTasks) => 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 && (
<Check
+51 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import {
catchUpDays,
CATCH_UP_DAYS,
@@ -67,7 +67,7 @@ export interface DailyItem {
* whose goal is a number has no "today" — nothing ticks — so it counts with
* its week, or, over its whole run, in no tally at all.
*/
cadence: "day" | "week" | "month" | "event";
cadence: "day" | "week" | "month" | "cycle" | "event";
/** A task's period, for finding the one before it in Catch up. */
period?: Period | undefined;
/** A task's unit, printed after its target. */
@@ -216,9 +216,10 @@ export function dailyGroups(
*/
export function tasksShown<T extends Pick<StripTask, "period">>(
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<Longer>, 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<Longer>): 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) => (
<li key={item.key}>
{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 && (
<span className="mt-0.5 block text-faint">
{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) => (
<Fragment key={name}>
{n > 0 && " · "}
{name} {t.complete}/{t.total}
</Fragment>
))}
</span>
)}
</h2>
@@ -554,7 +586,7 @@ export function Dailies({
{longer.length > 0 && (
<>
<p className="relative mt-3 text-[0.6875rem] font-medium text-faint">
This week &amp; month
{longerHeading(new Set(longer.map((i) => i.cadence as Longer)))}
</p>
<ul className="relative mt-1.5 flex flex-wrap gap-1.5">
{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.`}
+10 -2
View File
@@ -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<StripShown>) => 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 && (
<Switch
label="Cycle tasks"
checked={shown.cycle}
onChange={(cycle) => editor.onShown({ cycle })}
/>
)}
</div>
</div>
);
+1 -1
View File
@@ -122,7 +122,7 @@ export function TaskForm({
const [f, setF] = useState<TaskFields>(() => ({
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",
+4
View File
@@ -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;
+5 -3
View File
@@ -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}`;
}
+14 -1
View File
@@ -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<typeof PeriodSchema>;
@@ -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();
+2 -8
View File
@@ -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.
+12 -2
View File
@@ -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<Task, "period" | "measure">): 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"