From d3c0f0ee7f3133e6e03bc81165829769a6fb4040 Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Sat, 13 Jun 2026 14:04:03 +0000 Subject: [PATCH] v0.3.0: read-only kanban board (gtd-kanban block) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New gtd-kanban block renders a three-column board from to-dos: Backlog / In Progress / Done. Notes excluded; to-dos only. - Bucketing: Done wins (todo_completed), else in-progress tag, else Backlog. in-progress tag name configurable (IN_PROGRESS_TAG constant, in-progress-tag: option). - Backlog shows all uncompleted untagged to-dos; Done filtered to a configurable done-window (default 7 days; 'all' for full history). - Per-column sort: due-date (default; dateless last) | title | modified-date, asc/desc. - Compact cards with configurable detail panel (hover/always/none), recurrence ↻, click-to-open drilldown. Empty columns still shown. - Reuses the calendar data layer: scope resolution, inclusion, the getNoteTagTitles tag fetch from v0.2.0, gtd block parsing. - markdown-it renderer now handles both gtd-calendar and gtd-kanban fences (data-block-type); webview branches getEvents/getKanban. - SPEC sort-type vocabulary noted for cross-block consistency (modified_date accepted as alias). - Version 0.3.0; 32 new tests (65 total). Read-only — drag-and-drop write-back is v0.4.0. --- README.md | 37 +++++ SPEC.md | 43 ++++- package.json | 2 +- src/Gtd/collectKanban.ts | 152 ++++++++++++++++++ src/Gtd/parseKanbanConfig.ts | 132 ++++++++++++++++ src/Gtd/types.ts | 44 ++++++ src/event-calendar.css | 71 +++++++++ src/gtd-calendar-renderer.ts | 14 +- src/gtd-calendar-webview.js | 152 +++++++++++++++++- src/index.ts | 63 ++++++++ src/manifest.json | 2 +- src/tests/Gtd/kanban.test.ts | 297 +++++++++++++++++++++++++++++++++++ 12 files changed, 997 insertions(+), 12 deletions(-) create mode 100644 src/Gtd/collectKanban.ts create mode 100644 src/Gtd/parseKanbanConfig.ts create mode 100644 src/tests/Gtd/kanban.test.ts diff --git a/README.md b/README.md index fdb8469..480832d 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,43 @@ GTD Calendar plays nicely with the **[Repeating To-Dos](https://joplinapp.org/pl Because that plugin keeps a recurring to-do as a single item at its *next* due date, the calendar shows the next occurrence rather than every future one. Recurrence is detected via the `recurring` tag the plugin maintains; if you use a different recurrence tool that tags its to-dos `recurring`, the ↻ will appear for those too. +## Kanban board + +Alongside calendars, GTD Calendar can render a **kanban board** from your to-dos. Add a `gtd-kanban` block — on its own, or stacked beneath a calendar in the same dashboard note: + +```` +```gtd-kanban +title: Editorial Board +scope: this-folder +todos: all +sort-type: due-date +in-progress-tag: in-progress +card-detail: hover +done-window: 7 +``` +```` + +Three columns, driven by your to-dos' state: + +- **Backlog** — every uncompleted to-do without the in-progress tag. +- **In Progress** — to-dos carrying the in-progress tag (default tag name `in-progress`; configurable). +- **Done** — completed to-dos, limited to those finished within `done-window` days (default 7; set `all` for the full history). + +A completed to-do always lands in **Done**, even if it still carries the in-progress tag. + +| Option | Values | Default | Description | +|---|---|---|---| +| `title` | text | — | Heading above the board. | +| `scope` | `this-folder`, `children`, integer | `this-folder` | Same folder-scanning rules as the calendar. | +| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. `gtd-only` requires a `gtd` block; `all` includes every to-do in scope. (Plain notes never appear on the board.) | +| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order of cards within each column. Under `due-date`, cards with no due date sort last. | +| `sort` | `asc`, `desc` | `asc` | Sort direction. | +| `in-progress-tag` | text | `in-progress` | The tag that places a to-do in the In Progress column. | +| `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. | +| `done-window` | integer or `all` | `7` | How many days back the Done column reaches. | + +Cards are compact (title, recurrence ↻ if applicable) and click through to the to-do. The board is **read-only** in this version — it reflects your to-dos' state but doesn't change it. (Drag-and-drop to move cards between columns is planned.) + ## Drilldown Everything is clickable: single-event tiles, every row of a multi-event hover card, every chip in the month grid, every Unscheduled chip. Clicking opens the source note. diff --git a/SPEC.md b/SPEC.md index 50aae44..e118be9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -196,12 +196,45 @@ Recurrence itself is delegated to an existing community plugin (open-environment *Note: multiple `gtd-calendar` blocks in one note already work as of 0.1.0 (each fence renders independently) — e.g. a "week ahead" strip plus a "month grid" in a single dashboard note. No work required; document as a recommended pattern.* -### v0.3.0 — Kanban beneath the calendar (read-only) +### v0.3.0 — Kanban board (read-only) — SHIPPED -- **To-dos only** (notes excluded). -- **Three columns, tag-driven:** an `in-progress` tag → *In Progress*; `todo_completed > 0` → *Done*; neither → *Backlog*. -- Same architecture as the calendar: plugin queries to-dos + tags, buckets them, webview renders columns, chips drill down to notes. -- Read-only: reflects state, does not change it. Cheap way to prove the columns and bucketing before adding write-back. +Decision: a **second block type within this plugin**, not a separate plugin. Rationale: a kanban needs the entire existing data layer (`folderScope`, `collectEvents` inclusion matrix, `getNoteTagTitles`, config parsing, recurrence detection, webview messaging); a separate plugin would duplicate all of it or require a shared-library split whose overhead isn't justified for "three tag-driven columns." One plugin also serves the intended use — a calendar and a kanban in one dashboard note — with a single install and a unified config/tag vocabulary. + +**The `gtd-kanban` block.** Parallel to `gtd-calendar`; lives in its own note or stacked beneath a calendar (multiple blocks already render independently). + +``` +```gtd-kanban +title: Editorial Board +scope: this-folder # this-folder | children | N — same semantics as gtd-calendar +todos: gtd-only # gtd-only (default) | all | none +sort-type: due-date # due-date (default) | title | modified-date +sort: asc # asc | desc +in-progress-tag: in-progress # configurable; default "in-progress" +card-detail: hover # hover (default) | always | none +done-window: 7 # days; integer or "all"; default 7 +``` + +**Columns (always three, always shown even when empty):** Backlog · In Progress · Done. + +**Bucketing rules:** +- **Done wins.** Any to-do with `todo_completed > 0` → *Done*, regardless of tags. +- Else, carrying the in-progress tag → *In Progress*. +- Else → *Backlog*. +- **Backlog shows everything** uncompleted and untagged — dated or not, near or far. (Acknowledged: can grow long; compact cards mitigate.) + +**Sorting (within each column):** +- `sort-type: due-date | title | modified-date`, with `sort: asc | desc`. +- Under `due-date`, to-dos **without** a due date sort *after* all dated ones. + +**Done column window:** shows only to-dos completed within `done-window` days (default 7); `all` shows the full history. Prevents unbounded growth. + +**Cards:** compact by default — title, due date, ↻ if recurring, click-to-open. `card-detail` controls the hover panel: `hover` (default), `always` (details inline), `none` (no panel). Notes are excluded entirely (to-dos only). + +**Read-only in 0.3.0:** reflects state, never mutates it. Proves columns/bucketing cheaply before write-back. + +**Architecture:** reuses the calendar's pipeline. New `gtd-kanban` markdown-it fence → placeholder → webview requests data → plugin runs scope + inclusion + tag fetch (already built) + bucketing → returns three columns → webview renders. `IN_PROGRESS_TAG` constant mirrors `RECURRING_TAG`, exposed via `in-progress-tag:` config from the start. + +**`sort-type` vocabulary reconciliation:** the calendar block's `sort-type` (currently `title | modified_date`) is unified toward `due-date | title | modified-date` across both blocks so they read consistently. For the calendar, `due-date` is accepted but falls back to chronological where less meaningful. (Note the spelling shift `modified_date` → `modified-date` for cross-block consistency; the old spelling stays accepted as an alias to avoid breaking existing calendars.) ### v0.4.0 — Drag-and-drop kanban (write-back) diff --git a/package.json b/package.json index 2be0610..4899862 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-gtd-calendar", - "version": "0.2.0", + "version": "0.3.0", "scripts": { "test": "jest", "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", diff --git a/src/Gtd/collectKanban.ts b/src/Gtd/collectKanban.ts new file mode 100644 index 0000000..e518ee8 --- /dev/null +++ b/src/Gtd/collectKanban.ts @@ -0,0 +1,152 @@ +import { + DataAdapter, + KanbanBoard, + KanbanCard, + KanbanConfig, + RawNote, + RECURRING_TAG, +} from "./types"; +import resolveScopedFolderIds from "./folderScope"; +import extractGtdBlock from "./gtdBlock"; +import { resolveEventDate } from "./resolveDate"; + +export interface KanbanResult { + board: KanbanBoard; + warnings: string[]; + scannedFolders: number; + scannedNotes: number; + cardCount: number; +} + +/** + * Build a three-column kanban board from to-dos in scope. + * + * - Notes are excluded entirely (to-dos only). + * - Bucketing: Done wins (todo_completed > 0); else in-progress tag; + * else Backlog. + * - Done column filtered to the done-window (days). + * - Each column sorted per config. + * + * The kanban note itself is always excluded. + */ +export default async function collectKanban( + adapter: DataAdapter, + kanbanNoteId: string, + kanbanFolderId: string, + config: KanbanConfig, + now: Date = new Date() +): Promise { + const warnings: string[] = []; + + const folders = await adapter.getFolders(); + const folderIds = resolveScopedFolderIds( + folders, + kanbanFolderId, + config.scopeDepth + ); + + let scannedNotes = 0; + const backlog: KanbanCard[] = []; + const inProgress: KanbanCard[] = []; + const done: KanbanCard[] = []; + + const doneCutoff = + config.doneWindow === Infinity + ? -Infinity + : now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000; + + for (const folderId of folderIds) { + const notes = await adapter.getNotesInFolder(folderId); + for (const note of notes) { + scannedNotes += 1; + if (note.id === kanbanNoteId) continue; + if (!note.is_todo) continue; // to-dos only + + if (config.todos === "none") continue; + + const result = extractGtdBlock(note.body); + if (config.todos === "gtd-only" && !result.found) continue; + + if (result.error) { + warnings.push( + `"${note.title}": gtd block problem — ${result.error}` + ); + } + + const tags = await adapter.getNoteTagTitles(note.id); + const card = buildCard(note, result.block, tags); + + // Bucketing — Done wins. + if (card.completed) { + if (card.completedTime >= doneCutoff) done.push(card); + // completed but outside the window: dropped from the board + } else if (tags.includes(config.inProgressTag)) { + inProgress.push(card); + } else { + backlog.push(card); + } + } + } + + sortColumn(backlog, config); + sortColumn(inProgress, config); + sortColumn(done, config); + + return { + board: { backlog, inProgress, done }, + warnings, + scannedFolders: folderIds.length, + scannedNotes, + cardCount: backlog.length + inProgress.length + done.length, + }; +} + +function buildCard( + note: RawNote, + block: ReturnType["block"], + tags: string[] +): KanbanCard { + return { + id: note.id, + title: block && block.title ? block.title : note.title, + date: resolveEventDate(note, block), + completed: note.todo_completed > 0, + completedTime: note.todo_completed || 0, + isRecurring: tags.includes(RECURRING_TAG), + bgColour: block ? block.bgColour : null, + fgColour: block ? block.fgColour : null, + icon: block ? block.icon : null, + text: block ? block.text : null, + updatedTime: note.updated_time, + }; +} + +function sortColumn(cards: KanbanCard[], config: KanbanConfig): void { + const direction = config.sort === "desc" ? -1 : 1; + + cards.sort((a, b) => { + let comparison: number; + + if (config.sortType === "due-date") { + // Dateless cards always sort after dated ones, regardless of + // direction. + const aHas = a.date !== null; + const bHas = b.date !== null; + if (aHas && !bHas) return -1; + if (!aHas && bHas) return 1; + if (!aHas && !bHas) { + comparison = 0; + } else { + comparison = (a.date as string).localeCompare(b.date as string); + } + } else if (config.sortType === "modified-date") { + comparison = a.updatedTime - b.updatedTime; + } else { + comparison = a.title.localeCompare(b.title, undefined, { + sensitivity: "base", + }); + } + + return comparison * direction; + }); +} diff --git a/src/Gtd/parseKanbanConfig.ts b/src/Gtd/parseKanbanConfig.ts new file mode 100644 index 0000000..b06b95c --- /dev/null +++ b/src/Gtd/parseKanbanConfig.ts @@ -0,0 +1,132 @@ +import { KanbanConfig, InclusionMode, KanbanSortType } from "./types"; + +const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"]; +const SORT_TYPES: KanbanSortType[] = ["due-date", "title", "modified-date"]; +const CARD_DETAILS = ["hover", "always", "none"]; + +/** + * Normalise a parsed gtd-kanban YAML object into a KanbanConfig with + * SPEC defaults. Invalid values produce warnings, not failures. + */ +export default function parseKanbanConfig(raw: any): KanbanConfig { + const warnings: string[] = []; + const input = raw && typeof raw === "object" ? raw : {}; + + const knownKeys = [ + "title", + "scope", + "todos", + "sort-type", + "sort", + "in-progress-tag", + "card-detail", + "done-window", + ]; + for (const key of Object.keys(input)) { + if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`); + } + + const title = + input.title !== undefined && input.title !== null + ? String(input.title) + : null; + + // scope (shared semantics with gtd-calendar) + let scopeDepth = 0; + if (input.scope !== undefined) { + const value = input.scope; + if (value === "this-folder") { + scopeDepth = 0; + } else if (value === "children") { + scopeDepth = Infinity; + } else if (Number.isInteger(Number(value)) && Number(value) >= 0) { + scopeDepth = Number(value); + } else { + warnings.push(`Invalid scope "${value}" (using "this-folder")`); + } + } + + // todos inclusion + let todos: InclusionMode = "gtd-only"; + if (input.todos !== undefined) { + const candidate = String(input.todos).toLowerCase() as InclusionMode; + if (INCLUSION_MODES.includes(candidate)) { + todos = candidate; + } else { + warnings.push(`Invalid todos "${input.todos}" (using "gtd-only")`); + } + } + + // sort-type + let sortType: KanbanSortType = "due-date"; + if (input["sort-type"] !== undefined) { + let candidate = String(input["sort-type"]).toLowerCase(); + if (candidate === "modified_date") candidate = "modified-date"; // alias + if (SORT_TYPES.includes(candidate as KanbanSortType)) { + sortType = candidate as KanbanSortType; + } else { + warnings.push( + `Invalid sort-type "${input["sort-type"]}" (using "due-date")` + ); + } + } + + // sort direction + let sort: KanbanConfig["sort"] = "asc"; + if (input.sort !== undefined) { + const candidate = String(input.sort).toLowerCase(); + if (candidate === "asc" || candidate === "desc") { + sort = candidate; + } else { + warnings.push(`Invalid sort "${input.sort}" (using "asc")`); + } + } + + // in-progress tag (configurable; lowercased to match tag comparison) + let inProgressTag = "in-progress"; + if ( + input["in-progress-tag"] !== undefined && + input["in-progress-tag"] !== null && + String(input["in-progress-tag"]).trim() !== "" + ) { + inProgressTag = String(input["in-progress-tag"]).trim().toLowerCase(); + } + + // card-detail + let cardDetail: KanbanConfig["cardDetail"] = "hover"; + if (input["card-detail"] !== undefined) { + const candidate = String(input["card-detail"]).toLowerCase(); + if (CARD_DETAILS.includes(candidate)) { + cardDetail = candidate as KanbanConfig["cardDetail"]; + } else { + warnings.push( + `Invalid card-detail "${input["card-detail"]}" (using "hover")` + ); + } + } + + // done-window (days; "all" => Infinity) + let doneWindow = 7; + if (input["done-window"] !== undefined) { + const value = input["done-window"]; + if (String(value).toLowerCase() === "all") { + doneWindow = Infinity; + } else if (Number.isInteger(Number(value)) && Number(value) >= 0) { + doneWindow = Number(value); + } else { + warnings.push(`Invalid done-window "${value}" (using 7)`); + } + } + + return { + title, + scopeDepth, + todos, + sortType, + sort, + inProgressTag, + cardDetail, + doneWindow, + warnings, + }; +} diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 683e1b6..b2f35ff 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -79,3 +79,47 @@ export interface DataAdapter { * switch to a different recurrence plugin) is a one-line change. */ export const RECURRING_TAG = "recurring"; + +/** + * Default tag that places a to-do in the kanban "In Progress" column. + * Overridable via the gtd-kanban `in-progress-tag:` option. Constant + * mirrors RECURRING_TAG. + */ +export const IN_PROGRESS_TAG = "in-progress"; + +export type KanbanSortType = "due-date" | "title" | "modified-date"; + +/** Parsed and normalised ```gtd-kanban config. */ +export interface KanbanConfig { + title: string | null; + scopeDepth: number; + todos: InclusionMode; + sortType: KanbanSortType; + sort: "asc" | "desc"; + inProgressTag: string; + cardDetail: "hover" | "always" | "none"; + /** Days; Infinity means "all". */ + doneWindow: number; + warnings: string[]; +} + +/** A to-do as a kanban card. */ +export interface KanbanCard { + id: string; + title: string; + date: string | null; + completed: boolean; + completedTime: number; + isRecurring: boolean; + bgColour: string | null; + fgColour: string | null; + icon: string | null; + text: string | null; + updatedTime: number; +} + +export interface KanbanBoard { + backlog: KanbanCard[]; + inProgress: KanbanCard[]; + done: KanbanCard[]; +} diff --git a/src/event-calendar.css b/src/event-calendar.css index 8cefe65..9cc3f7e 100644 --- a/src/event-calendar.css +++ b/src/event-calendar.css @@ -284,3 +284,74 @@ overflow: hidden; text-overflow: ellipsis; } + +/* gtd-kanban board */ + +.gtd-kanban { + margin: 0.5em 0; +} + +.gtd-kanban-columns { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + align-items: start; +} + +.gtd-kanban-column { + border: 1px solid rgba(128, 128, 128, 0.35); + border-radius: 6px; + background-color: rgba(128, 128, 128, 0.06); + min-height: 3em; +} + +.gtd-kanban-column-header { + font-weight: bold; + font-size: 0.85em; + padding: 0.4em 0.6em; + border-bottom: 1px solid rgba(128, 128, 128, 0.3); + position: sticky; + top: 0; +} + +.gtd-kanban-cards { + padding: 0.4em; + display: flex; + flex-direction: column; + gap: 0.4em; +} + +.gtd-kanban-card { + border: 1px solid rgba(128, 128, 128, 0.4); + border-radius: 4px; + padding: 0.35em 0.5em; + background-color: aliceblue; + color: black; + cursor: pointer; + font-size: 0.85em; +} + +.gtd-kanban-card:hover { + border-color: rgba(20, 20, 20, 0.9); +} + +.gtd-kanban-card-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gtd-kanban-card-detail { + font-size: 0.85em; + opacity: 0.7; + margin-top: 0.15em; +} + +/* When card-detail is "hover", the detail line is hidden until hover. */ +.gtd-kanban-card-hover .gtd-kanban-card-detail { + display: none; +} + +.gtd-kanban-card-hover:hover .gtd-kanban-card-detail { + display: block; +} diff --git a/src/gtd-calendar-renderer.ts b/src/gtd-calendar-renderer.ts index c8043e2..405d274 100644 --- a/src/gtd-calendar-renderer.ts +++ b/src/gtd-calendar-renderer.ts @@ -1,4 +1,5 @@ -const FENCE_INFO = "gtd-calendar"; +const CALENDAR_FENCE = "gtd-calendar"; +const KANBAN_FENCE = "gtd-kanban"; /** * Markdown-it content script. @@ -35,16 +36,23 @@ export default function (context: { contentScriptId: string }) { self: any ) { const token = tokens[idx]; - if (token.info !== FENCE_INFO) + const isCalendar = token.info === CALENDAR_FENCE; + const isKanban = token.info === KANBAN_FENCE; + if (!isCalendar && !isKanban) return defaultRender(tokens, idx, options, env, self); const encodedConfig = encodeURIComponent(token.content); + const blockType = isCalendar ? "calendar" : "kanban"; + const label = isCalendar + ? "Loading GTD Calendar…" + : "Loading GTD Kanban…"; return ( `
` + - `

Loading GTD Calendar…

` + + `

${label}

` + `
` ); }; diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index 2a4156b..d89000f 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -22,6 +22,7 @@ el.setAttribute("data-gtd-processed", "true"); const contentScriptId = el.getAttribute("data-content-script-id"); + const blockType = el.getAttribute("data-block-type") || "calendar"; const rawConfig = decodeURIComponent( el.getAttribute("data-config") || "" ); @@ -38,9 +39,12 @@ return; } + const messageType = + blockType === "kanban" ? "getKanban" : "getEvents"; + webviewApi .postMessage(contentScriptId, { - type: "getEvents", + type: messageType, rawConfig: rawConfig, }) .then(function (payload) { @@ -48,7 +52,11 @@ renderError(el, "GTD Calendar: empty response from plugin."); return; } - renderPayload(el, payload, contentScriptId); + if (blockType === "kanban") { + renderKanban(el, payload, contentScriptId); + } else { + renderPayload(el, payload, contentScriptId); + } }) .catch(function (error) { renderError(el, "GTD Calendar: " + String(error)); @@ -396,6 +404,146 @@ return section; } + // ------------------------------------------------------------------ + // Kanban board (gtd-kanban block) + // ------------------------------------------------------------------ + + function renderKanban(el, payload, contentScriptId) { + el.innerHTML = ""; + + const wrapper = document.createElement("div"); + wrapper.className = "gtd-kanban"; + + if (payload.title) { + const heading = document.createElement("h3"); + heading.className = "gtd-calendar-title"; + heading.textContent = payload.title; + wrapper.appendChild(heading); + } + + if (payload.configError) { + const error = document.createElement("p"); + error.className = "gtd-calendar-error"; + error.textContent = "Config problem: " + payload.configError; + wrapper.appendChild(error); + } + + (payload.warnings || []).forEach(function (warning) { + const warn = document.createElement("p"); + warn.className = "gtd-calendar-warning"; + warn.textContent = "\u26A0 " + warning; + wrapper.appendChild(warn); + }); + + const board = payload.board || { + backlog: [], + inProgress: [], + done: [], + }; + const detail = payload.cardDetail || "hover"; + + const columns = document.createElement("div"); + columns.className = "gtd-kanban-columns"; + + columns.appendChild( + renderColumn("Backlog", board.backlog, detail, contentScriptId) + ); + columns.appendChild( + renderColumn( + "In Progress", + board.inProgress, + detail, + contentScriptId + ) + ); + columns.appendChild( + renderColumn("Done", board.done, detail, contentScriptId) + ); + + wrapper.appendChild(columns); + + if (payload.stats) { + const meta = document.createElement("p"); + meta.className = "gtd-calendar-debug-meta"; + meta.textContent = + payload.stats.cardCount + + " card(s) from " + + payload.stats.scannedNotes + + " note(s) in " + + payload.stats.scannedFolders + + " folder(s)"; + wrapper.appendChild(meta); + } + + el.appendChild(wrapper); + } + + function renderColumn(title, cards, detail, contentScriptId) { + const column = document.createElement("div"); + column.className = "gtd-kanban-column"; + + const header = document.createElement("div"); + header.className = "gtd-kanban-column-header"; + header.textContent = title + " (" + cards.length + ")"; + column.appendChild(header); + + const list = document.createElement("div"); + list.className = "gtd-kanban-cards"; + + cards.forEach(function (card) { + list.appendChild(renderCard(card, detail, contentScriptId)); + }); + + column.appendChild(list); + return column; + } + + function renderCard(card, detail, contentScriptId) { + const el = document.createElement("div"); + el.className = "gtd-kanban-card gtd-calendar-clickable"; + if (detail === "hover") el.className += " gtd-kanban-card-hover"; + if (card.bgColour) el.style.backgroundColor = card.bgColour; + if (card.fgColour) el.style.color = card.fgColour; + + const titleRow = document.createElement("div"); + titleRow.className = "gtd-kanban-card-title"; + const glyph = card.icon ? card.icon : card.completed ? "\u2611" : "\u2610"; + const recur = card.isRecurring ? " \u21BB" : ""; + titleRow.textContent = glyph + recur + " " + card.title; + if (card.completed) titleRow.style.textDecoration = "line-through"; + el.appendChild(titleRow); + + // Detail line: due date (+ hover text). Shown inline when + // card-detail is "always"; in a hover panel when "hover"; + // suppressed entirely when "none". + if (detail !== "none") { + const hasDetail = card.date || card.text; + if (hasDetail) { + const detailEl = document.createElement("div"); + detailEl.className = "gtd-kanban-card-detail"; + const bits = []; + if (card.date) bits.push(formatCardDate(card.date)); + if (card.text) bits.push(card.text); + detailEl.textContent = bits.join(" \u00B7 "); + el.appendChild(detailEl); + } + } + + el.addEventListener("click", function () { + openNote(contentScriptId, card.id); + }); + + return el; + } + + function formatCardDate(dateISO) { + const date = isoToLocalDate(dateISO); + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + } + processPlaceholders(); // Joplin may replace rendered content without re-running asset scripts; diff --git a/src/index.ts b/src/index.ts index b48579f..9ec09ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ const YAML = require("yaml"); import parseCalendarConfig from "./Gtd/parseCalendarConfig"; import collectEvents from "./Gtd/collectEvents"; import groupEvents from "./Gtd/groupEvents"; +import parseKanbanConfig from "./Gtd/parseKanbanConfig"; +import collectKanban from "./Gtd/collectKanban"; import { DataAdapter, RawFolder, RawNote } from "./Gtd/types"; const CONTENT_SCRIPT_ID = "gtd-calendar-renderer"; @@ -72,6 +74,8 @@ joplin.plugins.register({ switch (message.type) { case "getEvents": return handleGetEvents(message); + case "getKanban": + return handleGetKanban(message); case "openNote": return handleOpenNote(message); default: @@ -148,6 +152,65 @@ async function handleGetEvents(message: { rawConfig?: string }) { }; } +async function handleGetKanban(message: { rawConfig?: string }) { + let rawParsed: any = {}; + let configError: string | null = null; + try { + rawParsed = YAML.parse(message.rawConfig || "") || {}; + } catch (error) { + configError = String(error); + } + const config = parseKanbanConfig(rawParsed); + + let sourceNote: { id: string; title: string; parentId: string } | null = + null; + try { + const note = await joplin.workspace.selectedNote(); + if (note) { + sourceNote = { + id: note.id, + title: note.title, + parentId: note.parent_id, + }; + } + } catch (error) { + console.warn("gtd-kanban: could not resolve selected note", error); + } + + if (!sourceNote) { + return { + title: config.title, + cardDetail: config.cardDetail, + configError: + configError || + "Could not determine which note contains this kanban.", + warnings: config.warnings, + board: { backlog: [], inProgress: [], done: [] }, + stats: null, + }; + } + + const result = await collectKanban( + joplinAdapter, + sourceNote.id, + sourceNote.parentId, + config + ); + + return { + title: config.title, + cardDetail: config.cardDetail, + configError, + warnings: [...config.warnings, ...result.warnings], + board: result.board, + stats: { + scannedFolders: result.scannedFolders, + scannedNotes: result.scannedNotes, + cardCount: result.cardCount, + }, + }; +} + async function handleOpenNote(message: { noteId?: string }) { if (!message.noteId) return { ok: false, error: "No noteId provided" }; try { diff --git a/src/manifest.json b/src/manifest.json index cac041b..5cbbfe6 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.victorwiebe.joplin.plugin.gtd-calendar", "app_min_version": "2.7", - "version": "0.2.0", + "version": "0.3.0", "name": "GTD Calendar", "description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.", "author": "Victor Wiebe", diff --git a/src/tests/Gtd/kanban.test.ts b/src/tests/Gtd/kanban.test.ts new file mode 100644 index 0000000..32c64c0 --- /dev/null +++ b/src/tests/Gtd/kanban.test.ts @@ -0,0 +1,297 @@ +import parseKanbanConfig from "../../Gtd/parseKanbanConfig"; +import collectKanban from "../../Gtd/collectKanban"; +import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types"; + +// --------------------------------------------------------------------------- +// parseKanbanConfig +// --------------------------------------------------------------------------- + +describe("parseKanbanConfig", () => { + test("applies SPEC defaults", () => { + const c = parseKanbanConfig({}); + expect(c).toMatchObject({ + title: null, + scopeDepth: 0, + todos: "gtd-only", + sortType: "due-date", + sort: "asc", + inProgressTag: "in-progress", + cardDetail: "hover", + doneWindow: 7, + }); + expect(c.warnings).toHaveLength(0); + }); + + test("parses a full config", () => { + const c = parseKanbanConfig({ + title: "Board", + scope: "children", + todos: "all", + "sort-type": "title", + sort: "desc", + "in-progress-tag": "WIP", + "card-detail": "always", + "done-window": 14, + }); + expect(c.title).toBe("Board"); + expect(c.scopeDepth).toBe(Infinity); + expect(c.todos).toBe("all"); + expect(c.sortType).toBe("title"); + expect(c.sort).toBe("desc"); + expect(c.inProgressTag).toBe("wip"); // lowercased for comparison + expect(c.cardDetail).toBe("always"); + expect(c.doneWindow).toBe(14); + }); + + test("done-window accepts 'all' as Infinity", () => { + expect(parseKanbanConfig({ "done-window": "all" }).doneWindow).toBe( + Infinity + ); + }); + + test("modified_date alias maps to modified-date", () => { + expect(parseKanbanConfig({ "sort-type": "modified_date" }).sortType).toBe( + "modified-date" + ); + }); + + test("invalid values warn and fall back", () => { + const c = parseKanbanConfig({ + todos: "maybe", + "sort-type": "priority", + "card-detail": "popup", + "done-window": "soon", + nonsense: 1, + }); + expect(c.todos).toBe("gtd-only"); + expect(c.sortType).toBe("due-date"); + expect(c.cardDetail).toBe("hover"); + expect(c.doneWindow).toBe(7); + expect(c.warnings.length).toBeGreaterThanOrEqual(5); + }); +}); + +// --------------------------------------------------------------------------- +// collectKanban +// --------------------------------------------------------------------------- + +function makeNote(overrides: Partial): RawNote { + return { + id: "id", + title: "Card", + parent_id: "board", + is_todo: 1, + todo_due: 0, + todo_completed: 0, + updated_time: 0, + body: "```gtd\n```", + ...overrides, + }; +} + +function makeAdapter( + folders: RawFolder[], + notes: RawNote[], + tagsByNote: Record = {} +): DataAdapter { + return { + getFolders: async () => folders, + getNotesInFolder: async (folderId: string) => + notes.filter((n) => n.parent_id === folderId), + getNoteTagTitles: async (noteId: string) => tagsByNote[noteId] || [], + }; +} + +const NOW = new Date(2026, 5, 15, 12, 0); // Jun 15 2026 noon +const folders: RawFolder[] = [{ id: "board", parent_id: "root" }]; + +function daysAgo(n: number): number { + return NOW.getTime() - n * 24 * 60 * 60 * 1000; +} + +describe("collectKanban — bucketing", () => { + test("Done wins over in-progress tag", async () => { + const notes = [ + makeNote({ id: "c", title: "Completed but tagged", todo_completed: daysAgo(1) }), + ]; + const result = await collectKanban( + makeAdapter(folders, notes, { c: ["in-progress"] }), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all" }), + NOW + ); + expect(result.board.done.map((c) => c.id)).toEqual(["c"]); + expect(result.board.inProgress).toHaveLength(0); + }); + + test("in-progress tag → In Progress; untagged → Backlog", async () => { + const notes = [ + makeNote({ id: "wip", title: "Working" }), + makeNote({ id: "todo", title: "Later" }), + ]; + const result = await collectKanban( + makeAdapter(folders, notes, { wip: ["in-progress"] }), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all" }), + NOW + ); + expect(result.board.inProgress.map((c) => c.id)).toEqual(["wip"]); + expect(result.board.backlog.map((c) => c.id)).toEqual(["todo"]); + }); + + test("notes (non-todos) are excluded entirely", async () => { + const notes = [ + makeNote({ id: "t", is_todo: 1 }), + makeNote({ id: "n", is_todo: 0, title: "Just a note" }), + ]; + const result = await collectKanban( + makeAdapter(folders, notes), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all", notes: "all" } as any), + NOW + ); + const allIds = [ + ...result.board.backlog, + ...result.board.inProgress, + ...result.board.done, + ].map((c) => c.id); + expect(allIds).toEqual(["t"]); + }); + + test("configurable in-progress tag is honoured", async () => { + const notes = [makeNote({ id: "x", title: "WIP item" })]; + const result = await collectKanban( + makeAdapter(folders, notes, { x: ["wip"] }), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all", "in-progress-tag": "WIP" }), + NOW + ); + expect(result.board.inProgress.map((c) => c.id)).toEqual(["x"]); + }); + + test("the kanban note itself is excluded", async () => { + const notes = [makeNote({ id: "kanban-note", title: "The board note" })]; + const result = await collectKanban( + makeAdapter(folders, notes), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all" }), + NOW + ); + expect(result.cardCount).toBe(0); + }); +}); + +describe("collectKanban — done window", () => { + test("only completions within the window appear in Done", async () => { + const notes = [ + makeNote({ id: "recent", todo_completed: daysAgo(3) }), + makeNote({ id: "old", todo_completed: daysAgo(30) }), + ]; + const result = await collectKanban( + makeAdapter(folders, notes), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all", "done-window": 7 }), + NOW + ); + expect(result.board.done.map((c) => c.id)).toEqual(["recent"]); + }); + + test("done-window: all keeps everything", async () => { + const notes = [ + makeNote({ id: "recent", todo_completed: daysAgo(3) }), + makeNote({ id: "old", todo_completed: daysAgo(300) }), + ]; + const result = await collectKanban( + makeAdapter(folders, notes), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all", "done-window": "all" }), + NOW + ); + expect(result.board.done.map((c) => c.id).sort()).toEqual([ + "old", + "recent", + ]); + }); +}); + +describe("collectKanban — sorting", () => { + test("due-date asc, dateless cards sort last", async () => { + const notes = [ + makeNote({ id: "later", todo_due: new Date(2026, 6, 20).getTime() }), + makeNote({ id: "soon", todo_due: new Date(2026, 6, 1).getTime() }), + makeNote({ id: "nodate" }), // todo_due 0 → unscheduled + ]; + const result = await collectKanban( + makeAdapter(folders, notes), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all", "sort-type": "due-date" }), + NOW + ); + expect(result.board.backlog.map((c) => c.id)).toEqual([ + "soon", + "later", + "nodate", + ]); + }); + + test("dateless cards still sort last under desc", async () => { + const notes = [ + makeNote({ id: "later", todo_due: new Date(2026, 6, 20).getTime() }), + makeNote({ id: "soon", todo_due: new Date(2026, 6, 1).getTime() }), + makeNote({ id: "nodate" }), + ]; + const result = await collectKanban( + makeAdapter(folders, notes), + "kanban-note", + "board", + parseKanbanConfig({ + todos: "all", + "sort-type": "due-date", + sort: "desc", + }), + NOW + ); + expect(result.board.backlog.map((c) => c.id)).toEqual([ + "later", + "soon", + "nodate", + ]); + }); + + test("title sort", async () => { + const notes = [ + makeNote({ id: "b", title: "Banana" }), + makeNote({ id: "a", title: "Apple" }), + ]; + const result = await collectKanban( + makeAdapter(folders, notes), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all", "sort-type": "title" }), + NOW + ); + expect(result.board.backlog.map((c) => c.id)).toEqual(["a", "b"]); + }); +}); + +describe("collectKanban — recurrence flag", () => { + test("recurring tag flags the card", async () => { + const notes = [makeNote({ id: "r", title: "Weekly post" })]; + const result = await collectKanban( + makeAdapter(folders, notes, { r: ["recurring"] }), + "kanban-note", + "board", + parseKanbanConfig({ todos: "all" }), + NOW + ); + expect(result.board.backlog[0].isRecurring).toBe(true); + }); +});