v0.3.0: read-only kanban board (gtd-kanban block)
- 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.
This commit is contained in:
parent
bbde832b6d
commit
d3c0f0ee7f
37
README.md
37
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.
|
||||
|
||||
43
SPEC.md
43
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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
152
src/Gtd/collectKanban.ts
Normal file
152
src/Gtd/collectKanban.ts
Normal file
@ -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<KanbanResult> {
|
||||
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<typeof extractGtdBlock>["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;
|
||||
});
|
||||
}
|
||||
132
src/Gtd/parseKanbanConfig.ts
Normal file
132
src/Gtd/parseKanbanConfig.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
`<div class="gtd-calendar-placeholder"` +
|
||||
` data-content-script-id="${context.contentScriptId}"` +
|
||||
` data-block-type="${blockType}"` +
|
||||
` data-config="${encodedConfig}">` +
|
||||
`<p class="gtd-calendar-loading">Loading GTD Calendar…</p>` +
|
||||
`<p class="gtd-calendar-loading">${label}</p>` +
|
||||
`</div>`
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
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;
|
||||
|
||||
63
src/index.ts
63
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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
297
src/tests/Gtd/kanban.test.ts
Normal file
297
src/tests/Gtd/kanban.test.ts
Normal file
@ -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>): 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<string, string[]> = {}
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user