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:
co-authored by
Claude Opus 5.5
parent
42e3325a1f
commit
a80f581d8b
+5
-3
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user