Changelog
Target changes, shipped changes, and what is currently broken.
Known issues are listed alongside features on purpose. A changelog that only carries good news is marketing.
2026-09-18
Staff page handles bound scanner grants Fixed
A scanner-access timestamp caused the Staff API response to fail validation and made the page unavailable. Timestamp serialisation is corrected and the live invitation form and roster were verified after the Singapore API deployment.
Event and Work improvements prepared for release Changed
The release candidate separates Joined/My Events from Work, reuses Explore event cards and adds crew invitations with explicit Accept/Reject. API 2.2.0 adds an email-verified invitation inbox; pending invitations grant no scanner access.
The same candidate fixes interests editing, calendar overflow, notification prompt presentation, duplicate country entry and low-resolution ticket email QR images. Organisers edit venue details and individual calendar/time ranges; visitors choose navigation apps in a Directions sheet. Actions use consistent feedback. The original simple category icons are retained. Zones & Staff combines crew invitations, zone assignments and checkpoint QR codes. Accepted Work events open assignment details and scanner entry. Tickets lead directly to the selected pass’s zone access and form builder.
Xperience production moves to xperience.my Changed
The app and AI Horizon event are live on the new domain with Singapore application and database hosting. Existing records were transferred; users must sign in again. The source is retained read-only for recovery. Google sign-in is enabled and verified; payment processing remains disabled.
Public event pages preserve daily schedules Fixed
Public event queries now include the saved daily schedule. Two-day events show their separate sessions instead of falling back to one continuous date range.
Sign-in returns to the public app Fixed
Google sign-in previously returned to an internal server hostname after successful authentication. It now returns to the configured public app URL. Old hosted links redirect to the new app while keeping their path and query.
Emails follow the platform design Changed
Sign-in codes, invitations, magic links and ticket emails now share Xperience's warm background, rose buttons, typography and spacing. The live sign-in email was delivered according to Resend after correcting the sender key domain. Recipient confirmation is pending. Resend uses Tokyo.
Paid passes are organiser records for launch Changed
Public registration offers free passes only. Organisers can retain recorded values for passes handled outside Xperience; those passes cannot be made public. Gateway processing is disabled in the new API configuration. Historical payment and refund records remain intact. The live frontend cutover is still pending.
Vendor commerce routes retire in the 2.0.0 candidate Changed
The undeployed candidate removes merchant storefronts, stall operations, customer stall orders, vendor analytics and related navigation. Historical financial records remain protected; payment processing is disabled for launch. MATRADE exhibitor reporting continues on its legacy backing data; this does not restore operational vendor access.
API 1.4.0 candidate documents pass forms and event operations Added
The local deployment candidate documents published pass questions, organiser event discovery, optional event schedules, exhibition checkpoints and zone assignment replacement, aggregate form analytics, and account push-subscription controls. Push requires all three VAPID settings and device permission; alerts are generic and the account inbox remains durable. No external provider delivery has been verified and these changes are not yet live.
Exhibition registration supports unlimited places Added
The deployment candidate supports unlimited event and ticket-type quantities, while preserving finite conference and workshop limits. AI Horizon's event page includes official event branding, Penang venue details and links to its separate conference and booth registration. These changes are not yet live.
Tablet navigation stays within reach Changed
The candidate uses bottom navigation on phones and tablets below 1024px, with the desktop sidebar at larger widths. Search and the More menu remain available with touch-sized controls.
Exhibition teams can work by assigned location Added
The deployment candidate adds crew password setup after email verification, department invitations, assigned entrance and breakout scanners, explicit VIP admissions, and organiser scan reporting. These changes are undergoing release verification and are not yet live.
Visitor tickets gain email recovery and source tracking Added
Brochure and entrance registrations retain their source and visitor information for reporting. Ticket emails use a retry queue and account-owned resend action. The proposed reporting fields do not establish MATRADE endorsement or approval.
Visitor email codes use valid database identifiers Fixed
An authentication adapter mismatch could reject a verification record before an email-code login completed. The candidate now consistently generates UUIDs; local database tests cover email OTP and crew password login.
2026-09-13
Each job has its own migration-preview surface Added
The undeployed target now separates the guest web app, organiser Studio, stall console, platform back office, public marketing pages, and documentation. Guides and API reference also have separate search scopes. Moving between those surfaces keeps the current account, organisation and stall authority checks; being able to see a link is not permission to use the destination.
This is a target architecture, not a production switch. The Expo workspace is still the unchanged starter: it does not implement native sign-in, native tickets or secure device session storage.
Ticket, stall and offline contracts survive the split Changed
The migration keeps Xperience's own ticket and stall commerce: capacity and
booking holds, purchased line snapshots, payment callbacks, fulfilment, refunds,
ledger entries, loyalty and reporting are not replaced by a second commerce
system. Device-saved tickets, attendee hubs and scanner queues keep the existing
event-os browser database and signed ticket formats.
Local synthetic checks completed a paid ticket and a stall order, rejected cross-account and cross-stall access, and moved a paid stall order through preparing, ready and collected. They also covered account-export isolation and immediate membership and session revocation. None of that is evidence of a provider journey, production data transfer or deployment.
Checkout returns to the site that started it Fixed
The split-host migration could send a browser back through the API host after ticket or stall checkout. Return addresses are now built from the trusted web origin that started checkout, while signed server callbacks still go to the API. The ticket and stall return journeys were observed locally; no live payment provider or deployed hostname was exercised.
Offline scans survive a deliberate sign-out Fixed
Signing out now clears personal tickets and event data without silently losing unsynchronised door scans. If scans remain, the app explains what is being kept and asks for a second confirmation. The queued scan, grant and manifest stay available for later sync; after the queue drains, normal sign-out clears them. Local browser verification queued a scan offline, reloaded without duplicating it, signed out, signed in again and synchronised it exactly once.
Account deletion also clears personal device data without discarding queued scans. The local guest journey removed both active sessions and identifying attendee details while retaining the anonymised registration and collected sale. Revoking a scanner grant blocks later online reads and sync, but cannot retract names already saved on an offline phone.
Same-origin scanners keep their existing transport Fixed
A browser's ordinary same-origin scanner request no longer fails merely because
it omits an Origin header. The signed, live scanner grant remains the authority;
forged or revoked grants and explicitly untrusted origins are still refused.
Legacy scan submission again returns its documented 200, rather than Nest's
default 201.
Search, selections and checkout controls stay reachable Fixed
Country, state and event choices are searchable with the keyboard. Booking requires a country; clearing an exhibitor country saves an unknown value rather than an invented country. Documentation search keeps guides and API results separate and returns focus to its trigger after Escape. An invalid acquisition email stays in the form without submitting a lead.
The desktop stall-checkout footer no longer covers item quantity controls. Phones retain the sticky checkout action. Audit columns have room to stay distinct and their horizontal scroller is keyboard reachable. Sign-out, account-erasure and install-prompt controls have full-size touch targets.
Private console links no longer race into a server error Fixed
Signed-out visitors are sent to sign-in, and accounts without the required
authority see a denial or missing-record page rather than an intermittent
500. This closes a race between a console layout and its page rendering.
The API still checks live membership, independent platform authority and
record ownership; the navigation change does not grant access.
Updates wait for you, and server errors remain observable Fixed
All five Next surfaces compare a real build identity and offer an explicit Refresh button. Dismissing the notice keeps the current document and unsaved work; no update forces a reload.
The API and all five Next servers retain structured error reporting and the optional, throttled alert webhook. Five deliberate errors in each exercised runtime produced five records and one configured delivery; with the webhook unset, the failures remained visible and sent nothing. Local Next development also declares the SDK dependencies its error-reporting integration injects.
In the undeployed target, native API connection errors could include complete upstream query URLs in Next's server logs, potentially including signed values. The transport now preserves the failure without forwarding that URL-bearing error object. Client-notification credentials are confined to approved configuration and send steps, not dependency installation.
The extracted API contract is documented independently Changed
The target API contract is now 1.1.0, covering the extracted domain operations and their runtime schemas. Its generated snapshot and reachable reference pages remain separate from Changesets application versions.
Cutover and release still have hard gates Known issue
Browser journeys, CLI doctor/scaffolding and HTTP/stdio MCP have been exercised locally. The private backup was restored and transferred only between isolated loopback databases, with a separate recovery restore and no API attached to the restored source. These checks are not a production rollout.
Real Resend and Google journeys, authorised DNS and host changes, a fresh frozen-source production data and storage transfer, callback retention, rollback reconciliation, and deployment verification all remain. Railway project and database ownership must also be resolved before cutover.
The target still has known race conditions around the cap on a free giveaway, sibling workshop overlap, and promotion-code use counts. The cover-image probe also follows redirects and DNS without the complete outbound request controls needed to close its server-side request-forgery risk.
Changesets metadata is pending only; no package was versioned or published.
Release automation uses GitHub's automatic GITHUB_TOKEN, never a separately
managed personal access token. The organisation's Actions-created pull-request
policy has returned 409 and remains unresolved, so configuration alone cannot
open a release pull request.
2026-09-12
Migration preview: keep your account, sign in again Changed
The Railway migration branch replaces Supabase sign-in with email codes, magic links and Google through Better Auth. User IDs, bookings, tickets and orders are preserved by the transfer. Existing sessions and password hashes are not: everyone must sign in again after cutover. Passwordless sign-in was explicitly chosen for the migration; no password sign-in screen is being introduced.
Not switched in production yet. The source remains on Supabase. A read-only production preflight and a transactional local transfer rehearsal have passed, including a deliberately failed copy that left the target empty. Real email delivery, Google credentials and redirect registration, the five new domains, and the final frozen-source transfer remain cutover gates.
Separate guides and API reference Added
The migration preview separates event-running guides and the changelog from
the API reference, including separate search results. The intended hosts are
xperience-docs.cenvora.dev and xperience-developer.cenvora.dev; the guest,
platform and organiser surfaces have their own hosts too. These hostnames
are not a claim that production has moved.
The documented API is now 1.0.0. Protected endpoints use the new server-side session, not a Supabase session. Existing callers must sign in again; old Supabase tokens will not authenticate them. Changesets-managed package versions are separate from this API contract version.
Release automation uses GitHub's automatically supplied token, with no custom token or repository secret to configure. It cannot yet open release pull requests because the organisation blocks Actions from creating them. That permission still needs approval; no organisation-wide policy was changed.
Stall photos and order updates on the new backend Changed
In the migration preview, stall photos upload directly to the application's Vercel Blob store. An upload is restricted to a live menu item in the signed-in stall; a different stall's path is refused.
Order screens refresh while open without needing Supabase Realtime. Local verification followed an order from payment through preparation, ready for pickup and collection without reloading the customer's screen.
The desktop My stall link now opens the management console rather than
the public stall page. Booking, retry-payment and share links use the full
/events/ path.
Your data export is now named after Xperience Changed
"Download everything we hold" saves as xperience-my-data.json instead of
event-os-my-data.json, the working name the product had before it was
Xperience. Contents unchanged.
Anyone who knew your mobile number could take your tickets Fixed
Booking asks for a mobile number and never checks it. Because the same number at the same organiser was treated as the same person, a signed-in user who typed somebody else's number when booking took over that person's record: their tickets moved to the new account, their name was overwritten, and their own Tickets page went empty. Reproduced in the 12 September audit; no report from a guest, and nothing logged that would have shown it happening.
A number that already belongs to another account is now refused with a message saying so. Your own bookings with your own number are unaffected.
"Delete my account" now deletes the person, not just the login Fixed
Erasing your account removed the sign-in and the details organisers held, and told you it was done. Your profile — name, mobile, birthday, gender — plus points, interests, team memberships and any stall you had claimed stayed in the database, keyed to an account that no longer existed. That was not the withdrawal of consent the page promised.
All of it now goes in the same step. Orders and registrations remain as the organiser's record of a sale, with nothing left on them that identifies you, including any note you typed at checkout.
An organiser's event tagline could run code in visitors' browsers Fixed
The structured data that helps search engines list events was written into the page without escaping. A tagline, venue or organiser name containing a closing script tag ended that block early and the rest ran as script for every visitor of a public event page. Imported listings carried the same risk from text on other people's websites. Now escaped; search engines see identical data.
Signing out on a shared phone no longer leaves your ticket behind Fixed
Tickets are cached on the device so they open without signal. Sign-out cleared the events hub but not that cache, so the next person to open Tickets on the same phone saw the previous holder's name and a working QR code. The cache is now cleared with everything else. A ticket bought without signing in on that phone is cleared too — it still opens from its link.
A crafted image upload could have run code on the server Fixed
The framework this app runs on published a critical advisory (GHSA-2xp9-vwfh-vxw4): a specially built AVIF image passed through the image optimiser could execute code on the server, with no login required. Stall photos and listing covers are served through that optimiser, so the path was reachable here — narrowed to a signed-in stallholder for uploads, which is a smaller door, not a closed one.
Upgraded past the patched release. No evidence anyone used it; there was also no logging that would have shown it. Dependency advisories for the app dropped from 29 to 7, none critical, and the remaining ones are in build tooling that never runs in production.
A private event was readable by anyone who had its address Fixed
Marking an event private kept it out of browse, out of search and out of the sitemap — but the event's own page did not check. Anyone with the address could open it, see the venue, date, ticket prices and the full stall list, and reach the booking page. That included one live event. The address is not a secret: it is in the browser bar of everyone legitimately invited, and it gets pasted.
A private event now answers only to its invitation code. Enter the code once and every page for that event works normally for the next ninety days on that device; without it, the event is not there. If you were already invited, enter your code again once — a link you saved before today will no longer open on its own.
Guests holding a ticket are unaffected: your event opens from Your events as before, which has always checked your ticket rather than your address.
2026-09-08
Payment retries recover purchases safely Fixed
A captured payment could be recorded as paid before its ticket or order was completed, leaving retries unable to recover it. Purchase confirmation, signed ticket creation and accounting now commit together. Concurrent captures choose one successful payment; extra captures are recorded for refund. Interrupted or uncertain refunds require provider reconciliation before another attempt. Historical paid transactions also require reconciliation during rollout.
Team removal revokes old stall invitations Fixed
A removed stall teammate could previously rejoin using an old signed link. Removal, replacement links and ownership transfer now revoke earlier links. Existing stall team invitation links must be replaced. Organiser invitations now support resend and rehire, and acceptance cannot consume an invitation without successfully granting membership.
Sign-in preserves your saved profile and choices Fixed
Returning guests skip completed onboarding; editing loads their saved details. Profile and preference changes save together, and withdrawing consent clears preferences and stops location-based recommendations. Sign-in return links are restricted to this site. The sign-in dialog supports keyboard focus and recovery from failed requests.
MATRADE report drafts include attendance, sales evidence and exports Changed
Organiser Studio’s Annex 2 draft counts checked-in visitors, excluding no-shows. Incomplete floor measurements remain unknown instead of producing misleading percentages. Exhibitor country edits apply only to the current event. Organisers can record exhibitor-reported sales and their evidence, and download Annex 2, foreign buyer and exhibitor CSV tables. External audit certification and MATRADE submission are still required; the application does not certify an event.
Failed error-alert deliveries leave a visible record Fixed
A webhook rejecting an alert now leaves an alert-failed log entry. Previously
only network failures were recorded this way, hiding HTTP rejection failures.
2026-08-25
Server errors can now reach a person Added
When something failed on the server, the only record was a line in a log file that somebody had to think to go and open. Nothing told anyone. For an event with a door scanner running on venue wifi, the gap between "check-in started failing" and "somebody noticed" was however long until a person looked.
Errors are now relayed to a chat channel as well as the log. Repeats of the same error are collapsed to one message every five minutes, so a route that fails on every request produces one notification rather than thousands — an alert that floods is an alert that gets muted, which is worse than none.
The log line is unchanged and still written whether or not the relaying is configured or working.
The door code screen was quietly calling the server forever Fixed
The check-in code display refreshes itself every thirty seconds, and it was leaving those refresh loops running after you left the page. Each one kept asking the server for a new code — permanently, until the browser tab was closed — and every visit that you navigated away from at the wrong moment started another.
Backgrounding the tab did the same thing. The screen is meant to be propped against a till and ignored all day, and coming back to it started a second loop alongside the first rather than replacing it. Five trips away and back left five loops running side by side.
Nothing looked wrong. The code on screen was correct the whole time, which is why this went unnoticed — the cost was on our servers, not on the display. It now runs exactly one refresh loop, and stops it when you leave the page.
2026-08-24
Two live scanner endpoints were documented nowhere Fixed
GET /api/scan — asking which tickets a door already has in — and
DELETE /api/scan — taking a mis-scan back — have both been live and working,
and neither appeared in the API reference or in openapi.json. If you built a
door integration from the reference, you did not know they existed. They are
documented now, with their status codes, and /api/scan is one page covering
all three verbs rather than a page about POST alone.
The reference also used to say POST was "the only write path the door scanner
uses". DELETE writes too, and has since it shipped.
The reference can no longer drift without failing a test Added
Every gap above was found by hand, which is why there were three of them. The
test suite now reads the route tree and compares it against both the spec and
the reference, and fails on drift in either direction: a live endpoint nobody
documented, a documented endpoint that is not live, a reference page fumadocs
cannot reach, or a committed openapi.json that no longer matches the spec it
was generated from.
Nothing about this is visible if you are using the API. It is here because the reference being trustworthy is the only reason to read it, and until now nothing enforced that.
API version is 0.4.0.
A bad request to the scan endpoint now says so Fixed
Sending anything that was not valid JSON to /api/scan came back as a 500.
That is the code for we broke, and it was pointed at the caller — a door
integrator reading it correctly would have gone looking for a fault on our
side that was never there. It now returns 400 with {"error": "Malformed JSON"}, checked before the scanner token is read, so the response confirms
nothing about whether that token would have been accepted.
The status code was the smaller half. Every one of those 500s also wrote a
[server-error] line to the logs, and that stream is the only way anyone here
finds out something is wrong. The endpoint is unauthenticated up to that first
line, so anyone who could reach the door could fill it with noise and bury a
real sync failure underneath. That is closed.
The scan reference was missing two status codes it already returned Fixed
413 — more than 500 scans in one batch — has been live the whole time and
appeared in neither the API reference nor openapi.json. It is documented now,
alongside the new 400. If you built against that reference and handled only
200, 403 and 404, this is the gap.
API version is 0.3.0. Nothing that worked before behaves differently: valid
requests are untouched, and the two added codes replace an undocumented 500
and an undocumented 413 rather than changing any documented response.
2026-08-23
More than one person can run a stall Added
Whoever redeemed the organiser's invitation was the only account that could ever open the stall. A booth is worked in shifts, so the workaround was sharing one login — which is exactly what makes an audit trail worthless: every order, every scan and every change looked like the same person.
Stall → Team. The owner creates an invitation link and sends it to a colleague, who can then open the same stall alongside them. Links last two weeks and can be handed out as often as needed.
A team link only adds somebody. It cannot hand the stall over, and the organiser's original handover link cannot be redeemed as a team invitation either — the two are signed differently, so each refuses the other exactly as it would refuse a forgery. Only the owner can invite or remove.
Working two stalls no longer opens a random one Fixed
The stall console read a single row with no ordering, so anybody running two stalls got whichever one the database happened to return. Same defect the studio had, in the other console. It now picks the same one every time.
Whoever invites the crew can now hand them a scanner Fixed
Staff could invite door crew but not give any of them a scanner link — that still needed an owner or admin. So an organiser running your floor could assemble a door team and then get stuck, which is no use to anybody and made yesterday's change pointless.
Inviting somebody as crew and handing that person a scanner are one act split in two, and they are now gated together. An organiser can staff a door end to end without ever being able to publish or delete your event.
The API now has a version that means something Fixed
openapi.json reported its version as a truncated deployment id, so it changed
on every deploy whether or not a single endpoint had. Anyone watching for
changes to the contract saw one several times a day and none of them were real.
The spec now carries a proper semantic version — 0.2.0, and 0.x because
the public organiser API is not published yet and may still change shape. A new
endpoint moves the minor; anything an existing caller would break on moves the
major.
The deployment id has not gone anywhere. It is still at GET /api/version,
which is what an open tab polls to notice a new build.
See your event before anybody else does Added
An event that was not published yet returned a 404 to everybody, including the person building it. The only way to check how the page looked was to make it live — so the first person ever to see it was a guest.
Open your own unpublished event and you now get the real page with a Preview banner and a link back into Studio. Only people in your organisation can do this; a stranger holding the address still gets nothing.
Previews are not counted in your page views, and an unpublished event is never described to search engines.
Belonging to two organisations no longer picks one at random Fixed
The console read a single membership row with no ordering. Anyone who belonged to more than one organisation got whichever row the database happened to return that request — so the studio could show one client on Monday and another on Tuesday, with no way to choose and nothing to explain it.
Which organisation you are in is now remembered per browser, and there is a switcher at the bottom of the sidebar when you belong to more than one. With a single organisation nothing changes: a switcher with one option is a control that does nothing.
Being crew somewhere no longer costs you the console elsewhere either. Crew have a scanner link and no screens, so those memberships are skipped when choosing where to open — they used to be able to win the coin toss and bounce you out.
The trade fair report MATRADE asks for Added
Studio → your event → Trade fair report. Every figure on MATRADE's ANNEX 2 form, computed from your own registrations and exhibitor list, plus a plain answer to the question that decides everything: does this event meet their criteria?
Anything nobody has told us reads as not asked or not measured — never as zero and never as Malaysian. Both of those would move a percentage in the direction that flatters you, on a document an external auditor puts their name to.
Two things it will not do. It counts each person once, however many workshops they booked — four registrations by one delegate is one visitor. And it refuses to fill in "total sales generated", because that means business done at the fair, which we cannot see; ticket revenue is a different number and offering it under that label would be wrong.
Set the gross floor space and which running of the fair it is under Details; set each exhibitor's country and net space at the bottom of the report itself.
Discount codes can finally be created Added
Codes have worked at checkout for months — percentage or fixed, limited to one event, capped by number of uses, with an expiry. There was simply no screen to make one, so a code could only exist if somebody wrote it into the database by hand.
Studio → Codes. A usage limit of 1 is how you give a single named buyer a code nobody else can use.
How many people looked at your event, before and after Added
Your event's overview now shows page views split three ways: before the doors opened, while it was running, and after it ended. Distinct browsers, not refreshes.
The existing analytics page counts backwards from today — 7, 30 or 90 days — which answers a different question and cannot be made to answer this one.
Door staff can be invited without handing over the event Fixed
Inviting crew required the Admin role, which can also publish, remove other admins and delete the event. An organiser running your floor under contract had to be given all of that, or you had to do their staffing for them.
Staff can now invite crew, and nothing else. Crew hold a scanner link and have no console screen at all, so it gives away nothing further.
An event can now contain workshops and breakout rooms Added
An event can hold as many workshops as you like, each with its own room, its own time, its own seats and its own tickets — free or paid, independently of what the main ticket costs. Studio → your event → Workshops.
Guests see them on the event page and book each one separately, so every workshop issues its own ticket. That is what lets your door staff scan somebody into Room 2 in the afternoon when the same person already came through the main entrance that morning: two tickets, two scans. One ticket serving both doors would have been read as the same person scanning twice, and the second scan would have been ignored.
Seats are enforced per room by the same counter the main event uses, so a forty-seat room cannot be oversold. Booking two workshops that overlap is refused, with the name of the one you are already in — a double booking takes a seat out of a small room that nobody can use.
Workshops do not appear in browse. Twelve breakout rooms would bury every other event on the day they run, and they belong on their parent's page anyway.
Nobody outside Malaysia could register, for any event Fixed
The number field only ever accepted a Malaysian mobile. A visitor from Singapore, Indonesia or anywhere else typing their own number was told "that does not look like a Malaysian mobile number" and could not finish — there was no way past it, and no way to tell us so.
This has been true of every event on the platform since registration existed. We do not know how many people gave up at that field, because a form nobody can submit leaves no record.
Any country code now works, and a number typed with a leading + is accepted
as it stands. A foreign number typed without its country code is still
refused rather than guessed at, because 6591234567 and a mistyped local
number are the same string.
Organisers who send us a Malaysian-only form — claiming a listing, listing an event — are unchanged.
Registration now asks three more questions Changed
Booking a ticket now asks which country you are travelling from and whether you are attending for work, and asks trade visitors for their company. Job title is optional.
This is not marketing data. For a trade exhibition, the organiser has to give MATRADE an externally audited report of how many visitors came, how many came from abroad, and how many attended on business — and if they cannot produce it, every exhibitor's grant claim is rejected. Those numbers can only come from the person at the point they sign up; nobody can be phoned in November to ask which country they walked in from in October.
If you have registered before, the answers are filled in for you. Leaving one blank never erases what you told us last time.
We now count who looks at your own event Added
Until today the traffic figures only covered events listed from somewhere else. If you run an event with us, the page nobody could put a number on was yours — an organiser could be told how many people viewed a rival's listing and nothing at all about their own.
Views on your event page are now counted the same way, with the same rules: prefetches and crawlers are excluded, and a visitor's very first page view goes uncounted because a browser that has not yet kept a cookie cannot honestly be told apart from a script. Undercounting is the right direction to be wrong in for a number we quote to you.
Nothing is counted retrospectively. The figure starts from today, which is the reason this shipped ahead of larger things: a view that happened last week cannot be recovered later.
Undoing a check-in left no trace of who did it Fixed
When someone on the door took back a mis-scan, the check-in row was deleted outright. Nothing recorded that it had ever existed, or who pressed undo.
That mattered in two ways. An organiser reviewing the day could not tell a careful scanner from one who undid a fifth of their scans, and if a guest ever said "I definitely came in", there was no longer anything to look at. The only surviving trace named the guest whose points were reversed, never the person holding the scanner.
Undos are now written to the audit log with who took it back, who had scanned it originally, when, and from which device. Replayed undos from a flaky connection are not logged, so nobody's error rate goes up because their phone lost signal.
This starts from today. Undos before now are gone and cannot be recovered.
2026-08-15
Pages with pictures now load on a phone Fixed
Browse was downloading about 22MB of images — one poster alone was 12MB, published at full print resolution by the source it came from. On mobile data that is not a page, it is a wait.
Images are now resized before they reach you. The same page carries under 1MB, a 96% reduction, and the pictures look the same at the size they are actually shown.
We still do not keep a copy of anybody's poster. Images are fetched from the organiser's own site and cached briefly, so a poster that gets corrected — a moved date, a cancellation — still reaches you, and one that gets taken down still disappears.
An event with a missing picture showed a broken icon Fixed
One listing pointed at an image its own source no longer serves, and the page drew the browser's broken-image symbol, which looks like our fault rather than a missing file.
Images are now checked before they are stored, and anything that fails to load later falls back to a plain panel instead.
2026-08-14
Say you are going, and see how many others are Added
Events listed from other sites now have an I'm going button. It tells you how many other people said the same, so an event you were unsure about stops looking empty.
It is not a ticket and it does not hold you a place. The organiser is not told you are coming, and entry is still handled by whoever runs the event — the page says so next to the button, because the difference matters at the door.
Separately, you can let the organiser of one specific event contact you about it. That is off unless you turn it on, it applies to that event only, and you can turn it off again in the same place.
The way through to the organiser is still on the page and always was — it now sits beside the button rather than in a second panel competing with it, and becomes the main action once you have said you are going, because by then it is what you actually want next. Where enough people are reading an event at the same time, the page says so.
The demonstration events have been taken down Changed
Browse was carrying seven events that existed only to show the product working — Waterfront Premiere, Penang Makers Market and the rest, all created on the day the platform was first set up. They are gone. What is on the page now is real: events read from myPenang's public listings.
Tickets, orders and payment records from those demonstrations are kept rather than erased, so nothing in the accounting history develops a hole.
Listed events now show the organiser's own poster Added
Events read from myPenang appeared as blank grey cards. They now carry the poster the organiser actually published, loaded from the source's own site rather than copied to ours — so if they change or withdraw it, what you see changes with it.
Descriptions come from the source too. Where a source publishes none, the listing simply has none, rather than a sentence we made up.
These pages are now also submitted to search engines, with the event's name, date and poster attached in a form Google can read. Listed events do not publish a venue, so they will not appear as full event cards in search until that is available — the page itself is complete either way.
Events from around Penang now show alongside the ones you can book Added
Browse used to show only events running on this platform, which on a quiet week was a short list. It now also carries events read from myPenang's public listings, mixed into the same grid in date order.
They look different on purpose. A listing carries a via myPenang line and no price. Opening one gives you a page that says plainly whose event it is, what we know about it, and that registration is handled by the source — with one button out to them. Nothing about it is bookable through us, and we do not claim otherwise.
Events with no published start time show the date alone rather than inventing one. If a listing says "Sat, 15 Aug" with no clock, that is because the source never gave a time — not because it starts at midnight.
Public holidays that appear on the source's own events list — Merdeka Day, Malaysia Day, Deepavali — are filtered out. They are real dates but there is nothing to attend or click through to.
Some listing descriptions were written by us, not by the organiser Fixed
Every aggregated listing carried a one-line description that our extraction step had composed rather than copied. Most read plausibly. Some were wrong: Sundown Festival, a music festival, was described as "a festival celebrated around sunset", and one entry said "a weekend event likely for families".
These were never shown to anyone — the browse page that displays listings ships today, and the descriptions were cleared before it did. Listings now carry the source's own wording or nothing at all.
The same fault had also been restating event names, so a few events were listed twice under different wordings of the same title. Those duplicates are gone.
2026-08-08
Getting a ticket no longer queues behind everyone else Changed
Every registration used to check whether the event was full by counting every registration already taken, while holding a lock that made everyone else wait their turn. For an event of a few hundred that is instant and nobody could have noticed. For one selling tens of thousands it meant a queue that got slower the more tickets were sold — exactly backwards.
The count is now a running total, so claiming a seat takes the same microseconds whether you are the tenth person or the thirty-thousandth. Capacity is still exact: no event will sell a seat it does not have.
If your checkout expires and the payment goes through anyway Fixed
You have twenty minutes to finish paying before your seat goes back on sale. If a payment landed after that — a slow bank, a page left open — the money could be taken for a seat that had already gone to somebody else.
That payment is now refunded automatically and recorded either way, including with the one provider that cannot send a refund by itself, where it is logged as owed for someone to return by hand.
Set up your own sizes, sugar levels and add-ons Added
Ordering has always been able to ask a question before an item goes in the basket — pick a size, choose kurang manis, add extra peanut sauce. Setting one up meant asking us, and we ran it against the database by hand. Every one of them on the platform got there that way.
Your menu now does it. Under any item, add a question, give it choices, and say whether it must be answered and whether people may pick one or several. A choice can carry an extra cost, and can be marked sold out for the night the same way an item can — running out of Kosong is not the same as never selling it.
Order from the stall's own page Changed
A stall's page showed you the menu, and then a button took you to a second page showing the same menu again — this time with the buttons that actually did something. Now the stall page is the one you order from. Old links still work.
Its menu is also no longer hidden until you hold a ticket. That was only ever stopping people who arrived the honest way: the ordering page showed the same menu to anyone with the link. You still need a ticket to order.
See how busy a stall is before you order Added
A stall page now says how many orders are waiting alongside how long that stall usually takes, so "about 8 minutes" means something. Once you have ordered, the strip at the top counts down how many orders are ahead of yours.
There is no estimated wait, on purpose. We know how many orders are in front of you; we do not know how many things that stall cooks at once, and a confident "about 5 minutes" that is wrong at minute twelve is worse than a number you can check yourself.
The status strip could sit there saying the wrong thing Fixed
The strip at the top of the screen tracks an order you are waiting on. It only ever updated on the order's own page — so while you walked around looking at other stalls, the very place it was meant to help, it kept saying "sent" long after your food was ready. It now keeps up wherever you are.
"You left 3 items with…" never once appeared Fixed
If you started an order and wandered off, the event page was supposed to offer you the way back to it. It never did, for anybody. The banner looked for your saved basket under one name while the order page filed it under another, and read it in a shape it was never written in — so the basket was always found to be empty and the prompt never showed.
Your basket itself was never lost; only the reminder was. It works now.
If you scanned a venue's check-in code without holding a ticket for that event, the page offered to show you the event — and then landed on a not-found screen. The link was built from the event's internal id instead of its web address, so it could never have resolved. It now goes to the event, which is the one thing worth doing at that moment: you are standing at the door of something you would like to get into.
Resuming a payment could charge you twice Fixed
The Pay now button added below, for resuming a declined or abandoned payment, opened a hole of its own: if the original payment page was still open in another tab and you paid through it after resuming, both payments could go through. The order or ticket was only ever confirmed once — but the gateway had taken the money twice, and nothing recorded that a refund was owed.
A second successful payment for the same order or registration is now caught the moment it settles. It is refunded automatically, and the amount is recorded on your organiser's books either way — including the one gateway we support that cannot send a refund by itself, where it is logged as owed for someone to send back by hand. Checked against production: nobody has hit this yet, but the window existed from the moment resuming shipped, so it is closed now rather than after the first report.
A declined or abandoned payment left nothing to do but start over Fixed
If a card was declined, or you closed the payment page partway through, that was it. A stall order sat there saying "the stall never saw this order" with no button anywhere on the page. A ticket said "you can try again" and then offered a tickets list with nothing in it — the only way forward was the whole booking again, name and phone and consent included, while the original registration might still be holding your seat.
Both now show a Pay now that reopens the same order or registration and sends you straight back to the gateway, with nothing to re-enter. A ticket hold is honoured honestly rather than pretended away: past its twenty minutes the seat may already be gone, so past that point you are told to book again instead of being sent to pay for one that might not exist. A stall order has no such window and can always be paid.
One place for each event you are going to Added
Tickets used to be a flat list, and your orders, points and giveaways lived on three other screens that knew nothing about each other. Now Tickets is a list of the events you hold a ticket for, and opening one gives you everything about that event in one place: the QR, the venue with directions, the organiser's messages, your food orders and what you have in the running.
Whatever is happening today shows its QR on the list itself, so getting through a gate is still one tap.
Your ticket now works with no signal Fixed
Your ticket was already saved on your phone — but the QR itself was drawn by our server. So with no signal you could see the ticket in your list and then hit a blank screen trying to show it, which is exactly the moment it has to work: at a gate, with people behind you.
The code is drawn on your phone now. The app also keeps the pages you have opened, so the whole screen loads with no connection, and says plainly when what it is showing you is from earlier.
Signing out left your ticket on the phone Fixed
Signing out ended your session but did not clear what was stored on the device. On a shared phone, the next person could reach the previous person's ticket code, their name, and their food orders with the pickup codes — and in one case a working, scannable QR — by pressing Back.
Three separate causes, all closed: the data was being cleared but the page navigated away before the write finished; the saved copy of the page was served back to whoever asked next; and the browser's own back-button snapshot survived all of it. Signing out now clears everything, and returning to a page you have signed out of reloads it.
Booking asked for a name we already had Fixed
Getting a ticket asked for your full name even when you had given it during sign-up. It only looked in a record created by booking, so a first-time booker never matched.
Your mobile number is still asked for the first time, because nothing had ever collected it — sign-up now does, so it is asked once rather than every time.
A ticket bought before signing in could disappear Fixed
A ticket bought without an account was saved on the device, as intended. Signing in afterwards then showed only tickets linked to the account — so the earlier one vanished from the list. It was never lost, just invisible. Both are shown now.
Removing someone from your team Added
Organisers could invite people and cancel unaccepted invitations, but there was no way to remove somebody who had already joined. Their access was permanent, including any door scanner links they held.
Owners and admins can now remove a member. Doing so revokes every door they could scan, immediately. An admin cannot remove an owner, and the last owner cannot be removed at all — an organisation with nobody who can manage it needs a database to recover.
A scanner link could be pointed at another organiser's event Fixed
A door scanner link is issued for one event. The check that it belonged to your own organisation was missing, so an admin could in principle create a working scanner link for a different organiser's event — which would have handed over that event's full guest list, since the scanner loads it up front to work offline.
No such link was ever created. The database now refuses to store one.
Promo codes and referral codes Added
Tickets can carry a code at checkout. A promo code takes an amount or a percentage off; a referral code changes nothing about the price and simply records where the booking came from.
Discounts are recorded as their own line rather than a smaller sale, so what was charged and what was discounted stay separately visible.
When something breaks, we now find out Changed
The app had no error reporting of any kind. Server errors are now recorded, and pages that fail show a recoverable screen rather than the browser's default — including the door scanner and check-in pages, where a timeout on venue wifi is an ordinary event rather than an exception.
The unbuilt API service is gone Changed
A second backend service had been started but never connected to anything — three endpoints, no consumer, and nothing in the app calling it. It has been removed along with its hosting. Nothing in the app changes; there is simply less of it to keep working.
2026-08-05
Sign in with Google Added
The button had been in the dialog for weeks with nothing behind it — the provider was never switched on, so tapping it returned "Google sign-in is not switched on for this project yet". It works now.
Email is unchanged: still a six-digit code, still no password.
A finished Google sign-in landed back on the sign-in dialog Fixed
Signing in succeeded at Google and then returned to "Confirm it's you", as though nothing had happened. Two pieces of our own code were racing for the same single-use code: the proxy caught it wherever it arrived, spent it, and stripped it from the address bar; the callback route then found nothing left and sent people to the error path — which is the sign-in dialog.
The callback owns that exchange now. The proxy still catches a code arriving anywhere else, which is what it was for.
Sign-in codes come from a real mail server Changed
Supabase's built-in mailer allows two emails per hour and is explicitly not supported for production. That is survivable in testing and catastrophic on the morning tickets go on sale. Codes now send through Resend from a verified domain.
The sign-in email no longer offers a link Changed
It carried a six-digit code and a "tap to sign in" button. They are the same login, so tapping the link silently invalidated the code somebody was halfway through typing. Worse, the link only works in the browser that requested it — open the mail on a phone after asking on a laptop and it dead-ends with no way back.
The code is now the only way in, which is what the screen asks for anyway. It also appears in the inbox preview line, so most people will not need to open the mail at all.
Event OS is now Xperience Changed
Same product, same addresses, same tickets. The name changed everywhere it is actually read: the app, the Google sign-in screen, the emails and their subject lines, the payment sheet, and the home-screen icon.
Applied database migrations were left alone. Rewriting the comment in a migration that ran months ago changes nothing and loses the record of what was true when it ran.
Date of birth is picked, not typed Changed
The field was a plain browser date input, which always opens on the current month. Entering a birth year meant paging back three hundred months, one at a time. It opens on a year dropdown now, starting around 1995 rather than today.
Event dates still use arrows, because there the current month is the right place to start.
2026-08-01
A mis-scan at the door can be taken back Added
Nothing could undo a scan, and the row was not cosmetic: it counted toward attendance and awarded points, so the wrong account gained something real and spendable. Undo now sits beside the name, small on purpose, because it is for the mistake and not for the motion.
A scan that has not synced only exists on the device, so undoing it works with no signal at all. One already sent needs the server told. Those are different situations and are handled as such.
Points follow the visit, not the ticket Changed
The award was keyed on the ticket, so a ticket earned once and never again. Two things fell out of that: undoing a mis-scan and then scanning the guest properly awarded nothing, and a two-day event paid once even though check-ins already treat each day separately.
Keyed on the check-in now, which settles both and makes "points for turning up" literally true. Paying twice is still impossible: a replayed offline queue inserts no row, so it fires no trigger.
The earn rate is no longer a secret Added
The rates had been in the database all along and were never shown, so the only way to learn how points worked was to earn some and infer it.
Saturday is back in the weekend Fixed
"This weekend" read the weekday in UTC. A Saturday 8am market is Friday 00:00 UTC, so morning bazaars — which is most of them — dropped out of it, while a Monday 7am conference was let in. Read in Malaysian time now.
Studio links say where you are Changed
/studio/e/aaaaaaaa-0000-4000-8000-000000000006/floor told an organiser
nothing, while the public side has used slugs all along. It is
/studio/e/year-end-trade-gala/floor now, and links already shared still
resolve, because renaming re-slugs a draft and anything pasted into a chat
would otherwise rot.
Three doors into the same product Added
The development bypass signed in as one address. The same screens mean different things to an organiser, a stallholder and somebody holding a ticket, and that is not visible without being all three in turn, so it now offers an account each. The configured list is the allowlist and the server checks against it: a button not offering an account is not a control.
Unchanged: the same two switches, the same audit line on every use, the same deliberately ugly dashed box. Still to be removed before October.
The order banner is a bag that changes with the order Added
Rendered in 3D and shipped as flat art. A live model would mean a WebGL runtime in the hundreds of kilobytes, on the mid-range Android and congested venue wifi this product is designed around, to decorate a status strip. Three states come to under eight kilobytes in total.
One bag, folded flat then open then rolled shut, so the progression reads with the words covered. Only the ready state keeps moving: the presence of motion is the signal. It also no longer appears while you are running an event, where it had been sitting across the studio's own sidebar.
Stalls can photograph what they sell Added
A maker's market is a visual business. A batik print, a glazed bowl and a tray of kuih are decisions people make with their eyes, and the catalog described all three in grey text at the same size.
Stalls now add a photo per item from the phone already in their hand. The camera is not forced: the good photo was usually taken this morning in daylight, not under a marquee at 8pm, and forcing the camera hides the camera roll where that photo lives.
The catalog follows Grab's shape, which runs two layouts on one screen: a photo grid for the few worth leading with, then rows with a thumbnail, the words that sell it, and the price. Add becomes a stepper in place, so the control never moves under the thumb. The bar at the bottom carries count on the left and money on the right, and neither shifts as the order grows.
A stall with no photos is the normal case and never looks broken. The lead grid only appears once at least two items have been photographed, because a grid of identical placeholders advertises what a stall has not done rather than what it sells. Everything degrades to the price list it was before.
Uploads go straight from the browser to storage through a one-shot signed URL, minted only after checking who owns the stall, and the path is built from that answer rather than from anything the browser sent. Three megabytes, JPEG, PNG or WebP. Verified end to end, including that an HTML upload is refused.
Stall cards on an event now name their cheapest item and its price. A stall name tells you nothing; "Satay ayam · from RM 12" tells you whether to walk over.
Skipping the queue now says what you are skipping Added
Ordering ahead promised no queue and never said how long the wait was. Stalls set their own typical wait on the menu screen, and it appears before anyone orders rather than after.
Set by the stall rather than derived from queue depth, deliberately: we can see the queue and not the kitchen, and two people behind a counter make a very different wait from five with the same queue in front of them.
An order in progress follows you around Added
Order status lived on the order page, which is the one screen a person at a bazaar is not looking at. It now rides along the top of every screen with the pickup code. Ready cannot be dismissed, because being told when to walk over is the entire point; the earlier states can, because "still preparing" repeated forever is just noise. It costs no extra query, riding on the who-is-looking lookup the layout already does.
A cart you walked away from says so Added
Carts survive a reload now, which fixed losing one and did nothing about forgetting it. The event page notices an unfinished cart and offers the way back to it, with a reminder that nothing has been ordered yet.
Also: the order note shows a character count once you are within sixty of the limit. It used to silently swallow the rest.
Abandoned checkouts were eating ticket tiers Fixed
Two triggers disagreed about what a taken place is. enforce_capacity counted
an unpaid registration as holding a seat for twenty minutes and no longer, so a
walked-away checkout released it. enforce_type_quantity counted every
non-cancelled row with no time limit at all, so the same walked-away checkout
held its ticket tier forever.
One-way and invisible: every unfinished paid checkout permanently shrank that tier, until it reported sold out while the event itself still had room. Nothing released it but a manual cancel. Both triggers now use the same twenty minutes, and so does the app, which had a third definition of its own.
Verified against the live database: a tier whose only seat is held by a 25-minute-old pending row now takes the next booking, and the same tier with a live hold still refuses it.
You can see the clock that was already running Added
Your place really is held for twenty minutes while you pay, and nothing ever said so. The booking page now says it before you leave for the gateway, and the payment screen counts it down, going red under two minutes. When it lapses it says so plainly rather than letting you find out by paying.
Tickets say when a tier has gone Added
Tiers were radio buttons with a price, and the only way to learn one had sold out was to be told after pressing pay. They now carry what is left of them.
Sold-out tiers stay on the page, struck through and unselectable, rather than disappearing: a tier that vanishes reads as though it never existed, while one crossed out says you were nearly in time. Counts only appear under ten, because "847 left" is not scarcity and nobody can act on it.
Carts and booking are pages, not drawers Changed
Both used to be modals, which DESIGN.md warns against and for the exact reasons that bit here: a drawer cannot be linked to, loses everything in it on a refresh, and hijacks the back button on a phone. Building a four-item order at a bazaar, on venue wifi, one back-swipe from losing it, is not an edge case.
Ordering now lives at /e/[slug]/order/[stall] and booking at
/e/[slug]/book. Back does what back should, the sign-in detour returns to the
page you were on rather than to a closed drawer, and the cart is mirrored into
session storage so a reload or a tab the phone reclaimed does not cost the
order. Quantity steppers went from 32px to 44px while the room was open.
The loading bar stopped causing the jank it reports Fixed
It animated width, which relayouts the document on every frame, during
navigation, on the mid-range Android the whole design targets. It is scaleX
now, which the compositor handles. The outer glow went too: DESIGN.md bans
glows, and the bar had one.
A stall's own page now sells Added
Tapping a stall on an event took you to its profile, which listed where to find them and then sold you nothing — a shop window with the shop behind it locked. It now carries the menu, with sold-out lines crossed out rather than hidden, and orders against the soonest event the stall is still booked for. Same order, same pickup code, same points as ordering from the event page, because it is the same action.
A stall with no upcoming dates shows the menu as a price list and says so, rather than offering a button that cannot work.
The organiser's own door Added
Scanning tickets already worked — camera, offline guest list, queued scans that survive a dead venue wifi. Getting to it did not: the only route in was Staff, find yourself in the list, issue a grant, copy the link, paste it. That is a fair flow for handing a scanner to a crew member and an absurd one for the person who owns the event, standing at the entrance.
Door on the event now opens the scanner on the phone in your hand. It goes through the same grant as everyone else rather than a back door, so scans are still attributed to a named person and revoking still works. It will not resurrect a grant somebody revoked, and the link is not prefetched — otherwise scrolling past it would quietly issue one.
2026-07-31
Signing in now means something at the ticket counter Fixed
Signed-in people were still typing their name and mobile number into every
ticket they took. The path that skips the form already existed — it just read
from the profiles table, which is written by an onboarding flow nobody
reaches and therefore has no rows at all. The condition had never once been
true for anybody. Both reads now come off the attendee record, where the
details people actually typed have been sitting the whole time.
Already going? Bring someone Added
If you hold a confirmed ticket, the bar at the bottom of an event stops trying to sell you a second one. It says you are going, links to your ticket, and offers Share with a friend — the OS share sheet where there is one, which on a Malaysian phone means WhatsApp, and a copied link where there is not.
Only confirmed registrations count. A pending one is an abandoned checkout, and telling that person they are going would cost them the ticket.
Stalls are invite-only Changed
Anyone could previously find a stall's public profile, press Claim this stall, and wait for an organiser to notice. Whether a stall trades on your floor is your call, so it now works the other way around: Invite on the event's floor plan produces a link, and that link is the only way a stall ever gets an owner. Nobody had ever used the old flow — 8 listings, 0 claims — so nothing was lost in the change.
The invitation is carried entirely in the link. It is signed with the same scheme as the scanner links, carries its own fortnight expiry inside the signature, and needs no table to write, sweep, or keep honest. Accepting sets the owner in a single conditional write, so a link forwarded around a vendor group still only ever hands the stall over once. An expired link and a forged one give the same answer, because neither is worth a different one.
Removed with it: the claim panel, the claim review screen, and their token
scheme. The merchant_claims table is left in place, empty and unused.
2026-07-30
Analytics, for both sides Added
Organisers get /studio/analytics: registered and turned up, attendance
rate, what was taken split between tickets and stalls, points issued, then
registrations per day, what people come for, busiest events, top stalls and
most-ordered items. The range switch (7 / 30 / 90 days) moves every number and
every chart together — the only interaction on a dashboard that earns its keep.
Stalls get /stall/insights: orders, takings, average order, cancellation
rate, orders per day, and when people order. That last one is the useful
one: knowing half your orders land between seven and nine changes how much you
prep and when you take a break. It shows trading hours only, because twenty-four
bars of which fifteen are zero says less than nine that are all real.
Every chart here is a single hue, and that is a decision rather than a limitation. The design system has exactly one non-semantic colour — moss means "checked in" and rust means "danger", so reusing either as a series would make a bar chart look like a warning. It turns out to be the right answer anyway: every figure on these pages is a magnitude, not an identity, and magnitude wants one hue with the label carrying the name. So there is no legend, because there is never a second series to name.
The colour was validated rather than eyeballed — it passes the lightness band, chroma floor and contrast checks against both the light and the dark surface. Every chart has a "show the figures" table under it, so nothing depends on being able to see the bars at all.
A top-five never quietly hides the rest: anything past the cut folds into a labelled "N more" row, because a chart that omits 60% of the total is a lie by omission.
A payment simulator, so the flow can be shown Added
⚠️ No money moves. A stand-in for the e-wallet step, in the same spirit as Stripe's test mode: it takes no card or wallet details, contacts no bank, and every screen it renders says so. It carries no logo or branding and is not connected to Touch 'n Go or any other wallet — the name in the code only records which real integration will replace it.
What it does not simulate is everything after the wallet. It signs a callback and posts it to the real webhook, so the signature check, the replay guard, the ledger write, the points award and the order release all run unchanged. A demo that shortcut those would prove nothing about whether the product works.
Verified against production: a signed callback confirmed the order and awarded
24 points on RM 12; the same callback replayed changed nothing; a forged
signature was refused with HTTP 400. The simulated wallet even keeps a 0.8%
cut, so the gateway_fee ledger row is exercised too.
TNG_DEMO_ENABLED off and the real gateway resumes with no code change.
Touch 'n Go cannot be done through Stripe — it is not a Stripe payment method. It needs a Malaysian gateway: Billplz, Fiuu/Razer or iPay88. That is an account decision, and the adapter takes it without changes.
Stalls run their own storefront Added
/stall/menu — a stallholder adds items, prices them, and marks things sold out
themselves rather than asking the organiser to. Sold out is the biggest button,
because it gets pressed several times a night, and it is never a delete: the
stall sells it again tomorrow and deleting would orphan the orders that already
contain it.
Stalls get their own screen Added
/stall — a stallholder's own queue, not a corner of the organiser's console.
Three numbers they glance at between customers (to make, today, taken today),
then the orders: pickup code largest, items under it, one button for whatever
comes next.
A stallholder is whoever owns a claimed listing. No separate account type, because the same person can be a stallholder at one bazaar, an organiser of another, and someone buying a ticket to a third.
Cancelling refunds the customer Added
Not "flags for refund" — refunds.
The order of operations is deliberate: the refund is written to the ledger whether or not the gateway call succeeds. A refund somebody is owed has to exist in our books even when Stripe is down, because the alternative is a cancelled order and a silent unrefunded charge. Where the gateway cannot do it at all — Billplz has no refund endpoint — the order says so plainly and somebody settles it by hand.
The points come back too. Without that, order → earn → spend → cancel is a farm: RM 50 of satay, 100 points, spend them on a giveaway, cancel the satay. The clawback reverses exactly what was awarded, and it is allowed to take a balance negative — someone who earned, spent, then got refunded genuinely owes those points, and refusing to record it would leave the refund unrecorded instead. Choosing to spend points you do not have is still refused; that is a different thing wearing the same minus sign.
Order status, in the customer's words Fixed
Order confirmed → Preparing your order → Ready for pickup → Collected, with a four-dot rail because a filled dot answers "is it my turn" faster than a label does. The stall's buttons produce exactly these words, so a phone and a stall can never describe the same order differently. A cancellation shows the stall's reason and whether the money is back yet.
Buy from a stall without queueing at it Added
Stalls at an event can have a menu. A visitor already at the event picks items, pays, and gets a four-character pickup code; the stall sees the order in the console and moves it along — making, ready, collected. Points are earned on the spend, which was the point.
Prices come from the database, never from the form. The browser sends item ids and quantities and nothing else is believed. Trusting a posted price is how a RM 48 platter costs five sen.
Line items are snapshotted onto the order — name and unit price — for the same reason a sold ticket type locks its price: a receipt has to keep saying what was bought and what it cost, whatever the stall does to its menu later.
Pickup codes avoid I, O, 0 and 1. A stall shouting "K seven O two" across a queue does not need the ambiguity.
Sold out is a toggle, not a delete. A stall runs out of satay at 8pm and has it again tomorrow, and deleting the item would orphan the orders containing it.
The stall only sees paid orders. A queue full of unpaid ones is worse than a queue.
Where the money actually goes Known issue
A visitor pays Century Group, not the stall. Xperience then owes the stall,
recorded as a merchant_sale credit and settled with a payout debit — so what
a vendor is owed is a SUM over the ledger, not a spreadsheet somebody keeps
separately.
That is a normal commercial arrangement for your own event with your own vendors. It is not a model that extends to third-party organisers: collecting on behalf of sellers and paying them out is regulated activity in Malaysia. Before Xperience takes stall money for somebody else's event, this needs Stripe Connect, where each stall onboards and Stripe splits the payment directly. That change lands in the gateway adapter and does not touch the order model.
2026-07-29
Every sheet is one component now Fixed
There were five hand-rolled drawers, and an audit of what each actually did:
| Escape | Scroll lock | Focus trap | |
|---|---|---|---|
| Checkout | ✗ | ✓ | ✗ |
| Claim a stall | ✗ | ✗ | ✗ |
| Search | ✓ | ✗ | ✗ |
| Sign in | ✓ | ✓ | ✗ |
None of them trapped focus, so tab walked out of the form and onto the page behind — on a phone that means the keyboard's "next" button quietly leaves the checkout. Search and claim let the page scroll under the open sheet. Checkout and claim ignored escape entirely.
Checkout, claim and search now use the same Radix-backed shell as the studio,
which brings all four behaviours with it. Only the content scrolls, so the
close button never leaves the screen on a long form, and overscroll-contain
stops a flick at the end of a list from dragging the page underneath.
The shell also learned when not to close: search's processing step blocks escape, the backdrop and the close button together, rather than hiding the button and leaving the sheet apparently stuck.
The new date field overlapped Capacity on a phone Fixed
Shipped and broken in the same afternoon. The date field is two controls wearing one label, and it went into a half-width grid cell built for a single input — so "Pick a date" wrapped onto three lines and the time select ran straight over the Capacity box beside it.
The field now wraps rather than overflows, whatever it is dropped into, and Starts has a row of its own. Measured at 360px and 320px: date and time share one line with no overlap and nothing escaping the panel.
The trigger also stopped repeating the time that the control immediately beside it was already showing.
A real calendar Added
<input type="datetime-local"> renders as a different control in every
browser and made you type a date one segment at a time. Event start and end,
and giveaway closing times, now use a calendar in a popover with a separate
time list — weeks starting Monday and dates read day-first, because that is
how a date is written and said here.
Popover rather than dialog: these fields already sit inside dialogs, and a dialog over a dialog traps focus twice and closes the wrong one on escape.
Things that could scroll sideways Fixed
The bottom navigation's centre slot was a fixed 5rem, which with four tabs
beside it needed 400px of width — more than a 360px phone has. It flexes now.
The loading skeletons had fixed-width bars up to 20rem, wider than the
narrowest screens once padding is counted.
The app was running on the wrong side of the Pacific Fixed
Vercel functions default to Virginia. The database is in Singapore. So a tap in Penang went to the Singapore edge, out to Virginia to run the page, back to Singapore for every query, back to Virginia, and home — roughly 230 ms of ocean per database round trip, several times a page.
Functions are pinned to sin1 now, beside the database and beside the people
using it. Everything else was noise next to this.
Warm time-to-first-byte:
| Route | Before | After |
|---|---|---|
/ | 1.50s | 0.18s |
/events | 1.04s | 0.18s |
/studio | 0.70s | 0.15s |
/studio/team | 1.48s | 0.14s |
/rewards | 0.76s | 0.14s |
A loading bar Added
A thin bar across the top for the moment between the tap and anything
changing. It listens for link clicks rather than wrapping every link, because
useLinkStatus only reports on the link it sits inside and a bar that works on
some links is worse than none. Back and forward get it too.
Middleware stopped verifying on every request Fixed
Middleware called getUser(), which asks Supabase's auth server to verify the
token — a network round trip paid on every request in the app, including every
navigation fetch. All it needed was the cookie refresh, so it calls
getSession(), which reads locally and only reaches the network when the token
has actually expired. Nothing about access changed: middleware grants nothing,
and every server component still verifies before trusting an identity.
Revisiting a page is also instant now: dynamic routes were not client-cached at all, so going back to a screen you looked at two seconds ago refetched it.
The team page has never listed anyone Fixed
profiles ( display_name ) asked PostgREST to follow a foreign key from
memberships to profiles. There isn't one — both hang off auth.users
independently — so the query failed with PGRST200 and took the whole member
list with it. An organisation with an owner in it rendered as an empty list.
Fetched separately and joined in the page now, falling back to the name someone registered with when they have no profile.
Navigation stopped feeling slow Fixed
Two separate problems wearing one coat.
It was slow. auth.getUser() is a network round trip to Supabase — it
verifies the token rather than trusting the cookie, which is correct and is why
it costs something. The root layout, the studio guard and the page each asked
independently, so one navigation made three auth round trips and three
identical membership queries before reading a single row it came for. They are
deduped per request now. The category list, eight rows of reference data that
was fetched on every request to populate a search sheet most people never open,
is cached for an hour.
Warm time-to-first-byte, before → after:
| Route | Before | After |
|---|---|---|
/ | 1.50s | ~0.45s |
/events | 1.04s | ~0.36s |
/studio | 0.70s | ~0.48s |
/studio/team | 1.48s | ~0.46s |
It also felt slow, separately. The studio had no loading state, so a click
did nothing visible until the server came back — and a dead half-second reads
as a broken button rather than a slow one. /studio and /rewards now show a
skeleton the instant you click. Because it lives inside the layout, the sidebar
stays put and only the panel swaps.
Stripe Added
Paid tickets can be switched on today rather than waiting on Malaysian merchant approval. Cards and FPX, since cards alone lose most of the audience at the last step.
Billplz stays: a flat fee beats 1.9% badly on a RM 480 table, and it takes over the moment its account clears. Nothing above the adapter knows which is in play — the gateway is chosen from whichever is configured.
Two details that matter for correctness. FPX settles asynchronously, so a
completed checkout session is not yet money and the ticket is issued only when
payment_status is paid. And the callback contract now carries the raw
request body, because Stripe signs the exact bytes it sent — parsing first
would make the signature unverifiable.
The Stripe fee is recorded as its own ledger row, so revenue and cost never have to be untangled from one net number later.
Winners get told Added
The first email this product sends on its own behalf. Drawing a giveaway now emails the winners through Resend.
Sending happens after the draw commits and never undoes it: a draw is a database transaction, an email is a call to somebody else's service, and a bounce must not un-pick a winner. Failures are named in the studio and the entry stays marked "not emailed yet" so nobody is quietly left unaware.
Registration has never worked Fixed
Not "was broken for paid tickets" — no registration has ever succeeded, for anyone, since launch. The first write of the flow threw every time.
attendees_org_phone_idx is a partial unique index (where deleted_at is null). Postgres only lets a partial index satisfy ON CONFLICT when the
statement repeats its predicate, and the Supabase client emits column names
only — so every attempt raised 42P10 and the visitor was told "Could not save
your details."
That is why there were no confirmed registrations, no ledger rows and no audit entries: the flow could not get past its first line. The upsert moved into a Postgres function where the predicate can be written.
Points and giveaways Added
Points are a ledger, like money — a balance is always a SUM, never a column,
because a stored integer is one failed decrement from being a lie nobody can
reconstruct. They are scoped to an organiser: whoever owes points has to fund
them, and a platform-wide balance would make Xperience the debtor for points
earned at one organiser and spent at another.
Earning is a database trigger, not application code. The door scanner is offline-first and replays its batches on purpose; the trigger fires only on a genuinely new check-in and keys on the ticket, so one ticket scanned on three phones earns once. Purchase points hang off the ledger, inheriting its idempotency for free.
Entering a giveaway is one Postgres function. A spend and an entry that can come apart is either theft or a free entry, and a client cannot hold a transaction open across two calls. Overdrafts are refused under a per-person advisory lock, because two concurrent redemptions would both pass any check written in application code.
Drawing picks winners once, records the pool size in the audit log, and refuses to run twice. A draw that can be re-run until the right name appears is not a draw.
Organisers get rates in Settings (both start at zero) and giveaways per event. Visitors get Rewards: balance, history, and what they can enter.
Every time was eight hours out Fixed
A datetime-local input carries no timezone, so new Date("2026-10-18T18:10")
resolved in whatever zone the code ran in — the browser for form defaults, UTC
on Vercel for server actions. An organiser typing 6:10 pm in Penang stored
6:10 pm UTC and read it back as 2:10 am the next day.
Event start and end, and giveaway closing times, are now read as Malaysian wall time at both ends. Malaysia has been UTC+8 with no daylight saving since 1935, so the offset is a constant rather than a lookup.
A registration now records which ticket it is for Fixed
The type chosen at checkout was thrown away. registrations had no
ticket_type_id, so the visitor was charged for what they picked and then
issued whatever type sorted first — and the ledger booked that type's
price. Pick the RM 480 table on an event whose free tier sorts first and the
gateway takes RM 480 while the ledger records nothing.
It was worse than first-by-position suggests: nothing ever set position, so
every ticket type created in the console defaulted to 0 and the winner was
whichever row Postgres happened to return.
No money was lost. Billplz has never been configured in production, so no paid registration could complete. The one live registration was inserted directly during testing. This was a loaded gun, not a fired one.
Fixed at all three levels: the column exists and is written at checkout, read at confirmation, and backfilled from issued tickets. New ticket types get a real position. The fallback for older rows now sorts by position and name, so a tie is never resolved by luck.
Ticket types enforce their own quantity Fixed
Capacity was only ever checked against the event. A type holding 150 seats could sell 500, as long as the venue had room — the count was impossible to write without knowing which type a registration was for, so it simply did not exist. There is now a row-locking trigger per type, alongside the existing one per event.
A sale is recorded before the ticket is issued Fixed
The ledger write was never checked for errors. A failure returned a ticket code and lost the revenue row in silence. The sale is now written first, the error is surfaced, and both entries are idempotent on their key — so a replayed gateway callback still cannot double-count.
The studio got a sidebar Added
The console was wearing a visitor's top navigation. A console is somewhere you work for hours, so navigation now stays put on the left and the page scrolls under it. Open an event and the sidebar goes in with you, listing that event's sections instead of the global ones — losing your bearings mid-setup is how half-built events get published. On a phone the same list opens as a drawer.
Creating things happens in a dialog Added
New event, new ticket type and team invites are dialogs now: a drawer on a phone, centred on a desktop. One component behind all of them, with Radix owning focus trapping, escape and scroll lock — the parts that are quietly wrong in hand-rolled modals and invisible to anyone testing with a mouse.
The event dialog also takes venue, start time and capacity, so a new event arrives with more than a name.
Events can be renamed, duplicated and deleted Added
Duplicate is how a weekly market gets set up once. It copies the shape — venue, times, ticket types, categories, images — and none of the run: no registrations, no invite code, and it lands as a draft so a stray copy can never be live by accident. A copy of a copy counts rather than stacking.
Delete is soft, owner or admin only, and refuses while anyone is registered. Those people still need their tickets to resolve to something; close the event instead.
Rename re-slugs a draft but never a published event, because that slug is a link somebody has already shared.
Ticket types can be edited and removed Added
Name and price go read-only once one is sold — the database enforces it, not the form, so a missed check in app code cannot break the ledger's agreement with what people actually paid. Quantity stays editable down to the number sold, because running out and printing more is a normal Tuesday. Removing is blocked entirely once anything is sold.
Sign-in dialog overlap Fixed
The development sign-in button was a sibling of the dialog in the same centred layout, so it landed on top of the "or use email" divider. It is a slot inside the dialog now, sitting with the other ways in.
Development sign-in Known issue
There is an authentication bypass live on this deployment. /auth shows a
button that signs you in as a configured account with no code and no password,
so the app can be inspected while email and Google are still being set up.
Two independent switches must both be set (DEV_LOGIN_ENABLED and
DEV_LOGIN_EMAIL), the flag is resolved server-side so it never reaches the
browser bundle, the button is deliberately ugly so nobody forgets it is there,
and every use is written to audit_log.
Both variables must be removed before real guests arrive. It uses genuine auth machinery rather than a forged session — an admin-minted magic-link token verified through the normal path — so the resulting session behaves exactly like production, which is the point.
Auth errors stopped lying Fixed
Sign-in failures said "check the address and retry" regardless of cause. The commonest failure is Supabase's built-in mailer hitting its two per hour cap, so the message sent people to inspect the one thing that was fine. Errors now name rate limits with the retry window, disabled providers, and expired versus wrong codes — and fall through to what the service actually said rather than inventing a cause.
Supabase config as code Added
Site URL, redirect allow-list, email templates and the Google provider now live
in supabase/config.toml and ship with supabase config push. Email templates
carry {{ .Token }}, without which the app asks for a six-digit code that was
never sent. See supabase/SETUP.md.
Organiser console Added
Pillar 3 of the board paper. A human can now create, edit and publish an event without a developer — every event on the site previously existed because someone wrote SQL.
/studio is gated by one membership check rather than scattered role tests.
Events start as drafts and are invisible until published, enforced at the RLS
policy so a half-built event physically cannot reach browse. Publishing
requires categories and coordinates, which is what finally makes the
preferences onboarding has been collecting since launch do something: nothing
read them because nothing set categories on an event.
Ticket price and name lock in the database once one is sold — a trigger, not an app check, so a future API or a hand-run UPDATE cannot bypass it. Quantity may still rise, because releasing more seats is safe.
Team invites use single-use tokens. Membership is written before the invitation is marked accepted: if the second write fails the invitation stays open and can be retried, whereas the reverse would lock someone out of a team they were meant to join.
Scans now carry a name Changed
Scanner links become scoped to a person rather than an event, so check_ins
records who scanned. Existing event links keep working, because one may already
be printed or saved on a device. Revoked grants are rejected at sync — the
device keeps its cached manifest but its scans stop landing.
Vendor import Added
Paste the spreadsheet you already keep. It finishes booth assignment and populates the merchant directory in the same action, so every row becomes a listing its owner can claim. Matching is on phone rather than name, because names are typed differently every time and phones are not.
A bad row is skipped and reported by line number. An organiser who loses a sixty-row paste to one blank cell goes back to the spreadsheet and stays there.
Claiming a stall Added
Verification is organiser-vouched, not self-serve. The phone number on a listing came from the organiser in the first place, so texting it proves nothing; the organiser knows exactly who ran booth A-01, and vouching has no fraud surface.
The organiser approves through a signed link, with no login. They are doing us a favour on their phone during setup at a venue — an account requirement is how the mechanic dies. Holding the link is the authorisation, it is revocable by rotating the secret, and the page says plainly not to forward it.
This is what solves the merchant cold start: listings come from the organiser's existing vendor list, so the directory is populated on day one, and the pitch to a stallholder becomes your page already exists, claim it rather than create an account.
Merchants Added
Stallholders are now first-class and platform-level, deliberately not scoped to an organisation. A vendor trades at many organisers' events, and the whole argument for this pillar is that they stop depending on one — scoping them to an org would rebuild the lock-in the product exists to remove, and the same stall would appear as three unrelated rows across three bazaars with no shared following.
Event pages now list who is actually there, with booth numbers. For a bazaar
that is the reason to come: the event name tells you nothing, the stall list
tells you everything. Each stall has a profile at /m/[slug] showing where else
to find them.
A stall only appears through an event that is itself public and published, so a private wedding's caterer never leaks. Listings created from an organiser's vendor list are marked unclaimed until a real person takes them over.
Bottom nav rebalanced to three Changed
Explore, search, profile. Removing Plan left the bar lopsided; Tickets moves inside Profile, which is where you go deliberately rather than flick between.
Sign-in is a drawer on mobile, a dialog on desktop Changed
One component, two gestures: a drawer slides up with a grab handle on a phone, a dialog scales in on a desktop. It now says why it is interrupting, names the number it will text, auto-submits on the sixth digit, and counts down the resend against Supabase's own 60-second throttle so the button is never a lie.
Search opens where you are Fixed
The nav search button used to navigate home first just to reach state owned by the browse grid. The flow now lives in a provider above the page, so the drawer opens in place from anywhere — and desktop gets its own search trigger in the nav rather than only the one on the home page.
Plan and Work marked Soon Changed
Both are visible but inert, with a Soon label, in the search modal and both navs. Discovery and the merchant surface come first; showing a control that does nothing is worse than showing one that says when it will.
Profile stripped back Changed
It answers one question — who am I signed in as — and offers the one thing people come for. The design system and developer docs are working surfaces, not guest destinations; they stay reachable by URL.
Payments Added
Free tickets confirm inline; paid ones create a pending registration and hand off to the gateway. The ticket is issued by the signed callback, once money has actually arrived — issuing at checkout and hoping is how people walk through a door without paying.
Everything the app knows about payment sits behind our own three-function interface, so swapping gateway touches one adapter rather than forty call sites. Billplz first, chosen because event tickets skew high: on a RM480 table a flat ringgit beats 1.9% comfortably, and free tickets never touch it at all.
Capacity now stops counting pending registrations after 20 minutes, so an abandoned checkout releases its seat instead of holding it forever.
The Billplz adapter is unverified Known issue
Written from documented behaviour but never run against real credentials —
there are none in this project yet. Before taking a single ringgit, run one
sandbox transaction end to end and check the X-Signature source-string
construction, the paid flag arriving as the string "true", and whether the
gateway fee is on the callback at all. Paid tickets refuse cleanly until
BILLPLZ_API_KEY, BILLPLZ_COLLECTION_ID and BILLPLZ_X_SIGNATURE are set.
Booking moved into a drawer Changed
Registration is no longer a form sitting open under an event nobody has decided on. A price-and-CTA bar flies in once the hero is behind you; tapping it opens a drawer. Signed-in users with details on file get a confirmation summary rather than a form. Pages too short to scroll show the CTA immediately rather than never.
Floating bars clear the tab bar Fixed
The booking bar and the install prompt sat on top of the mobile bottom nav,
covering it. Both now offset by a --bottom-nav-h token. A CTA covering the tab
bar traps people on a page.
Hero rebuilt for a phone Changed
The rotating word ran to three lines on mobile. Replaced with a rotating category icon in a tile — same idea, one line — plus shorter copy: Something's always on. One tap in. Your ticket opens at the door, wifi or not. Reduced motion gets a still first icon.
Search moves to the centre of the tab bar Changed
Five slots with search raised dead centre, in thumb reach. It navigates to
/?search=1 rather than reaching into browser state — the URL owns the intent,
so back works and the nav needs no global store.
Category rail replaces wrapping pills Fixed
Eight categories wrapped to two rows and pushed events below the fold; twenty would have made five rows. Now one horizontal rail, icon above label, fixed vertical space at any category count.
Real photography Changed
Random placeholders replaced with Unsplash photography matched per event. Every
image was fetched and looked at before assignment — a plate of food on a
hardware meetup is worse than no photo. source.unsplash.com is dead (503), so
these are direct CDN ids.
Desktop navigation Added
Desktop carries Explore, Tickets, Plan an event, and an account control that reflects auth. Mobile keeps one action up top and lets the bottom bar do the rest. Nav hides entirely on full-bleed surfaces.
One icon family Changed
Hand-built clay SVGs and hand-drawn step art both dropped in favour of Phosphor duotone throughout. Rendered 3D assets are the more faithful Airbnb look, but a consistent set needs eleven renders and the generation workspace ran out of credits after one — a single 3D icon among eight flat ones is worse than none.
Plan an event is a dialog Changed
The four-step brief presents over the app instead of replacing it. The route still exists so it is shareable and back works.
Guided search Added
The search bar is a trigger, not a live input. Tapping it opens a flow: join, work at, or create an event, then public or private, then type and place, then a real processing sequence before results. Typing into a box only helps people who already know the name of the thing they want.
Create hands straight off to the event brief. Work records interest against the state and category, because there is no workforce module yet and recording the demand is the only honest evidence for whether to build one.
Private events Added
visibility is now separate from status: status says whether an event is
live, visibility says who may find it. Browse excludes private events at the
RLS policy, not in application code, so a missed filter cannot leak one.
Invitation codes are discovery, not access control Known issue
A code resolves to a private event's slug. The slug itself is then reachable by anyone who has it, exactly like an unlisted URL. Fine for a wedding, not sufficient for anything confidential. Per-guest tokens would be the fix.
2026-07-28
Warm Host design direction Changed
Chosen from four candidates. Airbnb lineage: photography leads, the interface
gets out of the way, warmth comes from whitespace and rounded geometry rather
than colour. Plus Jakarta Sans replaces Geist for readable text; Geist Mono
stays for data. One palette, two densities — guest surfaces breathe, operator
surfaces compress via data-density="operator".
Accounts at confirmation Added
Google, phone OTP and email OTP. Browsing never asks for an account; confirming a place does. Three-step onboarding collects birthday, gender and category preferences, each with its own consent timestamp — the PDPA asks what you collected data for, and one blanket checkbox cannot answer that.
Tickets live in the app Changed
WhatsApp delivery removed. Tickets are issued straight into the account, cached on the device for the door, and no longer depend on a messaging provider or cost anything per guest.
Offline door scanner Added
A signed per-event link rather than a login, because an offline device cannot refresh a session and door staff change on the night. The guest manifest caches to IndexedDB and scans sync idempotently, so replays cannot double-admit.
Update banner Added
Open clients poll the deployment id and offer a refresh when a new build ships. An installed PWA can otherwise sit on a stale build for days — including at a door, running the bug you already fixed.
Capacity enforced in Postgres Added
A row-locking trigger, not application logic. Two concurrent requests would otherwise both pass an app-level check and oversell the room.
Forged scanner token returns HTTP 200 Known issue
A forged token renders the not-found page and leaks no data — verified. But
the status code is 200 rather than 404, because Next streams the response before
notFound() commits. Wrong for monitoring and CDN caching. Fix pending.