Status: Accepted (amended 2026-07-05 — see Amendments)
Date: 2026-07-04
Deciders: Maintainer (richardthe3rd)
Amendment 2026-07-05 (implemented in #461/#463): one sub-decision below was reversed during implementation. A drink's rating and notes are drink-level and independent of the tasting timeline — a user can rate or note a drink without recording that they drank it, and clearing the tasting log never wipes a rating. They are not derived from "the most recent tasting." This supersedes the "Drink-level values are derived, not stored" text in Decision bullet 4, the migration's rated-but-never-tasted synthesis in Consequences, and Open Question 1 — read the Amendments section for the current model. (This change is about rating and notes only;
wouldRecommendremains a reserved per-pour field, unaffected.) The rest of the ADR — the check-in as the primary diary entity, the per-entry timeline,wantToTryas the plan axis — stands.
Context: "My Festival" has two jobs for a user: it is a plan (a
forward-looking wishlist of drinks they intend to try) and a diary (a
backward-looking record of their festival). The current model is
drink-centric: UserDrinkState (lib/models/user_drink_state.dart) hangs
rating, notes, and photoIds off a drink, and records tastings only as a
bare List<DateTime>. Three requirements break that model: (1) a tasting wants
to carry "how was it" (rating/note/photo) per pour, not per drink; (2) the
diary should capture non-drink events — food, a moment, anything — which
have no drink to hang off; and (3) users must be able to add entries they
forgot, after the fact, with a chosen time. Before building the capture flow
(#415) and photos/recommend (#416/#417), we need to decide what the primary
entity of My Festival is.
Make the check-in — a My Festival log entry — the primary entity: a
festival-scoped, timestamped record that optionally references a drink.
The diary is a timeline of these entries; the plan is a separate,
per-drink wantToTry intent.
| Axis | What it is | Scope | Field |
|---|---|---|---|
| Plan (wishlist) | drink flagged to try | per drink | wantToTry: bool |
| Diary (log) | timeline of check-ins | per festival | ordered List<LogEntry> |
A tasting is simply the drink-kind check-in. The entity generalises it:
class LogEntry { // a My Festival check-in
String id; // stable UUID — identity (see User Experience)
DateTime when; // user-editable; defaults to now, can be backdated
String? drinkId; // set = a tasting; null = a freeform `other` entry
String? title; // freeform label for `other` ("Scotch egg from the pie stall")
String? note; // any entry
List<String> photoIds; // any entry (#416)
int? rating; // tasting only (drinkId != null) — 1–5
bool? wouldRecommend; // tasting only (#417)
}The kind is derived, not stored. drinkId != null is a tasting; a null
drinkId is a freeform other entry. One source of truth — there is no
separate kind field to drift out of sync with drinkId. A tasting carries the
full set; an other entry is a title + optional note/photos with a time,
and no rating/recommend — a free-text title absorbs the long tail (food, a
band, "arrived"), so there is no per-category kind explosion.
Consequently:
- The festival timeline is the source of truth for the diary. Drink-level
views derive from it by filtering
entries.where((e) => e.drinkId == drink.id). wantToTrystays a per-drink intent, separate from the timeline — it is the plan axis, not an event.- Non-drink entries are first-class but minimal. An
otherentry — food, a moment, anything — has nodrinkId, a free-texttitle, and optionally a note/photos; it carries no rating/recommend and appears in the timeline only (nowhere drink-specific). - Drink-level values are derived, not stored. A drink's "your rating", recommend, and pour count derive from its tasting entries: rating/recommend = the most recent tasting's value (surfaced as "your latest", so the single number never silently shifts meaning), pours = count of tasting entries.
- Storage shape. Entries persist as per-entry keyed records (keyed by
festivalId + id), matchingUserDataStore's existing keyed pattern — an edit or delete writes/removes one record, never rewrites the whole timeline.wantToTrystays a per-drink flag, present only while true. There is no empty-record pruning for entries (an entry always hasid+when); the only removal is an explicit user delete. Reads build a memoiseddrinkId → entriesindex so per-drink derivations stay off the O(drinks × entries) path.
Local-first boundary (important): this decides the local model only.
The deployed Review API and the DrinkEntry proto are per-drink
(star_rating, would_recommend, note, pours as a count). Per-entry detail
— the timeline, per-pour notes/photos, and all non-drink entries (which have
no wire home at all) — stays device-local. Sync continues to carry only the
per-drink aggregate: any create/edit/delete of a tasting entry re-derives that
drink's aggregate (latest rating/recommend, pour count) and enqueues its
per-drink sync — the wire stays per-drink even as the local model goes
per-entry. Making the wire contract per-entry is a separate, proto-first
decision (a future ADR) and must not block the local diary. This keeps the
campaign's local-first / free-tier / low-ops constraints.
The diary only earns its keep if logging is effortless, forgiving, and able to capture the whole festival — not just scanned drinks. Four principles, each with an architectural consequence.
On a drink page, "Mark as Tasted" is one tap: it creates+persists a tasting check-in at the current time, before anything else. Detail (rate / recommend / note / photo) is offered by a non-blocking affordance — a brief auto-dismissing sheet or an inline "add detail?" prompt — never a modal the user must dismiss on every tap. Log-and-move-on users are done after one tap. No required fields, no wizard, no blocking spinner. The festival-conditions bar: one hand, a pint, patchy signal.
A "+" on the My Festival timeline creates a check-in: pick a drink
(catalogue search) for a tasting (rate / recommend / note / photo), or type
a free-text title for an other entry (note / photo only). The time
defaults to now but is freely set — so "add the pie I forgot at lunch" is
the same flow with an earlier time. Backfill is not a special case; it is
create-with-a-past-when.
A diary gets revised. Every field of an entry — rating, recommend, note, photos,
title, drinkId (which flips it between a tasting and an other entry), and
the timestamp — is editable later; an entry is deletable (with a confirm,
deletion being the one irreversible action).
My Festival is a personal log — not shared, not moderated, no audience. That removes a whole class of concerns: no sharing controls, no content moderation, no "are you sure this is public", no edit-history/audit. Edits and deletes are unconstrained and freely reversible; notes/photos can be anything and stay device-local. Design for a personal notebook, not a social feed.
Editable timestamps and non-drink entries both break the current identity
scheme. Today a tasting is its DateTime, and deletion matches by timestamp
value (user_drink_state.dart normalises to millisecond precision precisely for
that delete-by-match). Once the time is user-editable — and once entries aren't
keyed under a drink at all — the timestamp can't be the key. So every LogEntry
carries a stable id (a generated UUID) assigned once and never changed;
edit and delete key off id. This also hands sync (Track B) a natural
per-entry key and idempotency handle if the wire contract ever goes per-entry.
- Simplest; no migration;
rating/notesmap 1:1 to the per-drink Review API. - Rejected because: it can't express the diary — a tasting is a bare timestamp, per-pour detail overwrites a single drink-level value, and there is nowhere to put a non-drink event at all.
- Makes tastings rich (per-pour rating/note/photo) while keeping per-drink storage.
- Rejected because: it is still drink-keyed, so non-drink events have no home — requirement (2) rules it out. Nesting under a drink cannot represent "had a scotch egg." This is the decisive constraint that pushed the model to a festival-scoped timeline.
- The diary is first-class and general (drink and non-drink); the plan
(
wantToTry) is orthogonal; drink aggregates derive from the timeline. - Accepted despite a real storage restructure and wire-contract divergence (see Consequences), because it is the only model that captures the whole festival and resolves the per-pour tension by construction.
- My Festival becomes a real diary + plan for the whole festival, not just scanned drinks — matching how a festival is actually lived.
- Rating/recommend/note/photo attach to a moment, resolving per-pour vs per-drink by construction.
- Backfill and edit fall out of the model (editable
when+ timeline "+"), needing no special code paths. - The "Tasted" timeline (#414) and capture flow (#415) sit on a model built for them. Auto-revert to want-to-try still falls out of the derived section rule.
- Storage restructure + released-app migration. Storage moves from
per-drink
UserDrinkStateblobs to a festival-scoped entry collection plus a small per-drinkwantToTryset. That is aUserDataStoreschema v1→v2 migration over real user data, routed throughUserDataStore.migrate. The bump is one-way: a subsequent app rollback would meet v2 data it predates, so the store must fail safe (ignore/quarantine an unknown schema version, never crash). - Divergence from the wire contract. The local model is richer than the
per-drink Review API /
DrinkEntry; non-drink entries have no wire home. Per-entry detail is device-local under this ADR; cross-device diary sync needs a future proto-first change. - Rating aggregation semantics. "Your rating of this beer" must be a
derivation over tasting entries (latest? mean?), and the synced/community value
(
reviewSummariesassumes one per device per drink) must stay a single per-drink number. - Identity scheme change. Entries move from delete-by-timestamp-match to a
stable
id; the delete path and millisecond normalisation in the model are reworked. - #415 must be re-scoped to build against the check-in entity (drink and non-drink, capture + edit + backfill) rather than the drink-level action bar it currently specifies.
On upgrade to schema v2, for each existing per-drink UserDrinkState:
- its
wantToTryflag moves to the per-drink intent set; - each existing tasting timestamp becomes a
LogEntry(thatdrinkId, a freshly generatedid,whenpreserved) appended to the festival timeline; - the drink-level
rating/notes/photoIds, if any, attach to that drink's most recent tasting entry — or, if the drink was rated but never tasted, synthesise one tasting atupdatedAtcarrying them.
No data is discarded; the choice only affects where an existing rating lands.
- Model: a
LogEntryvalue object with a stableid, optionaldrinkId/title, andwhen/rating/wouldRecommend/note/photoIds(a tasting isdrinkId != null— no storedkind). Edit/delete key offid. Derive drink-level aggregates by filtering the timeline. - Storage:
UserDataStoregains a festival-scoped entry collection + per-drinkwantToTry;currentSchemaVersion1→2 with the migration above. - Provider: expose the timeline and per-drink derived views; keep
myFestivalEntries' timeline shape (#414). - UX: drink-page one-tap tasting fast path; a timeline "+" for any-kind/backfilled entries; edit-any-field; confirm-on-delete.
- Sync: unchanged — per-drink aggregate only; per-entry + non-drink detail stays device-local.
The storage restructure touches the model, the store, and every existing consumer (#413 badge, #414 screen, drink card). To avoid the sweeping-change failure mode (AGENTS.md), it ships in ordered, independently-shippable PRs, the migration first and alone:
- Model + migration behind the existing public getters
(
tastingCount,isFavorite,rating, …) so no consumer changes yet — golden-tested v1→v2, idempotent and crash-safe (a partial migration must recover, not corrupt). - Provider derivations + memoised
drinkId → entriesindex; existing view behaviour unchanged. - #415 — drink-page capture/edit against the new entity.
- Timeline "+" + non-drink
otherentries + backfill.
docs/planning/my-festival/vision.md— the multi-phase roadmap (#411–#417)proto/cambeerfestival/festival/v1alpha/drink_entry.proto— the per-drink wire contract this model deliberately diverges from (locally)- Skills:
architecture-contract(storage/versioning invariants),api-contract(wire-contract evolution),my-festival-campaign - Issues: #315 (epic), #411 (mutators, done), #414 (timeline, done), #415 (detail capture — to be re-scoped), #416 (photos), #417 (would-recommend)
- Migration of a rating with no tasting: synthesise a tasting entry, or keep a per-drink "overall"? — Resolved by the 2026-07-05 amendment: keep a per-drink detail record; do not synthesise. See Amendments.
- When, if ever, does the wire contract go per-entry? (Deferred to a future proto-first ADR; not required for the local diary.)
Decided:
- Kind is derived from
drinkId(present = tasting, absent = freeformother); no storedkindfield. Two kinds only. - A drink's rating & notes are drink-level and independent of tastings (amended 2026-07-05 — superseded "most recent tasting's value"); pours = tasting count. See Amendments.
- Storage is per-entry keyed records for the timeline, a per-drink
wantToTryflag, and a per-drink detail record for rating/notes/photos; no entry pruning (explicit delete only). - Rollout is migration-first, one consumer per PR (see Phasing).
Implemented in: #461 / PR #463.
What changed: the ADR originally decided that a drink's rating/recommend/notes derive from its most recent tasting, with a rated-but-never-tasted drink synthesising a tasting on migration. Implementation reversed this.
Why: rating a drink is personal tracking, not a claim that the drink was
drunk. A user must be able to rate or note a drink they have only looked at, and
untoggling "Tasted" (or deleting every tasting) must never wipe a rating. Deriving
rating from a tasting forced a rating to fabricate a tasting — changing isTasted
as a side effect and making a note-then-clear leave a phantom pour. That
contradicted the phase's own "interface-preserving, no behaviour change" goal.
The model now: three orthogonal per-drink axes.
| Axis | What | Storage |
|---|---|---|
| Plan | want-to-try | per-festival want_to_try_{festivalId} set |
| Diary | timeline of tasting check-ins (pours) | per-entry log_entry_{festivalId}_{id} |
| Detail | drink-level rating / notes / photos | per-drink drink_detail_{festivalId}_{drinkId}, pruned when empty |
UserDrinkState derives rating/notes/photos from the detail record and pours
from the tasting entries. isTasted depends only on tastings, so a rated,
never-tasted drink has isTasted == false.
Migration consequence: the v1→v2 fold is now lossless and behaviour-preserving
— rating/notes/photoIds move to the detail record, tasting timestamps become
bare pour entries, and a rated-but-never-tasted drink keeps isTasted == false
(no synthesis).
Still per-pour (unchanged): LogEntry retains optional rating /
wouldRecommend / note fields for future per-pour capture (#415/#417). This
amendment only governs the drink-level value the current UI reads; if and when a
per-pour vs drink-level rating both exist, reconciling them is a later decision.
Wire contract (unchanged): sync still carries the single per-drink aggregate; the detail record is that drink-level value's home.