FS Garage Gantt (FE) Architecture
Covers components/ui/gantt/ only - the Gantt tab on app/(auth)/tasks/page.js and app/(auth)/project/[id]/page.js. The separate frappe-gantt-based Milestone page (app/(auth)/project/[id]/milestone/page.js) has different, mixed authorship and isn't documented here.
System Overview
The Gantt view converts each task's/milestone's dates into pixel positions along a single horizontal timeline, entirely with hand-written date math - there's no charting library underneath. Zoom level controls how many pixels represent one day; the visible date range is derived from the data itself, not a fixed window.
Architecture Diagram
graph TB
subgraph FE["Frontend: base-fs-garage-fe"]
GanttControls["GanttControls.jsx<br/>zoom: day / week / month"]
GanttView["GanttView.jsx<br/>timelineBoundary, pixelsPerDay,<br/>date-to-position"]
GanttGroupHeader["GanttGroupHeader.jsx<br/>milestone/unassigned groups"]
GanttTaskRow["GanttTaskRow.jsx"]
GanttBar["GanttBar.jsx<br/>planned-vs-actual segments"]
end
PocketBase["PocketBase: tasks + milestones"]
PocketBase -->|"plannedStart/End, actualStart/End"| GanttView
GanttControls -->|"zoom"| GanttView
GanttView --> GanttGroupHeader
GanttGroupHeader --> GanttTaskRow
GanttTaskRow --> GanttBar
How to read this diagram
Arrows mean flow / dependency / "talks to", not button clicks.
Core Components
GanttView.jsx
The largest file (~1,800 lines) - owns zoom state, computes the timeline's date boundary and pixel scale, renders the header (date columns), the resizable/collapsible left pane, the today marker, and orchestrates groups/rows/bars.
GanttControls.jsx
Zoom toggle (Day → Week → Month → Day) and other timeline-level controls.
GanttGroupHeader.jsx
Renders one collapsible group (a milestone, or "Unassigned"), showing its task count and an optional milestone-status badge.
GanttTaskRow.jsx / GanttBar.jsx
One row per task; GanttBar.jsx computes and renders the actual bar segments (see below) plus tooltips.
Timeline & Bar Calculations
Zoom level → pixels per day
| Zoom | Cell represents | Cell width (px) | Pixels per day |
|---|---|---|---|
| Day | 1 day | 50 | 50 / 1 = 50 |
| Week | 7 days | 100 | 100 / 7 ≈ 14.3 |
| Month | 30 days | 120 | 120 / 30 = 4 |
pixelsPerDay = cellWidth / cellDays
Timeline boundary (visible date range)
The timeline's min/max dates are derived from the data, not fixed:
dates = every plannedStart/plannedEnd/actualStart/actualEnd across all tasks,
and start_date/end_date/actual_start_date/actual_end_date across all milestones
if dates is empty:
min = today - (cellDays × 2)
max = today + (cellDays × 4)
else:
min = earliest(dates) - (cellDays × 2)
max = latest(dates) + (cellDays × 2)
i.e. the range always pads by 2 cell-widths' worth of days on each side (or centers a default window on today with no data at all), so bars are never flush against the timeline's edge.
Timeline sizing
rangeDays = max(cellDays × 3, ceil((max - min) / 1 day))
timelineWidth = max(960, ceil(rangeDays × pixelsPerDay) + cellWidth × 21)
The cellWidth × 21 term reserves extra trailing width (about 3 weeks at Day zoom) so the last visible bars aren't cramped against the right edge.
Date → pixel position
Used identically for task bars, milestone markers, and the today-line:
pos(date) = ((date - timelineMin) / 1 day) × pixelsPerDay
Bar width (end-date inclusive)
A bar spanning start...end extends one extra day's width past end's own position, so the end date's full column is covered rather than stopping at its left edge:
left = pos(start)
right = pos(end) + pixelsPerDay
width = max(6, |right - left|) (6px floor so a same-day bar stays visible/clickable)
Planned-vs-actual segments
A task with both planned and actual dates renders as up to four segments, not one bar - this is how schedule variance becomes visible at a glance:
| Segment | Condition | Color | Meaning |
|---|---|---|---|
| Planned outline | always, if planned dates exist | outline only | The originally planned span |
| Overlap | max(plannedLeft, actualLeft) < min(plannedRight, actualRight) | blue #3b82f6 | On-time progress - actual work that fell inside the planned window |
| Early start | actualLeft < plannedLeft | green #86efac | Actual work that started before the planned start |
| Late end | actualRight > plannedRight | red #ef4444 | Actual work that continued past the planned end |
If only actual dates exist (no planned dates), the bar is a single solid blue bar (actual-only). If only planned dates exist, it's the outline alone (planned-only). Gaps - started late, or ended early relative to plan - are shown as empty space, not a distinct colored segment.
Today marker
Positioned with the exact same pos(date) formula as task bars, using the current date as input - it moves and rescales consistently with zoom just like every other element on the timeline.
Data Flow
sequenceDiagram
participant User
participant Controls as GanttControls.jsx
participant View as GanttView.jsx
participant Bar as GanttBar.jsx
User->>View: Open Gantt tab
View->>View: compute timelineBoundary from all tasks/milestones
View->>View: pixelsPerDay = cellWidth / cellDays (current zoom)
View->>Bar: render each task's planned/actual segments
Bar-->>User: colored bar(s) showing on-time/early/late
User->>Controls: Change zoom (Day/Week/Month)
Controls->>View: new zoom
View->>View: recompute cellDays, cellWidth, pixelsPerDay, timelineWidth
View-->>User: Re-rendered timeline at new scale
Design Decisions
Hand-rolled positioning instead of a charting library
Context: The repo already vendors frappe-gantt for the separate Milestone page, but this Gantt tab needed planned-vs-actual segment coloring and custom grouping that didn't map cleanly onto that library's model.
Decision: Build the timeline as absolutely-positioned divs driven by a single pos(date) formula, giving full control over segment rendering at the cost of maintaining the date math by hand.
Data-derived timeline range, not a fixed window
Context: A fixed calendar window (e.g. "this month") would either clip out-of-range tasks or show mostly empty space for a project with a short or unusual schedule.
Decision: Derive min/max from the actual planned/actual dates in the data, padded by 2 cell-widths on each side, so the timeline always fits what's actually there.
Four-segment planned-vs-actual coloring
Context: A single bar per task can't show how a task deviated from plan - only whether the dates differ.
Decision: Split into overlap/early-start/late-end/outline segments so a viewer can tell "started early but finished on time" apart from "started on time but ran long" at a glance, without opening the task.
Technology Stack
| Layer | Technology |
|---|---|
| Framework | Next.js App Router |
| Language | JavaScript |
| Rendering | Custom absolute-positioned DOM, no charting library |
| Backend | PocketBase tasks/milestones data (shared, no Gantt-specific schema) |
| Default branch | dev |