Max
Max is rule-based. Everything Max says is computed from the plan, its attendees and its items: there are no AI calls and no tables of its own.
Two rules shape the code:
- Max drafts and proposes. The host sends and approves.
- Max only says things that are true of the plan as it is. Nothing is invented.
The logic: lib/max/
These are pure functions, with no database or browser access, so they are easy to test.
| File | What it does |
|---|---|
insights.ts | getMaxInsights decides what Max has to say to a given viewer |
split.ts | proposeSplit suggests who brings each free item |
messages.ts | Builds the invite, the recap, the nudge and the "who can bring this?" text |
activity.ts | Lists what happened on the plan, from the rows' own timestamps |
parse-plan.ts | Reads a title, date, time and place out of one sentence |
plan.ts, types.ts | The slice of a plan Max works with |
Insights
An insight is one thing Max has to say, with its actions. getMaxInsights returns them for one viewer:
| Rule | Who | When |
|---|---|---|
answer | Anyone | The viewer has not answered, and someone else has |
take | A guest | The viewer is in, brings nothing, and an item is free |
share | The host | No guest has answered yet |
nudge | The host | A guest answered "maybe" |
split | The host | Items are free and someone is in. It needs the host's OK |
ask | The host | Items are free and nobody is in |
Each insight's id includes a fingerprint of the situation, for example the ids of the free items. A viewer can dismiss an insight, and it comes back only when its id changes. Dismissals are kept in the browser, under duplan.max.dismissed.<slug>.
To add a rule, add it to getMaxInsights, give its message and action labels a key under max in the three message files, handle any new action in useMax, and cover it in tests/unit/max.test.ts.
The split
proposeSplit gives each free item to the attendee who is coming and carries the least so far. Ties go to whoever answered first. Approving it calls assignItem once per item: the same host-only action as assigning by hand, so the server enforces who may do it.
Reading a sentence
parsePlanSentence(text, locale, now) works with regular expressions on a lower-cased, accent-stripped copy of the sentence. It finds a date, then a time, then a place, and takes the start of the first sentence as the title. Word lists exist for English, French and Spanish.
It is best effort. The create flow always shows the result and lets the host correct it, and the function can be replaced without touching the UI.
The UI: components/max/ and lib/hooks/useMax.ts
useMax turns the logic into what a screen needs: the insights that have not been dismissed, the activity list, and what each action does (open the answer sheet, take an item, share a drafted text, open the split).
| Component | Where it shows |
|---|---|
MaxCard | Inline in the plan, next to the section it is about. For everyone |
MaxFab | The round M button and its menu, on a phone. Host only |
MaxEdgeTab | The tab and side panel, on a tablet. Host only |
MaxPanel | The docked column on a wide screen, and the content of the tablet panel. Host only |
MaxQueue, MaxSplitDecision | The approval queue and the split decision, at /p/[slug]/max |
MaxInviteCard | On the "plan created" screen |
Which of these shows at a given width is decided in CSS, not in JavaScript.
Max's surfaces render only in the browser, after the device's identity and dismissals are known. That keeps the server's HTML and the first client render identical.