Bygga
Type the product name from the box. Bygga shows every assembly step in 3D, with the parts, screws and tools for each one, and reads it out loud.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Jio
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗Jio — Singlish, "to invite someone along."
A progressive web app for the daily question nobody wants to be the one to ask: where are we eating? It learns what you actually like from what you rate, runs a ranked-choice vote when a group has to agree, and stays out of the way the rest of the time.
Runs on free tiers end to end. Works on a phone and on a desktop, and installs to a home screen.
You need Node 18 or newer.
npm install
npm run dev
Open http://localhost:3000.
That is the whole setup. With no configuration at all the app runs in demo mode: an in-memory store with 22 places around Bras Basah and Bugis, two invented teammates with opinions, a lunch group, and one Jio mid-vote. No accounts, no keys, no database. Everything resets when the dev server restarts.
When you are ready to make it real, see Going live.
| Jios | Create a lunch outing, everyone ranks the options, a Borda count decides. RSVP, live vote updates, a roulette wheel for when the group genuinely cannot choose, and a month calendar with Hosting / Invited / Past filters for browsing, plus a Calendar / List segmented toggle labeled by which view is active rather than which one tapping it switches to. RSVPing itself — not just seeing who answered what — stays available after the Jio closes too: "Who's coming" breaks the list down into Going / Maybe / Can't make it for everyone to see, not just the host, and the "I'm in" / "Maybe" / "Can't" buttons stay live regardless of open or closed, since a host inviting someone new post-decision (below) would otherwise have no way for that person to actually answer. A host can also add or remove invitees at any time, before or after, from an "Invited" section on the Jio itself (host-only; a linked Kaki's members aren't individually listed there — they come and go with the group; a plain-language note there says "Only the host can add people to this Jio," UX review log #19, since that default was never communicated anywhere in the app). A decided Jio whose date is still ahead reads "Going to X," not "Went to X" — closing the vote early doesn't make the lunch itself any less upcoming, and both the Jios list's own "Up next" and Home's "Upcoming" (above) key off the same real-date logic rather than treating closed as synonymous with "already happened." Removing someone also drops their own RSVP, ballot and any Flexi date-availability on that Jio, so a stray response doesn't linger for someone no longer in it; whatever they added (an option, a candidate date) stays for everyone else. A Flexi Jio also takes a shared time when its candidate dates are set (noon by default) — the same time carries onto whichever date the host later confirms, built via an explicit +08:00 offset rather than left as a bare "YYYY-MM-DD," which always parses as UTC midnight (8am once shown in Singapore time). Anyone can edit a place, any signed-in user may edit any place's details, and the host can cancel a Jio outright — it stays visible, marked cancelled, rather than vanishing. Once a Jio is decided, its result — place, and a top-3 vote breakdown as a compact bar chart (the winner's row in ember with its point count folded into the bar itself, the rest in one shared muted tone, capped at 3 rows and omitted entirely if nobody voted at all) — can be copied or shared as a PNG, generated client-side, for pasting straight into a chat instead of a link. Sharing carries a plain "Jio confirmed!" caption alongside the image, deliberately not the winner's Google Maps link — a shared link in the caption text reliably makes at least one common share target (WhatsApp) drop the attached image entirely and show only a link preview instead, worse than the image alone. The Maps link is offered as its own "Copy Maps link" action next to Share/Download instead, so it can still go out as its own message rather than silently costing the image. A place name is a link wherever it's shown while voting or once decided — Your ranking, Standing, and the Decided banner itself — with a small map-pin icon next to it opening Google Maps in a new tab, and a second icon alongside it (Instagram, Facebook, or a plain link glyph, sniffed from the URL's domain) when that place has a socials link set (CHANGES_20260821b.md §1); a free-text option with no real place behind it stays plain text, nothing to link to. The host can also correct either side of a Jio after the fact: its date/time, at any point short of cancelled (even after it's closed — using this on a still-polling Flexi Jio finalizes the date the same way confirming a candidate does, just not limited to the pre-listed candidates), and once closed, which place it actually ended up at ("Where did you actually go?"), accepting any real place rather than only one that was ever a voting option. The winner correction only touches the Jio's own record — it doesn't retroactively log a visit or move anyone's personal metrics. The Jio's own name is a third correction in "Edit this Jio," ("Change name") — purely cosmetic, so unlike the date/time and winner corrections it stays available at any time at all, including once cancelled, with no state-machine gate of its own. A host can also undo a close outright — "Reopen for voting" — at any time, including once the Jio's own scheduled time has passed (098_reopen_any_time.sql; the original "only while still upcoming" restriction assumed a closed Jio past its scheduled time always meant the lunch itself had happened, which the lazy vote-deadline close bug report below showed isn't true); existing ballots aren't touched (a vote already persists until its owner recasts it, same as any other voting), only the recorded winner and close time are cleared, since closing again recomputes both. Unlike the winner correction, this is a structural state change back to open, so it goes through its own gated database function rather than a plain write, the same reasoning behind the dedicated function cancelled already gets. Reopen works the same afterward regardless of how the Jio closed — by the host's own hand or the vote-deadline sweep below. A Jio used to close itself (CHANGES_20260821_combined.md Part 2), no host action required, the moment every participant had RSVP'd Yes or Can't (a lingering Maybe, same as silence, kept it open) and everyone who said Yes had cast a ballot. A confirmed real bug in that full-consensus path — "Jio keeps closing whilst new members trying to add their votes in" — was more fundamental than a fixable race: a Jio anyone can still join, via its own share link or (kaki-linked) just by being a member, can never have a genuinely final population, so "everyone's answered" is a moving target. Someone opens the link, joins, votes, and if everyone already joined had answered, the Jio closed right then, before whoever was about to open that same link a moment later ever got the chance. It doesn't auto-close any more. The same condition (every participant answered, every Yes backed by a fresh vote) is still computed, live, on every page load — EventDetail.readyToClose — but only ever surfaced as a prompt right under the share link on the host's own page ("Everyone's answered — ready to close?", with its own "Close with the vote" button), not acted on by itself; a new joiner un-readies it the instant they show up, same as it would've blocked the old auto-close from ever having fired in the first place. The host also gets pushed the same prompt (reusing the existing "someone voted" push and its throttle) the moment an RSVP or a vote makes this newly true. A host can also set a vote deadline at creation ("Voting closes," a duration picker — 30 min up to 24h before the Jio's own start time, defaulting to 3h, or "No deadline") that force-closes voting once it passes, regardless of who's still pending — genuinely automatic, and safe in a way full-consensus never was, since a deadline is a fixed point in time, not "whoever's joined so far." The deadline is editable any time the Jio's still open ("Edit this Jio"), shown as a live countdown on the Jio page itself and in the signed-out /e/[token] preview, and mentioned in the invite push. A Flexi Jio takes the same setting at creation but only resolves an actual deadline once its date is confirmed; a recurring series carries its own configured offset onto every occurrence it generates, propagated onto an already-generated still-open one the same way time_of_day already is (unlike place/mode/invitees, moving a deadline doesn't invalidate anyone's existing answer). Enforcement is a cron sweep (/api/cron/vote-deadline, same external-scheduler requirement as the "starting soon" reminder — see Free-tier realities) that also sends a one-time "voting closes soon" push to whoever's still pending, an hour out by default. A bug report found the gap in relying on that cron alone: without it actually configured, a Jio past its own deadline just sat open forever — the countdown already read "Voting: Closed" (a plain client-side comparison against the deadline), but nothing had force-closed it server-side, leaving a voter unable to tell whether their ballot would even count and the host with no "Reopen" button to reach for since there was nothing yet to reopen. GET /api/events/[id] now also runs the identical close check lazily, scoped to just the one event being loaded (097_lazy_vote_deadline_close.sql) — the same "cron plus a lazy page-load fallback" shape already used for the starting-soon reminder. Since the write is host-gated under RLS but this fires from any viewer's page load, the actual close goes through a SECURITY DEFINER function that independently re-checks every guard condition (open, non-polling, deadline actually passed) before writing anything, rather than trusting the caller. As soon as the date is fixed — for a Flexi Jio, once its poll resolves; otherwise from the moment it's created — "Add to calendar" unlocks: a one-tap Google Calendar link and a .ics download, deliberately available before the Jio closes or a place wins (so nobody has to wait on the group just to block their own calendar), showing "Place TBD" until a place is decided. Both are a one-time snapshot at a fixed 1-hour duration rather than a live sync — if the Jio's time or place changes afterward, an already-added calendar entry doesn't follow. It's unavailable only once a Jio is cancelled. A host can also start a Jio with votes hidden: nobody, host included, sees the running standing until it closes — only the ballot count shows. Whether votes are hidden can also be toggled after the fact, either direction, in "Edit this Jio" while it's still open — a recurring Jio's occurrences have no creation-time form of their own to set hide_votes on up front, so this "Hide votes" / "Reveal votes now" toggle is the only way a host ever hides one of those, and hiding even once some votes are already visible is fine, since anyone can still revote. A plain host-gated column write same as the winner correction rather than its own SECURITY DEFINER function, unlike cancelled/reopen. Each option in the Standing and Your ranking lists shows a compact cuisine descriptor beneath its name, so a placeless or unfamiliar option still says something about what it is before anyone clicks in. Following a shared event invite link (/e/[token]) while signed out now shows a narrow preview first (CHANGES_20260821_combined2.md §3A) — host name, a rough going-count, and the place options as plain chips, no votes/invitee identities/RSVP names, same "unguessable token, SECURITY DEFINER resolver" shape as /p/[id]//l/[token] — with "Sign in to join" as the one way through; a signed-in visitor still gets bounced straight to the real event page as before. An account's first-ever "Start a Jio" tap (CHANGES_20260821_combined2.md §3C) also pre-checks a co-attendee list — the host plus everyone invited to the most recent Jio this account joined as a guest — in "Who is coming," rather than starting from the usual empty picker; each name is still a removable chip, and the search field is still right there to add anyone else. This is gated on having never hosted before, not on being new — the very next hosting attempt, and every one after it, gets today's ordinary empty/ranked picker. Any Jio an account RSVP'd (any answer) and voted on, once decided and still upcoming, gets a one-time celebration above the everyday "Decided" card on that Jio's own page (originally first-Jio-only — CHANGES_20260821_combined2.md §3D — generalised to every qualifying Jio by UX review log #25): three pebbles converge from scattered positions into the real JioMark's exact path geometry against a coffee-toned backdrop, its centre dot recoloured a lighter warm tan (a local override of JioMark's own --color-espresso variable, scoped to just this one usage — true espresso-on-espresso would be invisible against the backdrop) rather than a separately-sized stand-in disc, which only ever produced either a hidden or a ringed circle once the real mark settled on top of it, with a plain-text aria-live announcement riding the same trigger. A full-screen splash, not an inline page card — a real report that the card version could land scrolled out of view (and its own entry animation missed with it) below wherever the page happened to be scrolled after the vote or RSVP that triggered it — dismissed by its own explicit "Continue" button back to the Jio underneath, never a timer or a backdrop tap. Not gated on catching the live open-to-closed transition, since most Jios close while nobody's watching (the vote-deadline sweep, or the host closing it) — it fires on whichever qualifying decided Jio's page this account next happens to load, stamped immediately (one row per (user, event), not an account-wide flag) so it can never show twice, and a Jio whose lunch has already passed by the time it's seen goes straight to the resting record instead. The shareable result card is a hawker-order-chit-styled ticket, not a plain rectangle — perforated notches, a dashed divider, serif headline type and the app's own logo watermarked into the header, all drawn straight onto the canvas — with "Copy Jio link" as its own button alongside the existing Copy image/Download/Copy Maps link actions. The create form also takes an optional free-text notes field (parking, dress code, anything else worth knowing) — shown to every invitee on the Jio's own page and in the signed-out /e/[token] preview alike, set once at creation with no edit path. Any place option can also carry its own short note ("opens at 12:30pm instead") — distinct from the Jio-wide notes above, since this is context about one specific place, not the outing overall. Only whoever added that option may set or clear it (deliberately narrower than removing an option, which the host can also do regardless of who added it — a note is someone claiming to know something specific, not a moderation action), edited inline right where an added-by-you place already shows in "Add a place," and surfaced everywhere that place appears while voting: Standing, Your ranking, and the add-a-place list itself. Also settable at creation, not just after — the create form's place picker grows the same per-place note input beneath whatever's currently selected (fixed-date Jios only; a Flexi Jio has no place options until its date resolves), reusing setOptionNote server-side right after createEvent rather than threading a notes map through that already-long parameter list. Bug report item 4 — a new place added to an open Jio now marks every already-cast "yes" ballot stale for readyToClose purposes: options_changed_at bumps whenever a place is added, and a ballot cast before that point no longer counts as "voted" until its owner recasts it (even the same ranking), with a plain banner on the Jio page — "A new place was added — recast your vote so it counts" — telling them why. A related but distinct bug: "Your ranking" is seeded once from the server and then left alone so a live refresh can't yank options out from under someone mid-drag, merging in any newly-arrived option id once that merge logic has run at least once — but a report found that merge never actually ran until the voter had already touched their ranking in this session, and a ballot holding exactly one place has no way to do that at all (both reorder arrows are disabled with only one row), so a newly added place could never appear in "Your ranking" to begin with — not merely unmerged yet, genuinely stuck with no way in. The initial seed now runs the same merge the touched state already did, so a freshly loaded page always shows every current option, ranked ones first in their existing order, anything new appended unranked at the end. This only ever gates the readyToClose prompt; the vote-deadline sweep is deliberately unaffected and still closes with whatever ballots exist once its own deadline passes, staleness or not, so a bounded close is never blocked by someone who never comes back to revote. The winner correction's place search only ever matched real places (winner_place_id has to resolve to one — Maps links, budget info, everywhere else that joins on it), so typing the exact name of a free-text ("not here? add it anyway") vote option that actually won found nothing and silently did nothing, with no indication why. The search now recognizes when the query matches one of this Jio's own free-text options and says so directly, linking straight to registering it as a real place (/places/new?...&fromEvent=...&draftPlaceId=..., the same "attach on save" flow a free-text option's own persistent link already uses) — search again afterward and it's a real candidate. A query matching neither a real place nor a free-text option gets a plain "add a new place" fallback instead of silence either way. Correcting the winner also stamps winner_corrected_at (cleared on reopen, same as winner_place_id/closed_at) — the group's own ballots never decided this result, so both the Final count list's winner row and the share chit's summary line swap the points clause for "Overruled" once it's set, rather than attributing a vote count (possibly zero, possibly for a place that was never even an option) to a result nobody actually voted for. |
| Vote on a place that isn't listed yet | Typing a name the search doesn't find logs it as a vote option immediately, no place record required. A non-blocking prompt afterward offers to add it to the pool; declining leaves it exactly as a text-only choice, permanently — but not stuck that way: the same map-pin-icon slot a registered place gets on Standing carries a small "+" link on a free-text option instead, a permanent way for any Jio participant (not just whoever typed it in, not just in that one moment right after adding) to register it as a real place later, while the Jio is still open. |
| Kakis | Lunch groups with a shareable invite link and shared stats — group favourites, who eats out most, who is most adventurous, with the same dashed-border empty state as everywhere else once nobody's logged a visit yet. Any current member can also add someone directly by searching their name, without a link changing hands first — same trust level as the invite link itself, just faster for someone already sitting across the table. Leaving a group asks for confirmation first, same as cancelling a Jio — there's no path back short of a new invite. "Who eats out most" and "who is most adventurous" now live on a dedicated group-vibe card (CHANGES_20260821_combined2.md Item 1) rather than as plain stat tiles — a locked monthly snapshot, same cadence and share button as the personal food identity card, alongside a third "Trailblazer" award for whoever's own visit history includes the most places no other member has ever been to (distinct from raw visit count or raw distinct-place count, either of which tends to just reward whoever eats out the most). The card's own cuisine/spend "vibe" numbers are weighed equally across members rather than pooled by raw visit count, so whoever logs the most visits doesn't silently become "the group's taste" — a member with 3 visits counts exactly as much as one with 50; the literal totals (total visits, distinct places, favourite places) stay pooled, since those are genuinely about collective volume, not opinion. Tapping any award row expands it into that award's full per-member ranking, not just the single winner — computed live off the group's current visits, independent of whichever month's headline/description is locked in. Who's actually shown as crowned in each slot also runs through a "home kaki" rule: someone who's simply the most active/adventurous/novel person across several of their groups is crowned only in their single best one (with a small "🔥 N-kaki streak" badge once that's 2+), while every other group where they'd also be #1 quietly promotes its own runner-up instead — shown with a small italic note ("[Leader]'s leading here too … — crowned in [group]") rather than silently. This is resolved fresh on every page load (nothing about it is persisted), so a changing streak takes effect immediately with no cooldown. A "Fresh reviews" card sits at the bottom of the page, right after group favourites — the last 3 public reviews from any member, anywhere, not limited to somewhere the group has actually been together (a broader "what are my Kaki-mates eating lately" than the group-favourites stats already give). Each one links through to its place and carries the same like button reviews get everywhere else. Private visits never surface here, same privacy floor as the rest of this page. Creating a new group can now invite others straight away — the same name-and-invite picker a Jio uses, minus the option to pick a whole other group (adding a group to a group makes no sense). A decided Jio also grows a "Turn this into a Kaki?" card once its exact group of participants doesn't already share one — host-only, and gone for good the moment either the host dismisses it or actually creates the group (accepting doesn't write anything special; the suggestion just stops finding a gap to fill). Its own "Turn into a Kaki" button lands on /kakis with that exact group pre-checked in the picker, so the host can still remove someone before confirming. Any current member can rename the group, not just whoever created it — same trust level as adding someone directly. A "Start a Jio with this group" button sits right under the header, member-only — lands on with that Kaki already picked in "Who is coming" (and its name in the page's own subtitle), the same prop every other prefilled-start path already uses ("Start a Jio with them" from a personal invite link, a lobang's "Start a Jio here," repeating a past Jio) rather than new form plumbing. A sits right after the group-vibe card, one step below it: a shared, group-owned counterpart to the personal wishlist (, migration 093) — "we should try that sometime" said once in the group chat now actually survives, since anyone in the group can add a place (search-to-add, same place search the recurring-Jio and rating forms already use) or clear one, not just whoever added it — same "any member is trusted with the group's shared state" reasoning renaming the group itself already runs on. Each row's "Jio this" jumps straight into the same inline already uses, with that place and this Kaki pre-selected — wiring onto existing form props (/), not new form logic. Filtered to the place still being wherever it's listed (confirmed: filtered from view, not pruned from the table, if a listed place later gets blocked — same convention the Jio "Suggest 3" ranking already applies) — recoverable if the place is ever unblocked. That same ranking () also gives a kaki-wishlisted place the same bump toward "personalized" an individual's own wishlist entry already gets, whenever the Jio being suggested for actually belongs to a Kaki — additive, decided locally rather than threaded through the deeper per-member scoring itself runs, since a group's shared wishlist is a different signal from any one member's personal one. |
| Spending | A private, per-user spending ledger (expense_entries, migration 094) — deliberately not the same shape as a Visit: most entries have no formal Place at all (a coffee run, a snack from an unlisted stall), so place_id is nullable and what was bought is a free-text label — but never only a lookup-free label either, since it doubles as an opportunistic place search (migration 095 also widened category from lunch/coffee/snack/other to add breakfast and dinner, six in total). "What was it?" runs the exact same /api/places?q= search every other search box in the app already uses, live under the field; picking a suggestion fills the label with that place's own name and links place_id, exactly the way Visit-created entries already do — but nothing forces a match. Typing straight past the dropdown, or editing the text again after a pick (which clears the just-made link, so a stale match never silently rides along with newly typed text), saves whatever's there as a plain label with no place attached, same as before this existed. The same search-then-fallback now also runs on an edit, not just a new entry, so a match missed at capture time (or one that needs correcting) isn't stuck that way forever. Two ways in: an optional amount right on the existing "rate this place" form (/places/[id], shown only when logging a brand-new visit, never an edit) — logs a lunch-category entry tied back to that visit via source_visit_id; and a standing quick-add on the /profile/spending page itself, reachable in two taps from "You" → the "Usual spend" tile. That form's own field reads "How much did you personally spend?", not the vaguer "How much did it cost?" it originally shipped with — real usage found reviewers reading "it cost" as the whole table's bill (this being a review form, not obviously a personal-tracker one), and logging a group total instead of their own share; the hint spells it out further ("just for your own private spending tracker — not the group's bill, and never shown on the review"). /profile/spending itself opens on an "Insights" tab by default, not the personal ledger — a budget-tier breakdown (BudgetTierInsights, shared with /profile/visits?view=spend) computed entirely from places already visited, needing not one logged amount — since most people will never manually track a dollar figure, and landing them on an empty ledger first (Jio's "warm," not-another-chore philosophy) undersold what the app already knows about their spending without asking for anything. A second "My tracker" tab holds everything below — the month nav, quick-add and entry list — one tap away, not removed, just no longer the first thing shown; the "Usual spend" tile itself links straight into "My tracker" () once it already knows real entries exist, so someone already using it isn't demoted behind an extra tap every time. The page also carries a "← Back to You" link at the top, matching every other subpage. That page is a real route, not another profile card: a month filter (« August 2026 », next disabled once back at the current month — nothing logged before someone starts using this, so no lower bound needed beyond that), a summary card (month total, a by-kind bar breakdown, and a plain-language sentence comparing the month's average entry against the account's own budget preference, reusing 's -tier formatting rather than a bare dollar comparison), and the entry list itself — every entry that month, newest first, (20 per page, confirmed over infinite scroll — pairs with the month filter's own bounded-unit paging, and the first user-facing pagination pattern in the app). Each row supports both (confirmed — typo'd amounts and mis-tapped categories are common enough that delete-and-re-add alone wasn't worth shipping as final), with "· From a review" the only trace of an entry created via the rating-form path — the list otherwise treats every entry the same regardless of how it was logged. An entry whose source visit is later edited or deleted survives untouched, just unlinked (confirmed: is , not cascade — a personal ledger entry never silently disappears because of something that happened to a review, though the two can drift apart, e.g. the visit's date moving without the expense's following it). Always strictly private — no policy on ever grants read access to anyone but the row's own , unlike the wishlist above or a 's own split; this is the one piece of financial-shaped data in the app that never becomes a shared or group-aggregate number the way already is. Deliberately not built yet: teaching //the Food Identity archetype to prefer a real logged average over a place's fixed price tier once enough amounts exist — safe to defer, since the ledger is useful entirely on its own first. |
| | Receipt photo → scanned line items → who owes who — its own top-level nav item (), not an extension of the private ledger above (099_bill_splits.sql): a split is inherently shared/multi-person, the opposite shape. Stands entirely on its own — no Jio required at all — with an link to one (, or "Start a split for this Jio" on a closed Jio's own page) purely as a convenience: linking just pre-fills who's splitting it from that Jio's confirmed-going RSVPs (falling back to every resolved participant if nobody RSVP'd), still fully editable afterward, never a ceiling. Who's in a split is built up by hand either way — search people and whole Kaki groups in one box (, the same widget a Jio invite uses), or "+ Add a guest" for someone who was never a Jio user at all, just a name and an optional phone number. A guest can owe money and get WhatsApp-nudged and marked paid exactly like a registered person, just never push-notified — there's no account to push to. The photo is downscaled client-side (a canvas resize, re-encoded JPEG) before it ever leaves the browser, then sent to Claude's vision API with a forced tool-use call for structured extraction — this app's first metered, per-call external API dependency; see Free-tier realities for . Every extracted item stays fully editable before anything saves — OCR on a real, sometimes-crumpled receipt won't always be perfect, so it only ever has to be "good enough to start from," and "Enter items by hand" skips OCR entirely for the same editable screen at zero cost. A top-level choice sets how the whole split is computed: (the itemized flow above) or — one subtotal, divided equally across everyone in the roster, no items at all, reusing the exact same per-person math as a single item shared by the whole table rather than its own calculation path (so GST/service charge, below, split evenly across that one shared "item" the same proportional way they'd split across any item several people share). Switching between the two never discards the itemized list; only decides which one actually gets sent. "Split evenly"'s own subtotal is live, not a one-time snapshot, while the host hasn't typed a number into it directly: it tracks whatever the (shared) item list currently sums to, so editing items under "Go Dutch" and then switching to "Split evenly" shows the current total rather than whatever was true the moment the editor first opened (the field only freezes at a manually typed figure, or a scanned subtotal/items on the very first mount, same one-way "don't clobber a real number" rule GST/service charge already follow — a production report caught this going stale after editing items post-scan). Within "Go Dutch," any item can be marked — a one-tap assign-all shortcut instead of tapping each person's chip individually; it's a derived checkbox, not a stored flag, so adding someone to the roster afterward un-checks it rather than silently growing who owes until re-confirmed. An item shared by several people (by hand or via "Shared by everyone") splits evenly across however many are checked; GST and service charge are each independently toggleable, in either mode, and split to each person's own item subtotal, not evenly — someone who had the $80 steak carries more of the service charge than someone who had a $4 kopi, same as a real bill would (in "Split evenly" everyone's proportion is identical by construction, so the proportional math still lands as an even split — the same formula, not a special case). Ticking "Split GST"/"Split service charge" on auto-fills the amount at Singapore's usual rate (9%/10% of the subtotal — the item subtotal in "Go Dutch," the one lump subtotal in "Split evenly" — GST compounding on top of service charge if that's also ticked, since GST applies to the full taxable supply, which includes the service charge) and keeps it live from there — editing the subtotal recomputes the shown figure on every render, not just once at the moment of ticking, and it's shared across both modes so switching between them doesn't reset it — until the field is typed into directly, which ends live recalculation for that field only, one-way, so a manual correction or an OCR-scanned real receipt figure is never silently overwritten by the formula; the other field, still in auto mode, keeps recalculating independently. Every dollar amount in this feature (item price, GST, service charge, the "Split evenly" total) is a plain decimal-text field, not — the latter re-formats to a fixed "X.XX" on every keystroke, which on mobile makes a "0.00" placeholder unreliable to clear and type over; this tracks the exact string being typed and only parses it to cents, so backspacing it and typing a fresh amount works the way a text field normally would. An item with nobody assigned is flagged, not silently dropped or charged to the payer. Discounts (101_bill_split_discounts.sql) come in two independent, combinable forms, always applied before GST/service charge so tax is only ever charged on what was actually paid: a flat (a membership/promo discount, its own field under the item list, shared between both modes) and a , dollar amount or percent — whichever the host used, a small / toggle next to the amount — with its own scope choice: "Just for them" reduces that item's own net amount directly, so only its assignees ever see it, the same effect as the item simply being cheaper; "Share with everyone" instead pools the discount and splits it across the roster weighted by each person's own order amount — the same proportional mechanism GST/service charge already use to split across the table, reused here rather than inventing a second allocation scheme, so someone who never even ordered the discounted item can still end up with a bigger slice of it if they ordered more overall. An item nobody's assigned to contributes no cost and no discount either, pooled or otherwise — it isn't really part of the bill yet, so a "shared" discount on it never quietly hands everyone else a rebate for something nobody's actually paying for. A linked Jio can have at most one split at a time (starting a new one for it replaces the old, same "corrections stay available" spirit as the rest of that page); a standalone split has no such limit, and lists everything you've started or are a participant in. Saving sends each registered non-payer a push ("you owe $X") the same way every other collaborative action here does; for whoever doesn't have push enabled, or is a guest, (below) is the notification path instead — one broadcast posted to the group's own chat rather than a per-person link, and this app already assumes WhatsApp is the team's actual channel (see the Jio-result share card's own reasoning about WhatsApp and link previews). A host-only "Mark as paid" toggle tracks collection progress per person. Notifying people who owes what is a single broadcast, not a per-person chore: rather than the host keying in each participant's own phone number and sending them an individual nudge, the payer sets just their own number once (shown on the summary below so people know how to pay them, same column, just the payer's own row now) and turns the live breakdown into a shareable card — a canvas-rendered image (Copy image/Share…/Download, the same actions 's Jio-result ticket already offers) plus a "Copy summary" plain-text button (, ) — meant to be posted once into the group's own chat rather than sent to each person separately. Explicitly out of scope: no way to attach a Jio after the fact or unlink one already linked — that choice only happens at creation — no self-serve "I've paid" tap from the payer's own side (host-marked only, same trust level as every other correction on this page), and no reuse of the payer's number across splits — captured fresh each time on purpose. |
| | A personal invite link (, with a QR code on your own profile for the across-the-office case — also reachable in one tap from a scan icon right next to the "You" page title, for "someone standing next to you asking to be added," sharing the same generated link rather than minting a second one) opens straight to that person's profile card — "Start a Jio with them" / "Add them to a Kaki," no friend-edge created just by viewing it. Every teammate picker (starting a Jio, adding to a Kaki, sending a lobang) is ranked, not alphabetical: people you've actually shared a Jio with first (frequency × recency), your Kaki co-members next, everyone else in your office last and search-only. The personal link is the bootstrap, co-attendance is the sustain — share your link once, have one Jio together, and from then on you're near the top of each other's pickers automatically. |
| | "Saw this online, thought of you" — send any registered place to specific teammates, a whole Kaki, or — one tap over — anyone at all, with an optional note and personalized "quick pick" suggestions for a teammate/Kaki send. "Share this lobang" is the one entry point on a place's own page (), same composer either way; a past Jio's "You → Past Jios" is the other, with the winning place pinned as a default. A teammate/Kaki send is tracked, shows up in the recipient's feed, and push-notifies them — and once it's sent, an optional "Let them know" step offers the place's own public page (, no sign-in needed to view it) to share or copy, a manual nudge for anyone who won't notice the in-app inbox or a push right away; the "Anyone" path instead mints an attributed link — the sender's real name and note, resolved server-side so nobody can forge one — that anyone can open, signed in or not, and never appears in anyone's Lobangs feed or triggers a push. A dedicated page (linked from "See all" on the profile inbox) shows every send, received and sent, as one reverse-chronological, message-bubble feed — your own sends right-aligned, a group send showing the Kaki's name as the recipient. A received lobang takes a heart (toggle, per-recipient, same shape as a review like), "Start a Jio here" (a one-tap shortcut straight into with that exact place already selected), and a comment thread — every lobang, however it was sent, has exactly one shared thread (, ) that the sender and every recipient of that send can all read and post into, back and forth, like a comments section under the lobang itself (a group send to a whole Kaki means one thread the sender and every member of that send share, not a private one per recipient). This replaced an earlier model — one freeform text reply per recipient, silently overwritten by a second reply rather than threaded, and postable only by the recipient, never the sender — after a report ("tried to reply a reply for a review, got a white screen") traced back to a genuine gap: no reply/comment feature existed anywhere in the app in the shape people expected, and the one "reply" that did exist could never hold more than one message or go both ways. Each lobang card shows a running "N comments" count that expands the thread in place; posting notifies (push, throttled the same way 's vote pushes are) everyone else in that thread. Liking, Jio-ing, and commenting used to only be reachable from the profile page's own inbox preview — itself was a strictly read-only feed; a report ("Lobangs page should show all interactions") confirmed that read as a bug, not a deliberate scope cut. Both surfaces now share one component (the thread itself is a nested , styled as a light "popout" card when it sits on the ember sent-bubble background), so the two can't drift apart again the way they just had. A sent lobang's own bubble on shows the same thread, read-and-postable, plus "· liked" inline with the timestamp when the recipient has liked it — the sender is a participant in their own send's thread now, not a bystander reading a one-shot reply back. A received lobang used to be reachable only by scrolling well down "You" — it now surfaces two more places the moment it's unseen: a card near the bottom of Home (same "worth surfacing, not worth interrupting" spirit as the availability/add-to-home-screen cards above it), and a "Lobangs" tab on Places itself, right where "where should we eat" actually gets decided, showing each one as the same place card every other list here uses, the sender's note in its "why" slot. Both surfaces resolve themselves the moment the lobang is actually viewed — "You" 's own inbox stays the one place to browse full sent+received history and dismiss individual ones. |
| | On a plain, unfiltered browse (no active search or cuisine filter), two personal-suggestion rails sit above the main list — "Quick & nearby" (your top-ranked picks, closest first) and "New to try" (top-ranked picks you've never personally logged a visit to) — replacing as a standalone destination (UX review log #6; the old page is retired, but itself lives on, powering these rails and a Jio's own ballot-builder). That ballot-builder is the same "Location" chip + filters + chip-wall panel in two places now — the create-a-Jio form, and a Jio's own detail page once it already exists ("Add a place," open to the host and every invitee, not host-only) — sharing one component so the two stay in lockstep rather than drifting into two different experiences. The post-creation panel now explicitly passes the Jio's own to — it used to fall through to 's own default resolution, the own , so the host and an invitee with a different office preference could see two different candidate pools for the exact same Jio, and a viewer whose own preference didn't match wherever the Jio actually was could end up with a walk-time-filtered pool narrowed to almost nothing — indistinguishable from the "Try:" randomizer being stuck, no matter how many times it's re-rolled, since it really was drawing from the right office's places but the wrong one's walking distances. When the office is already right and the leftover pool genuinely is tiny — a long-running standing rotation, or a group's accumulated blocklists/dislikes, can leave almost nothing "not already an option" within the active filters — the panel now says so plainly (a small note under the filter row, once "Try:" plus "Suggested" between them turn up two or fewer distinct places) rather than leaving a handful of unchanging chips to read as broken. A real, deeper bug turned up right behind that note, on a group Jio specifically: computed each member's own personal ranking already truncated to the caller's own (8, from this panel) intersecting "must survive every member" across the group — so a place merely ranked outside one member's own top-8 (nothing to do with an actual exclusion — just a slightly worse walk/budget/rating fit for that one person) silently vanished from the eligible set entirely, even on a Jio whose true group-eligible pool, computed properly, ran into the dozens. A long-running recurring Jio's standing list eating most of that artificially tiny top-8 then left "Try:"/"Suggested for the group" with only one or two leftover places, forever, no matter how many times a filter was loosened or the randomizer re-rolled. now ranks each member without a limit and only truncates the combined, group-averaged result at the very end; the suggestion endpoint's own randomizer pool needed the matching fix (it also draws from the group's already-truncated top-N for its "surprises," rather than recomputing an unlimited group ranking the way personal mode already did) so "Try:" can actually reach every genuinely eligible place, not just whatever's left of the top 8. The creation-time fetch is capped at 8 suggestions rather than a fixed 15, tight enough to read as roughly five rows of chips instead of pushing the rest of the form below the fold. The post-creation panel deliberately does auto-insert into the ballot the way the old "Suggest 3" button used to (silently inserting picks with zero confirmation, and deleting anyone's previously-suggested-but-unvoted options on a re-roll) — every suggestion, including the "Try:" randomizer, is tap-to-add through the same a manual search-and-add already uses, so nothing lands in the ballot without someone actually choosing it. The randomizer itself shows up to 3 picks at once rather than one (, repeating the same epsilon-greedy draw against a shrinking pool so nothing repeats), each its own tap-to-add chip, all drawn from — and so already respecting — the exact same filtered/ranked list "Suggested for you" uses; one shared "↻" re-fetches a new set of (up to) 3. Both panels can filter by cuisine and by "On Foodpanda"/"On Grab" presence now, on top of area/budget/"new to you only" — takes , , the same way does. Cuisine here is a "Cuisine (N)" dropdown of checkboxes rather than a chip row — a horizontally-scrolling strip of every cuisine either overflowed illegibly or wrapped into several rows depending on how many were selected, where a fixed-height dropdown button holds its size regardless — and every pill in the row, including the budget , shares one flat background rather than the select rendering as a separate white island against the row's cream card. The dropdown panel measures its own trigger button against the viewport on open and hangs off whichever edge keeps it fully on-screen (an iOS report: a fixed left-aligned panel ran off the right edge whenever the button itself landed in the second half of a wrapped row) rather than assuming a fixed side. The randomizer's reroll button is pinned to the row's own right edge () rather than placed immediately after the place chip — its screen position then depends only on the row's own stable width, never on how long the current pick's name happens to be, so a re-roll's shorter/longer name can never reflow a second tap meant for reroll onto the place chip instead (an iOS report: "accidentally click the place when trying to randomise"). It's also sized as a real, visible 44px circle rather than an invisible expanded hit-area over a smaller glyph — 's negative-margin trick (UX review log #2) is meant for a plain text link with no chrome of its own; applied to this bordered/filled button, that same negative margin shifted the circle up and left of its actual slot, reading as a stray shape floating past the card's edge rather than a small control sitting in its own row. The chip itself just reads "📍 Location" — office-relative is the implied default rather than spelled out in the collapsed state ("Anywhere near office" read as a stale, over-explained label once "Location" said the same thing in one word) — and tapping it opens a picker with two tabs, station search over a static ~180-entry MRT/LRT dataset (sourced from the Singapore Open Data Portal, not hand-typed) or drop a pin on the existing Leaflet map — either resolves to a plain that overrides 's usual office resolution for that one request and re-fetches, with a tight 6-minute walk cap by default (no equivalent of a "typical distance" around an arbitrary point the way there is for a fixed office; an area search is a "what's actually around here" ask, a narrower bar than the office-relative default — an initial 15-minute placeholder read too loose in practice and was tightened after shipping). The office-relative default itself now also caps at a real distance (, the same 30 min Places/Map's own filter defaults to) rather than being genuinely unbounded — that used to mean a place far enough away that Places/Map would already exclude it by default could still turn up as a "Suggested for you" chip or the "Try:" randomiser pick, which read as a bug rather than a deliberately looser default the moment someone actually saw it happen (a report: being offered a place clear across town). The same suggestion fetch also takes a budget cap ("Up to," the same six-tier – scale and "no ceiling above the top tier is a no-op" convention as the Places page's own filter) and a "New to you only" toggle (excludes anywhere the host has Tried — a logged visit, or a Jio actually attended and decided, not just a visit alone) — both hard cutoffs on itself (/, the latter now unioning in alongside logged visits), not just a soft nudge from the host's own , and both apply per-member in group mode too: one member having already tried somewhere, or it costing more than the group's shared cap, rules a place out for everyone, same as an existing cuisine dislike already does. Nothing here is stored: the area is a per-Jio × per-request query parameter, not a field on the Jio itself, so trying a second area (or clearing the chip back to the office default) within the same form session is just another tap and re-fetch, and neither nor the Jio record is ever touched by it. "New to try" is cuisine-exclude-aware via , which is what StreakBanner's "Break it" link and Welcome's "no group yet?" card now point at instead. Searchable list with cuisine, budget, walk-time and rating filters, sortable by nearest, highest-rated, newly rated (most recent rating activity, any visit regardless of , same as already blends both — , trigger-maintained since migration 021 but never surfaced until now), or rated by your Kaki group — the budget filter runs six tiers deep ( under $8 through over $100; CHANGES_20260821.md §1 split what used to be one flat "$$$$ over $30" tier into three, since it lumped a $35 lunch in with a $150 dinner), and defaults to no ceiling rather than silently capping at the old top tier — plus a "Kaki favourites only" chip that actually narrows the list, not just reorders it — joined now by "On Foodpanda"/"On Grab" chips, same narrows-not-reorders shape, computed the same way at the API layer (/ on ) — and a badge on the card itself once at least 2 Kaki-member visits back a place's rating, so a lone review doesn't read as group consensus. 's "Clear" link tracks whether anything has been touched this session rather than a live value-equals-defaults check — the latter made it flicker away mid-drag the moment the walk slider crossed back over its own default value (30), even though the slider visually sits mid-track there and gives no cue that 30 is special; only an explicit Clear (or a fresh page load) hides it again now. 's own cuisine row is now the same "Cuisine (N)" checkbox dropdown as the ballot-builder's, not a second, separately-maintained horizontally-scrolling chip strip — both share one component, differing only in the trigger button each renders around it (a bordered here, a flat pill there) to match their own surrounding row. itself moved out of the plain browse ("All") tab and now renders once, above every tab — search, cuisine, budget, walk-time and Foodpanda/Grab narrow Want to try/Tried/Favourites the same way they already narrowed "All," computed client-side (, mirroring the server-side demoRepo/supabaseRepo already run for "All") since those three lists are small and already fully fetched rather than paginated. Two things stay "All"/"Not tried"-only, hidden on the other tabs rather than rendered inert: "Kaki favourites only" and sorting "Rated by your Kaki group," both needing a Kaki-scoped rating only ever computed for a server-backed fetch — which "Not tried" (below) is too, so it gets both for free rather than needing its own case. The walk-time slider's own default (30 min) deliberately does apply to Want to try/Tried/Favourites until the slider's actually been touched this session (the same touched-not-value-equals-default flag "Clear" already tracks) — those three were never walk-filtered to begin with, so applying the default the instant the shared bar rendered would have silently dropped a saved place across town with no visible cause. A tab a filter narrows down to nothing gets its own empty state ("Nothing here matches these filters," a Clear-filters button) distinct from that tab's genuinely-empty state (nothing saved/tried/favourited at all). sits right after "All" — the exact mirror of "Tried," every active place minus everywhere already logged a visit to or attended a decided Jio at (, same broader-than-visits bar used everywhere else it's read). Unlike Want to try/Tried/Favourites, it's built as a second mode of the same server-paginated "All" already uses ( on ), not a third small unpaginated list — the "not yet been" set is, by construction, close to the size of the whole catalog, not a bounded personal list. Fixing this up also caught a small pre-existing gap in 's own "needs the full set before slicing" branch (shared by "Kaki favourites only," Foodpanda/Grab presence, and now this): it silently ignored / whenever one of those filters was active, since only a sort was ever handled there — now resolved the same way the plain path already does, so every sort option works underneath any of these filters, not just on an unfiltered browse. The old flat All/Saved split grew into six tabs — All, Not tried, Want to try, Tried, Favourites, Lobangs — once "saved" stopped being one idea. Want to try and Favourites are independent personal lists, not mutually exclusive: saving one never clears the other, so a place can be both at once (a table alongside the existing one, gated on its own feature flag). Want to try can also carry a short free-text reminder — "the laksa," "ask for the corner table" — since the reason you wanted to go is easy to forget by the time you actually do (, edited inline via a small "Add a reminder" link under the card, shown back as the card's own "why" text once set). Tried is never toggled by hand: it's derived from what you've actually logged — a visit, or a Jio you attended (hosted it, or RSVP'd "Yes" and it went on to close with a winner) — a broader bar than "you clicked save," and the same bar "New to you only" (Places' ballot-builder filter) now excludes against too, not just logged visits alone, since a Jio you actually went to is just as much "not new" as a review you wrote. A decided Jio only counts once its own has actually passed, not the moment it closes — a Jio confirmed for next Friday hasn't been attended yet regardless of how settled the vote is, same "Going to X" vs "Went to X" tense the Jio itself already uses (); this applies everywhere is read, so a place you're already committed to for an upcoming Jio still reads as "new to you" in suggestions too, correctly, since you genuinely haven't been there yet. Each tab's own count is a plain length except Lobangs, which stays unseen-only, not a running total — viewing it acknowledges every lobang in it the same way opening "You" or already does, so the number goes back to a plain "Lobangs" rather than sitting there forever once you've actually looked. Lobangs itself is unchanged: received-only, each shown as the same card shape as everywhere else, the sender's note standing in for the usual recommendation reasoning, with a "Not interested" link removing just that one (the sender is never told); a line underneath points at "You" for the full sent+received history. Add places by hand (address or postal code — a postal code found anywhere in a pasted-in address, e.g. one copied straight from Google Maps, is tried first, since OneMap's road/block index can choke on a business-name prefix or unit number that a bare postal code sidesteps), import candidate names from a food blog, or let the daily cron find them on OpenStreetMap. Cuisine tagging draws from a shared, runtime-extensible list (a table, not a fixed set — see Security notes) rather than free-text alone: typing something new under "Other" that doesn't match an existing cuisine offers to add it permanently, visible to every place and every profile's Taste preferences from then on ("No" keeps today's behaviour, a one-off tag on that place only). An admin combine tool () folds near-duplicates ("Korean BBQ" / "korean bbq" / "KBBQ") together after the fact, since normalizing on write only catches exact ones. A place's own page has a "View on Google Maps" link next to Directions, and — when set — a "Socials" link right beside it: one optional, freely-editable field (CHANGES_20260821b.md §1, "Socials," not locked to Instagram — Facebook is just as likely for plenty of places), stored as whatever full URL was pasted and labeled/iconed by sniffing its domain at display time ("View on Instagram," "View on Facebook," or a plain "View socials" for anything else). No auto-resolution exists for it the way Google Places' own lookup does — there's no equivalent "search any business by name" API for Instagram or Facebook to build on — so the edit form's empty-state helper is a plain "Search Instagram for {name}" shortcut instead, Instagram-specific by choice even though the field itself takes any platform's link. Without a Google Places API key configured it's a plain coordinate pin, computed client-side, no backend call — that's still the default. With one configured, the app resolves each place to its actual Google listing, best-effort, server-side, at create/edit time (gated on the result actually looking like the same place — name similarity plus a distance check — not just Google's own top result), and the link opens that instead: the restaurant's real page, photos and reviews included, with the coordinate link as an automatic fallback if the match ever stops resolving. A place can also carry a Foodpanda and/or a Grab link — separate fields from Socials, since a place can reasonably have all three at once and the ballot needs to show whichever are set side by side, not just one generic slot. Same shape as Socials in every respect: manual-only (neither Foodpanda nor Grab exposes a lookup-by-business-name API either), stored as whatever full URL was pasted, freely editable by anyone who can edit the place, and shown next to "View on Google Maps" wherever that already appears — the place page, the ballot/standing lists, and both public preview pages. Each gets its own real brand mark (), same exception already makes for Instagram/Facebook — a user needs to recognize the actual app, not a generic stand-in — sourced from Simple Icons (CC0-licensed vector artwork) rather than redrawn or approximated. Grab's own real-world brand has no square glyph the way Foodpanda's panda or Instagram's camera do — its logo is a wordmark — so it reads smaller and thinner than the other icons at the compact size the ballot/standing rows use; that's inherent to Grab's own branding, not a rendering bug. The edit form's Grab field gets the same "Search GrabFood for {name}" empty-state shortcut Instagram's field has (GrabFood's own search-results URL, confirmed working); Foodpanda's field has no equivalent yet — its exact search-URL format couldn't be confirmed from this environment, and a wrong guess baked into a real link is worse than no shortcut at all.
Every active place also has a public preview page (/p/[id], linked from a Share button on the full page) — the app's only page that needs no sign-in, showing name, cuisine, address, best dishes, aggregate rating and the same Maps link (real listing or pin, same rule) to anyone with the link, alongside the same Socials/Foodpanda/Grab links and icons the full page shows when a place has them set (a link the place's own owner chose to make public carries no more sensitivity than the Maps link already does) — the attributed /l/[token] lobang link carries all three the same way — with a "Join to see more" prompt into /login for everything past that, including the named review list. A place farther than the walk-time filter's own range (past 45 min, the slider's ceiling — Places, Map, and a Jio's place search all cap out at 60 min or less) is correctly excluded from every list view with no filter setting able to surface it; without any indication of why, that reads as the place having silently vanished rather than just being filtered, so its own page — reachable regardless, unlike the list views — says so plainly, distinguishing "past today's default, widen the filter" from "past the slider's ceiling entirely, no filter helps." |
| Map | Leaflet map of everything in walking distance, with real walking routes when OneMap is configured. A Kaki-favourite place gets an amber ring on its marker, layered independently of the existing selected/not-selected fill color. Places that geocode to (almost) the same spot — every stall in a food court, a mall's whole food floor — used to stack invisibly under one clickable marker, so only whichever place happened to render on top was ever reachable; clusterPlaces() (src/lib/mapClusters.ts) groups them onto a ~11m grid instead of exact-equality, and a group of more than one renders as a single, slightly bigger marker labelled with the count rather than a walk time (identical across the group anyway) — tapping it opens a scrollable list of every place there instead of picking one arbitrarily. Two more badges now show on both a single place's popup and every row in a cluster's list, neither personalized to the viewer (unlike "your Kakis," which stays too): "Highly rated" (avg_rating ≥ 4.5, gated on at least 2 visits so a lone glowing review doesn't count — same floor the Kaki-rating badge already applies, just without requiring the raters share a Kaki) and "New" (added within the last 14 days, created_at) — distinct from Places' own personalized "New to try" rail (never-visited-by-you, regardless of how long the place has existed). Walking distance — here, on Places, and in the ranking behind "Suggested for you" — is measured from your own Profile-page Office setting when you've set one (user_prefs.default_office_id), not always the same office regardless of who's asking; changing it there updates all three immediately. That "immediately" needed its own fix: / resolve the office server-side, so their SWR cache keys never change when it does, and nothing told the client those cached responses were now stale — this page's own header text and office marker (which read the saved preference directly, not through those two routes) could already show the new office while the pins underneath it were still whatever the old one returned. Saving on Profile now explicitly invalidates every cached / response so Map, Places, and a Jio's own suggestion rails actually refetch. Previously only the teammate pickers actually read that preference — everywhere distance-related silently used a single fixed office no matter what a person had chosen on their own profile. |
| | Log a visit privately, or share it as a review. Each visit is its own entry, editable later from either of its two entry points — an "Edit" link next to your own review on the place page, or on the profile's own History list (the only place a private, never-shared visit is reachable at all) — reusing the exact "How was it?" form a new visit uses, pre-filled and pointed at a instead. Un-sharing a review back to private is just unticking the same checkbox. "What would you recommend?" is a , not a plain single-line field — Enter makes a line break there, never submits the form early the way a bare would (and the way iOS's own "Go" keyboard button would trigger on one), with either a comma or a line treated as a separator between dishes. Each shared review can be liked — a bare count plus your own toggle state, no "liked by" list — which nudges the reviewer with a throttled push (at most one per review per ~10 minutes) and feeds into a weekly recap push for anyone whose reviews got at least one like that week. A review is public by default now (UX review log #19, a deliberate reversal of the earlier private-by-default behaviour — every review already written flips too, not just new ones), visible to anyone viewing that place unless you tick it back to private; a plain-language note next to the review form says so, right where the choice actually gets made. The recommended-dishes line on each review reads "Recos:" (matching the app's own term for it elsewhere) rather than "Had:", sitting above the review text at the same size but a lighter grey tone so the two don't read as one undifferentiated block. |
| | Bookmark anywhere from any list, either as Want to try or as a Favourite (independent of each other — see Places above), and saving nudges a place up your own suggestions. |
| | Checks the NEA two-hour forecast. When rain is likely, the walk penalty doubles and closer places quietly rise. |
| | What you actually eat versus what you think you eat, plus a nudge when you have had the same cuisine three days running. "Your numbers" on the profile falls back to the shared empty-state card, with a link into Places, before any visit is logged. Headlining that section is a rule-based "food identity" card (CHANGES_20260821_combined2.md Item 1) — The Loyalist, The Explorer, The Regular, The Enthusiast, The Connoisseur, Budget Hunter, or a Well-Rounded Eater catch-all, decided by simple thresholds over your existing metrics, never anything trained. It's a locked monthly snapshot, not something recomputed on every visit — a new cron locks in each month's card early the next month, so it can't flicker mid-month, and a share button renders it as a shareable PNG. When Profile's Taste preferences (cuisine likes/dislikes) are set, one extra sentence is appended after whichever archetype was picked, comparing what you actually ate that month against what you said you like — a real chunk of visits to a disliked cuisine, or mostly sticking to a liked one; purely additive, so it never changes which archetype wins, and says nothing at all when no preferences are set or when actual behaviour doesn't clearly lean either way. Each archetype (and each of the two Kaki award slots) gets its own small hand-drawn "pebble" icon — a single-fill organic silhouette (/), never paired with a legend or tooltip since every place it appears already sits next to the text that explains it. A history strip of tappable month chips sits under the headline once more than one month is locked in — tapping one swaps the whole card (headline, description, icon, and the Share button's target) to that month, without changing which month is "current." A "Why this archetype?" toggle reveals the exact priority-ordered rule checks ran that month, the account's real number against each threshold, stopping at (and bolding) the one that actually matched. Below the 3-visit floor, the card no longer disappears — a outline icon plus a small progress line ("2 of 3 visits logged…") replaces the archetype entirely until enough visits exist to lock one in. The four stat tiles below the card are now tappable rather than staying static numbers — "Visits logged" opens the same month-grouped list the profile page used to show inline (), "Different places" a by-place breakdown (), "Average rating" a star-count histogram (), all three on ; "Usual spend" is the one exception, linking to instead (see above) — that page is the only entry point into logging a first expense, so the link can't wait for one to already exist there first, a real bug in an earlier pass where it kept pointing at the old tier-breakdown tab until an entry existed. It links straight into that page's "My tracker" tab () once a real logged amount exists, or its default "Insights" tab otherwise — never demoting someone already using the personal tracker behind an extra tap, without forcing everyone else through an empty ledger first either. The cuisine chart grows a "Show all N cuisines" toggle once there are more than the 7 it shows by default. 's histogram and 's tier breakdown (still available at , now sharing with 's own "Insights" tab) are both computed live inside alongside the numbers it already derived, not a new endpoint. The tile's own number still upgrades once a real logged amount exists — a real per-entry average in place of the place-tier estimate — but with none yet it shows that same estimate exactly as before, just now landing somewhere you can actually do something about it. The profile page itself was getting long as more activity sections accumulated, so "Want to try," "History," and "Past Jios" now show a 3-item peek with a "See all N →" link into their own page (, reusing , ) instead of the full list inline — adds a Closed/Cancelled filter the inline version never had, and factors the "Send lobang" composer into a shared so the peek and the full page render the exact same row rather than two copies that could drift. |
| | A quick-action dashboard, not a second Jios list: today's Jio becomes the headline when there is one, and — since eyes go to the list below out of habit regardless of how bold the headline is — it also stays in "Upcoming" itself rather than being excluded just for already being the headline; a capped list (next one or two) of what's coming up otherwise — a Jio counts as "coming up" once its date is real and it isn't cancelled, whether or not voting's closed yet, since being decided early doesn't make a future lunch any less upcoming; "Same as last time?" one-tap repeat of your last hosted Jio. Accounts less than 14 days old also see the same compact "Add to home screen" row profile's Account zone uses, placed just below Upcoming rather than several scrolls down — a separate placement from the global snoozable install banner, not a replacement for it. An unseen lobang gets the same near-the-bottom treatment: a card naming who sent it (or just a count, once there's more than one), gone the moment it's actually been viewed. |
| | A one-line, dismissible card — icon circle, one sentence, an X to dismiss — shown once on a user's first visit to Home, Places, You, Kakis, Lobangs, and Start a Jio, sitting directly below each page's own header. Deliberately not a tour: no sequence, no "next," nothing blocking, matching 's existing "single field, not a wizard" stance. Two pages explain the app's own vocabulary (Kaki, Lobang) or a voting term (Borda count) rather than a feature; the rest point at something on that page worth noticing early (Home's two ways to get lunch, Places' filters and bookmarking, You's QR shortcut and Taste preferences). Tracked with one flag per page, same mechanism uses for visit counting — no backend, and independent of that banner's own global, count-gated logic, so the two never conflict. |
| | A standing weekly Jio — same place every time (auto-confirmed, no vote needed) or a vote over the same option pool each week. Generates its next occurrence lazily, a few days ahead, when the host loads Home or Jios; invitees are expanded fresh from current kaki membership every time, not frozen at series creation. A real report ("didn't see the invite") found that a generated occurrence never actually sent the "You're invited to a Jio" push to anyone — kaki member or individually invited — unlike a one-off Jio: generation happens through the repo layer directly, which bypasses 's own push call entirely, so the only way to ever notice a new occurrence was to open the app and stumble onto it. Each newly generated occurrence now gets the identical invite push its one-off counterpart always has. The configured time is anchored to Singapore's fixed UTC+8 offset explicitly at generation time, not the server process's own local timezone — the write-side counterpart to the display-only timezone bug above: a 12:00 series used to generate at 12:00 UTC (8pm SGT) instead of the intended 04:00 UTC. Editable, not just stoppable — an "Edit" link reuses the same create form, prefilled. Changes to the weekday only ever affect what generates from then on; time, place/mode and invitees also propagate onto an occurrence the series already generated, but only while it's still open and nobody's voted or RSVP'd on it yet — once someone has, that one occurrence is left exactly as it was, so an edit can never invalidate an answer someone already gave. A series also carries its own vote-deadline offset (see Jios above), defaulting to 3h before each occurrence's start; unlike place/mode/invitees, a deadline change propagates onto an already-generated open occurrence regardless of whether anyone's answered yet, since it can't invalidate a response the way changing what's being voted on would. A series can also carry its own notes for invitees, same field and same propagation rule as the vote deadline (notes don't invalidate an answer either) — previously the only field a one-off Jio has that a series had no equivalent for at all, so every generated occurrence's notes silently stayed blank regardless of what the host typed on the series form. — since generation only ever happens lazily, a few days ahead, a standing Wednesday lunch three weeks out otherwise shows nothing at all on the calendar, which is exactly how someone forgets it's there and double-books that slot. A hollow, dashed amber marker (distinct from a real Jio's filled status dot) appears on every future date matching an active series' weekday that doesn't already have a generated occurrence; selecting that day surfaces a lightweight, non-clickable-as-a-Jio preview card ("Wed Lunches · 12:00 · standing, not started yet", with an Edit link straight to the series) rather than a real — it isn't one yet, and shouldn't be mistaken for one. Purely a client-side preview over the series' own weekday/time (, already fetched for the "Recurring" list above it) — nothing is written or generated early just to back this. |
| | Opt in from "You": get notified when you're invited to a Jio, when someone votes on one you're hosting (throttled to at most one push per event per ~10 minutes), when a Jio you're in is starting in 30 minutes and you haven't voted or RSVP'd, when one you're in gets decided, when someone sends you a lobang, when someone likes a review you shared (same ~10 minute throttle, skipped for liking your own review), and a weekly recap of how many likes your reviews picked up, sent only if that count is above zero. iOS only ever delivers push to an installed PWA, never a browser tab, so the app also nudges toward "Add to Home Screen" after a few visits — dismissible with "remind me later," not a one-shot ask — plus an always-available "Add to home screen" card in "You" for anyone who dismissed that prompt but changes their mind later. Both surfaces' instructions call out that the Share icon isn't always sitting directly in the visible toolbar — some iOS layouts tuck it behind a "•••"/"More" button first, and a user report confirmed people were getting stuck right there without that hint. In mode, a successful install is also followed by an offer to attach an email, since installing the icon is the moment a second, independent signed-in context is about to exist. also declares (plus an explicit ) so a tapped Jio/Lobang link can open straight into the already-installed app instead of a fresh browser tab, on whichever Chromium-based browser (Chrome, Edge) has shipped support for it — still rolling out unevenly across browsers as of this writing, and a no-op, not a break, wherever it isn't recognized yet. iOS has no equivalent at all: Safari always opens a tapped link in itself (or whatever app the link was tapped from), never into an already-installed home-screen icon, and that icon runs in its own storage partition entirely separate from Safari's — a sign-in done in one never carries into the other except for a one-time cookie copy Apple performs at the exact moment "Add to Home Screen" is used, which is why installing while already signed in (rather than the other way around) is the only way that first session carries over. Since iOS can't be told to hand a tapped link off to the icon automatically, asks instead: a dismissible banner on the signed-out and previews (where a link shared through WhatsApp/Telegram/etc. actually lands) reading "Already added Jio to your Home Screen? Skip this browser — open the icon there instead," shown only on iOS and only when the current view isn't already the installed, standalone app (reusing 's own / detection). Android/desktop installs share cookies with their installing browser, so this repeat-sign-in problem — and the banner — doesn't apply there. The service worker behind the installed icon () never caches the API — stale lunch data is worse than none — but does cache the page shell itself, network-first with a 2.5s timeout: reopening the app on a slow or dead connection shows the last good copy instantly rather than a blank/black splash screen for however long that one request takes, refreshing in the background once the real response lands. |
| | A separate, configurable "starting soon" reminder (CHANGES_20260821c.md §1) — not the fixed 30-minute non-responder nudge above, which only ever pushes people who haven't voted or RSVP'd (that one used to be page-load-only; a real report confirmed it as unreliable, so it's now cron-driven too — see Free-tier realities). This one is for anyone confirmed going (RSVP "Yes"), whether or not they've already voted, at a lead time you actually choose. "You" carries the on/off default and the default lead time, right below the push toggle; a Jio you're confirmed for shows its own reminder card, collapsed to "using your default" with a "Change for this Jio" override that only that Jio ever sees. Fires once per person per Jio, same one-shot idea as the fixed reminder above — reopening a Jio for voting or changing your RSVP doesn't matter to it either way. The cron route (, hit by an external scheduler — see Free-tier realities) claims a reminder as sent attempting the actual push, so a send that never reaches anyone (a transient error, every subscription for that user being dead) used to burn the one-shot claim with nothing ever delivered and no way to retry. now reports back exactly which users it actually reached; anyone the cron's batch didn't reach — including a whole event's worth, if fetching its push targets itself throws — gets un-claimed () so the next scan picks them back up, rather than one bad send silently and permanently losing that reminder. |
| | A "Report a problem" row in "You" (UX review log #17, rescoped from a full help/FAQ page — the vocabulary glossary and auto-close explainer that idea called for needed facts nobody had to hand) opens a small form: a category ("Something's not working," "A place's info is wrong," "Something else") and free-text details. Reuses the exact report mechanism already powering a place's own "Report an issue," generalised with a category field rather than standing up a second reporting system — see Admin above for where these land. Filing one (from here, or Home's "Give feedback" suggestion card) pushes every admin who hasn't muted it — a dedicated toggle stacked on top of the master push switch, in "You" 's own Admin tools block ("Report & suggestion pushes," on by default), the same "per-type mute on top of the master" shape Jio reminders already use; the reporter is never notified of their own submission even if they're an admin themselves. |
| | A standing card at the very bottom of Home — "Got an idea for Jio?" — for ideas and suggestions specifically, distinct from "Report a problem" above (that one's for something broken; this is for "you should build..."). Shares the exact same pipeline rather than a second one: , the table, and the admin moderation queue, just its own category (migration 074 widened the table's category check) so it doesn't get mislabelled as a bug report — deliberately never offers this category itself, keeping the two entry points visually separate while sharing the backend. No category picker on this card — every submission here is a suggestion by construction. The subtext also promises something concrete: "Genuinely good suggestions get an in-person coffee, on us." Same admin push as "Report a problem" (see below), muted the same way. |
| | Moderation (place reports plus general "Report a problem" reports — see below — side by side on the same screen, filterable by type rather than a separate inbox for each; a general report resolves one at a time, unlike a place's reports which batch-resolve together, since there's no place to group a general one by), block/unblock, an analytics dashboard split into seven tabbed views — Overview (funnel, growth, Jio outcomes), Users, Places, Social, Moderation, Wishlist, and Performance — office management (add, edit in place, or remove; every Jio, walk-time filter, and route default that isn't given an explicit office falls back to the very first office ever created, so moving to a new building means editing that one rather than adding a second office alongside it, which changes nothing on its own), and an accounts screen for merging duplicate identities (auto-surfaced by shared name, or search any account) and issuing recovery links — all reachable from "You", no dedicated nav icon, since admins are the one group that needs it least often. Merging is deliberately two-step, since it's irreversible: "Confirm merge" stays disabled until "Preview" has actually loaded (what will move, what's kept), and — since a disabled button gives no feedback of its own, which read as "I pressed merge and nothing happened" rather than "load a preview first" — a plain-text hint next to the buttons now says so directly while it's still disabled. A genuine Preview/Confirm-merge failure now surfaces right next to that group's own buttons, not a single page-top banner easy to scroll past. Confirming that fix in production turned up the actual bug behind an "I previewed, then confirmed, and the duplicate never disappeared" report: retiring the merged-in account is the one step needing the service role () — deleting an row isn't something any RLS-scoped call can do — and a failure there (a stale/misconfigured key, the row already gone) used to be swallowed as a server-log-only warning even though every row of real data had already moved successfully. now reports that failure back through the merge response instead of silently keeping it server-side, so the admin sees exactly why an account that should be gone is still sitting there, rather than a merge that looks like it did nothing at all. That warning's own wording needed a second pass once it actually reached production: Supabase's Auth Admin API can fail with an HTTP error status but an empty JSON body, and falls back to ing that empty body for — the literal, uninformative string — so the merge warning now reads / off the same error instead of trusting alone, and merging 2+ stale accounts into one keeper in a single request (each capable of producing its own delete-failure warning) now lists them one per line rather than running them together into one garbled, duplicated sentence. That diagnostic upgrade then found the actual root cause: / (Daily Activity Log, added later than was last touched) both have a hard with no cascade, and every signed-in page view writes an row — so any real account being merged away almost always had one, which blocked its own row from ever being deletable at all, no matter how many times "Confirm merge" was pressed. now reassigns both tables to the surviving account (merging same-day visit counts rather than dropping one side's history on a collision), so a duplicate account's data — and the account itself — actually goes away. The Performance tab now includes an in-app DAU/WAU/MAU trend (distinct users doing anything — voting, hosting, logging a visit, saving a wishlist item, adding a place, or filing a report — bucketed by day/week/month) alongside the existing Vercel/Supabase link-outs, which still cover Core Web Vitals and platform-quota metering this app's own database can't query. Page views and unique visitors by page moved in-app too — "Page views by tab" (also on the Performance tab) is a table per day, one row per section (Home/Jios/Kakis/Places/Map/You, plus Admin/Other for everything outside the bar) with PV (total views) and UV (distinct signed-in visitors), over a fixed trailing 14 days independent of the date-range picker, newest day expanded by default. 's existing per-route-change beacon now also posts the pathname it fired from; the ping route buckets it into a tab () and upserts a row (user, day, tab) alongside the pre-existing per-day tally, same increment-on-conflict shape as that table — unlike that one, this one's FK is from the start (migration 087), having just been bitten by the alternative in the paragraph above. carries a merged-in account's page-view-by-tab history onto the survivor the same way, merging same-(day, tab) counts rather than dropping one side's on a collision. Overview also carries a real step funnel — invited → responded → voted → attended → reviewed, with real drop-off percentages, a per-week trend for each step, and a cohort table grouped by signup week — scoped to every Jio that actually closed with a winner in the window, distinct from the same-day activity snapshot above it. The Places view's top-rated/most-visited lists are now click-through: opening a place shows who's visited (ranked), a weekly rating trend rather than just the single current average, wishlist-save and lobang-mention counts, and how well the place's cuisine/budget lines up with the people who actually go there. The Users view carries a leaderboard scored by a composite engagement signal (hosting, voting, RSVPing, logging visits, writing public reviews, sending lobangs) — equal-weighted by default, but adjustable from an on-page settings form since the next admin may want to weight differently — six rule-based segments (Power hosts, Active voters, RSVP-only lurkers, Reviewers, Dormant, New & active; not a partition, a person can land in more than one or none), and the same click-through pattern into a per-person drill-down: full visit history, cuisine breakdown, regulars, Jios hosted, Kaki memberships, lobangs sent/received, last-active date, and lifetime RSVP responsiveness. A shared date-range picker (7/30/90/180 days or 1 year, via a param that persists across every tab) replaced the dashboard's old fixed 90-day window everywhere. Overview also carries a segment filter that re-slices Jio Outcomes and the real funnel to just the Jios hosted by one segment's members (Growth stays unfiltered — filtering brand-new signups by an activity segment they haven't had time to earn isn't a coherent question), and the Growth view's "new users" chart now expands to show who actually joined each day, not just the count. An "Export CSV" button sits on the dashboard's highest-traffic tables and charts (Growth's four series, the Users leaderboard, Places' two ranked lists, Wishlist's saves and most-saved places) — plain client-side generation from data already on the page. The dashboard's two chart primitives — a sparkline and a horizontal-bar distribution — print their key numbers as visible text by default rather than only on hover: a sparkline shows its date range plus the peak and latest values always, with a tap-or-hover readout per bar for the rest; a distribution bar shows a 0→max scale and an "N total" line, in one consistent color rather than cycling through decorative hues that don't encode anything. Overview also carries "Recent entrants" — who visited the app on each of the last 7 days, day by day, always the trailing week regardless of the date-range picker (Daily Activity Log). Every signed-in page view is tallied server-side, per calendar day — this is "who came back," not "how many pages were requested," which is what the tab-level PV/UV table above is for. A per-person drill-down's own "Daily activity" card shows the same person's last 30 days: a day they visited with nothing else logged still appears, empty; a day they never opened the app at all doesn't appear. Instrumented actions (hosting, voting, RSVPing, logging a visit or review, sending a lobang, saving a place, adding a place, creating a Kaki, filing a report, flagging a place) show up under whichever day they happened, alongside the plain page-view count. |
The whole point of the structure is that you can replace any one piece without touching the others.
src/
├── app/ Next.js App Router — pages and API routes
├── components/ UI. Nothing here knows what a database is.
├── lib/
│ ├── config/ Which implementation backs each seam + feature flags
│ ├── data/ The Repo interface and its implementations
│ ├── auth/ The AuthAdapter interface and its implementations
│ ├── providers/ Routing, weather and discovery adapters
│ ├── supabase/ Supabase client factories
│ ├── recommend.ts Scoring engine (pure)
│ ├── voting.ts Borda count (pure)
│ ├── metrics.ts Visit statistics (pure)
│ ├── blogImport.ts Blog parsing + SSRF guard (pure)
│ └── discovery.ts OSM normalisation + dedupe (pure)
└── types/ Domain types, storage-agnostic on purpose
Everything swappable answers to an interface, and one factory picks the implementation from an environment variable.
| Seam | Interface | Ships with | Set with |
|---|---|---|---|
| Database | Repo (lib/data/index.ts) | demoRepo, supabaseRepo | JIO_DATA_ADAPTER |
| Auth | AuthAdapter (lib/auth/index.ts) | demoAuth, nameAuth, supabaseAuth | NEXT_PUBLIC_JIO_AUTH_ADAPTER |
| Walking routes | RoutingProvider | OneMap, haversine | JIO_ROUTING_PROVIDER |
| Weather | WeatherProvider | NEA, none | JIO_WEATHER_PROVIDER |
| Place discovery | DiscoveryProvider | Overpass, none | JIO_DISCOVERY_PROVIDER |
No page or API route imports a database client. They call getRepoAsync()
and use the interface. That is what makes the next section a small job rather
than a rewrite.
src/lib/data/myRepo.ts implementing the Repo interface."mydb" to the DataAdapter union in src/lib/config/index.ts.src/lib/data/repo.ts.That is it. tests/repoConformance.test.ts will fail loudly if you miss a
method or change an arity, so a half-finished port cannot ship quietly.
The same three steps apply to auth, routing, weather and discovery.
src/components/icons.tsx is the
whole set (UX review log #20) — nav glyphs, the walk/budget/place-pin
icons, and food-category icons grouped by what a cuisine tag actually
looks like (a noodle bowl covers Chinese/Japanese/Korean/etc., a wok
covers Malay/Indian, and so on — see that file's own comment for the full
18-tag mapping). Every icon shares the same prop shape lucide-react's did
(className, strokeWidth, fill, aria-hidden), so a new one drops
in the same way an old lucide import used to. The one deliberate
exception: SocialsIcon keeps real Instagram/Facebook brand marks rather
than redrawing them — those need to read as "this is Instagram," not "this
matches Jio's house style."docs/game-feel.md is the north star for anything with "character" —
celebrations, streaks, awards, micro-interactions. One firm rule baked in
there: no leaderboards or cross-Kaki comparison, ever. Check new work in
that family against it before shipping, per that doc's own instructions.ToastProvider/useToast() (queued, not stacked — one
toast fully has its moment before the next starts) for lightweight
success feedback; LiveAnnouncerProvider/useAnnounce(), a single
persistent aria-live region reused for every milestone announcement
rather than one freshly mounted per event; CountUp for a number that
should animate to its value instead of appearing instantly; and
src/lib/haptics.ts (hapticTap/hapticResolve, gated on a
localStorage on/off toggle — a permanent no-op on iOS Safari, which has
never implemented navigator.vibrate).role="img" + aria-label + a matching title (removed from
aria-hidden) so a screen-reader gets the plain state name and a mouse
user gets a hover tooltip, from the same string — no visual change.role="dialog" popover gets real dialog mechanics (UX review
log #9) via a shared useDialogFocus hook (src/lib/useDialogFocus.ts):
focus moves to the dialog's primary control on open, Tab/Shift+Tab are
trapped inside while it's open, Escape closes from anywhere inside, and
focus returns to exactly whatever had it before the dialog opened on
close. Applies to the add-to-home-screen prompt, the recovery-link
nudge, the QR code popup, and the decided-Jio celebration — a generic
hook rather than moving all four to the native element, since
they range from a full-screen overlay to a backdrop-less bottom
snackbar and re-styling every one for 's top-layer rendering
risked layout regressions this pass couldn't fully re-verify visually.Every optional slice can be switched off without deleting code. Disabling one hides its navigation entry and makes its API routes return 404 — so a stripped build has no half-live endpoints.
NEXT_PUBLIC_JIO_DISABLED_FEATURES=kakis,blogImport,roulette
Valid keys: events, kakis, wishlist, favourites, lobangs, blogImport,
discovery, weather, map, metrics, roulette, reviews, offices, splits.
Three modes, set by NEXT_PUBLIC_JIO_AUTH_ADAPTER. name is the default.
name — type your name, that's itOne field, one button, you're in. No email, no password, no provider to configure, nothing to verify. You get a distinct user with a real UUID, so votes, reviews and recommendations are all properly attributed and everyone can tell who is who.
The sign-in screen is the only screen a new user sees — it stamps
profiles.onboarded_at itself, so the /welcome onboarding step never fires
in this mode. Asking for a name and then asking again to confirm it was one
question too many. /welcome still exists for email mode, where people
genuinely arrive without having given a name.
Office is asked at most once, and only of someone it's actually new
information for. The sign-in form itself no longer shows an office field at
all — it used to show one permanently, locked to the single seeded default,
which was actively wrong once an admin added a second office (/admin/offices)
and confusing regardless: a returning user staring at a field they can
neither change nor usefully ignore. Now the name field's submit alone decides
what happens next: claiming an existing account (the "an account named 'X'
already exists — is this you?" confirmation below, answered yes) skips office
entirely and finishes signing in immediately — that account already has
whichever office it was set up with, and re-asking would look like it just
got reset. Only a genuinely new name — no match, nothing to claim — gets a
second, one-question step offering every office GET /api/offices returns
(so anything an admin has added is selectable immediately, not just the
original default), defaulting to the seeded office, saved via the same
user_prefs.default_office_id PUT the Profile page's own office field
already writes to. Skipped entirely when there's nothing to actually choose
between — the offices feature flag is off, or an admin hasn't added a
second office yet — so a single-office deployment sees no picker at all,
same as before. /welcome picked up the identical picker for email mode's
equivalent first-timer screen, replacing its own copy of the old locked
field.
/e/[token]'s first-timer redirect (CHANGES_20260821_combined2.md §2) uses
two independent signals, not one, because name mode breaks the obvious
one. The obvious check — !profile.onboarded_at, the same gate Home
already has — only actually catches an email-mode first-timer, since
onboarding there genuinely lags behind sign-in. In name mode (the mode
actually deployed today, email planned once the user pool grows) onboarding
is stamped the moment sign-in completes, before /e/[token] ever runs, so
that check is always already satisfied and would never fire for a real
name-mode first-timer. The second signal — listEvents(user.id) returning
empty before the join below runs, i.e. this account has never touched a
single Jio in any capacity (host, invitee, kaki member, voter, or RSVP) —
is mode-agnostic and is what actually catches it; either signal being true
routes through /welcome.
/welcome itself also grew three optional additions (CHANGES_20260821_combined2.md
§2/§3B), all skippable via the one Continue button: a two-tap taste-preference
bootstrap (a curated handful of cuisine chips, multi-select, plus a single-select
row of the six budget-tier pills, each labeled with its actual dollar range —
$ alone doesn't mean anything on first sight — deliberately simpler than
/profile's own Taste section's like/dislike cycling and min/max budget
range, since this is a head start, not a full preference edit) that seeds cuisine_likes/
budget_min/budget_max so a first personalized-picks visit isn't running on an empty
profile; the person's own personal invite link/QR (PersonalInvitePanel,
reused as-is); and a pointer to Places (/suggest itself is retired — UX
review log #6 — Places carries the same personal-suggestion rails now) as
the no-group option.
Underneath it is a Supabase anonymous session. That detail matters: the
obvious shortcut — a signed cookie holding a user id we made up — would mean no
auth.uid(), which would mean every Row Level Security policy in the schema
stops working and every query has to run as service role. Anonymous sessions
give a real row in auth.users and a JWT with the authenticated role, so
every migration's worth of access control keeps applying exactly as written.
Zero sign-up friction, security model intact.
Two things you are accepting in exchange:
Recovering a lost identity doesn't require the session-storage fix to be
perfect — two independent paths exist regardless of what caused the loss.
Typing your name again offers to resolve to your existing profile rather
than forking a new one, if a different, case/whitespace-normalized-matching
profile already exists (nameAuth.signInWithName, CHANGES_20260807.md §4) —
same "anyone can claim any name" trade-off as above, just now actually
useful instead of a landmine. Offers, not merges silently:
CHANGES_20260807c.md §3 added a confirmation step first — "an account named
'X' already exists, is this you?" — since the earlier silent version caught
the deliberate-impersonation case fine but not the accidental one (a typo,
or two different real people who happen to share a first name). Answering
"no, different person" refuses the name outright rather than signing in
under it (CHANGES_20260818.md §2) — two accounts sharing a display name is
never actually correct, since nothing else in the app disambiguates them
afterward (reviews, lobangs, vote lists all show only the name), so the
earlier version's "sign in anyway and notify an admin" just let a real,
user-visible clash happen and hoped someone would clean it up later. The
refusal stays inside the same confirm card rather than bouncing back to the
plain login form (CHANGES_20260819.md §5): "no, different person" swaps the
Yes/No prompt for an inline "no problem — what should we call you instead?"
field, styled with the app's ordinary neutral tone rather than as an error
— a shared name being ordinary, not a mistake, was the whole point of
asking in the first place. Typing a second name that also collides shows
the same confirm card again, recursively, rather than a special case.
Whether typing a
name can auto-merge (or auto-refuse a collision) at all is a manual switch
— JIO_NAME_CLAIM_ENABLED=false turns the whole check off once names
genuinely aren't unique across the team anymore (decided to be a manual
call, not an automatic team-size trigger — the confirmation step already
catches the accidental case that made an automatic trigger feel urgent).
That stops being safe the moment two different real people can share a
name, which is where recovery links come in: an unguessable, per-account
token (get one from "You" while still signed in — nudged after a few visits
if you haven't, same dismissible pattern as the install prompt below, since
you can't generate one for an account you've already lost — or an admin
issues one from /admin/accounts for someone already locked out) that
redeems at /recover/<token> with zero name-matching involved —
collision-proof by construction, and built independently of the name-claim
code path specifically so it keeps working unmodified once
JIO_NAME_CLAIM_ENABLED is turned off. Both funnel into the same
reassignment operation an admin can also trigger directly from
/admin/accounts — pick the account to keep, pick the stale one(s) to fold
in, preview what moves, confirm.
One real trap in name mode worth knowing about explicitly: a browser tab
and a home-screen icon are genuinely separate storage contexts on iOS, each
capable of holding its own signed-in session. Confirming a name-match
claim always deletes the matched account, which is exactly right when it's
a genuinely abandoned duplicate — but if that account is actually the
other context's live session (both tab and icon signed in as the same
person, independently), confirming on one side signs the other out, and
doing the same back on that side repeats it indefinitely
(CHANGES_20260807c_1.md §6). The confirmation prompt now says this
explicitly rather than leaving it a silent side effect. The actual fix is
attaching an email: it makes an identity portable across contexts without
ever needing the merge-and-retire machinery, so the Add-to-Home-Screen
prompt now offers it right after a successful install — installing the icon
is precisely the moment a second context is about to exist, which is a more
useful moment to ask than a generically-timed nudge would be.
Setup: enable Authentication → Providers → Anonymous sign-ins in the Supabase dashboard, and apply migration 015.
email — magic link plus a 6-digit codePasswordless. The same email carries both a link and a code; the code exists because on a phone a magic link often opens in the mail client's in-app browser, which does not share cookies with the browser holding the session — sign-in silently does nothing and it is baffling to debug. Six digits always works.
Fixes both of the trade-offs above: identity is portable across devices, and names cannot be claimed by someone else.
Requires custom SMTP (see below).
demo — everyone is the same userNo sign-in at all. What you get with no configuration.
Change one environment variable. Nothing else. Both real modes sit on the same
auth.users table, so existing users keep their ids, their history and their
display names across the change — an anonymous user can even be upgraded in
place by attaching an email to it.
Seven weighted components, all in src/lib/recommend.ts, all pure functions.
Every tunable number lives in src/lib/recommendConfig.ts — you can change the
app's whole personality in one file.
| Component | Weight | What it is for |
|---|---|---|
| Cuisine affinity | 2.0 | What you rate highly, learned from your history, plus anything you explicitly liked or disliked. |
| Bayesian rating | 1.5 | Community and personal ratings, smoothed toward a prior so one glowing review does not beat forty good ones. |
| Budget fit | 1.0 | In range, one tier out, or out. |
| Walk penalty | 1.0 | Free for the first five minutes, then a cost per minute, floored. Doubles when rain is likely. |
| Variety | 1.2 | Rewards somewhere new, penalises somewhere you went yesterday, and lets a favourite come back as the memory fades. |
| Wishlist | 2.0 | Saving something makes it more likely to be suggested. Absorbed the old teammate-reco weight when Reco was removed — a received Lobang's "Add to Wishlist" is now the honest downstream path for that signal, and it only fires once the recipient actually acts on it. |
Three things are hard exclusions rather than penalties: a blocked place, a place on your personal blocklist, and a place whose every cuisine you have marked as one you would rather not eat.
Group mode scores each member separately and averages. A place excluded by any single member is excluded for the group — one person's hard no outranks everyone else's mild preference.
Plurality voting picks whatever the loudest minority wants. With six people and five options, a place three people love and three people refuse can win with a plurality while making half the table unhappy. Borda asks everyone to rank and rewards broad acceptability, which is the actual goal when a group has to eat together.
A voter who ranks N options gives N points to their first choice down to 1 for their last. Partial ballots are fine and are scaled by that voter's own ballot length, so ranking three options does not buy more influence than ranking all six. Ties break on points, then on first-place votes, then at random.
Roughly 30 minutes end to end. Everything below stays on a free tier.
anon key, and the
service_role key. The last one is a secret; it bypasses all access
control.supabase/migrations/ in numeric order,
001 through 068. They are idempotent, so re-running is harmless.http://localhost:3000 to the redirect URLs for local work.email modeIn the default name mode the app never sends an email, so there is nothing to
configure and you can skip straight to step 3.
If you later set NEXT_PUBLIC_JIO_AUTH_ADAPTER=email, this stops being
optional. Supabase's built-in email service is rate limited to a handful of
messages an hour and is explicitly not for production — and since sign-in
is email in that mode, hitting the limit looks exactly like the app being
broken.
Set up custom SMTP under Project Settings → Authentication → SMTP Settings, and enable Authentication → Providers → Email with magic links on. Resend and Brevo both have free tiers well beyond what a team of this size needs.
Register free at onemap.gov.sg for real walking distances. Without it the app falls back to straight-line estimates, which run about 20–30% optimistic in a dense area — everything still works, the numbers are just softer.
Without this, the "View on Google Maps" link on a place's page opens a coordinate pin — same as before this feature existed, nothing breaks. With it, the app resolves each place to its actual Google listing instead.
GOOGLE_PLACES_API_KEY (below).npm run backfill:google-places once to resolve
every place added before the key existed — new places resolve
themselves automatically going forward.Push to GitHub, import the repo at vercel.com, and add the environment variables. Vercel detects Next.js with no configuration.
| Variable | Value |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | From step 1 |
NEXT_PUBLIC_SUPABASE_ANON_KEY | From step 1 |
SUPABASE_SERVICE_ROLE_KEY | From step 1 — secret |
NEXT_PUBLIC_DEMO_MODE | false |
NEXT_PUBLIC_JIO_AUTH_ADAPTER | name (or email once SMTP is set up) |
CRON_SECRET | openssl rand -hex 32 |
NEXT_PUBLIC_SITE_URL | Your live URL. Used to build shareable invite links — without it the app falls back to the browser's origin, which leaves the first server render showing a bare path. Do not use VERCEL_URL: it resolves to the per-deployment hostname, not the production alias. |
ONEMAP_EMAIL / ONEMAP_PASSWORD | Optional, from step 3 |
GOOGLE_PLACES_API_KEY | Optional, from step 4 |
ANTHROPIC_API_KEY | Optional — enables receipt photo scanning for bill-splitting; see Free-tier realities |
VAPID_PUBLIC_KEY / NEXT_PUBLIC_VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT | Optional — push notifications. Generate with npx web-push generate-vapid-keys; the public key goes in both variables (server and client need it under different names), same value. Without these, push silently no-ops rather than breaking anything. |
JIO_NAME_CLAIM_ENABLED | Optional, defaults to true. Set to false once names genuinely aren't unique across the team anymore — typing an existing name then always signs in as (or renames) the current session rather than offering to merge. Only meaningful in name mode; recovery links and the admin merge tool at /admin/accounts both keep working unaffected either way. |
JIO_CUISINE_ADD_OPEN | Optional, defaults to true. Set to false to require an admin to promote a custom cuisine tag into the shared list — everyone can still browse/pick from the existing list, and the admin combine tool at is unaffected either way. |
Then go back to Supabase and set the Site URL to your live URL.
npm run seed:manual # 25 hand-picked spots around Bras Basah / Bugis
npm run seed:overpass # everything OSM knows about nearby → review queue
npm run seed:walktimes # compute and cache walking times
npm run backfill:google-places # optional — needs step 4's API key set first
All four read .env.local and exit cleanly with a message if Supabase (or,
for the last one, the Google Places key) is not configured, rather than
failing.
curl -H "Authorization: Bearer $CRON_SECRET" \
https://your-app.vercel.app/api/cron/discover
# {"fetched":45,"new":3,"skipped":42,...}
Then sign in — one name field, no email — open /places, and confirm the
"Quick & nearby"/"New to try" rails show up with reasons attached. Ask a
colleague to do the same and check they show up as a separate person on
/kakis and in an event's vote.
Things that will bite eventually. None are urgent at team scale, but you should know they exist before they surprise you.
Supabase pauses a free project after 7 days with no queries. The daily discovery cron queries the database on every run, which keeps the project awake. This is not a side effect — it is half of why that cron exists. If you disable discovery, add some other daily ping.
Vercel Hobby runs each cron job at most once a day (imprecise timing,
±59 minutes), but the old per-project job-count cap is gone — Hobby now
allows up to 100 cron jobs, confirmed directly against Vercel's docs
(last updated 2026-07-15). vercel.json has three: discovery at 02:00 UTC,
the weekly review-likes recap (CHANGES_20260814.md §3) at 03:00 UTC every
Monday, and the monthly food identity snapshot (CHANGES_20260821_combined2.md
Item 1) at 04:00 UTC on the 1st — all comfortably under the once-a-day-per-job
limit since none fires more than once a day. For anything more frequent than
once a day, point an external scheduler such as
cron-job.org at the route with the same bearer token.
The "starting soon" reminder (CHANGES_20260821c.md §1) is the first thing
that actually needs this. GET /api/cron/event-reminders is deliberately
left out of vercel.json — a per-person, per-Jio configurable lead time
(as opposed to discovery's daily sweep) has to be checked every few
minutes to fire anywhere close to on time, which Hobby's own cron can't
do. Point an external scheduler at it every 5 minutes or so, same
Authorization: Bearer $CRON_SECRET header as the two cron jobs above.
Without it configured, per-Jio reminders are still fully functional to
set — they just never actually fire, the same "silently does nothing"
failure mode a misconfigured VAPID key already has elsewhere. This same
route also now runs the other, fixed-30-minute "you haven't voted or
RSVP'd yet" nudge (the Push notifications row above) — it used to be
lazy, page-load-triggered only (039_close_reminder.sql), and a real report
confirmed that as unreliable in practice: a Jio's reminder simply never
arrived until someone happened to open the app well after it was due. Set
up this one scheduler entry and both reminders actually fire on time; skip
it and both silently degrade to "only when someone opens the app."
The vote-deadline sweep (below) needs the same setup, as a third job on
the same external scheduler. GET /api/cron/vote-deadline is likewise
left out of vercel.json for the same reason — a host-set deadline needs
checking every few minutes to actually close on time, not once a day.
Same bearer token, roughly every 5 minutes. Unlike the reminders above,
skipping this one doesn't leave the Jio stuck forever any more — a real
report confirmed that used to happen (a Jio past its deadline sat open
indefinitely, the countdown reading "Closed" while nothing had actually
force-closed it), so GET /api/events/[id] now also runs the same close
check lazily, scoped to just the one event being viewed. Without the cron
configured, a Jio with a deadline set still force-closes — just only once
someone (anyone with a stake in it) actually opens its page past the
deadline, rather than within a few minutes of it on a guaranteed clock.
Bill-splitting receipt scans are the first thing in this app that costs
real money per use, not just infrastructure setup. ANTHROPIC_API_KEY
(and optionally ANTHROPIC_MODEL, defaulting to the cheap claude-haiku- 4-5) needs setting for src/lib/receiptOcr.ts to actually scan a
receipt — a fraction of a cent per photo, but genuinely metered, unlike
every other dependency in this app. Same "silently degrades" shape as a
missing VAPID key: without it configured, Splits still works fully via
"Enter items by hand," it just skips straight past the photo
path with a plain message instead of erroring. Receipt photos themselves
go to a new, private Supabase Storage bucket (receipts, created by
099_bill_splits.sql's own migration) — nothing extra to configure in the
Supabase dashboard beyond running that migration, since the bucket and
its policies are created by SQL, the same as every other piece of this
schema.
Multi-office discovery is paced, not parallel. With more than one office, the cron sweeps them one after another with a 2s gap between each — not to respect an Overpass rate limit (none is documented), just to avoid firing N simultaneous requests. A time-budget check stops starting new offices once there isn't comfortably enough of the function's 60s ceiling left, so a slow run skips the remainder rather than getting killed mid-sweep and losing whatever that office had already found — the skipped offices pick up on the next day's run.
Vercel Web Analytics and Speed Insights are on, both free on the Hobby
plan — page views, unique visitors and Core Web Vitals in the Vercel
dashboard, no extra configuration beyond the <Analytics /> /
<SpeedInsights /> components already in the root layout.
Rating aggregates are trigger-maintained columns, not computed on read.
A row-level trigger on visits (migration 021_place_ratings_trigger.sql)
recomputes places.avg_rating/visit_count for the one affected place on
every insert/update/delete — always current, no refresh schedule, and
listPlaces() no longer needs a visits fetch at all. The shared
bayesianRating() helper in src/lib/rating.ts is what turns those two
columns (plus, where relevant, the current user's own ratings) into the
smoothed score recommend.ts actually ranks on.
OSM coverage is uneven. Some entries are long-closed businesses, some are
vending machines tagged as cafés. Everything discovered lands in a
needs_review queue and never reaches suggestions until a human confirms it.
The 25 curated places in scripts/manual-seed.json are the fallback.
OneMap tokens last 72 hours. They are cached in memory and refreshed 12 hours early; a 401 clears the cache and retries once. If it still fails, the app falls back to haversine rather than showing an error.
Google Places requires billing enabled on the project even though the
cost at this scale should be close to nothing. The lookup this app makes
(places:searchText, requesting only id/displayName/location) isn't
Google's fully-free Essentials/IDs-only tier — the extra two fields are
what lib/googlePlaces.ts needs to build its own confidence check, so
they're requested deliberately — but at one small team's worth of
occasional place adds/edits, real usage should land in the low cents,
ever, not a recurring cost. Not independently verified against Google's
live billing console — this session's sandbox blocks outbound requests to
developers.google.com — so treat it as reasoned from Google's published
pricing tiers, not confirmed firsthand; worth a spot-check against your
own Cloud console once real usage starts.
Row Level Security is the access control, not the application code. The
server-side data client always uses the anon key, so every query is subject to
RLS. tests/clients.test.ts asserts it never falls back to the service-role
key, even when that key is sitting in the environment — that silent-escalation
bug is exactly the kind that works fine in development and leaks everything in
production.
The service-role client has four callers. /api/cron/discover, which
needs to write to the review queue with no user session; account merge
(mergeUserAccounts), which needs to delete the old auth.users row once its
data has moved — an Auth Admin API operation no RLS policy could ever grant;
/api/cron/weekly-recap (listReviewLikesSince), which needs to read
every user's review_likes rows with no user session to satisfy that
table's owner-only RLS policy; and the food identity cron
(CHANGES_20260821_combined2.md Item 1) — listAllUserIds/listAllKakiIds
(the cron's own iteration set), the two save*FoodIdentitySnapshot
methods (068_food_identity_snapshots.sql deliberately grants authenticated
no insert/update policy on either table at all), and, since a real bug fix,
listAllVisitsForCron/listAllPlacesForCron/listAllKakisForCron/
listAllUserPrefsForCron (the last one added later, to compare each
account's Profile Taste preferences against actual visit behaviour for the
optional preference-alignment note — same authenticated-only,
owner-scoped RLS on user_prefs as the others, same reason it needs the
service-role client). That fix mattered: the cron used to gather its own
input data — visits, places, Kaki membership — through the ordinary
per-request client (listVisits, listPlaces, getKaki), every one of
which is authenticated-only under RLS, same as review_likes. A Vercel Cron invocation has no session at
all, so those reads silently returned nothing against a live project —
totalVisits came out 0 for every single account regardless of real
history, permanently locking every month's snapshot as
just_getting_started, and the per-Kaki loop never found a Kaki to
snapshot at all. computeFoodIdentity()/computeUserMetrics() themselves
were never at fault (confirmed correct by feeding them real data directly)
— the bug was entirely in what the cron could actually see. Once a
snapshot is written for a given month it is never recomputed, so an
account that already has a just_getting_started row locked in from
before this fix stays that way until next month's run; there is no
automatic backfill for a month already written incorrectly. The module
throws at import time if it is ever bundled for the browser.
middleware.ts validates the session once per request; every page and
route trusts that instead of repeating it. Speed Insights showed FCP/LCP
far worse than INP/CLS pointed to — a server-response problem, not a
rendering one — and the cause was 55 pages each independently calling
getCurrentUser(), which makes its own network round-trip to Supabase's
Auth server, on top of the identical round-trip middleware had just made.
getValidatedUser() (lib/supabase/serverAuth.ts) now reads the already-
validated result from a request header (AUTH_HEADER_NAME,
lib/supabase/authHeader.ts) instead, falling through to a real
getUser() call only when that header is missing. Trusting a header like
this is normally a spoofing risk; it isn't here because middleware.ts
unconditionally overwrites it on every request — Headers.set() replaces
rather than appends — so whatever a client sent never survives to be read
downstream.
Blog import is SSRF-guarded. validateBlogUrl() rejects localhost, all the
private IPv4 ranges, link-local (which is where cloud metadata endpoints live),
and the IPv6 equivalents — including IPv4-mapped addresses in both their dotted
and hex spellings, since new URL() silently normalises one into the other.
One limitation to be aware of: it does not resolve DNS, so a public hostname
that resolves to a private address would still pass. Pin egress at the network
layer if that matters to you.
Some SELECT policies are permissive on purpose. Any authenticated user can
read events, options, kakis and profiles. Invite tokens are unguessable, so
possession of an id already implies an invite, and the alternative — recursive
membership checks inside policies — needs SECURITY DEFINER helper functions
that are much easier to get subtly wrong. Writes are owner-scoped throughout.
One exception: profiles.recovery_token (migration 041) is deliberately
not covered by that same permissive read — RLS is row-level, so the fix is
a column-level grant (revoke select on profiles ... grant select (the public columns) ...) rather than a policy, and getProfile()'s query was
switched off select("*") accordingly, since * errors on any column the
role lacks privilege on rather than silently dropping it. Every new
profiles column since then needs its own grant select (...), not because
it's necessarily sensitive like recovery_token, but because 041's column
restriction applies to any column, granted or not; skip the grant and it's
silently unreadable through getProfile()'s explicit column list rather
than just omitted. (Migration 067's one-shot celebration flag was itself an
example of this, back when it lived on profiles — migration 070, UX
review log #25, moved it to its own decided_celebration_views table
instead, one row per (user, event) rather than one account-wide flag, RLS'd
owner-scoped exactly like wishlist.)
Adding a place to a Jio is checked twice. Once in the repo, for a readable error message, and once in the RLS policy from migration 013, which is the actual gate. Host, kaki member or explicit invitee, and only while the event is open.
Listing every admin needs SECURITY DEFINER too, for the same reason
checking one does not. admins_select_self only ever lets a session see
its own row in that table — enough for an isAdmin check, not enough to
enumerate the allowlist. list_admin_ids (migration 042) exists for exactly
one caller: notifying admins when a declined name-match confirms a real
duplicate (CHANGES_20260807c.md §3 item 5) — granted to any authenticated
user, since it is the person who just signed up (not necessarily an admin
themselves) who triggers that notification.
admins.user_id has no foreign key to auth.users, which used to mean
admin status was silently dropped on every forced re-login. Deliberate,
per its own migration comment (017) — but it also means deleting an
auth.users row during an account merge never cascades to admins, and
merge_user_accounts (040) didn't touch that table either, so an admin's
row was simply left behind, orphaned, pointing at a user that no longer
existed. Confirmed bug, CHANGES_20260807c_1.md §8: any admin who went
through §4-style recovery lost admin access every time, with nothing
anywhere re-granting it, since the app deliberately never grants admin
itself. Migration 043 adds admins to the reassignment list, same
"surviving account's own row wins" shape as every other table
merge_user_accounts already handles.
The browser-side Supabase client never writes a session cookie, on
purpose. It exists for exactly one thing — authenticating a Realtime
websocket on the Jio detail page — which needs reading the current
session, never writing one. Left at its defaults, createBrowserClient gets
no cookies adapter and falls back to managing the session itself via plain
document.cookie, and with autoRefreshToken on by default, just visiting
that page starts an internal refresh loop that can rewrite the session
cookie from client-side JS. Safari's Intelligent Tracking Prevention treats
a script-written cookie differently from the real Set-Cookie response
header every other session write in this app uses (middleware.ts /
serverAuth.ts) — the leading theory behind an iOS-specific sign-out
reproducing in minutes rather than the access token's real ~1 hour lifetime
(CHANGES_20260807c.md §4). src/lib/supabase/browser.ts now passes an
explicit cookies adapter whose setAll is a deliberate no-op and disables
autoRefreshToken, so nothing this client does — however it might try —
can ever write a cookie. The trade-off: a tab left open with no navigation
for over an hour can see its Realtime connection go stale, since neither
this client nor the server-side middleware (which only runs on navigation)
refreshes it in that scenario. A reload fixes it; it is a soft failure, not
the sign-out this was written to close off.
Admins are a DB-side allowlist, populated by hand. The admins table
(migration 017) has no insert/update/delete policy for authenticated at
all — the only way into it is a direct Supabase dashboard or SQL editor
connection, on purpose, to keep "make me admin" from ever being something
the app itself can do. repo.isAdmin(userId) and /api/me's is_admin
field are for the UI to decide what to render; they are not the enforcement.
"Removing" a place blocks it, never deletes it. status moves to
blocked instead — already excluded from recommendations and the default
places list — because a real DELETE risks foreign-key trouble the moment a
place has visits, event options or lobangs pointing at it. Reaching
blocked only ever happens through block_place/unblock_place, two
SECURITY DEFINER functions in migration 017: block requires being the
place's own creator or an admin, plus a non-blank reason; unblock is admin
only. A column-level GRANT UPDATE on places deliberately excludes
status, so a plain client-side update can't touch it at all — those two
functions (running as the table owner) are the only path, and every call
they make is logged to place_moderation_log, which only admins can read.
Newly-discovered places get a lighter, separate gate. Confirming or
dismissing something OSM discovery just added (needs_review →
active/blocked) goes through its own function, review_place, open to
any signed-in user with no reason required — that's crowd-confirmation of
data quality, not moderation of a place the team has actually been relying
on, so it deliberately doesn't share block/unblock's admin-or-creator gate.
Any signed-in user may edit any place — the grant is derived, not
hand-listed. Migration 027's column-level GRANT UPDATE on places is
built from information_schema at migration time (everything except a
fixed protected list — status, the trigger-maintained rating/flag
columns, provenance) rather than naming the editable columns by hand. The
earlier hand-written version is what caused a real "permission denied"
error in production: it never grew when the edit form started writing
columns it didn't cover. Deriving which columns are editable means a
future column needs no code change here — but the GRANT itself is still
a snapshot taken when the migration runs, not a standing rule, so adding a
column still needs a migration that re-issues the grant (033 does this for
custom_cuisine_tags, 049 for the exclusion the other way when
google_place_id needed to stay off the grant, 060 for socials_url
(CHANGES_20260821b.md §1), and 083 for foodpanda_url/grab_url).
Skipping that step reproduces the exact bug this migration exists to
prevent, just for the new column instead.
A vote option with no place record is still just an id — not a schema
fork. event_votes.place_id has no foreign key to places, so a
free-text option (§8 of the working log) gets a generated id instead of a
real place's, and every tally/winner code path stays unchanged. Upgrading
it to a real place later goes through attach_place_to_option, a
SECURITY DEFINER function — widened by migration 056 (CHANGES_20260819d.md
§1) from the option's own adder or the Jio's host to any Jio participant
(host, kaki member, or invitee), so a persistent per-option link can let
anyone who can already see the ballot help register it, not just whoever
happened to type it in — moving votes already cast for the draft along
with it, so voting for something before it becomes a real place doesn't
discard those votes the moment it does.
event_options.place_id, unlike event_votes.place_id, did carry a
foreign key to places — migration 029's own comment claimed otherwise,
conflating the two columns, and that gap is what produced a real
invalid input syntax for type uuid: "draft-<uuid>" error in production
(CHANGES_20260803.md §12a) the first time someone added a free-text
option outside the demo repo, which has no such constraint to catch it.
Migration 032 drops that foreign key and the app-level draft- string
prefix goes with it — place_id is a bare generated uuid now, same as
before 029 intended, and label alone marks a draft option.
Cancelling a Jio goes through a dedicated function, same shape as
block/unblock. cancel_event (migration 030) is host-only and only from
open — not a reuse of closed, since a host calling a Jio off on purpose
needs to read differently from one that simply ended without a decision.
lunch_events has no column-level grant restriction the way places does
(closeEvent still does a plain client update, gated only by the
host-scoped RLS policy), so this is deliberately the one column write on
that table that goes through a function rather than reworking the whole
table's grant model for one new transition.
Reopening a Jio for voting goes through a dedicated function too, same
reasoning as cancel_event. reopen_event (migration 058) is host-only,
only from closed, and only while scheduled_at is still ahead of now().
closeEvent/editEventWinner/rescheduleEvent all still do a plain
host-scoped client update — reworking lunch_events's whole grant model
wasn't warranted for those — but closed → open is a new structural
transition, so it gets its own gated path rather than adding one more
column a raw client write could quietly get wrong.
The invite picker is scoped to the caller's own office. /api/users
resolves the caller's user_prefs.default_office_id (falling back to the
default office) and both filters and scopes server-side in listAllUsers()
itself, rather than fetching everyone and narrowing in the route or the
browser. Office is a hard boundary for discovery — see
docs/user-discovery.md §6 — worth treating as a defect fixed on its own
merits under PDPA, independent of how large the team gets.
A recurring series only ever writes on its own host's behalf. Unlike
cancel_event or attach_place_to_option, generating an occurrence
(generateDueOccurrences, migration 031) is a plain client write rather
than a SECURITY DEFINER function — deliberately, since it's triggered by
loading Home or the Jios list, and recurring_series/lunch_events both
already require host_id = auth.uid() to insert. The trade-off this
accepts, stated plainly: only the host's own visit generates their
series' next occurrence. A kaki member or invitee opening the app first
does not trigger it on the host's behalf — the same one-cron-a-day
constraint that shaped the discovery cron (see Free-tier realities) is
what kept this lazy rather than reaching for a service-role write path.
A recurring series' occurrence gets claimed atomically, not checked then
written two steps later. Confirmed bug: because generateDueOccurrences
runs on every GET /api/events, two overlapping requests from the same
host — a second tab, an SWR refetch racing the initial page load — could
both read a series' last_generated_date before either had written it,
both pass the "not yet generated this week" check, and both go on to
create their own copy of the same week's Jio (reported as a recurring
"Wed Lunches" appearing twice, same date/time, in both the calendar and
list views). The occurrence is now claimed as one step, before anything
else in that pass awaits: the real backend does it as a single conditional
UPDATE ... WHERE last_generated_date IS NULL OR < this week's date, the
same "claim before acting" shape listAndClaimDueReminders already uses
for reminders, so a losing, overlapping request sees zero rows affected
and skips instead of creating a duplicate; demoRepo's in-memory version
gets the equivalent fix — writing the claim before its own first await,
since that's the only place another interleaved call could slip in on a
single-threaded store. Existing duplicates already created by the old
code aren't cleaned up automatically — a host needs to cancel the extra
copy by hand.
A hidden-vote Jio's blindness is enforced at the API response layer, not
the database. hide_votes (migration 034) is a plain host-write-only
column — RLS on event_votes still lets a participant read their own
ballot rows directly, same as always, since blocking that would also break
a voter seeing their own submitted ranking confirmed. What actually hides
the aggregate from everyone else is redactHiddenVotes() in
src/lib/voting.ts, which every route returning an EventDetail — the
vote route above all, but also options, RSVP, invitees, availability,
candidate-dates and suggest-options — runs its response through before
json(). That number of call sites is exactly the shape of bug this
codebase has already shipped twice (§1's grant, §12a's uuid): one function,
applied everywhere, rather than trusting N handlers to each remember.
"Rated by your Kaki group" inherits the same visibility limit
computeKakiMetrics already has. Both aggregate ratings across a Kaki's
members by calling listVisits once per member — subject to the same RLS
as any other query (user_id = auth.uid() or is_public = true), so a
private, unshared rating from a groupmate is invisible to this computation
in live mode, same as it already was for the Kaki page's group favourites.
Not a new gap this feature introduces, just one it inherits.
The admin analytics dashboard reads across every user's data on purpose,
through a SECURITY DEFINER function, not the plain anon-key client.
get_admin_analytics (migration 035) checks admins itself before
returning anything — same shape as resolve_place_flags/block_place:
privilege is earned by the check inside the function, not by which key
signed the request. This is deliberately different from "rated by your
Kaki group" above, which stays on the plain client and inherits RLS's
private-data limit — an admin dashboard's whole job is seeing the true
aggregate, a per-user feature's isn't. get_admin_place_detail (063),
get_admin_users/get_engagement_weights/set_engagement_weights (064),
and get_admin_user_detail (064) all follow the identical pattern for the
Places/Users views added later.
get_admin_user_detail shows one person's full visit history — every
rating, note, and best-dish list, regardless of is_public — not an
aggregate summary. This is a deliberate, documented privacy debt
(CHANGES_20260821_combined.md Part 1 §2), not an oversight: full detail was
chosen because there's currently exactly one admin, so the exposure is
Sean seeing Sean's own data model reflected back. It's flagged for
revisiting once more admins are added, since "full detail, visible to
whoever holds admin" gets riskier as that list grows — dropping to
aggregate-only later means deleting the one 'visits' key from the
function's jsonb_build_object and the corresponding block in the detail
page, not a rebuild. admin_engagement_weights (064) — the composite
score's per-signal weights — is a one-row singleton table with RLS enabled
and zero policies defined, the same "no policies, the function is the
only door" shape as recovery_tokens: every access has to go through
get_engagement_weights/set_engagement_weights, both of which check
admins themselves.
get_admin_user_detail's lobangsReceived counted a column that hadn't
existed since migration 019 (fixed in 077). Migration 019 moved a
lobang's recipients out of a single lobangs.to_user_id column into a
many-to-many lobang_recipients table (a group send, or one sent to
several teammates at once, has no single "to" to hold) and dropped the
old column — but 064's get_admin_user_detail, written later, still
queried lobangs where to_user_id = p_user_id for that count, and 076
carried the same line forward verbatim (a create or replace reproduces
the whole body, bug included). It went unnoticed until an admin actually
opened a person's drill-down against a real Supabase project — the demo
store has no schema to enforce this, so it silently returned 0 there
instead of erroring, which is exactly why the existing test for this
field only ever asserted lobangsSent. Fixed to count lobang_recipients
in both places: the SQL function (077) and demoRepo's parallel computation
(same file, same bug shape — s.lobangs.filter(l => l.to_user_id === userId) filtered on a field the raw stored row never carries).
Fixing the lobangsReceived bug above uncovered a second, bigger one
underneath it: getAdminUserDetail's Supabase implementation never
computed metrics at all. The person drill-down page reads detail. metrics.totalVisits first thing — get_admin_user_detail's SQL returns
raw visits rows precisely so a caller can compute that from them (the
same "raw material in, aggregate out" shape computeUserMetrics already
has, and exactly what demoRepo's own getAdminUserDetail does with its
in-memory equivalent). But supabaseRepo.getAdminUserDetail just cast the
raw RPC JSON straight to AdminUserDetail and returned it — metrics was
never in that JSON to begin with, so detail.metrics was undefined in
live mode, and undefined.totalVisits crashed the whole page into the
app's generic error boundary. This had silently never worked, on any
account, since the page was built: the lobangsReceived bug above always
failed the RPC call first (with a scoped, graceful inline error), so
nothing ever got far enough to hit this one. Fixed by fetching places
alongside the RPC result and calling computeUserMetrics in supabaseRepo
itself, the same as demoRepo already does — no new migration needed,
since the SQL was already returning everything required.
admin_segment_member_ids (065) is never granted to authenticated at
all — only get_admin_analytics calls it, internally. It recomputes one
segment's membership (the same six rules get_admin_users already defines
— duplicated rather than shared, since refactoring already-shipped,
already-tested code to save one copy of six where clauses wasn't worth
the regression risk) to power Part 1 §E's segment filter. Adding this
required a drop function if exists get_admin_analytics(integer) before
the create or replace that adds the new p_segment parameter — Postgres
only replaces a function whose parameter list matches exactly, so
without the drop the old one-argument version would have stuck around as
a second, still-callable overload instead of actually being replaced.
The Daily Activity Log's own two functions are SECURITY INVOKER, not
SECURITY DEFINER — the opposite of every admin-analytics function above.
track_daily_visit (076) and log_action (076) only ever read or write
auth.uid()'s own row in app_daily_visits/action_events, which RLS
already permits directly (app_daily_visits_insert/_update, action_ events_insert, all user_id = auth.uid()) — no elevated privilege is
needed to make the increment-on-conflict upsert atomic, unlike
get_admin_analytics and friends, which genuinely need to read across
every user and so have to earn that with their own explicit admin check.
logAction (lib/actions.ts) wraps the repo call in a try/catch that only
logs and returns — same "a courtesy on top of the real write, never a
condition for it succeeding" shape sendPushToUsers already established
for push, so a failure logging jio.hosted can never fail creating the
Jio itself. AppVisitTracker is mounted in layout.tsx only when
getValidatedUser() resolves someone signed in — there is nothing to
attribute a page view to otherwise — and posts to /api/activity/ping on
mount and on every usePathname() change, which only fires on a real
navigation, never a prefetch (those don't run client code at all). The
endpoint was originally /api/track/visit, renamed after a real gap
turned up: that path's "track" substring is a common ad-blocker/privacy-
filter match, so a signed-in session on a machine with such a filter could
go completely uncounted with no error anywhere, since the beacon's own
failure handling is silent by design.
Staying signed in depends on middleware.ts, not just the Supabase
client config. @supabase/ssr's access token is short-lived; the refresh
token is what's supposed to rotate it before it expires, and that rotation
has to persist a new cookie. Server Components can't set cookies at all —
so before this middleware existed, a refreshed session was computed and
then silently discarded on every request past the first, and the token
eventually expired for real. getCurrentUser() then reads "no valid
session" as "never signed in," which is exactly what sent a returning user
back through onboarding into a brand-new anonymous identity, orphaning
everything tied to the old one. Middleware is the one place in the request
pipeline that can persist a refreshed cookie before a Server Component
ever runs, which is also why this couldn't have been fixed anywhere else.
The personal invite link (/u/[token]) used to glue two origins together
once NEXT_PUBLIC_SITE_URL was actually set. personalInviteUrl()
already returns a fully absolute URL server-side (see siteOrigin()
above) — but the client-side hook that displays it (usePersonalInviteLink
in PersonalInvitePanel.tsx) unconditionally prepended
window.location.origin on top anyway, the same pattern the sibling
recovery-link flows correctly use since their API returns a bare path.
Result on any deployment with the env var configured: a shared link that
read https://your-apphttps://your-app/u/token, broken for anyone who
tapped it. Fixed by only prepending the origin when the API's response
isn't already absolute — the same absolute-URL check ShareLink's own
resolved fallback already uses.
Following an event's own invite link now actually registers the visit.
join_event_via_invite (migration 036) is SECURITY DEFINER because
event_invitees_insert's RLS policy is host-only by design — a visitor
adding themselves needs the same kind of deliberate, gated exception as
attach_place_to_option, not a loosened policy that would also let a host
be invited-added by anyone who merely knows their event exists. Narrow on
purpose: the function only ever inserts a row for auth.uid(), never an
id passed in, so it can't be used to invite anyone else.
Push notifications read across users the same way admin analytics
does, for the same reason. get_push_targets (migration 037) is
SECURITY DEFINER because both push_subscriptions and profiles are
owner-scoped by RLS — notifying someone as a side effect of your action
(inviting them, closing a Jio) means reading their subscription, which a
plain client query would refuse. Unlike get_admin_analytics, there's no
admin check: any signed-in user legitimately triggers these sends just by
using the app normally, the same "any authenticated teammate" trust
boundary the rest of this single-office app already runs on. Also unlike
the Kaki-rating/admin-analytics pair above, this one stays a narrow,
purpose-built read (exactly the columns the send path needs) rather than
a broadened table-wide SELECT policy — a push endpoint and keys are more
sensitive than a display name or a rating.
The two remaining push triggers — "someone voted" and the pre-start
reminder — are SECURITY DEFINER for the same shape of reason.
claim_vote_push_window (migration 038) and claim_event_reminder
(migration 039) each need to write a timestamp onto lunch_events, but
lunch_events_update's RLS policy is host-only — and the caller claiming
the window is whoever just voted, or whoever's page load happened to
trigger the reminder check, not necessarily the host. Both are
one-purpose atomic claims (UPDATE … WHERE … RETURNING, nothing else),
the same narrow shape as claim_vote_push_window's neighbour. The vote
route claims its window the instant a vote lands — genuinely event-driven,
no cron needed. The pre-start reminder originally rode the same lazy,
page-load-triggered pattern generateDueOccurrences uses for recurring
series, on the reasoning that Vercel Hobby's cron already ran once a day
for discovery and nowhere near frequent enough for "fire 30 minutes before
an arbitrary time of day" anyway. That trade-off — the reminder only
actually fires when someone with a stake in the Jio has the app open
somewhere near that 30-minute mark, not on a guaranteed clock — was stated
plainly at the time as a known limitation, and a real report ("push didn't
fire until I opened the app") confirmed it as a genuine bug in practice,
not an acceptable rough edge. listAndClaimUpcomingEventReminders now
runs the identical scan from /api/cron/event-reminders (same external
scheduler the configurable per-Jio reminder just below already needed),
sharing the same reminder_sent_at one-shot claim so the two triggers can
never double-send. The lazy page-load path stays in place alongside it as
a same-instant fast path, not removed — it just isn't the only path
anymore.
The like-triggered push reuses that exact shape; the weekly recap is the
one push that's cron rather than inline, because it genuinely needs to be.
claim_review_like_push_window (migration 048) is the same one-purpose
atomic claim as claim_vote_push_window, fired inline from the like route
the instant a like lands — no cron slot needed, same as vote-push. The
weekly recap can't work that way: "how many likes this week" only means
something once the week is over, not the instant any single like happens,
so it's the one push in this app that genuinely needs a scheduled sweep
rather than firing off a user action. That's what earns it the second
vercel.json cron entry rather than a third inline claim function.
The "starting soon" reminder (CHANGES_20260821c.md §1) needed a real
scheduled sweep too, but couldn't reuse the weekly recap's shape as-is.
A per-person, per-Jio configurable lead time has to be checked far more
often than once a week to fire anywhere near on time — frequently enough
that it can't sit in vercel.json at all (see Free-tier realities above),
so listAndClaimDueReminders runs off an external scheduler instead of
either Vercel cron path. It also can't reuse a SECURITY DEFINER RPC the
way claim_vote_push_window/claim_event_reminder do: the external
scheduler's request carries only a bearer token, no auth.uid() at all,
so a function gated on "the caller must be the row's own user" has no
caller identity to check. Granting a cross-user RPC like that to anon
instead — the only other option for a no-session caller — would hand
"who's confirmed going to what, across every user" to anyone holding the
public anon key. So this reaches for the service-role client instead
(same reasoning as listReviewLikesSince), and claims each due
(event, user) pair with a plain conditional UPDATE … WHERE sent_at IS NULL — the same one-purpose atomic-claim shape as the RPCs above, just
run as a table operation the service role doesn't need a grant to use,
rather than a function it would.
Full-consensus auto-close (CHANGES_20260821_combined.md Part 2) used to
be a fifth reason the service-role client existed — it no longer is.
maybeAutoCloseEvent ran synchronously inside the RSVP and vote routes
right after each write, closing a Jio itself, no host action required,
the moment every current participant had RSVP'd and everyone going had
voted. A real bug report — "Jio keeps closing whilst new members trying
to add their votes in" — found the flaw: a Jio anyone can still join via
its own share link (or, kaki-linked, just by being a member) can never
have a genuinely final population, so "everyone's answered" is a moving
target. Someone opens the link, joins, votes, and if everyone already
joined had answered, the Jio closed right then — locking out whoever was
about to open that same link a moment later. There's no way to tell
"everyone who'll ever join has voted" from "everyone who's joined so far
has voted" from inside that check alone, so closing on it automatically
was fundamentally unsafe for any Jio someone else could still join.
The same condition is still computed — every current participant
answered, every "yes" backed by a fresh (non-stale) vote — but only ever
as a read, EventDetail.readyToClose (computeReadyToClose,
src/lib/voting.ts), recomputed on every getEvent. It's surfaced as a
prompt on the host's own Jio page ("Everyone's answered — ready to
close?") right under the share link, not a background job — the host
decides when it's actually done, same "Close with the vote" action the
page already offers further down, just harder to miss. notifyHostOfVote
(vote route) and its RSVP-route counterpart both switch their push copy
to the same prompt once a write makes this newly true, reusing the
existing per-event vote-push throttle rather than a new one. Closing
itself now only ever happens through the host's own explicit request
(closeEvent, still host-only RLS, no service role needed) or the
vote-deadline sweep below — both already had a legitimate path to close
without racing a still-growing guest list, since a deadline is a fixed
point in time, not "whoever happens to have joined so far."
Account merge writes across two different auth.uid()s, so it has to be
SECURITY DEFINER — and it has to check which two. merge_user_accounts
(migration 040) moves every row a user_id-owned table has for one account
onto another (Jios hosted, votes, RSVPs, invites, Kaki ownership/membership,
wishlist, visits, push subscriptions, prefs), which no RLS policy could ever
permit as a plain write. The authorization is deliberately narrower than
"any admin, any pair": the caller must either be merging into their own
current session (self-service name-claim / recovery-link redemption,
CHANGES_20260807.md §4) or be an admin merging two accounts that are neither
their own (§5's admin tool) — never an arbitrary user moving a stranger's
data around. Composite-PK tables (votes, RSVPs, invites, Kaki membership,
wishlist) get an explicit collision check first — if both accounts already
have a row for the same event/kaki/place, the surviving account's own row
wins and the merged-in duplicate is dropped, rather than erroring on the
resulting duplicate key.
Recovery-link generation is gated the same way merge is; redemption
deliberately is not. generate_recovery_token (migration 041) — self, or
admin for someone else — matches merge_user_accounts's authorization
exactly, since it's gating access to the same underlying capability one step
removed. resolve_recovery_token has no auth check at all: same "possession
of the token is the invite" reasoning already applied to every other
unguessable token in this schema (lunch_events.invite_token,
kakis.invite_token) — the token itself is the credential, checking the
caller's identity on top of it wouldn't add anything since whoever is
redeeming it is, by construction, whoever's browser currently holds the
link.
Personal invite links (discovery_token, migration 053) are a deliberately
separate column from recovery_token, not a reused one, despite an
identical "unguessable, SECURITY DEFINER resolver" shape. The two have
opposite threat models: recovery_token is a login bypass that must stay
secret indefinitely, discovery_token is meant to be handed out — posted,
texted, put on a QR code. Sharing a column would mean either loosening the
secrecy expectation on account recovery or making the invite link
regenerate (and thus quietly break) every time someone recovers their
account, neither of which either feature should ever cause.
Ranking teammate pickers by co-attendance (get_co_attendance_scores,
migration 054) needed its own SECURITY DEFINER function, for yet another
distinct reason. event_invitees_select (013_event_invitees.sql) only
lets a session see its own invitee row, or every row on an event it hosts
— exactly right for "can I see who's coming," and exactly wrong for
"who else was at the Jios I've been to as a plain invitee, not the host,"
which needs reading other people's invitee rows on events the caller
didn't host. Same shape as get_push_targets/is_lobang_recipient
bypassing RLS for one specific cross-row read, applied to a third table.
Adding someone to a Kaki directly needs the same SECURITY DEFINER
escape hatch as the invite link's own join step. kaki_members_insert's
RLS policy is user_id = auth.uid() — you may add yourself, no one else —
so add_kaki_member (migration 045) exists for exactly the same reason
join_event_via_invite does: a legitimate second-party write RLS is never
going to allow by policy alone. Authorization is narrower than "any
signed-in user": the caller must already be a member of the Kaki they're
adding someone to, checked inside the function before the insert runs, not
left to the application layer alone.
Merging an account now also carries the earlier profiles.created_at
forward. Migration 044 extends merge_user_accounts's existing create or replace with one least() update: the survivor keeps whichever signup
date is older. Everything else about a merge was already collision-safe
across owned tables; this closes the one field that wasn't — without it, a
merge silently rewrote history by making a years-old account look brand
new, which is exactly what was distorting the admin analytics "new users"
chart (CHANGES_20260812.md §5).
The public place-preview page is the first route in this app an
anonymous request can see anything through, so it earns its own function
rather than a loosened policy. places_select (007_rls.sql) is
for select to authenticated using (true) — a signed-out visitor runs as
Postgres role anon, which that policy grants nothing to, so a plain
select from places already returns nothing for them; there was no
accidental exposure to patch. get_public_place (migration 046, widened by
047 and again by 049) is SECURITY DEFINER, callable by anon, and
returns the PublicPlace shape — name, address, cuisine, best dishes,
aggregate rating, lat/lng, and google_place_id — scoped to
status = 'active' so a
still-under-review or blocked place never becomes the first thing a
forwarded link shows a stranger. place.id doubles as the public
identifier rather than a new invite-token system: it's already an
unguessable UUID, and the function's own status filter is what keeps it
from being useful for anything beyond a place already meant to be public.
lat/lng are included deliberately (migration 047,
CHANGES_20260814.md §2) so a signed-out visitor gets the same "View on
Google Maps" link a signed-in one does — every place here is a restaurant
or eatery already publicly discoverable on Google Maps regardless, so the
exact pin doesn't carry the same sensitivity as notes or created_by,
both still excluded, along with the named review list entirely — visits
were only ever shared with "the team," not the public internet, and
nothing about this feature changes that consent. A signed-in visitor who
opens a /p/[id] link is bounced straight to the full /places/[id] page
instead of seeing the cut-down version — same reasoning as /k/[token]
sending an existing Kaki member straight into the group rather than a join
screen they don't need.
Review likes are the one place a second-party write needs the same
SECURITY DEFINER throttle shape as vote-push, for the same reason.
claim_review_like_push_window (migration 048) exists because
visits_update (007_rls.sql) is user_id = auth.uid() — only a review's
own author may write to it — but the person claiming the push window is
whoever just liked the review, not its author. Matches
claim_vote_push_window (038) exactly: an atomic claim-the-window update
rather than a real debounce, since Vercel Hobby's cron is nowhere near
frequent enough to wait out a quiet period. review_likes itself needs no
elevated path at all — a user's own like/unlike is already covered by
plain RLS scoped to user_id = auth.uid(), same as wishlist. Reading
across every user's likes for the weekly recap is the one place this
reaches for the service-role client instead: the cron runs with no user
session, so there's no auth.uid() for RLS to match, the same "no session
to go through RLS with" situation as the discovery cron's writes
(lib/supabase/serviceClient.ts).
google_place_id is excluded from a plain place edit the same way
status is, and for the same reason: a client that could set it directly
could point a place's Maps link at an arbitrary listing. 027's dynamic
column grant (see "Adding a new protected column" in its own comment)
made this a one-line change rather than a new mechanism: migration 049
just re-runs that same derivation with google_place_id added to the
exclusion list, and adds set_google_place_id — a narrow SECURITY DEFINER function, status's exact shape — as the one legitimate way to
set it. Only the app server calls it, right after
resolveAndStoreGooglePlaceId (lib/googlePlaces.ts) runs following a
create or a name/address edit, never from client input directly.
Two tables' RLS policies must never each hold a subquery into the
other — lobangs/lobang_recipients did, and it broke every send.
019_lobang_group_send.sql gave lobangs_select a subquery into
lobang_recipients, and gave lobang_recipients_select/_insert/
_delete each a subquery back into lobangs. Evaluating either table's
RLS-protected subquery requires evaluating the other table's policy, which
requires the first table's policy again — Postgres has to expand that into
the query plan before execution even starts, so it hit a genuine,
unbounded A→B→A cycle and refused with "infinite recursion detected in
policy for relation" (SQLSTATE 42P17), deterministically, on every
sendLobang() call, not intermittently. 050_fix_lobang_rls_recursion.sql
fixes it with the same SECURITY DEFINER escape hatch used everywhere
else in this file for "one table's policy legitimately needs to read
another's" — is_lobang_sender/is_lobang_recipient each do their lookup
as the function owner, bypassing RLS for that one query, so checking "is
this mine" no longer re-triggers the other table's policy. Worth
remembering as a standing rule for any future pair of RLS-protected tables
that reference each other: at most one side may hold a plain subquery into
the other; the other side needs a SECURITY DEFINER function instead.
A public lobang link resolves an unguessable token server-side, never a
name typed into the URL — that distinction is the whole reason it's safe
to combine "public" and "attributed" at all. get_public_lobang
(migration 051) is SECURITY DEFINER, callable by anon, same shape as
get_public_place: lobangs_select (050) is authenticated-only, so a
signed-out visitor has no table privileges on lobangs to begin with, and
from_display_name is resolved from lobangs.public_token — an
unguessable column set only by the app server at send time — never from a
query parameter a visitor's own browser could hand-edit. That's the
concrete difference between this and the spoofing risk raised (and
rejected) earlier in the design: a ?from=Sean¬e=... URL would have let
anyone claim to be anyone; a token resolved through this function can't,
since the token is the only thing the URL carries and everything it
resolves to comes from the row the sender actually created. A public send
is a lobangs row with public_token set and no lobang_recipients rows
at all — there's no specific person, so there's nothing to fan out —
which is also how listLobangsSent/listLobangsReceived (both filter on
public_token is not null, or simply have no recipient row to match
against) keep a public send out of the sender's own history and off
everyone's /lobangs feed, exactly as decided: "will not be saved"
anywhere but its own link.
The signed-out event-invite preview (/e/[token], CHANGES_20260821_combined2.md
§3A) is deliberately more redacted than an authenticated participant ever
sees, not just less than the full event. get_public_event_preview
(migration 066) is SECURITY DEFINER, callable by anon, resolved by
invite_token only (never a raw event id) — lunch_events/event_options/
event_rsvps are all authenticated-only in RLS (007_rls.sql), so a
signed-out visitor has zero table privileges on any of them, same reasoning
as get_public_place/get_public_lobang. It excludes votes, the tally,
invitee identities, and per-person RSVPs regardless of the Jio's own
hide_votes setting (038_vote_push_throttle.sql) — that flag only ever
redacts from authenticated participants while a Jio is open; a stranger
with just the link gets that same floor always, never the full standing
once the host later reveals it to signed-in participants.
The cuisine-combine preview needed its own SECURITY DEFINER function for
a reason distinct from every other one in this file. user_prefs_select
(007_rls.sql) is strictly self-only — "nobody else's business what you
refuse to eat" — which is exactly right for a normal user, but means a
plain client-side count of "how many people like this cuisine" would only
ever see the admin's own row, silently undercounting everyone else's.
count_cuisine_references (052_cuisines.sql) bypasses that the same way
get_push_targets and friends bypass RLS elsewhere, but it's worth being
precise about why this one is still safe to grant broadly (authenticated,
no admin check inside the function itself): it returns aggregate counts
only, never row contents or user identities — there is nothing in its
response a stranger's taste preferences could leak through. The actual
combine (merge_cuisines) is gated at the API route (isAdmin), same
split as mergeUserAccounts/supabaseRepo.ts — the SQL function itself
does the reassignment, not the authorization check.
npm test # 902 tests across 71 files
npm run typecheck
npm run lint
| File | Covers |
|---|---|
recommend.test.ts | Every scoring component, exclusions, ranking, boosts, group mode; rankPlaces's two hard-cutoff filters for the Jio ballot-builder's suggestion list — budgetMax (excludes anything tiered above it, a no-op at the top tier) and excludeVisited (excludes anywhere already logged, rated or not) — and that groupRecommend passes both through per-member, so one member's own visit or budget ceiling rules a place out for the whole group; that groupRecommend's own per-member ranking ignores the caller's limit (a place ranked outside one member's own top-N, but excluded by nobody, still belongs in the group's eligible set) and only the final combined result gets truncated; and that composing it with surprisePicks off a limit-capped ranking loses genuinely eligible places a truly unlimited ranking can still reach |
areaScopedSuggest.test.ts | Area-Scoped Suggest — listPlaces's officeId filter widened to accept an ad-hoc {lat, lng} point: computes haversine directly from that point (even one sharing the same coordinates as a cached office, to rule out an accidental cache hit), never consults walk_cache for it, and leaves office-relative resolution (no officeId, or the office id string) behaviourally unchanged; the static STATIONS dataset is non-trivial, within Singapore's bounds, has no duplicate names, and carries every real serving line for known interchanges (Dhoby Ghaut, City Hall, Promenade, Raffles Place) |
blogImport.test.ts | HTML extraction and the full SSRF matrix |
eventAdditions.test.ts | Who can add, remove, invite, vote and close; setting a place-specific note is restricted to whoever added that option (not the host, unlike removing), rejected once closed, and a blank note collapses to null; joining via an invite link makes a stranger a real invitee, not just visible; a genuine first-timer's listEvents is empty right up until their first join; the vote-push throttle window; the lazy, page-load-triggered starting-soon reminder's (remindDueEvents) timing, one-shot firing, and non-responder targeting, plus its cron-driven counterpart (listAndClaimUpcomingEventReminders) covering the identical scan system-wide and confirming the two share one reminder_sent_at claim so they can't double-send |
eventReminders.test.ts |
npm test does not typecheck. Vitest transforms TypeScript with esbuild,
which strips annotations without checking them, and the suite is all pure
logic and adapter conformance — no component renders, no route handlers. A
green run says nothing about whether the app compiles. npm run typecheck is
the other half of the gate.
*.vercel.app. Point DNS, add the domain in
Vercel, then update the Supabase Site URL and redirect URLs.globals.css are single
light-only values with no dark variant computed for any of them; don't
silently reintroduce this without revisiting that decision first.nameAuth's claim flow),
with no check for whether that matched account already has a verified
email attached — so linking an email today buys portability across
devices, not protection from a stranger claiming your name. This is
already the one caveat README's own name-mode section names as "fixed
only by switching to full email mode." The recommended fix, if this
becomes worth building: once a profiles row has a verified email, flip
the claim flow from "confirm you're this person" (a click) to "prove
it" — challenge the claimed email with the 6-digit OTP attach-email
already sends (OTP as the primary path, magic link only as a
secondary convenience — a tapped magic-link opens Safari, not an
installed iOS Home Screen icon, which is a separate storage partition;
an OTP is typed back into whatever context is already open, so it
can't fragment sessions the way a link click can). No verified email on
the matched account → today's trust-based claim behavior stays
unchanged. Open design question: what happens to the person who typed a
taken, email-verified name — flat deny, or auto-suggest a variant.
Deliberately not built now — the user base is small enough that this
isn't worth the added claim-flow friction yet. Revisit if this comes up
again, or once name-squatting/impersonation is a real incident rather
than a theoretical one.Place data from OpenStreetMap
contributors, ODbL. Walking routes from
OneMap, © Singapore Land Authority. Weather from
data.gov.sg. MRT/LRT station coordinates (src/lib/ stations.ts) from the Singapore Open Data Portal (SODL-1.0), via
elliotwutingfeng/singapore_train_station_coordinates.
Maps rendered with Leaflet.
Jio does not scrape Google, Burpple or HungryGoWhere. Their terms forbid it, and in Singapore the Computer Misuse Act makes it a worse idea than usual. Everything here is open data or something a user typed in.
/events/new?kakiId=initialInvitekaki_wishlist_entriesJioFormHomeHeroinitialPlaceIdsinitialInviteactivepickCommitteeSuggestionsgroupRecommend?tab=tracker/profile/*budgetLabel()$source_visit_idon delete set nulllogged_atexpense_entriesuser_idVisitis_publicavgBudgetTieravgBudgetTiergroupAvgBudgetTierbudget_hunter/splitsexpense_entries/splits/new?eventId=…InvitePickerANTHROPIC_API_KEYsave()type="number"$%/splitsSplitShareCardphone_numberSplitShareCardShareResultCardbuildSplitSummaryTextsrc/lib/splitSummary.ts/u/[token]/places/[id]/lobangs/p/[id]/l/[token]/lobangs/events/new?placeId=lobang_comments096_lobang_comments.sql038_vote_push_throttle.sql/lobangsLobangReactionsLobangThread/lobangs/suggest/api/suggestSuggestFilterControlsoffice_id/api/suggest/api/suggestdefault_office_idgroupRecommend?limit=groupRecommendPOST /api/events/[id]/optionssurprisePickssurprisePick/api/suggestcuisineshasFoodpandahasGrab/api/places<select>ml-auto.tap-target-text{lat, lng}/api/suggestDEFAULT_WALK_MINUTES$$$$$$$/api/suggestbudgetMaxexcludeVisitedlistTriedPlaceIdsuser_prefswalk_cache?exclude=is_publicavg_ratingplaces.rating_updated_at$$$$$$$hasFoodpandahasGrab/api/placesFilterBarFilterBarCuisinePickerChipFilterBarfilterPlacesapplyFilterslistTriedPlaceIdsBrowseList?excludeTried=true/api/places/api/placessortBy=ratingnewly_ratedkaki_ratingfavouriteswishlistfavouriteswishlist.notescheduled_atEventRow.tsxlistTriedPlaceIds/lobangscuisines/admin/cuisinesDeliveryIcons.tsxSocialsIcon.tsx/api/places/api/suggest/api/places/api/suggestPATCH<textarea><input>ArchetypeIconAwardIconcomputeFoodIdentity()just_getting_started/profile/visits?view=places?view=ratings/profile/visits/profile/spending?tab=trackeravgRatingGivenavgBudgetTier/profile/visits?view=spendBudgetTierInsights/profile/spendingcomputeUserMetrics/profile/wishlist/profile/visits/profile/jios/profile/jiosPastJioCard/welcomelocalStorageAddToHomeScreenPromptPOST /api/events/events) previews a series' future weeks even before they've actually generatedEventRowactiveSeriesnamemanifest.json"handle_links": "preferred""scope"HomeScreenNudge/e/[token]/l/[token]InstallPromptProviderplatformstandalonepublic/sw.js/api/cron/event-reminderssendPushToUsersunclaimRemindersnotify_admin_reports/api/reportsgeneral_reportssuggestionReportProblemPanelserviceClient.tsauth.usersmergeUserAccounts@supabase/auth-jsJSON.stringify().message"{}".status.code.messageapp_daily_visitsaction_eventsmerge_user_accountsreferences auth.users(id)app_daily_visitsauth.usersmerge_user_accountsBottomNavAppVisitTrackertabForPathpage_views_by_tabuser_idon delete cascademerge_user_accounts?days=<dialog><dialog>SkeletonJioDetail/SkeletonPlaceDetail
/SkeletonKakiDetail in ui.tsx) proportioned to that specific page's
real major sections, rather than sharing one uniform ~220px block
against pages that actually run 1,500–2,500px; Places' browse/Saved/
Lobangs-tab rows now use a skeleton row sized to the real ~110px
PlaceCard rather than an 80px default; and the standalone Lobangs
feed's skeleton alternates left/right alignment to mirror the real chat
layout instead of six identical left-aligned bars.text-xs/text-sm are
metadata/secondary-only now, text-base (16px) is the new default for
primary reading text — place names and Jio titles in every list row,
and a review's own body text — moved up from text-sm, and a detail
page's own title (Jio, Place, Kaki) moved from text-2xl to text-3xl,
filling the gap between a 24px section header and 36px+ hero text.
Tailwind's own default scale already matched the target shape exactly
(no new tokens needed) — the fix was widening use of steps that
existed but sat underused, not inventing new ones.src/components/brand/motifs.tsx), built from
JioMark.tsx's own real traced path geometry rather than an
approximation: a hollow dashed pebble outline for "No Jios yet" and
"You are not in any groups," the same outline stretched toward a pin
silhouette for "Nothing saved yet," the three real ember pebbles
(no centre dot) for the everyday "Decided" banner — a static echo of
the resolved-celebration animation already locked in #25 — and two
overlapping solid pebbles for the empty Kakis state, extending the
pebble-as-person treatment already scoped to Kaki Members in #24.
EmptyState grew an optional icon prop for this; every other empty
state in the app is unaffected.SaveButton
bookmark used on every card get the same disable-on-tap + optimistic-
update + rollback-on-error pattern RSVP/vote already used, so two taps
landing before the first response returns can no longer race and cancel
each other out. Cancelling a recurring series is one-way, not a true
toggle, so it gets disable-on-tap plus error catch/surface only — there
was nothing to roll back to./admin/cuisinesThe configurable per-Jio "starting soon" reminder (CHANGES_20260821c.md §1) — a genuinely different feature from the row above's non-responder nudge: per-Jio override set/read/clear; listAndClaimDueReminders firing only for confirmed-going (RSVP yes) attendees within their effective lead time (override, else their user_prefs default), one-shot per (event, user), skipping cancelled Jios, already-passed Jios, and anyone with reminders turned off — including someone with no user_prefs row at all, who still gets the column defaults rather than being silently skipped; unclaimReminders reverting a claim so the same (event, user) pair is due again, touching only the exact pairs given (a sibling's already-delivered claim stays claimed), and a no-op for a pair with no reminder-state row at all |
voteDeadlineReminder.test.ts | listAndClaimVoteDeadlineReminders (088_vote_deadline.sql) — a different one-shot shape from the row above: event-level, not per-user, since it's a single courtesy nudge per Jio rather than a configurable per-recipient lead time. Claims within the lookahead window; resolves only pending participants (excludes whoever's already voted or declined); doesn't re-fire on a second sweep; skips a deadline outside the window, already passed, unset, or on a still-polling Flexi Jio; re-fires once a host pushes an already-claimed deadline back out into the future |
autoClose.test.ts | EventDetail.readyToClose (formerly maybeAutoCloseEvent's own auto-close condition, CHANGES_20260821_combined.md Part 2 — now a read-only prompt, not a close, per the bug report below) — false while any participant hasn't responded or answered "maybe", false while any confirmed-yes participant hasn't voted, true once every condition holds (the Jio itself stays open regardless — nothing closes it), true with no winner in view when everyone declines, the host has no auto-confirm exception, false on a still-polling Flexi Jio, an already-closed event, and a cancelled event; a new place added after a yes-voter's ballot keeps it false entirely — bug report item 4 — until everyone's recast past it, and getEvent's own stale-vote flag flips true then clears the same way; a new joiner (via joinEventViaInvite) flips an already-true Jio back to false — the exact "Jio keeps closing whilst new members trying to add their votes in" report this replaced, now impossible since nothing ever closes on this by itself; closeEventsPastVoteDeadline (088_vote_deadline.sql) — closes an event past its deadline even with unresponded participants (confirming the deadline is a real fallback, not just theoretical) or a stale ballot (the deadline still wins regardless of staleness), no-op before the deadline / with no deadline set / on a still-polling Flexi Jio / on an already-closed or cancelled event, null winner when nobody voted by the deadline; closeEventIfPastVoteDeadline (097_lazy_vote_deadline_close.sql) — the same scan, scoped to one event and exercised through getEvent instead of a returned count, covering the same guard conditions plus a plain no-op for an id that doesn't exist |
accountMerge.test.ts | Duplicate-name grouping; merge authorization (self vs. admin vs. neither); row reassignment and collision handling across every owned table; recovery-token generation, resolution, regeneration, and cleanup after a merge; listing every admin |
metrics.test.ts | User and group statistics, cuisine streaks; computeUserMetrics also buckets ratings into a by-star histogram and visits into a by-budget-tier breakdown, the data behind the "Average rating"/"Usual spend" stat tiles' own detail pages; computeKakiMetrics weighs each member's cuisine taste and average spend equally regardless of visit volume (a member with 50 visits doesn't outweigh one with 3), and names a "Trailblazer" — the member with the most places nobody else in the group has been to, null when everyone's only ever been somewhere shared; also returns every member's full ranking for each of the three awards, sorted highest-first, leaving a member out entirely once their value is 0 |
kakiCrown.test.ts | resolveKakiAwardCrown's home-kaki rule (Log 6 Part B): a normal crowning when the winner isn't #1 anywhere else; crowns the winner (with a streak count) when this is their best group among several they lead; passes the crown to the runner-up, with a note naming the real leader and their actual home group, when a different group is their best; null when the crown would pass but there's no runner-up to receive it; the earliest-created-group tie-break when two groups tie on raw value — matches the log's own worked example (Priya/Weekend Foodies/Office Lunch Crew) exactly |
discovery.test.ts | OSM normalisation and deduplication |
budgetTiers.test.ts | The six-entry BUDGET_TIERS table (CHANGES_20260821.md §1) and that budgetLabel clamps to the new $$$$$$ ceiling, not the old four-tier one |
voting.test.ts | Borda count, partial ballots, tie-breaking; isVoteStale — never stale with nothing changed, stale once the vote predates the last options change, fresh once cast after it, a missing timestamp treated as stale rather than given the benefit of the doubt |
weather.test.ts | Rain detection and weather-aware ranking |
clients.test.ts | The Supabase client factories cannot escalate privilege |
auth.test.ts | Every auth adapter answers every method, and refuses cleanly |
repoConformance.test.ts | Both repos implement the same interface |
rating.test.ts | Bayesian smoothing over the trigger-maintained columns |
visitEdit.test.ts | Editing and deleting your own visits: ownership, and which fields an edit may touch |
moderation.test.ts | Block, unblock, and the admin allowlist |
placeFlags.test.ts | Flagging a place and resolving the queue |
generalReports.test.ts | "Report a problem" / "Give feedback"'s shared general_reports pipeline — lands pending, surfaces in the queue oldest-first, an admin resolves one report without touching others, only an admin can resolve, and the suggestion category (Home's FeedbackCard) is accepted and queued the same as any other |
adminReportNotifications.test.ts | listAdminReportRecipients() — the read that decides who gets the "someone filed a report/suggestion" push: includes an admin by default, excludes one who has muted it via setNotifyAdminReports, includes them again once turned back on |
lobang.test.ts | Targeted sends, group-send member snapshotting; public sends — no recipient rows created, excluded from the sender's own Sent list, resolve through getPublicLobang with the real sender name and note (including foodpanda_url/grab_url, same as socials_url), null for an unknown token or once the place is no longer active; sendLobang's resolved recipient_ids (teammates, every Kaki member but the sender, empty for a public send) that the API route pushes to directly; heart toggles on/off and refuses a non-recipient; the shared comment thread — a posted comment is visible via both sides' own lists, the sender can post back into it too (not one-way), a group send's thread is shared by every recipient and the sender, refuses an empty comment, and refuses reading or posting for anyone who isn't the sender or a recipient |
flexiJio.test.ts | Availability voting and host confirmation |
suggestCommittee.test.ts | The three-pick suggestion and re-roll rules |
onboarding.test.ts | The one-time welcome gate |
placeEditing.test.ts | Any signed-in user can edit a place; status cannot move through a plain edit; socials_url/foodpanda_url/grab_url round-trip through create/update same as every other optional field, and clear back to null |
placelessVoteOptions.test.ts | A free-text vote option with no place record: tallying, winning, and attaching a real place afterward without stranding existing votes; attach authorization now covers any host/kaki-member/invitee, not just the option's original adder |
cancelEvent.test.ts | Host-only cancellation, only from open, stays findable afterward |
rescheduleAndEditWinner.test.ts | Host-only date/time correction at any status short of cancelled, finalizing a still-polling Flexi Jio's date the same way confirming a candidate does; host-only rename, available at any status including cancelled, trimmed, rejecting an empty title; host-only winner-place correction, closed-only, accepting a place that was never a voting option, stamping winner_corrected_at; host-only reopen-for-voting, closed-only but no longer gated on the Jio's scheduled time still being ahead (098_reopen_any_time.sql), clearing the winner (and a prior correction's stamp) while leaving existing ballots untouched |
eventInvitees.test.ts | Host-only add/remove, both work regardless of status (open, closed, cancelled), removing drops the person's RSVP/ballot but leaves what they added, host can't be removed |
userDiscovery.test.ts | Server-side name filtering and office scoping for the teammate pickers; co-attendance ranking — a shared-event participant outranks a Kaki-only co-member, tier 1 ordered by score, tier 2 by Kaki name, a tier-3 stranger hidden by default but reachable by search, and includeIds force-including an already-picked person regardless of tier |
recurringSeries.test.ts | Recurring-series date math (lookahead window, no double-generation), fixed vs. voted occurrences, fresh kaki-membership expansion per occurrence, each generated occurrence inheriting the series' own vote-deadline offset and notes, a deadline or notes change propagating onto an already-generated open occurrence even after someone's voted (unlike place/mode/invitees), and generateDueOccurrences returning each occurrence's eventId/title/voteEndAt/inviteeIds — not just a count — so its caller can actually push the invite |
sortPlacesForList.test.ts | /places's nearest-first default vs. the highest-rated sort, unrated places sinking rather than sorting first, tie-breaking |
mapClusters.test.ts | Map marker clustering: a lone place stays its own group, exact-coordinate and within-grid places group together, different buildings stay apart; the "Highly rated"/"New" badge thresholds (rating + minimum visits, listing age) |
filterPlaces.test.ts | The client-side counterpart to demoRepo's server-side applyFilters, run against Want to try/Tried/Favourites: cuisine (any-match), budget (no-op at the top tier), search (name/address/dish/cuisine, case-insensitive), Foodpanda/Grab presence; the walk-time cap specifically only narrows once applyMaxWalk is passed (mirroring the shared FilterBar's own "touched" flag), so a saved place across town doesn't vanish the instant the bar starts applying to a tab that was never walk-filtered before |
wishlistFavouritesTried.test.ts | Places' Want to try / Tried / Favourites restructure: a wishlist reminder note round-trips and can be cleared back to null; a note is refused for a place never saved; Favourites toggles independently of the wishlist (a place can be both, or neither, or either alone), hydrates its joined place record; listTriedPlaceIds/listTried include a logged visit, a closed Jio the user hosted, and one they attended as a guest (RSVP'd yes), while excluding a Jio they were only invited to (RSVP'd maybe/no), one that never closed, or one that's closed but decided for a still-future date, de-duplicating a place tried both ways, and returning nothing for an account with no visits or attended Jios |
walkTimeNotice.test.ts | walkTimeVisibilityNotice's three states: nothing for a place within the default filter or an unknown walk time, "widen the filter" between the default and the slider's ceiling (inclusive), and "no filter would help" past the ceiling |
hiddenVotes.test.ts | A hidden-vote Jio's standing is blind only while open, reveals on close, and voter count is distinct voters not ballot rows |
kakiRating.test.ts | "Rated by your Kaki group" averages only the member set's ratings, scoped independently per place; the companion visit-count used for the badge/filter's minimum-2 threshold |
adminAnalytics.test.ts | Asia/Singapore day/week/month bucketing across the UTC boundary, median with no-data returning null not 0, walk-time bucket edges, distinct-user (not per-event) DAU/WAU/MAU bucketing |
adminAnalyticsFunnel.test.ts | Real step-funnel population logic (invited/responded/voted/attended/reviewed) scoped to decided Jios only, a still-open or no-winner Jio excluded entirely, "reviewed" requiring the visit to land at/after closed_at, signup-week cohort aggregation |
adminPlaceDetail.test.ts | Place drill-down: visitors ranked by count, rating trend bucketed by SGT week, wishlist/lobang counts, cuisine/budget alignment against actual visitors' prefs, null (not 0) alignment when no visitor has any prefs recorded |
adminUsersView.test.ts | Composite score responds to weight changes, segment membership rules (power hosts, active voters, dormant), per-person drill-down (hostedCount/lobangs/lastActiveAt), lifetime RSVP responsiveness across every Jio a person was ever invited to |
adminAnalyticsSegmentFilter.test.ts | Segment filter restricts jioOutcomes/funnelSteps to Jios hosted by that segment's members, an unmatched segment yields an empty population, growth.newUsersDetail lists real signups per day and is unaffected by the segment filter |
csv.test.ts | CSV field quoting — commas, embedded quotes (doubled), newlines — and the plain unquoted case |
kakiMembers.test.ts | Adding an existing user to a Kaki directly: any current member can, a non-member can't, no duplicate membership, rejects a nonexistent group |
kakiWishlist.test.ts | The shared Kaki wishlist: a member can add (hydrated with the place record and adder's name) and any current member can remove an entry, not just whoever added it; a non-member can neither add nor remove; refuses a duplicate add of the same place; scoped per-Kaki (one group's list never leaks into another's); an entry disappears once its place is blocked without deleting the underlying row (confirmed: filter, don't prune) |
expenseTracking.test.ts | The personal expense ledger: logs an entry defaulting logged_at to today; lists only the calling user's entries for a given month, newest first; paginates 20/page with no overlap between pages; amends an entry in place (confirmed: edit, not delete-only); accepts all six categories (breakfast/lunch/dinner/coffee/snack/other, migration 095); links a place match found while editing and can clear it back off; refuses editing or deleting someone else's entry; an entry survives its source visit being deleted, just unlinked (confirmed: on delete set null, not cascade); hydrates place_name once an entry is created with a matched place; getExpenseMonthSummary totals by category, computes a delta against the previous month (null with nothing to compare), and writes a plain-language comparison note against the account's own budget preference; centsToBudgetTier's reverse-mapping of the budget-tier dollar bands |
splits.test.ts | Bill-splitting, standalone-first (099_bill_splits.sql): a split creates fine with no Jio at all; linking one is host-only and closed-only, and a second linked create replaces the Jio's existing split rather than stacking a history (including the same-millisecond tie case), while a standalone creator can start as many as they like; splits an item evenly across its assignees — registered or a name-only guest, mixed together — charging an item only to whoever's actually assigned; GST/service charge split proportional to each person's own item subtotal (not evenly — a pricier order carries more of it), and left out of the split entirely when toggled off; an item with nobody assigned charges nobody, not the payer; the payer defaults to the creator but is overridable, and is always included as a participant even with nothing assigned to them; updateSplit re-saves a fresh item/participant list, preserving a kept participant's phone number/paid status by id, and actually removes anyone dropped from the roster; deleteSplit/setSplitParticipantPhone/setSplitPaid are writer-only (creator, or the linked Jio's host), the phone/paid setters refusing an unknown participant id; getSplit is visible to its creator, a registered participant, or (when linked) anyone with access to that Jio, and null for a stranger either way; listSplits reports both what you created and what you're merely in; uploadReceiptImage returns null in demo mode; discounts (101_bill_split_discounts.sql): an item's own "item"-scope discount (dollar or percent) only reduces what its own assignees owe; a "shared"-scope discount instead pools and splits across everyone weighted by their own order amount, even someone who never ordered that item at all; the whole-bill % discount reduces everyone's share proportionally, computed before GST/service charge are applied on top; an unassigned item's discount never pools, whether shared or not; and a guest's math matches a registered participant's exactly when item and whole-bill discounts combine |
receiptOcr.test.ts | extractReceipt's Claude vision call, fetch mocked since there's no real ANTHROPIC_API_KEY in CI: degrades to ok: false without throwing when unconfigured, on a non-ok API response, when the response has no tool_use block, and when fetch itself throws — a bad scan must never fail the upload; sends a forced tool_choice call with the image in the expected shape; parses a well-formed extraction; drops malformed items (no label, missing amount) rather than failing the whole response, clamping a negative amount to 0 instead of dropping it; honors ANTHROPIC_MODEL when set, else the cheap default |
splitSummary.test.ts | buildSplitSummaryText lists every non-payer's owed amount under the split's title (never the payer's own, since they're not owed anything), marks a settled one "(paid)", and closes with how to pay the payer — their own number when set, just their name otherwise — the same wording SplitShareCard's canvas card and "Copy summary" clipboard button both read from |
publicPlace.test.ts | The public place-preview data: only the safe field subset, never notes/created_by, carries lat/lng, hides needs_review and blocked places, computed rating and visit count match the authenticated view; carries foodpanda_url/grab_url same as socials_url, null when neither is set |
publicEventPreview.test.ts | The public event-invite preview: safe subset for a real event, goingCount only counts confirmed "yes" RSVPs (not maybe/no), never exposes votes/tally/invitees/RSVPs, redacted the same regardless of a Jio's own hide_votes setting, null for an unknown token; winnerPlaceName (UX review log #25) stays null while open and surfaces the real decided place once closed |
firstHostInvite.test.ts | Picking the source Jio for a first-ever host's pre-checked invite list: null once the account has hosted anything at all (or has no history), the most recently scheduled Jio joined as a guest otherwise; building the invite from it: source host + invitees, self excluded even if present, deduped, never a Kaki group, null when nobody would end up pre-checked |
decidedCelebration.test.ts | The decided-Jio celebration's qualifying condition (UX review log #25, generalised from the original one-time-ever version) — closed + still upcoming + any RSVP answer + a cast vote, never once already seen for that event, unaffected by which RSVP answer it was, and not once its lunch has already passed — hasSeenDecidedCelebration/markDecidedCelebrationShown's per-(user, event) idempotent stamp, scoped so seeing one Jio's celebration doesn't mark another, and an end-to-end check wired through a real closeEvent call, including that a non-voter's RSVP alone doesn't qualify |
foodIdentity.test.ts | computeFoodIdentity's priority-ordered archetype rules (Loyalist/Explorer/Regular/Enthusiast/Connoisseur/Budget Hunter, the Well-Rounded Eater catch-all, "Just getting started" below the visit floor), including that a dominant cuisine outranks a high rating when both technically qualify; the optional preference-alignment note it appends afterward — a real chunk of visits to a disliked cuisine, sticking to a liked one, no note at all when no preferences are set or when neither clearly leans either way, in every case without changing which archetype was picked; getArchetypeRuleTrace's "why" panel rows — stops at and marks the rule that matched, lists every unmatched rule before it in order, falls through to the last-checked rule unmatched for the well-rounded catch-all; computeKakiFoodIdentity's group vibe headline and award passthrough; previousMonthKey's month rollback, including across a year boundary |
foodIdentitySnapshots.test.ts | listAllUserIds/listAllKakiIds cover every seeded account/Kaki; listAllUserPrefsForCron returns every account's cuisine likes/dislikes; save*FoodIdentitySnapshot upserts per (id, month) rather than duplicating, keeps months scoped to their own account/Kaki, and lists most-recent-first |
kakiVibeNarration.test.ts | The Kaki vibe card's new narrated sentence (UX review log #24) — combines the top cuisine's share with the top favourite's visit count/rating, falls back gracefully when only one of the two is available, null when there's nothing to narrate at all |
cuisineIconGrouping.test.ts | The cuisine-plate icon mapping (UX review log #20's grouping table) — every seeded cuisine tag resolves to a defined icon, the noodle-bowl/wok/bakery/kopi groupings match exactly, Vegetarian gets its own mushroom icon, Halal gets the certification shield rather than a food picture |
reviewLikes.test.ts | Toggling a like on/off, independent counts across multiple likers, liked_by_me populated only for a known viewer, the like-push throttle window (claims once, refuses within the window, claims again after), and listReviewLikesSince's cutoff filtering |
googlePlaces.test.ts | The Google Maps link confidence gate (nameSimilarity — exact/reordered/partial/unrelated/punctuation/empty-input matches) and the link builder (googleMapsPlaceUrl — real listing when a place id is present, coordinate fallback otherwise) |
lobangFeed.test.ts | mergeLobangFeed's tag-and-sort: direction tagging, newest-first ordering across both lists, either list empty, and that it doesn't mutate its inputs |
cuisinePreference.test.ts | cycleCuisinePreference's neutral → like → dislike → neutral transition table, a full cycle returning to the start, other cuisines left untouched, no input mutation |
utils.test.ts | formatTime/formatDate render in Singapore time regardless of the runtime's own timezone; relativeDayLabel agrees with the Singapore calendar rather than UTC's right at the UTC day boundary; socialsHost/socialsLabel (CHANGES_20260821b.md §1) sniffing Instagram/Facebook/other from a socials_url, falling back cleanly on an unparseable URL; instagramSearchUrl's keyword-search link; grabFoodSearchUrl's GrabFood search-results link |
calendar.test.ts | .ics/Google Calendar link generation converts straight to UTC regardless of runtime timezone, RFC 5545 text escaping, canAddToCalendar's gate (any status but cancelled, as long as the date isn't still a Flexi poll) |
cuisines.test.ts | Normalizing a typed label into its slug, idempotent on an exact slug collision, preview counts across places and taste preferences, admin-only and no-self-merge guards on combine, and a combine actually reaching and deduping every reference before retiring the merged-away slug |
personalInvite.test.ts | Self or admin can generate a personal invite link, a non-admin can't generate one for someone else, an unknown token resolves to null, and regenerating retires the previous token |