Time-grid View
Normative contract for the generic civil-time scheduler engine, its Kanban adapter, span semantics, overlap lanes, drag session, and persistence seam.
The time-grid engine is the shared scheduler primitive in packages/. It is the implementation of the Kanban Calendar scheduler described in the Kanban Board manual and is consumed by the kanban adapter in packages/kanban-board/. The Month surface remains the existing shared CalendarView; this document covers only the Week, 3 days, Day, Time, and Stack scheduler surfaces. The directory-backed card storage contract remains Kanban Directory Model.
This page is the contract for the engine boundary. If an adapter needs a different storage grammar or a different meaning for a range, it must translate at the adapter seam rather than change the generic engine's civil-time model.
Ownership and public surface
The engine is bespoke: TimeGridView<T>, StackView<T>, pure layout modules, and one drag-session primitive. It does not use FullCalendar. Both generic components consume the same item contract:
type SpanEndpoint = {
kind: "date" | "datetime";
value: string;
};
type ItemSpan = {
start: SpanEndpoint;
end?: SpanEndpoint;
};An adapter supplies stable item identity, getItemSpan, an item renderer, an ARIA label, the visible view, and an anchor date. Mutation callbacks are separate: Time emits onCreate, onMove, onResize, onUnschedule, and external-drop spans; Stack emits a day move as { fromDate, toDate, deltaDays }, plus create, unschedule, and external-drop callbacks. The engine does not write files or know what a kanban column means.
The time-axis views are week, 3day, and day. Week starts on Sunday by default, and the default hour labels are 24-hour labels. The generic grid uses 15-minute slots by default, a fixed 24-hour civil axis, and a caller-provided hour height. The Stack surface has no Y-as-time interpretation: each visible day is a naturally stacked column.
The pure contract is split by concern: day-segments.ts produces visible segments, overlap-layout.ts and allday-layout.ts assign rows, geometry.ts maps civil minutes to pixels and snaps pointer positions, and stack-order.ts orders Stack columns. day-head.tsx, TimeGridView, and StackView render those results; use-drag-session.ts supplies the shared interaction primitive.
The contracts below were established across the pure algorithms and scaffolding waves (S06/S07), the TimeGridView and StackView read-only surfaces (S08/S09), and the drag-session interaction layer (S10). The Kanban adapter owns the provider and card-file decisions around that shared primitive.
Floating civil time
Card timing is a floating local wall-clock value, not an instant. The accepted endpoint grammar is minute-precision YYYY-MM-DD for date and YYYY-MM-DDTHH:MM for datetime; it has no timezone suffix. The span model is deliberately the same shape as Kanban's timing: grammar.
All validation, geometry, snapping, day segmentation, and arithmetic use civil date/minute helpers. Never round-trip timing through Date. In particular, do not parse a timing string with new Date, compare it as an epoch, or convert it to a Date and back before writing it: a DST gap must not make a valid local wall-clock value disappear or move. Date is reserved for the UI boundary that derives the current local day/clock for the today marker and now-line (and for the view's display anchor); it is never an intermediate representation of a card timing.
The mutation path has one owner for normalization:
engine emits ItemSpan
→ kanban normalizeCardTiming (civil snap + range normalization)
→ provider updateCard
→ card frontmatter timing:The parser owns normalization; the engine must not duplicate parser arithmetic. The default grid snap is 15 minutes and explicit timed ranges receive the parser's minimum logical duration. A point datetime stays a point in storage; its visual floor is a geometry concern only.
Span and segment semantics
The timing grammar is the span model, with the following binding meanings:
datetime/datetimeis a timed half-open interval[start, end). It is split into one segment per covered civil day. A range ending at the next day'sT00:00does not occupy that ending day.A
datepoint or adate/daterange is an all-day span over inclusive civil dates. The all-day row lays out those inclusive ranges in the minimum number of rows.Mixed endpoint kinds are all-day over the covered dates. They are editable as all-day spans; conversion back to a timed span is an adapter normalization, not a time-grid reinterpretation.
A point
datetime(noend) is shown as a 30-minute visual block labelled with its start. An explicit zero-length timed segment has the same one-slot visual floor after layout; neither changes the logical span.A segment outside the configured visible hours is clipped. The day header reports the number continuing before (
↑n) or after (↓n) the visible range.
Each timed segment retains its source span and continuesBefore / continuesAfter flags. Resize handles are exposed only on an actual segment edge, so a continued or clipped segment cannot accidentally edit a hidden edge. The one-slot visual floor is applied after lane layout and never feeds back into overlap arithmetic.
Overlap lanes and all-day rows
Timed segments use deterministic interval partitioning, lanes only (no staggered overlap mode):
Sort by start ascending, end descending, then stable id.
Split the sorted intervals into clusters using the running maximum end. A segment whose start is at or after the cluster's maximum end begins the next cluster.
Within each cluster, place each interval in the smallest free column whose previous end is no later than its start.
Expand into trailing columns when no interval in that column overlaps the segment.
For C columns, geometry is left = col / C and width = span / C. Points do not consume an overlap lane; geometry gives them their visual floor after this calculation. Invalid or reversed intervals are omitted from layout.
All-day ranges use inclusive bounds and the same minimum-row principle: ranges that overlap on a date cannot share a row. Their visible row is separate from the timed grid.
Stack ordering is also deterministic: all-day segments first (longest covered range first), then timed segments by start minute, with stable id as the final tie-breaker. A multi-day span appears in every covered day. Moving a card between Stack columns reports the source date, destination date, and signed civil-day delta; timed cards retain their time of day.
Interaction and accessibility contract
use-drag-session is per component instance and captures the pointer on the source element. It starts after a 3px movement threshold. Hit-testing uses elementFromPoint plus the owning instance's DropTargetRegistry, so one calendar cannot consume another calendar's drop. A session is cancelled by pointercancel, lostpointercapture, window blur, document visibility change, Escape, unmount, or an explicit cancel. Edge auto-scroll runs through requestAnimationFrame and is capped at 20px per frame. Mouse and pen pointer-down prevents the browser's competing gesture; touch does not support drag-create and starts item dragging only after a 300ms press.
The calendar subtree does not use @dnd-kit. External sources register a drop target with the shared registry; the unscheduled tray registers its own target, and dropping a scheduled item there invokes the adapter's unschedule callback.
The item DOM shape is stable for keyboard and assistive technology: a div.tg-item contains a button.tg-item-body, with sibling resize spans on the editable edges. Focus is requested again after a mutation. The Kanban keyboard contract is:
| Focused surface | Key | Mutation |
|---|---|---|
| Any scheduler card | Enter | Open the card editor |
| Any scheduler card | Delete / Backspace | Unschedule; never delete the card |
| Timed card | ↑ / ↓ | Move by one slot |
| Timed card | Shift + ↑ / ↓ | Resize the end by one slot |
| Timed card | Alt + Shift + ↑ / ↓ | Resize the start by one slot |
| Time or Stack card | Alt + ← / → | Move one civil day |
The surrounding Kanban view additionally handles ←/→ for range navigation, T for today, W/3/D for the scheduler view, and S for Time/Stack when focus is not in an editor. The Stack adapter's day move carries { fromDate, toDate, deltaDays }; the time-axis adapter emits the normalized span instead.
Kanban adapter and persistence seams
The Kanban adapter resolves a card's visible span with this precedence: timing first, then a date-only due, then an absolute notify datetime. Timing is identity-preserved when valid; a malformed present timing blocks the legacy fallbacks. Archived cards are excluded. Thus a due-only card is all-day, while an absolute-notify-only card is a timed point. The adapter renders the real board-card surface: surface background, edge border, medium radius, a priority left stripe and muted status chip; labels provide the card's categorical colors without per-column or per-event fill colors.
Calendar mutations use normalizeCardTiming, setCardTiming, moveCardScheduleByDays, and unscheduleCard from the parser package. Moving or resizing writes timing:. Unscheduling clears timing, due, and notify together, so a stale fallback cannot make the card reappear. Column membership and order remain the KANBAN.md manifest concern and are not changed by a calendar move.
Kanban calendar view state is separate from card storage. The provider persists exactly these eight fields in calendarViewState:
shared month fields:
minCellHeightPx,anchorMonth,unscheduledRevealed;scheduler fields:
view,layout,anchorDate,hourHeightPx,trayRevealed.
The provider's equality comparator compares exactly those eight fields—no more, no less—before mirroring deserialized props or dispatching a layout change. This value gate prevents fresh frameset objects from causing a provider update loop. hourHeightPx is clamped to the engine's 32–96px range and commits on slider release, keyboard completion, blur, or settle. anchorDate is settle-gated after navigation. Selection, drag ghosts, and the active List legend filter are component state and are not persisted.
The shared three-field CalendarViewState is the todo-board/month contract and is not expanded in place. Kanban uses KanbanCalendarViewState extends
CalendarViewState, so the existing Month engine remains unchanged.
Adopting the engine in todo-board
Todo-board can adopt the same engine without coupling it to Kanban storage:
Keep its existing Month
CalendarViewStateand add a todo-scoped extension for the scheduler's view, layout, anchor date, hour height, and tray state.Adapt each todo item to
ItemSpanwith a stable id and a renderer. Keep the todo parser's own precedence and persistence rules at this adapter boundary; do not importKanbanCard,normalizeCardTiming, or the kanban provider.Render
TimeGridViewand/orStackViewwith callbacks that return pure spans or civil-day moves. Normalize and persist those values in the todo provider, retaining the no-Daterule for timing strings.Reuse the generic lane, segment, geometry, all-day, keyboard, and drag-session contracts. Keep todo-specific styling, unschedule meaning, external-drop targets, and accessibility labels in the todo adapter.
This split lets both boards share the difficult civil-time and interaction machinery while preserving separate storage seams and leaving the existing Month view untouched.