feat(cycle-dates): real lunar dates, an editable cycle window, and visible boundaries #39

Merged
forgejo-admin merged 2 commits from cycle-dates into main 2026-08-02 23:57:23 +00:00

Implements the cycle-dates OpenSpec change (all 11 tasks in openspec/changes/cycle-dates/tasks.md).

Fixes the 2026-08-02 incident directly: no surface showed when the running cycle ends, so July's mid-flight cycle read as corrupted data.

⚠️ Design revised mid-implementation: the boundary is the FULL moon

The change as proposed defaulted the cycle end to the next new moon. Operator decision on review, now recorded as D2a in design.md, flipped it to the full moon. The reasoning:

  • MOON_NAMES are the traditional full-moon names, and D5 already named the cycle from its full moon.
  • getLunarPhase().inWindow — the modification window LoadIndicator gates on — is true for full moon, waning gibbous and last quarter (lunar age ~15–22). On new-moon boundaries (age 0 → 29.5) that window lands in the middle of the cycle. On full-moon boundaries (14.8 → 14.8) it becomes the cycle's opening week, which is what "you can still change the plan" should mean.

proposal.md, design.md (D2, D2a, D5) and the spec scenarios that hardcoded new-moon dates were rewritten to match — the docs were not left diverged from the code.

Naming needed a second helper as a consequence: the window is now bounded by two full moons, and nextFullMoon(start) returns the one that closes it. Cycles get started a day either side of the full moon, so "next" would name half of them for the following lunation — a whole-month error in the displayed name. nearestFullMoon(start) picks the lunation the window opens in, which is also what the spec's requirement text ("the lunation the window predominantly covers") always said.

What changed

SharednextNewMoon / nextFullMoon / nearestFullMoon in packages/shared/src/lib/lunarPhase.ts, by lunar-age projection against the mean synodic month (D1). No new dependency, no ephemeris table. Validated against every published 2026 new moon and full moon; worst calendar-day error across the year is 1 day, inside the spec's ±1 tolerance. The ceiling is marked with a ponytail: comment.

Server — new pure cycleWindow.ts:

  • planCycleWindow(proposed, now) resolves start/end (defaults: today → next full moon), computes the real newMoonDate/fullMoonDate, and names the month from nearestFullMoon(start).
  • parseWindowBounds is the single validation path — parses and enforces end > start — used by both startCycle and updateActiveMonth.
  • StartCycleType gains optional startDate/endDate, so existing clients and tests keep working (D2). The window is planned before rollover, so an invalid one rejects without touching the outgoing cycle.
  • createNewMonth stores the planned window; the literal string "TODO" is gone from new rows. newMoonDate still stores a real new moon — it now falls mid-cycle.
  • New updateActiveMonth procedure: a plain update of the active month row, no task re-parenting (D3).

Web — Goals page shows the proposed window with native <input type="date"> (D4) wired into Start Cycle, plus a Current cycle card that corrects the running cycle's boundaries. Home header now reads Buck Moon - 26 · Aug 2 – Aug 28. Date round-tripping stays in local time (lib/cycle-dates.ts) so a picked date reads back as the same date and useLunarPhase's days-remaining stays consistent.

Seed — seeded scenarios now pass an explicit 30-day window. The lunar default would produce a very short cycle whenever a seed runs just before the full moon, and the e2e suite schedules tasks days out against the DatePicker's endDate bound — that would have been a fail-by-calendar flake.

No migration

new_moon_date / full_moon_date columns already exist. Past months keep their "TODO" values (explicit non-goal).

Verification

  • bun run lint — 0 errors (12 pre-existing warnings, none from these files)
  • bun run test:unit — 109 passed (20 new in tests/unit/cycle-window.test.ts)
  • bun run test:seed — 1 passed
  • bun run test:playwright — 40 passed, 2 skipped (4 new in tests/e2e/cycle-dates.spec.ts)
  • tsc --noEmit on apps/lunacycle-server — clean; bun run build:client — clean

Open: one intermittent e2e failure, filed not waved off

tests/e2e/goals.spec.ts:64 failed once in six full-suite runs on this branch — expect(gateRow.schedules).toBe(1) got 0, with month_id correctly stamped. It did not reproduce in 3 further full-suite runs on this branch, 3 isolated runs of the spec, or 3 full-suite runs on origin/main.

0-in-3 vs 1-in-6 does not distinguish "rare pre-existing flake" from "introduced here", so I am claiming neither. Written up in KNOWN_TEST_FAILURES.md with the leading (unconfirmed) hypothesis: a placeholder-month race where DatePicker derives its bounds from EMPTY_MONTH's "-" endDate while currentMonth is still loading. If that holds it is a live-app bug, not just a test flake — a user opening the schedule dialog during initial atom load would silently lose the selection. Worth a look before merge.

Known limitation, pre-existing, not fixed here

MOON_NAMES is a 12-entry array indexed by calendar month. A year has 13 full moons roughly every 2.7 years, so in a blue-moon year two consecutive cycles get the identical name. This predates the change, but full-moon boundaries make cycles lunation-locked, so it will now recur on schedule rather than by luck.

🤖 Generated with Claude Code

Implements the `cycle-dates` OpenSpec change (all 11 tasks in `openspec/changes/cycle-dates/tasks.md`). Fixes the 2026-08-02 incident directly: no surface showed when the running cycle ends, so July's mid-flight cycle read as corrupted data. ## ⚠️ Design revised mid-implementation: the boundary is the FULL moon The change as proposed defaulted the cycle end to the next **new** moon. Operator decision on review, now recorded as **D2a** in `design.md`, flipped it to the **full** moon. The reasoning: - `MOON_NAMES` are the traditional **full**-moon names, and D5 already named the cycle from its full moon. - `getLunarPhase().inWindow` — the modification window `LoadIndicator` gates on — is true for full moon, waning gibbous and last quarter (lunar age ~15–22). On new-moon boundaries (age 0 → 29.5) that window lands in the **middle** of the cycle. On full-moon boundaries (14.8 → 14.8) it becomes the cycle's **opening week**, which is what "you can still change the plan" should mean. `proposal.md`, `design.md` (D2, D2a, D5) and the spec scenarios that hardcoded new-moon dates were rewritten to match — the docs were not left diverged from the code. Naming needed a second helper as a consequence: the window is now bounded by two full moons, and `nextFullMoon(start)` returns the one that **closes** it. Cycles get started a day either side of the full moon, so "next" would name half of them for the following lunation — a whole-month error in the displayed name. `nearestFullMoon(start)` picks the lunation the window opens in, which is also what the spec's requirement text ("the lunation the window predominantly covers") always said. ## What changed **Shared** — `nextNewMoon` / `nextFullMoon` / `nearestFullMoon` in `packages/shared/src/lib/lunarPhase.ts`, by lunar-age projection against the mean synodic month (D1). No new dependency, no ephemeris table. Validated against every published 2026 new moon and full moon; worst calendar-day error across the year is 1 day, inside the spec's ±1 tolerance. The ceiling is marked with a `ponytail:` comment. **Server** — new pure `cycleWindow.ts`: - `planCycleWindow(proposed, now)` resolves start/end (defaults: today → next full moon), computes the real `newMoonDate`/`fullMoonDate`, and names the month from `nearestFullMoon(start)`. - `parseWindowBounds` is the single validation path — parses and enforces `end > start` — used by both `startCycle` and `updateActiveMonth`. - `StartCycleType` gains **optional** `startDate`/`endDate`, so existing clients and tests keep working (D2). The window is planned *before* rollover, so an invalid one rejects without touching the outgoing cycle. - `createNewMonth` stores the planned window; the literal string `"TODO"` is gone from new rows. `newMoonDate` still stores a real new moon — it now falls mid-cycle. - New `updateActiveMonth` procedure: a plain update of the active month row, no task re-parenting (D3). **Web** — Goals page shows the proposed window with native `<input type="date">` (D4) wired into Start Cycle, plus a *Current cycle* card that corrects the running cycle's boundaries. Home header now reads `Buck Moon - 26 · Aug 2 – Aug 28`. Date round-tripping stays in **local** time (`lib/cycle-dates.ts`) so a picked date reads back as the same date and `useLunarPhase`'s days-remaining stays consistent. **Seed** — seeded scenarios now pass an explicit 30-day window. The lunar default would produce a very short cycle whenever a seed runs just before the full moon, and the e2e suite schedules tasks days out against the DatePicker's `endDate` bound — that would have been a fail-by-calendar flake. ## No migration `new_moon_date` / `full_moon_date` columns already exist. Past months keep their `"TODO"` values (explicit non-goal). ## Verification - `bun run lint` — 0 errors (12 pre-existing warnings, none from these files) - `bun run test:unit` — 109 passed (20 new in `tests/unit/cycle-window.test.ts`) - `bun run test:seed` — 1 passed - `bun run test:playwright` — 40 passed, 2 skipped (4 new in `tests/e2e/cycle-dates.spec.ts`) - `tsc --noEmit` on `apps/lunacycle-server` — clean; `bun run build:client` — clean ## Open: one intermittent e2e failure, filed not waved off `tests/e2e/goals.spec.ts:64` failed **once in six** full-suite runs on this branch — `expect(gateRow.schedules).toBe(1)` got `0`, with `month_id` correctly stamped. It did not reproduce in 3 further full-suite runs on this branch, 3 isolated runs of the spec, or 3 full-suite runs on `origin/main`. 0-in-3 vs 1-in-6 does not distinguish "rare pre-existing flake" from "introduced here", so I am claiming neither. Written up in `KNOWN_TEST_FAILURES.md` with the leading (unconfirmed) hypothesis: a placeholder-month race where `DatePicker` derives its bounds from `EMPTY_MONTH`'s `"-"` endDate while `currentMonth` is still loading. If that holds it is a **live-app bug**, not just a test flake — a user opening the schedule dialog during initial atom load would silently lose the selection. Worth a look before merge. ## Known limitation, pre-existing, not fixed here `MOON_NAMES` is a 12-entry array indexed by calendar month. A year has 13 full moons roughly every 2.7 years, so in a blue-moon year two consecutive cycles get the identical name. This predates the change, but full-moon boundaries make cycles lunation-locked, so it will now recur on schedule rather than by luck. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(cycle-dates): real lunar dates and an editable, visible cycle window
All checks were successful
pr-validate / validate (pull_request) Successful in 17s
e62617e9f9
New cycles no longer store the literal string "TODO" in newMoonDate/
fullMoonDate and no longer run a blind 30 days from whenever the button was
pressed. `nextNewMoon`/`nextFullMoon` project off `Moon.lunarAge()` against
the mean synodic month (±1 calendar day against published 2026 tables), a
pure `planCycleWindow` resolves the window and names the month for the moon
of its full moon, and `updateActiveMonth` lets the running cycle's
boundaries be corrected. The Goals page proposes the window with native date
inputs before commit; the Home header shows the range next to the name, so a
calendar month rolling over no longer reads as the cycle having ended.

Seeded scenarios pass an explicit 30-day window: the default lunar window
would be two days long when a seed runs just before a new moon, and the e2e
suite schedules tasks days out.
fix(cycle-dates): cycle boundaries are full moons, not new moons
All checks were successful
pr-validate / validate (pull_request) Successful in 17s
ea79a5c9f6
The rest of the app is full-moon-centric and the new-moon default the change
originally proposed contradicted it. MOON_NAMES are the traditional full-moon
names, and getLunarPhase().inWindow — the modification window LoadIndicator
gates on — is true for full moon through last quarter. On new-moon boundaries
(lunar age 0 -> 29.5) that window lands mid-cycle; on full-moon boundaries
(14.8 -> 14.8) it becomes the cycle's opening week, which is what "you can
still change the plan" should mean.

Naming needs nearestFullMoon, not nextFullMoon: the window is now bounded by
two full moons and nextFullMoon(start) returns the one that CLOSES it. Cycles
get started a day either side of the full moon, so "next" would name half of
them for the following lunation — a whole-month error in the displayed name.

newMoonDate still stores the real new moon; it now falls mid-cycle.

Design docs updated rather than silently diverged: D2a records the boundary
decision and its rationale, D5 records nearest-vs-next, and the spec scenarios
that hardcoded new-moon dates are rewritten.

Also files KNOWN_TEST_FAILURES.md for an intermittent goals.spec.ts scheduling
failure seen once in six full-suite runs — not attributed to this change, and
not waved off either.
Sign in to join this conversation.
No description provided.