Ask how it repeats, not how often
Three answers — never, forever, or after a delay — because "how often" is rarely the question a reader has. They know it comes back the moment it ends, or that it comes back after a wait. The cadence follows from that and from dates they have already typed, so the form reports what it measured instead of asking for a number, and says it out loud rather than filling a field silently. A delay is expressed by where it lands: the next opening is the wait added to the close, so a delay cannot produce a rule that comes round before it ends. Only a hand-stated cadence can, and stating one by hand stays available — measuring is the convenience, not a cage. Both readings are shown for a delay, since a week's wait after a week's window is a fortnightly rule and seeing that spelled out is how a wrong number gets caught. contiguousOpening is the load-bearing detail. An end given as a date is stored as 23:59:59, so a successor opening "the moment it closes" opens at the following midnight, a second later. Comparing against the stored end instead read every day-precision rule as having a gap it does not have — and the form has to measure the instants it will save, not the ones the record happens to hold, or it opens describing a rule it would not write. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2c301d466d
commit
1f1d608e95
@@ -6,10 +6,14 @@ import {
|
|||||||
type LaneId,
|
type LaneId,
|
||||||
} from "../../shared/custom.ts";
|
} from "../../shared/custom.ts";
|
||||||
import {
|
import {
|
||||||
|
addUnits,
|
||||||
comesRoundEarly,
|
comesRoundEarly,
|
||||||
movesOccurrences,
|
movesOccurrences,
|
||||||
|
repeatModeOf,
|
||||||
|
repeatSpanning,
|
||||||
RepeatUnit,
|
RepeatUnit,
|
||||||
type Repeat,
|
type Repeat,
|
||||||
|
type RepeatMode,
|
||||||
} from "../../shared/recurrence.ts";
|
} from "../../shared/recurrence.ts";
|
||||||
import { EventType } from "../../shared/schema.ts";
|
import { EventType } from "../../shared/schema.ts";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
@@ -95,6 +99,99 @@ export function repeatFrom(
|
|||||||
return { unit, interval, until: existingUntil };
|
return { unit, interval, until: existingUntil };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant a successor opens if it opens the moment this window closes.
|
||||||
|
*
|
||||||
|
* `readerInstant` resolves an end the reader gave no time to as 23:59:59 —
|
||||||
|
* the last second of the day they named, because that is what "runs until the
|
||||||
|
* 8th" means to a person. The next window therefore opens at midnight, one
|
||||||
|
* second later, not on that final second. An end they *did* give a time to is
|
||||||
|
* an instant they chose, and a successor opens on it exactly.
|
||||||
|
*
|
||||||
|
* One second is not a fudge factor: it is the exact distance between this
|
||||||
|
* form's end-of-day convention and the midnight that follows it. The
|
||||||
|
* convention lives here rather than in `recurrence.ts` because this form is
|
||||||
|
* what wrote the boundary in the first place.
|
||||||
|
*/
|
||||||
|
function contiguousOpening(endsMs: number, endHasTime: boolean): number {
|
||||||
|
return endHasTime ? endsMs : endsMs + 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to put in the number-and-unit pair when the form opens.
|
||||||
|
*
|
||||||
|
* Both the delay control and the hand-stated cadence share one pair, because
|
||||||
|
* only one of them is ever on screen and carrying the number across when the
|
||||||
|
* reader changes their mind is kinder than resetting it to 1.
|
||||||
|
*
|
||||||
|
* For a rule that already has a gap this recovers the gap itself — the stored
|
||||||
|
* interval spans the window *and* the wait, and the reader entered the wait —
|
||||||
|
* so reopening shows them the number they typed rather than the one derived
|
||||||
|
* from it. A rule with no gap has none to recover and falls back to its own
|
||||||
|
* cadence, which is the sensible starting point if they switch to a delay.
|
||||||
|
*/
|
||||||
|
function openingControls(
|
||||||
|
startsAt: string | null,
|
||||||
|
endsAt: string | null,
|
||||||
|
endHasTime: boolean,
|
||||||
|
rule: Repeat | null,
|
||||||
|
): { unit: RepeatUnit; amount: number } {
|
||||||
|
if (rule === null || startsAt === null) return { unit: "weeks", amount: 1 };
|
||||||
|
|
||||||
|
const contiguousMs =
|
||||||
|
endsAt === null ? null : contiguousOpening(Date.parse(endsAt), endHasTime);
|
||||||
|
const gap =
|
||||||
|
contiguousMs === null
|
||||||
|
? null
|
||||||
|
: repeatSpanning(
|
||||||
|
contiguousMs,
|
||||||
|
addUnits(Date.parse(startsAt), rule.unit, rule.interval),
|
||||||
|
);
|
||||||
|
return gap === null
|
||||||
|
? { unit: rule.unit, amount: rule.interval }
|
||||||
|
: { unit: gap.unit, amount: gap.interval };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule the three-way control currently describes.
|
||||||
|
*
|
||||||
|
* Pulled out of the component because it is the one place the three answers
|
||||||
|
* become the single `{unit, interval}` the schema stores, and that translation
|
||||||
|
* is worth reading in one piece rather than spread through the render.
|
||||||
|
*
|
||||||
|
* A delay is expressed by where it lands: the next opening is the wait added
|
||||||
|
* to the close, and the interval is whatever spans the anchor to there. That
|
||||||
|
* is why a delay can never produce a rule that comes round before it ends —
|
||||||
|
* the next opening is at or after the close by construction. Only a
|
||||||
|
* hand-stated cadence can, which is why `comesRoundEarly` still guards the
|
||||||
|
* form.
|
||||||
|
*/
|
||||||
|
function repeatOf(input: {
|
||||||
|
mode: RepeatMode;
|
||||||
|
measuring: boolean;
|
||||||
|
measured: Repeat | null;
|
||||||
|
startMs: number | null;
|
||||||
|
contiguousMs: number | null;
|
||||||
|
unit: RepeatUnit;
|
||||||
|
amount: number;
|
||||||
|
until: string | null;
|
||||||
|
}): Repeat | null {
|
||||||
|
const { mode, measuring, measured, startMs, contiguousMs, unit, amount, until } =
|
||||||
|
input;
|
||||||
|
|
||||||
|
if (mode === "never") return null;
|
||||||
|
if (mode === "forever") {
|
||||||
|
return measuring && measured !== null
|
||||||
|
? repeatFrom(measured.unit, measured.interval, until)
|
||||||
|
: repeatFrom(unit, amount, until);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startMs === null || contiguousMs === null) return null;
|
||||||
|
if (!Number.isInteger(amount) || amount < 1 || amount > 365) return null;
|
||||||
|
const span = repeatSpanning(startMs, addUnits(contiguousMs, unit, amount));
|
||||||
|
return span === null ? null : repeatFrom(span.unit, span.interval, until);
|
||||||
|
}
|
||||||
|
|
||||||
function labelClass(): string {
|
function labelClass(): string {
|
||||||
return "block text-xs font-medium text-muted";
|
return "block text-xs font-medium text-muted";
|
||||||
}
|
}
|
||||||
@@ -211,6 +308,24 @@ export function EventForm({
|
|||||||
const start = fields(initial?.startsAt ?? null);
|
const start = fields(initial?.startsAt ?? null);
|
||||||
const end = fields(initial?.endsAt ?? null);
|
const end = fields(initial?.endsAt ?? null);
|
||||||
|
|
||||||
|
// Round-tripped through the same fields-then-readerInstant path the live
|
||||||
|
// form uses, rather than read straight off the record. The two disagree: a
|
||||||
|
// day-precision end is stored as whatever instant it was written at, and the
|
||||||
|
// form re-resolves it to the end of the reader's own day. Deriving the
|
||||||
|
// opening state from the stored value and everything after it from the
|
||||||
|
// re-resolved one is how the control opens saying "with a delay" about a
|
||||||
|
// rule that has no gap at all.
|
||||||
|
const initialStartTime = initial?.startPrecision === "exact" ? start.time : "";
|
||||||
|
const initialEndTime = initial?.endPrecision === "exact" ? end.time : "";
|
||||||
|
const initialStartsAt =
|
||||||
|
start.date === ""
|
||||||
|
? null
|
||||||
|
: (readerInstant(start.date, initialStartTime, "start") ?? null);
|
||||||
|
const initialEndsAt =
|
||||||
|
initial?.endsAt == null || end.date === ""
|
||||||
|
? null
|
||||||
|
: (readerInstant(end.date, initialEndTime, "end") ?? null);
|
||||||
|
|
||||||
// The reader's own games first, and so the default too. Someone filling this
|
// The reader's own games first, and so the default too. Someone filling this
|
||||||
// in by hand is usually doing it *because* the game isn't tracked; making
|
// in by hand is usually doing it *because* the game isn't tracked; making
|
||||||
// them scroll past nine that are gets the common case backwards. Stable
|
// them scroll past nine that are gets the common case backwards. Stable
|
||||||
@@ -227,23 +342,36 @@ export function EventForm({
|
|||||||
const [summary, setSummary] = useState(initial?.summary ?? "");
|
const [summary, setSummary] = useState(initial?.summary ?? "");
|
||||||
const [startDate, setStartDate] = useState(start.date);
|
const [startDate, setStartDate] = useState(start.date);
|
||||||
const [startTime, setStartTime] = useState(
|
const [startTime, setStartTime] = useState(
|
||||||
initial?.startPrecision === "exact" ? start.time : "",
|
initialStartTime,
|
||||||
);
|
);
|
||||||
// Separate from an empty end date so "I don't know" is a thing the reader
|
// Separate from an empty end date so "I don't know" is a thing the reader
|
||||||
// states, not a field they leave blank and hope about.
|
// states, not a field they leave blank and hope about.
|
||||||
const [endKnown, setEndKnown] = useState(initial ? initial.endsAt !== null : true);
|
const [endKnown, setEndKnown] = useState(initial ? initial.endsAt !== null : true);
|
||||||
const [endDate, setEndDate] = useState(end.date);
|
const [endDate, setEndDate] = useState(end.date);
|
||||||
const [endTime, setEndTime] = useState(
|
const [endTime, setEndTime] = useState(
|
||||||
initial?.endPrecision === "exact" ? end.time : "",
|
initialEndTime,
|
||||||
);
|
);
|
||||||
// "never" rather than a null unit, so the select has one vocabulary and the
|
// Three answers rather than a unit and a number, because "how often" is
|
||||||
// default reads as an answer the reader gave rather than a field they left.
|
// rarely the question a reader actually has. They know it comes back the
|
||||||
const [repeatUnit, setRepeatUnit] = useState<RepeatUnit | "never">(
|
// moment it ends, or that it comes back after a wait; the cadence follows
|
||||||
initial?.repeat?.unit ?? "never",
|
// from that and from dates they have already typed.
|
||||||
);
|
const [repeatMode, setRepeatMode] = useState<RepeatMode>(() =>
|
||||||
const [repeatInterval, setRepeatInterval] = useState(
|
initial === undefined
|
||||||
String(initial?.repeat?.interval ?? 1),
|
? "never"
|
||||||
|
: repeatModeOf(
|
||||||
|
Date.parse(initialStartsAt ?? initial.startsAt),
|
||||||
|
initialEndsAt === null
|
||||||
|
? null
|
||||||
|
: contiguousOpening(Date.parse(initialEndsAt), initialEndTime !== ""),
|
||||||
|
initial.repeat,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
// Measuring is the convenience, not a cage: an irregular rotation still has
|
||||||
|
// to be sayable when the first window does not describe it.
|
||||||
|
const [ownCadence, setOwnCadence] = useState(false);
|
||||||
|
const opening = openingControls(initialStartsAt, initialEndsAt, initialEndTime !== "", initial?.repeat ?? null);
|
||||||
|
const [cadenceUnit, setCadenceUnit] = useState<RepeatUnit>(opening.unit);
|
||||||
|
const [cadenceAmount, setCadenceAmount] = useState(String(opening.amount));
|
||||||
|
|
||||||
const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start");
|
const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start");
|
||||||
const endsAt =
|
const endsAt =
|
||||||
@@ -252,10 +380,44 @@ export function EventForm({
|
|||||||
const endMissing = endKnown && endDate !== "" && endsAt === null;
|
const endMissing = endKnown && endDate !== "" && endsAt === null;
|
||||||
const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt;
|
const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt;
|
||||||
|
|
||||||
const interval = Number(repeatInterval);
|
const startMs = startsAt === null ? null : Date.parse(startsAt);
|
||||||
const intervalValid =
|
const endMs = endsAt === null ? null : Date.parse(endsAt);
|
||||||
Number.isInteger(interval) && interval >= 1 && interval <= 365;
|
|
||||||
const repeat = repeatFrom(repeatUnit, interval, initial?.repeat?.until ?? null);
|
const amount = Number(cadenceAmount);
|
||||||
|
const amountValid = Number.isInteger(amount) && amount >= 1 && amount <= 365;
|
||||||
|
const existingUntil = initial?.repeat?.until ?? null;
|
||||||
|
|
||||||
|
// What the dates already say, when they say anything. Null covers both "no
|
||||||
|
// end given yet" and a window that is no whole number of any unit — two
|
||||||
|
// exact times a few hours apart — where rounding would move a boundary the
|
||||||
|
// reader chose, so the form asks instead.
|
||||||
|
const contiguousMs =
|
||||||
|
endMs === null ? null : contiguousOpening(endMs, endTime !== "");
|
||||||
|
const measured =
|
||||||
|
startMs === null || contiguousMs === null
|
||||||
|
? null
|
||||||
|
: repeatSpanning(startMs, contiguousMs);
|
||||||
|
const measuring = repeatMode === "forever" && !ownCadence && measured !== null;
|
||||||
|
|
||||||
|
// A delay is measured from the end, so it has nothing to work with until one
|
||||||
|
// is given. Forever still does: the reader states the cadence and each
|
||||||
|
// occurrence runs until the next opens.
|
||||||
|
const delayNeedsEnd = repeatMode === "delay" && endMs === null;
|
||||||
|
|
||||||
|
const repeat = repeatOf({
|
||||||
|
mode: repeatMode,
|
||||||
|
measuring,
|
||||||
|
measured,
|
||||||
|
startMs,
|
||||||
|
contiguousMs,
|
||||||
|
unit: cadenceUnit,
|
||||||
|
amount,
|
||||||
|
until: existingUntil,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeatIncomplete =
|
||||||
|
(repeatMode === "forever" && !measuring && !amountValid) ||
|
||||||
|
(repeatMode === "delay" && (delayNeedsEnd || repeat === null));
|
||||||
|
|
||||||
// The same predicate the schema refines on, so the form cannot start
|
// The same predicate the schema refines on, so the form cannot start
|
||||||
// refusing saves the schema would accept or promising ones it will reject.
|
// refusing saves the schema would accept or promising ones it will reject.
|
||||||
@@ -275,7 +437,7 @@ export function EventForm({
|
|||||||
!endMissing &&
|
!endMissing &&
|
||||||
(!endKnown || endDate !== "") &&
|
(!endKnown || endDate !== "") &&
|
||||||
!earlyReturn &&
|
!earlyReturn &&
|
||||||
(repeatUnit === "never" || intervalValid);
|
!repeatIncomplete;
|
||||||
|
|
||||||
const draft: EventDraft | null =
|
const draft: EventDraft | null =
|
||||||
startsAt === null
|
startsAt === null
|
||||||
@@ -414,36 +576,112 @@ export function EventForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<label className={`${labelClass()} mt-3`}>
|
||||||
<label className={labelClass()}>
|
Repeat
|
||||||
Repeats
|
<select
|
||||||
<select
|
value={repeatMode}
|
||||||
value={repeatUnit}
|
onChange={(e) => setRepeatMode(e.target.value as RepeatMode)}
|
||||||
onChange={(e) => setRepeatUnit(e.target.value as RepeatUnit | "never")}
|
className={inputClass()}
|
||||||
className={inputClass()}
|
>
|
||||||
|
<option value="never">never</option>
|
||||||
|
<option value="forever">forever</option>
|
||||||
|
<option value="delay" disabled={endMs === null}>
|
||||||
|
with a delay
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{repeatMode !== "never" && endMs === null && (
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed text-faint">
|
||||||
|
A delay needs an end date to be measured from. With none, each one
|
||||||
|
just runs until the next opens.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Measured, and said out loud — a cadence the form worked out silently
|
||||||
|
would be a date the reader never agreed to, which is the one thing
|
||||||
|
this product does not do. */}
|
||||||
|
{measuring && measured !== null && (
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-faint">
|
||||||
|
{cadenceLabel(measured)} · worked out from your dates.{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOwnCadence(true)}
|
||||||
|
className="underline transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<option value="never">never</option>
|
state it myself
|
||||||
{RepeatUnit.options.map((u) => (
|
</button>
|
||||||
<option key={u} value={u}>
|
</p>
|
||||||
{u}
|
)}
|
||||||
</option>
|
|
||||||
))}
|
{repeatMode === "forever" && !measuring && (
|
||||||
</select>
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
</label>
|
|
||||||
{repeatUnit !== "never" && (
|
|
||||||
<label className={labelClass()}>
|
<label className={labelClass()}>
|
||||||
Every
|
Every
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={365}
|
max={365}
|
||||||
value={repeatInterval}
|
value={cadenceAmount}
|
||||||
onChange={(e) => setRepeatInterval(e.target.value)}
|
onChange={(e) => setCadenceAmount(e.target.value)}
|
||||||
className={inputClass()}
|
className={inputClass()}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
<label className={labelClass()}>
|
||||||
</div>
|
<span className="invisible">Unit</span>
|
||||||
|
<select
|
||||||
|
value={cadenceUnit}
|
||||||
|
onChange={(e) => setCadenceUnit(e.target.value as RepeatUnit)}
|
||||||
|
className={inputClass()}
|
||||||
|
>
|
||||||
|
{RepeatUnit.options.map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{u}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repeatMode === "delay" && (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<label className={labelClass()}>
|
||||||
|
Wait
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={cadenceAmount}
|
||||||
|
onChange={(e) => setCadenceAmount(e.target.value)}
|
||||||
|
className={inputClass()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={labelClass()}>
|
||||||
|
<span className="invisible">Unit</span>
|
||||||
|
<select
|
||||||
|
value={cadenceUnit}
|
||||||
|
onChange={(e) => setCadenceUnit(e.target.value as RepeatUnit)}
|
||||||
|
className={inputClass()}
|
||||||
|
>
|
||||||
|
{RepeatUnit.options.map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{u}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/* Both readings, so the reader can check one against the other: a
|
||||||
|
week's wait after a week's window is a fortnightly rule, and
|
||||||
|
seeing that spelled out is how they catch a wrong number. */}
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed text-faint">
|
||||||
|
after it ends
|
||||||
|
{repeat !== null ? ` · ${cadenceLabel(repeat)} in all` : ""}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{earlyReturn && (
|
{earlyReturn && (
|
||||||
<p className="mt-2 text-xs text-critical">
|
<p className="mt-2 text-xs text-critical">
|
||||||
@@ -462,13 +700,13 @@ export function EventForm({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{!endKnown && repeatUnit === "never" && (
|
{!endKnown && repeatMode === "never" && (
|
||||||
<p className="mt-2 text-xs leading-relaxed text-faint">
|
<p className="mt-2 text-xs leading-relaxed text-faint">
|
||||||
It'll show with no countdown and no daily checklist, the same as an
|
It'll show with no countdown and no daily checklist, the same as an
|
||||||
event whose source hasn't announced an end.
|
event whose source hasn't announced an end.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!endKnown && repeatUnit !== "never" && (
|
{!endKnown && repeatMode !== "never" && (
|
||||||
/* Not a degraded answer here — the interval bounds it. */
|
/* Not a degraded answer here — the interval bounds it. */
|
||||||
<p className="mt-2 text-xs leading-relaxed text-faint">
|
<p className="mt-2 text-xs leading-relaxed text-faint">
|
||||||
Each one runs until the next one opens, so it still counts down.
|
Each one runs until the next one opens, so it still counts down.
|
||||||
|
|||||||
@@ -161,18 +161,27 @@ export type RepeatMode = "never" | "forever" | "delay";
|
|||||||
* state describes it, rather than in whichever state happens to be the
|
* state describes it, rather than in whichever state happens to be the
|
||||||
* default.
|
* default.
|
||||||
*
|
*
|
||||||
|
* **`contiguousMs` is the instant a successor would open if it opened the
|
||||||
|
* moment this one closed — which is not always the stored end.** A boundary
|
||||||
|
* the reader gave a time to is an instant, and a successor opens on it. One
|
||||||
|
* they gave only a date to is stored as the last second of that day, so its
|
||||||
|
* successor opens a second later, at the following midnight. Passing the
|
||||||
|
* stored end for both would read every day-precision rule as having a gap it
|
||||||
|
* does not have. The caller owns that convention because the caller is what
|
||||||
|
* wrote the boundary.
|
||||||
|
*
|
||||||
* An unstated end is `forever`: the reader gave a cadence and no end, so each
|
* An unstated end is `forever`: the reader gave a cadence and no end, so each
|
||||||
* occurrence runs until the next opens. There is no gap to describe, and a
|
* occurrence runs until the next opens. There is no gap to describe, and a
|
||||||
* delay would have nothing to be measured from.
|
* delay would have nothing to be measured from.
|
||||||
*/
|
*/
|
||||||
export function repeatModeOf(
|
export function repeatModeOf(
|
||||||
startsMs: number,
|
startsMs: number,
|
||||||
endsMs: number | null,
|
contiguousMs: number | null,
|
||||||
repeat: Repeat | null,
|
repeat: Repeat | null,
|
||||||
): RepeatMode {
|
): RepeatMode {
|
||||||
if (repeat === null) return "never";
|
if (repeat === null) return "never";
|
||||||
if (endsMs === null) return "forever";
|
if (contiguousMs === null) return "forever";
|
||||||
return addUnits(startsMs, repeat.unit, repeat.interval) === endsMs
|
return addUnits(startsMs, repeat.unit, repeat.interval) === contiguousMs
|
||||||
? "forever"
|
? "forever"
|
||||||
: "delay";
|
: "delay";
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-6
@@ -368,17 +368,58 @@ describe("stating a repeat", () => {
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a fresh form offers a repeat, set to never", () => {
|
// The fixture runs 1-8 Sep inclusive with no times given, so it closes at
|
||||||
|
// 23:59:59 on the 8th and a successor opening "the moment it closes" opens
|
||||||
|
// at midnight on the 9th — eight days after the anchor, not seven. Its rule
|
||||||
|
// is every two weeks, which leaves a gap, so `repeating()` is the delay
|
||||||
|
// case; `forever()` narrows the interval to exactly that eight-day step.
|
||||||
|
const forever = () => repeating({ repeat: { unit: "days", interval: 8, until: null } });
|
||||||
|
|
||||||
|
test("a fresh form offers the three answers, set to never", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<EventForm lanes={["mygame:limbus-company"]} customGames={GAMES} onSave={() => {}} onCancel={() => {}} />,
|
<EventForm lanes={["mygame:limbus-company"]} customGames={GAMES} onSave={() => {}} onCancel={() => {}} />,
|
||||||
);
|
);
|
||||||
expect(html).toContain("Repeats");
|
expect(html).toContain("Repeat");
|
||||||
// The interval field is hidden until there is something to count, so the
|
expect(html).toContain("with a delay");
|
||||||
// form a reader already knows is unchanged until they reach for this.
|
// Nothing to configure until they pick one, so the form a reader already
|
||||||
|
// knows is unchanged until they reach for this.
|
||||||
expect(html).not.toContain("Every");
|
expect(html).not.toContain("Every");
|
||||||
|
expect(html).not.toContain("Wait");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("editing a rule shows the rule it already has", () => {
|
test("a rule that reopens as it closes shows its cadence rather than a control", () => {
|
||||||
|
// The whole point of "forever": the dates already say how often, so the
|
||||||
|
// form reports what it measured instead of asking for a number.
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EventForm
|
||||||
|
lanes={["mygame:limbus-company"]}
|
||||||
|
customGames={GAMES}
|
||||||
|
initial={forever()}
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("every 8 days");
|
||||||
|
expect(html).toContain("from your dates");
|
||||||
|
expect(html).not.toContain("Wait");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a measured cadence can still be overridden by hand", () => {
|
||||||
|
// Measuring is the convenience, not a cage — an irregular rotation has to
|
||||||
|
// be sayable even when the first window does not describe it.
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EventForm
|
||||||
|
lanes={["mygame:limbus-company"]}
|
||||||
|
customGames={GAMES}
|
||||||
|
initial={forever()}
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("state it myself");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rule with a gap opens as a delay, showing the gap", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<EventForm
|
<EventForm
|
||||||
lanes={["mygame:limbus-company"]}
|
lanes={["mygame:limbus-company"]}
|
||||||
@@ -388,8 +429,37 @@ describe("stating a repeat", () => {
|
|||||||
onCancel={() => {}}
|
onCancel={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
expect(html).toContain("Wait");
|
||||||
|
expect(html).toContain("after it ends");
|
||||||
|
// A week's gap after a week's window is a fortnightly rule; both readings
|
||||||
|
// are shown so the reader can check the one against the other.
|
||||||
|
expect(html).toContain("every 2 weeks");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no end date there is nothing to measure, so it asks", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EventForm
|
||||||
|
lanes={["mygame:limbus-company"]}
|
||||||
|
customGames={GAMES}
|
||||||
|
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 1, until: null } })}
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
expect(html).toContain("Every");
|
expect(html).toContain("Every");
|
||||||
expect(html).toContain('value="2"');
|
});
|
||||||
|
|
||||||
|
test("a delay needs an end date to be measured from", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EventForm
|
||||||
|
lanes={["mygame:limbus-company"]}
|
||||||
|
customGames={GAMES}
|
||||||
|
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 1, until: null } })}
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("needs an end date");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an unknown end with a rule stops claiming there is no countdown", () => {
|
test("an unknown end with a rule stops claiming there is no countdown", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user