The app
One prototype, one Organisation, one shell — walk between destinations the way
a user would. Nothing is saved, nothing is real.
Built: Calendar (STORY-014) · Events and guests
(EPIC-002, EPIC-003) · Venues (STORY-013) · People (STORY-015) · Money (EPIC-004) ·
Inventory (EPIC-006) · Tasks (EPIC-005) · Settings → Currencies and Timezones
(STORY-041).
Every destination in the navigation is now real. The "not
prototyped" screen stays wired up for the next one: a destination is marked unbuilt
in one place, and that is what keeps a scope boundary visible on screen rather than
only written down.
Signing up happens before this shell exists, so it stays a separate
prototype: signup-flow.
The question this exists to answer
- The reference workbook tracked valuable kit on one sheet while its companion sheet —
the one built to record what went where — held a header, a formula, and
zero rows. The team designed the control and could not sustain it by
hand.
- So EPIC-006's open question 4 is not about modelling:
“if the flow is slower than a text message to a colleague, it fails the same way
the sheet did.” That is only answerable by doing it.
- Hence the tap counter in the prototype toolbar. It counts every
interaction inside the product — the toolbar itself is excluded — so the flows can be
measured rather than argued about.
What it measured
| Flow | Taps |
| Take three items to an event, from the item list | 6 |
| Pack down — mark a four-item loadout returned | 1 |
| Mark one item returned | 1 |
| Typing the same three item names into a message | ~20 |
Three ticks plus event plus confirm is N + 3 for any
loadout, and packing down is one. That clears the bar the epic set. The
number to defend in review is the +3, not the ticks.
The decisions that make it fast
- Multi-select is the primary path, not a convenience. The whole row is
the checkbox target, not a 16px box beside it.
- The selection bar is stuck to the bottom, not the top. On a phone held
one-handed the bottom of the screen is the only part the thumb reaches without
regripping — and this screen is used standing next to a van.
- Marking something returned is one tap. Capturing condition is offered
afterwards, never required. Requiring it is precisely the friction that emptied
the original sheet.
- Bulk return is grouped by event, because packing down happens all at
once, not item by item.
- Returning is idempotent. A second tap changes nothing — a double-tap
on a phone is normal, not an error.
Try this
- Tick three items, Take to an event, confirm. Watch the tap counter.
Reset it and try the same loadout from Loadouts → open an event → Add
equipment — that path costs one more tap, which is the argument for keeping the
item-list entry point.
- Add an item that is already on the chosen loadout: it says so and skips
it rather than erroring or duplicating. One assignment per item per event; quantity
covers “more than one”.
- Take the Knife roll (marked Lost) to an event. It warns and
allows it — you may simply have found it. Then mark it returned: that one asks
what condition it came back in, because it cannot come home and stay Lost.
Resolves EPIC-006 OQ3.
- Mark any healthy item returned. The condition follow-up appears, phrased as optional,
with “Leave it as it is” beside it.
- Try to delete the Speaker: refused, because it is on a loadout. Marking
it Lost is offered instead — the record stays, its cost stays in the history, and it can
be found again.
- Press Bring everything back. “Nothing is out” reads as a
good outcome, in the success tone — not as an error or an absence.
- Look at the two Induction hobs: same name, same cost, same purchase
date, one Excellent and one Problematic. Two records, two ids. A quantity field on the
item would lose that, which is the modelling decision STORY-028 turns on.
- Look at the Speaker: owner and holder differ. They are equal on almost
every other row, which is exactly why the distinction is easy to collapse — and the case
where they differ is the whole point of the module. EPIC-006 OQ1.
- The Coffee urn has no cost. It was a gift; cost is optional.
- The Serving ware cost is in JPY. Item cost is money and follows
ADR-0005 exactly like a Transaction — same parser, same formatter, digit count from the
currency.
Raised by building it — not yet in a story
- “Still out” and “packed for later” are not the same thing. STORY-030
says the screen lists every unreturned assignment. Read literally that files next
month's loadout under things to chase. The prototype splits them: kit for an event that
has already happened is out; kit for one that has not is a packing list,
and the tab's count excludes it. STORY-030 needs this distinction written in.
- There is no record of when kit physically left. Days-out is computed
from the event date, which is the only date available — so an item loaded into
the van three days early reads as out for three fewer days than it was. Good enough, and
worth stating rather than discovering.
- Quantity is on the assignment but not on the item, per STORY-029.
The Chafing dish set × 4 is the only honest use of it — an item record that genuinely
represents a set. Is “× 4” on an assignment worth the confusion it invites against
STORY-028's one-record-per-object rule?
- Nothing links an item to the Transaction that bought it. Both hold a
cost, and they will drift. Explicitly out of scope in STORY-028, but the two modules now
both exist in prototype and the duplication is visible.
Two bugs worth recording
Presenting a second panel over a first deleted
the first from the document. The panel-in-two-homes pattern moves one element
between a phone view and the dialog. Presenting swapped the modal slot's contents with
replaceChildren(), which does not put the displaced panel anywhere — it drops
it. Opening a loadout and then “Add equipment” therefore destroyed the loadout panel, and
the next lookup inside it returned null. That is a crash on the one path the
whole module is built around. Presenting now returns the current panel to its own home
first.
dialog.close() does not fire its
close event synchronously — the event is queued. So “dismiss this panel, then
immediately present the next one” (which is what confirming a loadout does) races: by the
time the queued handler runs, it restores the new panel to its off-screen home and
the dialog goes blank. A programmatic dismiss now marks its own close as handled; Escape and
the backdrop still fall through, which is the case the handler exists for. The same
dismiss/present pattern is in money-flow/ and core-loop/ — it does
not currently trigger there, but it is the same latent bug, and the fix should travel with
the shell when it is extracted.
Components this found
- Selectable row — whole-row checkbox target with title, meta and a
trailing badge. Used by the item list and both pickers.
- Selection bar — bottom-sticky, count on the left, actions on the right,
stacking to full-width buttons on a phone.
- Group heading with a bulk action — accent edge, title, meta, and the
action pushed to the end.
The three figures
- Per-event profit attributes costs to events. Business profit sums everything. Any cost
belonging to no particular event lands in the second and not the first, and the two drift
apart by exactly that amount, silently. That is the state the reference
workbook was in.
- The fix is not to force attribution — Overhead is real. The fix is to show
it as a line, so the arithmetic is visible:
attributed + overhead = organisation.
- The identity is checked on every render. If it ever failed, the summary
withholds all three figures and says so, rather than showing one that is plausible and
wrong. Press Break the identity in the prototype toolbar to see that state.
- It holds per currency. Nothing is ever summed across currencies and no
exchange rate is applied.
The amount field
- Every amount here is an integer of minor units plus a currency code.
Parsing goes string → integer and formatting goes integer → string →
Intl.
There is no point in the file where a fractional quantity exists to be rounded.
- Decimal places come from the currency. The seed data is EUR (2 digits)
and JPY (0) deliberately. Switch the currency on a typed amount and watch
12.50 become invalid rather than silently becoming ¥13.
- Separators come from your locale, not from a guessed regex. The hint
under the field shows what your browser thinks
1234.5 looks like.
- Direction carries the sign, so the amount is always positive and a
typed minus is refused with an explanation rather than quietly absorbed.
Telling the currencies apart
- The real risk with per-currency figures is not ugliness, it is a reader carrying the
EUR total in their head three inches down the page and reading it against JPY. A muted
“In EUR” heading is nowhere near enough separation for that.
- So on Dashboard and Summary there is a
currency deck: one currency at a time, in a card that names it in 28px
with its full name beside it, stepped through with prev/next arrows and a “1 of 2”
count.
- The usual objection to a carousel is that it hides things people never
find. The answer here is that each control carries that currency's
headline total —
JPY ¥83,000 on the dashboard, its
reconciled organisation profit on the summary. You can see that JPY exists and roughly
what it holds without switching to it. It never auto-advances, and arrow keys work.
- The choice follows you between the two tabs. Switching from Dashboard
to Summary must never silently put you in a different currency.
- Where two currencies genuinely do belong on one screen — the transactions strip, an
event with costs in two — they stay side by side, but the divider gains an
accent edge and full text weight instead of being a grey line.
- It scales: a third currency is a third chip, and the strip scrolls.
The dashboard
- A bento grid: the chart takes two columns and two rows, the lead
figures take two, the rest take one. It collapses to two columns and then to one, and
the spans collapse with it.
- One chart per currency: profit by event. The reader's job is "which
nights made money and which lost it", which is polarity — so it is a
diverging bar either side of a zero baseline, not five bars all
starting from the left edge. Blue and red rather than green and red: they read as
opposite without being the one pair red-green colour blindness collapses.
- Colour is never the signal. The side of the baseline carries the
sign, every bar is labelled with its own value outside the mark, and a loss is worded.
Nothing is lost in greyscale. Each bar is a button that opens that event, and its
accessible name carries income, expense and profit in full.
- Budget utilisation is a meter, not a chart — a single ratio against a
limit. The track runs to whichever is larger, the limit or the actual, so the JPY
overrun shows as length rather than as a bar that has simply stopped.
- Every chart has a table twin — “Show as a table”. The tooltip
enhances; it is never the only way to reach a value.
- What is deliberately not charted. Income, expense and profit are
three headline numbers, and three headline numbers are a stat tile each — a grouped bar
of them would be a chart of nothing. There is no time series either: no date range is in
scope, and a trend line over five events would be an invention.
What differs between the two
- Secondary surfaces. The transaction form and an event's money panel are
one element with two homes: a full screen with a back button on the phone,
a dialog over the page on desktop. Moved, never duplicated.
- The transaction list. Phone stacks each row; desktop gets a real table
with column headers, because comparing amounts down a column is the whole job.
- Filtering. Phone gets one control — direction, or overhead only.
Desktop gets direction, event, currency and category. Same data, same results.
- The chart's labels. On desktop the event name sits beside its bar;
on a phone a 9rem name column would leave the plot about 60px, which is not a chart, so
the name moves above the bar and the track takes the full width. The comparison the
chart exists for survives — only the layout changes.
- Navigation is a drawer on the phone and a sidebar on desktop, with the
same eight destinations as the signup prototype.
Try this
- Record an expense of
0. Refused — a zero row reads as unfinished, and
allowing it makes "unfinished" and "genuinely zero" indistinguishable (EPIC-004 OQ2).
- Type
-40. Refused, and the message points at the direction control rather
than just saying "invalid".
- Enter
12.505 in EUR. Refused rather than rounded — nothing you typed is
silently discarded.
- Type
12.50, then switch the currency to JPY. It goes
invalid immediately, with the reason.
- Switch Money out to Money in with a category chosen.
The category clears and says why — a category belongs to one direction.
- Leave Attributed to an event as Overhead. It saves without complaint,
then appears on the Summary tab as its own line.
- On Summary, open See the transactions behind it. Overhead is
never an unexplained lump — and one of the rows is income, because nothing
requires an unattributed receipt to be a cost.
- On Dashboard, read every basis line. Average profit says over
how many events and which are excluded; Budget utilisation names its denominator.
- Press Force a tie. Most profitable shows both events, not an arbitrary
first.
- Press Empty it. Counts read zero, no average is attempted, and budget
utilisation reads Not set — never 0%.
- Open Tokyo pop-up from the dashboard, then Late-summer supper
club. The second has spend in two currencies against a budget in one.
- Delete a category that is in use: refused, with the count. Add
ingredients as an expense category: refused as a duplicate of
Ingredients. Add it as an income category: allowed — the rule is per
direction.
Nothing here is gated
- The workspace is on Free and sees every figure.
pricing.md
is explicit: no feature gating at launch — financial reporting, inventory,
multi-currency and export are all on Free. You pay for scale, not for
capability.
- What Free limits is active Events (1), seats (1) and contacts (100).
None of those are consumed by anything on these screens, so no limit prompt belongs
here.
- The entitlement seam still exists (STORY-033) — a capability is a
yes/no lookup that currently answers yes on every tier, so gating something later is a
row in the plan catalogue rather than a refactor. It is just never answered no
today, and the UI must not pretend otherwise.
Settled here
- Overhead is a separate line, never apportioned across events (EPIC-004
OQ1, resolved 2026-08-10). Every figure stays traceable to rows.
- Zero amounts are refused (EPIC-004 OQ2, resolved 2026-08-10).
- A broken identity is a hard failure, not a caveat. The figures are
withheld.
- Every figure states its basis in the UI. A bare number on a dashboard is
how a wrong one survives for months.
Raised by building it — not yet in a story
- "Most profitable event" cannot rank across currencies. STORY-024 treats
it as one metric; with a EUR event and a JPY event there is no honest single winner without
a rate. The prototype shows one per currency. This needs a line in STORY-024.
- An event's budget is one amount in one currency; its spend need not be.
Late-summer supper club has EUR and JPY costs against a EUR budget, so its JPY
utilisation reads "not set for JPY". STORY-022 does not cover this case.
- STORY-024 lists charts as out of scope. This prototype has one, and
it earns its place — it is what makes "most profitable event" and the spread the average
sits in legible at a glance. If the chart stays, the story's scope needs amending; if the
story is right, the chart comes out. Not decided here.
- Where does the dashboard live? It sits as a tab under Money here to
avoid inventing a ninth navigation destination. Its three event-count tiles are not
really "money", so the placement is a question, not a decision.
- Editing and deleting a transaction need an actor and timestamp
(STORY-020). Nothing in the UI shows that trail yet — is it a per-row history, or an
organisation-level audit screen?
Settled by clicking it — now in STORY-041
- The Member hard stop stays. Enabling is Owner-only and there is no
inline escape, so a Member who needs an un-enabled currency is stopped twice — they
cannot add it, and cannot reach the screen that would. STORY-013 states the opposite
principle for venues: that sending someone to settings mid-task loses what they were
writing. Walked as a Member here and kept anyway — the everyday list
staying genuinely short is worth more than the rare mid-task block. Press
“View as Member” to see what was judged.
- Renamed zones needed a small alias map — and it is not the committed list
i18n.md forbids. supportedValuesOf('timeZone') lists
only one half of each IANA backward pair, and which half is
version-dependent: this browser has Asia/Calcutta and not
Asia/Kolkata; a newer one reverses it. So “Kolkata” found nothing. Matching
the zone's long name does not help either — that is “India Standard Time”.
Fixed with ~14 pairs applied in both directions, and the row now
shows the current city name with the identifier beside it: heading a Kolkata search with
“Calcutta” makes a user doubt they found the right place. Try kolkata,
calcutta, kyiv, kiev, yangon. The map is presentation
only — the stored value is always the runtime's own identifier, and every zone stays
selectable whether or not it appears in it.
Components this found
- Figure — label, value, and basis. Used by every tile, every total, and
every reconciliation row. The most reused thing in the flow and the one most worth
promoting into
design/components/.
- Money input — amount plus currency as one control, with
currency-derived precision. STORY-020 calls it "the highest-risk field in the product".
- Segmented radio group — real radios in a fieldset, used for Direction
here and reusable wherever a choice is binary and worth showing both sides of.
- Diverging bar and meter — the two chart forms this
product actually needs. Both are plain HTML and CSS with no library, and both are
theme-aware because every value is a token.
- Currency deck — the switcher plus its card. Its one real idea is that
the controls carry each option's headline figure, so nothing is hidden by being
off-screen. Reusable anywhere one of several parallel sets of figures must be read
without being confused for another.
Two chart-spec deviations, on purpose
- No 48px hero figure. The type ladder tops out at 36px and
design.md rejects off-scale values, so the lead tiles use 28px and the
emphasis comes from the grid instead. Rules outrank a skill.
- The meter's unfilled track is the neutral sunken surface, not a
lighter step of the fill's own hue. There is no light accent step among the semantic
tokens, and reaching into the private palette is forbidden.
Both mark colours were
measured, not eyeballed: --color-accent is 4.62:1 light / 7.13:1 dark against
the card, --color-danger 5.44 / 7.48, and the zero baseline in
--color-border-strong 3.23 / 3.01 — all clear of the 3:1 floor for a
meaningful boundary.