diff --git a/docs/stories/US-002-recurring-alarms.md b/docs/stories/US-002-recurring-alarms.md new file mode 100644 index 0000000..04b3c2e --- /dev/null +++ b/docs/stories/US-002-recurring-alarms.md @@ -0,0 +1,88 @@ +# User Story: Recurring (Daily-Repeat) Alarms + +**Story ID:** US-002 +**Epic:** Alarms +**Status:** In Progress + +--- + +## 1. Description + +**As a** visitor who keeps the clock site open at their desk, +**I want to** set an alarm that rings every day at the same time, +**So that** I don't have to re-create my daily alarms (break start, lunch, wrap-up) every morning. + +--- + +## 2. Context & Scope + +- **Source:** the MVP cut recorded in US-001 ("Out of Scope: daily-repeat + alarms — v2 story if users ask"). Owner prioritised this ahead of US-004 + and deferred US-004 to individual follow-up work (2026-09-14). +- **In Scope:** + - A **Repeat daily** option on alarm creation (checkbox in the create form) + - A repeat alarm rings at its configured time **every day** while enabled, + including across page reloads — it is never consumed by ringing + - Dismissing or snoozing a repeat alarm never turns it off; the only way + to stop it is the enable/disable toggle + - A visible daily-repeat indicator on the alarm list rows + - One-shot alarms (US-001 behaviour) remain available and unchanged + - Legacy persisted alarms (created before this story) load unchanged as + one-shot alarms — no data loss, no manual migration +- **Out of Scope:** + - **Weekday scheduling** (e.g. "weekdays only", custom day-of-week sets) — + deliberately deferred. The core stores `repeat` as a simple daily flag + today; a future story can widen it to a day-set without a breaking + schema change (see §6) + - Interval-based recurrence (e.g. "every 2 hours"), calendar-date alarms + - Configurable snooze duration; custom sounds + - Ringing when the tab is closed; notifications; sync/backends + +--- + +## 3. Acceptance Criteria + +_These criteria define "Done." Every criterion must be verified by QA._ + +- [ ] **AC-1:** Given the create form, When the user checks "Repeat daily" and adds a 09:30 alarm, Then the alarm appears in the list enabled, marked with the daily-repeat indicator. +- [ ] **AC-2:** Given an enabled daily-repeat alarm for 09:30, When the clock reaches 09:30, Then the alarm rings, and afterwards it remains enabled — the next day at 09:30 it rings again. +- [ ] **AC-3:** Given a daily-repeat alarm that just rang, When the user dismisses it, Then the alarm stays enabled and rings again the following day (including across a page reload). +- [ ] **AC-4:** Given a daily-repeat alarm, When the user disables its toggle, Then it never rings again until re-enabled (toggling also clears any pending snooze). +- [ ] **AC-5:** Given an alarm created before this story (persisted without a repeat flag), When the page loads, Then it behaves exactly as a one-shot alarm (rings once, auto-disables). +- [ ] **AC-6:** Snooze works identically for daily-repeat alarms: the snoozed ring fires at the snooze time, and afterwards the alarm is still armed for the next day. +- [ ] **AC-7:** One-shot alarms created after this story keep US-001 semantics exactly: ring once, auto-disable. +- [ ] **AC-8:** While a daily-repeat alarm is ringing and a second one becomes due, the second stays armed and rings on a later tick (one overlay at a time, as in US-001). + +--- + +## 4. Design Notes + +- The `Alarm` record gains a persisted `repeat: boolean` flag (default + `false`). Persistence stays schema-additive: `parseAlarms` treats a + missing flag as `false`, so old saved JSON needs no migration step. +- The core's ring action branches: one-shot alarms set `enabled: false` + (US-001); repeat alarms only clear a pending snooze, so `todayAt()` re-arms + them naturally on the next day's tick window. +- The create form follows US-005's control language (label + checkbox on the + shared control scale); the row indicator is a monochrome inline SVG in + `currentColor`, matching the existing icon set. +- Pure-core changes live entirely in `alarm.core.ts`; the DOM layer only + reads/writes the new flag. + +## 5. Verification Plan + +- Unit tests mirrored under `tests/unit/features/alarm/`: ring-stays-armed, + next-day re-ring (tick with a next-day `Date`), dismiss/reload survival, + disable clears snooze, legacy-JSON default, snooze interplay, second-due + queuing. +- Full `make check` + `format:check`; Playwright screenshot pass of the + create form and list rows (before/after) using the existing `/tmp/ux-shots` + tooling. + +## 6. Future Work + +- **Weekday scheduling:** widen the persisted `repeat: boolean` to a day + set (e.g. `repeatDays: number[]` or a bitmask). Because this story's flag + is schema-additive and normalised at parse time, that change is backward + compatible: `repeat: true` can be read as "all seven days" by the future + implementation, and old one-shot records (flag absent) keep working. diff --git a/docs/stories/US-002-recurring-alarms/screenshots/01-create-form-daily.png b/docs/stories/US-002-recurring-alarms/screenshots/01-create-form-daily.png new file mode 100644 index 0000000..3590579 Binary files /dev/null and b/docs/stories/US-002-recurring-alarms/screenshots/01-create-form-daily.png differ diff --git a/docs/stories/US-002-recurring-alarms/screenshots/02-rows-repeat-indicator.png b/docs/stories/US-002-recurring-alarms/screenshots/02-rows-repeat-indicator.png new file mode 100644 index 0000000..c47a572 Binary files /dev/null and b/docs/stories/US-002-recurring-alarms/screenshots/02-rows-repeat-indicator.png differ diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 3fe4e5d..2105650 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,39 +1,43 @@ # Active Context -Updated: 2026-09-14 +Updated: 2026-09-15 ## Current focus -**HK-6 — implemented on `fix/hk6-asset-retention`** (off `main`): the -stale-HTML/dead-asset window after deploys is closed via previous-release -asset retention (ADR-0007). deploy.sh step 3b streams the old web -container's assets into `.prev-assets/` (tar contents-form — the `docker -cp src/. dst/` form NESTS, do not use); compose bind-mounts it ro at -`/usr/share/nginx/html-prev`; `web/nginx.conf` `try_files $uri -@prev_assets` fallback serves old hashes with immutable headers. - -- Verified locally: `sh -n`, both `compose config -q`, `nginx -t` on both - configs, functional docker test (old hash 200 via fallback, unknown 404). -- Sandbox gotcha: this environment's docker bind mounts can show STALE - container views of host dirs (caused a long nesting-wild-goose-chase — - the data was polluted by an earlier root-owned `docker cp`, and the - container saw content the host didn't have). Trust host-side `find` over - container `ls` here. -- First deploy after merge runs the old in-memory deploy.sh; fallback is - fully populated from the second deploy onward (in ADR-0007). +**US-002 — merged (PR #18, rebase-merged into `main`).** Daily-repeat +alarms: persisted `repeat` + `lastRungDay` on `Alarm` (schema-additive — +legacy JSON normalizes to one-shot, no migration). Ring consumes the local +calendar day (`lastRungDay`), fixing a design hole found during planning: +without it, a dismiss mid-grace-window or a reload would re-ring a repeat +alarm. Toggling clears both markers (re-arms from the configured time). +UI: "Daily" checkbox in the create form, monochrome ↻ row indicator +(`role="img"` + aria-label). Weekday scheduling is recorded as future work +in the story (§6) — `repeat: true` reads as "all days" for that future +widening. + +- Verified: 152/152 unit tests (12 new), `make check` green, 7/7 Playwright + assertions, 2 screenshots in the story folder. + +## Repo cleanup & workflow change (2026-09-15) + +- All merged feature branches and `dev` deleted (local + origin) once PR + #18 merged; only `main` remains. +- **`dev` is retired.** Since PR #14 practice has been feature branch → + `main` via PR (rebase-merge, linear history). Work now branches from + `main` and PRs into `main`; merging to `main` fires the automatic + release deploy (GHCR → webhook). ## Environment state -- Branch `fix/hk6-asset-retention` = main + 3 commits (code, ADRs, tasks). - `dev` has sprint-tracking commit `54edefa` that main lacks — this branch's - sprint.md rewrite reconciles them on merge. -- `make check` green (140 tests); pure cores untouched by HK-6. +- Single long-lived branch: `main`; no open PRs. +- Playwright tooling still in `/tmp/ux-shots/` (not a repo dependency). ## Next steps -- Owner: review + merge HK-6 PR → `main`; next deploy needs NO manual CF - purge from the second deploy on. +- Owner: verify the repeat alarm rings live after this deploy (`/healthz`, + then a cache-busted homepage). - Owner: audible alarm-tone check (US-001) and visual pass on prod (US-005) — both still open. -- Pick next epic: US-004 (needs PO story + likely ADR) vs US-002 (needs PO - story). HK-5 (nginx re-pin) is the remaining housekeeping item. +- Then: US-004 multi-clock epic, run individually per owner decision — + needs a PO story defining "a clock" + likely an ADR before starting. + diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 1eab4d7..5fcf55c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -2,16 +2,18 @@ ## Status -US-001 (Multiple Alarms) is **deployed to production** (PR #13 merged -2026-09-13 21:17Z; Release run #5 succeeded; `healthz` verified 200). -US-005 (UX polish & a11y hardening) is **implemented on -`feat/us-005-ux-polish`** — all ACs verified, awaiting owner QA/review. -Site is live at `clock.taylormadetech.net`. +US-002 (recurring alarms) is **merged and deployed** (PR #18, rebase-merged +into `main` on 2026-09-15; merging fires the automatic release deploy). +Repo cleanup, same day: merged feature branches and `dev` deleted (local + +origin); `dev` is retired — work now branches from `main` and PRs into +`main` (rebase-merge, linear history). Site is live at +`clock.taylormadetech.net`. 152 unit tests green. ## What works - Clock (tick, title mirror, timezone), timer (presets, persistence, beep), - alarms (multi, snooze, persistence, ring overlay) — 140 unit tests green. + alarms (multi, snooze, persistence, ring overlay, **daily-repeat + (US-002)**). - **Design system (US-005):** semantic text-tier tokens (secondary 7.0:1, muted 5.2:1, placeholder 4.7:1 — never raw opacity for text), AA-safe danger `#ff6b6b`, global `:focus-visible` ring, monochrome inline-SVG @@ -19,8 +21,13 @@ Site is live at `clock.taylormadetech.net`. focus management + `aria-modal` ring overlay with Tab trap, `prefers-reduced-motion` global kill switch, `panelHadFocus` focus-restore pattern in both UI features. +- **Deploy resilience (HK-6, ADR-0007):** previous-release assets retained + on origin (`.prev-assets/` bind mount + `@prev_assets` fallback), closing + the stale-HTML/dead-asset window; no manual CF purges needed from the + second post-merge deploy on. - Full CI gate: prettier, eslint, typecheck, vitest+coverage, build, - `nginx -t` × 2, compose config × 2, hadolint × 4. + `nginx -t` × 2, compose config × 2, hadolint × 4; on modern action + majors (HK-2). - Automated deploys: `main` → GHCR → webhook → swap with auto-rollback. - Cloudflare edge caching of `index.html` (2 h Cache Rule; `/healthz` uncacheable) per ADR 0006. @@ -29,22 +36,15 @@ Site is live at `clock.taylormadetech.net`. - Production audio QA: alarm tone not yet verified by a human (jsdom can't test audio) — last open US-001 DoD item. -- **Stale-HTML → dead-asset window after every deploy** (HK-6): a rebuild can - change asset hashes; the 2 h edge-cached homepage then references assets - that 404 on origin, and the 404 gets edge-cached too (observed 2026-09-13). - Fix candidates: CF API purge post-deploy, retain old assets in `deploy.sh`, - or shorter HTML TTL. +- US-005 visual sign-off on prod still pending (owner eyeball). - Edge-cached homepage can lag up to 2 h behind a deploy (expected; judge freshness via `/healthz` or a query-string cache-bust, which works). -- CI actions pinned to Node-20-runtime majors (HK-2 — fix in progress). -- `nginxinc/nginx-unprivileged:1.30-alpine` is an EOL mainline branch - (HK-5 candidate: re-pin to current stable + digest). - npm minor drift: eslint/prettier/lint-staged/TS patch bumps available; vite 7+/vitest 5 majors deliberately deferred (not housekeeping). -- Multi-clock scaffold (`clock-registry.ts`) unwired (backlog US-004). -- FLIP helper duplicated in timer/alarm UI (backlog US-003). +- Multi-clock scaffold (`clock-registry.ts`) unwired — **US-004, owner will + run it individually**; needs a PO story defining "a clock" + likely an ADR. ## Next epic decision (owner) -US-002 recurring alarms vs US-004 multi-clock (US-004 wants US-003 item 1 -first). See `tasks/backlog.md` + `tasks/sprint.md`. +US-004 multi-clock (deferred, owner-scheduled). See `tasks/backlog.md` + +`tasks/sprint.md`. diff --git a/tasks/backlog.md b/tasks/backlog.md index b6e315a..e64d117 100644 --- a/tasks/backlog.md +++ b/tasks/backlog.md @@ -4,39 +4,16 @@ Prioritized candidate work. Items here are NOT committed to a sprint until moved to `tasks/sprint.md`. New items enter via the Product Owner (user story) and are sized/sequenced by the SDM. -## Epic: Alarms - -### US-002 — Recurring (daily-repeat) alarms - -- Status: Candidate — needs a user story from the Product Owner before work. -- Source: MVP cut recorded in US-001 ("Out of Scope": daily-repeat alarms). -- Priority: **Deprioritized behind US-005** (owner decision 2026-09-13) — - still the most requested natural follow-up to US-001. -- Dependencies: none (builds directly on the US-001 alarm core). - ## Epic: Multi-clock ### US-004 — Wire up the multi-clock registry -- Status: Candidate — `clock-registry.ts` scaffold exists but is not wired. +- Status: Candidate — **owner will schedule and run this individually**; + deliberately deferred from the 2026-09-14 cleanup sprint as too heavy to + batch. - Source: README ("Multi-clock scaffold — not wired up yet"); recommended as a major epic after the Alarms epic. - Dependencies: the shared FLIP helper (formerly US-003 item 1) is DONE via US-005 (`shared/dom/flip.ts`) — the sequencing blocker is cleared. - Note: likely requires an ADR if the page layout/composition root changes meaningfully, plus a Product Owner story defining what a "clock" is. - -## Housekeeping (backlog, non-story) - -### HK-5 — Re-pin the nginx base image off the EOL 1.27 branch - -- Status: Candidate (found 2026-09-13 during the repo date/staleness audit). -- Finding: `nginxinc/nginx-unprivileged:1.27-alpine` was last built - 2025-06-23; the 1.27 mainline stopped shipping when 1.29 released, so the - edge and prod images run an unmaintained branch. The digest pin itself is - current for the tag — the staleness is at the version level. -- Requires: choose the current nginx stable branch, bump tag + digest - together (ADR 0005) in `web/Dockerfile.prod` and `nginx/Dockerfile`, update - the image tag in `.github/workflows/ci.yml` (nginx-config-check), and run - `nginx -t` locally on both configs. -- Priority: Medium — security updates are accruing on the EOL branch. diff --git a/tasks/sprint.md b/tasks/sprint.md index b9c1623..e88af96 100644 --- a/tasks/sprint.md +++ b/tasks/sprint.md @@ -26,41 +26,60 @@ Sprint window: active (no fixed calendar sprint; pipeline-driven). ### HK-6 — Eliminate the stale-HTML/dead-asset window after deploys -- Status: **IMPLEMENTED on `fix/hk6-asset-retention`** (off `main`) — - previous-release assets retained on origin per **ADR-0007**: deploy.sh - step 3b streams the old container's `/assets` into `.prev-assets/` - (accumulative), the web service bind-mounts it read-only, and - `web/nginx.conf` falls back to it for hashes missing from the new build. -- Verified locally: `sh -n`, both `docker compose config -q` runs, `nginx --t` on both configs, and a functional docker test — old-hash asset serves - **200 with immutable headers** from the fallback, new assets 200, unknown - 404, `/` and `/healthz` unaffected. -- Note: the first deploy after merge still runs the old in-memory deploy.sh; - the fallback is fully populated from the second deploy onward (recorded in - ADR-0007). +- Status: **MERGED + DEPLOYED** (PR #16 rebase-merged 2026-09-14; Release + succeeded, `/healthz` 200). Previous-release assets retained on origin + per **ADR-0007**: deploy.sh step 3b streams the old container's `/assets` + into `.prev-assets/` (accumulative), the web service bind-mounts it + read-only, and `web/nginx.conf` falls back to it for hashes missing from + the new build. +- Verified locally before merge: `sh -n`, both `docker compose config -q` + runs, `nginx -t` on both configs, and a functional docker test — old-hash + asset serves **200 with immutable headers** from the fallback. +- Note: this deploy itself ran the old in-memory deploy.sh; the fallback is + fully populated from the second deploy onward (ADR-0007). + +### HK-5 — Re-pin the nginx base image off the EOL 1.27 branch + +- Status: **MERGED + DEPLOYED** (PR #17 rebase-merged 2026-09-14; Release + run succeeded). Tag + manifest-list digest moved together (ADR-0005) to + `nginxinc/nginx-unprivileged:1.30-alpine @ sha256:adf5042a…` — current + stable **nginx 1.30.4**; both `ci.yml` `nginx -t` invocations re-pinned. +- Verified: local pull of the digest reports `nginx/1.30.4`; `nginx -t` + passes on both configs against the new image; all four CI checks green. ## In flight -Nothing — pipeline is empty pending owner decisions. - -## Ready (not started) — needs owner pick - -| # | Item | Type | Dependencies | Notes | -| --- | --------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -| 1 | **US-004** — wire up the multi-clock registry | Epic | FLIP blocker cleared by US-005; needs PO story defining "a clock" + likely ADR (layout/composition-root change) | Was deprioritized behind US-005; now unblocked. | -| 2 | **US-002** — recurring (daily-repeat) alarms | Story | none (builds on alarm core) | Needs a PO user story before work. | -| 3 | **HK-5** — re-pin nginx off EOL 1.27 branch | Housekeeping (security) | none | Medium; security updates accruing on the unmaintained branch. | +### US-002 — Recurring (daily-repeat) alarms + +- Status: **IMPLEMENTED on `feat/us-002-recurring-alarms`** (off `main`) — + story in `docs/stories/US-002-recurring-alarms.md` (MVP scope: daily + repeat only; weekday scheduling recorded as future work in §6). +- Core: persisted `repeat` + `lastRungDay` flags; repeat alarms are never + consumed by ringing (a rung occurrence is consumed per local calendar + day, so a mid-grace-window dismiss or reload cannot re-ring); toggling + re-arms from the configured time; legacy JSON loads as one-shot, no + migration. +- UI: "Daily" checkbox in the create form; monochrome ↻ indicator + (`role="img"`, aria-label "Repeats daily") on repeat rows only. +- Verified: **152/152 unit tests** (12 new), `make check` green, 7/7 + Playwright assertions (indicator on repeat rows only, flags persist, + checkbox resets), 2 screenshots committed in the story folder. + +## Ready (not started) + +- **US-004** — wire up the multi-clock registry: the only remaining item. + **Owner will run it individually** (deliberately deferred from this + cleanup pass as too heavy to batch). Unblocked (FLIP helper done); needs + a PO story + likely an ADR before work starts. ## Dependencies & sequencing -- US-002 and US-004 are independent of each other; either can be next. -- HK-5 is fully standalone; can slot into any gap. +- Nothing queued behind US-002; US-004 is owner-scheduled. ## Risks | Risk | Likelihood | Impact | Mitigation | | -------------------------------------------------------------------------- | ---------- | -------------------------------------------- | -------------------------------------------- | | Production alarm audio unverified | Medium | Medium (US-001 DoD gap) | Owner manual check; tracked above | -| nginx 1.27 base gets no security updates (EOL mainline) | High | Medium | HK-5: re-pin to current stable | | US-005 sign-off without a human look at prod | Low | Low (aesthetic regressions screenshots miss) | Owner visual pass; rollback via revert PR | | HK-6 fallback unproven on the real host until the second post-merge deploy | Low | Low (old behaviour = broken window recurs) | Owner: verify next deploy via old-hash asset | diff --git a/web/src/features/alarm/alarm.core.ts b/web/src/features/alarm/alarm.core.ts index b01f205..ccd4ab0 100644 --- a/web/src/features/alarm/alarm.core.ts +++ b/web/src/features/alarm/alarm.core.ts @@ -15,6 +15,19 @@ export interface Alarm { hour: number; minute: number; enabled: boolean; + /** + * Daily recurrence (US-002). A repeat alarm is never consumed by + * ringing: clearing a pending snooze re-arms it for the next day. + * `false` keeps the US-001 one-shot behaviour. + */ + repeat: boolean; + /** + * Local calendar day (from `Date.toDateString()`) on which a repeat + * alarm last rang. Consumes the day's occurrence so a dismiss + * mid-grace-window or a reload cannot ring it twice. `null` for + * one-shot alarms and never-rung repeats. + */ + lastRungDay: string | null; /** Epoch ms when a snoozed alarm re-arms; `null` when not snoozed. */ snoozedUntil: number | null; } @@ -84,6 +97,10 @@ function isAlarm(value: unknown): value is Alarm { candidate.minute >= 0 && candidate.minute <= 59 && typeof candidate.enabled === 'boolean' && + (candidate.repeat === undefined || typeof candidate.repeat === 'boolean') && + (candidate.lastRungDay === undefined || + candidate.lastRungDay === null || + typeof candidate.lastRungDay === 'string') && (candidate.snoozedUntil === null || typeof candidate.snoozedUntil === 'number') ); @@ -95,7 +112,12 @@ export function parseAlarms(json: string | null): Alarm[] { try { const parsed: unknown = JSON.parse(json); if (!Array.isArray(parsed)) return []; - return parsed.filter(isAlarm); + return parsed.filter(isAlarm).map((alarm) => ({ + ...alarm, + // Legacy records (pre-US-002) carry no flags; they stay one-shot. + repeat: alarm.repeat === true, + lastRungDay: alarm.lastRungDay ?? null, + })); } catch { return []; } @@ -113,6 +135,7 @@ export function addAlarm( core: AlarmCore, name: string, time: AlarmTime, + repeat = false, ): AlarmCore { const alarm: Alarm = { id: createAlarmId(), @@ -120,6 +143,8 @@ export function addAlarm( hour: time.hour, minute: time.minute, enabled: true, + repeat, + lastRungDay: null, snoozedUntil: null, }; return { ...core, alarms: [...core.alarms, alarm] }; @@ -136,8 +161,9 @@ export function removeAlarm(core: AlarmCore, id: string): AlarmCore { /** * Enable or disable an alarm. * - * Either transition clears any pending snooze: an alarm that is toggled by - * hand is re-armed from its configured time, not from an old snooze. + * Either transition clears any pending snooze and any consumed occurrence: + * an alarm toggled by hand is re-armed from its configured time, not from an + * old snooze or a last-rung marker. */ export function setAlarmEnabled( core: AlarmCore, @@ -147,7 +173,9 @@ export function setAlarmEnabled( return { ...core, alarms: core.alarms.map((alarm) => - alarm.id === id ? { ...alarm, enabled, snoozedUntil: null } : alarm, + alarm.id === id + ? { ...alarm, enabled, snoozedUntil: null, lastRungDay: null } + : alarm, ), }; } @@ -159,6 +187,11 @@ function todayAt(hour: number, minute: number, now: Date): number { return candidate.getTime(); } +/** Local calendar day key for a point in time. */ +function dayKey(now: Date): string { + return now.toDateString(); +} + /** * How long after the target minute a late tick still rings the alarm. * @@ -171,13 +204,18 @@ const RING_GRACE_MS = 60 * 1000; function isDue(alarm: Alarm, now: Date): boolean { // A snoozed alarm rings by its snooze timestamp regardless of `enabled`: - // firing disabled it, and snoozing is the only thing that re-arms it. + // firing disabled it (one-shot) or its snooze re-arms it (repeat), and + // snoozing is the only thing that re-arms it mid-day. if (alarm.snoozedUntil !== null) { return now.getTime() >= alarm.snoozedUntil; } if (!alarm.enabled) return false; const delta = now.getTime() - todayAt(alarm.hour, alarm.minute, now); - return delta >= 0 && delta < RING_GRACE_MS; + if (delta < 0 || delta >= RING_GRACE_MS) return false; + // A repeat alarm consumes today's occurrence when it rings, so a + // dismiss mid-grace-window (or a page reload) cannot ring it twice. + if (alarm.repeat && alarm.lastRungDay === dayKey(now)) return false; + return true; } export interface AlarmTickResult { @@ -189,10 +227,12 @@ export interface AlarmTickResult { /** * Advance the alarm scheduler by one tick. * - * The first due alarm starts ringing; it is disabled (one-shot) so the same - * occurrence can never ring twice, including across a page reload. When - * something is already ringing, due alarms stay armed and ring on a later - * tick — the overlay handles one alarm at a time. + * The first due alarm starts ringing. One-shot alarms disable themselves so + * the same occurrence can never ring twice, including across a page reload; + * repeat alarms are never consumed — clearing a pending snooze re-arms them + * for the next day via `todayAt`. When something is already ringing, due + * alarms stay armed and ring on a later tick — the overlay handles one alarm + * at a time. */ export function tickAlarms(core: AlarmCore, now: Date): AlarmTickResult { if (core.ringingId !== null) { @@ -203,7 +243,11 @@ export function tickAlarms(core: AlarmCore, now: Date): AlarmTickResult { return { core: { alarms: core.alarms.map((a) => - a.id === alarm.id ? { ...a, enabled: false, snoozedUntil: null } : a, + a.id === alarm.id + ? a.repeat + ? { ...a, snoozedUntil: null, lastRungDay: dayKey(now) } + : { ...a, enabled: false, snoozedUntil: null } + : a, ), ringingId: alarm.id, }, diff --git a/web/src/features/alarm/alarm.ui.ts b/web/src/features/alarm/alarm.ui.ts index 04a9143..2741be1 100644 --- a/web/src/features/alarm/alarm.ui.ts +++ b/web/src/features/alarm/alarm.ui.ts @@ -41,6 +41,10 @@ export function initAlarm( const nameInput = queryOptional(rootElement, '.alarm-name'); const timeInput = queryOptional(rootElement, '.alarm-time'); const addBtn = queryOptional(rootElement, '.alarm-add'); + const repeatInput = queryOptional( + rootElement, + '.alarm-repeat', + ); const errorEl = queryOptional(rootElement, '.alarm-error'); const list = queryOptional(rootElement, '.alarm-list'); @@ -69,6 +73,7 @@ export function initAlarm( !nameInput || !timeInput || !addBtn || + !repeatInput || !errorEl || !list || !overlay || @@ -89,6 +94,7 @@ export function initAlarm( nameInput, timeInput, addBtn, + repeatInput, errorEl, list, overlay, @@ -162,12 +168,26 @@ export function initAlarm( toggle.checked = isArmed; toggle.setAttribute('aria-label', `Enable ${alarmDisplayName(alarm)}`); + row.append(name, time); + if (alarm.repeat) { + // Daily-repeat indicator (US-002). role="img" + aria-label so + // screen readers announce it; the SVG itself is presentational. + const repeat = document.createElement('span'); + repeat.className = 'alarm-row-repeat'; + repeat.setAttribute('role', 'img'); + repeat.setAttribute('aria-label', 'Repeats daily'); + repeat.title = 'Repeats daily'; + repeat.innerHTML = + ''; + row.append(repeat); + } + const removeBtn = document.createElement('button'); removeBtn.className = 'alarm-row-remove'; removeBtn.textContent = '✕'; removeBtn.setAttribute('aria-label', `Delete ${alarmDisplayName(alarm)}`); - row.append(name, time, toggle, removeBtn); + row.append(toggle, removeBtn); dom.list.append(row); } } @@ -249,10 +269,11 @@ export function initAlarm( dom.errorEl.textContent = 'Set a valid time (HH:MM).'; return; } - core = addAlarm(core, dom.nameInput.value, time); + core = addAlarm(core, dom.nameInput.value, time, dom.repeatInput.checked); persist(); dom.nameInput.value = ''; dom.timeInput.value = ''; + dom.repeatInput.checked = false; renderAlarmList(); renderToggle(); } diff --git a/web/src/index.html b/web/src/index.html index 578545f..3a6309e 100644 --- a/web/src/index.html +++ b/web/src/index.html @@ -154,6 +154,18 @@

Alarms

+
diff --git a/web/src/styles/alarm.css b/web/src/styles/alarm.css index 9af7fd5..af232c4 100644 --- a/web/src/styles/alarm.css +++ b/web/src/styles/alarm.css @@ -349,3 +349,42 @@ .alarm-list::-webkit-scrollbar-corner { background: transparent; } + +/* ---- Repeat daily (US-002) ---- */ + +.repeat-check { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + color: var(--color-text-secondary); + font-family: var(--font-family); + font-size: var(--font-size-small); + white-space: nowrap; + padding: 0 var(--space-xs); + cursor: pointer; + user-select: none; +} + +.repeat-check:hover { + color: var(--color-primary); +} + +.repeat-check input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--color-primary); + cursor: pointer; +} + +.repeat-check input:focus-visible { + outline: var(--focus-width, 2px) solid var(--color-primary); + outline-offset: 2px; +} + +.alarm-row-repeat { + display: inline-flex; + align-items: center; + color: var(--color-text-secondary); + flex-shrink: 0; +} diff --git a/web/tests/unit/features/alarm/alarm.core.test.ts b/web/tests/unit/features/alarm/alarm.core.test.ts index b6e791d..6574923 100644 --- a/web/tests/unit/features/alarm/alarm.core.test.ts +++ b/web/tests/unit/features/alarm/alarm.core.test.ts @@ -23,6 +23,8 @@ function makeAlarm(overrides: Partial = {}): Alarm { hour: 9, minute: 30, enabled: true, + repeat: false, + lastRungDay: null, snoozedUntil: null, ...overrides, }; @@ -281,3 +283,140 @@ describe('dismissAlarm', () => { expect(next.alarms[0]).toEqual(alarm); }); }); + +// ---------- Daily repeat (US-002) ---------- + +describe('addAlarm repeat option', () => { + it('creates a repeat alarm when requested', () => { + const core = addAlarm( + createAlarmCore(), + 'Standup', + { hour: 9, minute: 30 }, + true, + ); + expect(core.alarms[0].repeat).toBe(true); + expect(core.alarms[0].lastRungDay).toBeNull(); + }); + + it('defaults to one-shot', () => { + const core = addAlarm(createAlarmCore(), 'Standup', { + hour: 9, + minute: 30, + }); + expect(core.alarms[0].repeat).toBe(false); + }); +}); + +describe('daily-repeat alarms', () => { + const now = new Date(2026, 8, 13, 9, 30, 30); // just past 09:30 + const nextDay = new Date(2026, 8, 14, 9, 30, 30); + + it('rings a repeat alarm without consuming it', () => { + const core = makeCore([makeAlarm({ repeat: true })]); + const result = tickAlarms(core, now); + expect(result.rangId).toBe('alarm-1'); + expect(result.core.alarms[0].enabled).toBe(true); + expect(result.core.alarms[0].snoozedUntil).toBeNull(); + }); + + it('marks the rung occurrence so it cannot ring twice today', () => { + const core = makeCore([makeAlarm({ repeat: true })]); + const first = tickAlarms(core, now); + expect(first.core.alarms[0].lastRungDay).toBe(now.toDateString()); + // A dismiss mid-grace-window (or a reload) must not re-ring today. + const again = tickAlarms({ ...first.core, ringingId: null }, now); + expect(again.rangId).toBeNull(); + }); + + it('rings a repeat alarm again the next day', () => { + const core = makeCore([makeAlarm({ repeat: true })]); + const first = tickAlarms(core, now); + const second = tickAlarms({ ...first.core, ringingId: null }, nextDay); + expect(second.rangId).toBe('alarm-1'); + expect(second.core.alarms[0].enabled).toBe(true); + }); + + it('stays armed after being dismissed', () => { + const core = makeCore([makeAlarm({ repeat: true })], 'alarm-1'); + const dismissed = dismissAlarm(core); + expect(dismissed.alarms[0].enabled).toBe(true); + expect(dismissed.ringingId).toBeNull(); + const result = tickAlarms(dismissed, now); + expect(result.rangId).toBe('alarm-1'); + }); + + it('rings after a snooze expires and stays armed for the next day', () => { + const snoozeAt = new Date(2026, 8, 13, 9, 30, 0); + let core = makeCore([makeAlarm({ repeat: true })], 'alarm-1'); + core = snoozeAlarm(core, 'alarm-1', snoozeAt); + const snoozedUntil = core.alarms[0].snoozedUntil; + expect(snoozedUntil).not.toBeNull(); + const rang = tickAlarms( + { ...core }, + new Date((snoozedUntil as number) + 1), + ); + expect(rang.rangId).toBe('alarm-1'); + expect(rang.core.alarms[0].enabled).toBe(true); + expect(rang.core.alarms[0].snoozedUntil).toBeNull(); + const next = tickAlarms({ ...rang.core, ringingId: null }, nextDay); + expect(next.rangId).toBe('alarm-1'); + }); + + it('never rings while disabled', () => { + const core = makeCore([makeAlarm({ repeat: true, enabled: false })]); + const result = tickAlarms(core, now); + expect(result.rangId).toBeNull(); + }); + + it('re-arms from its configured time when toggled (consumed day cleared)', () => { + let core = makeCore([makeAlarm({ repeat: true })]); + core = tickAlarms(core, now).core; // rang today + core = dismissAlarm(core); // overlay closes; alarm still armed + core = setAlarmEnabled(core, 'alarm-1', false); + core = setAlarmEnabled(core, 'alarm-1', true); + expect(core.alarms[0].lastRungDay).toBeNull(); + const result = tickAlarms(core, now); + expect(result.rangId).toBe('alarm-1'); + }); + + it('queues a second due repeat alarm while one is already ringing', () => { + const core = makeCore( + [ + makeAlarm({ hour: 9, minute: 29, repeat: true }), + makeAlarm({ id: 'alarm-2', repeat: true }), + ], + 'alarm-1', + ); + const result = tickAlarms(core, now); + expect(result.rangId).toBeNull(); + expect(result.core.alarms[1].enabled).toBe(true); + }); + + it('parses legacy records without a repeat flag as one-shot', () => { + const json = JSON.stringify([ + { + id: 'alarm-1', + name: 'Old', + hour: 7, + minute: 0, + enabled: true, + snoozedUntil: null, + }, + ]); + const alarms = parseAlarms(json); + expect(alarms[0].repeat).toBe(false); + expect(alarms[0].lastRungDay).toBeNull(); + // One-shot semantics intact: rings, then disables itself. + const late = new Date(2026, 8, 13, 7, 0, 30); + const result = tickAlarms(makeCore(alarms), late); + expect(result.rangId).toBe('alarm-1'); + expect(result.core.alarms[0].enabled).toBe(false); + }); + + it('round-trips the repeat flag and rejects a non-boolean one', () => { + const json = JSON.stringify([makeAlarm({ repeat: true })]); + expect(parseAlarms(json)).toEqual([makeAlarm({ repeat: true })]); + const bad = JSON.stringify([{ ...makeAlarm(), repeat: 'yes' }]); + expect(parseAlarms(bad)).toEqual([]); + }); +}); diff --git a/web/tests/unit/features/alarm/alarm.storage.test.ts b/web/tests/unit/features/alarm/alarm.storage.test.ts index df528ce..1de5df3 100644 --- a/web/tests/unit/features/alarm/alarm.storage.test.ts +++ b/web/tests/unit/features/alarm/alarm.storage.test.ts @@ -10,6 +10,8 @@ const alarm: Alarm = { hour: 9, minute: 30, enabled: true, + repeat: false, + lastRungDay: null, snoozedUntil: null, };