Skip to content

feat(tasks): link canvases from the Related picker - #1879

Merged
h4yfans merged 8 commits into
mainfrom
task-related-canvas
Aug 28, 2026
Merged

feat(tasks): link canvases from the Related picker#1879
h4yfans merged 8 commits into
mainfrom
task-related-canvas

Conversation

@h4yfans

@h4yfans h4yfans commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #1849

Behavior change

The task drawer's Related picker listed only notes, so a canvas could not be linked to a task at all. It now lists canvases alongside notes, each with its own icon, and clicking a linked canvas opens it in a canvas tab.

The same pass fixes the second defect the issue flags: the picker fetched the 50 most recently modified notes once and filtered that array in memory, so a note outside that window was unfindable no matter what you typed. Note results now come from the existing FTS index (search:query), so the whole vault is reachable. The empty-query case still lists recent notes, so opening the picker looks the same as before.

Storage design

linkedNoteIds is not a column — it is the task_notes junction table. The canvas half mirrors it as a new task_canvases junction, with linkedCanvasIds added to the sync payload beside linkedNoteIds. linkedNoteIds is untouched and no canvas id ever enters it.

Two alternatives were rejected on backward-compatibility grounds, not taste:

Option Old-client round-trip Verdict
linkedCanvasIds as a JSON column on tasks Wiped. The plain-apply branch writes scalar columns unguarded (description: data.description ?? null), so an absent key clears the column Rejected
Polymorphic task_links(task_id, item_type, item_id) Breaks the guard — an older client writing linkedNoteIds would union-merge against a table it can only see half of. Also forces migrating existing task_notes rows Rejected
task_canvases junction mirroring task_notes Preserved. Junction writes are guarded, so an absent key means "unchanged" Chosen

At the UI layer the two disjoint fields are unioned into a discriminated reference ({ kind: 'note' | 'canvas'; id }) with kind:id React keys. A note and a canvas can share an id, so a bare string[] would have been ambiguous.

linkedCanvasIds is deliberately not added to TASK_SYNCABLE_FIELDS. Junction relations carry no field clocks; they are union-merged, exactly like tags and linkedNoteIds.

Migration

apps/desktop/src/main/database/drizzle-data/0055_task_canvases.sql, hand-written (the Drizzle generator has been off since 0020; db:generate was not run). Journal entry 55 hand-appended, no meta snapshot.

Additive only: one CREATE TABLE IF NOT EXISTS, no ALTER, no backfill, no DELETE. Existing installs upgrade by gaining an empty table; every tasks row and every task_notes row is left exactly as it is.

It mirrors task_notes column for column including the absence of a foreign key to canvases. A FK there would make sync apply order load-bearing: a task arriving before the canvas it links to would fail the whole upsert. The ON DELETE CASCADE FK to tasks stays so deleting a task reclaims its links.

Older-client analysis

The issue asked whether a round-trip through an old client drops the new field. Traced end to end:

  1. Old client receives a task carrying linkedCanvasIds. TaskSyncPayloadSchema is a plain non-strict z.object, so Zod strips the unknown key. The task applies normally — it does not reject. This matters: a schema failure returns 'skipped', which advances the cursor permanently. The schema is deliberately left non-strict.
  2. Old client pushes the task back. It builds its payload from its own DB and has no canvas rows, so the key is absent entirely — not empty.
  3. New client applies that payload. Every canvas write sits behind if (data.linkedCanvasIds), so an absent key preserves the local rows. Canvas links survive the round-trip.

An explicit linkedCanvasIds: [] still clears, which is how a real unlink travels. Absent means "no information"; empty means "none". That distinction is what makes the round-trip safe, so it is covered by tests in both the merge and plain-apply branches.

Rollback is inert: an older build never reads task_canvases, and its migrator's max journal when is lower, so it applies nothing and never drops the table.

One inherited property worth naming: junction merges are unions, so a concurrent unlink loses to a link. Canvas links behave exactly like note links here. This is pre-existing for notes and was matched rather than diverged from; changing it needs per-link tombstones and belongs in its own issue.

Verification

  • Reviewer-side mutation testing. Removing the guard on the plain-apply branch fails exactly preserves local canvas links when an older client omits linkedCanvasIds on APPLY and nothing else. Removing the merge-branch guard and union fails exactly the two MERGE tests. The guards are load-bearing and the tests prove it.
  • Main-process: 4 files, 54 tests passing, covering the migration upgrade path (leaves an existing task and its note links untouched when upgrading, idempotent re-execution, cascades on task delete and has no foreign key to canvases), the three compat tests above, union-merge, and both push paths.
  • Renderer: 35 tests passing, including lists notes and canvases together in the related picker and links a canvas from the picker and opens the canvas when its row is clicked.
  • pnpm lint 0 · desktop typecheck:web / typecheck:node / typecheck:test 0 · pnpm ipc:generate && pnpm ipc:check 0 with no regenerated diff · git diff --check clean · docs:impact --strict and docs:build 0.
  • Nothing was added to the tsconfig.test.* exclude backlog.

Notes for the reviewer

  • pnpm ipc:generate needs Node 24 (--experimental-transform-types was removed in Node 26); the native modules are built for the Node running the tests.
  • Repo-wide pnpm typecheck currently fails on apps/mobile/vitest.config.ts -> node:url, an architecture-boundary violation present on origin/main and untouched by this branch. main is independently red on Desktop CI and an E2E shard as of the feat(mobile): UI foundation and the four search screens #1863 merge.
  • Out of scope, deliberately: the kanban card badge and task-linked-note-indicator still count only note links, so a task linked solely to a canvas shows no badge. Cosmetic, and widening those is a separate change.

@github-actions github-actions Bot added documentation Improvements or additions to documentation enhancement New feature or request test labels Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

React Doctor found 1 new issue in 1 file · 1 warning · score 93 / 100 (Great) · 1 fixed · vs main

1 warning

src/renderer/src/components/tasks/task-detail-drawer.tsx

  • ⚠️ L717 transition: all animates everything no-transition-all

Reviewed by React Doctor for commit d53f190. See inline comments for fixes.

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.47368% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...nderer/src/components/tasks/task-detail-drawer.tsx 87.35% 11 Missing ⚠️
apps/desktop/src/main/database/queries/tasks.ts 37.50% 5 Missing ⚠️
apps/desktop/src/renderer/src/pages/tasks.tsx 50.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@h4yfans

h4yfans commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-ups after the first CI run

A regression CI caught that my targeted test run had missed. enrichTask now reads the task_canvases junction, and tasks-handlers.test.ts partially mocks @main/database/queries/tasks. The mock did not list getTaskCanvasIds/setTaskCanvases, so all 25 handler tests failed on the missing export. Fixed in b0f356092; the GET assertion now passes a real canvas id so the enrichment is covered rather than defaulted to []. Unit & integration tests passes on the following run.

Test coverage for the new hook (a33fc2fe7), prompted by the Codecov comment. The hook's two sources settle independently so one failing cannot blank the other — that invariant was stated in a comment and nothing tested it.

Worth flagging how that test was validated, because the first two versions of it passed against deliberately broken code. mockRejectedValue settles before the sibling source's .then chain does, so a handler that wrongly cleared its sibling's results had its damage overwritten before the assertion ran. The failure cases now reject on a timer, which reproduces the realistic ordering; mutating the hook to clear the sibling fails exactly that test and nothing else.

React Doctor's transition-all warning is a false positive on "new". transition-all appears twice in this file on origin/main and twice here — unchanged. It is flagged because the union refactor moved the line. Changing it would not trace to this issue, so it is left alone.

CI status. Everything this PR owns is green. The one red check, Lint, typecheck, test, and boundary check, is a pre-existing main breakage this branch does not touch — apps/mobile/vitest.config.ts -> node:url, present identically on origin/main. Fixing it revealed a second one behind it (a stale editor-web build artifact). Both are fixed in #1882; this PR goes green once that lands.

@h4yfans
h4yfans force-pushed the task-related-canvas branch from a33fc2f to 96d9191 Compare August 28, 2026 07:26
Storage and contract groundwork for listing canvases alongside notes in the
task drawer's Related picker (#1849).

`task_canvases` mirrors `task_notes` column for column, including the absence
of a foreign key to `canvases` so sync apply order stays irrelevant. The new
`linkedCanvasIds` field is optional at every type site, which keeps existing
task fixtures compiling and lets an older client omit the key entirely.
Asserts an existing task and its `task_notes` rows survive the migration
untouched, that the statements are re-runnable, and that the table cascades
from `tasks` while holding no foreign key to `canvases`.
Adds `linkedCanvasIds` to the task handler's three receive branches, to
`buildPushPayload` and to `seedUnclocked`, and to the sync client's shared
junction enrichment.

Every receive-side write is guarded, so a payload that omits the key preserves
local links while an explicit empty array still clears them. That guard is what
keeps an older client, whose non-strict payload schema drops the key on parse,
from erasing canvas links when it pushes a task back. Merge unions both sides
the way tags and note links already do; `linkedCanvasIds` is deliberately not a
field-clocked column.
The picker now offers canvases alongside notes, each with its own icon, and a
linked canvas opens as a canvas tab. Related references are modelled as a
discriminated `{ kind, id }` union at the UI layer because a note and a canvas
can share an id, which a flat list of ids could not tell apart.

Note results now come from full-text search instead of a single fetch of the 50
most recent notes filtered in memory, so an older note is findable by title. An
empty query still falls back to recent notes. Canvases are matched client-side
because search does not index them.
…ounce

The empty query has no keystrokes to coalesce, so debouncing it only delayed
the first paint behind the results from the previous time the picker opened.
enrichTask now reads the task_canvases junction, so the partial mock of
@main/database/queries/tasks has to expose getTaskCanvasIds/setTaskCanvases or
every handler test fails on the missing export. GET asserts a real canvas id so
the enrichment is covered rather than defaulted.
The hook's two sources settle independently so one failing cannot blank the
other, which was asserted in a comment and nothing else. The failure cases
reject on a timer rather than with mockRejectedValue: an immediate rejection
settles before the sibling's .then chain, which hid a handler that cleared its
sibling's results.
@h4yfans
h4yfans force-pushed the task-related-canvas branch from 96d9191 to d53f190 Compare August 28, 2026 09:25
<span className="flex-1 min-w-0 text-[12px] text-text-secondary leading-4 truncate">
{info?.title ?? t('drawer.loading')}
</span>
<button

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-transition-all (warning)

Your users see janky animation because transition-all animates every property that changes, including expensive layout ones and instant ones like focus rings. Name the properties: transition-colors, transition-opacity, or transition-transform.

Fix → List the specific properties: transition: "opacity 200ms, transform 200ms". In Tailwind, use transition-colors, transition-opacity, or transition-transform

Docs

@h4yfans
h4yfans merged commit 0c97f0d into main Aug 28, 2026
27 of 28 checks passed
@h4yfans
h4yfans deleted the task-related-canvas branch August 28, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task drawer "Related" cannot link a canvas

1 participant