feat(tasks): linked tasks sync title and subtasks with their note in both directions
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 2m32s

Subtasks of a task linked to a note checkbox are tied 1:1 to checkboxes of that note
(subtasks[].blockId), wherever they are moved. ReconcileDocTaskLinks now carries tick, title and
subtasks from the note to the task and adopts new checkboxes directly under the task;
SyncTaskToDoc carries tick, title and subtasks to the note and adds checkboxes for new subtasks.
Deleting is not mirrored. Deepening a task ties its subtasks right away; saves without links keep
the server's links. The sync response reports taskLinks/subtaskLinks; the editor shows an
"Unteraufgabe" badge. v0.36.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 12:29:10 +01:00
1 parent f91926c208
commit 5a8922011d
20 files changed
+511 -53

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.35.0",
"version": "0.36.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.35.0",
"version": "0.36.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.35.0",
"version": "0.36.0",
"license": "Unlicense",
"type": "module",
"scripts": {
@@ -128,6 +128,7 @@ function sameBlocks(a: DocumentBlock[], b: DocumentBlock[]): boolean {
!!x.collapsed !== !!y.collapsed ||
(x.lang || '') !== (y.lang || '') ||
(x.taskId || '') !== (y.taskId || '') ||
(x.subtaskOf || '') !== (y.subtaskOf || '') ||
(x.conflictOf || '') !== (y.conflictOf || '')
) {
return false;
@@ -360,10 +361,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
setBlocks(normalized);
}
const links = new Map<string, string>();
const subtaskLinks = new Map<string, string>();
for (const b of fetched.blocks || []) {
if (b.taskId) links.set(b.id, b.taskId);
if (b.subtaskOf) subtaskLinks.set(b.id, b.subtaskOf);
}
engineRef.current?.setTaskLinks(links);
engineRef.current?.setTaskLinks(links, subtaskLinks);
setLoading(false);
onDocMetadataChange(fetched);
@@ -1488,6 +1488,24 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</button>
</div>
)}
{/* Checkbox tied to a subtask of a linked task: tick and text follow the subtask */}
{block.type === 'todo' && !block.taskId && block.subtaskOf && (
<div className="flex items-center gap-1 mt-0.5 select-none">
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(block.subtaskOf as string);
}}
className="badge-neutral text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
title="Unteraufgabe einer verknüpften Aufgabe – Aufgabe öffnen"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Unteraufgabe</span>
</button>
</div>
)}
{/* Conflict copy: text that lost against someone else's change */}
{block.conflictOf && (
@@ -1619,7 +1637,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
className="absolute right-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
{block.subtaskOf && !block.taskId ? (
<p className="px-2 py-1.5 text-faint">Unteraufgabe einer verknüpften Aufgabe</p>
) : !block.taskId ? (
<button
type="button"
onClick={async () => {
+7 -1
View File
@@ -121,7 +121,12 @@ export function diffFlat(d: Doc, blocks: DocumentBlock[], newOpId: () => string)
}
/** Converts the tree into the flat list used by the editor components. */
export function toDocumentBlocks(d: Doc, docId: string, taskLinks?: Map<string, string>): DocumentBlock[] {
export function toDocumentBlocks(
d: Doc,
docId: string,
taskLinks?: Map<string, string>,
subtaskLinks?: Map<string, string>
): DocumentBlock[] {
return flatten(d).map((b, i) => ({
id: b.id,
documentId: docId,
@@ -133,6 +138,7 @@ export function toDocumentBlocks(d: Doc, docId: string, taskLinks?: Map<string,
level: b.level,
position: (i + 1) * 1000,
taskId: taskLinks?.get(b.id),
subtaskOf: subtaskLinks?.get(b.id),
conflictOf: b.props.conflictOf,
conflictAuthor: b.props.conflictAuthor,
}));
+7 -2
View File
@@ -133,6 +133,7 @@ export class DocSyncEngine {
private lockedByOthers: LeaseInfo[] = [];
private conflicts: Result[] = [];
private taskLinks = new Map<string, string>();
private subtaskLinks = new Map<string, string>();
private focused: string | null = null;
private activity = new Map<string, number>(); // blockId -> last edit/focus time
@@ -234,7 +235,7 @@ export class DocSyncEngine {
errorMessage: this.errorMessage,
role: this.role,
title: this.local.title,
blocks: toDocumentBlocks(this.local, this.docId, this.taskLinks),
blocks: toDocumentBlocks(this.local, this.docId, this.taskLinks, this.subtaskLinks),
pendingCount: this.pending.length + (this.inflight?.length || 0),
shared: this.shared,
sessions: this.sessions,
@@ -252,8 +253,9 @@ export class DocSyncEngine {
}
/** Task IDs of blocks linked to tasks (kept outside the synced tree). */
setTaskLinks(links: Map<string, string>): void {
setTaskLinks(links: Map<string, string>, subtaskLinks?: Map<string, string>): void {
this.taskLinks = new Map(links);
if (subtaskLinks) this.subtaskLinks = new Map(subtaskLinks);
this.emit();
}
@@ -562,6 +564,9 @@ export class DocSyncEngine {
this.role = res.role;
this.shared = res.shared;
this.sessions = res.sessions;
// The server reports the links after every sync, so badges follow subtasks it adopted or added
if (res.taskLinks) this.taskLinks = new Map(Object.entries(res.taskLinks));
if (res.subtaskLinks) this.subtaskLinks = new Map(Object.entries(res.subtaskLinks));
this.presence = res.presence || [];
this.lockedByOthers = res.leases?.others || [];
+3
View File
@@ -51,6 +51,9 @@ export interface SyncResponse {
sessions: number;
presence: Presence[];
leases: { granted: string[]; denied: string[]; others: LeaseInfo[] };
// Checkboxes linked to a task / to a subtask (block → task), for the badges
taskLinks?: Record<string, string>;
subtaskLinks?: Record<string, string>;
}
/** status 0 = network error or timeout; otherwise the HTTP status. */
+4
View File
@@ -138,6 +138,8 @@ export interface SubTask {
title: string;
isCompleted: boolean;
createdAt?: string;
// Note checkbox this subtask is tied to (its task is linked to a note); kept by the server
blockId?: string;
}
// Repeat rule of a task: completing it creates the next one (due date one interval later)
@@ -315,6 +317,8 @@ export interface DocumentBlock {
docId?: string;
documentId?: string;
taskId?: string;
// Checkbox tied to a subtask of this task (the task itself is linked to another checkbox)
subtaskOf?: string;
type: BlockType;
content: string;
checked?: boolean;