Files
whowhat/README.md
Kacper 9c39b33636
All checks were successful
Build and Push Docker Images / build-and-push (push) Successful in 3m10s
v1.1.0 Kalendarz i listy zakupów
Dodaje wspólny kalendarz wydarzeń grupy (zakres dat/godzin, cały dzień,
notatki, lokalizacja, uczestnicy z gospodarstwa + goście zewnętrzni)
oraz listy zakupów (wiele list, dodawanie/edycja/odznaczanie/usuwanie
produktów). Nowe tabele w schemacie SQLite są czysto addytywne
(CREATE TABLE IF NOT EXISTS), więc nie ruszają istniejących danych.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 23:26:00 +02:00

14 KiB
Raw Blame History

WhoWhat — shared expenses for groups, couples and roommates

A web app (PWA) for splitting expenses within a household — who paid what, who owes whom, with history, stats and settlements. Supports any number of people per household and any number of households per user. Mobile-first, installable on your phone, works partially offline. Available in Polish and English, with more languages easy to add.

Features

  • Dashboard — "who owes whom" tiles (automatically simplified to the minimum number of transfers for the whole group) with a "Settle up" button, a pie chart of expenses by category, and a month summary.
  • Add expense — large amount field, categories with icons, payer selection, three split modes: equally (among all members), exact amounts, or the full amount to one person.
  • History — expense list with filters (month/category/payer), edit and delete.
  • Stats — month-over-month expenses, comparison of household members' spending, category ranking.
  • Calendar — a shared household calendar for planning events together: pick a date range or a specific day/time (or mark it all-day), add notes and a location, and choose who's involved (just you, everyone, or specific members) plus any number of external guests (name only, no account needed).
  • Shopping lists — any number of named shopping lists per household; add/edit/delete items, check them off while shopping, rename or delete a whole list.
  • Multiple households — a user can belong to several households at once and switch between them (Settings → "Your households"); each household can have any number of members, who join with the same invite code.
  • Settings — switch and manage households (rename/change currency, remove a member, delete a household, invites), custom categories (rename/re-icon), light/dark/system theme, language switcher (Polish/English), email notifications, CSV export.
  • Account — email/password registration, login, rename, change password, password reminder/reset by email, account deletion.
  • PWA / offline — installable on your phone's home screen, cached views, expenses added offline are queued and sync automatically once the connection is back.

Tech stack

Layer Technology
Frontend React (Vite), react-router-dom, TanStack Query, Recharts, vite-plugin-pwa, Material Symbols (Google Fonts)
Backend Node.js + Express, better-sqlite3, JWT (jsonwebtoken), bcryptjs, nodemailer
Database SQLite (a file on a bind mount at ./sqlite)
Infrastructure Docker Compose, nginx (serves the frontend + proxies /api), optionally Traefik (TLS + domain routing)

Internationalization (i18n)

The UI ships in Polish and English out of the box, switchable at any time from Settings → Language (persisted to localStorage, and to the user's account once logged in, so transactional emails and CSV exports match their preference too). Backend API errors are returned as stable machine-readable codes (e.g. invalid_credentials) and translated client-side — the server never hardcodes user-facing language.

Frontend translations live in frontend/src/i18n/locales/<lang>/<namespace>.json, one JSON file per component/page, auto-discovered at build time (no registration step). To add a new language: copy the locales/en/ directory to locales/<code>/, translate every value, and add { code, label } to frontend/src/i18n/languages.js. Backend-rendered content (emails, default category names, CSV headers) lives in backend/src/i18n.js — add the new language code there too if you want those translated as well.

Project structure

whowhat/
├── compose.yaml               # the only file needed to run everything
├── .env                      # configuration (secrets, domain, SMTP) — do NOT commit
├── .env.example              # configuration template to copy
├── sqlite/                   # bind mount — app.db lives here (data persistence)
├── backend/
│   ├── Dockerfile
│   └── src/
│       ├── index.js              # Express app, router mounting
│       ├── i18n.js                # server-rendered translations (emails, CSV, default categories)
│       ├── db/                   # schema.sql + better-sqlite3 connection
│       ├── middleware/auth.js     # JWT verification
│       ├── routes/                # auth, households, categories, expenses, settlements, stats, events, shoppingLists
│       └── utils/                 # balance calculation, mailer (nodemailer), household helpers
└── frontend/
    ├── Dockerfile                 # multi-stage: build (node) -> serve (nginx)
    ├── nginx.conf                 # proxies /api -> backend:3000
    ├── vite.config.js             # PWA config (manifest, service worker)
    └── src/
        ├── i18n/                  # I18nContext, per-namespace locale JSON files
        ├── pages/                 # Dashboard, AddExpense, History, Stats, Settings, Calendar, ShoppingLists, ShoppingListDetail, Login, Register, ...
        ├── components/            # BottomNav, charts, forms, EventFormModal, ShoppingListItemRow, Icon, Switch, LanguageSwitcher, ...
        ├── api/                   # fetch client + React Query hooks
        ├── auth/                  # auth context (JWT in localStorage)
        ├── household/             # active household context (list + switching)
        ├── theme/                 # light/dark/system theme context
        └── offline/                # IndexedDB queue + sync on reconnect

Running it

Docker (with the Compose plugin) is the only requirement.

cp .env.example .env
# fill in .env (see below) — no need to edit compose.yaml
sudo docker compose pull
sudo docker compose up -d

The app will be available at http://localhost:8856 (and at your Traefik domain, if configured — see below).

Configuration (.env)

All deployment configuration lives in .envcompose.yaml doesn't need editing.

Variable Description Default
JWT_SECRET Secret used to sign login tokens. Generate with: openssl rand -hex 32 — (required)
FRONTEND_URL Public URL of the app, used in email links (e.g. password reset) https://example.com
DOMAIN Domain Traefik should expose the app on example.com
TRAEFIK_NETWORK Name of the existing external Docker network Traefik is attached to traefik_public
SMTP_HOST SMTP server address. Empty = email sending disabled (console log only) — (optional)
SMTP_PORT SMTP port 587
SMTP_SECURE true for immediate SSL/TLS (port 465), otherwise false (STARTTLS) false
SMTP_USER / SMTP_PASS SMTP login credentials
SMTP_FROM Sender address on outgoing emails SMTP_USER

Without SMTP configured the app works normally — "password reset" and "email notifications" simply don't send real emails (the backend logs to the console that sending was skipped).

Data / persistence

The SQLite database lives at ./sqlite/app.db on the host (a bind mount, not a named Docker volume) — easy to copy, back up, or inspect with the sqlite3 CLI without entering the container.

Deploying behind Traefik

The frontend service is attached to the external traefik_public network (name configurable via TRAEFIK_NETWORK) and carries Traefik labels (domain routing from .env, TLS via tls-resolver). Prerequisite: the traefik_public network must already exist on the host (normally created by the Traefik stack itself):

docker network create traefik_public   # only if it doesn't exist yet

The frontend's port 8856 is also published directly on the host — useful for local testing alongside access through Traefik.

Data model (SQLite)

  • users — accounts (email, password hash, email notification preference, UI language)
  • households — households (name, currency)
  • household_members — household membership (any number of people; a user can be in several households at once)
  • invites — household invite codes (valid 7 days, reusable — they don't expire after a single join)
  • password_resets — one-time password reset tokens (valid 1h)
  • categories — expense categories (name, Material Symbols icon, color)
  • expenses — expenses (amount, payer, category, date, split type)
  • expense_shares — the final split of an expense across household members (always sums to the expense amount regardless of split type)
  • settlements — settlement history ("Settle up")
  • events — calendar events (title, notes, location, all-day flag, start/end)
  • event_attendees — who's attending an event: either a user_id (household member) or a free-text guest_name for external guests
  • shopping_lists — named shopping lists per household
  • shopping_list_items — items on a list (name, quantity, checked state)

Per-person balance is computed as: (amount they paid) (sum of their expense shares) (net settlements). For the "who owes whom" display, balances are simplified with a greedy algorithm (backend/src/utils/balance.js: simplifyDebts) that produces the minimum number of transfers to settle everyone (instead of a separate debt between every pair).

New rows in users get a language column ('pl' or 'en', default 'pl'); on an existing database this is added automatically on startup via ALTER TABLE ... ADD COLUMN — no manual migration, and no existing rows are touched or deleted.

Multiple households — how it works

A user can belong to multiple households. Since every request for household-scoped resources (expenses, categories, balance, stats, settlements) needs to know which household it's about, the frontend sends an X-Household-Id: <active household id> header on every such request (set automatically by frontend/src/household/HouseholdContext.jsx whenever the active household is switched in Settings). The backend's requireHousehold middleware verifies the logged-in user is actually a member of the given household.

Removing the last member from a household automatically deletes the household itself (along with its entire expense history — cascading deletes via foreign keys). Deleting a user account that has shared financial history with others (expenses/shares/settlements) doesn't physically remove it from the database (that would break the history visible to the rest of the household) — instead the account is anonymized (name → "Deleted account", email replaced with a unique non-existent address, password invalidated). A fresh account with no history is deleted outright.

API (overview)

Every endpoint except /auth/register, /auth/login, /auth/forgot-password, /auth/reset-password and /health requires an Authorization: Bearer <token> header. Household/category/expense/settlement/stats endpoints additionally require X-Household-Id: <id>.

Error responses are { "error": "<code>" }, where <code> is a stable snake_case identifier (e.g. invalid_credentials, household_not_found) meant to be translated client-side — see frontend/src/i18n/locales/en/errors.json for the full list.

Group Endpoints
Auth POST /auth/register, /login, /change-password, /forgot-password, /reset-password, GET /auth/me, PUT /auth/me, PUT /auth/me/notifications, PUT /auth/me/language, DELETE /auth/me
Households GET /households (yours), GET/PUT/DELETE /households/:id, POST /households, POST /households/:id/invite, POST /households/join, DELETE /households/:id/members/:userId
Categories GET/POST/PUT/DELETE /categories[/:id]
Expenses GET/POST/PUT/DELETE /expenses[/:id] (filters: month, categoryId, payerId)
Settlements GET/POST /settlements (POST immediately settles all simplified transfers)
Stats GET /stats/balance, /summary, /monthly, /export.csv
Events GET/POST/PUT/DELETE /events[/:id]
Shopping lists GET/POST/PUT/DELETE /shopping-lists[/:id], POST/PUT/DELETE /shopping-lists/:id/items[/:itemId]

Offline mode (PWA)

A service worker (Workbox, via vite-plugin-pwa) caches the app shell and recently fetched GET data from the API (NetworkFirst strategy), so the app opens and shows data even without a connection. A new expense added offline is queued in IndexedDB (frontend/src/offline/) and sent automatically once the connection comes back (online event) — a banner then shows the number of pending entries.

Known limitations

  • Copying the invite code via navigator.clipboard requires a secure context (HTTPS or localhost) — over plain HTTP on a local network the browser may block it; the code is therefore always also available as a selectable text field (manual copy always works).
  • schema.sql uses CREATE TABLE IF NOT EXISTS — adding a new column to an existing table on an already-running database requires either a manual migration (ALTER TABLE) or startup logic like the one already in place for users.language (see backend/src/db/db.js); a fresh database gets the current schema immediately.
  • A household invite code doesn't expire after first use (intentionally — it lets you invite any number of people with the same code), only after time (7 days) or a manual regeneration in Settings.

Local development (without Docker)

Requires Node.js 20+.

cd backend && npm install && JWT_SECRET=dev DATABASE_PATH=./data/app.db npm start
cd frontend && npm install && npm run dev   # dev server on :5173, proxies /api -> :3000

License

MIT