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 nightly-2026-04-25 in rust-toolchain.toml — required for bind:value (unstable Leptos feature) and let-chains

Compile target

wasm32-unknown-unknown

 — 

Auto-installed by toolchain file

Framework

leptos

0.8

CSR + nightly features

Routing

leptos_router

0.8

Path syntax: path!("admin/shifts/:id")

Bundler / dev server

trunk

(via cargo install)

Hot-reload, WASM optimization (data-wasm-opt="z")

CSS

Tailwind

v4.1.7

Pinned in Trunk.toml; v4-only syntax (rust-ui requirement)

HTTP

gloo-net

0.7

!Send futures on wasm32 — drives LocalResource / Action::new_local patterns

Local storage

gloo-storage

0.4

JSON-encoded values; key shiftit_token

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 src/ui/)

MIT

Attribution at frontend/src/ui/LICENSE.rust-ui

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.

Layer 2 — Surface shells

Each surface has a shell.rs that defines its layout (sidebar + header). The shell is a <ParentRoute view=…> so all children inherit it.

Layer 3 — Pages

Per-route components. Most read via LocalResource, mutate via Action::new_local, refetch via a version signal (the EmployeeDetail and ShiftDetail pattern). Older pages (Roles, Locations, FormList) don’t refetch — see follow-up.


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.

Auth

provide_auth_context puts a Resource<Option<MeResponse>> into Leptos context. Pages that need the current user (most of them, for role-gating) use_context::<AuthContext>(). get_token reads shiftit_token from local storage; the API client uses it on every request.


Routing

Single <Routes> block in app.rs. 16 routes total:

Path Component Notes

/

redirect

/admin/dashboard

/admin

redirect (parent)

/admin/dashboard

/admin/dashboard

Dashboard

/admin/events, /admin/events/:id

EventList, EventDetail

/admin/shifts, /admin/shifts/list, /admin/shifts/:id

ShiftCalendar (placeholder), ShiftList, ShiftDetail

Calendar widget is P2 work

/admin/employees, /admin/employees/:id

EmployeeList, EmployeeDetail

/admin/forms, /admin/forms/new, /admin/forms/:id, /admin/forms/:id/submissions

FormList, FormBuilder, FormSubmissions

/admin/locations, /admin/roles, /admin/settings

Locations, Roles, Settings

/app

redirect

/app/schedule

/app/schedule, /app/shifts, /app/overtime, /app/notifications

Schedule, AvailableShifts, Overtime, Notifications

/f/:slug

CustomerFormPage

Customer form, no shell

Fallback: literal "404" text node — minimal but functional.


Build & dev

Action Command

Dev server with hot-reload

cd frontend && trunk serve (port 8080, proxies /api/*:3010)

Production build

cd frontend && trunk build --releasefrontend/dist/

Clippy

cd frontend && cargo clippy -p shiftit-frontend --target wasm32-unknown-unknown --all-targets -- -D warnings

Format

cargo fmt --all -- --check (workspace-wide)

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

  1. rust-ui Dialog/Popover/DropdownMenu need JS shims — they emit <script> tags referencing window.ScrollLock etc. that we didn’t pull. P1 follow-up.

  2. Switch has no on_checked_change — internal state only. Settings page works around by using Checkbox.

  3. Sonner is markup-only — toast logic lives in upstream JS; pages use Callout instead.

  4. No real Zitadel OAuth wiring — pages show "Sign in required" placeholder. P0 follow-up #1.

  5. Slot-picker for shift claiming — available.rs still passes shift_id as placeholder slot id. P0 follow-up #2.

  6. Refetch after mutation is patterned in newer pages but not all. Older admin pages need the version signal 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 Leptos nightly feature (features = ["nightly"] in Cargo.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/.