Architecture — Frontend (shiftit-frontend)
Executive summary
A Rust → WebAssembly Single-Page App built with Leptos 0.8 (CSR mode) + Trunk + Tailwind v4. Three user surfaces (/admin/, /app/ employee, /f/{slug} customer) on a single SPA bundle, each with its own shell. UI primitives come from the rust-ui MIT-licensed component library copied into frontend/src/ui/ with attribution preserved. The entire app is statically served from nginx in production; there is no SSR.
The frontend is a pure REST client of the backend at /api/v1. Same-origin in production (nginx proxies the path); in dev, Trunk’s proxy forwards /api/* to 127.0.0.1:3010.
Technology stack
| Concern | Choice | Version | Notes |
|---|---|---|---|
Language |
Rust (nightly) |
edition 2024 |
Pinned to |
Compile target |
|
— |
Auto-installed by toolchain file |
Framework |
leptos |
0.8 |
CSR + nightly features |
Routing |
leptos_router |
0.8 |
Path syntax: |
Bundler / dev server |
trunk |
(via |
Hot-reload, WASM optimization ( |
CSS |
Tailwind |
v4.1.7 |
Pinned in |
HTTP |
gloo-net |
0.7 |
|
Local storage |
gloo-storage |
0.4 |
JSON-encoded values; key |
WASM bridge |
wasm-bindgen + wasm-bindgen-futures |
0.2 / 0.4 |
|
Browser API |
web-sys |
0.3 |
Notification, PushManager, ServiceWorker, etc. |
UI primitives |
rust-ui (copied to |
MIT |
Attribution at |
Variant utility |
tw_merge |
0.1 |
rust-ui dependency |
Lucide icons |
icons |
0.18 |
Available but not yet used in Sidebar (P2 follow-up) |
Enum string |
strum |
0.27 |
rust-ui dependency |
Architecture pattern: surface-segmented SPA
src/
├── main.rs # wasm-bindgen entry; mounts <App/>
├── lib.rs # Re-exports
├── app.rs # <App> — provides auth context + Router with all 16 routes
│
├── api/ # Cross-cutting backend client
│ ├── client.rs # api_get/post/patch/delete with JWT + /api/v1 base
│ └── types.rs # DTOs mirroring backend (frontend declares its own minimal subsets)
│
├── auth/ # Cross-cutting auth context + token storage
│ └── context.rs # provide_auth_context, get_token (gloo-storage)
│
├── ui/ # Cross-cutting UI primitives (rust-ui MIT) + our Sidebar
│
├── admin/ # Surface 1 — /admin/* (10 pages)
│ ├── shell.rs # AdminShell layout
│ ├── dashboard.rs, locations.rs, roles.rs, settings.rs
│ ├── employees/, events/, forms/, shifts/ # Each: list.rs + detail.rs (or builder/calendar)
│
├── employee/ # Surface 2 — /app/* (5 pages)
│ ├── shell.rs
│ ├── schedule.rs, available.rs, overtime.rs, notifications.rs
│
└── customer/ # Surface 3 — /f/:slug (single page)
├── form_page.rs, form_renderer.rs
└── fields/ # Per-field-type components
The three surfaces are co-located in one binary because they share the same auth context, API client, and UI primitives. Routing in app.rs uses <ParentRoute> to attach each shell to its sub-tree. Initial / redirects to /admin/dashboard.
Component layering
Layer 1 — Primitives (src/ui/)
20 rust-ui components + 1 hook + our hand-rolled sidebar.rs. See component-inventory-frontend.md for the full table. Three primitives (Dialog, Popover, DropdownMenu) are runtime-broken pending JS shims.
Data flow
Reads
let user = LocalResource::new(move || async move {
api_get::<User>(&format!("/users/{id}")).await
});
LocalResource is the wasm32-friendly variant — gloo-net::Request::send().await returns a !Send future, so Resource (which requires Send) wouldn’t work. The resource hydrates async; views suspend on .read() until ready.
Mutations
let create_role = Action::new_local(|payload: &CreateRoleRequest| {
let payload = payload.clone();
async move { api_post::<_, RoleDefinition>("/roles", &payload).await }
});
After a mutation, an Effect watches create_role.value() and bumps a version signal that’s part of the LocalResource's key, triggering refetch. Pattern lives in EmployeeDetail/ShiftDetail; should propagate.
Routing
Single <Routes> block in app.rs. 16 routes total:
| Path | Component | Notes |
|---|---|---|
|
redirect |
→ |
|
redirect (parent) |
→ |
|
|
|
|
|
|
|
|
Calendar widget is P2 work |
|
|
|
|
|
|
|
|
|
|
redirect |
→ |
|
|
|
|
|
Customer form, no shell |
Fallback: literal "404" text node — minimal but functional.
Build & dev
| Action | Command |
|---|---|
Dev server with hot-reload |
|
Production build |
|
Clippy |
|
Format |
|
The pre-commit config separates clippy hooks per crate so the frontend’s nightly + wasm32 requirements don’t leak into the backend’s stable build path.
PWA layer
-
frontend/public/manifest.json— manifest with placeholder icons (P2: real PWA icons) -
Service worker is intended but not wired up yet
-
Web Push registration is half-implemented in
employee/notifications.rs(browser permission works,subscribe({ applicationServerKey })flow stubbed pending VAPID key plumbing)
Known constraints
-
rust-ui Dialog/Popover/DropdownMenu need JS shims — they emit
<script>tags referencingwindow.ScrollLocketc. that we didn’t pull. P1 follow-up. -
Switchhas noon_checked_change— internal state only. Settings page works around by usingCheckbox. -
Sonneris markup-only — toast logic lives in upstream JS; pages useCalloutinstead. -
No real Zitadel OAuth wiring — pages show "Sign in required" placeholder. P0 follow-up #1.
-
Slot-picker for shift claiming —
available.rsstill passesshift_idas placeholder slot id. P0 follow-up #2. -
Refetch after mutation is patterned in newer pages but not all. Older admin pages need the
versionsignal pattern. P2 follow-up.
All tracked in docs/follow-ups.md.
Why nightly?
Specifically because rust-ui components use:
-
bind:value—let-binding-on-RHS— currently behind a Leptosnightlyfeature (features = ["nightly"]inCargo.toml). -
let-chains —
if let Some(x) = a && cond— stable-ish in edition 2024 but only on recent nightly.
Backend stays on stable Rust because none of these are needed there. The split toolchain is encoded in frontend/rust-toolchain.toml and respected by cargo from inside frontend/.