diff --git a/services/jtype-core/src/lib.rs b/services/jtype-core/src/lib.rs index ef9ef31..8e63021 100644 --- a/services/jtype-core/src/lib.rs +++ b/services/jtype-core/src/lib.rs @@ -1349,6 +1349,12 @@ pub struct BoardCardInfo { pub task_total: i64, pub icon: Option, pub excerpt: Option, + /// Card slugs this card is blocked by (frontmatter `blocked_by`). + pub blocked_by: Vec, + /// Card slugs this card blocks (frontmatter `blocks`). + pub blocks: Vec, + /// Card slugs this card relates to without direction (frontmatter `relates`). + pub relates: Vec, } /// The body content after the frontmatter block (for previews/excerpts). @@ -1399,6 +1405,23 @@ fn parse_card_tags(raw: &str) -> Vec { .collect() } +/// Parse a frontmatter dependency value (`[[slug-a]], [[slug-b]]`) into a list of +/// card slugs, stripping the `[[ ]]` wikilink wrapper. Mirrors `parse_card_tags` +/// (flat, comma-separated, lenient) but additionally unwraps the brackets so the +/// stored references match the `[[name]]` link tokens used elsewhere. +fn parse_card_links(raw: &str) -> Vec { + raw.split(',') + .map(|tok| { + tok.trim() + .trim_start_matches("[[") + .trim_end_matches("]]") + .trim() + }) + .filter(|tok| !tok.is_empty()) + .map(str::to_string) + .collect() +} + /// Count Markdown task checkboxes (`- [ ]` / `- [x]`) in a card body, returning /// (done, total). Frontmatter lines are `key: value` so they never match. fn count_tasks(content: &str) -> (i64, i64) { @@ -1482,6 +1505,9 @@ fn scan_board_cards_inner( task_total, icon: fm.get("icon").cloned().filter(|v| !v.is_empty()), excerpt: body_excerpt(&content), + blocked_by: fm.get("blocked_by").map(|v| parse_card_links(v)).unwrap_or_default(), + blocks: fm.get("blocks").map(|v| parse_card_links(v)).unwrap_or_default(), + relates: fm.get("relates").map(|v| parse_card_links(v)).unwrap_or_default(), }); } } @@ -2632,4 +2658,38 @@ mod tests { .expect("board file listed in tree"); assert!(matches!(board.kind, EntryKind::Board)); } + + #[test] + fn parse_card_links_unwraps_wikilinks() { + assert_eq!(parse_card_links("[[a]], [[b]]"), vec!["a", "b"]); + assert_eq!(parse_card_links("a, b"), vec!["a", "b"]); + assert_eq!(parse_card_links(" [[x]] "), vec!["x"]); + assert!(parse_card_links("").is_empty()); + assert_eq!(parse_card_links("[[a]], , [[b]]"), vec!["a", "b"]); + } + + #[test] + fn scan_board_cards_reads_dependency_frontmatter() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("login.md"), + "---\ntitle: Login\nboard: proj\nstatus: todo\nblocked_by: [[design]], [[api]]\nblocks: [[release]]\nrelates: [[research]]\n---\nbody", + ) + .unwrap(); + fs::write( + root.join("design.md"), + "---\ntitle: Design\nboard: proj\nstatus: todo\n---\n", + ) + .unwrap(); + + let cards = scan_board_cards(root, "proj").unwrap(); + let login = cards.iter().find(|c| c.title == "Login").unwrap(); + assert_eq!(login.blocked_by, vec!["design", "api"]); + assert_eq!(login.blocks, vec!["release"]); + assert_eq!(login.relates, vec!["research"]); + // A card without the keys yields empty vecs (not missing). + let design = cards.iter().find(|c| c.title == "Design").unwrap(); + assert!(design.blocked_by.is_empty()); + } } diff --git a/services/jtype-web/frontend/src/pages/WebBoardView.tsx b/services/jtype-web/frontend/src/pages/WebBoardView.tsx index 3cc3c9c..1758f44 100644 --- a/services/jtype-web/frontend/src/pages/WebBoardView.tsx +++ b/services/jtype-web/frontend/src/pages/WebBoardView.tsx @@ -7,7 +7,9 @@ import { DEFAULT_DONE_COLUMN, bodyExcerpt, countTasks, + parseLinks, parseTagList, + serializeLinks, slugify, type BoardViewCard, type BoardViewConfig, @@ -92,6 +94,9 @@ export function WebBoardView({ taskDone: tasks.done, taskTotal: tasks.total, excerpt: bodyExcerpt(fm.body), + blockedBy: fm.data.blocked_by ? parseLinks(fm.data.blocked_by) : [], + blocks: fm.data.blocks ? parseLinks(fm.data.blocks) : [], + relates: fm.data.relates ? parseLinks(fm.data.relates) : [], }) } setMetaByPath(nextMeta) @@ -215,6 +220,9 @@ export function WebBoardView({ if (patch.due !== undefined) next.due = patch.due ?? '' if (patch.icon !== undefined) next.icon = patch.icon ?? '' if (patch.tags !== undefined) next.tags = patch.tags.map((t) => t.label).join(', ') + if (patch.blockedBy !== undefined) next.blocked_by = serializeLinks(patch.blockedBy) + if (patch.blocks !== undefined) next.blocks = serializeLinks(patch.blocks) + if (patch.relates !== undefined) next.relates = serializeLinks(patch.relates) const newBody = patch.notes !== undefined ? patch.notes : body try { await saveCard(id, next, newBody) diff --git a/shared/components/board/BoardPeek.tsx b/shared/components/board/BoardPeek.tsx index e66fa99..b48c897 100644 --- a/shared/components/board/BoardPeek.tsx +++ b/shared/components/board/BoardPeek.tsx @@ -18,6 +18,7 @@ export function BoardPeek({ statusOptions, assigneeOptions, tagOptions, + dependencyCards, loadNotes, loadComments, addComment, @@ -33,6 +34,8 @@ export function BoardPeek({ statusOptions: BoardOption[]; assigneeOptions?: BoardOption[]; tagOptions?: BoardTag[]; + /** Sibling cards (excluding this one) offered as dependency targets. */ + dependencyCards?: { slug: string; title: string }[]; loadNotes?: (id: string) => Promise; loadComments?: (id: string) => Promise; addComment?: (id: string, body: string) => Promise; @@ -157,6 +160,19 @@ export function BoardPeek({ const tagLabels = draft.tags.map((t2) => t2.label); + // Dependency editing maps slug<->title so the picker shows titles while the + // card stores slugs. Unresolved slugs (renamed/cross-board) are preserved as-is. + const slugToTitle = new Map((dependencyCards ?? []).map((c) => [c.slug, c.title])); + const titleToSlug = new Map((dependencyCards ?? []).map((c) => [c.title, c.slug])); + const depOptions = (dependencyCards ?? []).map((c) => ({ label: c.title })); + const depField = (key: "blockedBy" | "blocks" | "relates") => ( + slugToTitle.get(s) ?? s)} + options={depOptions} + onChange={(titles) => setField({ [key]: titles.map((tt) => titleToSlug.get(tt) ?? tt) }, true)} + /> + ); + return (