Everything you need to run Potluck on your machine and change it. For how changes get reviewed and merged, see CONTRIBUTING.md.
| Tool | Version | Notes |
|---|---|---|
| Node.js | 22.18 or newer (.nvmrc: 22) |
engine-strict=true in .npmrc fails the install on an older Node; 22.18 adds the type stripping pnpm test:functions needs |
| pnpm | 9 (packageManager pins 9.15.9) |
corepack enable picks the pinned version |
| Docker | recent Docker Desktop or Engine | runs the local Supabase stack |
| Git | any recent | on Windows, see Windows notes |
The Supabase CLI comes in as a dev dependency, so pnpm db:start works without a global install. npx supabase <command> runs the same binary for commands without a script.
git clone https://github.com/Fermulate/potluck.git
cd potluck
pnpm install
pnpm db:start # Postgres, Auth, Storage, Realtime, Studio in Docker
cp .env.example .env.local # then paste the publishable key printed by db:start
pnpm dev # http://localhost:5173db:start applies every migration and loads supabase/seed.sql, so you can sign in straight away with a demo account.
The web app reads .env.local from the repo root (envDir in apps/web/vite.config.ts). Only VITE_* variables reach the browser, and everything in them is public.
| Variable | Local value | What it is |
|---|---|---|
VITE_SUPABASE_URL |
http://127.0.0.1:54321 |
Supabase API URL |
VITE_SUPABASE_PUBLISHABLE_KEY |
printed by pnpm db:start (npx supabase status) |
publishable (anon) key. RLS decides what it can do |
VITE_APP_URL |
http://localhost:5173 |
base URL for links the app builds (invites, redirects) and for the social preview tags in index.html |
VITE_VAPID_PUBLIC_KEY |
empty, or your own key | web push public key. Leave empty to turn push off locally |
VITE_DOCS_URL |
empty | user guide that help links open. Empty means the public guide at https://docs.potluck.fermulate.com |
pnpm build (a production build) stops with an error when VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY or VITE_APP_URL is missing, so a deploy can never ship pointing at a local Supabase or with a literal %VITE_APP_URL% in index.html. pnpm dev and the unit tests keep working without them.
.env and .env.* are git-ignored except .env.example. Server-side secrets never go in these files; see deployment.md.
supabase/config.toml drives the local stack. Ports:
| Service | URL |
|---|---|
| API (PostgREST, Auth, Storage) | http://127.0.0.1:54321 |
| Postgres | postgresql://postgres:postgres@127.0.0.1:54322/postgres |
| Studio | http://127.0.0.1:54323 |
| Email catcher (sign-up and magic link mail) | http://127.0.0.1:54324 |
Local auth differs from production on purpose: email confirmation is off, the minimum password length is 6, and MFA is off. The production values are under [remotes.production] in the same file.
Useful commands:
pnpm db:start # start the stack (no-op if it is already running)
npx supabase status # URLs and keys again
pnpm db:reset # drop and rebuild from migrations + seed
npx supabase stop # stop containers, keep dataThere is one stack per machine. project_id = "potluck" and the ports above are fixed, so every clone and every worktree (.claude/worktrees/* included) talks to the same containers and the same database.
pnpm db:resetdrops all data and applies the migrations of the checkout you run it from, for everyone. Run it only when nobody else (another worktree, another agent) is using the stack, and expectpnpm db:testandpnpm verify:dbto test whatever schema was reset last.- Do not
supabase stopa stack you did not start. - e2e runs create users and set today's EUR to USD rate in this database.
- To get a separate stack for one checkout, change
project_idin itssupabase/config.tomland move everyport,shadow_portandinspector_portvalue to a free block (for example 55321 and up), then pointVITE_SUPABASE_URLand the publishable key in that checkout's.env.localat it. Do not commit that change.
- Each worktree needs its own
pnpm installand a copy of.env.local(Claude Code copies the files listed in.worktreeinclude). .claude/worktrees/is ignored by git, ESLint and Prettier, so linting the main checkout never walks into a worktree.- Two checkouts can run e2e at once only on different ports:
E2E_PORT=4174 pnpm e2e.
Database hooks (notify on new notifications, the daily fx-sync) do nothing locally, because the Vault secrets project_url and edge_secret do not exist there. That keeps tests and seeding from making network calls.
To work on a function, serve it and call it yourself:
# supabase/functions/.env (git-ignored)
EDGE_SECRET=any-long-random-string
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:you@example.com
APP_URL=http://localhost:5173npx supabase functions serve --env-file supabase/functions/.env
curl -X POST http://127.0.0.1:54321/functions/v1/fx-sync \
-H "x-potluck-secret: any-long-random-string"Generate a VAPID key pair with npx web-push generate-vapid-keys. SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY are injected by the runtime.
To exercise the hooks end to end, create the two Vault secrets in your local database, with project_url set to an address the database container can reach (for example http://host.docker.internal:54321), and edge_secret equal to EDGE_SECRET.
supabase/seed.sql writes through the real RPCs, so balances, activity and notifications look like production. All dates are relative to now.
| Name | Role | |
|---|---|---|
ava@potluck.test |
Ava Chen | owns Hike crew |
ben@potluck.test |
Ben Ortiz | owns Roommates, admin of Hike crew |
cleo@potluck.test |
Cleo Park | admin of Roommates |
dev@potluck.test |
Dev Patel | admin of Dim sum club |
eli@potluck.test |
Eli Novak | member |
fay@potluck.test |
Fay Okafor | owns Dim sum club, 1:1 with Ava |
Password for all of them: potluck-demo. Open http://localhost:5173/join/hikecrew-demo to try the invite flow. The seed also has about 30 expenses across every split type (one in EUR), settlements, a polling hike with availability from five people and an open request, and a scheduled taco night with RSVPs.
For performance work, supabase/snippets/perf_volume.sql adds 40 more accounts (perf01..perf40@perf.potluck.test, same password) with 15 groups, 3,000 expenses, settlements and 40 events, and supabase/snippets/perf_hot_paths.sql prints EXPLAIN (ANALYZE, BUFFERS) for the hot read paths as one of them. Both are for the local database only; see dogfood/performance.md.
Run from the repo root. Turbo runs the per-package scripts in dependency order and caches what it can.
| Command | What it does |
|---|---|
pnpm dev |
Vite dev server for apps/web |
pnpm build |
production build of every package that has one |
pnpm typecheck |
tsc --noEmit in every workspace |
pnpm lint |
ESLint over the repo, zero warnings allowed |
pnpm test |
Vitest in every workspace |
pnpm test:coverage |
Vitest with the coverage thresholds CI enforces |
pnpm test:functions |
Node tests for the edge function helpers |
pnpm e2e |
Playwright journeys (see testing.md) |
pnpm verify |
every check of the CI check job |
pnpm verify:db |
every check of the CI db job |
pnpm format |
Prettier over the repo |
pnpm db:start |
start local Supabase |
pnpm db:reset |
rebuild the local database from migrations and seed |
pnpm db:types |
regenerate packages/api/src/database.types.ts |
pnpm db:test |
run the pgTAP tests in supabase/tests |
pnpm gen:routes |
regenerate apps/web/src/routeTree.gen.ts |
pnpm gen:schema |
regenerate supabase/schema/current.sql |
pnpm gen:map |
regenerate docs/agent-map.json |
pnpm gen:skills |
copy .agents/skills to .claude/skills |
pnpm gen / gen:db |
the generators without / with the database |
Filter to one package with pnpm --filter @potluck/core test, run one file with pnpm --filter @potluck/core exec vitest run src/currency/currency.test.ts, or run Vitest in watch mode inside a package with pnpm vitest.
Every generated file has a --check mode that pnpm verify or pnpm verify:db runs, so a stale one fails CI with the command to fix it.
-
Path length. pnpm's virtual store paths can pass the Windows limit, which shows up as
ERR_PACKAGE_IMPORT_NOT_DEFINEDfrom Vite or Vitest. The repo's.npmrcsetsvirtual-store-dir-max-length=50to prevent it. If you still hit it (an oldnode_modules, or a global config overriding it), deletenode_modulesand reinstall with the variable set:export npm_config_virtual_store_dir_max_length=50 pnpm installgit config --global core.longpaths trueavoids the same problem in Git. -
Line endings.
.gitattributesforces LF. If your editor writes CRLF, Prettier and ESLint will complain. -
Shell. The repo scripts are plain Node (
scripts/*.mjs), so they run the same from Git Bash, WSL or PowerShell. -
Non-interactive shells (CI, coding agents):
export CI=truestops pnpm and turbo from prompting. -
Docker. Docker Desktop with the WSL 2 backend runs the Supabase stack. Give it at least 4 GB of memory.
packages/api/src/database.types.ts is generated from the local database. After any migration:
pnpm db:reset # apply the migration from scratch
pnpm db:types # supabase gen types typescript --local, then prettierCommit the regenerated file in the same PR as the migration. Do not edit it by hand. pnpm gen:db regenerates it together with the schema snapshot and the agent map.
supabase/schema/current.sql is supabase db dump of the public and private schemas after every migration ran, plus the realtime publication and the cron jobs. Functions are redefined across migrations (settle_up has three versions), so this is the one place to read the live definition of a table, policy or function. pnpm gen:schema regenerates it; CI fails when it is stale. agent-map.json indexes it: each RPC's arguments, grants and error codes, and the newest migration that defines it.
npx supabase migration new <short_name>createssupabase/migrations/<timestamp>_<short_name>.sql. Never pick a timestamp by hand, and make sure it sorts after the newest migration onmain(rebase and rename if not): a latercreate or replacesilently wins over an earlier one, whatever the merge order.- Redefining a function: copy its live body from
supabase/schema/current.sql, not from the migration that first created it, and say so in the header ("Body as in X plus ..."). - Follow the existing patterns:
revoke all ... from anon, authenticated, then grant the exact columns clients need.alter table ... enable row level securityand policies for every new table inpublic. Use(select auth.uid())and the helpers inprivate. For membership checks on read policies writegroup_id = any (array(select private.my_group_ids())): it runs once per statement and, unlikein (select ...), can use thegroup_idindex.- Functions:
security defineronly when they must bypass RLS, alwaysset search_path = '', schema-qualify every name, raise errors asmessage = '<snake_case_code>', detail = '<English>', errcode = '<sqlstate>'withPT404for not found (neverP0002, which old files still show). - New
publicfunctions:revoke execute on function public.<name>(...) from public, anon;thengrant execute ... to authenticated, service_role;. Supabase's default privileges grant execute on new public functions toanon; onlyinvite_preview,email_unsubscribe,guest_link_previewandset_guest_availabilityare meant to be anon-callable. - Balance-moving RPCs share lock the group row (
perform 1 from public.groups g where g.id = gid for share) beforeprivate.require_memberand before any expense or payment row; bulk settles take itfor no key updateinstead (see the lock order in architecture). Writes into a group callprivate.require_unarchived(money) orprivate.require_group_open(everything else). One exception: small immutable SQL helpers evaluated per row (private.minor_units,private.convert_minor) use a SQL-standard body (return ...) and nosetclause, so the planner can inline them. Those bodies are bound when the function is created, so the caller's search path cannot change them.supabase db advisorsreports them asfunction_search_path_mutable; that warning is expected. - Helpers that clients must not call go in
privateand get an explicitgrant executeonly if a policy needs them. - Additive only. A breaking RPC change is a new
_v2function (ADR 8).
- If the change affects money or scheduling rules, change the matching function in
packages/coretoo and test both. - Give every new error code wording (see Adding strings) and add it to the error list in architecture.md.
- Update
supabase/tests/01_schema.test.sqlif you added a table, a public RPC, an anon-callable RPC, a realtime table, anapp_configkey or a cron job (its header lists what it pins), and bumpplan(N). pnpm db:reset,pnpm db:test,pnpm gen:db, thenpnpm verify:db.- Update architecture.md if you added a table, RPC, bucket, channel or job.
Never change the schema in the Supabase dashboard. Production is only changed by supabase db push from the deploy workflow.
Tests live in supabase/tests/*.test.sql and run in file name order. 000_setup.test.sql installs helpers in a tests schema:
| Helper | Does |
|---|---|
tests.create_user('alice') |
auth user alice@test.local, returns the id |
tests.uid('alice') |
that user's id |
tests.login('alice') / tests.login_anon() |
switch to the authenticated or anon role with JWT claims |
reset role |
back to the superuser |
tests.group_with(owner, members[], admins[], name, currency) |
builds a group directly, returns its id. Everything after owner is optional |
tests.remember('g', id) / tests.id('g') |
stash ids between statements |
tests.people('alice', 'bob') |
split payload [{user_id}, ...] |
A test file is one transaction that rolls back:
begin;
select plan(3);
select tests.create_user('owner');
select tests.create_user('stranger');
select tests.remember('g', tests.group_with('owner'));
select tests.login('stranger');
select is((select count(*)::int from public.groups), 0, 'non-members see no groups');
select throws_ok(
format($$ select public.create_invite(%L) $$, tests.id('g')),
'42501', 'not_a_member', 'non-members cannot invite'
);
select tests.login('owner');
select lives_ok(
format($$ select public.create_invite(%L) $$, tests.id('g')),
'the owner can invite'
);
select * from finish();
rollback;For every policy, test both sides: who can read or write, and who cannot. For every RPC, test the rejections, not only the happy path.
plan(N) is a hand count; finish() fails with "planned N but ran M" when it is off. Two files share the prefix 14_; a new file takes the next free number after the highest one (ls supabase/tests). Run one file with npx supabase test db supabase/tests/000_setup.test.sql supabase/tests/<file>.test.sql.
- Find the feature file in
packages/i18n/src/en/(groups.ts,money.ts,plans.ts, ...). Add the key there, never inen.ts. - Use
{name}placeholders and pass already formatted values (formatMoney,formatDate). For counts, add_oneand_other(and optionally_zero) siblings and look up the base key with{ count }. - No em or en dashes in copy. The voice notes are at the top of
en.tsand in design.md. - In code:
t('expenses.paidSummary', { name, amount }). A wrong key or a missing placeholder failspnpm typecheck.
Error codes from the database: errorMessage(error, scope) in apps/web/src/lib/errors.ts tries the screen's scope first (expenses.errors.<code> in en/money.ts, events.errors.<code> in en/plans.ts), then the auth codes, then errors.codes.<code> in en/shell.ts, then a generic message. Put the wording in the scope of the screen that shows it, or in errors.codes when several do. apps/web/src/lib/server-codes.test.ts fails when a code raised in any migration has no wording.
A new namespace needs one line in en.ts to add it to the dictionary.
ADR 18 has the decisions and backup-format.md the JSON format. Notes for working on it (kept out of the AGENTS.md files, which have a size limit):
- SQL.
import_expensesrepeats the checksprivate.save_expensemakes, as set queries (for speed), and sends every payment throughprivate.check_payment. A new expense rule goes in both places. While an import runs,potluck.importingholds the group id and triggers onactivityandnotificationsdrop the per row entries for that group: keep new per row side effects going through those tables, or check the setting yourself. - Core.
packages/core/src/portability/holds CSV reading and writing, Potluck's spreadsheet export (ledgerCsv) and its reader, the Splitwise reader,planImport(file rows toimport_expensesrows, with the rate each row gets) and the backup schema. The export's balance columns come fromexpenseEffect; keep the round trip tests passing.nearestRatepicks the same stored rate the server holds a typed rate against. - Web. Everything lives in
apps/web/src/features/export-import. Every spreadsheet goes throughgroupCsv/ coreledgerCsvwithledgerLabels(), files are saved withsaveFile, and import files are read byreadFile(a worker for large files), then planned in core. Seats are members, so a name in a file can be matched to one (labelled "not on Potluck yet"). - Strings. The copy is the
transfernamespace.transfer.csvnames the spreadsheet columns, which the import reads back in the current language and in English, so renaming one breaks reading older files in that language: translate it once and leave it.
Routes are files under apps/web/src/routes/, picked up by the TanStack Router Vite plugin, which regenerates routeTree.gen.ts while pnpm dev or pnpm build runs. Without Vite, pnpm gen:routes does the same (a new route fails pnpm typecheck until the tree is regenerated). Do not edit that file.
-
Signed-out pages must be added to the public paths in
apps/web/src/features/shell/use-auth-redirects.ts, or signing out on them redirects to/sign-in. -
Signed-in screens go under
routes/_app/. The_app.tsxlayout redirects to/sign-inwithout a session, to/welcomeuntil the account is onboarded, and renders the shell fromfeatures/shell/app-layout.tsx(side rail, bottom tabs, quick-add sheet). -
$paramsegments become params:routes/_app/groups/$groupId/balances.tsxis/groups/:groupId/balances. -
Files and folders starting with
-are ignored by the router (routes/-dev-kit/), so colocated helpers go there. -
Keep route files thin. Put screens and hooks in
apps/web/src/features/<feature>/and import them. -
Code splitting is automatic (
autoCodeSplitting: true). -
Route files are part of the first download, so keep their non-component parts light: no schema libraries (search params use
src/lib/search.ts) and no feature modules. Aloaderthat callsprefetch.<screen>()fromsrc/lib/prefetch.tsstarts the screen's queries while its code loads; it must not await them. -
/is prerendered at build time (prerender-landing.ts) and, for signed-out visitors, stays static apart from the demo. A change to the landing page shows up there automatically; anything on it must render without a browser (nowindowduring render).pnpm devdoes not prerender.
// apps/web/src/routes/_app/groups/$groupId/recurring.tsx
import { createFileRoute } from '@tanstack/react-router'
import { RecurringScreen } from '@/features/recurring/recurring-screen'
export const Route = createFileRoute('/_app/groups/$groupId/recurring')({
component: RecurringScreen,
})Deep links work in production because the Worker serves index.html for unknown paths.
Notes for changing particular screens in apps/web. The general rules are in apps/web/AGENTS.md.
- Categories: pick with
CategoryPicker(features/expenses/form/category-picker.tsx, top level chips plus a row of subcategories) and label withcategoryLabel(). A new expense's category follows coresuggestCategory(group history fromcategoryHistoryQueryOptions) until someone taps one. Subcategories are stored as their own ids; totals and charts roll them up with coreparentCategory(). - Groups (ADR 20): a deleted group is gone from every query for 30 days; "Recently deleted" (
features/groups/recently-deleted.tsx) readsmy_deleted_groups, and its old address shows the deleted page with Restore (MissingGroup). Covers render throughGroupCover/coverSrc()(public URL, works signed out). The Notes tab (notes-page.tsx) saves with a base version and subscribes togroup_whiteboards, refetching throughuseRealtime'sonSubscribedonce the channel joins (an edit saved while it was joining sends no change event, and every channel asks Realtime to holdSUBSCRIBEDuntil Postgres changes flow withpostgres_changes_options.wait; the plan page'suseEventRealtimedoes the same for paints and guest answers); keep that pattern for any other shared text. Long names with no spaces usewrap-anywhere, notbreak-words, so they cannot widen a grid column or the phone page. - Receipts (
features/expenses/receipts, ADR 21): onlyocr.tsimportstesseract.js, lazily; a scan only fills the form (the merchant becomes the description, so the category suggestion follows it). Full rules in receipts. - Plan pages add guests from
/a/$code(guest links, signed out) tomemberswithguest: true. - Money:
fx_ratearrives as decimal text; pass it toconvert()unchanged. An expense can have several payers and be a refund (ADR 12): read who paid withpayersOf()and feed core withtoBalanceExpense()(features/expenses/expense-math.ts), neverpaid_byalone. What an expense means for the viewer (viewerLine) comes from coreexpenseEffectin the group currency, so a row can never disagree with the balance. - Repeating expenses (
features/recurring/, ADR 14): a template opens in the expense form (ExpenseForm mode="recurring", route/recurring/$recurringId) throughtemplateAsExpense(), so payers, adjustments and items work as for an expense. Skip, pause and stop live inuseTemplateActions; the expense page showsRepeatCard. - Language:
src/lib/language.tsstarts the app in the remembered (or browser) language before the first render; the root keys its outlet by language, so a switch redraws every screen. The profile'slocalewins once it loads (features/account/use-profile-language.ts). Amounts and dates follow it: give components that take alocaleintlLocale(), neverDEFAULT_LOCALE(landing only). - Two-step sign in: signed-in routes check
needsSecondFactor(session)(src/lib/auth.ts) and send the person to/two-factor; the database refuses that session anyway (ADR 13). Sign in and security sections live insrc/features/account/, mounted on Me with one line each. - Friends (
src/features/friends): a friend page row shows what the expense left between the two people (coreexpenseBetween), not the viewer's whole lead; the standing per group comes fromfriend_balances. "Add expense" is off with a reason when the viewer blocked the friend (my_blocks) orcan_split_withsays no. The menu exports the one-friend spreadsheet (useAccountExports().friend, the same file as on Me). - Naming groups: a one-on-one tab (
is_direct) is stored as "Direct"; name any group on screen or in a file withgroupTitle(features/expenses/format.ts, coregroupDisplayName), and inside a notification sentence withnotificationGroupName. Balance rows that put a name before "is owed" / "owes" usenetLabel(features/balances/net-label.ts) so the viewer reads "You are owed", never "You is owed". - Payments live in
src/features/settle: the settle form (PaymentForm, also used to edit at/payments/$paymentId/edit), the payment page/payments/$paymentId(restore, comments throughCommentThreadfromfeatures/expenses/comments.tsx), pay links, reminders and "Settle everything". Cross-group settle is on the Friends page. Pay links never move money (ADR 15).