Task — engineering-spec@1

"Timeline view — cycle window × assignee swimlane with day-grid layout, drag-resize for date changes, and milestone markers"

doneTASK-PROJ-015
module proj · class product · priority p0 · created 2026-05-16 · shipped null
depends on TASK-PROJ-002 · blocks none

§1 — Description (BCP-14 normative)

The Timeline view MUST render issues as horizontal bars on a day-grid × assignee swimlane. The contract:

  1. MUST present X-axis = days within cycle window (defaults: cycle.starts_at → cycle.ends_at; configurable zoom day/week/month).
  2. MUST present Y-axis = swimlanes, one per assignee active in cycle (i.e. assigned to ≥1 issue in the cycle); ordered alphabetically; "Unassigned" lane at top.
  3. MUST render each issue as a horizontal bar positioned by starts_at..ends_at. Bar height proportional to swimlane row height; bar colour from status enum (TASK-PROJ-018 design tokens).
  4. MUST support drag-to-move: dragging mid-bar changes both starts_at and ends_at by Δdays.
  5. MUST support edge-drag-to-resize: left edge changes starts_at; right edge changes ends_at. Minimum bar = 1 day; resize past min snaps.
  6. MUST PATCH date changes via TASK-PROJ-003 LWW scalar handlers (issue.starts_at + issue.ends_at are LWW fields).
  7. MUST render milestone markers from TASK-PROJ-007 Fixed-Fee config: vertical gold-bordered line at target_date; hover tooltip shows milestone name + amount.
  8. MUST show today-indicator: vertical thin line marking current date; subtle pulse animation.
  9. MUST support keyboard navigation parity:
  1. MUST lazy-render swimlanes off-screen via IntersectionObserver; ≥ 60fps with 50+ swimlanes.
  2. MUST emit memory audit proj.timeline_bar_moved per resize/move with {issue_id, field, before, after, was_keyboard, trace_id}.
  3. MUST emit OTel client metrics:
  1. MUST RLS-enforce (issues + assignees only for tenant).
  2. MUST pass axe-core (per TASK-PROJ-018 a11y CI).
  3. MUST support workload overlap visualisation: when multiple bars in same swimlane overlap (one assignee has 3 concurrent issues), stack them vertically OR show a "3" badge on the densest period.
  4. MUST support ?from=&to= URL params overriding cycle window (operator scrolls beyond cycle); preserve in URL for shareability.
  5. MUST support snap-to-week + snap-to-month at week/month zoom: drag end snaps to nearest week boundary; precise date entry via Brief Modal.
  6. MUST support per-assignee filter: ?assignee=<uuid> shows only that swimlane (full-width). Useful for 1:1 reviews.
  7. MUST include dependency arrows between issues (basic, slice-3 minimal version): when issue A depends_on issue B, render thin arrow from B's right edge to A's left edge. Full Gantt is TASK-PROJ-016.
  8. MUST support "show non-active members" toggle: by default, only members with ≥1 issue in cycle; toggle shows all team members (even with no issues) to plan capacity.
  9. MUST highlight bars that span weekends/holidays differently (faded weekend background) so operators see when work is planned across non-business days.
  10. MUST support keyboard reordering of swimlanes: focused swimlane + Shift+Up/Down moves it; persists in cyberos_proj_timeline_swimlane_order per user.
  11. MUST include cycle goal/theme banner above the timeline: shows cycle name + goal text + days-remaining count.

§2 — Why this design (rationale for humans)

Why day-grid × assignee (DEC-360)? Two questions ops ask: "what is everyone doing this week" + "when does X land." Swimlane answers both — vertical scan shows team coverage; horizontal scan shows duration.

Why LWW resize (DEC-361)? Date changes are simple scalars; TASK-PROJ-003 already provides LWW infrastructure. Two users dragging same bar → last-writer-wins with subject_id tie-break.

Why milestone markers (DEC-362)? Fixed-Fee engagements bill at milestones; visible markers show "are we tracking to next milestone." Without them, milestones are invisible until billing closes.

Why zoom (§1 #1)? A 6-month engagement at day-resolution = 180 columns; too wide. Week-zoom = 26 columns. Month-zoom = 6. Operator chooses based on planning horizon.

Why lazy swimlane render (§1 #10)? 50-person teams produce 50 swimlanes × 60 days × 5 bars/lane = thousands of DOM nodes. Off-screen lanes don't render until scrolled to.

Why 1-day minimum (§1 #5)? Sub-day estimates aren't tracked (time-entries are; but issue planning works in days). Snap-to-minimum prevents zero-width bars.

Why overlap visualisation (§1 #15)? Operator scanning workload should see "Alice has 3 concurrent issues" — overlap signals over-commitment.

Why URL window override (§1 #16)? Operators want to scroll past the cycle boundary (look at next cycle's work-in-flight); URL preserves the view for sharing.

Why snap-to-week/month (§1 #17)? At week zoom, single-day precision in drag is hard; snap-to-week matches the visual grid. Precise dates available in modal.

Why per-assignee filter (§1 #18)? 1:1 reviews focus on one person's work; full-width view = readable.

Why minimal dependency arrows here (§1 #19)? Operators in slice-3 want basic "X depends on Y" visualisation; full Gantt (critical path, parallel chains) is TASK-PROJ-016.

Why show non-active members (§1 #20)? Capacity planning: "who's free to take this on?" requires seeing empty swimlanes.

Why weekend highlight (§1 #21)? Work-planning-on-weekends often unintentional; visual cue surfaces.

Why kbd swimlane reorder (§1 #22)? Operators prioritise lane order (team-lead first, etc.); keyboard parity for ordering.

Why cycle goal banner (§1 #23)? Context-setting: what is this cycle for? Days remaining = urgency signal.


§3 — API contract

// web/proj-client/src/views/Timeline/Timeline.tsx
type Zoom = 'day' | 'week' | 'month';

export function Timeline({ cycleId }: { cycleId: string }) {
  const [zoom, setZoom] = useState<Zoom>('day');
  const cycle = useCycle(cycleId);
  const issues = useIssuesForCycle(cycleId);
  const milestones = useMilestonesForCycle(cycleId);
  const swimlanes = useMemo(() => groupBySwimlane(issues), [issues]);

  return (
    <div className="timeline" role="application" aria-label="Timeline view">
      <ZoomControls value={zoom} onChange={setZoom} />
      <DayGrid start={cycle.starts_at} end={cycle.ends_at} zoom={zoom}>
        {milestones.map(m => <MilestoneMarker key={m.id} date={m.target_date} milestone={m} />)}
      </DayGrid>
      <TodayIndicator />
      {swimlanes.map(lane => (
        <Swimlane key={lane.assigneeId ?? 'unassigned'} lane={lane}>
          {lane.issues.map(issue => <IssueBar key={issue.id} issue={issue} zoom={zoom} />)}
        </Swimlane>
      ))}
    </div>
  );
}
// web/proj-client/src/views/Timeline/IssueBar.tsx
export function IssueBar({ issue, zoom }: { issue: Issue; zoom: Zoom }) {
  const [draftDates, setDraftDates] = useState<{starts_at: Date; ends_at: Date}>({
    starts_at: issue.starts_at, ends_at: issue.ends_at,
  });

  const left  = computeOffset(draftDates.starts_at, zoom);
  const width = computeWidth(draftDates.starts_at, draftDates.ends_at, zoom);

  return (
    <div className="issue-bar"
         style={{ left, width, backgroundColor: tokens.status[issue.status] }}
         role="button" tabIndex={0}
         aria-label={`${issue.title} from ${fmt(issue.starts_at)} to ${fmt(issue.ends_at)}`}
         onKeyDown={handleKbd}>
      <ResizeHandle edge="left"  onDrag={Δ => setStartsAt(addDays(issue.starts_at, Δ))} />
      <span className="title">{issue.title}</span>
      <ResizeHandle edge="right" onDrag={Δ => setEndsAt(addDays(issue.ends_at, Δ))} />
    </div>
  );

  async function setStartsAt(newDate: Date) {
    setDraftDates(d => ({ ...d, starts_at: newDate }));
    const res = await writeScalarLWW(issue.id, 'starts_at', newDate.toISOString(), jwt);
    if (!res.accepted) { rollbackDates(); toast('Stale write; refreshed'); }
    emitMemory('proj.timeline_bar_moved', { issue_id: issue.id, field: 'starts_at',
                                            before: issue.starts_at, after: newDate,
                                            was_keyboard: false });
  }

  function handleKbd(e: React.KeyboardEvent) {
    if (e.shiftKey && e.key === 'ArrowRight') {
      setEndsAt(addDays(issue.ends_at, 1));
    } else if (e.shiftKey && e.key === 'ArrowLeft') {
      setEndsAt(addDays(issue.ends_at, -1));
    }
    // ... Cmd+Shift+Arrow for move, etc.
  }
}

§4 — Acceptance criteria

  1. Day-grid renders cycle window — cycle Jan 1 → Mar 31 → 90 day columns at day-zoom.
  2. Swimlanes for active assignees — fixture: 3 members assigned → 3 lanes (+ Unassigned if applicable).
  3. Issue bar positioned correctly — starts_at Mar 5, ends_at Mar 10 → bar at days 64..68 from cycle start.
  4. Drag move shifts both dates — drag bar 3 days right → starts_at + 3, ends_at + 3; PATCH fires.
  5. Edge-resize updates one date — drag right edge → ends_at only updates.
  6. 1-day minimum enforced — drag right edge left past starts_at → snaps to starts_at + 1.
  7. Milestone markers render — Fixed-Fee milestones → gold lines at target_date.
  8. Today indicator pulses — current date → animated vertical line.
  9. Zoom day→week — bar width recomputes; columns coalesce.
  10. Kbd: Shift+→ extends end by 1 day — focused bar + shortcut → ends_at +1.
  11. Kbd: Cmd+Shift+→ moves bar 1 day forward — both dates +1.
  12. Lazy swimlane render — 50 swimlanes; ~10 visible → only those rendered.
  13. LWW reject on stale — concurrent edit detected → rollback + toast.
  14. memory audit per moveproj.timeline_bar_moved row.
  15. OTel resize latency metric — drag completion → histogram populated.
  16. axe-core passes — no critical/serious violations.
  17. Hover milestone tooltip — hover gold line → tooltip with name + amount_minor formatted.
  18. RLS isolates — tenant A's cycle invisible to tenant B.
  19. Overlap stacking — 3 concurrent bars in one swimlane → stacked vertically; badge if > 5 (AC for §1 #15).
  20. URL window override?from=2026-01-01&to=2026-06-30 → renders extended window; URL preserved (AC for §1 #16).
  21. Snap-to-week at week zoom — drag to mid-week at week zoom → snaps to nearest week boundary (AC for §1 #17).
  22. Per-assignee filter?assignee=<uuid> → single swimlane full-width (AC for §1 #18).
  23. Dependency arrow rendered — A depends_on B → arrow from B's right to A's left (AC for §1 #19).
  24. Show non-active members toggle — toggle on → empty swimlanes appear (AC for §1 #20).
  25. Weekend background faded — Sat/Sun column has faded bg color (AC for §1 #21).
  26. Kbd swimlane reorder — focused swimlane + Shift+Up → moves up; persists (AC for §1 #22).
  27. Cycle banner shows goal + days remaining — banner above timeline (AC for §1 #23).

§5 — Verification

test('issue bar positions by starts_at/ends_at', () => {
  const cycle = mkCycle({ starts_at: '2026-01-01', ends_at: '2026-03-31' });
  const issue = mkIssue({ starts_at: '2026-01-05', ends_at: '2026-01-10' });
  render(<Timeline cycleId={cycle.id} />);
  const bar = screen.getByLabelText(/from 2026-01-05/);
  expect(parseInt(bar.style.left)).toBeCloseTo(4 * DAY_PX, 0);
  expect(parseInt(bar.style.width)).toBeCloseTo(5 * DAY_PX, 0);
});

test('edge-resize updates ends_at only', async () => {
  const { user } = render(<Timeline cycleId={cycle.id} />);
  const bar = screen.getByTestId('issue-bar-iss-1');
  const rightHandle = within(bar).getByTestId('resize-right');
  await user.drag(rightHandle, { delta: { x: 2 * DAY_PX, y: 0 } });
  expect(mockPatch).toHaveBeenCalledWith('iss-1', 'ends_at', expect.any(String));
  expect(mockPatch).not.toHaveBeenCalledWith('iss-1', 'starts_at', expect.anything());
});

test('1-day minimum snap', async () => {
  const issue = mkIssue({ starts_at: '2026-01-05', ends_at: '2026-01-10' });
  const { user } = render(<Timeline cycleId={cycle.id} />);
  const rightHandle = screen.getByTestId('resize-right');
  await user.drag(rightHandle, { delta: { x: -100 * DAY_PX, y: 0 } });
  // ends_at can't go past starts_at + 1
  expect(mockPatch).toHaveBeenLastCalledWith('iss-1', 'ends_at', '2026-01-06T...');
});

test('milestone markers from fixed-fee', () => {
  const eng = mkEngagement({ billing_mode: FixedFee, milestones: [
    { id: 'm1', target_date: '2026-02-15', amount_minor: 50_000_000 }
  ]});
  render(<Timeline cycleId={cycle.id} />);
  expect(screen.getByTestId('milestone-marker-m1')).toBeInTheDocument();
});

test('kbd Shift+Right extends', async () => {
  const { user } = render(<Timeline cycleId={cycle.id} />);
  const bar = screen.getByTestId('issue-bar-iss-1');
  bar.focus();
  await user.keyboard('{Shift>}{ArrowRight}{/Shift}');
  expect(mockPatch).toHaveBeenCalledWith('iss-1', 'ends_at', expect.stringMatching(/2026-01-11/));
});

§6 — Implementation skeleton

(Sketches above.)


§7 — Dependencies


§8 — Example payloads

{
  "kind": "proj.timeline_bar_moved",
  "payload": {
    "issue_id": "iss-...",
    "field": "ends_at",
    "before": "2026-03-10",
    "after": "2026-03-12",
    "was_keyboard": true,
    "trace_id": "0af..."
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Drag past cycle boundaryclamp at boundaryVisual snapNone
Concurrent resizeLWW rejectRollback + toastUser refreshes
Issue without datesomit from timelineVisible in Unassigned laneOperator sets dates
100+ swimlaneslazy + scrollSmoothNone
Milestone after cycle.ends_atrender at right edgeVisibleNone
Locale RTLflex reverses; bars maintain orderUsableNone
Touch deviceTouchSensorDrag worksNone
Browser zoom > 200%reflowUsableNone
Date in past clamp at cycle.starts_atrenderBar starts at edgeNone
WS disconnectoffline bannerDrags queuedReconnect drains
Mid-drag tab closeserver state untouchedLocal UI snaps back on remountNone
Milestone target_date nullskip renderNoneNone
Cycle duration > 1 yearday-zoom unusableUI defaults to week-zoomNone
Issue ends_at < starts_at (data bug)clamp + log warningSingle-day barOperator fixes data
Overlap > 10 concurrent barsbadge shows count; "10+"Operator clicks to expandNone
URL window > 2 yearswarn at loadrenders but slowOperator narrows
Snap-to-week with operator wanting day precisionmodal entry availableNoneOperator uses modal
Per-assignee filter with no issuesempty timeline + helpful copyNoneNone
Dependency arrow with cycle (A→B→A)arrow rendered with dashed stylevisible cycle warningOperator fixes
Dependency arrow crossing many swimlanesrendered with curveNoneNone
Show non-active toggle with 100+ membersvirtualisedNoneNone
Weekend bg color clashes with bar coloraccessibility checkNoneOperator changes theme
Swimlane reorder race with WS updatelocal order preservedNoneNone
Cycle goal text > 500 charstruncated with tooltipNoneNone
Days remaining negative (cycle in past)banner shows "ended N days ago"NoneNone
RTL localetimeline reverses; bars maintain orderNoneNone
Dependency arrow with non-rendered issue (collapsed lane)arrow goes to lane edgepartial visualisationNone

§11 — Implementation notes


End of TASK-PROJ-015.

As built (2026-07-02)

Client code lives under apps/web/src (there is no web/proj-client/).