Skip to content

Frontend architecture

How the React SPA is put together: the tech stack, routing without a router, the Zustand stores and their persistence, the API client and transform layer, the Gantt and Kanban internals, the hooks catalog, the utilities worth knowing, and the What-If sandbox. It was moved verbatim out of the repo-root CLAUDE.md, which keeps the frontend conventions; the design tokens and shared components are in UI_GUIDELINES.md.

Code described: frontend/src/ — main.tsx, App.tsx, stores/, api/, components/gantt/, components/views/, hooks/, utils/, contexts/.

Frontend Architecture

Tech stack

React 18 + TypeScript 5.6, Vite 7, Vitest 4, Zustand 5, date-fns 4, CSS Modules. All data flows through the Zustand stores — there is no query library (TanStack Query was installed but unused and has been removed; don't re-add it) and no router package — react-router-dom was removed as unused; there is no route tree.

Routing without a router

  • src/main.tsx boots the theme and wires the API client (configureApiClient): What-If check + queue from whatIfStore, and onUnauthorized: expireSession. (The legacy rd_* storage migration was removed.)
  • src/App.tsx renders by branching: window.location.pathname exactly /admin or under /admin/ (isAdminPath) → AdminApp (a fully separate admin-portal app: AdminLoginScreen / AdminDashboard with tenants/organizations/admins/stats tabs); otherwise TenantApp: auth state decides LoginScreen vs the main app, with LoadingScreen during the initial load and LoadErrorScreen (Retry) if it failed, then WebSocketProvider → MainLayout + ModalContainer + ContextMenuContainer + ActivityFeed. TenantApp also owns the what-if-mode body class.
  • In-app navigation = viewStore.currentView (gantt | kanban | staff | equipment | crosssite | archived) switched by the sidebar; views are state, not URLs. setCurrentView refuses every view but gantt while What-If is active (see What-If Mode).
  • All administration is one Settings hub (components/settings/), a full-size shared Modal with a grouped section list. The sidebar AdminSection has a single Settings entry (admins + superusers). Sections come from the SETTINGS_SECTIONS registry, each with a minRole mirroring the backend (superusers see General — per-browser theme only, the panel renders instance-wide settings for admins — Bank holidays, Company events, Users, Skills, Equipment, Equipment types, Predefined phases; Sites, Absence types, Tags, SSO are admin-only). Add new admin UI as a registry entry + …Panel component (no <Modal> wrapper), not as a new sidebar item or standalone modal. The General panel reports unsaved changes to the hub (onDirtyChange) so switching section/closing asks first. Custom columns stay in the Gantt project panel. Self-service time off is My absences (MyAbsencesModal, user menu).
  • The tenant prefix /t/{slug} comes from the URL: getTenantPrefix() in client.ts prepends it to every /api/ call. Deep links work because FastAPI's catch-all (prod) or tenantSpaPlugin (dev) serve index.html for /t/* paths.

Zustand stores (src/stores/)

Store Persisted Purpose
appStore only _persistedSiteId Domain data (sites, projects, staff, equipment, blocks, vacations, absence types, holidays+date Sets, events + derived closureIndex (built in setCompanyEvents), users, skills, tags + tagGroups, instance settings), auth state, current site/user, critical-path state. Site switch clears undo history. Exports only non-array selectors (selectStaffSiteIds, selectAbsenceTypesById, selectCanManageResources) — array selectors were removed because they re-render forever under Zustand 5
viewStore yes View mode (week/month/quarter/year) + a single cellWidth (per-mode defaults applied on mode switch, then freely zoomed — widths are not remembered per mode), current view, current date (memory-only), resource tab, expansion Sets (persisted: projects/phases/subphases; session-only: staff/equipment/bank-holidays), level-based expand/collapse, scroll position, panel collapse flags, overview toggles (staff/equipment — mutually exclusive), tagFilters (project tag filter {tagIds, noneGroupIds} keyed "<tenant slug>:<site id>" via tagFilterKey() in utils/tags.ts — the storage key is per origin, so tenants must not share entries; persisted, stale ids pruned when applied), ganttGrouping (tag group id) + collapsedGanttSections (tag-<id>/none keys) for the Gantt "Group by", and ganttColorBy (tag group id) for the Gantt "Colour by" (independent of Group by), same tagFilterKey keying and rehydrate sanitising (sanitizeSiteGroupIds), not cleared on logout; Kanban board prefs kanbanProjectId, kanbanGrouping (none/project/phase/assignee/customColumn) + kanbanGroupingColumnId, kanbanMyTodoOnly (persisted)
uiStore no Active modal + editing context, Settings hub state (openSettings(section?, intent?) → activeModal: 'settingsHub' + settingsSection + settingsIntent view/add/edit; legacy modal keys settings/manageSites/siteManagement/site/userManagement/manageEquipment/predefinedPhases/ssoConfig/bankHoliday/customHoliday/companyEvent are aliases that open the hub at the matching section; openSettings, setActiveModal('settingsHub') and the alias keys are no-ops while What-If is active), tooltip, drag/resize state, drag indicator, dependency-linking state, phantom-sibling state, context menu, scroll/zoom triggers, resource drag, Kanban card dialog (openKanbanCardModal), modalContext.returnTo (reopen the previous modal on close, e.g. My absences → absence dialog), isLoading / loadingMessage, flashNotice + showFlash(message, tone) / dismissFlash() (a detached error with no form to sit in — context-menu deletes, refused dependency links, failed card moves — rendered bottom-right by layout/FlashNotice, auto-cleared after 6 s)
whatIfStore no What-If mode flag, structuredClone snapshot of projects, queued operations, whatIfApplyError {applied, total, message} + dismissApplyError, reset
undoStore no Undo/redo stacks of project-tree snapshots (max 50)
customColumnStore visibility only Columns, values map (key "{columnId}-{entityType}-{entityId}"), per-column filters (__empty__ sentinel for blanks), visibility
notificationStore no In-app notification inbox (server-owned; not persisted — the bell reloads on mount/focus), unreadCount, panelOpen; receive() dedupes live pushes by id. Derived due-soon/overdue reminders are not here — the bell merges them in from useDerivedDueNotifications
confirmStore no Queue of pending confirmAction() requests (first in shows); ConfirmDialog settles the head. Reset by resetAllStores(), which resolves every pending request false
adminStore no Admin portal: admin user + mustChangePassword, tenants, organizations, admin users, stats, active tab, selectedTenantId / selectedOrganizationId. Not re-exported from stores/index.ts and not touched by resetAllStores()

Persistence gotchas: Sets are serialized as arrays and restored in custom merge functions. viewStore's merge is field by field, so every field added to partialize must also be listed in merge, or it silently resets on every reload. currentDate and timelineScrollLeft are memory-only — the app always opens on today.

API client (src/api/client.ts)

  • apiRequest<T> + apiGet/Post/Put/Patch/Delete + downloadFile (Content-Disposition-aware).
  • Cookie auth (credentials: 'include') — no token headers.
  • Dev detection: when the page runs on port 3333, API and WS target :8485 directly (no Vite proxy).
  • FastAPI 422 validation errors are flattened to field: message strings; non-JSON responses raise with the first 200 chars logged (catches proxy HTML).
  • Session expiry: a 401 on a session-bound call fires the onUnauthorized hook → expireSession() (src/stores/index.ts): resetAllStores() (appStore except the persisted site id, What-If, undo, notifications, custom-column data, uiStore back to its initial state, and the transient body classes phantom-sibling-mode / resource-dragging / kanban-dragging / what-if-mode removed; viewStore and column visibility are kept, adminStore is left alone) and back to the login screen. useAuth.logout also calls resetAllStores(), so nothing carries over to the next person on the tab. Add new stores to resetAllStores().
  • What-If interception happens here (see What-If Mode).
  • Endpoint modules beyond the per-domain ones: health.ts (getAppVersion()), importChunks.ts (postInChunks(), used by sites.ts / vacations.ts), kanban.ts, notifications.ts. The last three are imported directly, not re-exported from api/index.ts.

Transform layer (src/api/endpoints/projects.ts)

All backend↔frontend field mapping is centralized. The frontend model is mostly snake_case like the API. Inbound, transformProject/Phase/Subphase/StaffAssignment/EquipmentAssignment map phase type→name and sort_order→order_index, reshape the tree (subphases→children, parent_type/parent_id→parent_phase_id/parent_subphase_id), camelCase the assignment arrays, default status to todo, and inject theme colors (getPhaseColor, getDepthColor). Outbound, only phase name→type is mapped. fetchAllProjects() fetches the list then each project detail in parallel; loadAllProjects() wraps it and is a no-op during What-If. Assignment CRUD routes differ by level (/assignments/{id} vs /phase-staff/{id} vs /subphase-staff/{id}) — the endpoint module picks the right one.

Gantt architecture (src/components/gantt/)

  • GanttContainer orchestrates: filters projects to the current site (non-archived), orders them with orderSiteProjects() (utils/storage.ts, shared with the Kanban board: per-site custom order from localStorage, default = confirmed-first, then name), applies the tag filter (useTagFilteredProjects), memoizes timeline cells/headers, and renders ProjectPanel (left tree) + resizer + Timeline (right) inside TimelineScrollProvider, with optional embedded staff/equipment overview panels below (canManageAnyProject: admin/superuser, or the PM of at least one project).
  • ProjectPanel/: header actions, right-aligned across the name column + custom columns (new/import/manage columns/visibility/tag filter — TagFilterDropdown from components/common, also mounted in the Kanban toolbar — then GroupByDropdown and ColorByDropdown, both thin wrappers over the shared TagGroupChoiceDropdown shell; the header dropdowns hang from the positioned .headerContent, right-aligned and width-capped) + SectionRow (Group-by section header) + rows ProjectRow → PhaseRow → SubphaseRow → AssignmentRow + custom-column cells; wrapped in ReorderProvider.
  • Timeline/: TimelineHeader + TimelineBody — grid cells (weekend/holiday/company-event backgrounds; week separators when weekends are hidden), TodayLine, per-project bars (ProjectBar/PhaseBar), DependencyLayer (arrows), phantom overlays, DragIndicator, ResourceDropOverlay. Handles scroll persist/restore (200 ms debounce), zoom re-centering on the visible center date, view-mode changes preserving the left-edge date, Ctrl+wheel zoom, and scroll-to-today/date triggers.
  • Group by tag group: GanttContainer turns useGanttGrouping() + the tag-filtered projects into one flat GanttRow[] (components/gantt/utils/projectSections.ts: buildProjectSections → buildGanttRows; first tag in group order wins, empty sections omitted, "No " last) and hands the same array to ProjectPanel (SectionRow) and TimelineBody (div.groupBand, a direct child of .rows); both panes fall back to a memoised flat list when rows is omitted. Section rows are --section-row-height (36 px) + 1 px border, content-box, = SECTION_ROW_TOTAL_HEIGHT (37) in calculateRowPositions(rows, …), keyed section-<key>; collapsed sections' projects get no position (no arrows, no drop target). Section class names must not contain row and carry no data-project-id/data-phase-id (the drag/resize hooks and DependencyLayer find rows by [class*="row"] / data attributes). Project drag-reorder is off while grouped (onProjectReorder undefined).
  • Scroll sync: TimelineScrollContext (horizontal, direct DOM with feedback-loop guard) across Gantt/Staff/Equipment timelines; useScrollSync (vertical) between tree panel and timeline body — wheel-driven in the same frame, echo-free, and padding the shorter pane so both reach the same scroll end (never a fixed scrollbar-height padding in CSS).
  • Drag pipeline (useDragAndDrop): mousedown or touchstart on a bar (touches become a mouse-like event via toMouseLikeEvent) → live DOM movement with cell snapping (week/month) and a dependency lag indicator → on drop, commitDragUpdate computes new dates from pixels, awaits confirmAction() for whole-project moves (cancel snaps the bar back), snapshots undo state, clones and mutates the project tree (cascading children + assignments), updates the store optimistically, then persists via batched PendingUpdates (savePendingUpdates). Any persist error → reload all projects + clear undo. The document mousemove/mouseup and touchmove/touchend/touchcancel listeners (here and in useResize) are attached at drag start and removed on mouse-up/touch-end and on unmount — never left on the document between drags. Touch: the bars hand the hooks a mouse-like event built by utils/touchEvents.ts (toMouseLikeEvent, an explicit object — never spread a SyntheticEvent, its methods are lost; its preventDefault is a no-op for the passive touchstart), and every touch start waits for a 5 px move first (armTouchGesture for project, assignment and resize-handle touches; the PhaseBar body has its own threshold) so a wobbly tap never commits a move; the non-passive touchmove listener calls preventDefault() only on interactive bars (React's root touchstart listener is passive, so only CSS touch-action: none on the interactive bars — the interactive class, set when a bar has drag/resize handlers, so read-only bars still scroll — plus that listener stop the timeline from scrolling), touchend is default-prevented so the browser sends no emulated click, and touchcancel restores the bar without saving (cancelDrag/cancelResize).
  • Auto-calculation semantics (gantt/utils/autoCalculation.ts): dependencies auto-align only at creation time; moving an item does not cascade to its dependents (users keep manual lead/lag control). Moves do (a) expand/contract parents to the union of their children and (b) auto-fit project dates to phases. Staff-assignment updates must include allocation (backend requirement).
  • Critical path (components/gantt/utils/criticalPath.ts): full CPM forward/backward pass, totalFloat <= 0 = critical; loaded via dynamic import from appStore.toggleCriticalPath to avoid a circular dependency.
  • Undo/redo (useUndoRedo + components/gantt/utils/diffProjects.ts): restore snapshot, diff old-vs-new date fields and dependency arrays into minimal PendingUpdates, persist; on failure reload + clear history.
  • Dependencies are stored per phase/subphase as JSON [{id, type, lag?, entity?: 'phase'|'subphase'}]. Phase and subphase ids are separate sequences, so new links always write entity; legacy entries without it resolve phase-first. Always go through components/gantt/utils/dependencies.ts: resolveDependencyPredecessor (lookup), dependencyMatches (find/delete a link, legacy-aware, missing type = FS), wouldCreateDependencyCycle (walks phases and subphases alike). Arrows, critical path and linking all use them.
  • Timeline headers: generated in components/gantt/utils/timeline.ts (generateTimelineCells / generateTimelineHeaders); week labels are ISO 8601 (date-fns getISOWeek / getISOWeekYear — 1–3 Jan 2027 is W53); the current month is highlighted.
  • Custom-column cells write one value with setCustomColumnValue, and a cascade to children with setCustomColumnValuesBatch (chunks of ≤ 500, the backend limit); on failure the touched cells are rolled back unless edited again since.

Kanban architecture (src/components/views/KanbanView/)

  • The board is a second rendering of the Gantt tree, not a separate dataset: KanbanView takes appStore.projects, orders them with orderSiteProjects(), filters with useTagFilteredProjects(), and derives cards with utils/kanbanCards.ts (card = leaf phase/subphase, milestones included; groupCards() builds the swimlanes for the viewStore.kanbanGrouping choice). KanbanBoard → KanbanColumn → KanbanCard; KanbanToolbar holds the project picker, grouping, tag filter and My-to-do toggle.
  • Comment counts come from api/endpoints/kanban.ts (getCommentCounts for one project, getSiteCommentCounts in "All projects" mode); the card dialog is modals/KanbanCardModal.tsx (assignees with AllocationSlider, comments with MentionTextarea, mention candidates from getMentionableUsers).
  • Dragging is contexts/KanbanDragContext.tsx — hand-rolled HTML5 drag events like ReorderContext and useResourceDragDrop, no DnD library. The move is optimistic; the server echoes {status, completion} and the client reconciles without a refetch.
  • Server writes broadcast as phase / subphase / staff_assignment / card_comment (see Real-Time Collaboration); a card status change from the Gantt slider is applied locally by statusAfterCompletionWrite because the rich broadcast excludes the sender.

Hooks catalog (src/hooks/)

Hook Purpose
useAuth Mount-time checkAuth(), login/logout (logout calls resetAllStores())
useDataLoader Parallel initial data load + site resolution + granular refreshers (refreshProjects, refreshSiteData, refreshCustomColumns, refreshStaff, refreshEquipmentBlocks, refreshAbsenceTypes) that components call after their own writes. WebSocketContext does not use it — it fetches on its own
useWebSocket / WebSocketContext See Real-Time Collaboration
useDragAndDrop Gantt bar dragging, mouse and touch (see drag pipeline); gated per project (canEditProjectById) and per own assignment (canEditAssignment)
useResize Bar edge resizing with date recalculation (mouse and touch; a touchcancel restores the bar without saving)
useResourceDragDrop Drag source only for staff/equipment from ResourcePanel and the Staff/Equipment views: permission check (project editors drag anything; everyone else only their own staff card), drag data, uiStore drag state and body class. It deliberately has no create path: Timeline/ResourceDropOverlay.tsx creates the 5-day default assignment (staff allocation defaults to max_capacity), checks the target's permission and blocks drops during What-If
useDependencyLinking Two-click dependency creation
usePhantomSibling Shift+click spawns a phantom sibling bar following the cursor (lag computed on placement)
useUndoRedo Undo/redo orchestration (see above)
useKeyboardShortcuts Esc (linking → phantom priority), Home (today), +/- zoom (±4 px, 12–120 px), Ctrl/Cmd+Z / Ctrl/Cmd+Y / Ctrl/Cmd+Shift+Z. Every shortcut is ignored while a modal is open (uiStore.activeModal) — the modal owns the keyboard, and the shared Modal owns Escape so guarded onCloses like the Settings hub's run. Also ignored while focus is in an input, textarea or contentEditable
useWhatIfBlocked {blocked, reason(action)} for create entry points during What-If (whatIfUnavailableMessage()) — use it for any new create button/dialog
useGanttGrouping Current site's Gantt "Group by" ({siteId, group, siteTags, collapsedKeys, setGroup, toggleSection, collapseAll, expandAll}); primitive selectors, stale group id → group: null without a write. Import from @/hooks/useGanttGrouping (tests mock @/hooks partially)
useGanttColorBy Current site's Gantt "Colour by" ({siteId, group, setGroup}), read by every ProjectTimeline row and the ColorByDropdown; same rules as useGanttGrouping (primitive selectors, stale id → null without a write, no-op without a site). Import from @/hooks/useGanttColorBy
useEscapeKey Escape-to-close for standalone dialogs that can't use the shared Modal (e.g. the admin-portal modals). Stands down while a confirmAction() request is pending, so Escape on the confirm never also closes the dialog that asked
useCtrlScrollZoom Ctrl+wheel zoom keeping the date under the cursor fixed
useScrollSync Bidirectional vertical scroll sync between two elements (used by GanttContainer and the Staff/Equipment/CrossSite/Archived views). Three behaviours: no echo (a write remembers the value and ignores the follower's matching scroll event, so the follower never snaps the source back), a non-passive wheel handler that moves both panes in the same frame (Ctrl+wheel and horizontal deltas are left alone), and equalised scroll range — it measures both panes' natural scrollHeight - clientHeight and pads the shorter one (ResizeObserver, window resize fallback), which replaced the fixed 8 px bottom padding that only matched a classic horizontal scrollbar
useWorkloadCalculation Per-cell staff workload for the Staff heatmap (allocations, vacations incl. recurring, visual states). Only absences whose type counts_as_unavailable (or untyped ones) zero capacity. An optional closure lookup (ClosureLookup, usually getClosureOn bound to the person's sites) zeroes capacity on site-closure days → vacation/conflict state, closureName/closureColor, tooltip Site closed: …
useEquipmentOverlaps / useEquipmentTodayStatus Equipment double-booking detection / today-status (blocked > booked > available) — two hooks in one file
useTagFilter useSiteTagFilter() (the current site's viewStore.tagFilters entry, shared by the Gantt, the Kanban board and TagFilterDropdown; ids of tags deleted or re-scoped away from the site are ignored on read and pruned on the next write) + useTagFilteredProjects(projects)
useHeaderDensity The one definition of the header's width tiers: full ≥ 1600 px, compact ≥ 1280, condensed ≥ 1024, minimal below (matchMedia). The Header sets it as data-density and moves controls into HeaderOverflowMenu by tier; header CSS must contain no pixel breakpoints
useDismissableOverlay The one dismiss behaviour for dropdowns, popovers and menus ({open, onClose, wrapperRef, triggerRef?} → {onBlur} for the wrapper): outside mousedown, Tab out, and Escape in the capture phase with stopPropagation + focus back on the trigger, so only the innermost overlay closes and the app-wide shortcuts never see the key. Every overlay uses it (TagFilterDropdown, TagGroupChoiceDropdown, header menus, NotificationBell, column visibility/filters, Staff/Equipment filters, DependencyPopup, ContextMenu). Never for a dialog — Modal must not close on an outside click. Both it and useEscapeKey stand down while a confirmAction() request is pending, so the confirm on top owns Escape and the overlay that asked stays open until the answer
useDerivedDueNotifications Due-soon (DUE_SOON_DAYS = 7) and overdue reminders for the current user's cards, derived from appStore.projects (no scheduler server-side; nothing fires while the app is closed). Negative ids, source: 'derived'; dismissals in localStorage (STORAGE_KEYS.KANBAN_DUE_DISMISSED) keyed by card + due date, so a rescheduled card notifies afresh. Only consumer: NotificationBell

Utilities worth knowing (src/utils/)

date.ts (~28 date helpers incl. business-day math, week labels, plus date-fns re-exports), storage.ts (typed storage wrappers, STORAGE_KEYS incl. KANBAN_DUE_DISMISSED, theme system setting data-theme on <html>, per-site project order + orderSiteProjects() / compareProjectsDefault() — the one ordering rule for the Gantt and the Kanban board), kanbanCards.ts (card derivation from the project tree, groupCards() / KanbanGrouping, dueStateOf(), cardKey(), and the status↔completion mirror of app/services/card_status.py guarded by cardStatusMirror.test.ts), mentions.ts (@[Name](id) tokens — mirror of app/services/mentions.py — plus the composer's offset-anchored mentions, which survive edits instead of being re-matched by name), colorSwatches.ts (EVENT_COLOR_SWATCHES / DEFAULT_EVENT_COLOR for events and absence types, SKILL_COLOR_SWATCHES), avatarColor.ts (avatarColorFor(id) — the one avatar palette for presence, activity toasts and Kanban assignees), projectLevels.ts (expansion levels shared by viewStore's level expand/collapse and the project row's +/- buttons), themeColors.ts (phase/depth colors from CSS vars), recurringVacation.ts ([R:0,2,4] pattern + absence-type helpers: getAbsenceOn() is the single "is this person on an absence / unavailable on this day" check (site closures are separate, see siteClosure.ts) — honours recurring patterns and counts_as_unavailable; guessAbsenceTypeId() for .ics imports), siteClosure.ts (buildClosureIndex() expands closure events into a day → events index once, in appStore.setCompanyEvents; getClosureOn(staffSiteIds, date, index) is the closure counterpart of getAbsenceOn() — used by the Staff heatmap, StaffView calcAvailability, ResourceCard and Gantt assignment-bar markers), permissions.ts (per-project gates, mirrors app/services/project_access.py — see Frontend Conventions), ics.ts (the one .ics parser/generator: multi-VEVENT, all-day exclusive DTEND, RRULE:FREQ=YEARLY expansion, CATEGORIES, Outlook BUSYSTATUS, UTF-16; buildICS() for every export), equipmentOverlap.ts, csvExport.ts (MS-Project-compatible CSV: outline levels, ID{FS|SS|FF|SF}±lag predecessors), xmlExport.ts (MS Project XML), subphaseUtils.ts (subphase tree helpers), persistFailure.ts (recoverFromPersistFailure — the failure protocol), reorder.ts (local phase/subphase reorder applied before the server call, so it shows in What-If too), localProjectTree.ts (clone-and-replace patch/remove helpers for phases, subphases, assignments — dialogs apply edits to the store with these so they show while What-If skips the reload), whatIf.ts (whatIfUnavailableMessage() for blocked creates), touchEvents.ts (getEventCoordinates/getPointerClientX/isTouchEvent/toMouseLikeEvent/armTouchGesture/NON_PASSIVE — the one touch→mouse-like mapping used by PhaseBar, ProjectBar, the assignment bars and the drag/resize hooks), uniqueById.ts (dedupe the one-row-per-equipment–site-pair GET /equipment rows in unfiltered views), tags.ts (pure tag helpers: tagsForSite, groupedTags, applyTagSelection (radio behaviour in single-select groups), projectPassesTagFilter (within a group OR, across groups AND, noneGroupIds = "no tag from that group", unresolvable ids ignored), projectBarColor(project, tagById, group) — the colour of the project's tag in the "Colour by" group, first in group order (sort_order, name — the tag Group by files it under), null for no group / no tag; always resolve colour/group through appStore.tags/tagGroups by id (indexTags), never from the inline project.tags[].color, which is stale during What-If; also normalizeSiteTagFilter, isTagFilterActive, violatesSingleSelect, barTextColor). Note: the Gantt utilities live in src/components/gantt/utils/, not here — autoCalculation.ts, completionStatus.ts (statusAfterCompletionWrite — the slider applies the Kanban status locally, since the rich broadcast excludes the sender), criticalPath.ts, dependencies.ts, diffProjects.ts, projectSections.ts, rowPositions.ts (calculateRowPositions, row heights incl. SECTION_ROW_TOTAL_HEIGHT) and timeline.ts (timeline cells and headers, calculateBarPosition, today position / scroll, centredDayRangeAtX).

What-If Mode

A client-side planning sandbox — the server is never aware of it.

  • Entering (WhatIfToggle, shown to admins and superusers only → whatIfStore.enterWhatIfMode) snapshots appStore.projects via structuredClone; TenantApp adds the what-if-mode body class while the flag is on.
  • While active, the API client intercepts every POST/PUT/PATCH/DELETE (exemption list: see What-If exemptions): the request is queued as a WhatIfOperation {id, method, url, body?, timestamp} and a fake success {success: true, whatIfMode: true, id: Date.now()} is returned, so optimistic local state updates normally.
  • Discard: restore the snapshot, drop the queue. Apply: temporarily disable the interception check (always restored in finally), replay queued operations sequentially with real requests, then reload from the server (fetchAllProjects()) and exit. On the first failure Apply stops: the failed and remaining operations stay queued, What-If stays active, projects are reloaded from the server and the snapshot is reset to that state (so a later Discard restores what really landed), and whatIfApplyError {applied, total, message} is set — WhatIfToggle shows it as a header banner with Retry / Discard / Dismiss (dismissApplyError), not an alert. Interception is back on during the post-Apply reload, so edits made in that window are queued and kept (appended after the remaining operations; on success What-If stays active with just those). If the stores were reset meanwhile (401 → expireSession, logout), Apply leaves them reset.
  • Undo history is cleared on enter, Discard and Apply (its snapshots straddle the sandbox boundary); undo/redo inside What-If persists through the API client, so it is queued like any edit.
  • loadAllProjects() is a no-op during What-If (returns the local projects) — every "reload after a write" goes through it, so a reload never wipes the sandbox. Use fetchAllProjects() when a real fetch is required. Consequently an edit must update the store locally (optimistically, or with utils/localProjectTree.ts after the queued call) or it stays invisible until Apply.
  • Gantt-only. Header.tsx renders WhatIfToggle only while currentView === 'gantt' (or a sandbox is already open). While What-If is active, view switching and the Settings hub are blocked in two layers: store guards (viewStore.setCurrentView accepts only 'gantt'; uiStore.openSettings, setActiveModal('settingsHub') and the legacy openModal settings aliases are no-ops) cover every present or future caller, and useWhatIfBlocked() visibly disables the controls with the reason. That covers Sidebar/Navigation.tsx, the Kanban deep link in NotificationBell.tsx, and every Gantt-reachable entry point: AdminSection, TagPicker "Manage tags…", and the embedded Staff view's BankHolidaysRow / CompanyEventsRow. The Delete buttons in EquipmentPanel/UsersPanel are disabled too (their bookings are plan data), and so are the holiday/event deletes in the embedded rows (calendar data is not in the snapshot, and the refetch after a queued delete would resurrect the row). Nothing is auto-discarded. Keep new navigation or Settings entry points behind the same hook.
  • Creates are blocked during What-If (a queued POST has no server id, and the new item could only appear after Apply): Phase/Subphase/Project dialogs and the Assignment dialog in create mode, absences (VacationModal, MyAbsencesModal), equipment blocks (EquipmentBlockModal), Kanban "add assignee"/"join", resource drops on the timeline and the context-menu "Add …"/"Assign …"/"Assign me" items are disabled via useWhatIfBlocked() / whatIfUnavailableMessage() (same wording as the import dialogs). Edits and deletes stay allowed, except holiday/event deletes in the Staff view rows (not snapshotted).
  • Project and custom-column WebSocket refreshes are deferred until What-If ends (see Real-Time Collaboration).
  • Implications for new code: any new write endpoint is queued by default (see What-If exemptions); operations that depend on real server responses (created IDs used by later operations) do not work correctly inside What-If — the queue replays with the original bodies. New create entry points must be blocked the same way.
  • Known limitations: MPP import uploads via a raw fetch that bypasses the queue, so ImportProjectModal blocks importing while What-If is active. .ics calendar imports (ICSImportPanel: holidays, events, absences) are blocked too — calendar data is not part of the What-If snapshot. Custom-column definition changes are queued but live in customColumnStore, which is not snapshotted — a definition created/deleted during What-If is not rolled back locally on Discard (reload to resync). Configuration edits (tags, tag groups, absence types, skills, equipment types, phase templates, users, site and equipment definitions — see the exemption list in What-If exemptions) bypass the queue entirely; the Settings hub and the equipment/user Delete buttons are unreachable during What-If, so a cascading delete (site → projects, equipment → bookings, user → assignments) cannot desync the sandbox from the UI — a delete issued another way (another tab, the API) still does, and the deferred refresh resyncs once What-If ends. A queued project edit that holds two tags of a group an admin makes single-select meanwhile fails on Apply with the backend 400 (Retry cannot fix it — Discard that edit).