CLAUDE.md¶
This file provides guidance to coding agents when working with code in this repository.
Project Overview¶
Shared Calendar PWA — a Django + HTMX progressive web app two people share. Everything is an Item: a thing one of them wants to do. Items start on the wishlist, get scheduled onto a date and time, and then show up on the calendar. Items can also arrive from outside the app via the PWA share target. Changes sync in real-time across devices via Server-Sent Events (django-eventstream). Push notifications are sent via pywebpush.
See CONTEXT.md for the domain glossary.
Tech Stack¶
- Backend: Django 6.0, SQLite, uvicorn (ASGI)
- Auth: django-allauth (email login, verification by code, password reset)
- Frontend: htmx 4 (pinned exactly; the SSE extension ships in core), bundled via Vite, vanilla CSS
- Real-time: django-eventstream (SSE) — stream URLs wired up in
shared_calendar/urls.py - Push notifications: pywebpush with VAPID keys
- Package management: uv (Python), npm (JS/Vite)
- Testing: pytest + pytest-django + factory_boy (backend), Playwright (E2E). Build people, Members and Items with
shared_calendar/tests/factories.py; the E2E seed uses them too
Commands¶
# Setup
uv sync # Install Python dependencies
npm install # Install JS dependencies
# Run dev servers (both needed simultaneously)
uv run uvicorn shared_calendar.asgi:application --reload --port 8090 --timeout-graceful-shutdown 3 # Django ASGI on :8090 (the timeout flag lets Ctrl+C work despite open SSE connections)
npm run dev # Vite dev server on :5173 (HMR)
# Tests
uv run pytest # All backend tests
uv run pytest shared_calendar/items/ # Tests for one app
uv run pytest -k test_share_save # Single test by name
npx playwright test # E2E tests — self-contained: boots its own Django server on :8091 with a disposable DB (e2e.sqlite3, recreated per run) and reuses/starts Vite on :5173; credentials come from .env.test (copy .env.test.example); the seed is two Members of one Circle and a Circle-less operator
# Build & deploy
npm run build # Vite build → dist/
uv run python manage.py collectstatic --noinput
uv run python manage.py migrate
Architecture¶
Django Apps¶
All apps live inside the shared_calendar/ project package:
shared_calendar.items— theItemmodel, the views for the calendar tab, the wishlist tab and share intake, andservices.py. Views return full pages or HTMX partials.
Share intake lives in its own module, items/share.py: a Share payload (the title /
text / url an external app hands over) in, an Item draft out. Both the confirm page and
the save endpoint read a payload through read_share_payload(), so the two verbs cannot
disagree about what a payload means. Share-target quirks belong there and nowhere else.
Never write an Item from a view. Every create/update/schedule/done/delete goes through items/services.py, which owns the Membership check, the storage invariants, and the SSE + push emit. Views parse the request, call an operation, and render. Every operation on an existing Item asks one question, _require_member(item, actor): any Member may reword, schedule, finish or delete any Item in their Circle, and created_by is provenance only (docs/adr/0006). Delete is soft: it sets deleted_at and leaves Status alone.
Read Items through Item.objects.for_member(user). Item.objects raises on any unscoped read, get_object_or_404(Item, …) included; for_member scopes to the user's Circle and hides Deleted Items. Reads may stay in views, since the queryset is the boundary. Another Circle's Item is a 404, never a 403. Item.unscoped is reserved for what crosses Circles on purpose: the expiry sweep, create_item, the admin and management commands (docs/adr/0008).
- shared_calendar.invites — Invite (#51): a Member offers their Circle's free seat to one address. invites/services.py is its only writer: invite() Replaces the Circle's Pending Invite and mails the link, accept_new() and accept_existing() verify, admit (accounts.services.admit) and seat the invitee in one transaction, then log them in. Only the token's hash is stored; Expired is computed from created_at. Settings' Invite sheet always answers the same (a Member's address is a silent no-op), limited by allauth's invite rate (per Circle). Every unusable link (/invite/<token>/) is one 404 page that never says why.
- shared_calendar.notifications — PushSubscription model, subscribe/unsubscribe JSON endpoints, send_push_for_item() service.
- shared_calendar.accounts — accounts.User, the person: the Email address is the login, first_name is their Name, and approval is Waiting, Approved or Rejected (never is_active). django-allauth owns login, logout and password reset; accounts/adapter.py is the app's main seam into it (SIGNUP_OPEN, every allauth mail through send_awaited, a verification mail that fails without losing the account, and the login stages). Its templates live in templates/account/ (allauth's name), its mail in templates/email/. The settings page is ours.
While SIGNUP_OPEN is on, a logged-out / is the signup form (accounts.views.front_door). Verification is by code, and comes before approval. Nobody gets a session until they're Approved: ApprovalStage (accounts/stages.py), appended after allauth's EmailVerificationStage in get_login_stages(), ends every other login on the waiting page, unless a live Invite to the person's verified address can seat them: then it Accepts it (invites.services.accept_on_login) and lets the login through, so an invitee who signed up at the front door joins without the link. test_approval_gate.py is what notices if allauth stops calling it. accounts/services.py holds approve() and reject(), the only writers of approval. approve founds the person's Circle, commits, then mails "You're in". The admin's User changelist is the queue, and its actions call those services. Verifying a Waiting person's first address mails ADMINS (accounts/signals.py).
Bot defence (#50): signup-triggered mail is capped at 20 per rolling 24h, counted in the DB by accounts/cap.py and charged in the adapter. At the cap, the person is Held (User.hold, written only by accounts/services.py) and nothing is sent. The admin's "Send verification" action (services.release()) lets their mail through outside the cap; a later login with room under it does too. accounts/disposable.py is_disposable() refuses throwaway addresses. The honeypot is allauth's own (ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD). SignupView is also mapped over allauth's /accounts/signup/, so its daily limit has no way around it. Settings explain why each daily rate limit gets an action of its own. Every refusal renders 429.html through handler429 in urls.py (allauth looks it up by name), whose one link goes to MARKETING_URL. tests/test_client_ip.py proves the limiter sees the client's address behind Caddy.
The model sits on Django's own auth_user table (ADR-0007). A database from before the swap needs manage.py prepare_user_swap before migrate, and CUTOVER_EMAILS for the data migrations (this one and the circles fold); deploy/rehearse-user-swap.sh rehearses both on a throwaway copy.
- shared_calendar.circles — the tenancy unit (ADR-0005). Circle (no name) and Membership (one per person, held by the database; the cap of two Members is a service rule, not a constraint). Every Item carries its Circle, stamped by create_item from the creator's Membership and never changed. circles/services.py answers who shares with whom: circle_of(user), and other_members(user), whose .first() the context processor injects as partner. Everything points at circles; circles points at nothing but the person.
Seating is circles/services.py too: has_free_seat() holds the cap of two, and seat() makes the Membership. The lock that makes the check safe is SQLite's write lock: DATABASES sets transaction_mode: IMMEDIATE, so every transaction.atomic() takes it at BEGIN and a second writer waits. select_for_update() alone is a no-op on SQLite. invites/tests/test_circle_lock.py proves it with real threads.
Ending a Membership is there too (#52). leave() and remove(), and accounts.services.delete_account(), re-read the Membership under the lock (membership_under_lock) and hand it to end_membership(), which deletes the row, and the Circle with its Items and Pending Invite when nobody is left. It returns a Departure, whose announce() runs after the commit: one log line, stream.refuse() to close the departed person's open stream, and the one send_notice. The Host is derived by host_of(), never stored. A signed-in person with no Circle is sent by circles.decorators.member_required (on every Items view) to the Circle-less page, /circle/new/, whose POST is found_circle(). Its views, and Leave's and Remove's, live in accounts/views.py with Settings, because the Leave dialog counts Items. Item.created_by is SET_NULL: a deleted person's Items read "a former Member".
circles.0002_the_fold is the cutover's second data migration: with users, it puts the CUTOVER_EMAILS people and every Item into one Circle, and deletes the dead items/events/wishlist event rows.
HTMX Pattern¶
Views serve HTML partials that HTMX swaps into the DOM. The standard flow for mutations:
- User action triggers
hx-get→ server returns a<dialog>partial → JS opens it - Form submits via
hx-post→ view saves, returns204 No Content+ anHX-Trigger: closeDialogheader. The list is not returned; SSE refreshes it. - Client-side JS in
frontend/main.jslistens forcloseDialogto close the modal - On validation failure the view re-renders the same sheet partial with
status=400. htmx 4 swaps every response except204and304, so the 400 partial swaps in with no extra handling.
Real-time Updates¶
Keep hx-sse:connect on an element of its own. On a connecting element hx-trigger says when to open the stream, not when to re-fetch — putting both on one element deadlocks the connection and kills real-time sync silently. The list and detail elements keep hx-get, hx-trigger="update from:body, stream-open from:body, stream-reset from:body" and their hx-vals.
Re-fetch on stream-open, not just update. htmx 4 closes the stream while the tab is hidden (pauseOnBackground, kept on deliberately) and reopens it on return. Whatever changed meanwhile is missed, and Last-Event-ID replay can't be trusted to fill the gap: django-eventstream's stream-open carries no id, so a page that hid before its first update reconnects with nothing to replay from. stream-open fires on every (re)connection, so listening for it brings the page current on wake. stream-reset (the replay window was trimmed away) means the same thing. tests/cross-client-sync.spec.ts covers this.
One channel per Circle, circle-<pk>, named only on the server. All of it lives in circles/stream.py. CircleChannelManager (wired in by EVENTSTREAM_CHANNELMANAGER_CLASS) derives the channel from request.user, never from the URL, query string or template. Its can_read_channel denies by default and reads the Membership from the table. The URL conf names no channels; without the manager, that would let a client pick its own. Storage stays on, so a backgrounded phone can replay what it missed.
The stream gate. /items/stream/ is circles.stream.stream, a wrapper in front of django-eventstream. It answers anonymous and Circle-less viewers with a plain 403, because past the gate the library would refuse with a 200 and a stream-error in the body, which looks like a quiet stream. The pages render the connecting element only under {% if request.user|may_stream %}, the same predicate the gate asks. On a 403 during a reconnect, frontend/main.js reloads once; a stream-error is only logged. Both the calendar and wishlist pages connect to /items/stream/. asgi.py is a plain get_asgi_application(); it does no SSE routing of its own.
Views never emit. Each service operation builds an Item event (items/events.py), ItemEvent.push(item, actor, ItemAction.…) or ItemEvent.quiet(item, actor), and _emit hands it to the installed emitter on commit. The production AdapterEmitter runs two adapters, each isolated from the other: the broadcast, circles.stream.broadcast(circle), and, for a pushed event, the fan-out, send_push_for_item(item, action, actor), which reaches the Circle's Members' devices through Membership, minus the actor's. Each adapter's failure is logged and swallowed without stopping the other. ItemAction is a closed set; an action with no push copy raises. Tests assert on events with shared_calendar/tests/events.py's record_item_events(), never by patching django_eventstream (the one exception is the adapter's own test in circles/tests/test_stream.py).
Don't override htmx's defaultTimeout for the stream. The 60s timeout does not apply to it: core arms the timer, and the SSE extension clears it on takeover. If a stream does look like it drops around the minute mark, suspect pauseOnBackground (a hidden tab), which reconnects without firing htmx:sse:error — not the timeout. Evidence and method: docs/research/htmx4-sse-timeout.md.
Outgoing Mail¶
All mail goes through shared_calendar/mail.py, a project-level module because accounts, circles and invites all send. send_awaited() raises MailNotSent for mail someone is waiting on; send_notice() logs and returns for mail nobody waits on. Its docstring has the rest.
Never send while a database transaction is open. Commit first, then send. On SQLite a send inside a transaction holds the one write lock for the whole SMTP round-trip.
EMAIL_BACKEND in the environment picks the backend by short name: console (the default, dev), locmem (pytest), filebased (the E2E server writes to e2e-mail/, which tests/mail.ts reads), smtp (AhaSend, production). With DEBUG off, settings refuse console and locmem, so nothing starts, not even migrate.
Static Files & Vite¶
Vite bundles frontend/main.js (htmx + htmx-ext-sse) into dist/. Django serves both static/ and dist/ via STATICFILES_DIRS. In dev mode, django-vite proxies to Vite's HMR server. Templates use {% vite_asset %} tags.
Deployment¶
Git push-based deploy to a VPS: deploy/post-receive runs uv sync, vite build and collectstatic, then stops the systemd service, migrates, and starts it. A failure after the stop leaves it stopped. A push runs the hook already in the bare repo, so a changed hook is installed on the server by hand first (README). Caddy reverse-proxies to uvicorn and serves static files.
Marketing site¶
A static page at joinsundown.app, on Cloudflare Pages, separate from the app at app.joinsundown.app. It lives in site/: plain HTML and CSS, no forms. It has two CSS-only demos: the hero (add, schedule, calendar) and, under "On every screen", installing it from Safari on iPhone. Its one script serves both: each Replay button rewinds the animations of the demo it sits in, and the install demo stays paused until it scrolls into view. Every link off the page goes to the app.
It loads the app's own static/css/tokens.css, so a colour change there reaches the site on the next build. The favicon is the app's static/icons/mark.svg; the marks drawn inline in the page carry their stroke colours as literals, as the design had them. Build and deploy by hand; nothing in deploy/post-receive touches it:
site/build.sh # site/* + tokens.css + mark.svg → site/dist/ (gitignored)
npx wrangler pages deploy site/dist # publish to Cloudflare Pages
Cloudflare Web Analytics belongs on the Pages project (a dashboard setting), not in the HTML.
Rules¶
Always plan before implementing. Ask for clarification when planning.
Agent skills¶
Issue tracker¶
Issues live as GitHub issues in avram-dames/shared-calendar, managed with the gh CLI. See docs/agents/issue-tracker.md.
Triage labels¶
The five canonical triage labels, used verbatim. See docs/agents/triage-labels.md.
Domain docs¶
Single-context: CONTEXT.md and docs/adr/ at the repo root. See docs/agents/domain.md.