Oracle3
Oracle3: 예측시장 거래 엔진·MCP 서버
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Split shared household bills by the hours each person actually stayed, with
appliance usage charged to whoever ran it. Replaces the spreadsheet.
Logs record what happened. Bills divide what it cost. The logs live on
the room screen and are the same whichever bill later reads them; a bill only
counts the entries that fall inside its own dates.
A room has any number of logs, and always at least one: Hours in the unit,
the built-in occupancy clock that every bill is weighted by. It cannot be
deleted. Added logs are whatever else is worth counting — the aircon, the washer.README 전문 보기 ↗Split shared household bills by the hours each person actually stayed, with appliance usage charged to whoever ran it. Replaces the spreadsheet.
A room is one household. Housemates sign in, join the room with a code, and clock in and out of the unit — and of the aircon, the washer, anything worth counting. A bill is then split from those logs, to the centavo: what only some of you used is charged to those people, and whatever is left is shared by days stayed.
Next.js 16 (App Router) · React 19 · TypeScript, strict · Tailwind CSS 4 with shadcn/ui · Supabase (Postgres, Auth, row-level security) · Vitest. Money is Philippine pesos (₱).
Jump to: Getting started · How it works · How a bill gets split · Rooms, accounts and privacy · Screens · Architecture · Design system · Tests · Deployment · Known limits · Roadmap · Launch checklist · Scaling
Status (12 October 2026): live, used by its owner's household, and not yet
announced anywhere. Google is the only sign-in. What is built, what the plan still
owes, the constraints and the order to do things in are in
docs/ROADMAP.md; what must be true before posting to dormitory
Facebook pages is docs/LAUNCH.md; what happens as users arrive, and
what to do about it, is docs/SCALING.md.
You need Node.js 20.9 or newer and a free Supabase project.
npm install
cp .env.example .env.local # then fill in the Supabase values, below
npm run dev
| Command | What it does |
|---|---|
npm run dev | Dev server on http://localhost:3000 |
npm run build | Production build. Type-checks; does not run lint |
npm start | Serve the production build |
npm test | Vitest — the calculation engine and the sign-in logic, proven |
npm run test:watch | Vitest in watch mode |
npm run typecheck | tsc --noEmit, strict |
npm run lint | ESLint |
In the Supabase SQL editor, run supabase/schema.sql once.
It is the only definition of the database shape — nothing is created through the
dashboard — so the file and the database cannot disagree. It assumes two project
settings chosen when the project was created: Automatically expose new tables
off and Enable automatic RLS on.
Then run supabase/verify-rls.sql. It makes two
throwaway accounts and proves the privacy rules hold (see
Who can see what); run it again after any change to a
policy. The files in supabase/migrations/ are for databases created before
those changes — a fresh schema.sql already contains them. Apply a new one
before pushing the code that calls it.
supabase/verify-checkpoint-clock.sql
does the same for the function that cuts a running clock at midnight (see
Logs): it runs a 52-hour clock through it on throwaway data and rolls
back if anything is off. Run it after applying
migrations/003_checkpoint_clock.sql.
Supabase → Authentication → Providers → Google, with a client ID and secret
from an OAuth client (type Web) in Google Cloud. Add Supabase's
/auth/v1/callback address as an authorized redirect URI there.
Then Authentication → URL Configuration. Set the Site URL, and add every origin
the app is served from — http://localhost:3000 and your production address —
to the Redirect URLs. This is the one to get right: when a redirect is not on
the list Supabase quietly falls back to the Site URL, so a sign-in started on
production can end up on localhost.
.env.local, from .env.example:
| Variable | |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | Your project's URL |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | Public by design. It only ever acts as the signed-in person's own identity; row-level security decides what that identity may read |
NEXT_PUBLIC_SITE_URL | Optional. The address invite links point at. Defaults to the live site |
A missing Supabase value fails loudly at startup rather than as a confusing 401 three screens later.
Logs record what happened. Bills divide what it cost. The logs live on the room screen and are the same whichever bill later reads them; a bill only counts the entries that fall inside its own dates.
A room has any number of logs, and always at least one: Hours in the unit, the built-in occupancy clock that every bill is weighted by. It cannot be deleted — the database allows exactly one per room. Added logs are whatever else is worth counting — the aircon, the washer.
Each log is fed one of two ways:
The counter on a card is a today counter: it starts each local day at zero, and a run still going at midnight is split between the two days by overlap. The entries behind it are never touched — only the window that is added up.
A clock left on past midnight is recorded a day at a time. Clock in at 7:10 PM on the 10th and out at 3:30 AM on the 11th, and the logs show two entries: 7:10 PM–11:59 PM on the 10th and 12:00 AM–3:30 AM on the 11th. The cut is made by the app, in your timezone: at midnight if the app is open, the next time it is opened if not, and always when you clock out. It moves no hours from one day to another and changes no bill — bills already count a run by how much of it falls inside their dates — it only changes how the run is written down. A day's entry stops one millisecond short of midnight so that it reads 11:59 PM; nobody is credited with that millisecond.
Logs are drag-sortable (pointer events, so it works on touch; arrow keys do the same job from the grip). The order is stored on the room, so it is the same for everyone.
An entry names a set of people, not an owner. When two housemates run the
aircon together, the session is charged to both and divides equally between them
— the same rule a shared appliance event has always used. One person's share of
an entry is quantity ÷ participants, and the shares of any entry always add
back to the whole of it. That is what keeps a bill reconciling once entries are
shared.
The built-in occupancy clock is the exception: it has no "charged to" at all. You cannot be in the unit on somebody else's behalf.
A running clock remembers who it is for, so a reload mid-run cannot quietly turn a shared run into a solo one.
Every entry is editable and deletable, and any log can take one typed in after the fact — a clock only records what somebody remembered to press. Deleting one asks first, in place; the hours simply stop counting and do not come back.
min and max do not block anywhere that matters: iPhone Safari
ignores them and Android shows a time greyed out but lets it be picked. A
disabled option in a list is refused everywhere.A date can only name a day the log will actually keep: nothing in the future,
nothing past the retention window. min/max steer the picker and
every save checks again, because a date typed straight in ignores them.
The same stretch cannot be logged twice, whether by a double-tap on Add entry, a retried request, or two housemates each adding the same night: a clock run with the same people (ticked in any order) over the same minutes as one already on the log is skipped. "The same" is what the screen shows: a live clock's 3:00:12 to 5:30:41 and a typed 3:00 to 5:30 both read 3:00 AM–5:30 AM, so they are one entry; a different minute, or a different set of people, is not. The add form says so before you save: a date already on the log reads Already on this log, is left out of Save N entries, and Save is disabled when every date is. Typed amounts are exempt — "4 cycles" twice in a day can honestly be two loads of laundry.
Two entries that were saved before this rule can still sit side by side; delete one. Bills already count overlapping use once (Overlapping use), but a person's Hours in the unit still adds their own entries up one by one.
Clock logs add entries through a calendar you can pick several dates from at once — clocking in is a per-day habit, so catching up is usually "I was here all of these days". Times default to the whole day (00:00–23:59).
Each picked date is frozen with the times and people current when it was picked. Changing the fields afterwards aims at the next date, not the ones already listed — otherwise there would be no way to record two days that ran differently. Little boxes under the calendar show what is about to be saved: date, time range, and (on a shared log) who it is charged to. The whole batch is one write, and every span is validated before any of it is stored.
The one exception is before the calendar is touched: the auto-selected today still follows the fields, because at that point the form is plainly just "one entry, today".
Manual logs keep the single-date form — they have no times to give a day a shape.
A card's toggle reads "3 entries today", and opens today's entries only, to match the counter above it. It filters by what contributes to today rather than what is stamped today, so a run from 10pm to 2am appears on both days.
Everything else lives on the logs history page: a tab per log, entries grouped by the day they happened, and the same editable row as on the card — so an entry is edited, deleted or added the same way wherever you find it. Two ways in: a Logs history button in the panel's header, and, under an opened card's entries, a See all logs link that lands on that log's own tab.
Two people who ran the aircon 2–4 pm and 3–5 pm did not use it for four hours between them — they used it for three: one hour each alone, and one hour together. A bill finds that after the fact, so nobody has to remember to tick each other's names whenever their times happen to coincide.
lib/tracking/overlap.ts cuts the timeline at every
point a clock span starts or ends. Whoever's span covers a slice shares that
slice equally — the same rule an entry logged for several people already uses —
and everyone's own non-overlapping hours stay with them. A gap between runs
contributes nothing. It is pure: the bill's window is passed in, never read from
the clock.
Typed entries have no times to compare, so they stay solo. A hand-entered figure for a person replaces what the log says for them, overlap included: they are taken out of every clocked entry before the rest is decomposed, so their number is never blended with somebody else's clock. This can lower what an appliance costs in total — the overlap is no longer charged twice — which is why the formula shown on a bill is worded as an explanation, not as a sum to re-add by hand.
Logs only go back 120 days. HISTORY_DAYS in
lib/tracking/elapsed.ts is the one place that number
lives: it bounds what the repository returns, what the date pickers accept, and
the note on the history page. It is deliberately longer than any billing period,
so a bill can always still reach the entries it covers.
Today this is a display rule. The Supabase repository filters older entries out as it reads them; nothing yet deletes the rows. (The original localStorage store really did drop them on its next write.) See Known limits.
A bill is electricity, water or something else. Only electricity can carry a rate and appliances. Electricity and water need their dates — days stayed weight the split, so the window has to exist — and "Others" may go without. If the collected amount should be a round number, the bill can round up to the peso first, and that rounded figure is what gets split.
The residual is defined as the leftover, never computed independently. That is what guarantees the column adds up to the bill exactly, every time, even though the appliance kWh figures are estimates.
Equal means equal. Steps 1–3 give every person the same figure: the total
divided and rounded once, like a spreadsheet's =total/n cell, instead of
₱101.24 / ₱101.25 / ₱101.25. That can leave a centavo unaccounted for inside
those steps, and it does not matter, because step 4 takes whatever they actually
sum to out of the bill — the grand total is unaffected. The two steps that have
nowhere to put a spare centavo — the occupancy split and the overflow scaling
below — use largest-remainder rounding instead, which makes the parts add up
exactly, with a deterministic tie-break.
Guard rails
Each appliance answers this with a two-option tick box:
Picking "track" does not choose a log for you — a silently chosen one would put a number on somebody's bill nobody asked for. Saving without one is refused.
Each option carries an info icon (a popover, info-popover.tsx)
that says how its cost is worked out: energy per day × electricity rate × the period's day
count for Equally (the fridge, which the form recommends it for), and energy per unit ×
units logged × electricity rate for Track based on logs. They are the engine's own
formulas (lib/billing/engine.ts, steps 1 and 2).
The energy fields carry info icons too (energy-guide-info.tsx),
under a small "Check your appliance's energy guide" line beneath each label (Field's hint),
each icon opening a sample of the paper that holds the figure, with that figure circled: a
refrigerator's energy guide for Appliance energy per day (the Energy Consumption line, in
kWh per 24 hours), and one air conditioner energy label, laid out like the Philippine DOE's, for
the converter's Monthly energy consumption and Hours a day. The label prints the monthly
kWh and, in small type, "based on 9-hour daily usage", so the hours field is the hours the
figure is based on, not how long you run the appliance. The DOE works its monthly figure out
over 30 days and the label never prints the 30, so the converter does not ask for it: it counts a
month as 30 days (lib/billing/label-energy.ts) and says so in the
monthly field's info dialog. It deliberately does not use the bill's dates: the bill's own days are
applied later, by the engine, to the per-day figure the converter stores. The samples are invented,
and leave out the DOE's seal, QR code and legal text
(sample-energy-guide.tsx,
sample-energy-label.tsx).
What the converter gives depends on what the charge counts. Around the clock ("Equally") and
per-day logs get a figure per day (monthly ÷ 30); per-hour logs, and "Track based on logs" before
a log is picked, get one per hour (monthly ÷ 30 ÷ the label's hours); per-cycle logs get no
converter, because a label gives energy per cycle itself. A figure the converter wrote is dropped if
you then pick a log that counts something else (0.57 kWh a "day" instead of 5.13 would bill nine
times too little); a figure you typed yourself is never touched
(convertibleUnit).
A bill reads its figures from the logs over its own dates. A hand-entered figure
overrides that for that bill only; the log itself is never rewritten, and
"Use the log" hands the row back. A blank means "count the log", not zero —
which is why a new bill weights itself correctly with nothing typed. A typed 0
means zero.
The electricity rate (₱/kWh) is the switch.
The field's label carries an info icon: tap it and a sample electricity bill opens
with the rate circled, so nobody has to guess which number on their own bill to type
(components/fairy/rate-info.tsx,
sample-bill.tsx).
A bill needs only a total and one member to produce a correct, fully reconciled split. Everything else is optional and only makes it more precise.
Accounts are Supabase Auth. Google is the live sign-in. Facebook and
email + password are built but switched off, by FACEBOOK_ENABLED and
EMAIL_PASSWORD_ENABLED in lib/auth/config.ts:
There is no sign-in page. Signed out, / is a splash — The fairies are doing the
math. — with a Clock me in button and, at the top right, a quiet Sign in.
Either opens the sign-in as a popup, Welcome back!: Google, plus Facebook and
email + password when their flags are on. A sign-in that comes back unhappy reopens
the popup on a short, calm message (lib/auth/problems.ts).
/login and /signup just redirect to /, query and all, so an old link still
lands somewhere.
Behind the line, hand-drawn diamond sparkles draw themselves in and out. Each is
one wobbly line traced round a four-pointed diamond
(lib/sparkle.ts): it is drawn on, held for a moment, rubbed out
the way the pen went, and then drawn somewhere else, at random, clear of the text.
They lead the page. They are chosen on the server, once per visit, so they are in
the page from the first paint and need no script to start. They set off one after
another over the first second, in a scattered order, each beginning at a different
corner of its diamond and some going the other way round; only then do the line
(at 1.0 s) and the button (1.3 s) ease in. For anyone who has asked their device for
less motion there is no waiting and no drawing: the sparkles simply sit there, whole.
Flip a flag and the path appears in that popup; nothing else needs rebuilding. A person's profile name comes from the provider (or the signup form) when the account is created, and is what housemates see when they create or join a room.
A member is a person in a room, which is not the same as an account. Each
room has its own member rows, each with its own name — you might be "Phoebe" in
one flat and "Ate Phoebe" in another — and members.user_id links a row to the
account that holds it.
A room's name and a person's name share one limit, 40 characters
(NAME_MAX in lib/forms/schemas.ts). The fields stop
taking characters at that point rather than complaining afterwards.
Which member is you is answered by that link
(repo.getMyMember(roomId)), never by comparing names. Renaming yourself, in the
header or in a room, cannot lose your clock, and two people called Phoebe cannot
be mixed up.
Within a room everyone is trusted: any member can rename or remove any member, and can rename or delete the room. That is how the spreadsheet worked, and a household is a small circle. Removing someone asks first, and says what will be erased along with them — their logged usage — before every bill re-splits without them.
A room has a six-character join code: letters and digits, with 0, O, 1 and I
left out because they get misread out loud. The People panel's Add button
offers the code and a whole invite message ready to paste, carrying a link,
…/?join=CODE.
lib/auth/join-intent.ts — only a real code ever
reaches the cookie or a redirect.)SITE_URL (lib/site.ts), never at
whatever address you happen to be browsing from, so one made on localhost still
works for a housemate.Every table has row-level security, and the rule is always the same: you can touch a row if you are a member of the room it belongs to.
SECURITY DEFINER functions in a private
schema. PostgREST only exposes public, so they are not an API endpoint, but
the policies can call them — and they have to be definers, because a policy on
members that queried members would recurse forever.anon has no grants at all. The browser only ever holds the publishable key.create_room, join_room) and the operations
that touch many rows (start_clock, stop_clock, checkpoint_clock,
remove_member) are database functions, so they happen completely or not at
all. The clock functions read Postgres's own clock, not the browser's.
checkpoint_clock lets the browser say where to cut a run (it knows the
timezone) but never how much time has passed: a cutoff outside the run is
ignored.supabase/verify-rls.sql proves it: person B cannot
read, write or delete anything in person A's room — and A can. Both halves
matter, because a rule that blocks everybody would pass the first on its own./privacy, /terms and
/data-deletion are public pages, reachable signed
out — Google's and Facebook's publishing flows ask for them (/terms is what
Google's consent screen links to; Meta's Terms field is optional). There is no delete-my-account button yet: deleting an account
is done by email, as /data-deletion spells out. Members can rename or remove
themselves from rooms first, and an account that created a room cannot be
deleted while that room exists (rooms.created_by is on delete restrict).
/ signed in: rooms ("/?join=CODE" opens the join dialog)
signed out: the splash; "Clock me in" or "Sign in" opens
the sign-in popup
/login, /signup no longer pages: they redirect to /
/auth/callback where Google sends people back; swaps the code
for a session
/auth/confirm landing for an email-confirmation link
/privacy, /terms, /data-deletion public
/rooms/[roomId] Your tracking (the logs) + Bills
/rooms/[roomId]/logs logs history: a tab per log
/rooms/[roomId]/settings name, join code, people, delete
/rooms/[roomId]/bills/[billId] the bill: your logs summary + the share table
Signed out, every room page sends you to /, the landing page.
The menu. A hamburger at the left of the header opens a drawer: Home (the rooms page); your name, which you tap to change the name your housemates see, with the account's own name in smaller type under it; Sign out; then a line and every room you are in, the one you are on marked. It reads your name and rooms when it opens, so a closed menu costs no requests. The header keeps your name and a sign-out icon for one tap, and a room's breadcrumb (Rooms › its name) stays above its title.
The room screen leads with Your tracking — your clocks and logs, the built-in one filled blush so it is distinguishable from the rest — then the list of bills. A bill in that list shows your hours and your share, and opens by being clicked (a › says so). The hours come from the same figure the bill's own summary uses, so the two can never quote different numbers for the same stay.
The bill screen is: the hero (name, amount, dates, and a ⚙ menu holding Edit and Delete), Your logs covered in this bill, then the share table. Appliances and other charges are managed inside the bill's own editor.
Your logs covered in this bill is one row per log the bill draws on, showing the amount, what it cost, and an ⓘ carrying the formula — hover on a mouse, tap on a phone, and a click pins it open so it survives the pointer moving away. Hours in the unit shows both days and hours, because days is what the split is weighted by and hours is what the clock recorded.
The share table is all money: one column per log, plus "Shared equally" and "Unlinked usage" when those exist, so the columns always add up to what somebody owes. Edit log entries on a summary row opens that log's history tab.
On a phone the table is wider than the screen, so it scrolls sideways inside its own
box and the rest of the page keeps its margins; the Person column stays pinned, so a
row never loses whose it is. (The page's grids use grid-cols-1 for this. A bare grid
sizes its column to its widest child, which stretched the whole page past the screen's
edges instead of letting the table scroll.)
A Share button above the table offers two ways out, and neither needs a dependency:
bill-print-sheet.tsx. The FairySplit
logo and a rose rule, the room, the bill's name and period, the total in a tinted
band, the table with a tick box per person (ticked for anyone marked paid), and a
footer with the date and the app's tagline. The sheet is rendered straight into
<body> and the print stylesheet just hides every other child of it, so no
on-screen layout can push it off the page. The page has no margin of its own (the
sheet pads itself), so the browser has nowhere to stamp its URL and date, and
the PDF's suggested file name is "Room - Bill - FairySplit". It is one A4 page for any
ordinary bill. The image and the PDF build their rows from the same
function (tableText in share-table.tsx), so they cannot disagree.A hand-rolled PDF was the obvious alternative and is the wrong one: the standard
PDF fonts have no glyph for ₱, so a peso table would come out mangled. The
browser's engine uses the real font. On a phone, where navigator.canShare
takes files, a third option sends the image straight to another app.
app/ routes. Pages gate access on the server; every
screen is a client component
auth/callback auth/confirm OAuth landing; email-link landing
privacy/ terms/ data-deletion/ public pages
rooms/[roomId]/… room, logs history, settings, bills
proxy.ts session refresh only (Next 16's Proxy)
lib/
billing/
money.ts THE ONLY place money is divided or kWh converted
engine.ts pure: plain objects in, plain objects out. No React, no clock.
occupancy.ts hours -> days, and the days a bill covers
from-bill.ts stored records -> engine input: the round-up, the bill's
date window, and the usage its logs supply
explain.ts pure: the line that says in words what a log cost somebody
label-energy.ts pure: an energy label's monthly kWh -> a figure per day or
per hour (a label's month is 30 days); never touches money
tracking/
elapsed.ts pure: turning a running clock into a number. `now` is passed
in, never read from the system. Also holds HISTORY_DAYS and
`midnightsBetween`, where a running clock gets cut.
overlap.ts pure: overlapping clock spans -> who shared each slice
span.ts pure: a date and two times -> a pair of instants; the end
has to come after the start, on the same day
time-of-day.ts pure: times as minutes, and which hours and minutes a time
field has to block
data/
types.ts the domain model
repository.ts the interface every screen reads and writes through
supabase-repository.ts the implementation the app uses (Postgres, behind RLS)
local-repository.ts the original localStorage implementation; kept as
the reference, not used by the app
index.ts exports the active implementation — one line to swap
hooks.ts useRepoQuery / useRepoAction, so components just await
change-event.ts, join-code.ts the refresh signal; the join-code alphabet
auth/
dal.ts getUser / requireUser — the one place that answers "who is asking?"
actions.ts server actions: sign in/up/out, Google, Facebook
config.ts which sign-in paths the UI offers
join-intent.ts carrying an invite through sign-in; the landing path
problems.ts what to say when a sign-in comes back unhappy
sign-in-request.ts the header's "Sign in" asking the landing page's popup to open
schemas.ts display-name.ts
supabase/ browser client, server client, the env check
forms/
schemas.ts Zod: text, dates, join codes
numeric.ts Zod: strict string -> number, then peso -> centavos
site.ts where invite links point
nav.ts which room a URL belongs to (the menu marks it)
sparkle.ts the landing page's hand-drawn diamond sparkles, and where they go
sample-bill.ts, sample-energy-guide.ts, sample-energy-label.ts
the invented figures behind the help dialogs' sample documents
supabase/ schema.sql, verify-rls.sql, verify-checkpoint-clock.sql,
migrations/
components/fairy/ the app's own components
components/ui/ shadcn/ui primitives
Integer centavos everywhere. A rate is millicents — 1/1000 of a centavo,
so ₱14.86/kWh is 1_486_000. In the database these are bigint, never numeric
and never float. Formatting to ₱1,234.56 happens once, at render time. Nothing
outside lib/billing/money.ts divides money or converts kWh into money.
That conversion is easy to get wrong by a factor of ten and impossible to see in
review, so money.test.ts pins it: a ₱10/kWh fridge at 1 kWh a day for 30 days
must cost ₱300.
Stored as hours, never days. Days are derived at HOURS_PER_DAY = 24 — one
number behind the split rather than two that could disagree. The bill screen lets
you type either, and converts.
resolvedMemberDays() in lib/billing/from-bill.ts is the single function that
supplies member days to the engine: a hand-entered figure if there is one,
otherwise the occupancy clock counted over the bill's dates.
Every read and write goes through Repository, and every method is async.
lib/data/index.ts is the one line that decides which implementation the app
gets: SupabaseRepository now, LocalRepository (one JSON blob in localStorage)
before the app had accounts.
Components await through two hooks and nowhere else. useRepoQuery re-runs a
read whenever the store changes, and shows a skeleton only for a genuine load —
mounting, or switching rooms — because flipping back to one on a refresh would
unmount whatever somebody was typing into. useRepoAction wraps a write: it
tracks in-flight state and surfaces the error text, and a ref (not state) stops a
fast double-tap from firing it twice.
SupabaseRepository fires a fairysplit:changed event after every successful
write. There is no single choke point for a browser client's requests, so its
constructor returns a Proxy that wraps a fixed set of write methods — one place
to forget, instead of thirty-odd. That is why screens refresh after a change
without anybody remembering to ask.
Wall-clock time is read at the edges only. Postgres stamps a clock's start and
end; useNow in the tracking panel exists purely to make the seconds on screen
advance, and nothing is ever stored from it.
proxy.ts does one job: hand Supabase the request cookies, let it rotate an
expiring token, and copy what it wrote onto the response. Next's own docs warn
that Proxy should not be a session-management or authorization layer, so who may
see what is decided next to the data: getUser() in lib/auth/dal.ts (always
auth.getUser(), which is checked against the auth server — never the
cookie-only session, which the browser could have edited), requireUser(), which
the room pages call, and underneath both, row-level security.
| Table | Holds |
|---|---|
profiles | one row per account: its display name |
rooms | name, join code, who created it |
members | a person in a room: a per-room name, and the account that holds it (if any) |
trackers | a room's logs; Hours in the unit is the built-in one |
tracker_runs | clocks running right now, and who each will be charged to |
log_entries | finished amounts — a clock run or a typed number — charged to a set of people |
bills | a bill, with its appliances, usage and other charges kept as JSONB |
bill_member_hours, bill_log_amounts | hand-entered overrides; an absent row means "count the log" |
bill_paid | who has settled up |
A bill's own contents stay JSONB because they are edited as a unit, in one dialog, by one person; splitting them into tables would buy concurrency nobody uses. The per-member facts get their own tables, because two housemates ticking "paid" at the same moment really are different rows.
The UI follows fairysplit-ui-sample.html — a light
blush palette in Figtree, with three rules doing all the work:
rounded-* utility derives from
--radius, so squaring it is one line in globals.css.Every colour lives in app/globals.css; no component contains a hex code. Three
deliberate departures from the sample, all documented in that file:
--bw-outline is 2px, not 1.8px. Chrome floors border-width to whole
device pixels, so at DPR 1 a 1.8px stroke renders identically to the 1px
hairline — losing exactly the contrast the outline button exists for.--grey (#7E6A70) is 5.0:1 on white but only 4.4:1 on the
page ground. --grey-strong (#6E6065) is 5.3:1 on the ground and is used
wherever a caption is not on white. Contrast is swept with a scripted
getComputedStyle pass; the tracking panel, the logs summary and the share
table were last audited clean at WCAG AA. A full sweep of every route has not
been re-run since the screens were restructured.The app is light-only. The sample's dark tokens cover its own study page, not the product screens, so there is no dark palette to port.
npm test — 290 tests in 24 files, all quick. No component is rendered and
nothing talks to a real database; everything below the UI is proven directly.
The one thing it cannot reach is the SQL itself, which is what
supabase/verify-rls.sql and
supabase/verify-checkpoint-clock.sql
are for.
| File | Tests | Covers |
|---|---|---|
lib/billing/engine.test.ts | 32 | the ladder, the property run, scaling, the overflow guard |
lib/billing/from-bill.test.ts | 34 | the bill's date window, usage drawn from logs, overrides, shared entries, overlapping use |
lib/billing/money.test.ts | 20 | centavos, millicents, largest-remainder, equal shares |
lib/tracking/elapsed.test.ts | 50 | the daily reset, midnight-spanning runs, where a run is cut (including across a daylight-saving change), per-person shares, duplicate detection to the minute |
lib/tracking/overlap.test.ts | 11 | splitting overlapping clock spans |
lib/tracking/span.test.ts | 9 | a span has to end after it starts, on the same day: 9 AM to 3:59 AM is refused, a night past midnight is two entries, the whole day is fine |
lib/tracking/time-of-day.test.ts | 23 | which hours, minutes and halves of the day a time field blocks — To after 4:00 AM, From before 3:08 AM — and that the three lists reach exactly the allowed minutes |
lib/forms/schemas.test.ts | 8 | one 40-character limit for room names and display names: exactly 40 passes, 41 does not, measured after trimming |
lib/nav.test.ts | 2 | which room a URL belongs to, for the menu's highlight |
lib/data/supabase-repository.test.ts | 14 | "which member is me" is answered by account; a clock is cut before it is stopped, and still stops if the cut fails |
lib/data/local-repository.test.ts | 12 | a run across midnight becomes one entry per day: where each ends, who it is charged to, nothing lost; and the same stretch logged twice, by two people or with seconds on one copy, is one entry |
lib/auth/*.test.ts | 27 | an invite surviving sign-in (cleaning the code, the cookie, the callback), the landing path a failed sign-in comes back to, the notice each one gets, and the header's "Sign in" reaching the popup |
lib/sparkle.test.ts | 17 | the landing page's sparkles: each is one line round a four-pointed diamond with its points where they belong, kept out of the middle, and different from the last; and how a field of them opens — one start moment each, spread evenly over the first second, the first at once, from any corner and either way round |
Highlights:
0 means zero, not "go back to the log".The engine's suite was written before the engine and verified by mutation: reverting step 5 to the naive "zero the residual, leave the carve-outs" version fails three tests, including the property run. The identity, invite, share-table, midnight-cut, span, time-blocking, name-limit, menu-helper, sign-in-notice, sign-in-request and sparkle tests were each checked the same way — put the bug back, watch them fail.
The app is deployed on Vercel at
https://fairy-split-v2.vercel.app. Every push to main is built with
npm run build and, if it passes, goes live.
npm run build before pushing..env.local, set for
Production and Preview.NEXT_PUBLIC_SITE_URL, which defaults to the Vercel address.
Set it when the app moves to its own domain.iad1; the x-vercel-id header
shows it), while the database answers in about 0.07 s from the Philippines. Set the
Function Region to Singapore (sin1) in the project's settings.docs/LAUNCH.md and
docs/SCALING.md.docs/ROADMAP.md.Written down rather than papered over.
/data-deletion describes.docs/SCALING.md, risk 3).join_room accepts unlimited guesses; the
database has no text-length or size limits, only the app's.Four components are still on disk but no longer rendered anywhere:
my-logs-panel.tsx, appliances-panel.tsx, other-charges-panel.tsx and
member-days-input.tsx. They are kept in case those sections come back; nothing
imports them.
lib/sample-bill.test.ts | 3 | the mock electricity bill behind the rate field's info icon adds up: the meter's kWh, its charges and the rate it circles agree to the centavo |
lib/billing/label-energy.test.ts | 8 | the converter: a label's month is 30 days, 153.9 kWh is 5.13 a day or 0.57 an hour at 9 hours a day, kept to four decimals; and which figure it can give (per day, per hour, none for per-cycle) |
lib/billing/explain.test.ts | 5 | the line that explains what a log cost somebody: multiplied out when it is exactly that, the kWh of several appliances on one log added and named, and "shared" only when it really was |
lib/forms/numeric.test.ts | 4 | the rate stops at ₱100/kWh (a missing decimal point is refused) and converts to millicents; hours stayed stop at a year's worth |
lib/sample-energy-label.test.ts | 3 | the mock aircon label behind the converter's info icons adds up: its monthly kWh follows from its power, 9 hours, 30 days and load factor, and the converter turns it into the energy per hour it would store |
lib/site.test.ts | 4 | invite links never point at localhost |
components/fairy/share-table.test.ts | 4 | the share table's column builder, and its plain-text rows that the image and the PDF share: the columns, "Someone" for a person who left, totals that add up |