# Lunch Coordinator: roadmap
`lunch-poll` started life as a collaborative "where should we eat?" poll (🟢
love it / 🟡 OK / 🔴 veto, fewest-reds-wins). The next evolution turns it into a
full **Lunch Coordinator** — the poll stays at the core, but it grows the
context a group actually needs to pick a place and go.
This doc separates shipped work from the remaining backlog so the roadmap stays
useful as the pattern evolves.
## Todo work
### 1. Per-option vote-history recap
Show a compact per-option history such as "last N times we did X: 🟢🟢🟡". This
likely needs a per-option query inside `options.map`, so it was deferred from
the durable vote-history snapshot work.
### 2. People's favorite foods
Let each participant record favorite foods / cuisines / dietary constraints on
their profile (per-user, in the space directory).
- Extend the `User` profile with `favorites: string[]` and
`restrictions: string[]` (vegetarian, allergies, etc.).
- When an option is added, highlight whose favorites it matches and warn about
restriction conflicts.
- Feeds future ranking/suggestion logic.
### 3. Calendar integration
Tie the poll to an actual lunch slot so coordination is real, not hypothetical.
- Read the group's availability / a shared lunch event from a calendar source.
- Show the target time alongside the poll; auto-close voting before that time.
- Optionally write the chosen place back as a calendar event with the location.
### 4. Map view
Show the candidate options on a map so distance and clustering are visible.
- Geocode each option (address / place name → lat-long).
- Render a map with pins for each option, colored by current vote standing.
- Show walking time / distance from a configurable origin (office).
### 5. Explorer mode
A discovery mode for when the group is bored of the usual rotation.
- Suggest nearby places the group hasn't tried (cross-reference the history log
and the map).
- Pull in candidates by cuisine that match people's favorites.
- One-tap "add this as an option" from a suggestion into the live poll.
### 6. Open/closed days per location
Track which days of the week each place is closed (or open) so a location that's
shut today never even shows up as a votable option. Almost every spot in a real
rotation is dark at least one work day, and nothing's more deflating than the
poll picking a place that's closed when you walk over.
- Add a per-option `closedDays` field (e.g. a set of weekday indices, or
`hours: PerWeekday<{ open, close } | "closed">` if we want open/close times
later). Editable host-side when an option is added or via a per-option editor.
- Filter the live ballot by the **current weekday**: an option closed today is
hidden from voting (or shown greyed-out and non-votable, "closed Mondays").
Derive the weekday in a handler-fed cell, not inside a `computed`/`derive` —
reading the clock in a derive is non-idempotent (same lesson as the history
visit labels in feature #1).
- Keep hidden options in the data so they reappear automatically on a day
they're open; don't delete them.
- Plays well with feature #3 (calendar): the target lunch slot's date decides
which weekday we filter on, so a poll scheduled for tomorrow can already drop
places closed tomorrow.
## Notes
- These features should layer on top of the existing scoped-cells identity model
(`users` per-space directory, `myName` per-user, derived `isAdmin`). See
[`ADMIN-FUTURE.md`](./ADMIN-FUTURE.md) for the planned move from pattern-level
admin checks to CFC integrity claims — favorites and history writes are good
candidates for the same authorship-claim treatment.
- Map, calendar, and explorer mode all imply external data sources; sequence
those after local-only features that need no new capabilities.
## Completed work
Completed entries are ordered newest first. Dates use the local merge/commit
date for the completed work.
### 2026-06-16 — Pattern composition refactor and sub-pattern standards
The lunch poll now exercises pattern-to-pattern composition by factoring the
largest UI-bearing pieces out of `main.tsx` into sibling pattern modules:
- ✅ `generated-art.tsx`: fallback-backed generated food thumbnail. It exposes
`[UI]`, `url`, and `fetchState`; callers can render it as JSX when they only
need UI, or instantiate by function call when they need the generated URL.
- ✅ `poll-option-card.tsx`: one ranked restaurant option row, including vote
buttons, vote-state styling, homepage display/edit/lookup, host-only
remove/history actions, and generated-art persistence.
- ✅ `participant-identity-card.tsx`: join/admin identity surface. It exposes
`me`, `isJoined`, `isAdmin`, `joinAs`, and `claimHost` for the parent to use
when gating add-option controls, per-option voting, and host-only actions.
Standards established by this factoring:
- Each UI-bearing sub-pattern is its own module with `export default pattern`
and exported `Input`/`Output` interfaces.
- Public contracts include `[NAME]` and static `[UI]: VNode`; `[UI]` is never
wrapped in `computed`.
- The parent owns durable/shared state (`PerSpace`/`PerUser`/`PerSession` cells)
and passes cells or resolved values down. Children may own only local
per-session UI state.
- Use JSX instantiation when only embedding a child's UI; use function-call
instantiation when the parent reads child outputs or streams.
- Field names are exact composition contracts, not auto-mapped. Imports are
direct sibling imports, not barrel exports.
- Each factored pattern has an overview comment plus documented `Input` and
`Output` fields so future consumers can evaluate functionality and contract.
- Focused pattern tests are part of the contract for non-trivial sub-patterns:
test the public outputs, streams, and rendered states that would otherwise
regress silently. Very small render-only wrappers can stay covered by parent
or integration checks when a direct test would only assert VNode plumbing.
Gotchas preserved during extraction:
- `myName` is resolved once in `main.tsx` into `me` before `options.map(...)`;
per-option children receive that resolved value, not the raw `PerUser` cell.
- The generated-art fallback remains a static CSS `background-image`; generated
or stored `
` content is only overlaid once a safe non-empty URL resolves.
Verification added or run for this work:
- Focused tests for `poll-option-card.tsx` and `participant-identity-card.tsx`.
- Existing `main.test.tsx`, `multi-user.test.tsx`, and lunch-stats coverage kept
green.
- Deployed locally to Toolshed and manually verified that the composed pattern
loads and the extracted UI still runs.
### 2026-06-15 — "Last days we went" history
Keep a per-space log of where the group actually ended up eating, with dates, so
nobody suggests the same place three days running.
- ✅ Stored in a **`PerSpace` array** (the `visits` input),
capped at the most-recent `MAX_HISTORY` (200) entries by date. Each entry is
`{ id, title, loggedByName (frozen), loggedBy (live Cell link), wentAt,
votes }`.
Appended via the host-only `logVisit` (`visits.push`, then a cap-trim only on
overflow). Each option still has a "✓ we went here" button.
- History was briefly on a **SQLite `visits` table** (#4144/#4145, the team's
first dogfood of the SQLite builtins, #3776/#3848). It surfaced real builtin
bugs (below), but SQLite wasn't the right fit for a small in-cell collection
— it's now back on plain fabric storage.
- ✅ **Backdating:** a host "Log 'we went here' as of:" date field (blank =
today) backdates the entry; `logVisit` also accepts an explicit `wentAt`. The
date draft clears after each log so it defaults back to today.
- ✅ **Editing:** `removeHistoryEntry({ id })` (a `visits.set(filter)`) drops a
single mistaken entry via a per-row ✕; `clearHistory` (two-step confirm)
empties the log (`visits.set([])`). Both host-only. The embedded vote snapshot
goes with the entry — no separate cascade to keep aligned.
- ✅ Shown as a **"Recently eaten" list below the options** (8 most recent,
newest first), a `computed` over `visits` rendered with the plain-JSX `.map`
idiom, labelled with each visit's own date ("Tuesday, May 20").
- Implementation notes (hard-won):
- Visit labels derive **only from the stored `wentAt`**, never from the
current clock — `safeDateNow()` inside a `derive`/`computed` is
non-idempotent (it belongs in handlers, like the backdate parse).
- Interactive `onClick` handlers must live in **plain-ternary JSX**, not
inside a `computed/lift`-returned VNode, or they mis-lower as lifts
(`$event in inputs`). `recentVisits` is an array-shaped `computed`, so the
plain-JSX `.map` (where the handlers live) is preserved unchanged.
- The SQLite era surfaced real builtin bugs, kept here as history (the
fabric-array model has none of them): the `@db/sqlite` binding truncated
bound JS numbers to 32 bits (worked around with TEXT-encoded timestamps);
`reactOn: db` left queries stale after writes in the test runner (worked
around with a `sqliteRev` write-counter); async query flushes landed after
the light settle (needed `{ settle: true }` test steps); and a
deployed-piece "invalid database handle" dispatch race (resolved by runtime
PR #3967).
### 2026-06-15 — Durable vote-history snapshot
When the host logs a visit, snapshot **who voted what** at that moment, embedded
in the entry's `votes` list. Live voting stays on the in-cell `votes` array —
the log keeps its own frozen copy.
- ✅ Each entry's `votes` is
`{ voter (frozen), voterLink (live Cell),
optionTitle (denormalized), color }[]`.
`logVisit` loops the current votes and embeds one snapshot each (option title
denormalized so the snapshot survives the option being removed).
- ✅ Surfaced as a read-only **"📊 Lunch stats"** card (the `summarizePlaces`
group-by `computed`): per-place visit count + green/yellow/red tallies across
the whole record, scoped to the votes cast for each visited place.