Compare commits

..
3 Commits
Author SHA1 Message Date
hh b70331f7bd fix(ui): panels stay above the keyboard, the day two offers share is listed once
The date field would not open at all on some days: on a Friday "Friday" and
"In a week" resolve to the same day, and the option list was keyed by the day
it writes, so Svelte tore it down with each_key_duplicate. On a desktop this
took the whole view with it - the same bug, not two. Duplicate offers are
dropped (the more specific name wins) and the list is keyed by position, so
no caller can tear it down that way again.

Panels are placed against the visual viewport rather than the window, which
a phone's keyboard shrinks without resizing anything: they scroll inside what
is left, re-place as the keyboard animates in, and come back into view when
the keyboard covers the very row they hang off - visible beats staying next
to the anchor. The quick look centres the same way.

Its actions are thumb-sized where the pointer is coarse, with the keycaps
hidden - there is no keyboard to press them on - and it gains "no date" and a
close button, since a phone has no Escape. Both menus gain a plain "Edit"
beside "Edit in Tasks"; it was only ever reachable by pressing e.
2026-09-05 00:22:07 +02:00
hh 9c0e57e29b feat(archive): completed cards move to a mirror of the boards folder
"Archive completed tasks" empties every board of what is finished into a
twin of the boards folder - `Boards/work.md` into `Boards/archive/work.md` -
itself a Kanban board with the same lanes, so the history opens like any
other board and the live one stays short. The same pass lifts a board's
native archive (everything below the `***` separator) into that mirror, so
finished work is not left sitting in two places.

Archived tasks render beside live ones when "Include the archive" and "Show
completed" are both on, from the mirrors and from any `***` section still in
a board. They carry an Archive badge instead of a project chip, ride with
the sphere they came from in the rail, and refuse drops.
2026-09-05 00:21:36 +02:00
hh 0cc4655f9f fix(ui): read panel keys at window capture so tasks cannot eat tab 2026-09-01 00:11:09 +02:00
19 changed files with 909 additions and 160 deletions

No files matched your search

+17
View File
@@ -5,6 +5,23 @@ Calendar and task list for Obsidian Kanban+Tasks
Sphere - md-file in Kanban boards directory. Project - h2 inside the file.
## Archiving
"Archive completed tasks" empties every board of what is finished with it, into
a mirror of the boards folder: `Boards/work.md` archives into
`Boards/archive/work.md`, itself a Kanban board with the same lanes, so the
history opens normally and the board stays short. The same pass lifts a board's
old native archive - everything below the `***` separator - into that mirror,
headings and all, so nothing is left behind in two places.
Set "Archive folder" before the first run: renaming it later leaves the mirrors
already written behind, where they read as ordinary boards.
Archived tasks render alongside live ones when both "Include the archive" and
"Show completed" are on - from the mirror files and from any `***` section still
sitting in a board. They carry an Archive badge instead of a project chip, ride
with the sphere they came from in the rail, and are not drop targets.
## Build and install
```bash
+42 -8
View File
@@ -13,6 +13,31 @@ export interface Spot {
const GAP = 6;
const PAD = 10;
export interface Viewport {
width: number;
height: number;
/** Where the visible area starts, once a keyboard has pushed it up. */
top?: number;
left?: number;
}
/**
* What is actually visible, which on a phone is not the window: the software
* keyboard shrinks the visual viewport and leaves `innerHeight` alone, so a
* panel placed against the window lands behind the keys.
*/
export function viewport(win: Window = window): Viewport {
const vv = win.visualViewport;
return vv
? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft }
: { width: win.innerWidth, height: win.innerHeight };
}
/** The tallest a panel may be here before it has to scroll its own body. */
export function roomFor(view: Viewport): number {
return Math.max(160, view.height - 2 * PAD);
}
/**
* The calendar draws its cards twice — once in the agenda and once in the
* month grid — and hides whichever the width does not call for. Both answer
@@ -31,24 +56,33 @@ export function onScreen(boxes: Box[]): Box | null {
export function place(
anchor: Box | null,
panel: { width: number; height: number },
view: { width: number; height: number },
view: Viewport
): Spot {
const originTop = view.top ?? 0;
const originLeft = view.left ?? 0;
const bottom = originTop + view.height;
const right = originLeft + view.width;
if (!anchor) {
return {
top: Math.max(PAD, (view.height - panel.height) / 2),
left: Math.max(PAD, (view.width - panel.width) / 2),
top: Math.max(originTop + PAD, originTop + (view.height - panel.height) / 2),
left: Math.max(originLeft + PAD, originLeft + (view.width - panel.width) / 2),
};
}
let top = anchor.top + anchor.height + GAP;
if (top + panel.height > view.height - PAD) {
const above = anchor.top - GAP - panel.height;
top = above >= PAD ? above : Math.max(PAD, view.height - PAD - panel.height);
if (top + panel.height > bottom - PAD) {
top = anchor.top - GAP - panel.height;
}
// The anchor itself can be outside the visible area - a keyboard covers the
// row the panel hangs off - and then neither side of it is reachable. What
// is visible wins over staying next to the anchor.
top = Math.min(top, bottom - PAD - panel.height);
top = Math.max(top, originTop + PAD);
const left = Math.min(
Math.max(PAD, anchor.left),
Math.max(PAD, view.width - PAD - panel.width),
Math.max(originLeft + PAD, anchor.left),
Math.max(originLeft + PAD, right - PAD - panel.width)
);
return { top, left };
+22 -11
View File
@@ -4,9 +4,10 @@ import {
DEFAULT_SETTINGS,
} from "./settings";
import type { BeaverCalendarSettings } from "./settings";
import { archiveRoot, toArchivePath } from "./model/archive";
import { BoardStore } from "./vault/store.svelte";
import { transaction } from "./vault/history";
import { archiveDone } from "./vault/mutate";
import { archiveBoard } from "./vault/mutate";
import { BoardsView, VIEW_TYPE_BOARDS } from "./view";
export default class BeaverCalendarPlugin extends Plugin {
@@ -90,34 +91,44 @@ export default class BeaverCalendarPlugin extends Plugin {
private async archiveAll(): Promise<void> {
const folder = this.settings.boardsFolder;
if (!folder) {
const archive = this.settings.archiveFolder;
if (!folder || !archive) {
new Notice(
"Beaver Calendar: no boards folder is set, archiving cancelled",
"Beaver Calendar: the boards and archive folders must both be set",
6000,
);
return;
}
// The mirror is under the boards folder, so it would sweep itself.
const root = `${archiveRoot(folder, archive)}/`;
const files = this.app.vault
.getMarkdownFiles()
.filter((file) => file.path.startsWith(`${folder}/`));
.filter(
(file) =>
file.path.startsWith(`${folder}/`) && !file.path.startsWith(root),
);
if (files.length === 0) {
new Notice("Beaver Calendar: no boards found");
return;
}
let moved = 0;
let migrated = 0;
// One undo step for the whole sweep, however many boards it touched.
await transaction("the archive", async () => {
for (const file of files) {
moved += await archiveDone(
this.app,
file.path,
this.settings.archiveHeading,
);
const mirror = toArchivePath(file.path, folder, archive);
if (!mirror) continue;
const outcome = await archiveBoard(this.app, file.path, mirror, true);
moved += outcome.moved;
if (outcome.migrated) migrated += 1;
}
});
const parts: string[] = [];
if (moved > 0) parts.push(`archived ${moved} task(s)`);
if (migrated > 0) parts.push(`moved the old archive of ${migrated} board(s)`);
new Notice(
moved > 0
? `Beaver Calendar: archived ${moved} task(s)`
parts.length > 0
? `Beaver Calendar: ${parts.join(", ")}`
: "Beaver Calendar: nothing to archive",
);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Completed cards leave the board for a mirror of the boards folder: a board
* at `Boards/work.md` archives into `Boards/archive/work.md`, which is itself
* a Kanban board, so the history opens like any other one. The mirror keeps
* the boards folder's shape, so a nested board archives to a nested twin.
*/
function strip(value: string): string {
return value.trim().replace(/^\/+/u, "").replace(/\/+$/u, "");
}
/** The folder the mirror lives in, or `""` when there is no archive folder. */
export function archiveRoot(boardsFolder: string, archiveFolder: string): string {
const name = strip(archiveFolder);
if (!name) return "";
const boards = strip(boardsFolder);
return boards ? `${boards}/${name}` : name;
}
export function isArchivePath(
path: string,
boardsFolder: string,
archiveFolder: string,
): boolean {
const root = archiveRoot(boardsFolder, archiveFolder);
return root !== "" && path.startsWith(`${root}/`);
}
/**
* The archive twin of a board, or `null` when the board is outside the boards
* folder and so has nowhere to mirror to. An archive file is its own twin.
*/
export function toArchivePath(
path: string,
boardsFolder: string,
archiveFolder: string,
): string | null {
const root = archiveRoot(boardsFolder, archiveFolder);
if (!root) return null;
if (path.startsWith(`${root}/`)) return path;
const boards = strip(boardsFolder);
if (!boards) return `${root}/${path}`;
if (!path.startsWith(`${boards}/`)) return null;
return `${root}/${path.slice(boards.length + 1)}`;
}
/** The board an archive file mirrors; any other path is its own board. */
export function toBoardPath(
path: string,
boardsFolder: string,
archiveFolder: string,
): string {
const root = archiveRoot(boardsFolder, archiveFolder);
if (!root || !path.startsWith(`${root}/`)) return path;
const boards = strip(boardsFolder);
const rest = path.slice(root.length + 1);
return boards ? `${boards}/${rest}` : rest;
}
+17 -2
View File
@@ -14,7 +14,16 @@ function isCardBody(line: string): boolean {
return /^\t/u.test(line);
}
export function parseBoard(path: string, content: string): Board {
/**
* `archived` marks a whole file as archive - the mirror files under the
* archive folder hold nothing else, so every lane in them is one, separator
* or no separator.
*/
export function parseBoard(
path: string,
content: string,
archived = false,
): Board {
const lines = content.split("\n");
const sphere = sphereOf(path);
const lanes: Lane[] = [];
@@ -35,7 +44,7 @@ export function parseBoard(path: string, content: string): Board {
}
}
let inArchive = false;
let inArchive = archived;
let current: Lane | null = null;
const closeLane = (end: number): void => {
@@ -117,6 +126,12 @@ export function findLane(
);
}
/** Where a heading-less board ends: before its Kanban settings block. */
export function bodyEnd(lines: string[]): number {
const settingsAt = lines.findIndex((line) => SETTINGS_OPEN.test(line));
return settingsAt === -1 ? lines.length : settingsAt;
}
export function laneInsertPoint(board: Board, lane: Lane): number {
let end = lane.end;
while (end > lane.start && board.lines[end - 1].trim() === "") {
+18 -10
View File
@@ -8,7 +8,7 @@ export type SortBy = "manual" | "priority" | "due";
export interface BeaverCalendarSettings {
boardsFolder: string;
archiveHeading: string;
archiveFolder: string;
view: ViewMode;
groupBy: GroupBy;
listSort: SortBy;
@@ -24,7 +24,7 @@ export interface BeaverCalendarSettings {
export const DEFAULT_SETTINGS: BeaverCalendarSettings = {
boardsFolder: "Boards",
archiveHeading: "Archive",
archiveFolder: "archive",
view: "list",
groupBy: "sphere",
listSort: "due",
@@ -70,25 +70,33 @@ export class BeaverCalendarSettingsTab extends PluginSettingTab {
);
new Setting(containerEl)
.setName("Archive heading")
.setDesc("The lane completed tasks are moved to by the archive command.")
.setName("Archive folder")
.setDesc(
"A subfolder mirroring the boards folder, where the archive command " +
"puts completed tasks: a board at Boards/work.md archives into " +
"Boards/archive/work.md, itself a Kanban board with the same lanes. " +
"The command also lifts a board's old *** archive section in there.",
)
.addText((text) =>
text
.setPlaceholder("Archive")
.setValue(this.plugin.settings.archiveHeading)
.setPlaceholder("archive")
.setValue(this.plugin.settings.archiveFolder)
.onChange(async (value) => {
this.plugin.settings.archiveHeading = value.trim() || "Archive";
this.plugin.settings.archiveFolder =
normalizeFolder(value) || "archive";
await this.plugin.saveSettings();
await this.plugin.store.reloadAll();
}),
);
new Setting(containerEl)
.setName("Include the archive")
.setDesc(
"Widen the show-completed toggle to reach tasks below the archive " +
"Widen the show-completed toggle to reach archived tasks - both the " +
"archive folder and whatever still sits below a board's *** " +
"separator. With this off, show-completed only reveals completed " +
"tasks still sitting in their lanes. Archived tasks carry an " +
"Archive badge instead of a project one.",
"tasks still in their lanes. Archived tasks carry an Archive badge " +
"instead of a project one.",
)
.addToggle((toggle) =>
toggle
+50 -9
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Portal } from "bits-ui";
import type { Snippet } from "svelte";
import { onScreen, place } from "../lib/place";
import { onScreen, place, roomFor, viewport } from "../lib/place";
import { cn } from "../lib/utils";
import { useView } from "./context";
@@ -27,13 +27,21 @@
const view = useView();
// iOS animates the keyboard in; the viewport only tells the truth after.
const KEYBOARD_SETTLE_MS = 350;
let panel = $state<HTMLElement | null>(null);
let at = $state({ top: 0, left: 0 });
// Kept in state as well as on the node: the `style` attribute is rendered
// from `at`, so a re-render would drop a height only set imperatively.
let room = $state(0);
let placed = $state(false);
/**
* Placed by hand rather than by a floating layer: these panels are summoned
* by a key press, and a floating layer wants a trigger element to hang off.
* Measured against the visual viewport, so a phone's keyboard pushes the
* panel up instead of hiding it.
*/
function measure(): void {
const node = panel;
@@ -42,15 +50,19 @@
const boxes = [...document.querySelectorAll(anchor)].map((element) =>
element.getBoundingClientRect(),
);
at = place(onScreen(boxes), node.getBoundingClientRect(), {
width: window.innerWidth,
height: window.innerHeight,
});
const view = viewport(node.ownerDocument.defaultView ?? window);
// Applied to the node before it is measured: a keyboard that just took
// half the screen makes the panel shorter, and placing it against its old
// height would leave it hanging below the keys.
room = roomFor(view);
node.style.maxHeight = `${room}px`;
at = place(onScreen(boxes), node.getBoundingClientRect(), view);
placed = true;
}
function mount(node: HTMLElement) {
panel = node;
const win = node.ownerDocument.defaultView ?? window;
measure();
if (autofocus) {
(node.querySelector<HTMLElement>("input") ?? node).focus({
@@ -64,17 +76,46 @@
const reflow = (): void => measure();
document.addEventListener("pointerdown", outside, true);
window.addEventListener("resize", reflow);
// The keyboard changes the visual viewport without resizing the window,
// and its animation takes a moment to settle.
const vv = win.visualViewport;
vv?.addEventListener("resize", reflow);
vv?.addEventListener("scroll", reflow);
const settle = win.setTimeout(reflow, KEYBOARD_SETTLE_MS);
// A panel whose body grew (an option list opened) re-places itself. Only
// the content height is watched: measure() sets max-height, so observing
// the panel itself would feed its own change back in.
const body = node.firstElementChild;
const watcher =
typeof ResizeObserver === "undefined" || !body
? null
: new ResizeObserver(reflow);
if (body) watcher?.observe(body);
win.addEventListener("keydown", keys, true);
return {
destroy() {
document.removeEventListener("pointerdown", outside, true);
window.removeEventListener("resize", reflow);
vv?.removeEventListener("resize", reflow);
vv?.removeEventListener("scroll", reflow);
win.clearTimeout(settle);
watcher?.disconnect();
win.removeEventListener("keydown", keys, true);
panel = null;
},
};
}
/**
* Read at the window's capture phase rather than on the panel itself.
* Obsidian's keymap listens there first, and a plugin that claims a key
* through `app.scope` — Tasks claims Tab — stops the event before it ever
* reaches an element handler. A listener on the same node still runs after
* that, so this is the one place the panel's keys cannot be taken away.
*/
function keys(event: KeyboardEvent): void {
if (!panel?.contains(event.target as Node)) return;
event.stopPropagation();
if (event.key === "Escape" && !event.defaultPrevented) {
event.preventDefault();
@@ -91,15 +132,15 @@
</script>
<Portal to={view.portal()}>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class={cn(
"fixed z-50 overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/45%)] outline-none",
"fixed z-50 flex flex-col overflow-y-auto overscroll-contain rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/45%)] outline-none",
className,
)}
onkeydown={keys}
role="dialog"
style="top: {at.top}px; left: {at.left}px; opacity: {placed ? 1 : 0}"
style="top: {at.top}px; left: {at.left}px; max-height: {room}px; opacity: {placed
? 1
: 0}"
tabindex="-1"
use:mount
>
+3
View File
@@ -65,6 +65,9 @@ export async function handleDrop(
if (id === dragged.id) return;
const onto = store.all.find((task) => task.id === id);
if (!onto) return;
// Archived cards are read-only ground: they live in the mirror file, and
// dropping onto one has no lane to mean.
if (onto.inArchive) return;
if (onto.path === dragged.path && onto.project === dragged.project) {
const board = store.boardFor(onto.path);
+3 -1
View File
@@ -82,7 +82,9 @@
</script>
<div bind:this={box} class={cn("max-h-56 overflow-y-auto", className)}>
{#each shown as { item, ranges }, at (item.value)}
<!-- Keyed by position, not by value: a caller that offers the same value
twice should read oddly, never tear the list down. -->
{#each shown as { item, ranges }, at (`${at}:${item.value}`)}
<button
class={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
+10 -1
View File
@@ -82,13 +82,22 @@
label,
hint: monthDay.format(fromKey(key)),
});
return [
const offers = [
{ value: "", label: "No date" },
on(now, "Today"),
on(shiftDays(now, 1), "Tomorrow"),
on(nextFriday(now), "Friday"),
on(shiftDays(now, 7), "In a week"),
];
// Two offers can land on the same day - a Saturday's "Friday" is also
// "In a week" - and the list is keyed by the day it writes. The first
// name wins, which is the more specific one.
const seen = new Set<string>();
return offers.filter((option) => {
if (seen.has(option.value)) return false;
seen.add(option.value);
return true;
});
});
const repeatOptions = $derived<Option[]>([
+10
View File
@@ -3,6 +3,7 @@ import ExternalLink from "@lucide/svelte/icons/external-link";
import FileText from "@lucide/svelte/icons/file-text";
import PenLine from "@lucide/svelte/icons/pen-line";
import ScanEye from "@lucide/svelte/icons/scan-eye";
import SquarePen from "@lucide/svelte/icons/square-pen";
import { STATUS_META } from "../lib/vocab";
import { STATUS_ORDER } from "../model/types";
import type { Task } from "../model/types";
@@ -60,6 +61,15 @@ export function taskMenu(view: ViewContext, task: Task): MenuItem[] {
},
{
icon: PenLine,
label: "Edit",
hint: "e",
onselect: () => {
view.store.cursorTask = task.id;
view.store.edit(task.id);
},
},
{
icon: SquarePen,
label: "Edit in Tasks",
onselect: () => void editInTasks(view, task),
},
+38 -8
View File
@@ -14,7 +14,7 @@
import { dueLabel, dueTone, fromKey } from "../lib/due";
import { labelHue } from "../lib/hue";
import type { Action } from "../lib/keymap";
import { onScreen } from "../lib/place";
import { onScreen, viewport } from "../lib/place";
import type { Box } from "../lib/place";
import { cn } from "../lib/utils";
import { PRIORITY_LABEL, STATUS_META } from "../lib/vocab";
@@ -63,6 +63,8 @@
label: string;
}
// Every row is a button first and a key second: on a phone the keys are not
// there to be pressed, so the lens has to be operable by thumb alone.
const rows = $derived<Row[]>(
task
? [
@@ -72,6 +74,7 @@
{ action: "taskBack", keys: ["⇧h"], label: "−day" },
{ action: "taskForward", keys: ["⇧l"], label: "+day" },
{ action: "taskToday", keys: ["⇧t"], label: "today" },
{ action: "taskUnschedule", keys: ["⇧x"], label: "no date" },
{ action: "taskOpen", keys: ["⏎"], label: "open note" },
]
: [],
@@ -85,13 +88,23 @@
* the middle, the way a window feels centred rather than measures it.
*/
function centre(node: HTMLElement): void {
const box = view.rootBox() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
const win = node.ownerDocument.defaultView ?? window;
const area = viewport(win);
const box =
view.rootBox() ??
new DOMRect(area.left ?? 0, area.top ?? 0, area.width, area.height);
const width = node.offsetWidth;
const height = node.offsetHeight;
const left = box.left + (box.width - width) / 2;
const top = box.top + (box.height - height) * 0.42;
node.style.left = `${Math.max(8, Math.min(left, window.innerWidth - width - 8))}px`;
node.style.top = `${Math.max(8, Math.min(top, window.innerHeight - height - 8))}px`;
// Kept inside what is actually visible: a phone's keyboard shrinks the
// viewport without touching the window, and the lens must stay reachable.
const minLeft = (area.left ?? 0) + 8;
const maxLeft = (area.left ?? 0) + area.width - width - 8;
const minTop = (area.top ?? 0) + 8;
const maxTop = (area.top ?? 0) + area.height - height - 8;
node.style.left = `${Math.max(minLeft, Math.min(left, Math.max(minLeft, maxLeft)))}px`;
node.style.top = `${Math.max(minTop, Math.min(top, Math.max(minTop, maxTop)))}px`;
}
/** The row or card under the cursor, wherever the current view draws it. */
@@ -151,6 +164,11 @@
watcher?.observe(node);
const reflow = (): void => centre(node);
window.addEventListener("resize", reflow);
// A keyboard (an editor opening behind the lens) moves the visible area
// without resizing the window.
const vv = (node.ownerDocument.defaultView ?? window).visualViewport;
vv?.addEventListener("resize", reflow);
vv?.addEventListener("scroll", reflow);
// A click on another task moves the lens rather than closing it; a click
// anywhere else puts it away.
@@ -166,6 +184,8 @@
destroy() {
watcher?.disconnect();
window.removeEventListener("resize", reflow);
vv?.removeEventListener("resize", reflow);
vv?.removeEventListener("scroll", reflow);
document.removeEventListener("pointerdown", outside, true);
// Only take the keyboard back if the lens had it; an editor that has
// just opened keeps its caret.
@@ -348,23 +368,33 @@
</div>
<div
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground"
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground pointer-coarse:gap-x-1.5 pointer-coarse:gap-y-1.5 pointer-coarse:p-2"
>
{#each rows as row (row.action)}
<button
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-9 pointer-coarse:border pointer-coarse:border-border pointer-coarse:bg-popover pointer-coarse:px-3 pointer-coarse:text-xs pointer-coarse:text-foreground"
onclick={() => act(row.action)}
type="button"
>
{#each row.keys as key (key)}
<kbd class="kbd">{key}</kbd>
<kbd class="kbd pointer-coarse:hidden">{key}</kbd>
{/each}
{row.label}
</button>
{/each}
<span class="ml-auto whitespace-nowrap px-1.5">
<span class="ml-auto whitespace-nowrap px-1.5 pointer-coarse:hidden">
<kbd class="kbd">␣</kbd> close
</span>
<!-- No Escape key on a phone, so the lens needs a way out by thumb. -->
<button
class="ml-auto hidden h-9 items-center rounded-md border border-border bg-popover px-3 text-foreground text-xs pointer-coarse:inline-flex"
onclick={() => {
store.peeking = false;
}}
type="button"
>
close
</button>
</div>
</div>
</Portal>
+119 -26
View File
@@ -1,4 +1,4 @@
import { findLane, laneInsertPoint, parseBoard } from "../model/board";
import { bodyEnd, findLane, laneInsertPoint, parseBoard } from "../model/board";
import type { Task } from "../model/types";
export class StaleTaskError extends Error {
@@ -101,46 +101,139 @@ export function applyReorder(
}
}
const SETTINGS_OPEN = /^%%\s*kanban:settings\s*$/u;
// ── The archive ──────────────────────────────────────────────────────────────
export function applyArchive(
const SEPARATOR = /^\*\*\*\s*$/u;
/** Where loose cards land when a native archive section carries no heading. */
const LOOSE_LANE = "Archive";
/** A fresh mirror file: enough front matter for Kanban to open it as a board. */
export const ARCHIVE_SEED = ["---", "", "kanban-plugin: board", "", "---", ""];
export interface Section {
project: string;
block: string[];
}
export interface Harvest {
/** Completed cards taken off the board, each with the lane it sat in. */
cards: Section[];
/** The board's native `***` archive, lifted whole, lane by lane. */
legacy: Section[];
}
function trimBlanks(block: string[]): string[] {
let from = 0;
let to = block.length;
while (from < to && block[from].trim() === "") from += 1;
while (to > from && block[to - 1].trim() === "") to -= 1;
return block.slice(from, to);
}
/** Splits a run of board lines into its `## lane` sections, content and all. */
function sections(block: string[]): Section[] {
const out: Section[] = [];
let current: Section | null = null;
for (const line of block) {
const heading = /^##\s+(.*?)\s*$/u.exec(line);
if (heading) {
current = { project: heading[1], block: [] };
out.push(current);
continue;
}
if (!current) {
if (line.trim() === "") continue;
current = { project: LOOSE_LANE, block: [] };
out.push(current);
}
current.block.push(line);
}
return out
.map((section) => ({ ...section, block: trimBlanks(section.block) }))
.filter((section) => section.block.length > 0);
}
/**
* Takes the completed cards off a board, and with `migrate` the native archive
* below the `***` separator as well - the mirror file is where history lives
* now, so the separator goes with it rather than growing forever in place.
* Mutates `lines`; what it returns is what the mirror has to be given.
*/
export function takeDone(
lines: string[],
path: string,
archiveHeading: string,
): number {
migrate: boolean,
): Harvest {
const board = parseBoard(path, lines.join("\n"));
const done = board.tasks.filter(
(task) => !task.inArchive && (task.status === "x" || task.status === "-"),
);
if (done.length === 0) return 0;
const blocks: string[][] = [];
const cards: Section[] = [];
for (const task of [...done].reverse()) {
const at = locate(lines, task);
blocks.unshift(lines.splice(at, extent(lines, at, task)));
cards.unshift({
project: task.project,
block: lines.splice(at, extent(lines, at, task)),
});
}
const heading = `## ${archiveHeading}`;
let archiveAt = lines.findIndex((line) => line.trim() === heading);
if (archiveAt === -1) {
const settingsAt = lines.findIndex((line) => SETTINGS_OPEN.test(line));
let tail = settingsAt === -1 ? lines.length : settingsAt;
let legacy: Section[] = [];
if (migrate) {
const from = lines.findIndex((line) => SEPARATOR.test(line));
if (from !== -1) {
const taken = lines.splice(from, bodyEnd(lines) - from);
legacy = sections(taken.slice(1));
// Close the gap the section left, in the spacing the boards already use.
let tail = from;
while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1;
lines.splice(tail, 0, "", "***", "", heading, "");
archiveAt = tail + 3;
const pad = from < lines.length ? ["", ""] : [""];
lines.splice(tail, from - tail, ...pad);
}
}
let insertAt = archiveAt + 1;
while (
insertAt < lines.length &&
!/^##\s/u.test(lines[insertAt]) &&
!SETTINGS_OPEN.test(lines[insertAt])
) {
insertAt += 1;
return { cards, legacy };
}
while (insertAt > archiveAt + 1 && lines[insertAt - 1].trim() === "") {
insertAt -= 1;
/** Appends a block to a lane of a mirror file, opening the lane if it is new. */
function appendToLane(
lines: string[],
path: string,
project: string,
block: string[],
): void {
const board = parseBoard(path, lines.join("\n"), true);
const lane = findLane(board, project, true);
if (lane) {
lines.splice(laneInsertPoint(board, lane), 0, ...block);
return;
}
let tail = bodyEnd(lines);
while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1;
// The boards leave one blank line under the front matter and two between
// lanes; a mirror nobody has to squint at reads the same way.
const lead = board.lanes.length > 0 ? ["", ""] : [""];
lines.splice(tail, 0, ...lead, `## ${project}`, "", ...block, "");
}
/**
* Writes a harvest into its mirror file. Cards join the lane they came from,
* so the mirror reads like the board it shadows; the old native archive keeps
* its own headings, whatever they were called.
*/
export function fillArchive(
lines: string[],
path: string,
harvest: Harvest,
): void {
if (lines.every((line) => line.trim() === "")) {
lines.splice(0, lines.length, ...ARCHIVE_SEED);
}
for (const { project, block } of [...harvest.cards, ...harvest.legacy]) {
appendToLane(lines, path, project, block);
}
while (lines.length > 1 && lines.at(-1) === "" && lines.at(-2) === "") {
lines.pop();
}
lines.splice(insertAt, 0, ...blocks.flat());
return blocks.length;
}
+87 -8
View File
@@ -7,15 +7,17 @@ import { tasksApi, warnMissingTasks } from "../tasks-api";
import { today } from "../lib/due";
import { record, transaction } from "./history";
import {
applyArchive,
applyReorder,
ARCHIVE_SEED,
cutTask,
extent,
fillArchive,
insertBlock,
locate,
MissingLaneError,
replaceLine,
StaleTaskError,
takeDone,
} from "./edits";
async function edit(
@@ -266,14 +268,91 @@ export async function appendRawTask(
});
}
export async function archiveDone(
/** Replaces a whole file, but only if it still reads the way it was read. */
async function overwrite(
app: App,
path: string,
archiveHeading: string,
): Promise<number> {
let moved = 0;
await edit(app, path, (lines) => {
moved = applyArchive(lines, path, archiveHeading);
before: string,
after: string,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
let ok = true;
await app.vault.process(file, (data) => {
if (data !== before) {
ok = false;
return data;
}
record(path, data, after);
return after;
});
return moved;
return ok;
}
/** The mirror file, made along with any folder it needs. */
async function openArchive(app: App, path: string): Promise<TFile | null> {
const existing = app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) return existing;
const parts = path.split("/");
parts.pop();
let folder = "";
for (const part of parts) {
folder = folder ? `${folder}/${part}` : part;
if (app.vault.getAbstractFileByPath(folder)) continue;
try {
await app.vault.createFolder(folder);
} catch {
/* another writer got there first, which is all we wanted */
}
}
try {
return await app.vault.create(path, ARCHIVE_SEED.join("\n"));
} catch {
new Notice(`Beaver Calendar: could not create ${path}`, 6000);
return null;
}
}
export interface ArchiveOutcome {
/** Completed cards moved out of the board. */
moved: number;
/** Whether the board's native `***` archive went with them. */
migrated: boolean;
}
/**
* Empties a board of what is finished with it: the completed cards, and with
* `migrate` the native archive section too. The mirror is written first, so a
* board only loses lines once the file that is to keep them has them.
*/
export async function archiveBoard(
app: App,
path: string,
archivePath: string,
migrate: boolean,
): Promise<ArchiveOutcome> {
const nothing: ArchiveOutcome = { moved: 0, migrated: false };
const source = app.vault.getAbstractFileByPath(path);
if (!(source instanceof TFile)) return nothing;
const before = await app.vault.read(source);
const lines = before.split("\n");
const harvest = takeDone(lines, path, migrate);
if (harvest.cards.length === 0 && harvest.legacy.length === 0) return nothing;
if (!(await openArchive(app, archivePath))) return nothing;
const pasted = await edit(app, archivePath, (into) => {
fillArchive(into, archivePath, harvest);
});
if (!pasted) return nothing;
if (!(await overwrite(app, path, before, lines.join("\n")))) {
new Notice(
`Beaver Calendar: ${path} changed mid-archive, undo to put it back`,
6000,
);
return nothing;
}
return { moved: harvest.cards.length, migrated: harvest.legacy.length > 0 };
}
+62 -16
View File
@@ -1,5 +1,6 @@
import { TFile } from "obsidian";
import type { App } from "obsidian";
import { isArchivePath, toBoardPath } from "../model/archive";
import { parseBoard, sphereOf } from "../model/board";
import { anchorDate, isOpen, STATUS_ORDER } from "../model/types";
import type { Board, Status, Task } from "../model/types";
@@ -91,6 +92,9 @@ function compareTasks(sortBy: SortBy, a: Task, b: Task): number {
export class BoardStore {
private readonly boards = new Map<string, Board>();
/** The mirror files under the archive folder, kept apart from the boards
* they shadow: they are history, not lanes to write into. */
private readonly archives = new Map<string, Board>();
private reloadTimer: number | null = null;
private readonly pending = new Set<string>();
@@ -228,13 +232,31 @@ export class BoardStore {
}
private inScope(path: string): boolean {
if (!path.endsWith(".md")) return false;
const folder = this.settings.boardsFolder;
if (!folder) return path.endsWith(".md");
return path.startsWith(`${folder}/`) && path.endsWith(".md");
return !folder || path.startsWith(`${folder}/`);
}
private isArchive(path: string): boolean {
return isArchivePath(
path,
this.settings.boardsFolder,
this.settings.archiveFolder,
);
}
/** The board a path belongs to: a mirror file answers with what it shadows. */
private boardPath(path: string): string {
return toBoardPath(
path,
this.settings.boardsFolder,
this.settings.archiveFolder,
);
}
async reloadAll(): Promise<void> {
this.boards.clear();
this.archives.clear();
const files = this.app.vault
.getMarkdownFiles()
.filter((file) => this.inScope(file.path));
@@ -244,7 +266,14 @@ export class BoardStore {
private async load(file: TFile): Promise<void> {
const content = await this.app.vault.cachedRead(file);
this.boards.set(file.path, parseBoard(file.path, content));
this.keep(file.path, content);
}
private keep(path: string, content: string): void {
const archived = this.isArchive(path);
const into = archived ? this.archives : this.boards;
(archived ? this.boards : this.archives).delete(path);
into.set(path, parseBoard(path, content, archived));
}
queueReload(path: string): void {
@@ -264,20 +293,21 @@ export class BoardStore {
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
this.boards.delete(path);
this.archives.delete(path);
continue;
}
const content = await this.app.vault.cachedRead(file);
this.boards.set(path, parseBoard(path, content));
this.keep(path, await this.app.vault.cachedRead(file));
}
this.version += 1;
}
drop(path: string): void {
if (this.boards.delete(path)) this.version += 1;
const gone = this.boards.delete(path) || this.archives.delete(path);
if (gone) this.version += 1;
}
boardFor(path: string): Board | undefined {
return this.boards.get(path);
return this.boards.get(path) ?? this.archives.get(path);
}
all = $derived.by<Task[]>(() => {
@@ -288,6 +318,9 @@ export class BoardStore {
if (this.includeArchive || !task.inArchive) out.push(task);
}
}
if (this.includeArchive) {
for (const board of this.archives.values()) out.push(...board.tasks);
}
return out;
});
@@ -333,8 +366,9 @@ export class BoardStore {
const byProject = new Map<string, number>();
for (const task of this.all) {
if (!this.matches(task)) continue;
byPath.set(task.path, (byPath.get(task.path) ?? 0) + 1);
const key = laneKey(task.path, task.project);
const path = this.boardPath(task.path);
byPath.set(path, (byPath.get(path) ?? 0) + 1);
const key = laneKey(path, task.project);
byProject.set(key, (byProject.get(key) ?? 0) + 1);
}
return { byPath, byProject };
@@ -356,11 +390,16 @@ export class BoardStore {
);
for (const group of groups) {
const first = group.tasks[0];
const path = this.boardPath(first.path);
// A group only offers a composer when every card in it shares one live
// lane; archived ones ride along, so they are read through their board.
const single = group.tasks.every(
(task) => task.path === first.path && task.project === first.project,
(task) =>
this.boardPath(task.path) === path && task.project === first.project,
);
group.lane = single
? { path: first.path, project: first.project }
group.lane =
single && this.boards.has(path)
? { path, project: first.project }
: null;
}
return groups;
@@ -369,7 +408,7 @@ export class BoardStore {
private slotFor(task: Task): Omit<Group, "tasks" | "lane"> {
if (this.groupBy === "project") {
return {
key: laneKey(task.path, task.project),
key: laneKey(this.boardPath(task.path), task.project),
label: task.project,
hint: task.sphere,
rank: 0,
@@ -387,7 +426,12 @@ export class BoardStore {
const bucket = dueBucket(anchorDate(task), this.today);
return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank };
}
return { key: task.path, label: task.sphere, hint: "", rank: 0 };
return {
key: this.boardPath(task.path),
label: task.sphere,
hint: "",
rank: 0,
};
}
byDay = $derived.by(() => {
@@ -463,7 +507,7 @@ export class BoardStore {
}
sphereName(path: string): string {
return this.boards.get(path)?.sphere ?? sphereOf(path);
return this.boardFor(path)?.sphere ?? sphereOf(path);
}
// ── Keyboard cursor ──────────────────────────────────────────────────────
@@ -714,7 +758,9 @@ export class BoardStore {
throughRail(task: Task): boolean {
if (this.lanes === null) return true;
// Archived tasks have no lane of their own; they ride with their sphere.
if (task.inArchive) return this.sphereState(task.path) !== "none";
if (task.inArchive) {
return this.sphereState(this.boardPath(task.path)) !== "none";
}
return this.lanes.has(laneKey(task.path, task.project));
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
archiveRoot,
isArchivePath,
toArchivePath,
toBoardPath,
} from "../src/model/archive";
import { parseBoard, sphereOf } from "../src/model/board";
describe("archive paths", () => {
it("mirrors a board into the archive folder", () => {
expect(toArchivePath("Boards/work.md", "Boards", "archive")).toBe(
"Boards/archive/work.md",
);
expect(toArchivePath("📆 доски/работа.md", "📆 доски", "архив")).toBe(
"📆 доски/архив/работа.md",
);
});
it("keeps the shape of a nested board", () => {
expect(toArchivePath("Boards/team/ops.md", "Boards", "archive")).toBe(
"Boards/archive/team/ops.md",
);
expect(toBoardPath("Boards/archive/team/ops.md", "Boards", "archive")).toBe(
"Boards/team/ops.md",
);
});
it("leaves a mirror file where it is", () => {
const mirror = "Boards/archive/work.md";
expect(toArchivePath(mirror, "Boards", "archive")).toBe(mirror);
expect(isArchivePath(mirror, "Boards", "archive")).toBe(true);
expect(isArchivePath("Boards/work.md", "Boards", "archive")).toBe(false);
});
it("has nowhere to put a board outside the boards folder", () => {
expect(toArchivePath("Notes/work.md", "Boards", "archive")).toBeNull();
expect(toArchivePath("Boards/work.md", "Boards", "")).toBeNull();
expect(archiveRoot("Boards", " ")).toBe("");
});
it("works with no boards folder at all", () => {
expect(archiveRoot("", "archive")).toBe("archive");
expect(toArchivePath("work.md", "", "archive")).toBe("archive/work.md");
expect(toBoardPath("archive/work.md", "", "archive")).toBe("work.md");
});
it("shrugs at slashes around the folder names", () => {
expect(toArchivePath("Boards/work.md", "/Boards/", "/archive/")).toBe(
"Boards/archive/work.md",
);
});
it("a mirror file keeps the sphere of the board it shadows", () => {
expect(sphereOf("Boards/archive/работа.md")).toBe("работа");
});
});
describe("parsing a mirror file", () => {
const content = [
"---",
"",
"kanban-plugin: board",
"",
"---",
"",
"## errands",
"",
"- [x] pick up the parcel ✅ 2026-07-30",
"",
].join("\n");
it("marks everything archive, separator or no separator", () => {
const board = parseBoard("Boards/archive/personal.md", content, true);
expect(board.tasks).toHaveLength(1);
expect(board.tasks[0].inArchive).toBe(true);
expect(board.tasks[0].project).toBe("errands");
expect(board.lanes.every((lane) => lane.inArchive)).toBe(true);
});
it("reads as a plain board when nobody says it is a mirror", () => {
const board = parseBoard("Boards/personal.md", content);
expect(board.tasks[0].inArchive).toBe(false);
});
});
+149 -58
View File
@@ -5,14 +5,15 @@ import { parseBoard } from "../src/model/board";
import { serializeTaskLine } from "../src/model/serialize";
import type { Task } from "../src/model/types";
import {
applyArchive,
applyReorder,
fillArchive,
cutTask,
insertBlock,
locate,
MissingLaneError,
replaceLine,
StaleTaskError,
takeDone,
} from "../src/vault/edits";
const FIXTURES = join(import.meta.dirname, "fixtures");
@@ -189,61 +190,17 @@ describe("applyReorder", () => {
});
});
describe("applyArchive", () => {
it("moves done cards under the archive heading", () => {
const { content, board: parsed } = board("personal.md");
/** Runs the archive over one board, mirror file and all. */
function archived(name: string, migrate = true) {
const { content } = board(name);
const lines = content.split("\n");
const open = parsed.tasks.find((item) => !item.inArchive) as Task;
replaceLine(
lines,
open,
serializeTaskLine({ ...open, status: "x", done: "2026-08-08" }),
);
expect(applyArchive(lines, "personal.md", "Archive")).toBe(1);
const next = lines.join("\n");
const reparsed = parseBoard("personal.md", next);
const archived = reparsed.tasks.find((item) => item.text === open.text) as Task;
expect(archived.inArchive).toBe(true);
expect(archived.done).toBe("2026-08-08");
expect(reparsed.tasks.filter((item) => item.text === open.text)).toHaveLength(1);
expect(landmarks(next).settings).toBe(1);
expect(landmarks(next).fences).toBe(1);
});
it("is a no-op when nothing is done", () => {
const { content } = board("home.md");
const lines = content.split("\n");
expect(applyArchive(lines, "home.md", "Archive")).toBe(0);
expect(lines.join("\n")).toBe(content);
});
it("creates the fence and heading on a board without one", () => {
const { content } = board("home.md");
expect(content).not.toContain("## Archive");
const lines = content.split("\n");
const parsed = parseBoard("home.md", content);
for (const task of parsed.tasks) {
replaceLine(
lines,
task,
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
);
const harvest = takeDone(lines, name, migrate);
const mirror: string[] = [];
fillArchive(mirror, `archive/${name}`, harvest);
return { harvest, board: lines.join("\n"), mirror: mirror.join("\n") };
}
expect(applyArchive(lines, "home.md", "Archive")).toBe(parsed.tasks.length);
const next = lines.join("\n");
expect(next).toContain("***");
expect(next).toContain("## Archive");
expect(next).toContain("%% kanban:settings");
expect(parseBoard("home.md", next).tasks.every((t) => t.inArchive)).toBe(true);
});
});
describe("every board survives a full archive pass", () => {
for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) {
it(`${name}: landmarks intact, no task lost`, () => {
function markAllDone(name: string): string[] {
const { content, board: parsed } = board(name);
const lines = content.split("\n");
for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
@@ -253,20 +210,154 @@ describe("every board survives a full archive pass", () => {
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
);
}
applyArchive(lines, name, "Archive");
return lines;
}
describe("takeDone", () => {
it("lifts a completed card out with the lane it sat in", () => {
const { content, board: parsed } = board("personal.md");
const lines = content.split("\n");
const open = parsed.tasks.find((item) => !item.inArchive) as Task;
replaceLine(
lines,
open,
serializeTaskLine({ ...open, status: "x", done: "2026-08-08" }),
);
const harvest = takeDone(lines, "personal.md", false);
expect(harvest.cards).toHaveLength(1);
expect(harvest.cards[0].project).toBe("errands");
expect(harvest.cards[0].block[0]).toContain(open.text);
expect(lines.join("\n")).not.toContain(open.text);
});
it("is a no-op when nothing is done and nothing was archived", () => {
const { content } = board("home.md");
const lines = content.split("\n");
const harvest = takeDone(lines, "home.md", true);
expect(harvest.cards).toHaveLength(0);
expect(harvest.legacy).toHaveLength(0);
expect(lines.join("\n")).toBe(content);
});
it("leaves the native archive alone without migrate", () => {
const { content } = board("personal.md");
const lines = content.split("\n");
takeDone(lines, "personal.md", false);
const next = lines.join("\n");
expect(next).toContain("***");
expect(next).toContain("## Archive");
expect(next).toContain("pick up the parcel");
});
it("takes the native archive whole, separator and all", () => {
const { board: parsed } = board("personal.md");
const { harvest, board: next } = archived("personal.md");
expect(harvest.legacy).toHaveLength(1);
expect(harvest.legacy[0].project).toBe("Archive");
const wasArchived = parsed.tasks.filter((item) => item.inArchive);
expect(harvest.legacy[0].block).toHaveLength(wasArchived.length);
expect(next).not.toContain("***");
expect(next).not.toContain("## Archive");
expect(next).not.toContain("pick up the parcel");
// The lanes that were live stay untouched, settings block included.
expect(landmarks(next).settings).toBe(1);
expect(landmarks(next).headings).toEqual(["## errands", "## someday"]);
expect(next).toContain("- [ ] renew the gym membership");
});
});
describe("fillArchive", () => {
it("writes a Kanban board with the lanes the cards came from", () => {
const lines = markAllDone("work.md");
const harvest = takeDone(lines, "work.md", true);
const mirror: string[] = [];
fillArchive(mirror, "archive/work.md", harvest);
const next = mirror.join("\n");
expect(next.startsWith("---\n\nkanban-plugin: board\n\n---\n")).toBe(true);
expect(next).toContain("## website");
expect(next).toContain("## notes");
// The old native archive keeps its own heading.
expect(next).toContain("## Archive");
expect(next).toContain("- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21");
const parsed = parseBoard("archive/work.md", next, true);
expect(parsed.tasks.every((task) => task.inArchive)).toBe(true);
expect(parsed.tasks.map((task) => task.project)).toContain("website");
});
it("keeps a card's indented body with it", () => {
const lines = markAllDone("work.md");
const harvest = takeDone(lines, "work.md", false);
const mirror: string[] = [];
fillArchive(mirror, "archive/work.md", harvest);
const next = mirror.join("\n");
expect(next).toContain("\t- shorter intro");
expect(next).toContain("\tfollow-up: check the analytics bundle size");
});
it("appends to a mirror that already holds history", () => {
const first = markAllDone("personal.md");
const harvest = takeDone(first, "personal.md", true);
const mirror: string[] = [];
fillArchive(mirror, "archive/personal.md", harvest);
const before = parseBoard("archive/personal.md", mirror.join("\n"), true);
fillArchive(mirror, "archive/personal.md", {
cards: [{ project: "errands", block: ["- [x] a later one ✅ 2026-09-01"] }],
legacy: [],
});
const after = parseBoard("archive/personal.md", mirror.join("\n"), true);
expect(after.tasks).toHaveLength(before.tasks.length + 1);
expect(after.lanes).toHaveLength(before.lanes.length);
const errands = after.tasks.filter((task) => task.project === "errands");
expect(errands.at(-1)?.text).toBe("a later one");
});
});
describe("every board survives a full archive pass", () => {
for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) {
it(`${name}: nothing lost, the board keeps its shape`, () => {
const { content, board: parsed } = board(name);
const lines = markAllDone(name);
const harvest = takeDone(lines, name, true);
const next = lines.join("\n");
const mirror: string[] = [];
fillArchive(mirror, `archive/${name}`, harvest);
// The board keeps its front matter, its settings and its live lanes,
// and holds no task at all any more.
const reparsed = parseBoard(name, next);
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true);
expect(reparsed.tasks).toHaveLength(0);
const before = landmarks(content);
const after = landmarks(next);
expect(after.settings).toBe(before.settings);
expect(after.frontmatter).toBe(before.frontmatter);
for (const heading of before.headings) {
expect(after.fences).toBe(0);
for (const heading of before.headings.filter((h) => h !== "## Archive")) {
expect(after.headings, `${heading} must survive`).toContain(heading);
}
// Every task that was on the board is now in the mirror, once.
const mirrored = parseBoard(`archive/${name}`, mirror.join("\n"), true);
expect(mirrored.tasks).toHaveLength(parsed.tasks.length);
expect(mirrored.tasks.every((task) => task.inArchive)).toBe(true);
expect(mirrored.tasks.map((task) => task.raw).sort()).toEqual(
[
...parsed.tasks.filter((task) => task.inArchive).map((t) => t.raw),
...markedDone(parsed.tasks.filter((task) => !task.inArchive)),
].sort(),
);
});
}
});
function markedDone(tasks: Task[]): string[] {
return tasks.map((task) =>
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
);
}
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { onScreen, place } from "../src/lib/place";
import { onScreen, place, roomFor } from "../src/lib/place";
import type { Box } from "../src/lib/place";
const VIEW = { width: 1280, height: 800 };
@@ -58,3 +58,40 @@ describe("place", () => {
expect(place(null, PANEL, VIEW)).toEqual({ top: 300, left: 480 });
});
});
// A phone's keyboard shrinks the visual viewport and leaves the window alone.
describe("place against a keyboard", () => {
const SHRUNK = { width: 390, height: 400, top: 0, left: 0 };
it("keeps the panel above the keyboard", () => {
const spot = place(row({ top: 380, left: 10, width: 370 }), PANEL, SHRUNK);
expect(spot.top + PANEL.height).toBeLessThanOrEqual(SHRUNK.height);
});
it("measures from where the visible area starts", () => {
const scrolled = { width: 390, height: 400, top: 120, left: 0 };
const spot = place(null, PANEL, scrolled);
expect(spot.top).toBeGreaterThanOrEqual(scrolled.top);
expect(spot.top + PANEL.height).toBeLessThanOrEqual(
scrolled.top + scrolled.height
);
});
it("comes back into view when the anchor itself is under the keyboard", () => {
// The row the panel hangs off is at 650, the keyboard leaves 380: neither
// above nor below the anchor is reachable, so the visible area wins.
const spot = place(row({ top: 650, height: 40 }), PANEL, SHRUNK);
expect(spot.top).toBeGreaterThanOrEqual(10);
expect(spot.top + PANEL.height).toBeLessThanOrEqual(SHRUNK.height);
});
});
describe("roomFor", () => {
it("leaves the panel less height than the visible area", () => {
expect(roomFor({ width: 390, height: 400 })).toBeLessThan(400);
});
it("never collapses to nothing when the keyboard takes almost everything", () => {
expect(roomFor({ width: 390, height: 60 })).toBeGreaterThan(100);
});
});
+81 -1
View File
@@ -9,6 +9,7 @@ import { JSDOM } from "jsdom";
const root = new URL("..", import.meta.url).pathname;
const fixtures = join(root, "tests/fixtures");
const FOLDER = "Boards";
const ARCHIVE = "archive";
const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs");
writeFileSync(
@@ -96,6 +97,23 @@ const files = new Map(
readFileSync(join(fixtures, name), "utf8"),
]),
);
// A mirror file, the way the archive command leaves one behind.
const MIRROR = `${FOLDER}/${ARCHIVE}/personal.md`;
files.set(
MIRROR,
[
"---",
"",
"kanban-plugin: board",
"",
"---",
"",
"## errands",
"",
"- [x] hang the shelf 📅 2026-07-02 ✅ 2026-07-03",
"",
].join("\n"),
);
const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)]));
const writes = [];
@@ -122,7 +140,7 @@ const app = {
const settings = {
boardsFolder: FOLDER,
archiveHeading: "Archive",
archiveFolder: ARCHIVE,
view: "list",
groupBy: "sphere",
listSort: "due",
@@ -153,6 +171,11 @@ await store.reloadAll();
const outsideArchive = 15;
check("parsed every board", store.spheres.length === names.length, `${store.spheres.length}`);
check(`${outsideArchive} tasks outside the archive`, store.all.length === outsideArchive, `got ${store.all.length}`);
check(
"the mirror file is not a sphere of its own",
store.spheres.every((sphere) => !sphere.path.includes(`/${ARCHIVE}/`)),
store.spheres.map((sphere) => sphere.path).join(", "),
);
const target = dom.window.document.body;
const instance = mount(App, {
@@ -181,10 +204,39 @@ flushSync();
await settle();
check("archive appears when both toggles are on", text().includes("pick up the parcel"));
check("archived task carries the Archive badge", text().includes("Archive"));
check("the archive folder is rendered too", text().includes("hang the shelf"));
const mirrored = store.all.find((task) => task.text === "hang the shelf");
check("a mirrored task is archive", mirrored?.inArchive === true);
check(
"it keeps the lane it was archived from",
mirrored?.project === "errands",
`${mirrored?.project}`,
);
check(
"it groups under the board it mirrors, not beside it",
store.groups.filter((group) => group.label === "personal").length === 1,
store.groups.map((group) => group.label).join(", "),
);
// The rail knows nothing of the mirror file, so archived cards have to ride
// with the sphere they came from.
store.soloSphere(`${FOLDER}/personal.md`);
flushSync();
await settle();
check("the rail keeps mirrored cards with their sphere", text().includes("hang the shelf"));
store.soloSphere(`${FOLDER}/work.md`);
flushSync();
await settle();
check("another sphere leaves them out", !text().includes("hang the shelf"));
store.filterAll();
flushSync();
await settle();
store.includeArchive = false;
flushSync();
check("archive hidden again", !text().includes("pick up the parcel"));
check("the archive folder is hidden with it", !text().includes("hang the shelf"));
store.showDone = false;
flushSync();
@@ -609,6 +661,34 @@ for (const [name, init] of [
store.tick();
}
// On a Friday "Friday" and "In a week" are the same day. The date field used
// to be keyed by that day and tore itself down, so the picker never opened.
{
store.today = "2026-09-04";
store.leaveToMain();
flushSync();
key(viewRoot, { key: "n", code: "KeyN" });
flushSync();
store.today = "2026-09-04";
flushSync();
const panel = panelOf();
for (let i = 0; i < 3; i += 1) {
(dom.window.document.activeElement ?? panel).dispatchEvent(
new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Tab", code: "Tab" }),
);
flushSync();
}
check("the date field opens on a friday", panelOf() !== null && store.editing !== null);
check(
"the day two offers share is listed once",
(panel.textContent.match(/Sep 11/gu) ?? []).length === 1,
panel.textContent.slice(0, 200),
);
panel.dispatchEvent(new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Escape", code: "Escape" }));
flushSync();
store.tick();
}
// A request whose task vanished must not leave the view deaf.
store.request = "priority";
store.cursorTask = "nothing/at/all#0";