Light the house for someone who has died (#80) #138

Merged
claude-bot merged 2 commits from feat/80-memorial into main 2026-09-05 03:57:21 +00:00
Contributor

Closes #80. All four gaps and all five acceptance criteria.

A Memorial button on the calendar toolbar takes a name, a date, optional notes and a "remember every year" toggle, and produces a candidate scheme to review.

The issue is right that most of this already worked — a memorial is structurally a user-defined event, and that path has been complete since v1. So this is deliberately thin, and there's a test asserting the generic POST /events path still does the job, because that claim is load-bearing for the whole design.

Gap 1: the AI prompt was holiday-shaped

The highest-value gap, and the one that would have hurt. It opened "generate a JSON lighting scheme for a holiday or observance event" with festive few-shot examples — asked for a memorial it would reliably return something bright and fast for someone's funeral.

The system prompt now branches, and the examples are replaced rather than appended: showing festive and sombre examples together invites the model to average two incompatible briefs.

Branching is also the right shape. Filtering a celebratory scheme into a sombre one afterwards gives a muddy version of the wrong idea, not the right one.

The brief names what to avoid — strobes, chases, sparkles, festive — rather than only asking for dignity, because those are exactly what a model reaches for when it thinks "event".

There's a limit to what a test can assert about a prompt: it can check the model was asked the right question, not that it answered well. So the tests assert the brief, and Gap 2 is what guarantees a dignified result when nothing is asked at all.

Gap 2: the no-AI floor

A memorial tag template in schemes/builtin.yaml — soft amber candlelight on a slow Breathe, brightness 120, an eight-second transition. AI_PROVIDER is optional and capped, so without this a memorial with no provider fell through to solid white.

Tested relative to the rest of the library rather than in the abstract: dimmer and slower than the patriotic template, which is the comparison that actually means "dignified".

Gap 3: annual recurrence

Opt-in, on a separate tag — most memorials mark someone who died this year, and quietly repeating one forever is a stranger default than not. The Jan 1 job materialises the next occurrence reusing the same event_key, which is what makes the anniversary reuse the approved scheme with no AI call and nothing to review. That mechanism already existed; this only creates the row.

It rolls from the most recent occurrence rather than the original, so an edit to the name carries forward instead of being reverted every January to whatever was first typed.

A 29 February anniversary is skipped in non-leap years rather than moved. Someone who died on a leap day has an anniversary that genuinely only occurs every four years, and picking the 28th or 1 March on their behalf is not this code's decision to make.

Gap 4: the UI

There was no event-create UI at all, so this is the first one. Priority is on the form, not hidden behind its default: user events sit at 100 and outrank everything, so a memorial on 23 December replaces Christmas Eve. The issue calls that the intended default, and it is — but a default that surprising belongs in front of someone at the moment they choose it rather than discovered in December. The field says so in as many words.

The event key is slugified from the name, and that's load-bearing rather than cosmetic: scheme reuse is keyed on it, so the same name must produce the same slug in two different years or the anniversary silently generates a fresh candidate. It has its own tests, including accents and punctuation.

Also corrected

builtin.yaml's header comment claimed 65=Colorful, 66=Traffic. Those are Palette and Fire 2012 (#132) — the same class of error as the one just fixed, sitting in the file most likely to be read by someone choosing an effect.

Verification

34 new backend tests, 8 new frontend. The prompt branch was mutation-tested — removing it fails three tests with the right diagnosis. Backend ruff/mypy clean, 795 tests passing. Frontend tsc -b clean, eslint clean.

Out of scope, as the issue says

No external death feed, no automatic detection. Memorials are created by hand, on purpose, by someone who has decided to mark a specific person. That's the character of the feature.

🤖 Generated with Claude Code

Closes #80. All four gaps and all five acceptance criteria. A **Memorial** button on the calendar toolbar takes a name, a date, optional notes and a "remember every year" toggle, and produces a candidate scheme to review. The issue is right that most of this already worked — a memorial is structurally a user-defined event, and that path has been complete since v1. So this is deliberately thin, and there's a test asserting the generic `POST /events` path still does the job, because that claim is load-bearing for the whole design. ## Gap 1: the AI prompt was holiday-shaped The highest-value gap, and the one that would have hurt. It opened *"generate a JSON lighting scheme for a holiday or observance event"* with festive few-shot examples — asked for a memorial it would reliably return something bright and fast **for someone's funeral**. The system prompt now branches, and the examples are **replaced rather than appended**: showing festive and sombre examples together invites the model to average two incompatible briefs. Branching is also the right shape. Filtering a celebratory scheme into a sombre one afterwards gives a muddy version of the wrong idea, not the right one. The brief names what to *avoid* — strobes, chases, sparkles, festive — rather than only asking for dignity, because those are exactly what a model reaches for when it thinks "event". There's a limit to what a test can assert about a prompt: it can check the model was asked the right question, not that it answered well. So the tests assert the brief, and Gap 2 is what guarantees a dignified result when nothing is asked at all. ## Gap 2: the no-AI floor A `memorial` tag template in `schemes/builtin.yaml` — soft amber candlelight on a slow Breathe, brightness 120, an eight-second transition. `AI_PROVIDER` is optional and capped, so without this a memorial with no provider fell through to **solid white**. Tested relative to the rest of the library rather than in the abstract: dimmer and slower than the patriotic template, which is the comparison that actually means "dignified". ## Gap 3: annual recurrence Opt-in, on a separate tag — most memorials mark someone who died this year, and quietly repeating one forever is a stranger default than not. The Jan 1 job materialises the next occurrence **reusing the same `event_key`**, which is what makes the anniversary reuse the approved scheme with no AI call and nothing to review. That mechanism already existed; this only creates the row. It rolls from the **most recent** occurrence rather than the original, so an edit to the name carries forward instead of being reverted every January to whatever was first typed. **A 29 February anniversary is skipped in non-leap years rather than moved.** Someone who died on a leap day has an anniversary that genuinely only occurs every four years, and picking the 28th or 1 March on their behalf is not this code's decision to make. ## Gap 4: the UI There was no event-create UI at all, so this is the first one. **Priority is on the form**, not hidden behind its default: user events sit at 100 and outrank everything, so a memorial on 23 December replaces Christmas Eve. The issue calls that the intended default, and it is — but a default that surprising belongs in front of someone at the moment they choose it rather than discovered in December. The field says so in as many words. The event key is slugified from the name, and that's **load-bearing rather than cosmetic**: scheme reuse is keyed on it, so the same name must produce the same slug in two different years or the anniversary silently generates a fresh candidate. It has its own tests, including accents and punctuation. ## Also corrected `builtin.yaml`'s header comment claimed `65=Colorful, 66=Traffic`. Those are **Palette and Fire 2012** (#132) — the same class of error as the one just fixed, sitting in the file most likely to be read by someone choosing an effect. ## Verification 34 new backend tests, 8 new frontend. The prompt branch was mutation-tested — removing it fails three tests with the right diagnosis. Backend `ruff`/`mypy` clean, **795 tests passing**. Frontend `tsc -b` clean, `eslint` clean. ## Out of scope, as the issue says No external death feed, no automatic detection. Memorials are created by hand, on purpose, by someone who has decided to mark a specific person. That's the character of the feature. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Light the house for someone who has died (#80)
Some checks failed
CI / Dockerfile lint (pull_request) Successful in 13s
CI / Alembic migration check (pull_request) Successful in 45s
CI / Frontend lint, test & build (pull_request) Failing after 49s
CI / Pre-commit hooks (pull_request) Successful in 52s
CI / Python lint & type-check (pull_request) Successful in 1m11s
CI / Docker build, health smoke & E2E (pull_request) Has been cancelled
CI / Python tests (pull_request) Has been cancelled
c257a58ef5
A Memorial button on the calendar toolbar takes a name, a date, optional
notes and a "remember every year" toggle, and produces a candidate scheme
to review.

The issue is right that most of this already worked -- a memorial is
structurally a user-defined event and that path has been complete since
v1 -- so this is deliberately thin. The work was the gaps around it.

The AI prompt was holiday-shaped, and that is the gap that mattered. It
opened "generate a JSON lighting scheme for a holiday or observance
event" with festive few-shot examples, so asked for a memorial it would
reliably return something bright and fast for someone's funeral. The
system prompt now branches, and the examples are replaced rather than
appended: showing festive and sombre examples together invites the model
to average two incompatible briefs. Branching is also the right shape --
filtering a celebratory scheme into a sombre one afterwards gives a muddy
version of the wrong idea, not the right one. The brief names what to
avoid (strobes, chases, sparkles) rather than only asking for dignity,
because those are what a model reaches for when it thinks "event".

A memorial tag template in builtin.yaml is the floor: AI_PROVIDER is
optional and capped, so without it a memorial with no provider configured
fell through to solid white. Soft amber candlelight on a slow breathe,
low brightness, eight-second transition -- a memorial that snaps on at
sunset is the wrong gesture.

Annual recurrence is opt-in, on a separate tag, because most memorials
mark someone who died this year and quietly repeating one forever is a
stranger default than not. The Jan 1 job materialises the next occurrence
reusing the same event_key, which is what makes the anniversary reuse the
approved scheme with no AI call and nothing to review; that mechanism
already existed and this only has to create the row. It rolls from the
most recent occurrence rather than the original, so an edit to the name
carries forward instead of being reverted every January.

A 29 February anniversary is skipped in non-leap years rather than moved
to the 28th or the 1st. Someone who died on a leap day has an anniversary
that genuinely only occurs every four years, and picking a neighbouring
date on their behalf is not this code's decision to make.

Priority is on the form rather than hidden behind its default. User
events sit at 100 and outrank everything, so a memorial on 23 December
replaces Christmas Eve -- the intended behaviour per the issue, but a
surprising enough one that it belongs in front of someone at the moment
they choose it rather than in December.

The event key is slugified from the name, and that is load-bearing rather
than cosmetic: scheme reuse is keyed on it, so the same name has to
produce the same slug in two different years or the anniversary silently
generates a fresh candidate instead. It has its own tests.

Also corrected: builtin.yaml's header comment claimed 65=Colorful and
66=Traffic. They are Palette and Fire 2012 (#132) -- the same class of
error, in the file most likely to be read by someone choosing an effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rbrooks force-pushed feat/80-memorial from c257a58ef5
Some checks failed
CI / Dockerfile lint (pull_request) Successful in 13s
CI / Alembic migration check (pull_request) Successful in 45s
CI / Frontend lint, test & build (pull_request) Failing after 49s
CI / Pre-commit hooks (pull_request) Successful in 52s
CI / Python lint & type-check (pull_request) Successful in 1m11s
CI / Docker build, health smoke & E2E (pull_request) Has been cancelled
CI / Python tests (pull_request) Has been cancelled
to 084f018d0d
Some checks failed
CI / Dockerfile lint (pull_request) Successful in 9s
CI / Alembic migration check (pull_request) Successful in 55s
CI / Pre-commit hooks (pull_request) Successful in 1m11s
CI / Frontend lint, test & build (pull_request) Failing after 52s
CI / Python lint & type-check (pull_request) Successful in 1m25s
CI / Python tests (pull_request) Successful in 4m55s
CI / Docker build, health smoke & E2E (pull_request) Has been skipped
2026-09-05 01:56:12 +00:00
Compare
rbrooks force-pushed feat/80-memorial from 084f018d0d
Some checks failed
CI / Dockerfile lint (pull_request) Successful in 9s
CI / Alembic migration check (pull_request) Successful in 55s
CI / Pre-commit hooks (pull_request) Successful in 1m11s
CI / Frontend lint, test & build (pull_request) Failing after 52s
CI / Python lint & type-check (pull_request) Successful in 1m25s
CI / Python tests (pull_request) Successful in 4m55s
CI / Docker build, health smoke & E2E (pull_request) Has been skipped
to 77ad4d8d26
Some checks failed
CI / Dockerfile lint (pull_request) Successful in 10s
CI / Pre-commit hooks (pull_request) Successful in 53s
CI / Alembic migration check (pull_request) Successful in 1m5s
CI / Frontend lint, test & build (pull_request) Failing after 1m10s
CI / Python lint & type-check (pull_request) Successful in 1m22s
CI / Python tests (pull_request) Successful in 5m24s
CI / Docker build, health smoke & E2E (pull_request) Has been skipped
2026-09-05 02:03:00 +00:00
Compare
Move the memorial event key out of the component (#80)
All checks were successful
CI / Dockerfile lint (pull_request) Successful in 13s
CI / Frontend lint, test & build (pull_request) Successful in 1m39s
CI / Alembic migration check (pull_request) Successful in 2m0s
CI / Pre-commit hooks (pull_request) Successful in 2m28s
CI / Python lint & type-check (pull_request) Successful in 3m11s
CI / Python tests (pull_request) Successful in 7m53s
CI / Docker build, health smoke & E2E (pull_request) Successful in 3m23s
8ba7c9dec7
CI's frontend lint runs with --max-warnings 0 and rejected exporting a
function alongside a component from the same file: it breaks fast
refresh. I had run bare `npx eslint .` locally, which does not apply that
flag, so my gate was weaker than CI's and the warning passed. Using
`npm run lint` is the fix for that, and the reason this was caught in CI
rather than before the push.

The rule is right on its own terms, and it is pointing at something real
here: the event key is not a presentation concern. Approved schemes are
looked up by event_key, so this function is what makes an annual memorial
reuse last year's colours instead of quietly generating a fresh
candidate. It belongs in lib/ with the rest of the logic that has
consequences.

Adds memorialEventKey alongside slugify so the prefix is defined once
rather than interpolated at the call site, with tests for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/80-memorial 2026-09-05 03:57:21 +00:00
Sign in to join this conversation.
No description provided.