[0.1.1] — 2026-06-13
Docs + accessibility. No API changes.
Added
- Much-expanded README: "Making it interactive" (hooks, the built-in toolbar,
the
on_*callback table, and theextra.actions/extra.badgesshapes), "Translations" (the chrometranslationsmap vs. consumer-resolved content — works with gettext, Cldr, or a JSONB multilang column), "Live updates", and an "Accessibility" section. New Gotchas: nil/duplicate id raises,todaydefaults to UTC, andwindow_start/window_endis all-or-nothing. :docfor thetranslationsattr.
Accessibility
- Sub-project chevrons now expose
aria-expanded(and anaria-label), so screen readers announce expand/collapse state. - The decorative connector + arrowhead SVGs are
aria-hidden, so a screen reader walks the bars rather than the path geometry.
[0.1.0] — 2026-06-13
Initial release. Extracted from live_calendar's waterfall view into a
standalone package.
Features
PhoenixLiveGantt.gantt/1component — horizontal bars on a time axis with orthogonal connector routing (FS/SS/FF/SF), bar-edge attach modes, bus stagger, smart trunk consolidation.PhoenixLiveGantt.Taskstruct — Gantt-focused (no calendar/recurrence baggage).- Sub-projects: any task with
extra.parent_idbecomes a child; parents roll up over descendants, expand/collapse via chevron or popover button, with framed timeline + sidebar treatment. - Per-bar popover (click to open) with title, assignee/progress subtitle, optional custom action buttons (icon + tooltip + phx-click + per-event badges).
- Corner badges (notification-style pills with stacking + flash).
- Built-in
PhoenixLiveGantt.Inspectorfor HTML → geometry parsing andPhoenixLiveGantt.TestHelpersfor property assertions. mix phoenix_live_gantt.dumpfor offline geometry inspection.- JS hooks
LgBarPopover+LgAutoScroll. PhoenixLiveGantt.scroll_to_start/2— scroll the timeline back to its start. APhoenix.LiveView.JScommand (composes withJS.push/2) that theLgAutoScrollhook consumes (lg:scroll-start) to scroll the chart to its leftmost column. Pair it with a "home"/"fit" button whose server handler refits the window — the server can't move the scroll, and the built-in scroll-to-today only fires when the today marker is in view, so a refit that doesn't include today would otherwise leave the timeline parked at a stale spot. A pending-flag in the hook makes the scroll authoritative across the refit patch even when it moves the today marker (which would otherwise re-center on today).window_start/window_endattrs — sub-day positioning window. The positioning axis is normallydate_range's whole-day, midnight-to-midnight span. A consumer can now override the ORIGIN and SPAN with a pair ofNaiveDateTimes so the axis starts/ends partway through a day — e.g. ~1 column before the first task at:hour/:min15/:min5zoom, instead of a wall of empty pre-task columns from midnight. Positioning threads aview = {origin, span_days}(origin is the whole-dayrange.firstDate in the default path, aNaiveDateTimewhen overridden) through bars, connector endpoints, the today marker, sub-project frames, obstacles, and a newwindow_columns/5column builder that walks fixed slot-minute steps from the origin (labels: the date on each midnight slot, a bare hour on:hour, the:15clock boundaries on sub-hour zooms).date_rangestill drives event partition / edge counts, so keep it covering the same window. Behavior is byte-identical when the override is absent (origin =range.first, span =total_days). Snapwindow_startto a slot boundary so column labels land on round clock times.tiny_bar_pxattr (default5) — "too small to see" marker. A bar whose TRUE width renders narrower than this many SCREEN pixels gets a small fixed-size down-triangle at the task's start, signalling a task that's there but too short to see. The decision is pure CSS — each marker lives inside a per-taskcontainer-type: inline-sizeelement whose width tracks the bar's rendered width, and an injected container query (@container (max-width: {tiny_bar_px}px)) reveals it. So it's server-emitted and browser-resolved against true screen pixels: correct under the responsive fill + zoom, instant on first paint (no socket/hook/measurement), and re-resolved on resize by the browser with zero JavaScript. The marker is clickable (opens the same popover — that part needsenable_hooks). Set0to disable. Pairs withmin_bar_px: 0(the default) so bars stay honest while hairline tasks remain discoverable. Assumes a uniformtiny_bar_pxacross charts sharing a page.min_bar_pxattr (default0) — bars reflect their TRUE duration. Previously every non-milestone bar was floored to a 4px minimum so a short task stayed a visible sliver. That made the bar overstate the task's span and diverge from the connector geometry (arrows attached to a phantom edge). The floor is now opt-in: by default a bar is exactly as wide as its duration (a task too short to show at the current zoom is a hairline / vanishes until you zoom in), so the chart is honest and connectors attach to the real edge. Setmin_bar_pxto e.g.4to restore the always-visible-sliver behavior. (A zero-DURATION task is still a milestone diamond regardless.) Connector endpoints are DRAWN from the RENDERED bar edges (so a non-zeromin_bar_pxstays consistent with where arrows attach), but the backward/invalid ("time-travel") decision is JUDGED from the NATURAL temporal edges — otherwise a zero-gap FS dependency (B starting exactly when A finishes) would be falsely flagged backward by A's min-width sliver poking past B's start.:hourzoom + continuous coordinates. The positioning axis is now a continuous "fractional days from range start" used uniformly by bars, the today marker, connector endpoints, and columns — so a:hourzoom (and DateTime/NaiveDateTimestart/end) renders intra-day detail (a 2h and a 6h task differ in width/position).Dateinputs at day/week/month zoom are byte-identical to before.todayaccepts aDateTime/NaiveDateTimefor a precise "now" line + current-hour column highlight; positioning uses wall-clock time (DST-safe).PhoenixLiveGantt.Layout.sequential/2gained a:min_span{unit, n}option and emits sub-day temporals when its:start/:advancedo.- Responsive fit-to-width (pure CSS, no round-trip). Horizontal geometry
(bars, columns, today marker, sub-project frames, badges, popovers) now
renders as PERCENTAGES of the content width, and the timeline uses
width: 100%; min-width: {content_px}pxinsideoverflow-x-auto. A short chart fills the container exactly (no gap); a long one scrolls at its natural density — instantly, on first paint, with zero measurement or server round-trip. The connector SHAFT SVG keeps a pixel viewBox but renderswidth: 100%withpreserveAspectRatio="none"+vector-effect="non-scaling-stroke", so the lines scale in lockstep with the bars and stay aligned at any width (the connector router is unchanged). Arrowheads are drawn in a SEPARATE, non-stretched overlay (positioned by%so the tip tracks the bar-aligned shaft end, but sized in fixed px so the triangle never distorts) — a stretched line is still a correct line, but a stretched triangle is not an arrowhead.day_width_pxnow sets the natural content width (scroll threshold / density);default_day_width_px/1exposes the per-zoom defaults. - Off-screen Today hint. When
todayfalls outsidedate_range, a directional pill (← Today/Today →) now pins to the edge pointing toward today, instead of the consumer having to widen the axis to keep the marker on screen. Optionalon_show_todaymakes it clickable (e.g. to jump to today); otherwise it's informational. The vertical marker line still renders only when today is in range.
Fixes
- Connectors to/from a task at the very edge of the window no longer clip off the
chart. A task flush against the left/right edge had no room for its connector's
exit/entry stub (it bulges ~
@elbow_pxpast the bar), so the stub — and sometimes the arrowhead — drew pastcontent_widthand got clipped by the chart'soverflow-x-auto. The time axis now reserves a fixed@axis_pad_px(16px) of horizontal breathing room on each side: every x coordinate shifts in by the pad,content_widthgrows by 2×, and transparent spacer columns hold the margin so bars still exactly cover their time columns. (Absolute %s move, but every layer shares the padded denominator, so alignment is unchanged.) - Arrowheads into a milestone no longer detach from the shaft at a low fill
factor. The head is nudged
@milestone_edge_px(12px) out to the diamond's edge — a fixed SCREEN px — but it rides the connector's final approach segment, which was only@elbow_px(10px) of VIEWBOX. When the chart scrolls rather than fills (e.g.:min5, where that segment renders ~1:1), 12px of nudge overshot the 10px segment and the head floated off the trunk, disconnected. A milestone target now gets an approach stem a hair longer than the nudge (@milestone_edge_px + 2), so the head always lands ON the shaft at every zoom. (Connectors are still the normal horizontal zigzag — longer at a high fill is fine.) - An open bar/label popover now sits above everything else in the chart
(
z-[60]). It wasz-40— tying with milestone diamonds — and since rows areposition: relativewith no z-index (one shared stacking context), a popover that overhung the row below lost to that row's diamond by DOM paint order and got clipped. Clicking a milestone in a stack of same-date diamonds now shows an un-occluded popover. (Above bars z-10, today line z-30, diamonds z-40, and badges z-50.) - Milestone diamonds are now clickable. They rendered with
cursor-pointer(the defaultmilestone_class) but carried no popover wiring — only the optionalon_event_click— so a consumer that relied on the built-in popover (as bars do) got a diamond that looked clickable but did nothing. Diamonds now get the sameLgBarPopoverhook + popover sibling as bars, so clicking one opens its title/actions popover AND highlights its dependency tree (fading unrelated tasks) — the tool for tracing arrows through a cluster of same-date milestones. (The dependency highlight walks ancestors only, by design; an inline comment claiming it walked "both directions" was stale and has been corrected.) - Column-header "today" highlight now honors the
todayattribute instead of always computing againstDate.utc_today(), so it agrees with the today-marker line when a consumer passes an explicittoday. - The built-in toolbar's Today button is now disabled (with an
explanatory tooltip) when it can't actually scroll — i.e.
enable_hooksis off and noon_scroll_todayis wired — instead of rendering enabled and silently doing nothing. (Default scroll-to-today needsid+enable_hooksso theLgAutoScrollhook has a target + listener.) - Popover action buttons now always expose the event id as
phx-value-event-id(hyphen), even when the action sets aphx_valuemap — previously a map made itphx-value-event_id(underscore), disagreeing with the no-value path and the chevron. Handlers now read%{"event-id" => id}consistently. LgBarPopoverre-anchors a bar popover to the bar's CURRENT geometry on open, instead of trusting its (frozen,phx-update="ignore") server- rendered position. Fixes popovers opening far from their bar after the chart re-rendered with new geometry — e.g. switching zoom.- Connector arrowheads no longer distort under the responsive fill. They were
SVG
<marker>s inside thepreserveAspectRatio="none"shaft SVG, so at high fill factors they stretched into thin, disconnected-looking triangles. They now render in a fixed-px overlay anchored by%to the shaft's true terminal point (PhoenixLiveGantt.PathFormat.terminal/1), so the head stays on the shaft end even whenconsolidate_piercing_trunksre-routes a forward path to end at a different y (the old marker rode the path, the overlay must re-derive it). NewInspectorarrowhead extraction +TestHelpers.assert_arrowheads_at_path_ends/2(wired intofind_geometry_issues/2) lock the head-meets-shaft invariant. - Sub-day tasks are no longer mis-routed as milestones.
milestone?/1(connector routing) testedDate.diff(end, start) <= 0, so any task shorter than a full day — common at:hourzoom — started and ended on the same DATE and was treated as a zero-duration milestone, even thoughbar_geometry/3(which uses fractional days) rendered it as a thin bar. The router then applied milestone endpoint offsets + the 10px diamond gap and frequently mis-flagged the dependency as backward (dashed), so arrows routed to/from a phantom diamond and looked disconnected.milestone?/1now uses the same fractional-day duration test asbar_geometry/3(identical to the old behavior for pure-Dateevents). - Arrow tips now land ON the target bar's edge (gap 0) instead of a 4px natural gap. Under the responsive fill the shaft SVG stretches with the bars, so a natural-px gap was magnified into a visible disconnect (4px → ~15px at a 3.8× fill); the fixed-px arrowhead overlay now supplies the visual separation, so arrows read as connected at any fill factor. (Milestone targets keep their diamond-clearance gap.)
Ergonomics & docs
PhoenixLiveGantt.Layout.sequential/2— optional, domain-agnostic helper that lays items with durations + order + sub-projects out intostart/enddates (sequential waterfall, sub-project span, day-aligned:min_span_days) with a pluggable:advancecalendar callback. Keepsgantt/1render-only while giving consumers the durations→dates layout they'd otherwise hand-roll. Does not do dependency-driven scheduling / critical path / resource leveling.PhoenixLiveGantt.toggle_expanded/2— convenience foron_toggle_expandhandlers (accepts a MapSet/list/nil, returns a MapSet).- README rewritten with the steps/gotchas that bite first-time consumers: the
required Tailwind content source (no stylesheet ships),
endbeing exclusive, the sub-project rules (always include descendants; give parentsnildates so they roll up), and theon_toggle_expand"event-id"param key. expanded/on_toggle_expandnow carry attr docs (the:allshortcut, the hyphenated param key).
Naming
CSS class prefix: lg-. JS hooks: LgBarPopover, LgAutoScroll.
Events dispatched: lg:scroll-today.