# EventSnap — Feature Set & Capability Matrix

This document is the authoritative, code-cross-checked summary of what EventSnap can do today
and what is planned. For the design rationale of each area see [PROJECT.md](../PROJECT.md);
for journeys / step-by-step flows see [USER_JOURNEYS.md](USER_JOURNEYS.md).

Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ out of scope**

---

## 1. Capability matrix by role

| Capability                                              | Guest | Host  | Admin | Notes                                                                 |
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
| **Onboarding & sessions**                               |       |       |       |                                                                       |
| Join via shared event link / QR code                    |   ✓   |   ✓   |   ✓   | Name-only registration; server issues JWT + 4-digit PIN               |
| First-visit guided tour (6 steps)                       |   ✓   |   ✓   |   ✓   | Welcome, upload, hashtags, long-press, hell/dunkel, PIN (+ privacy-note pointer when one is set). Dismissed once, flag in `localStorage` |
| Persistent 30-day session                               |   ✓   |   ✓   |   ✓   | JWT in `localStorage`; refreshed on activity                          |
| Sign in on another device using name + PIN              |   ✓   |   ✓   |   ✓   | 3 wrong PINs → 15-min lockout                                         |
| "Ich habe bereits einen Account" link on the join page  |   ✓   |   ✓   |   ✓   | Small inline link → `/recover` (name + PIN)                           |
| View / copy own PIN any time ("My Account")             |   ✓   |   ✓   |   ✓   | Read from `localStorage`; never sent back from the server             |
| Log out ("Abmelden" / "Auf allen Geräten abmelden")     |   ✓   |   ✓   |   ✓   | Confirmation bottom-sheet; `DELETE /session` (this device) or `DELETE /sessions` (all devices). Nothing is deleted — account, PIN and uploads stay |
| Rename own display name                                 |   ◯   |   ◯   |   ◯   | Not yet wired; PIN-protected change                                   |
| Pick **data mode** (Saver / Original) in My Account     |   ✓   |   ✓   |   ✓   | Saver = compressed (default). Original = full files + data-usage warning. Applies to feed and diashow. Per-device, in `localStorage` |
| Read the **Datenschutzhinweis** (privacy note)          |   ✓   |   ✓   |   ✓   | Free text set by Admin during setup; rendered preformatted in My Account; first-visit guide briefly points to it |
| Admin password login (separate route)                   |       |       |   ✓   | 1-day token; lives in `sessionStorage`                                |
| Reset another user's PIN (one-time display modal)       |       |  ✓*   |   ✓   | Host: guests only. Admin: hosts + guests. New PIN shown once to the requester; user signs in with it; PIN is stored on their device on next login. \* Host cannot reset another Host's PIN |
|                                                         |       |       |       |                                                                       |
| **Posting**                                             |       |       |       |                                                                       |
| Pick photos/videos from device library (multi-select)   |   ✓   |   ✓   |   ✓   | Bottom-sheet source picker                                            |
| In-app camera capture (`getUserMedia`)                  |   ✓   |   ✓   |   ✓   | Front/back toggle, photo, `MediaRecorder` video                       |
| Caption + `#hashtag` extraction                         |   ✓   |   ✓   |   ✓   | Optional; hashtags parsed server-side                                 |
| Edit own caption / hashtags after upload                |   ✓   |   ✓   |   ✓   | `PATCH /api/v1/upload/{id}`                                           |
| Delete own upload                                       |   ✓   |   ✓   |   ✓   | Long-press on the card (or the kebab menu on desktop) → **Löschen** in the context sheet. Comment-style trash icon also available on each post elsewhere as it's added. |
| Delete own comment                                      |   ✓   |   ✓   |   ✓   | Trash icon in lightbox                                                |
| Background upload queue (survives reload)               |   ✓   |   ✓   |   ✓   | IndexedDB-persisted, sequential, retry                                |
| Rate-limit auto-resume banner                           |   ✓   |   ✓   |   ✓   | Countdown above bottom nav; resumes when window opens                 |
| Chunked / resumable upload for > 100 MB                 |   ◯   |   ◯   |   ◯   | Planned (v1.x)                                                        |
|                                                         |       |       |       |                                                                       |
| **Feed & social**                                       |       |       |       |                                                                       |
| Chronological list feed (full-width cards)              |   ✓   |   ✓   |   ✓   | Default view, infinite scroll                                         |
| 3-column grid feed with toggle                          |   ✓   |   ✓   |   ✓   | Video play badges, duration                                           |
| Search & autocomplete (uploader + hashtag)              |   ✓   |   ✓   |   ✓   | Grid view; derived in-memory, no extra API calls                      |
| Active filter chips (OR within type, AND across types)  |   ✓   |   ✓   |   ✓   | Multiple hashtags = OR; uploader + hashtag = AND                      |
| Fullscreen lightbox with prev/next navigation           |   ✓   |   ✓   |   ✓   | Navigates the *filtered* set. On-screen prev/next controls, ← / → arrow keys, and swipe left/right on touch |
| Like / unlike any post                                  |   ✓   |   ✓   |   ✓   | Single toggle; SSE `like-update`                                      |
| Read comments on any post                               |   ✓   |   ✓   |   ✓   |                                                                       |
| Add a comment                                           |   ✓   |   ✓   |   ✓   | Hashtags in comments also parsed                                      |
| Real-time feed via SSE                                  |   ✓   |   ✓   |   ✓   | `new-upload`, `new-comment`, `like-update`, `upload-processed`, `pin-reset`, `event-updated`, etc.  |
| Pause SSE when app is backgrounded                      |   ✓   |   ✓   |   ✓   | Page Visibility API; reconnect on foreground                          |
| Delta-fetch (`/feed/delta?since=`) on reconnect         |   ✓   |   ✓   |   ✓   | Runs on every visibility-restore; merges new + deleted uploads        |
| Individual file download button per post                |   ✓   |   ✓   |   ✓   | "Original anzeigen" in the post context sheet — streams via `/api/v1/upload/{id}/original` |
|                                                         |       |       |       |                                                                       |
| **Live diashow** (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) |  |  |  |                                                                       |
| Start fullscreen auto-advancing slideshow               |   ✓   |   ✓   |   ✓   | Two queues: live (SSE) drains first, shuffle as fallback. Crossfade + Ken Burns transitions; pluggable. Respects data mode.   |
|                                                         |       |       |       |                                                                       |
| **Moderation (Host)**                                   |       |       |       |                                                                       |
| List all event users                                    |       |   ✓   |   ✓   | Includes upload count, total bytes                                    |
| Ban / unban a user                                      |       |   ✓   |   ✓   | Ban **always hides** their existing uploads — no opt-out, the modal is a plain confirm. Unban restores them. Both invalidate and rebuild a released keepsake. See USER_JOURNEYS §9/§10 |
| Delete any upload                                       |       |   ✓   |   ✓   |                                                                       |
| Delete any comment                                      |       |   ✓   |   ✓   |                                                                       |
| Promote guest to Host                                   |       |   ✓   |   ✓   |                                                                       |
| Demote Host to guest                                    |       |       |   ✓   | **Admin only** — the backend 403s a Host demoting a peer Host (F1: it would launder past the ban / PIN-reset peer guards). Nobody may change their own role; Admins cannot be demoted at all. The button is hidden for non-admin Hosts |
| Reset a guest's PIN (Host) / any non-admin PIN (Admin)  |       |   ✓   |   ✓   | New PIN shown once in modal; Host shows/shares it with the guest      |
| Lock new uploads ("Event schließen")                    |       |   ✓   |   ✓   | Likes + comments + browsing remain open                               |
| Unlock new uploads                                      |       |   ✓   |   ✓   |                                                                       |
| Release gallery → trigger export generation             |       |   ✓   |   ✓   | Enqueues both ZIP and HTML-viewer jobs                                |
|                                                         |       |       |       |                                                                       |
| **Instance configuration (Admin)**                      |       |       |       |                                                                       |
| Live disk-usage / user / upload / banned stats          |       |       |   ✓   | Stats tab; queries `sysinfo`                                          |
| Edit per-file limits (image MB / video MB)              |       |       |   ✓   | Config tab; hot-reloadable from DB                                    |
| Edit per-endpoint rate limits                           |       |       |   ✓   | Upload/hour, feed/min, export/day                                     |
| Toggle **all** rate limits on/off                       |       |       |   ✓   | Master switch — when off, every limiter passes through                |
| Toggle **individual** rate limits on/off                |       |       |   ✓   | Per-endpoint switch (upload / feed / export / join)                   |
| Toggle quota enforcement on/off (master + per-area)     |       |       |   ✓   | Master switch + per-area (storage / upload count). When off, nothing is enforced |
| Edit quota tolerance                                    |       |       |   ✓   | Live `(free_disk × tolerance) / active_uploaders` formula enforced on upload |
| Edit estimated guest count                              |       |       |   ✓   |                                                                       |
| Edit compression-worker concurrency                     |       |       |   ✓   |                                                                       |
| Edit **Datenschutzhinweis** (privacy note, free text)   |       |       |   ✓   | Plain text, whitespace + newlines preserved, no HTML. SSE `event-updated` broadcasts edits live. |
| Inspect export job list & progress                      |       |       |   ✓   |                                                                       |
| Low-disk alert                                          |       |   ✓   |   ✓   | Red banner at the top of the Host dashboard when `disk_low`, naming free space and the keepsake's required bytes. Fails closed to "not low" on an unreadable mount |
| Event banner / cover image                              |       |       |   ◯   | DB column exists, no UI                                               |
|                                                         |       |       |       |                                                                       |
| **Quota visibility (staff-only)**                       |       |       |       |                                                                       |
| Show current per-user quota estimate                    |       |   ✓   |   ✓   | **Deliberately not shown to guests** — the storage widget in My Account and on the upload screen is gated on host/admin. Computed from the live formula; also hidden when quota enforcement is toggled off |
|                                                         |       |       |       |                                                                       |
| **Export**                                              |       |       |       |                                                                       |
| Wait at locked export page until released               |   ✓   |   ✓   |   ✓   | Friendly "not yet available" copy                                     |
| Download `Gallery.zip` (full-quality originals)         |   ✓   |   ✓   |   ✓   | Streamed via `async-zip`; `Photos/` + `Videos/` folders               |
| Download `Memories.zip` (offline HTML viewer)           |   ✓   |   ✓   |   ✓   | Self-contained SvelteKit-static app + `data.json` + `media/`          |
| HTML-export in-app guide modal before download          |   ✓   |   ✓   |   ✓   | Explains: unzip first, open `index.html`                              |
| Per-**user** export download rate limit (3 / day)       |   ✓   |   ✓   |   ✓   | Keyed on the user id, not the IP — a venue behind one NAT would otherwise share a 3/day budget across every guest |
|                                                         |       |       |       |                                                                       |
| **Banned guest** (subset)                               |       |       |       |                                                                       |
| Cannot upload, like, or comment                         |   ✗   |       |       | Returns HTTP 403                                                      |
| Can browse the feed                                     |   ✓   |       |       |                                                                       |
| Can still download the export once released             |   ✓   |       |       | Spec design choice                                                    |

---

## 2. Feature areas in detail

### 2.0 Touch-first interactions (mobile) vs. buttons (desktop)

EventSnap is mobile-first. Where it makes the UI cleaner, primary actions are reached via
**gestures** on touch devices, with conventional **buttons** mirrored on tablet/desktop:

- **Long-press on a post** → context bottom sheet ("Löschen", "Original anzeigen", report,
  share). On desktop the same actions are a kebab/⋯ menu in the card's corner.
- **Long-press on a comment** → context sheet with "Löschen" (own comments only) and
  "Kopieren".
- **Swipe left/right in the lightbox** → navigate the filtered set.
- **Swipe down on a bottom sheet** → dismiss.
- **Pull-to-refresh on the feed** → force a delta-fetch even when SSE is up.
- **Double-tap on a post** → like (Instagram-style), with a heart-burst animation. Tap the
  heart icon as the explicit alternative.

Design rule: **gestures should always have a discoverable button equivalent** somewhere on
the page, so the app stays usable on a stylus, mouse, or for users who don't know the
gesture vocabulary. Take inspiration from Instagram, WhatsApp, and Telegram for the
"feels right" baseline — long-press for context, swipe to dismiss, double-tap to react.

### 2.1 Authentication and identity

EventSnap's identity model is "**a name + a 4-digit PIN, scoped to one event**". There is no
email, no password, no account portal.

- **Joining.** On the join page the user types a display name. The server creates a `user`
  row, generates a 4-digit PIN, stores `bcrypt(pin)`, signs a 30-day JWT, and returns the
  PIN in clear text **once** in the response. The client persists the JWT and the PIN to
  `localStorage`.
- **PIN visibility.** The PIN is shown to the user *prominently* once at registration, and
  remains visible in the My-Account page (read directly from `localStorage` — never sent
  back from the server).
- **Returning on the same device.** A valid JWT in `localStorage` → straight to the feed.
- **Returning on a new device.** Type the name on the join page → server detects the
  existing user → user is prompted for their PIN. `bcrypt.verify` → new JWT, fresh device
  is now bound to the same account.
- **Lockout.** 3 wrong PIN attempts → 15-minute lockout per user (`pin_locked_until` column,
  migration 006).
- **Name collisions.** Names are unique per event (case-insensitive, migration 007). If
  someone tries to join with a name already taken, the join page automatically presents the
  PIN-recovery form for that account ("Already taken — sign in instead, or pick another
  name"). The join page also surfaces an explicit **"Ich habe bereits einen Account"**
  link routing to `/recover` for users who already know they want to sign in.
- **PIN reset by Host / Admin.** If a guest loses their PIN and `localStorage` is
  gone everywhere, a Host (for guests) or Admin (for hosts and guests) can hit a
  **PIN zurücksetzen** action in the user list. A fresh PIN is generated server-side, its
  bcrypt stored, and the plaintext is shown **once** in a modal to the requesting
  operator. The operator shows / sends the new PIN to the user, who then signs in via
  `/recover` — the PIN is persisted to `localStorage` on that device on a successful
  recovery, exactly like a brand-new join. Host cannot reset another Host's PIN; only
  Admins can.
- **Roles.** `guest` (default), `host`, `admin`. The Admin role is seeded from the
  `ADMIN_PASSWORD_HASH` env var; admins log in at `/admin/login` with a password (separate
  JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by a host or an admin.
  **Only an Admin may change a Host's role** — a plain Host may promote guests but may not
  demote a peer Host, because demoting one would then let them ban or PIN-reset (→
  `/recover` takeover) that ex-peer, the guards keying off the target's *current* role (F1).
  Nobody may change their own role, so the event can't be locked out of moderation, and
  Admins are un-demotable and un-bannable. Enforced in `handlers::host::set_role`; the
  button is hidden for non-admin Hosts.

### 2.2 Posting pipeline

The upload pipeline is built for flaky mobile networks:

1. **Source picker** (bottom sheet from the FAB): camera or gallery.
2. **Preview screen** — staged files appear as thumbnails; user can remove individuals, add
   a caption (with `#hashtags`), and tap quick-tag chips derived from the caption.
3. **Submit** — the client immediately returns to the feed (optimistic UX). Files enter an
   IndexedDB-persisted queue.
4. **Queue worker** — runs sequentially (one upload at a time), per-file progress via XHR.
   Survives reloads and app backgrounding. A red badge on the FAB indicates active uploads.
5. **Server-side processing** — multipart received → MIME-sniffed via `infer` → size
   validated → original stored → compression worker (bounded by a `tokio::sync::Semaphore`)
   resizes to an 800-px preview (images via the `image` crate + `oxipng` for PNG) or
   extracts a frame at the 1-second mark (videos via `ffmpeg`). Status is tracked in the new
   `compression_status` column (migration 008).
6. **Real-time fan-out** — `new-upload` SSE first (no preview yet), then `upload-processed`
   when the preview/thumbnail is ready, so clients can swap a placeholder for the real image
   without re-fetching the feed.
7. **Rate-limit-aware client** — when the server returns HTTP 429 with `Retry-After`, the
   queue parks remaining items and shows an inline countdown banner; uploads resume
   automatically.

### 2.3 Feed

- **Two layouts** — chronological list (default) and 3-column grid. Toggle in the header.
- **List view** has no search; it's the consumption-focused mode (like an Instagram feed).
- **Grid view** has the search bar — autocomplete suggestions are computed in-memory from
  the loaded uploads, so typing never hits the server.
- **Filter chips** — multiple hashtags combine with OR; multiple uploaders combine with OR;
  hashtag + uploader combine with AND. Matches the redesign concept exactly.
- **Lightbox** — fullscreen view with embedded like/comment UI. Navigation across the
  *filtered* set is available three ways: on-screen prev/next controls, the ← / → arrow
  keys, and a left/right swipe on touch.
- **Real-time** — SSE delivers `new-upload`, `upload-processed`, `like-update`,
  `new-comment`, `upload-deleted`, `event-closed`/`event-opened`, `export-progress`,
  `export-available`. Client pauses SSE on `visibilitychange: hidden` and reopens on visible.

### 2.4 Host / Admin tooling

- **Host dashboard** — three collapsible sections: Stats, Event-Einstellungen,
  Nutzerverwaltung. The ban modal is a plain confirm: banning **always hides** the user's
  existing uploads, there is no keep-visible option. Promote/demote/unban/release are each
  behind a confirmation sheet that spells out the consequence, not one-tap.
- **Admin dashboard** — same user list plus four inner tabs (Stats, Config, Export,
  Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated
  guest count and the colour theme — all stored in the `config` table and read on
  each request, so changes take effect without a restart. Disk widget pulls from the
  `sysinfo` crate live. The Export tab mirrors the host dashboard's release controls:
  the release button is disabled once released, the live keepsake status (progress /
  ready / failure reason) is shown, and a rebuild is offered as the recovery path.

### 2.5 Data mode

Each device picks a **data mode** in My Account; the setting lives in `localStorage` so a
guest can be on Saver on their phone and Original on their laptop.

| Mode       | Default? | Feed loads...               | Lightbox / diashow loads... | Warning shown? |
|------------|:--------:|-----------------------------|------------------------------|:-------------:|
| Datensparer (Saver)  |  ✓ | preview (compressed)        | preview                      | no            |
| Original             |    | original                    | original                     | yes — "kann mobile Datennutzung erhöhen" once on enable |

Applies uniformly to the live app's feed/lightbox **and** the diashow. The viewer (offline
HTML export) is unaffected — it's already a snapshot of pre-bundled media variants.

### 2.6 Rate limits and quotas — toggleable

The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:

- **Master switch — all rate limits.** When off, every limiter middleware short-circuits to
  pass-through. Useful for testing or trusted internal events.
- **Per-endpoint switches.** Upload / feed / export / join each have their own toggle. The
  numeric input becomes informational while the toggle is off.
- **Master switch — quotas.** When off, no quota check ever runs.
- **Per-area quota switch.** Storage-bytes quota and upload-count quota can be disabled
  independently.

When a feature is toggled off, the dependent UI adapts: the "Speicher: X / Y" widget hides
itself when the storage quota is disabled. The quota estimate is computed from the same
formula the server uses (`(free_disk × tolerance) / max(active_uploaders, 1)`) and is
surfaced in My Account *and* on the upload preview screen — but **only to hosts and
admins**. Guests never see it: the number moves as other people upload and as disk frees
up, which reads as a broken or unfair limit to someone who can't see why.

### 2.7 Privacy note (Datenschutzhinweis)

Admin sets a free-text **Datenschutzhinweis** during instance setup (Admin Dashboard →
Config). It's stored as a single config key (plain text, whitespace and newlines
preserved, no HTML). Guests see it in their **My Account** page, rendered inside a
preformatted block — no parsing, no markdown, just exactly what the admin typed. The
first-visit onboarding guide gains a one-line nudge: *"Datenschutzhinweis findest du in
deinem Account."*

Rationale: many real events (in Germany especially) need a per-event privacy statement
without the operator wanting to ship a separate static page or rebuild the app.

### 2.8 Export

Two artifacts, both generated on demand after the host taps "Release gallery":

- **Gallery.zip** — full-quality originals only, structured into `Photos/` and `Videos/`,
  filenames `{date}_{time}_{username}_{id}.{ext}`, streamed via `async-zip` with no full
  archive in memory.
- **Memories.zip** — the offline HTML viewer. Pre-built SvelteKit-static app from
  [frontend/export-viewer/](../frontend/export-viewer/), bundled with a generated
  `data.json` snapshot and a `media/` folder of thumbnails + full-size variants. Open
  `index.html` in any browser — no server required, no internet required. List/grid views,
  lightbox, hashtag chips, like counts, comments — all visually matched to the live app.

The export page shows live progress (SSE) while jobs run, then becomes a download button
when complete.

---

### 2.9 Maintainability and extensibility

EventSnap is small enough to be a single-developer project; it should stay easy to extend.
A few principles to keep adding features cheap:

- **Diashow transitions are drop-in components.** Each animation implements a small
  interface and lives under `frontend/src/lib/diashow/transitions/`. Adding a transition is
  one file + one entry in a registry.
- **Feature toggles live in the `config` table.** Today's rate-limit and quota switches
  follow the same pattern any new opt-in feature would use — no redeploy to flip
  behaviour.
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
  diashow state — composable rather than copy-pasted into each route.
- **Migrations are append-only.** Never edit a shipped migration; always add a new pair.
- **Background jobs share one pipeline.** Export and compression already publish progress
  via the `export_job` row + SSE; future long-running work (analytics, archival) should
  plug into the same shape.

See [IDEAS.md](IDEAS.md) for a longer riff on these patterns.

## 3. Out of scope (intentionally not built)

These are explicit non-goals from [PROJECT.md §4](../PROJECT.md):

- Native iOS / Android apps
- Multiple simultaneous events (multi-tenancy)
- Email-based auth / password reset
- Push notifications
- User-to-user direct messaging
- Payment / monetisation
- CI/CD pipeline
- "Save to camera roll" automation on iOS/Android — guests download the ZIP and use their
  platform file manager

---

## 4. See also

- [USER_JOURNEYS.md](USER_JOURNEYS.md) — step-by-step flows for every supported scenario.
- [CONCEPT_MOBILE_UI.md](CONCEPT_MOBILE_UI.md) — design reference for the mobile layout.
- [CONCEPT_HTML_VIEWER.md](CONCEPT_HTML_VIEWER.md) — export-viewer design.
- [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md) — planned diashow design.
- [IDEAS.md](IDEAS.md) — speculative extensions (global diashow, reactions, multi-tenancy, ...).
- [PROJECT.md](../PROJECT.md) — full architectural blueprint and rationale.
- [TEST_GUIDE.md](../TEST_GUIDE.md) — manual smoke-test script for the main flows.
