The whole flow on one page
Three ways in, one door. The door is the gate; everything else is how a person reaches it and what happens when it says no.
Where the code question sits: after sign in / sign up, decided by Leong Sen Fong on 2026-09-15. An earlier draft of this document put it before the OTP; that was written to solve a problem which has since been fixed β see Decisions on record.
What the three sources are, and why they stay three
| Source | Who holds it | Expires | Costs a quota | Records who invited |
|---|---|---|---|---|
| Admin batch mint | You, to hand out personally | Optional, you choose | No | No β shows as HOOP |
| Member "Create invite & share" | A member, for a friend | Never | Yes, 1 of 3 | Yes |
| Waiting-list invitation | Sent automatically by email | 7 days | No | No β shows as HOOP |
They are one kind of code in one table with one format and one validation path. Only three columns differ at creation. They stay separate because each needs one behaviour the others must not have: attribution and a quota; a hard expiry; or no known recipient at all.
Arriving β the new-user journey
Order decided by Leong Sen Fong on 2026-09-15: the invite code is asked for after the person signs in or signs up. They prove the email address first, then the door asks for a code. Existing members never see this step at all β the gate only runs when no account exists yet, verified at all three signup paths.
apple:<sub> with no "@" in it, so the
waiting-list row is never matched and that person stays queued forever even though they are inside.
Task 4.5.The waiting list β from dead end to the main marketing asset
ReclaimExpired restores the original position). Only
the screen is missing.The queue lifecycle
Built today: queued β invited β joined, and the expiry return. Not built: the confirmation step at the front, everything the waiting person can see, and the second-expiry removal.
Inviting a friend β the member's side
Admin β what you operate it with
The first two screens below are live today (shipped 2026-09-14 and 2026-09-15). The capacity section is the new part.
The invite gate could not be read 3 times and signups were let through unchecked. Last error 14 Sep 09:12.
The emails
Email is the only channel that reaches someone who is not in the app yet, which makes it the entire waiting-list product.
Your HOOP invite is ready
You joined the HOOP waiting list β here is your invite code.
This invite expires in 7 days. If you do not use it, your place goes back in the queue where it was.
Confirm your place on the list
Someone put this address on the HOOP waiting list. If it was you, confirm below.
If it was not you, ignore this email and nothing happens.
Your invite expires tomorrow
Your HOOP invite expires tomorrow. After that your place goes back in the queue at the same position β you will not lose it.
Build plan β 4 phases, 21 tasks
Each task is one card on the workroom board, small enough to finish and verify on its own. Phases are ordered by what a real person hits first, not by what is easiest.
| Task | What | Why it is first | Size |
|---|---|---|---|
| 1.1 v2.214 | Make the three refusals at the door say the right thing (was: move the code question before the OTP β withdrawn on Leong's instruction, 2026-09-15) |
The code step stays after sign in / sign up. What is still wrong is the wording: "invalid", "already used" and "someone just took it" are three different situations and the user is told the same unhelpful thing. Also surface that the sign-in code survives a mistake, because the person does not know that. | S |
| 1.2 v2.215 | hoop://invite deep link + a readiness rule that works
logged-out | The share link's "Open HOOP" button has never done anything. The dispatcher has no invite branch, and it requires being logged in β which a person redeeming a signup code never is. | M |
| 1.3 v2.216 | Distinct message for "someone just used that code" | A code valid at pre-check and claimed two seconds later currently says "invalid", which sends the user hunting for a typo that does not exist. | S |
| 1.4 v2.221 | Show who invited you, on arrival and at the gate | The invite tree already records it and nothing has ever displayed it. Turns a coupon into an introduction. | S |
| 1.5 v2.223 | The code is typed once and never asked for again | Leong's rule, 2026-09-15. Today the code lives only in the login screen's memory
(_inviteCode): resending the sign-in code keeps it, but killing the app or leaving the
screen loses it and the person types it again for no reason. Persist it locally until it is
consumed. Pair with 1.2, which removes the typing entirely for anyone arriving by link. | S |
| 1.6 v2.224 | After authentication, land on the page they were going to | Leong's rule, 2026-09-15. Two things block it today. There is no notion of an intended
destination at all β every successful login does pushReplacement to Home (or the
contacts priming screen on first run). And the deep-link replay queue gives up after
20 seconds (deep_links.dart: 50 ticks Γ 400 ms), which is shorter than any
signup that involves waiting for an email. So a link tapped before signing up is already gone by
the time the person is authenticated. | M |
| Task | What | Why | Size |
|---|---|---|---|
| 2.1 v2.229 | Double opt-in β confirm the email before holding a place | Anyone can put any address on the list today, so positions can be squatted and every position number would be a lie. Must land before 2.2. | M |
| 2.2 v2.237 | Show position, and the "ask a friend to skip the queue" card | A number that moves is the most shareable thing in this feature, and it converts queue pressure into demand for member invites. | M |
| 2.3 | Expiry reminders at 48h and 12h, and the expired screen | The 7-day clock already runs and already returns the place. Nobody is told, so invitations die quietly. | S |
| 2.4 | Admin list: paging, and funnel numbers | The page loads the entire queue and filters in the app. Fine at 0 entries, wrong at 1,000. | S |
| 2.5 | BUG Bind a waiting-list invitation to the address it was sent to | Decided 2026-09-15: tie it to the email. Today the code is not bound, so anyone holding
the string can redeem it β and when someone else does, the person who waited is stranded forever:
their row is never marked accepted, ReclaimExpired skips it (the code now has a
used_contact), and the queue never draws them again. Binding fixes the stranding at
its source. Read the warning below before building this β binding has one collision that
must be handled in the same change. | M |
| Task | What | Why | Size |
|---|---|---|---|
| 3.1 | Settings columns, daily counter, atomic slot claim | The foundation. Ordering matters: capacity is checked before the code is claimed, so a full door never burns a code. | L |
| 3.2 | Admin capacity controls and today's numbers | A limit you cannot see or change during the day is not a control. | M |
| 3.3 | "HOOP is full for today" screen + remind me tomorrow | Needs no account β a local notification. The refusal has to be worth something to the person refused. | M |
| 3.4 | Audit trail for settings changes | The switch governing every new signup records when it changed, never who or what. | S |
| 3.5 | Alert when the gate falls open | The panel exists; it only helps someone who happens to look. | S |
| Task | What | Why | Size |
|---|---|---|---|
| 4.1 | Quota becomes "3 at a time" instead of "3 for life" | Leong's decision, 2026-09-15. Removes the 132-person growth wall and enables the "your invite was used" moment. | M |
| 4.2 | Fix the minting quota race | Check and insert are two statements. Two fast taps get 4 codes from a quota of 3. | S |
| 4.3 | Record who issued the code on all three sources, and add a
source column |
Leong's rule, 2026-09-15. Today only a member's code names a person; batch mints and
waiting-list invitations both store NULL β and NULL is simultaneously
being used to mean "official", so the two cannot be filled in without the
source column landing in the same change. The admin's id is already available at
both sites and is currently thrown away. The same column fixes account deletion silently
promoting a member's codes to official ones. | M |
| 4.4 | Fix the stats labels | "Codes issued" counts all three sources as one number and two labels describe something other than what they count. | S |
| 4.5 | Hide My Email: match the waiting-list row | Apple/Google private relay stores apple:<sub> with no "@", so that person
stays queued forever despite being inside. | S |
alice@gmail.com, gets invited, then signs up by tapping
"Continue with Apple". The contact we store is not that address: it is Apple's relay address,
or apple:<sub> when Apple withholds the email entirely
(auth/service.go). A naive binding check refuses that person their own invitation,
and it is the path a privacy-minded user is most likely to take.
The fix is not to weaken the binding β it is to fail usefully: "this invite was sent to aΒ·Β·Β·e@gmail.com β sign in with that address to use it." A dead end that names the way out is a different thing from a dead end. This is the same root as task 4.5, so build them together.
Later, deliberately not scheduled
Referral credit on the waiting list Β· a public capacity indicator (only worth building once the number actually moves β see the note below) Β· rejection sampling in code generation Β· wider dash and lookalike normalisation Β· a founding-member marker Β· per-member quota override.
Decisions on record
Made by Leong Sen Fong in the workroom on 2026-09-15, after asking for the same questions to be looked at through a marketing lens rather than an engineering one. Two of the three answers changed as a result, and the changed ones are marked.
| Question | Decision | Reasoning |
|---|---|---|
| Where does the invite code step sit in signup? | After sign in / sign up | Decided 2026-09-15. An earlier draft of this document proposed asking before the OTP, on the grounds that a wrong code destroyed the user's sign-in code and the 10-minute window was spent hunting for one. The destructive half of that was fixed on 2026-09-14 β invite mistakes now have their own counter and the OTP is preserved β so the reason for moving it had largely gone before the proposal was read. The residual cost is only the 10-minute window, and a fresh sign-in code can be requested. |
| How many times may a person type the code? | Once | Decided 2026-09-15. Entering it again after typing it wrongly is not a second entry β that one stays. What must never happen is being asked again for a code the app already had: after a restart, after leaving and returning to the screen, or after arriving through an invite link that carried the code in the first place. |
| Where does the person land after authenticating? | The page they were going to β never the invite code screen | Decided 2026-09-15. This does not happen today (a successful signup goes to Home), so the rule is here to stop 1.2 and 1.5 from introducing it: once a code has been accepted, no later screen may ask for it again. The positive half β actually landing on the intended page β is task 1.6, and it needs the replay queue to outlive a signup before it can work at all. |
| Can a waiting-list invitation be forwarded? | No β tied to the address it was sent to | Decided 2026-09-15. The queue's promise is to a person, not to whoever ends up holding the string. Untied, one forwarded code silently strands the person who waited. See the warning on the build plan: binding must not lock out someone signing in with Apple or Google. |
| Do we choose who to admit, the way Manus did? | No β first come, first served, email only | Decided 2026-09-15. Manus admitted people by reading what they said they wanted to use it for. We stay strictly FIFO and keep asking for nothing but an email. Worth knowing this closes the door quietly: choosing later would need information we never collected, and it cannot be gathered retrospectively from people already in the queue. |
| Do we record who invited each new member? | Yes, on all three sources | Decided 2026-09-15. Recording and displaying are separate, though: a member's code is a personal invitation and saying "Sarah invited you" is warm, whereas a batch mint or a waiting-list turn is HOOP letting you in, and naming the admin who pressed the button would be both odd and misleading. Record all three for attribution; display a name only when a real person chose to spend an invite on you. Task 4.3 records, task 1.4 displays. |
| Does an invite code get in when the day is full? | Yes β always honoured CHANGED | An invited friend is the best arrival there is, and turning them away embarrasses the member who invited them. Exposure stays bounded because it equals the number of live codes, every one of which exists because the admin allowed it. |
| When does "today" end? | Midnight, Malaysia time | UTC midnight is 8am local, so the counter would reset mid-morning and read as a bug. If the cap ever binds, "doors reopen at midnight" is an event worth having at midnight. |
| Is a member's 3 for life, or 3 at a time? | 3 at a time CHANGED | "For life" throttles the small group who actually evangelise and creates a hard 132-person wall. "At a time" keeps the felt scarcity β you still hold three and still choose who deserves them β and makes "your invite was used, here is another" possible. |
| Is the waiting list a panel or a page? | A page | Decided 2026-09-15 01:00. Shipped the same day as v2.204. |
| When does the gate open? | Not yet decided | The only item on this feature that nobody but the owner can answer. |
false, quota 3,
updated_at still 2026-08-05 08:15 (the moment the row was created), 4 codes in existence,
0 people on the waiting list. The gate has never been switched on. Opening it is not a config
change β it is a first production run of code that has never met a real user.Standing design rules this feature is built on
- A code must never be burned by a door that was closed for some other reason. This single rule dictates the order of every check in the signup path.
- Fail open, but visibly. If the gate cannot be read, signups are let through β a database wobble must not stop everyone joining. But a gate that falls open and a gate that was switched off look identical from outside, so the fall-open is counted and surfaced.
- One code, three sources. Resist the pull to make "types" of code; the differences belong in columns set at creation, not in the validation path.