Compare commits

..
12 Commits
Author SHA1 Message Date
hh 3424222dab fix(due,store,ui): month grid steps by calendar day so dst cannot repeat one 2026-10-18 20:18:03 +02:00
hh e7178c6d3f fix(store,due): poll the clock so today turns over after sleep 2026-09-26 03:47:43 +02:00
hh b415b17007 docs(readme,preview): screenshots of both tabs, a calendar scene on the busiest month 2026-09-05 04:56:17 +02:00
hh fa36710f0c fix(view,ui): follow the keyboard through the navbar hiding behind it
The panel drifted while the keyboard came up and then slid under it at the
end. Two reasons, both about the ground moving twice: iOS animates the
keyboard, and once it is up Obsidian hides its own navbar - a second move that
the old four-frame watch had already stopped waiting for, and that a trigger
arriving mid-watch was dropped for instead of restarting it.

The watch now runs until the reading holds still for twelve frames, a fresh
trigger restarts it, and Obsidian's Capacitor keyboard events (`keyboardWill/
DidShow`, `...Hide`) are listened to as well - they fire around the animation
that `visualViewport` does not always report, and never fire on the desktop.
2026-09-05 01:14:54 +02:00
hh f980abcc1d fix(view,place): the boards view pads clear of the phone's navbar
The panel had an inset for this; the calendar never did. Its root filled the
leaf, and Obsidian's phone navbar floats over a leaf rather than shortening
it, so the scroll container ran to the bottom of the screen and the last row
of the list sat under the bar - unreachable.

`lib/inset.ts` is the same pure measurement the panel plugin grew, with the
same tests: the deeper of navbar and keyboard, never their sum, and a bar that
sits beside the view covers nothing of it. The view re-measures by frame until
it holds still and writes `--bcal-inset-bottom`.

`viewport()` subtracts the bar too, so anchored panels and the quick look are
placed against what is actually reachable instead of ending under it.
2026-09-05 01:05:14 +02:00
hh f7ccd21d7b fix(ui,preview): panels chase the keyboard by frame, and the preview is finally mobile
The panel measured itself once, a fixed delay into the keyboard's animation,
so it landed against a viewport that was still moving - and in a sidedock
drawer no resize event fires at all, so it never moved. It now re-places every
frame until its own position holds still, which needs neither a duration to
guess nor an event to wait for. Its input taking focus is another trigger:
that is what opens the keyboard in the first place.

The preview had no viewport meta, so a 390px window still laid out at 980px:
every "mobile" screenshot was a desktop one at a phone's aspect, and
coarse-pointer rules could not apply. Which is why the tap targets looked
fine here and were not.
2026-09-05 00:47:21 +02:00
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
hh d692dff19a fix(css): scope the subject compound so theme-dark rules keep matching 2026-08-29 19:50:01 +02:00
hh 4f485b0bc4 fix(css): leave tailwind theme vars on :root so obsidian's accent wins inside the view 2026-08-29 19:42:54 +02:00
hh 8c971e431a fix(css,preview): scope utilities under the view root, preview with real boards and foreign css 2026-08-29 19:39:19 +02:00
37 changed files with 1807 additions and 233 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ node_modules/
# vault by `make install`.
main.js
styles.css
.tailwind.css
*.zip
# Obsidian writes plugin settings here when the repo is symlinked into a
+37 -2
View File
@@ -1,9 +1,28 @@
# beaver-calendar
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
Calendar and task list for Obsidian Kanban+Tasks
A calendar and a task list for Obsidian on top of Kanban boards and the Tasks plugin. A sphere is a board file, a project is an h2 inside it; every task keeps living in its board, the plugin only shows them together - by deadline, by sphere, or on a month grid - and lets you drag, edit and close them without opening the board.
Sphere - md-file in Kanban boards directory. Project - h2 inside the file.
![The Calendar tab: a month with tasks by day, the undated ones below](docs/calendar.webp)
![The List tab: tasks grouped by deadline, the rail with spheres and projects on the left](docs/list.webp)
## 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
@@ -18,3 +37,19 @@ make zip # distributable
```
After `make install` turn on plugin in settings.
`styles.css` is Tailwind output run through `scripts/scope-css.mjs`: every
selector is prefixed with `:is(.bcal-root, .bcal-root *)`. Other plugins built
with Tailwind put their utilities into the same `utilities` cascade layer, and
without the extra specificity whichever plugin loaded last would win (their
`.hidden` over our `@container` variants, for one).
Preview knobs, all optional:
```bash
VAULT=/path/to/vault npm run preview # the vault's theme
BOARDS=/path/to/vault/Boards npm run preview # real boards, not fixtures
EXTRA_CSS=/path/to/other-plugin/styles.css … # another plugin's css, loaded after ours
open 'http://localhost:4173/?groupBy=due&railCollapsed=1#edit-date' # prefs + scene
node preview/shot.mjs out.png 'http://localhost:4173/#cal' # screenshot + console
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+3 -3
View File
@@ -6,10 +6,10 @@
"private": true,
"type": "module",
"scripts": {
"css": "tailwindcss -i src/tailwind.css -o styles.css",
"css:watch": "tailwindcss -i src/tailwind.css -o styles.css --watch",
"css": "node scripts/css.mjs",
"css:watch": "node scripts/css.mjs --watch",
"dev": "npm run css && node esbuild.config.mjs",
"build": "svelte-check --tsconfig ./tsconfig.json --threshold error && npm run css -- --minify && node esbuild.config.mjs production",
"build": "svelte-check --tsconfig ./tsconfig.json --threshold error && node scripts/css.mjs --minify && node esbuild.config.mjs production",
"test": "vitest run && node tests/smoke.mjs",
"preview": "node preview/serve.mjs"
},
+64 -9
View File
@@ -2,17 +2,46 @@ import { Component, TFile } from "obsidian";
import { mount } from "svelte";
import App from "../src/ui/app.svelte";
import { DEFAULT_SETTINGS } from "../src/settings";
import type { BeaverCalendarSettings } from "../src/settings";
import { BoardStore } from "../src/vault/store.svelte";
const BOARDS = ["personal", "work", "reading", "home"];
const FOLDER = "Boards";
const FIXTURES = ["personal", "work", "reading", "home"];
/**
* Settings come off the query string, so a screenshot can start from the
* same preferences a real vault has: ?groupBy=due&includeArchive=1&railCollapsed=1
*/
function prefsFromQuery(): Partial<BeaverCalendarSettings> {
const params = new URLSearchParams(location.search);
const out: Record<string, unknown> = {};
const flag = (value: string): boolean => value === "1" || value === "true";
for (const [key, value] of params) {
if (!(key in DEFAULT_SETTINGS)) continue;
const current = DEFAULT_SETTINGS[key as keyof BeaverCalendarSettings];
out[key] = typeof current === "boolean" ? flag(value) : value;
}
return out as Partial<BeaverCalendarSettings>;
}
async function main(): Promise<void> {
// Real boards when the server was given a folder, the fixtures otherwise.
const index = (await (await fetch("/boards.json")).json()) as {
folder: string;
names: string[];
} | null;
const folder = index?.folder ?? "Boards";
const sources = index
? index.names.map((name) => ({ path: `${folder}/${name}`, url: `/boards/${name}` }))
: FIXTURES.map((name) => ({
path: `${folder}/${name}.md`,
url: `/tests/fixtures/${name}.md`,
}));
const files = new Map<string, string>();
await Promise.all(
BOARDS.map(async (name) => {
const response = await fetch(`/tests/fixtures/${name}.md`);
files.set(`${FOLDER}/${name}.md`, await response.text());
sources.map(async ({ path, url }) => {
const response = await fetch(url);
files.set(path, await response.text());
}),
);
const handles = new Map(
@@ -37,7 +66,7 @@ async function main(): Promise<void> {
plugins: { plugins: {} },
};
const settings = { ...DEFAULT_SETTINGS, boardsFolder: FOLDER };
const settings = { ...DEFAULT_SETTINGS, boardsFolder: folder, ...prefsFromQuery() };
const store = new BoardStore(app as never, settings, () => {});
await store.reloadAll();
@@ -58,6 +87,14 @@ async function main(): Promise<void> {
await wait(50);
store.keyboard = true;
const first = store.spheres[0];
const tab = async (times: number): Promise<void> => {
for (let i = 0; i < times; i += 1) {
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", code: "Tab", bubbles: true }),
);
await wait(20);
}
};
if (scene.startsWith("rail")) {
store.toggleRail();
store.railExpanded = new Set([first.path, store.spheres[1].path]);
@@ -68,6 +105,17 @@ async function main(): Promise<void> {
}
if (scene === "rail-none") store.filterNone();
}
// The month grid on its own, opened on the month the fixtures are busiest in.
if (scene === "cal") {
const months = new Map<string, number>();
for (const item of store.visible) {
const key = (item.due ?? item.scheduled)?.slice(0, 7);
if (key) months.set(key, (months.get(key) ?? 0) + 1);
}
const busiest = [...months.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
store.view = "calendar";
if (busiest) store.focusDay(`${busiest}-15`);
}
if (scene.startsWith("peek")) {
if (scene === "peek-cal") store.view = "calendar";
if (scene === "peek-notes") store.showDone = true;
@@ -85,10 +133,17 @@ async function main(): Promise<void> {
if (scene === "date") {
store.write(null, "[data-composer='toolbar']");
await wait(50);
for (let i = 0; i < 3; i += 1) {
document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await wait(20);
await tab(3);
}
// The editor over an existing task, tabbed through to its date field.
if (scene === "edit-date") {
const task = store.visible[0];
store.zone = "task";
store.cursorTask = task.id;
await wait(20);
store.edit(task.id);
await wait(50);
await tab(3);
}
}
+5
View File
@@ -2,10 +2,15 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Without this a phone-sized window still lays out at 980px, so a
"mobile" screenshot is not one and coarse-pointer rules never apply. -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>beaver-calendar preview</title>
<link rel="stylesheet" href="/app.css" />
<link rel="stylesheet" href="/theme.css" />
<link rel="stylesheet" href="/styles.css" />
<!-- Other plugins' stylesheets (EXTRA_CSS), after ours like a later plugin. -->
<link rel="stylesheet" href="/extra.css" />
<style>
body.theme-dark,
body.theme-light {
+63 -1
View File
@@ -3,12 +3,13 @@ import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http";
import { homedir, tmpdir } from "node:os";
import { extname, join, normalize } from "node:path";
import { basename, extname, join, normalize, resolve } from "node:path";
import { extractFile } from "@electron/asar";
import esbuild from "esbuild";
import sveltePlugin from "esbuild-svelte";
@@ -65,6 +66,26 @@ if (!theme) {
);
}
/**
* Other plugins' stylesheets, loaded after ours the way Obsidian would load a
* plugin enabled later: EXTRA_CSS=path[,path…]. This is how a cascade fight
* with another Tailwind build is reproduced outside Obsidian.
*/
const extraCss = (process.env.EXTRA_CSS ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => resolve(item));
for (const file of extraCss) {
if (!existsSync(file)) console.warn(`preview: EXTRA_CSS not found: ${file}`);
}
/** Real boards instead of the fixtures: BOARDS=<folder of .md boards>. */
const boards = process.env.BOARDS ? resolve(process.env.BOARDS) : null;
if (boards && !existsSync(boards)) {
console.warn(`preview: BOARDS folder not found: ${boards}`);
}
const ctx = await esbuild.context({
entryPoints: [join(root, "preview/entry.ts")],
bundle: true,
@@ -85,9 +106,17 @@ const TYPES = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".md": "text/plain; charset=utf-8",
};
function boardNames() {
if (!boards || !existsSync(boards)) return null;
return readdirSync(boards)
.filter((name) => name.endsWith(".md"))
.sort();
}
createServer((request, response) => {
const url = decodeURIComponent((request.url ?? "/").split("?")[0]);
const external = { "/app.css": appCss, "/theme.css": theme };
@@ -103,6 +132,37 @@ createServer((request, response) => {
return;
}
if (url === "/extra.css") {
const body = extraCss
.filter((file) => existsSync(file))
.map((file) => `/* ${file} */\n${readFileSync(file, "utf8")}`)
.join("\n");
response
.writeHead(200, { "content-type": TYPES[".css"], "cache-control": "no-store" })
.end(body);
return;
}
if (url === "/boards.json") {
const names = boardNames();
const body = names ? { folder: basename(boards), names } : null;
response
.writeHead(200, { "content-type": TYPES[".json"], "cache-control": "no-store" })
.end(JSON.stringify(body));
return;
}
if (url.startsWith("/boards/") && boards) {
const file = join(boards, normalize(url.slice("/boards/".length)));
if (!file.startsWith(boards) || !existsSync(file) || statSync(file).isDirectory()) {
response.writeHead(404).end("not found");
return;
}
response.writeHead(200, { "content-type": TYPES[".md"], "cache-control": "no-store" });
createReadStream(file).pipe(response);
return;
}
const file = join(root, normalize(url === "/" ? "/preview/index.html" : url));
if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) {
response.writeHead(404).end("not found");
@@ -115,4 +175,6 @@ createServer((request, response) => {
createReadStream(file).pipe(response);
}).listen(port, () => {
console.log(`preview: http://localhost:${port}/`);
if (boards) console.log(`preview: boards from ${boards}`);
if (extraCss.length > 0) console.log(`preview: extra css ${extraCss.join(", ")}`);
});
+137
View File
@@ -0,0 +1,137 @@
// node preview/shot.mjs <out.png> <url> [width] [height]
//
// Headless Chrome over the DevTools protocol: a screenshot plus everything the
// page said on the console, and a note if the page stopped answering — which
// a plain --screenshot run would only report as a silent timeout.
// DUMP=1 also print the page text
// PROBE=js evaluate an expression at the end and print its value
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
const [out, url, w = "1100", h = "720"] = process.argv.slice(2);
if (!out || !url) {
console.error("usage: node preview/shot.mjs <out.png> <url> [width] [height]");
process.exit(2);
}
const port = 9334;
const chrome =
process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const proc = spawn(
chrome,
[
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
`--remote-debugging-port=${port}`,
`--window-size=${w},${h}`,
"--user-data-dir=/tmp/bcal-chrome-profile",
"about:blank",
],
{ stdio: "ignore" },
);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let targets = [];
for (let i = 0; i < 40; i += 1) {
try {
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
if (targets.length > 0) break;
} catch {
/* not up yet */
}
await wait(250);
}
const page = targets.find((target) => target.type === "page");
if (!page) {
console.error("shot: chrome did not come up");
proc.kill();
process.exit(1);
}
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve) => {
ws.onopen = resolve;
});
let id = 0;
const pending = new Map();
const logs = [];
ws.onmessage = (message) => {
const data = JSON.parse(String(message.data));
if (data.id && pending.has(data.id)) {
pending.get(data.id)(data);
pending.delete(data.id);
}
if (data.method === "Runtime.consoleAPICalled") {
logs.push(
`${data.params.type}: ${data.params.args
.map((arg) => arg.value ?? arg.description)
.join(" ")}`,
);
}
if (data.method === "Runtime.exceptionThrown") {
const details = data.params.exceptionDetails;
logs.push(`EXC: ${details.exception?.description ?? details.text}`);
}
};
const send = (method, params = {}, timeout = 15_000) =>
new Promise((resolve, reject) => {
const next = ++id;
const timer = setTimeout(() => {
pending.delete(next);
reject(new Error(`${method} timed out after ${timeout} ms`));
}, timeout);
pending.set(next, (value) => {
clearTimeout(timer);
resolve(value);
});
ws.send(JSON.stringify({ id: next, method, params }));
});
await send("Emulation.setDeviceMetricsOverride", {
width: Number(w),
height: Number(h),
deviceScaleFactor: 2,
mobile: false,
});
await send("Page.enable");
await send("Runtime.enable");
await send("Page.navigate", { url });
await wait(Number(process.env.SETTLE ?? 2500));
let alive = true;
try {
const ping = await send("Runtime.evaluate", { expression: "1 + 1", returnByValue: true }, 5000);
alive = ping.result?.result?.value === 2;
} catch {
alive = false;
}
if (!alive) {
console.log("shot: PAGE UNRESPONSIVE (main thread busy — a hang)");
}
if (alive && process.env.PROBE) {
const probe = await send("Runtime.evaluate", {
expression: process.env.PROBE,
returnByValue: true,
});
console.log("PROBE:", JSON.stringify(probe.result?.result?.value ?? probe.result));
}
if (alive && process.env.DUMP) {
const text = await send("Runtime.evaluate", {
expression: "document.body.innerText.slice(0, 1500)",
returnByValue: true,
});
console.log("TEXT:", text.result?.result?.value);
}
try {
const shot = await send("Page.captureScreenshot", { format: "png" }, 10_000);
writeFileSync(out, Buffer.from(shot.result.data, "base64"));
console.log("wrote", out);
} catch (error) {
console.log("shot: no screenshot —", error.message);
}
if (logs.length > 0) console.log("LOGS:\n" + logs.join("\n"));
ws.close();
proc.kill();
process.exit(alive ? 0 : 3);
+48
View File
@@ -0,0 +1,48 @@
// node scripts/css.mjs [--minify] [--watch]
//
// Tailwind writes to a scratch file; the scoped, optionally minified result
// is what lands in styles.css. See scope-css.mjs for why the extra step.
import { spawn, spawnSync } from "node:child_process";
import { readFileSync, watch, writeFileSync } from "node:fs";
import { join } from "node:path";
import { scopeCss } from "./scope-css.mjs";
const root = new URL("..", import.meta.url).pathname;
const args = new Set(process.argv.slice(2));
const minify = args.has("--minify");
const watching = args.has("--watch");
const input = join(root, "src/tailwind.css");
const scratch = join(root, ".tailwind.css");
const output = join(root, "styles.css");
const cli = join(root, "node_modules/.bin/tailwindcss");
function finish() {
const built = readFileSync(scratch, "utf8");
writeFileSync(output, scopeCss(built, { minify }));
console.log(`css: wrote styles.css (${minify ? "minified, " : ""}scoped)`);
}
if (!watching) {
const run = spawnSync(cli, ["-i", input, "-o", scratch], { stdio: "inherit" });
if (run.status !== 0) process.exit(run.status ?? 1);
finish();
} else {
writeFileSync(scratch, "");
const child = spawn(cli, ["-i", input, "-o", scratch, "--watch"], {
stdio: "inherit",
});
let timer = null;
watch(scratch, () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
try {
finish();
} catch (error) {
console.error("css:", error);
}
}, 60);
});
child.on("exit", (code) => process.exit(code ?? 0));
}
+91
View File
@@ -0,0 +1,91 @@
import { transform } from "lightningcss";
/**
* Obsidian loads every plugin's stylesheet into one document, and Tailwind
* puts its utilities into a cascade layer called `utilities` in each of them.
* Layers with the same name merge, so another Tailwind plugin's
* `.hidden{display:none!important}` and this plugin's
* `@container … .\@min-\[30rem\]\:inline-block{…!important}` end up in the
* same layer with the same specificity, and whichever plugin loaded last
* wins. Prefixing every selector with `:is(.bcal-root, .bcal-root *)` adds a
* class of specificity that no unscoped utility can match, whatever the load
* order, while still reaching the view root itself and the portal layer,
* both of which carry `.bcal-root`.
*
* Tailwind's own theme variables stay on `:root, :host`: moved onto the view
* root they would sit above Obsidian's `body` values, and `--color-accent`
* is a name both sides use — the accent would turn into the hover grey.
*/
const ROOT = "bcal-root";
const SCOPE = {
type: "pseudo-class",
kind: "is",
selectors: [
[{ type: "class", name: ROOT }],
[
{ type: "class", name: ROOT },
{ type: "combinator", value: "descendant" },
{ type: "universal" },
],
],
};
function isPseudo(selector, kind) {
return (
selector.length === 1 &&
selector[0].type === "pseudo-class" &&
selector[0].kind === kind
);
}
function mentions(selector, name) {
for (const part of selector) {
if (part.type === "class" && part.name === name) return true;
if (part.selectors?.some((inner) => mentions(inner, name))) return true;
}
return false;
}
function scopeSelector(selector) {
if (isPseudo(selector, "root") || isPseudo(selector, "host")) {
return selector;
}
if (mentions(selector, ROOT)) {
return selector;
}
// The scope goes on the subject — the last compound — not the first:
// `.theme-dark .hue-chip` must keep matching with `.theme-dark` on body,
// outside the root.
let at = 0;
for (let i = 0; i < selector.length; i += 1) {
if (selector[i].type === "combinator") {
at = i + 1;
}
}
const head = selector.slice(0, at);
const subject = selector.slice(at);
// `*` and type selectors must open a compound; :is() goes in their place
// or right after them.
if (subject[0]?.type === "universal") {
return [...head, SCOPE, ...subject.slice(1)];
}
if (subject[0]?.type === "type") {
return [...head, subject[0], SCOPE, ...subject.slice(1)];
}
return [...head, SCOPE, ...subject];
}
/** Rewrites a built stylesheet so its rules only reach the plugin's own DOM. */
export function scopeCss(code, { minify = false } = {}) {
// The selector visitor, not the rule one: handing a whole rule back makes
// lightningcss re-read its declarations, and some of Tailwind's it cannot.
const result = transform({
filename: "styles.css",
code: Buffer.from(code),
minify,
visitor: { Selector: scopeSelector },
});
return result.code.toString();
}
+8 -4
View File
@@ -29,10 +29,14 @@ export function shiftDays(key: string, days: number): string {
return localKey(date);
}
/** Milliseconds until the next local midnight, with a little slack past it. */
export function untilMidnight(now = new Date()): number {
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
return Math.max(1000, next.getTime() - now.getTime() + 500);
export function monthGrid(month: Date): Date[] {
const year = month.getFullYear();
const index = month.getMonth();
const offset = (new Date(year, index, 1).getDay() + 6) % 7;
return Array.from(
{ length: 42 },
(_, cell) => new Date(year, index, 1 - offset + cell),
);
}
export function daysFromToday(key: string, from = today()): number {
+54
View File
@@ -0,0 +1,54 @@
/**
* How much of the view's bottom is covered by something the operator cannot
* move: Obsidian's desktop status bar, a phone's navbar (which paints over a
* leaf instead of shortening it), and the software keyboard, which shrinks the
* visual viewport while the layout keeps its full height.
*
* Pure on purpose - the view feeds it live rectangles, the tests feed it the
* numbers a phone actually reports.
*/
export interface Rect {
bottom: number;
height: number;
left: number;
right: number;
top: number;
}
export interface Visible {
/** `visualViewport.height`: what is left once the keyboard is up. */
height: number;
/** `visualViewport.offsetTop`: where the visible area starts. */
offsetTop: number;
}
/** Bars that float over a leaf rather than making room for it. */
export const BOTTOM_BARS = [
".status-bar",
".mobile-navbar",
".mobile-toolbar",
] as const;
export function insetFor(
leaf: Rect,
bars: Rect[],
visible: Visible | null,
): number {
if (leaf.height <= 0) return 0;
let overlap = 0;
for (const bar of bars) {
// A bar that does not sit over this leaf horizontally covers nothing of
// it - the desktop status bar beside a left-hand sidedock, say.
if (bar.height > 0 && bar.left < leaf.right && bar.right > leaf.left) {
overlap = Math.max(overlap, leaf.bottom - bar.top);
}
}
if (visible) {
// While the keyboard is up the navbar is behind it: the deeper of the two
// is the inset, and summing them would push the view twice as far.
overlap = Math.max(overlap, leaf.bottom - (visible.offsetTop + visible.height));
}
return Math.max(0, Math.round(overlap));
}
+64 -8
View File
@@ -13,6 +13,53 @@ 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. The navbar is not in
* the viewport at all - it floats over it - so it is subtracted by hand.
*/
export function viewport(win: Window = window): Viewport {
const vv = win.visualViewport;
const base: Viewport = vv
? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft }
: { width: win.innerWidth, height: win.innerHeight };
return { ...base, height: base.height - floating(win, base) };
}
/**
* How much of the bottom of `view` a floating bar covers. Obsidian's phone
* navbar paints over every leaf, so a panel that ends under it cannot be
* reached even though it is inside the viewport.
*/
function floating(win: Window, view: Viewport): number {
const doc = win.document;
const bottom = (view.top ?? 0) + view.height;
let covered = 0;
for (const selector of [".mobile-navbar", ".mobile-toolbar", ".status-bar"]) {
const node = doc.querySelector(selector) as HTMLElement | null;
if (!node || node.offsetParent === null) continue;
const box = node.getBoundingClientRect();
if (box.height > 0 && box.top < bottom) {
covered = Math.max(covered, bottom - box.top);
}
}
return Math.min(covered, Math.max(0, view.height - 120));
}
/** 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 +78,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
+5
View File
@@ -102,7 +102,12 @@
}
.bcal-root {
box-sizing: border-box;
height: 100%;
/* The phone's navbar floats over the leaf rather than shortening it, so the
last row of the list would sit underneath it. The view measures how much
and sets this; on a desktop it stays 0. */
padding-bottom: var(--bcal-inset-bottom, 0px);
font-family: var(--font-interface);
font-size: var(--text-sm);
color: var(--foreground);
+96 -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,31 @@
const view = useView();
// The keyboard animates both ways: rather than guess how long that takes,
// the panel re-places every frame until its position holds still. Long enough
// to outlast Obsidian hiding its navbar, which moves the ground a second time.
const STEADY_FRAMES = 12;
/** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */
const KEYBOARD_EVENTS = [
"keyboardWillShow",
"keyboardDidShow",
"keyboardWillHide",
"keyboardDidHide",
];
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 +60,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({
@@ -62,19 +84,84 @@
if (!node.contains(event.target as Node)) onclose();
};
const reflow = (): void => measure();
/**
* Re-place every frame until the position has held still for a while. The
* keyboard animates, and Obsidian hides its navbar *after* it is up, which
* moves the ground under the panel a second time - a short "steady" run
* exits between the two and leaves the panel where neither wanted it. In a
* sidedock no resize event fires at all, so a frame loop is the only thing
* that notices either move.
*/
let frame: number | null = null;
const settle = (): void => {
let last = Number.NaN;
let steady = 0;
if (frame !== null) win.cancelAnimationFrame(frame);
const tick = (): void => {
measure();
steady = at.top === last ? steady + 1 : 0;
last = at.top;
if (steady >= STEADY_FRAMES) {
frame = null;
return;
}
frame = win.requestAnimationFrame(tick);
};
frame = win.requestAnimationFrame(tick);
};
document.addEventListener("pointerdown", outside, true);
window.addEventListener("resize", reflow);
// The keyboard changes the visual viewport without resizing the window.
const vv = win.visualViewport;
vv?.addEventListener("resize", settle);
vv?.addEventListener("scroll", settle);
// Obsidian's own keyboard events (Capacitor, mobile only): they fire before
// the animation and again as the navbar comes back, which `visualViewport`
// on its own does not always report.
for (const name of KEYBOARD_EVENTS) {
win.addEventListener(name, settle);
} // The panel's own input takes focus a beat before the keyboard is up, and
// swapping fields re-opens it.
node.addEventListener("focusin", settle);
settle();
// 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", settle);
vv?.removeEventListener("scroll", settle);
for (const name of KEYBOARD_EVENTS) {
win.removeEventListener(name, settle);
}
node.removeEventListener("focusin", settle);
if (frame !== null) win.cancelAnimationFrame(frame);
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 +178,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
>
+5 -13
View File
@@ -5,7 +5,7 @@
<script lang="ts">
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { fromKey, localKey } from "../lib/due";
import { fromKey, localKey, monthGrid } from "../lib/due";
import { WEEKDAYS } from "../lib/vocab";
import { cn } from "../lib/utils";
@@ -20,7 +20,6 @@
let { anchor, tone, onpick, onhover, today }: Props = $props();
const DAY = 86_400_000;
const monthName = new Intl.DateTimeFormat("en-US", { month: "long" });
const todayKey = $derived(today ?? localKey(new Date()));
@@ -32,20 +31,13 @@
return new Date(base.getFullYear(), base.getMonth(), 1);
});
const days = $derived.by(() => {
const offset = (month.getDay() + 6) % 7;
const start = new Date(month.getTime() - offset * DAY);
start.setHours(0, 0, 0, 0);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start.getTime() + index * DAY);
date.setHours(0, 0, 0, 0);
return {
const days = $derived(
monthGrid(month).map((date) => ({
date,
inMonth: date.getMonth() === month.getMonth(),
key: localKey(date),
};
});
});
})),
);
function shiftMonth(delta: number) {
picked = new Date(month.getFullYear(), month.getMonth() + delta, 1);
+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>
+121 -28
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;
}
while (insertAt > archiveAt + 1 && lines[insertAt - 1].trim() === "") {
insertAt -= 1;
}
lines.splice(insertAt, 0, ...blocks.flat());
return blocks.length;
return { cards, legacy };
}
/** 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();
}
}
+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 };
}
+85 -46
View File
@@ -1,9 +1,10 @@
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";
import { daysFromToday, fromKey, localKey, shiftDays, untilMidnight } from "../lib/due";
import { daysFromToday, fromKey, localKey, monthGrid, shiftDays } from "../lib/due";
import { laneKey, parseLaneKey } from "../lib/keys";
import type {
BeaverCalendarSettings,
@@ -12,7 +13,6 @@ import type {
ViewMode,
} from "../settings";
const DAY = 86_400_000;
export interface Group {
key: string;
@@ -91,6 +91,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>();
@@ -193,48 +196,64 @@ export class BoardStore {
this.settings = settings;
}
/** Re-reads the wall clock; a no-op while the day has not turned. */
/**
* Re-reads the wall clock; a no-op while the day has not turned. A cursor
* left on the old today, and a month showing it, follow the day over.
*/
tick(): void {
const now = localKey(new Date());
if (now !== this.today) this.today = now;
const was = this.today;
if (now === was) return;
this.today = now;
if (this.cursorDay === was && this.cursorTask === null) this.cursorDay = now;
if (this.monthKey.slice(0, 7) === was.slice(0, 7)) this.monthKey = now;
}
/**
* Keeps `today` honest for as long as the view lives: a timer for the
* midnight crossing, and a re-read when the window wakes up, since a
* sleeping laptop fires no timers.
* Keeps `today` honest for as long as the view lives. The clock is polled
* rather than timed to midnight: Chromium's timers stop while a Mac sleeps,
* so a midnight timeout fires hours late, and a window that stayed focused
* through the sleep sends no focus event on waking. Waking still re-reads
* at once where the events do come.
*/
watchClock(): () => void {
let timer = 0;
const arm = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
this.tick();
arm();
}, untilMidnight());
};
const wake = (): void => {
this.tick();
arm();
};
arm();
window.addEventListener("focus", wake);
document.addEventListener("visibilitychange", wake);
const tick = (): void => this.tick();
const timer = window.setInterval(tick, 30_000);
window.addEventListener("focus", tick);
document.addEventListener("visibilitychange", tick);
return () => {
window.clearTimeout(timer);
window.removeEventListener("focus", wake);
document.removeEventListener("visibilitychange", wake);
window.clearInterval(timer);
window.removeEventListener("focus", tick);
document.removeEventListener("visibilitychange", tick);
};
}
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 +263,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 +290,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 +315,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 +363,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 +387,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 +405,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 +423,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(() => {
@@ -407,13 +448,9 @@ export class BoardStore {
monthDays = $derived.by<DaySlot[]>(() => {
const anchor = fromKey(this.monthKey);
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const offset = (first.getDay() + 6) % 7;
const start = new Date(first.getTime() - offset * DAY);
const todayKey = this.today;
const byDay = this.byDay;
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start.getTime() + index * DAY);
date.setHours(0, 0, 0, 0);
return monthGrid(first).map((date) => {
const key = localKey(date);
return {
date,
@@ -463,7 +500,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 +751,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));
}
+93
View File
@@ -1,13 +1,29 @@
import { ItemView } from "obsidian";
import type { WorkspaceLeaf } from "obsidian";
import { mount, unmount } from "svelte";
import { BOTTOM_BARS, insetFor } from "./lib/inset";
import type { Rect } from "./lib/inset";
import App from "./ui/app.svelte";
import type BeaverCalendarPlugin from "./main";
export const VIEW_TYPE_BOARDS = "beaver-calendar-boards";
// The keyboard and the navbar both animate, and Obsidian hides the navbar
// *after* the keyboard is up: the inset is re-measured every frame until the
// reading has held still long enough to outlast both moves.
const STEADY_FRAMES = 12;
/** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */
const KEYBOARD_EVENTS = [
"keyboardWillShow",
"keyboardDidShow",
"keyboardWillHide",
"keyboardDidHide",
] as const;
export class BoardsView extends ItemView {
private root: Record<string, unknown> | null = null;
private settling: number | null = null;
constructor(
leaf: WorkspaceLeaf,
@@ -51,6 +67,30 @@ export class BoardsView extends ItemView {
}),
);
this.registerEvent(this.app.workspace.on("resize", () => this.settle()));
this.registerEvent(
this.app.workspace.on("layout-change", () => this.settle()),
);
// The keyboard shrinks the visual viewport without resizing the layout, so
// no workspace event fires - and in a sidedock none fires at all.
const vv = window.visualViewport;
if (vv) {
const onViewport = () => this.settle();
vv.addEventListener("resize", onViewport);
vv.addEventListener("scroll", onViewport);
this.register(() => {
vv.removeEventListener("resize", onViewport);
vv.removeEventListener("scroll", onViewport);
});
}
this.registerDomEvent(this.contentEl, "focusin", () => this.settle());
this.registerDomEvent(this.contentEl, "focusout", () => this.settle());
for (const name of KEYBOARD_EVENTS) {
const onKeyboard = () => this.settle();
window.addEventListener(name, onKeyboard);
this.register(() => window.removeEventListener(name, onKeyboard));
}
await this.plugin.store.reloadAll();
this.root = mount(App, {
target: this.contentEl,
@@ -61,6 +101,59 @@ export class BoardsView extends ItemView {
component: this,
},
});
this.settle();
}
/**
* Re-measure until the viewport stops moving: the keyboard animates both
* ways, Obsidian hides its navbar while it is up, and a frame loop needs
* neither a duration to guess nor an event that a sidedock never sends.
*/
private settle(): void {
let last = Number.NaN;
let steady = 0;
// A fresh trigger restarts the watch rather than being dropped: the navbar
// coming back is a second move, after the first one has settled.
if (this.settling !== null) cancelAnimationFrame(this.settling);
const tick = () => {
const inset = this.inset();
steady = inset === last ? steady + 1 : 0;
last = inset;
if (steady >= STEADY_FRAMES) {
this.settling = null;
return;
}
this.settling = requestAnimationFrame(tick);
};
this.settling = requestAnimationFrame(tick);
this.register(() => {
if (this.settling !== null) {
cancelAnimationFrame(this.settling);
this.settling = null;
}
});
}
/** Pads the view clear of the navbar and the keyboard. */
private inset(): number {
const box = this.contentEl.getBoundingClientRect();
if (box.height <= 0) return Number.NaN;
const bars: Rect[] = [];
for (const selector of BOTTOM_BARS) {
const node = document.querySelector<HTMLElement>(selector);
if (node && node.offsetParent !== null) {
bars.push(node.getBoundingClientRect());
}
}
const vv = window.visualViewport;
const inset = insetFor(
box,
bars,
vv ? { height: vv.height, offsetTop: vv.offsetTop } : null,
);
this.contentEl.style.setProperty("--bcal-inset-bottom", `${inset}px`);
return inset;
}
async onClose(): Promise<void> {
+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);
});
});
+28 -8
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
daysFromToday,
dueLabel,
dueTone,
localKey,
monthGrid,
nextFriday,
untilMidnight,
} from "../src/lib/due";
describe("due helpers read the day they are given", () => {
@@ -26,12 +27,31 @@ describe("due helpers read the day they are given", () => {
expect(nextFriday("2026-08-23")).toBe("2026-08-28");
expect(nextFriday("2026-08-28")).toBe("2026-09-04");
});
});
it("arms the midnight timer for the coming midnight", () => {
const late = new Date(2026, 7, 23, 23, 59, 0);
expect(untilMidnight(late)).toBeGreaterThan(60_000);
expect(untilMidnight(late)).toBeLessThan(62_000);
const early = new Date(2026, 7, 23, 0, 0, 1);
expect(untilMidnight(early)).toBeGreaterThan(23 * 3_600_000);
describe("monthGrid steps by calendar day", () => {
const zone = process.env.TZ;
beforeAll(() => {
process.env.TZ = "Europe/Berlin";
});
afterAll(() => {
process.env.TZ = zone;
});
it("keeps every day once across the autumn clock change", () => {
const keys = monthGrid(new Date(2026, 9, 1)).map(localKey);
expect(keys).toHaveLength(42);
expect(new Set(keys).size).toBe(42);
expect(keys[0]).toBe("2026-09-28");
expect(keys[27]).toBe("2026-10-25");
expect(keys[28]).toBe("2026-10-26");
expect(keys[41]).toBe("2026-11-08");
});
it("keeps every day once across the spring clock change", () => {
const keys = monthGrid(new Date(2027, 2, 1)).map(localKey);
expect(new Set(keys).size).toBe(42);
expect(keys).toContain("2027-03-28");
expect(keys).toContain("2027-03-29");
});
});
+150 -59
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" }),
);
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, "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" }),
);
}
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" }),
);
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { BOTTOM_BARS, insetFor } from "../src/lib/inset";
import type { Rect } from "../src/lib/inset";
const rect = (over: Partial<Rect> = {}): Rect => ({
top: 0,
left: 0,
right: 390,
bottom: 780,
height: 780,
...over,
});
// A phone: 780 tall, a 56px navbar floating over the bottom, 340px of keyboard.
const NAVBAR = rect({ top: 724, bottom: 780, height: 56 });
describe("insetFor", () => {
it("clears nothing when the view stands alone", () => {
expect(insetFor(rect(), [], null)).toBe(0);
});
it("clears the navbar that paints over the view", () => {
expect(insetFor(rect(), [NAVBAR], null)).toBe(56);
});
it("clears the keyboard, which shortens no layout", () => {
expect(insetFor(rect(), [], { height: 440, offsetTop: 0 })).toBe(340);
});
it("takes the deeper of the two rather than their sum", () => {
// The navbar hides behind the keyboard while it is up.
expect(insetFor(rect(), [NAVBAR], { height: 440, offsetTop: 0 })).toBe(340);
});
it("comes back to the navbar once the keyboard goes", () => {
expect(insetFor(rect(), [NAVBAR], { height: 780, offsetTop: 0 })).toBe(56);
});
it("ignores a bar that sits beside the view, not over it", () => {
const sidedock = rect({ right: 300 });
const statusBar = rect({
top: 760,
bottom: 780,
height: 20,
left: 700,
right: 980,
});
expect(insetFor(sidedock, [statusBar], null)).toBe(0);
});
it("measures from where the visible area starts", () => {
expect(insetFor(rect(), [], { height: 440, offsetTop: 100 })).toBe(240);
});
it("says nothing about a view with no height yet", () => {
expect(insetFor(rect({ height: 0, bottom: 0 }), [NAVBAR], null)).toBe(0);
});
it("never returns a negative inset", () => {
const short = rect({ bottom: 300, height: 300 });
expect(insetFor(short, [], { height: 780, offsetTop: 0 })).toBe(0);
});
it("names the bars that float rather than make room", () => {
expect(BOTTOM_BARS).toContain(".mobile-navbar");
expect(BOTTOM_BARS).toContain(".status-bar");
});
});
+76 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { onScreen, place } from "../src/lib/place";
import { onScreen, place, roomFor, viewport } from "../src/lib/place";
import type { Box } from "../src/lib/place";
const VIEW = { width: 1280, height: 800 };
@@ -58,3 +58,78 @@ 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);
});
});
// Obsidian's phone navbar is not part of the visual viewport - it floats over
// it - so a panel sized to the viewport still ends up underneath it.
describe("viewport minus the floating navbar", () => {
function fakeWin(navbarTop: number | null, height = 780): Window {
const bar = {
getBoundingClientRect: () => ({
top: navbarTop ?? 0,
bottom: height,
height: height - (navbarTop ?? 0),
left: 0,
right: 390,
}),
offsetParent: {},
};
return {
innerWidth: 390,
innerHeight: height,
visualViewport: { width: 390, height, offsetTop: 0, offsetLeft: 0 },
document: {
querySelector: (selector: string) =>
selector === ".mobile-navbar" && navbarTop !== null ? bar : null,
},
} as unknown as Window;
}
it("takes the navbar off the bottom", () => {
expect(viewport(fakeWin(724)).height).toBe(724);
});
it("leaves the height alone with no navbar in the way", () => {
expect(viewport(fakeWin(null)).height).toBe(780);
});
it("still leaves a usable strip if a bar somehow covers everything", () => {
expect(viewport(fakeWin(0)).height).toBeGreaterThanOrEqual(120);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { scopeCss } from "../scripts/scope-css.mjs";
const one = (css: string) => scopeCss(css).replace(/\s+/gu, " ").trim();
describe("scoping the built stylesheet", () => {
it("prefixes a utility with the view root, keeping the root itself in reach", () => {
expect(one(".flex{display:flex!important}")).toBe(
":is(.bcal-root, .bcal-root *).flex { display: flex !important; }",
);
});
it("outranks the same utility from another plugin's Tailwind", () => {
// Class specificity: theirs is (0,1,0); ours must be (0,2,0).
const out = one(".hidden{display:none!important}");
expect(out.startsWith(":is(.bcal-root, .bcal-root *).hidden")).toBe(true);
});
it("keeps pseudo-elements at the end where they are valid", () => {
expect(one(".x::placeholder{color:red}")).toBe(
":is(.bcal-root, .bcal-root *).x::placeholder { color: red; }",
);
expect(one(".y::first-letter{t:1}")).toContain(".y:first-letter");
});
it("scopes rules nested in container queries and layers", () => {
const out = one(
"@layer utilities{@container (min-width:30rem){.a{display:inline-block!important}}}",
);
// lightningcss rewrites the query into range syntax; Obsidian's Chromium
// has understood that inside @container since container queries landed.
expect(out).toContain("@container (width >= 30rem)");
expect(out).toContain(":is(.bcal-root, .bcal-root *).a");
});
it("drops a leading universal selector instead of producing `:is()*`", () => {
const out = one("*,:before,::backdrop{--tw-a:initial}");
expect(out).toContain(":is(.bcal-root, .bcal-root *),");
expect(out).toContain(":is(.bcal-root, .bcal-root *):before");
expect(out).not.toContain("*)*");
});
it("leaves tailwind's theme variables on :root, under obsidian's body values", () => {
expect(one(":root,:host{--spacing:.25rem}")).toBe(
":root, :host { --spacing: .25rem; }",
);
});
it("scopes the subject, so an ancestor outside the root still matches", () => {
expect(one(".theme-dark .hue-chip{c:1}")).toBe(
".theme-dark :is(.bcal-root, .bcal-root *).hue-chip { c: 1; }",
);
expect(one(".a > button{c:2}")).toBe(
".a > button:is(.bcal-root, .bcal-root *) { c: 2; }",
);
expect(one(".a *{c:3}")).toBe(".a :is(.bcal-root, .bcal-root *) { c: 3; }");
});
it("leaves rules that already name the root alone", () => {
const reset = ".bcal-root.bcal-root button{cursor:pointer}";
expect(one(reset)).toBe(".bcal-root.bcal-root button { cursor: pointer; }");
expect(one(".theme-dark .bcal-root{--note:red}")).toBe(
".theme-dark .bcal-root { --note: red; }",
);
});
it("scopes variant selectors without breaking their inner :where()", () => {
const out = one(".dark\\:bg-x:where(.theme-dark,.theme-dark *){b:1}");
expect(out).toBe(
":is(.bcal-root, .bcal-root *).dark\\:bg-x:where(.theme-dark, .theme-dark *) { b: 1; }",
);
});
});
+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";