Skip to main content

FS Garage Gantt (FE) Architecture

Scope

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​

ZoomCell representsCell width (px)Pixels per day
Day1 day5050 / 1 = 50
Week7 days100100 / 7 ≈ 14.3
Month30 days120120 / 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:

SegmentConditionColorMeaning
Planned outlinealways, if planned dates existoutline onlyThe originally planned span
Overlapmax(plannedLeft, actualLeft) < min(plannedRight, actualRight)blue #3b82f6On-time progress - actual work that fell inside the planned window
Early startactualLeft < plannedLeftgreen #86efacActual work that started before the planned start
Late endactualRight > plannedRightred #ef4444Actual 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​

LayerTechnology
FrameworkNext.js App Router
LanguageJavaScript
RenderingCustom absolute-positioned DOM, no charting library
BackendPocketBase tasks/milestones data (shared, no Gantt-specific schema)
Default branchdev

Next Steps​