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
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:
1 parent
f91926c208
commit
5a8922011d
20 files changed
+511
-53
No files matched your search
@@ -0,0 +1,153 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/collab"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// edit applies operations to a note like a client would.
|
||||
func edit(t *testing.T, s *Store, userID, docID string, build func(d *collab.Doc) []collab.Op) {
|
||||
t.Helper()
|
||||
if _, _, err := s.applyDocChange(docID, collab.Actor{UserID: userID, ClientID: "test"}, nil, build); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func lastChildKey(d *collab.Doc, parent string) string {
|
||||
key := ""
|
||||
for _, c := range d.Children(parent) {
|
||||
key = c.OrderKey
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Linked tasks follow their note in both directions: tick, title and subtasks (1:1, wherever the
|
||||
// checkbox goes); new checkboxes directly under the task become subtasks; deleting is not mirrored.
|
||||
func TestLinkedTaskAndSubtasksFollowTheNote(t *testing.T) {
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "sync.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
th, _ := s.CreateThoughtWithDetails(anna.ID, "Umzug planen", "normal", "manual", "", "", "todo", "")
|
||||
_, _ = s.UpdateThoughtTriage(anna.ID, th.ID, "keep", "task", "", "", "")
|
||||
subs := []domain.SubTask{{ID: "st1", Title: "Kartons besorgen"}, {ID: "st2", Title: "Helfer fragen"}}
|
||||
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
|
||||
task, err := s.UpdateThoughtTriage(anna.ID, th.ID, "keep", "document", "", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docID, mainID := task.DocID, task.BlockID
|
||||
if task.Subtasks[0].BlockID == "" || task.Subtasks[1].BlockID == "" {
|
||||
t.Fatalf("deepening should tie the subtasks to their checkboxes: %+v", task.Subtasks)
|
||||
}
|
||||
kartons := task.Subtasks[0].BlockID
|
||||
if _, subLinks := s.DocTaskLinks(docID); subLinks[kartons] != th.ID {
|
||||
t.Fatalf("subtask checkbox should be reported for the badge: %v", subLinks)
|
||||
}
|
||||
|
||||
// Note → task: tick and title of a subtask, title of the main checkbox
|
||||
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
|
||||
yes := true
|
||||
text := "Umzugskartons besorgen"
|
||||
main := "Umzug nach Leipzig planen"
|
||||
return []collab.Op{
|
||||
{OpID: domain.NewID("op"), Kind: collab.OpSetProps, BlockID: kartons, Patch: &collab.PropsPatch{Checked: &yes}},
|
||||
{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: kartons, BaseVersion: d.Blocks[kartons].Version, Content: &text},
|
||||
{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: mainID, BaseVersion: d.Blocks[mainID].Version, Content: &main},
|
||||
}
|
||||
})
|
||||
if changed := s.ReconcileDocTaskLinks(docID); len(changed) != 1 {
|
||||
t.Fatalf("task should change: %v", changed)
|
||||
}
|
||||
got, _ := s.GetThoughtByID(anna.ID, th.ID)
|
||||
if got.Content != "Umzug nach Leipzig planen" || !got.Subtasks[0].IsCompleted || got.Subtasks[0].Title != "Umzugskartons besorgen" {
|
||||
t.Fatalf("note changes should reach the task: %+v", got)
|
||||
}
|
||||
|
||||
// New checkbox directly under the main one → subtask; one level deeper → stays in the note only
|
||||
var direct, deeper string
|
||||
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
|
||||
k1, _ := collab.KeyBetween(lastChildKey(d, mainID), "")
|
||||
direct, deeper = domain.NewID("blk"), domain.NewID("blk")
|
||||
p1, p2 := mainID, kartons
|
||||
t1, t2 := "Strom ummelden", "Zählerstand fotografieren"
|
||||
k2, _ := collab.KeyBetween("", "")
|
||||
return []collab.Op{
|
||||
{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: direct, ParentID: &p1, OrderKey: k1, Type: "todo", Content: &t1, Props: &collab.Props{}},
|
||||
{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: deeper, ParentID: &p2, OrderKey: k2, Type: "todo", Content: &t2, Props: &collab.Props{}},
|
||||
}
|
||||
})
|
||||
s.ReconcileDocTaskLinks(docID)
|
||||
got, _ = s.GetThoughtByID(anna.ID, th.ID)
|
||||
if len(got.Subtasks) != 3 || got.Subtasks[2].BlockID != direct || got.Subtasks[2].Title != "Strom ummelden" {
|
||||
t.Fatalf("checkbox right under the task should become a subtask (and only that one): %+v", got.Subtasks)
|
||||
}
|
||||
|
||||
// Moving a subtask checkbox elsewhere in the note keeps the link
|
||||
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
|
||||
top := ""
|
||||
k, _ := collab.KeyBetween(lastChildKey(d, ""), "")
|
||||
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpMove, BlockID: direct, ParentID: &top, OrderKey: k}}
|
||||
})
|
||||
s.ReconcileDocTaskLinks(docID)
|
||||
got, _ = s.GetThoughtByID(anna.ID, th.ID)
|
||||
if got.Subtasks[2].BlockID != direct {
|
||||
t.Fatalf("a moved checkbox stays tied to its subtask: %+v", got.Subtasks)
|
||||
}
|
||||
|
||||
// Task → note: tick and title of the main task and a subtask, a new subtask gets a checkbox
|
||||
done := true
|
||||
title := "Umzug nach Leipzig organisieren"
|
||||
subs = append(got.Subtasks, domain.SubTask{ID: "st9", Title: "Nachsendeauftrag stellen"})
|
||||
subs[1].IsCompleted = true
|
||||
// An app with an older state sends the subtasks without their links: the server keeps them
|
||||
for i := range subs {
|
||||
subs[i].BlockID = ""
|
||||
}
|
||||
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, &title, &done, nil, nil, nil, nil, nil, &subs, nil)
|
||||
if s.SyncTaskToDoc(anna.ID, th.ID) != docID {
|
||||
t.Fatal("the note should change")
|
||||
}
|
||||
d, _, _ := loadCollabDoc(s.db, docID)
|
||||
got, _ = s.GetThoughtByID(anna.ID, th.ID)
|
||||
if d.Blocks[mainID].Content != title || !d.Blocks[mainID].Props.Checked {
|
||||
t.Fatalf("main checkbox should follow the task: %+v", d.Blocks[mainID])
|
||||
}
|
||||
if !d.Blocks[got.Subtasks[1].BlockID].Props.Checked {
|
||||
t.Fatal("subtask tick should reach its checkbox")
|
||||
}
|
||||
added := got.Subtasks[3].BlockID
|
||||
if added == "" || d.Blocks[added].ParentID != mainID || d.Blocks[added].Content != "Nachsendeauftrag stellen" {
|
||||
t.Fatalf("new subtask should get a checkbox under the task: %+v", got.Subtasks)
|
||||
}
|
||||
if len(d.Children(mainID)) != 3 { // the two original subtasks and the new one ("Strom ummelden" moved away)
|
||||
t.Fatalf("no duplicate checkboxes expected: %d children", len(d.Children(mainID)))
|
||||
}
|
||||
|
||||
// Deleting is not mirrored: a deleted checkbox only unlinks, a deleted subtask leaves its checkbox
|
||||
helfer := got.Subtasks[1].BlockID
|
||||
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
|
||||
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: helfer}}
|
||||
})
|
||||
s.ReconcileDocTaskLinks(docID)
|
||||
got, _ = s.GetThoughtByID(anna.ID, th.ID)
|
||||
if len(got.Subtasks) != 4 || got.Subtasks[1].BlockID != "" {
|
||||
t.Fatalf("subtask should stay, only unlinked: %+v", got.Subtasks)
|
||||
}
|
||||
remaining := append([]domain.SubTask{}, got.Subtasks[0], got.Subtasks[2], got.Subtasks[3])
|
||||
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, nil, nil, nil, nil, nil, nil, nil, &remaining, nil)
|
||||
s.SyncTaskToDoc(anna.ID, th.ID)
|
||||
d, _, _ = loadCollabDoc(s.db, docID)
|
||||
if b := d.Blocks[kartons]; b == nil || b.Deleted {
|
||||
t.Fatal("deleting a subtask must not delete its checkbox")
|
||||
}
|
||||
// The unlinked "Helfer fragen" subtask was deleted in the task; nothing came back
|
||||
if n := len(got.Subtasks); n != 4 {
|
||||
t.Fatalf("unexpected subtasks: %d", n)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user