Skip to content

feat(database): add Notion-style timeline view - #555

Merged
appflowy merged 21 commits into
mainfrom
feat/timeline-view
Sep 16, 2026
Merged

appflowy merged 21 commits into
mainfrom
feat/timeline-view

Conversation

@appflowy

@appflowy appflowy commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a Timeline database layout (DatabaseViewLayout.Timeline = 8, ViewLayout.Timeline = 10) that plots rows as bars on a horizontally scrolling, infinitely growing canvas next to a docked table — the Notion timeline anatomy, styled to match the calendar view.

Depends on the server change in AppFlowy-IO/AppFlowy-Cloud-Premium#1162 (new layout ids, timeline document block type, TimelineLayoutSetting).

What's included

  • Renderer in src/components/database/timeline/, ported from frappe/gantt (MIT, attributed in file headers) and rebuilt on our Yjs selectors / dispatch hooks, @tanstack/react-virtual rows and src/components/ui.
  • Scales: Hours / Day / Week / Bi-week / Month / Quarter / Year, with today pill, weekend shading, steppers and Today.
  • Bar interactions: move, resize-start / resize-end and progress drags with column snapping, auto-scroll near the edges, Escape to cancel, grouped undo / redo; hover card; selection + keyboard open; off-screen pills; undated rows in the table / No Date list that a canvas click dates.
  • Docked table: Notion's hover gutter (+ insert below, ⋮⋮ menu with insert above / below, duplicate, delete, and drag to reorder), table properties as extra columns (configured separately from bar chips) and a calculations footer.
  • Dependencies: one-click setup creates a two-way Blocked by / Blocking pair (also triggered by the first connector drop); the bound field may list either side; arrows from the relation; drag a bar's connector onto another bar to link; click an arrow to edit the link's type (FS / SS / FF / SF) and lag / lead in days or remove it; Shift dependents setting (only when dates overlap — default, keep the time between items, never); Avoid weekends; type- and lag-aware clamps (a finish-to-start dependent cannot start before its dependency finishes) unless shifting is off.
  • Separate start & end date fields (End date setting) — drags write both cells as one undo group.
  • Group by a property: group header rows spanning the canvas (collapse, count, hide, remove grouping, quick add), per-group "+ New row".
  • Progress: fill from a number field, editable via the progress handle.
  • Everywhere a calendar is: view tabs +, Layout-menu conversion, sidebar New page → Timeline, slash menu /timeline and /linked timeline (new timeline document block), page modal, publish, mobile outline / chat icons, copy-link.
  • Storage mirrors the calendar: layout_settings['8'] with layout_ty, first_day_of_week_v2 (fallbacks), field_id, end_field_id, show_table, table_field_ids, dependency_field_id, dependency_direction, dependency_links, dependency_shift_ty, avoid_weekends, progress_field_id, hide_empty_groups; use24Hour from user metadata; read-only viewers keep a local override.
  • Row selectors re-read the user's own writes in a microtask (createLocalFirstObserver) so a dropped bar never snaps back while the 150 ms remote debounce catches up.
  • Fixes a latent List bug on the way: the ⋮⋮ row handle could never start a native drag because Radix cancelled pointer-down; it now opens on click.
  • Gated by TIMELINE_VIEW_ENABLED in src/application/constants.ts.

Feature Preview

Screenshots (Month scale with a dependency arrow and 40% progress fill; Week scale; table hidden; table columns + calculations; grouped) — to be dropped in from the session; the CLI cannot upload images.


Checklist

General

  • I've included relevant documentation or comments for the changes introduced.
  • I've tested the changes in multiple environments (e.g., different browsers, operating systems).

Testing

  • I've added or updated tests to validate the changes introduced for AppFlowy Web.
    • jest: timeline/__tests__/geometry.test.ts, dependencies.test.ts (shift modes, avoid weekends, link types / lag, per-type routing), database-yjs/__tests__/timeline-layout.test.ts, timeline-events-selector.test.tsx (start/end fields), local-first-observer.test.ts
    • Playwright e2e: playwright/e2e/database/timeline.spec.ts (2)
    • Playwright BDD: playwright/bdd/features/database/timeline.feature (36 scenarios: drag / snap / undo / redo, no snap-back on drop, resize + Escape, hover card, selection & open, undated rows + hide table, dependencies for every shift mode, avoid weekends, connector drag, one-click setup, Blocking-side binding, arrow editor (type / lag / remove), progress handle, every scale, steppers / pills / Today, No Date list, settings, Layout-menu conversion, empty state, row gutter insert / duplicate / delete / reorder, separate start & end fields, table columns + calculations, grouping) and timeline-integration.feature (4: New page menu, /timeline, linked timeline + page modal, published read-only)
    • pnpm type-check, pnpm lint clean

Feature-Specific

  • For feature additions, I've added a preview (video, screenshot, or demo) in the "Feature Preview" section.
  • I've verified that this feature integrates seamlessly with existing functionality.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM

Add a Timeline database layout (DatabaseViewLayout.Timeline = 8,
ViewLayout.Timeline = 10) that plots rows as bars on a horizontally
scrolling, infinitely growing canvas next to a docked table.

Renderer ported from frappe/gantt (MIT, attributed in file headers) and
restyled with the calendar's toolbar, gutters, today pill and event cards:
- Hours / Day / Week / Bi-week / Month / Quarter / Year scales
- Move, resize-start / resize-end and progress drags with column snapping,
  auto-scroll near the edges, Escape to cancel, undo / redo groups
- Dependency arrows from a self-relation field; dependents follow the
  dragged bar and a bar cannot start before its dependencies
- Progress fill from a number field
- Hover card, selection, keyboard open, off-screen pills, Today / steppers
- Undated rows in the table and the No Date list; click on the canvas
  dates them

The layout setting is stored like the calendar's under
layout_settings['8'] (layout_ty, first_day_of_week_v2 with the legacy and
user-metadata fallbacks, field_id, show_table, dependency_field_id,
progress_field_id); read-only viewers keep a local override.

Tests: 29 jest cases (geometry, dependencies, layout setting), a
Playwright e2e spec and a 22-scenario BDD feature covering every
interaction.

Requires the matching server change in AppFlowy-Cloud-Premium
(feat/timeline-layout) so the new layout ids are accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @appflowy, your pull request is larger than the review limit of 150,000 diff characters

appflowy and others added 20 commits September 13, 2026 14:43
Dropping a bar cleared the drag preview at once, but the row selectors
re-read cell data through a 150 ms trailing debounce, so the bar painted
at its old dates for several frames before jumping to the new ones.

Add createLocalFirstObserver: the user's own transactions re-read in a
microtask (folding a dropped bar and its followers into one read, flushed
with the drop itself), while remote bursts keep the debounce. Wire it into
useDateFieldEventsSelector and useTimelineFieldValues, so date, progress
and dependency edits all land in the same paint.

A new BDD scenario samples the bar's position every animation frame across
a drop and fails if any frame after the first move shows the origin again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
Register ViewLayout.Timeline in every layout switch the calendar is in:
isDatabaseLayout, the view-loader collab-type map, ViewModal, published
CollabView / DatabaseView skeletons, copy-link row params, first-child
navigation, mobile outline and chat icons.

Add the `timeline` document block type so a timeline can be embedded
from the slash menu (Timeline / Linked Timeline) and created from the
sidebar's New page menu.

BDD: timeline-integration.feature covers the New page menu, both slash
entries (including the page modal), and a visitor opening a published
timeline read-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
Give the docked table Notion's hover gutter: `+` inserts a row below,
`⋮⋮` opens insert above / below, duplicate and delete, and dragging it
reorders rows (with the List view's clear-sorts confirmation). The gutter
is editor-only; header and "+ New row" line up with it.

Reuse the List view's ListRowActions and move its row DnD hook to the
shared drag-and-drop folder (useRowDnd, now with an optional wider drop
target so a drop anywhere along a timeline row counts).

Fix a latent List bug on the way: Radix opened the ⋮⋮ menu on
pointer-down and cancelled the event, which stopped the browser from ever
starting the native drag on the same handle. The trigger now opens on
click, so the handle can be dragged.

BDD: hover gutter insert / insert above / duplicate / delete, drag
reorder + undo, and no gutter for published visitors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
Notion's dependency options on the timeline settings:
- Shift dependents: "Only when dates overlap" (default — a follower moves
  just far enough to start after the bar it depends on, cascading through
  the chain), "Keep the time between items" (the former behaviour), or
  "Never" (followers stay, and a dependent may be dragged before its
  dependency). Stored as `dependency_shift_ty`.
- Avoid weekends: a shifted follower never lands on a Saturday or Sunday
  (`avoid_weekends`).
- Drag to connect: every bar shows a connector handle when a dependency
  field is bound; dragging it onto another bar appends the source row to
  the target's relation cell, with duplicate and cycle guards. A dashed
  connector follows the pointer.

useUpdateRelationCell is now a thin wrapper over a row-agnostic
useUpdateRelationCellDispatch. The layout setting reader also carries
`end_field_id` for the upcoming separate start/end feature.

Tests: 4 new jest cases for the shift math; BDD scenarios for each shift
mode, avoid weekends (date-aware) and the connector drag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
Notion's "separate start and end dates": an optional End date field
(`end_field_id`) in the timeline settings makes each bar run from the
start field's date to the end field's date. A row whose end is missing
or earlier than its start is a single-unit bar; a row without a start
stays undated.

Drags write the two cells as one undo group; moving a bar that has no
end only moves its start, while the end handle creates the end date.

Tests: a hook test for the merged selector (range, backwards, open,
unbinding) and a BDD scenario covering bind, move, undo, resize and
unbind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
Notion's table properties: a "Table properties" submenu in the timeline
settings picks which properties appear as columns of the docked table
(`table_field_ids`, kept in the view's property order, separate from the
bar's chips). Each column is 140px, headed by the field's icon and name,
and rendered with the same CardField cells the board uses. A calculations
footer under the table offers the grid's calculation menu per column.

GridCalculateRowCell now accepts explicit row orders so it works outside
the grid provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
Group the timeline the way the List groups: a header row per visible
group (the List's header — collapse toggle, value tag, count, hide /
remove-grouping menu, quick add) with a tinted band across the canvas,
the group's rows beneath it, and a per-group "+ New row" footer that
creates rows already holding the group's value. Collapsed and hidden
groups drop out of the canvas; bar and arrow geometry stays index-based
over the mixed item list.

The grouping selector, hide-empty toggle and layout-setting accessor now
understand DatabaseViewLayout.Timeline (`layout_settings['8']`), a
TimelineGroupingProvider wraps the view in DatabaseViews, and the
settings menu gains the shared Group submenu. Manual row reordering is
off while grouped; inserts from the row gutter stay in their group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
The default became "only when dates overlap"; the e2e spec asserts frappe's
move_dependencies behaviour, which the BDD suite now covers per mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
…inding

- Set up dependencies (settings item, or the first connector drop on a
  view without them): creates a two-way "Blocked by" / "Blocking"
  self-relation pair (or reuses an existing one), binds it, shows both as
  table columns and keeps them off the bars — Notion's one click.
- The bound field may list either side: "Field lists: Blocked by /
  Blocking" (`dependency_direction`) flips the edges so a "Blocking"
  property draws the same arrows and writes go to the right cell.
- Per-link type and lag: clicking an arrow opens an editor with
  finish-to-start / start-to-start / finish-to-finish / start-to-finish,
  a lag in days (negative = lead) and Remove dependency. Stored in
  `dependency_links` keyed "predecessor:successor"; missing = FS, lag 0.
- Arrows route per type (FS keeps frappe's route; the others run
  orthogonally between the matching edges, detouring along the row
  boundary), and the shift math and drag clamps honour type and lag:
  start-type links bound a bar's start, end-type links its end. The
  finish-to-start clamp now means "cannot start before its dependency
  finishes" (was: before it starts).

Tests: 7 new jest cases (direction, constraintStart per type/lag,
SS + lag shifting, minEnd clamp, per-type routing); BDD scenarios for
setup, connector-driven setup, Blocking-side binding, and editing type /
lag / removal from the arrow; e2e clamp expectation updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
A dependent could not be dragged over or before the bar it depends on:
the drag stopped at the constraint (frappe's rule). Notion places the
dragged bar where it is dropped and re-routes the arrow; only the bar's
own dependents shift. Drop the minStart / minEnd clamps and the
"Cannot start before its dependencies" string.

Also lock in that dragging writes nothing until the drop: a scenario
counts Yjs updates on the dragged row and its follower while the pointer
is held with both bars visibly moved (zero), then after release (both
written).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
…able

Making arrows clickable had lifted their SVG above the rows, so lines
were painted over cards and, once the canvas scrolled, over the sticky
table cells. Put the layer back beneath the rows (cards cover the lines,
the table hides them) and hit-test link clicks from the row canvas
instead, via isPointInStroke on each arrow's wide invisible twin.

The click the browser fires after a drop (on the common ancestor of the
press and release targets) is now ignored by the canvas, so releasing a
bar over a line no longer opens the link editor or clears the selection.

BDD: a scenario asserts the line loses the hit test to the row layer and,
after scrolling, to the docked table cell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
- TimelineLinkEditor derives its lag draft during render (previous-prop
  pattern) instead of an effect, and is memoized; the view memoizes the
  anchor selection and close handler so an open editor no longer
  re-renders on every scroll frame.
- readTimelineLayoutSetting caches parsed dependency links and table
  field ids per stored Yjs value (WeakMap) — getSnapshot runs on every
  subscriber render and was re-allocating both each time.
- buildDependencyGraph dedupes edges with a Set instead of an O(n)
  includes per insert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
The header placed the table toggle after the property columns while each
row placed its open button before them, so column edges in the header sat
one button-width left of the cells. Rows now keep the open button in the
same trailing slot as the toggle, and the calculations footer reserves
that slot too; a BDD assertion checks header, cell and calculation x.

Also a second react-best-practice pass on the last three commits:
- the connector-drag hook bound its window listeners per pointermove
  (its effect depended on the preview object); it now depends on a
  boolean like the bar drag does
- one memoized row index serves the bar press and the link editor's
  titles instead of a Map rebuild per press and two rescans per render
- the layout-setting comparators short-circuit on identity now that
  parsed links / ids are cached

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
Three re-render findings from reviewing the branch against the Vercel
React rules, all on hot paths:

- TimelineRow handed the memoized TimelineBar a fresh onLinkPointerDown
  lambda every render, re-rendering every bar's cells and tooltip subtree
  on scroll-pill flips, drag start/end and selection; it is a stable
  useCallback now.
- The header's drag highlight re-reconciled every column label per drag
  step; the labels are their own memo child (HeaderColumns) so a step
  touches one element.
- Every table row mounted its own useNewRowDispatch / useDuplicateRowDispatch
  (each a bundle of database-context subscriptions read only in click
  handlers). ListRowActions is split into a presentational RowActionsMenu
  plus the List's hook-owning wrapper; the timeline creates the dispatches
  once and hands rows a stable actions object (deps behind a ref).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
The bar's hover card aligned to the bar's start and clamped only to the
viewport, so a bar running under the sticky table opened its card over
the table cells. The tooltip now pads its collision boundary on the left
up to the table's right edge (measured once per opening), so the card
shifts right of the table while still sitting above the bar. Covered by
a new step in the hover scenario.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
* Fix duplicate feed layout import

* Add Confluence HTML ZIP import to web

* Improve import cancellation, keyboard access, and lazy loading

* Test native file chooser activation for page imports

* Enable Confluence workspace import from settings

* Open imported space home pages from the sidebar
…550)

* fix: preserve calendar draft isolation on dismissal and save failure

* fix: handle row registration delay during document duplication
Selecting a row swapped the docked table cell's opaque background for
the theme's selection fill, which is translucent — so a bar or arrow
scrolled under the table showed through the selected row, as if drawn
over the cells. The tint is now painted over the opaque background.
The hover scenario checks the selected cell's computed background.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoEH4HcEiojEcioVSLE3QM
Read desktop-authored Yjs table columns and dependency metadata, including nested updates. Align new-row and calculation footers, keep the table divider continuous, and route dependency connectors to the bar edges without reversed bends.

Validated with TypeScript checks, focused timeline tests, and live desktop and web checks.
@appflowy
appflowy merged commit a6ae14f into main Sep 16, 2026
16 of 17 checks passed
@appflowy
appflowy deleted the feat/timeline-view branch September 16, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant