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.tsxboots the theme and wires the API client (configureApiClient): What-If check + queue fromwhatIfStore, andonUnauthorized: expireSession. (The legacyrd_*storage migration was removed.)src/App.tsxrenders by branching:window.location.pathnameexactly/adminor under/admin/(isAdminPath) →AdminApp(a fully separate admin-portal app:AdminLoginScreen/AdminDashboardwith tenants/organizations/admins/stats tabs); otherwiseTenantApp: auth state decidesLoginScreenvs the main app, withLoadingScreenduring the initial load andLoadErrorScreen(Retry) if it failed, thenWebSocketProvider→MainLayout+ModalContainer+ContextMenuContainer+ActivityFeed.TenantAppalso owns thewhat-if-modebody class.- In-app navigation =
viewStore.currentView(gantt|kanban|staff|equipment|crosssite|archived) switched by the sidebar; views are state, not URLs.setCurrentViewrefuses every view butganttwhile What-If is active (see What-If Mode). - All administration is one Settings hub (
components/settings/), a full-size sharedModalwith a grouped section list. The sidebarAdminSectionhas a single Settings entry (admins + superusers). Sections come from theSETTINGS_SECTIONSregistry, each with aminRolemirroring 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 +…Panelcomponent (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()inclient.tsprepends it to every/api/call. Deep links work because FastAPI's catch-all (prod) ortenantSpaPlugin(dev) serveindex.htmlfor/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
:8485directly (no Vite proxy). - FastAPI 422 validation errors are flattened to
field: messagestrings; non-JSON responses raise with the first 200 chars logged (catches proxy HTML). - Session expiry: a 401 on a session-bound call fires the
onUnauthorizedhook →expireSession()(src/stores/index.ts):resetAllStores()(appStore except the persisted site id, What-If, undo, notifications, custom-column data,uiStoreback to its initial state, and the transient body classesphantom-sibling-mode/resource-dragging/kanban-dragging/what-if-moderemoved; viewStore and column visibility are kept, adminStore is left alone) and back to the login screen.useAuth.logoutalso callsresetAllStores(), so nothing carries over to the next person on the tab. Add new stores toresetAllStores(). - What-If interception happens here (see What-If Mode).
- Endpoint modules beyond the per-domain ones:
health.ts(getAppVersion()),importChunks.ts(postInChunks(), used bysites.ts/vacations.ts),kanban.ts,notifications.ts. The last three are imported directly, not re-exported fromapi/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/)¶
GanttContainerorchestrates: filters projects to the current site (non-archived), orders them withorderSiteProjects()(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 rendersProjectPanel(left tree) + resizer +Timeline(right) insideTimelineScrollProvider, 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 —TagFilterDropdownfromcomponents/common, also mounted in the Kanban toolbar — thenGroupByDropdownandColorByDropdown, both thin wrappers over the sharedTagGroupChoiceDropdownshell; the header dropdowns hang from the positioned.headerContent, right-aligned and width-capped) +SectionRow(Group-by section header) + rowsProjectRow → PhaseRow → SubphaseRow → AssignmentRow+ custom-column cells; wrapped inReorderProvider.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:
GanttContainerturnsuseGanttGrouping()+ the tag-filtered projects into one flatGanttRow[](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) andTimelineBody(div.groupBand, a direct child of.rows); both panes fall back to a memoised flat list whenrowsis omitted. Section rows are--section-row-height(36 px) + 1 px border, content-box, =SECTION_ROW_TOTAL_HEIGHT(37) incalculateRowPositions(rows, …), keyedsection-<key>; collapsed sections' projects get no position (no arrows, no drop target). Section class names must not containrowand carry nodata-project-id/data-phase-id(the drag/resize hooks andDependencyLayerfind rows by[class*="row"]/ data attributes). Project drag-reorder is off while grouped (onProjectReorderundefined). - 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 viatoMouseLikeEvent) → live DOM movement with cell snapping (week/month) and a dependency lag indicator → on drop,commitDragUpdatecomputes new dates from pixels, awaitsconfirmAction()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 batchedPendingUpdates (savePendingUpdates). Any persist error → reload all projects + clear undo. The documentmousemove/mouseupandtouchmove/touchend/touchcancellisteners (here and inuseResize) 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 byutils/touchEvents.ts(toMouseLikeEvent, an explicit object — never spread a SyntheticEvent, its methods are lost; itspreventDefaultis a no-op for the passive touchstart), and every touch start waits for a 5 px move first (armTouchGesturefor project, assignment and resize-handle touches; the PhaseBar body has its own threshold) so a wobbly tap never commits a move; the non-passivetouchmovelistener callspreventDefault()only on interactive bars (React's roottouchstartlistener is passive, so only CSStouch-action: noneon the interactive bars — theinteractiveclass, set when a bar has drag/resize handlers, so read-only bars still scroll — plus that listener stop the timeline from scrolling),touchendis default-prevented so the browser sends no emulated click, andtouchcancelrestores 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 includeallocation(backend requirement). - Critical path (
components/gantt/utils/criticalPath.ts): full CPM forward/backward pass,totalFloat <= 0= critical; loaded via dynamic import fromappStore.toggleCriticalPathto avoid a circular dependency. - Undo/redo (
useUndoRedo+components/gantt/utils/diffProjects.ts): restore snapshot, diff old-vs-new date fields and dependency arrays into minimalPendingUpdates, 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 writeentity; legacy entries without it resolve phase-first. Always go throughcomponents/gantt/utils/dependencies.ts:resolveDependencyPredecessor(lookup),dependencyMatches(find/delete a link, legacy-aware, missingtype= 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-fnsgetISOWeek/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 withsetCustomColumnValuesBatch(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:
KanbanViewtakesappStore.projects, orders them withorderSiteProjects(), filters withuseTagFilteredProjects(), and derives cards withutils/kanbanCards.ts(card = leaf phase/subphase, milestones included;groupCards()builds the swimlanes for theviewStore.kanbanGroupingchoice).KanbanBoard→KanbanColumn→KanbanCard;KanbanToolbarholds the project picker, grouping, tag filter and My-to-do toggle. - Comment counts come from
api/endpoints/kanban.ts(getCommentCountsfor one project,getSiteCommentCountsin "All projects" mode); the card dialog ismodals/KanbanCardModal.tsx(assignees withAllocationSlider, comments withMentionTextarea, mention candidates fromgetMentionableUsers). - Dragging is
contexts/KanbanDragContext.tsx— hand-rolled HTML5 drag events likeReorderContextanduseResourceDragDrop, 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 bystatusAfterCompletionWritebecause 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) snapshotsappStore.projectsviastructuredClone;TenantAppadds thewhat-if-modebody 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), andwhatIfApplyError {applied, total, message}is set —WhatIfToggleshows 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. UsefetchAllProjects()when a real fetch is required. Consequently an edit must update the store locally (optimistically, or withutils/localProjectTree.tsafter the queued call) or it stays invisible until Apply.- Gantt-only.
Header.tsxrendersWhatIfToggleonly whilecurrentView === '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.setCurrentViewaccepts only'gantt';uiStore.openSettings,setActiveModal('settingsHub')and the legacyopenModalsettings aliases are no-ops) cover every present or future caller, anduseWhatIfBlocked()visibly disables the controls with the reason. That coversSidebar/Navigation.tsx, the Kanban deep link inNotificationBell.tsx, and every Gantt-reachable entry point:AdminSection,TagPicker"Manage tags…", and the embedded Staff view'sBankHolidaysRow/CompanyEventsRow. The Delete buttons inEquipmentPanel/UsersPanelare 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 viauseWhatIfBlocked()/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
fetchthat bypasses the queue, soImportProjectModalblocks importing while What-If is active..icscalendar 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 incustomColumnStore, 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).