Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions services/jtype-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,12 @@ pub struct BoardCardInfo {
pub task_total: i64,
pub icon: Option<String>,
pub excerpt: Option<String>,
/// Card slugs this card is blocked by (frontmatter `blocked_by`).
pub blocked_by: Vec<String>,
/// Card slugs this card blocks (frontmatter `blocks`).
pub blocks: Vec<String>,
/// Card slugs this card relates to without direction (frontmatter `relates`).
pub relates: Vec<String>,
}

/// The body content after the frontmatter block (for previews/excerpts).
Expand Down Expand Up @@ -1399,6 +1405,23 @@ fn parse_card_tags(raw: &str) -> Vec<String> {
.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<String> {
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) {
Expand Down Expand Up @@ -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(),
});
}
}
Expand Down Expand Up @@ -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());
}
}
8 changes: 8 additions & 0 deletions services/jtype-web/frontend/src/pages/WebBoardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
DEFAULT_DONE_COLUMN,
bodyExcerpt,
countTasks,
parseLinks,
parseTagList,
serializeLinks,
slugify,
type BoardViewCard,
type BoardViewConfig,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions shared/components/board/BoardPeek.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function BoardPeek({
statusOptions,
assigneeOptions,
tagOptions,
dependencyCards,
loadNotes,
loadComments,
addComment,
Expand All @@ -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<string>;
loadComments?: (id: string) => Promise<BoardComment[]>;
addComment?: (id: string, body: string) => Promise<BoardComment>;
Expand Down Expand Up @@ -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") => (
<TagMultiSelect
value={(draft[key] ?? []).map((s) => slugToTitle.get(s) ?? s)}
options={depOptions}
onChange={(titles) => setField({ [key]: titles.map((tt) => titleToSlug.get(tt) ?? tt) }, true)}
/>
);

return (
<aside className="flex h-full w-full flex-col border-l border-black/[0.06] bg-white">
<div className="flex items-center justify-between border-b border-black/[0.05] px-3 py-2">
Expand Down Expand Up @@ -275,6 +291,25 @@ export function BoardPeek({
}
/>
)}

{dependencyCards && (
<>
<span className="text-xs text-brand-gray">
<Trans>Blocked by</Trans>
</span>
{depField("blockedBy")}

<span className="text-xs text-brand-gray">
<Trans>Blocks</Trans>
</span>
{depField("blocks")}

<span className="text-xs text-brand-gray">
<Trans>Relates</Trans>
</span>
{depField("relates")}
</>
)}
</div>

<div className="mt-4 flex items-center justify-between">
Expand Down
19 changes: 18 additions & 1 deletion shared/components/board/BoardSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DocumentDuplicateIcon,
ChevronRightIcon,
CheckCircleIcon,
LockClosedIcon,
Squares2X2Icon,
TagIcon,
MagnifyingGlassIcon,
Expand All @@ -33,6 +34,8 @@ import {
DEFAULT_DONE_COLUMN,
PRIORITY_ORDER,
PRIORITY_STYLE,
blockedCounts,
cardSlug,
effectiveColumns,
groupValueOf,
sortCards as sortCardsFn,
Expand Down Expand Up @@ -116,6 +119,8 @@ export function BoardSurface({
[config, cards, groupKey],
);
const vis = useMemo(() => visibleCardsFn(cards, search, filter), [cards, search, filter]);
// Blocker counts resolve against ALL cards (a blocker may be filtered out of view).
const blockers = useMemo(() => blockedCounts(cards, config.doneColumn), [cards, config.doneColumn]);
const assignees = useMemo(() => [...new Set(cards.map((c) => c.assignee).filter(Boolean) as string[])], [cards]);
const allTags = useMemo(() => [...new Set(cards.flatMap((c) => c.tags.map((tg) => tg.label)))], [cards]);
const statusName = (key: string) => config.columns.find((c) => c.key === key)?.name || key || t`Unassigned`;
Expand Down Expand Up @@ -564,12 +569,14 @@ export function BoardSurface({
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
{colCards.map((card, idx) => {
const overdue = card.due && card.due < today && card.columnKey !== doneKey;
const blockedCount = blockers.get(card.id) ?? 0;
const hasMeta =
(card.priority && card.priority !== "none") ||
card.assignee ||
card.due ||
(card.taskTotal ?? 0) > 0 ||
card.tags.length > 0;
card.tags.length > 0 ||
blockedCount > 0;
return (
<Fragment key={card.id}>
{showLine(idx) && <div className="mx-1 h-0.5 rounded bg-brand" />}
Expand Down Expand Up @@ -651,6 +658,15 @@ export function BoardSurface({
)}
{hasMeta && (
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
{blockedCount > 0 && (
<span
className="inline-flex items-center gap-0.5 rounded bg-red-50 px-1.5 py-0.5 text-[11px] font-medium text-red-600"
title={t`Blocked by ${blockedCount} unfinished card(s)`}
>
<LockClosedIcon className="h-3 w-3" />
{blockedCount}
</span>
)}
{card.priority && card.priority !== "none" && (
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${PRIORITY_STYLE[card.priority] ?? "bg-stone-100 text-stone-500"}`}>{card.priority}</span>
)}
Expand Down Expand Up @@ -823,6 +839,7 @@ export function BoardSurface({
statusOptions={config.columns.map((c) => ({ value: c.key, label: c.name }))}
assigneeOptions={assigneeOptions}
tagOptions={tagOptions}
dependencyCards={cards.filter((c) => c.id !== selected.id).map((c) => ({ slug: cardSlug(c), title: c.title }))}
loadNotes={loadNotes}
loadComments={loadComments}
addComment={addComment}
Expand Down
2 changes: 1 addition & 1 deletion shared/i18n/locales/en/messages.mjs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
/*eslint-disable*/export const messages=JSON.parse("{\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"Notes\"],\"1YABGm\":[\"Link (Ctrl+K)\"],\"1hKEom\":[\"Priority\"],\"2wxgft\":[\"Rename\"],\"3qkggm\":[\"Fullscreen\"],\"4gdyen\":[\"Local (yours)\"],\"4hJhzz\":[\"Table\"],\"54sFiP\":[\"flowchart TD\\n A[Start] --> B[End]\"],\"5Q_DQ6\":[\"Inline Code\"],\"7VpPHA\":[\"Confirm\"],\"8PifYj\":[\"Mermaid diagram\"],\"8hSn0h\":[\"Result (editable)\"],\"8lE269\":[\"Sort: Manual\"],\"9gxam6\":[\"Could not render this Draw.io diagram.\"],\"AC9Gkf\":[\"Expand column\"],\"AS5WO9\":[\"Could not render this PDF.\"],\"AVreQ5\":[\"Drag to resize\"],\"AgvHni\":[\"Add column\"],\"AxAubu\":[\"Group: Assignee\"],\"BfMZ7w\":[\"Accept cloud\"],\"BnmEvM\":[\"Save as template\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"Code\"],\"EbMPZJ\":[\"Unassigned\"],\"G4qrLy\":[\"Unset done column\"],\"GKu3m4\":[\"No labels\"],\"Gpfctt\":[\"Due\"],\"H_SQFv\":[\"No color\"],\"I6SWEy\":[\"Split\"],\"ICip_B\":[\"Cloud (remote)\"],\"Ik60OC\":[\"Open in editor\"],\"Iw6WJa\":[\"Set WIP limit\"],\"JTYvAw\":[\"Search cards\"],\"K_F6pa\":[\"Saving…\"],\"KmydK6\":[\"Bold\"],\"KvW1VO\":[\"Draw.io diagram\"],\"LQn6-8\":[\"Accept local\"],\"MHrjPM\":[\"Title\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"OYHzN1\":[\"Tags\"],\"OepdfE\":[\"Group: Status\"],\"Q2mGA7\":[\"Clear filter\"],\"QD8opX\":[\"Board\"],\"QlsPZy\":[\"Write Mermaid syntax to see the diagram.\"],\"S5Qbb1\":[\"comma, separated\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"Blank card\"],\"VNa_N2\":[\"This file type can not be previewed yet.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"Sort: Priority\"],\"X03-eC\":[\"Please enter a value.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"Diagram error\"],\"Zot9XS\":[\"No cards\"],\"_5CsXX\":[\"Done column\"],\"_EsjyQ\":[\"Use this\"],\"a6uhHr\":[\"Bold (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"Add details...\"],\"agOeRN\":[\"Could not render this API specification.\"],\"b4hVKD\":[\"Color columns\"],\"cfaWH-\":[\"Add labels\"],\"cnGeoo\":[\"Delete\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP limit \",[\"0\"]],\"dEgA5A\":[\"Cancel\"],\"euc6Ns\":[\"Duplicate\"],\"fYcKtB\":[\"Sort: Due\"],\"gLDJuJ\":[\"Untitled card\"],\"hnK1gR\":[\"PDF document\"],\"i4_LY_\":[\"Write\"],\"iTylMl\":[\"Templates\"],\"iYVqZq\":[\"Column name\"],\"jZlrte\":[\"Color\"],\"kZlRKE\":[\"Mermaid source\"],\"kryGs-\":[\"Card\"],\"lCF0wC\":[\"Refresh\"],\"ltF1xa\":[\"Save merged result\"],\"nabda1\":[\"Delete card\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"Filter\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"Assignee\"],\"p9yTeb\":[\"Sort: Title\"],\"pKztsX\":[\"Open in full editor\"],\"pnrmSP\":[\"New card\"],\"pwN6Ae\":[\"Collapse column\"],\"pzutoc\":[\"Italic\"],\"rdUucN\":[\"Preview\"],\"sCzmvQ\":[\"cards\"],\"sQpDn6\":[\"Exit fullscreen\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" Conflict\",[\"1\"],\" to Resolve\"],\"u2IprG\":[\"Card title (Enter to add, Esc to cancel)\"],\"uAQUqI\":[\"Status\"],\"wf6Djn\":[\"Italic (Ctrl+I)\"],\"wtw-au\":[\"Set as done column\"],\"wwu18a\":[\"Icon\"],\"y1eoq1\":[\"Copy link\"],\"y9cj46\":[\"Group: Priority\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}");
/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"Notes\"],\"1YABGm\":[\"Link (Ctrl+K)\"],\"1hKEom\":[\"Priority\"],\"2wxgft\":[\"Rename\"],\"3qkggm\":[\"Fullscreen\"],\"4gdyen\":[\"Local (yours)\"],\"4hJhzz\":[\"Table\"],\"54sFiP\":[\"flowchart TD\\n A[Start] --> B[End]\"],\"5Q_DQ6\":[\"Inline Code\"],\"7VpPHA\":[\"Confirm\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid diagram\"],\"8hSn0h\":[\"Result (editable)\"],\"8lE269\":[\"Sort: Manual\"],\"9gxam6\":[\"Could not render this Draw.io diagram.\"],\"AC9Gkf\":[\"Expand column\"],\"AS5WO9\":[\"Could not render this PDF.\"],\"AVreQ5\":[\"Drag to resize\"],\"AgvHni\":[\"Add column\"],\"AxAubu\":[\"Group: Assignee\"],\"BfMZ7w\":[\"Accept cloud\"],\"BnmEvM\":[\"Save as template\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"Code\"],\"EbMPZJ\":[\"Unassigned\"],\"G4qrLy\":[\"Unset done column\"],\"GKu3m4\":[\"No labels\"],\"Gpfctt\":[\"Due\"],\"H_SQFv\":[\"No color\"],\"I6SWEy\":[\"Split\"],\"ICip_B\":[\"Cloud (remote)\"],\"Ik60OC\":[\"Open in editor\"],\"Iw6WJa\":[\"Set WIP limit\"],\"JTYvAw\":[\"Search cards\"],\"K_F6pa\":[\"Saving…\"],\"KmydK6\":[\"Bold\"],\"KvW1VO\":[\"Draw.io diagram\"],\"LQn6-8\":[\"Accept local\"],\"MHrjPM\":[\"Title\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"OYHzN1\":[\"Tags\"],\"OepdfE\":[\"Group: Status\"],\"Q2mGA7\":[\"Clear filter\"],\"QD8opX\":[\"Board\"],\"QlsPZy\":[\"Write Mermaid syntax to see the diagram.\"],\"S5Qbb1\":[\"comma, separated\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"Blank card\"],\"VNa_N2\":[\"This file type can not be previewed yet.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"Sort: Priority\"],\"X03-eC\":[\"Please enter a value.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"Diagram error\"],\"Zot9XS\":[\"No cards\"],\"_5CsXX\":[\"Done column\"],\"_EsjyQ\":[\"Use this\"],\"a6uhHr\":[\"Bold (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"Add details...\"],\"agOeRN\":[\"Could not render this API specification.\"],\"b4hVKD\":[\"Color columns\"],\"cfaWH-\":[\"Add labels\"],\"cnGeoo\":[\"Delete\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP limit \",[\"0\"]],\"dEgA5A\":[\"Cancel\"],\"euc6Ns\":[\"Duplicate\"],\"fYcKtB\":[\"Sort: Due\"],\"gLDJuJ\":[\"Untitled card\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF document\"],\"i4_LY_\":[\"Write\"],\"iTylMl\":[\"Templates\"],\"iYVqZq\":[\"Column name\"],\"jZlrte\":[\"Color\"],\"kZlRKE\":[\"Mermaid source\"],\"kryGs-\":[\"Card\"],\"lCF0wC\":[\"Refresh\"],\"ltF1xa\":[\"Save merged result\"],\"nabda1\":[\"Delete card\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"Filter\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"Assignee\"],\"p9yTeb\":[\"Sort: Title\"],\"pKztsX\":[\"Open in full editor\"],\"pnrmSP\":[\"New card\"],\"pwN6Ae\":[\"Collapse column\"],\"pzutoc\":[\"Italic\"],\"rdUucN\":[\"Preview\"],\"sCzmvQ\":[\"cards\"],\"sQpDn6\":[\"Exit fullscreen\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" Conflict\",[\"1\"],\" to Resolve\"],\"u2IprG\":[\"Card title (Enter to add, Esc to cancel)\"],\"uAQUqI\":[\"Status\"],\"wf6Djn\":[\"Italic (Ctrl+I)\"],\"wtw-au\":[\"Set as done column\"],\"wwu18a\":[\"Icon\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"Copy link\"],\"y9cj46\":[\"Group: Priority\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}");
Loading
Loading