feat: init

This commit is contained in:
hh
2026-08-08 16:30:22 +02:00
commit 8a609da778
59 changed files with 10053 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules/
.idea
# Build artifacts. Both are produced by `npm run build` and copied into the
# vault by `make install`.
main.js
styles.css
*.zip
# Obsidian writes plugin settings here when the repo is symlinked into a
# vault as the live plugin directory.
data.json
tests/.entry.ts
preview/bundle.js
+23
View File
@@ -0,0 +1,23 @@
PLUGIN_ID := beaver-calendar
ASSETS := manifest.json main.js styles.css versions.json
.PHONY: install zip _build
_build:
npm install --silent
npm run build
install: _build
@test -n "$(VAULT)" || (echo "set VAULT=<path-to-vault>" >&2; exit 1)
@test -d "$(VAULT)/.obsidian" || (echo "no .obsidian dir at $(VAULT)" >&2; exit 1)
@target="$(VAULT)/.obsidian/plugins/$(PLUGIN_ID)"; \
mkdir -p "$$target"; \
cp $(ASSETS) "$$target/"; \
echo "installed → $$target"
zip: _build
@version=$$(node -p "require('./manifest.json').version"); \
out="$(PLUGIN_ID)-$$version.zip"; \
rm -f "$$out"; \
zip -q -j "$$out" $(ASSETS); \
echo "wrote $$out"
+20
View File
@@ -0,0 +1,20 @@
# beaver-calendar
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
Calendar and task list for Obsidian Kanban+Tasks
Sphere - md-file in Kanban boards directory. Project - h2 inside the file.
## Build and install
```bash
npm install
npm run dev # esbuild watch
npm run build # typecheck + bundle + css
npm test # round-trip, smoke
npm run preview # UI in browser, no Obsidian
make install VAULT=/путь/к/vault # build and copy to vault
make zip # distributable
```
After `make install` turn on plugin in settings.
+57
View File
@@ -0,0 +1,57 @@
import esbuild from "esbuild";
import process from "node:process";
import builtins from "builtin-modules";
import sveltePlugin from "esbuild-svelte";
const prod = process.argv[2] === "production";
const ctx = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
// Svelte components ship their scoped ``<style>`` blocks inside the JS
// bundle (``css: "injected"``) rather than as a separate stylesheet.
// Obsidian only auto-loads a single ``styles.css`` per plugin, and that
// slot is taken by the Tailwind output — injecting keeps us to one CSS
// artifact instead of two that would have to be concatenated.
//
// No preprocessor: Svelte 5 strips type-only TS syntax itself, and we
// stick to that subset (no enums, no decorators, no parameter
// properties) so ``lang="ts"`` components compile as-is.
plugins: [
sveltePlugin({
compilerOptions: { css: "injected", runes: true },
}),
],
format: "cjs",
target: "es2022",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
conditions: ["svelte", "browser"],
mainFields: ["svelte", "browser", "module", "main"],
});
if (prod) {
await ctx.rebuild();
await ctx.dispose();
} else {
await ctx.watch();
}
+9
View File
@@ -0,0 +1,9 @@
{
"id": "beaver-calendar",
"name": "Beaver Calendar",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Calendar and list views over Tasks-plugin todos living in Kanban boards.",
"author": "h",
"isDesktopOnly": false
}
+4758
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "beaver-calendar",
"version": "0.1.0",
"description": "Calendar and list views over Tasks-plugin todos living in Kanban boards.",
"main": "main.js",
"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",
"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",
"test": "vitest run && node tests/smoke.mjs",
"preview": "node preview/serve.mjs"
},
"devDependencies": {
"@electron/asar": "^4.2.1",
"@lucide/svelte": "^1.23.0",
"@tailwindcss/cli": "^4.3.0",
"@testing-library/svelte": "^5.4.2",
"@types/node": "^20.11.0",
"bits-ui": "^2.16.3",
"builtin-modules": "^4.0.0",
"clsx": "^2.1.1",
"esbuild": "^0.25.0",
"esbuild-svelte": "^0.9.3",
"jsdom": "^30.0.1",
"obsidian": "^1.5.7",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+55
View File
@@ -0,0 +1,55 @@
import { Component, TFile } from "obsidian";
import { mount } from "svelte";
import App from "../src/ui/app.svelte";
import { DEFAULT_SETTINGS } from "../src/settings";
import { BoardStore } from "../src/vault/store.svelte";
const BOARDS = ["personal", "work", "reading", "home"];
const FOLDER = "Boards";
async function main(): Promise<void> {
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());
}),
);
const handles = new Map(
[...files.keys()].map((path) => [path, new TFile(path)]),
);
const app = {
vault: {
getMarkdownFiles: () => [...handles.values()],
getAbstractFileByPath: (path: string) => handles.get(path) ?? null,
cachedRead: async (file: TFile) => files.get(file.path) ?? "",
read: async (file: TFile) => files.get(file.path) ?? "",
process: async (file: TFile, fn: (data: string) => string) => {
const next = fn(files.get(file.path) ?? "");
files.set(file.path, next);
store.queueReload(file.path);
return next;
},
on: () => ({}),
},
workspace: { getLeaf: () => ({}), activeEditor: null },
plugins: { plugins: {} },
};
const settings = { ...DEFAULT_SETTINGS, boardsFolder: FOLDER };
const store = new BoardStore(app as never, settings, () => {});
await store.reloadAll();
mount(App, {
target: document.getElementById("app") as HTMLElement,
props: {
app: app as never,
store,
settings,
component: new Component() as never,
},
});
}
void main();
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>beaver-calendar preview</title>
<link rel="stylesheet" href="/app.css" />
<link rel="stylesheet" href="/theme.css" />
<link rel="stylesheet" href="/styles.css" />
<style>
body.theme-dark,
body.theme-light {
--accent-h: 254;
--accent-s: 80%;
--accent-l: 68%;
--font-interface:
-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto,
sans-serif;
}
body {
margin: 0;
height: 100vh;
overflow: hidden;
background: var(--background-primary);
}
#app {
height: 100vh;
}
</style>
</head>
<body class="theme-dark">
<div id="app"></div>
<script src="/preview/bundle.js"></script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
import {
createReadStream,
existsSync,
mkdtempSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http";
import { homedir, tmpdir } from "node:os";
import { extname, join, normalize } from "node:path";
import { extractFile } from "@electron/asar";
import esbuild from "esbuild";
import sveltePlugin from "esbuild-svelte";
const root = new URL("..", import.meta.url).pathname;
const port = Number(process.env.PORT ?? 4173);
const ASAR_CANDIDATES = [
process.env.OBSIDIAN_ASAR,
"/Applications/Obsidian.app/Contents/Resources/obsidian.asar",
join(homedir(), "Applications/Obsidian.app/Contents/Resources/obsidian.asar"),
"/opt/Obsidian/resources/obsidian.asar",
"/usr/lib/obsidian/resources/obsidian.asar",
].filter(Boolean);
function findAppCss() {
for (const candidate of ASAR_CANDIDATES) {
if (!existsSync(candidate)) continue;
try {
const path = join(mkdtempSync(join(tmpdir(), "bcal-app-")), "app.css");
writeFileSync(path, extractFile(candidate, "app.css"));
return path;
} catch {
/* try the next candidate */
}
}
console.warn(
"preview: could not read app.css from an Obsidian install. Set " +
"OBSIDIAN_ASAR to point at obsidian.asar. Without it, button and input " +
"chrome will not match the real app.",
);
return null;
}
function findTheme() {
if (process.env.THEME) return process.env.THEME;
const vault = process.env.VAULT;
if (!vault) return null;
const themes = join(vault, ".obsidian/themes");
if (!existsSync(themes)) return null;
for (const name of readdirSync(themes)) {
const css = join(themes, name, "theme.css");
if (existsSync(css)) return css;
}
return null;
}
const appCss = findAppCss();
const theme = findTheme();
if (!theme) {
console.warn(
"preview: no community theme loaded. Set VAULT to a vault path, or " +
"THEME to a theme.css, to preview against one.",
);
}
const ctx = await esbuild.context({
entryPoints: [join(root, "preview/entry.ts")],
bundle: true,
format: "iife",
target: "es2022",
outfile: join(root, "preview/bundle.js"),
logLevel: "info",
sourcemap: "inline",
conditions: ["svelte", "browser"],
mainFields: ["svelte", "browser", "module", "main"],
alias: { obsidian: join(root, "tests/obsidian-stub.ts") },
plugins: [sveltePlugin({ compilerOptions: { css: "injected", runes: true } })],
});
await ctx.rebuild();
await ctx.watch();
const TYPES = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".md": "text/plain; charset=utf-8",
};
createServer((request, response) => {
const url = decodeURIComponent((request.url ?? "/").split("?")[0]);
const external = { "/app.css": appCss, "/theme.css": theme };
if (url in external) {
const file = external[url];
if (!file) {
response.writeHead(200, { "content-type": TYPES[".css"] }).end("");
return;
}
response.writeHead(200, { "content-type": TYPES[".css"], "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");
return;
}
response.writeHead(200, {
"content-type": TYPES[extname(file)] ?? "application/octet-stream",
"cache-control": "no-store",
});
createReadStream(file).pipe(response);
}).listen(port, () => {
console.log(`preview: http://localhost:${port}/`);
});
+189
View File
@@ -0,0 +1,189 @@
export interface DragPayload {
id: string;
kind: string;
}
export interface DropZone {
target: string;
after: boolean;
}
let dragging = $state<DragPayload | null>(null);
let zone = $state<DropZone | null>(null);
export function isDragging(id: string): boolean {
return dragging?.id === id;
}
export function dragPayload(): DragPayload | null {
return dragging;
}
export function dropZone(): DropZone | null {
return zone;
}
const MOVE_THRESHOLD = 6;
const HOLD_MS = 350;
interface DraggableOptions {
payload: () => DragPayload;
ondrop: (target: string, after: boolean) => void;
enabled?: () => boolean;
}
export function draggable(node: HTMLElement, options: DraggableOptions) {
let current = options;
let startX = 0;
let startY = 0;
let holdTimer: number | null = null;
let active = false;
let armed = false;
let pointerId: number | null = null;
let frame: number | null = null;
let lastX = 0;
let lastY = 0;
const swallowClick = (event: Event): void => {
event.preventDefault();
event.stopPropagation();
};
const clearHold = (): void => {
if (holdTimer !== null) {
window.clearTimeout(holdTimer);
holdTimer = null;
}
};
const paint = (): void => {
frame = null;
node.style.transform = `translate3d(${lastX - startX}px, ${lastY - startY}px, 0)`;
};
const begin = (): void => {
if (active) return;
active = true;
dragging = current.payload();
if (pointerId !== null) node.setPointerCapture(pointerId);
node.style.touchAction = "none";
node.style.willChange = "transform";
node.classList.add("bcal-dragging");
};
const finish = (commit: boolean): void => {
clearHold();
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
if (pointerId !== null && node.hasPointerCapture(pointerId)) {
node.releasePointerCapture(pointerId);
}
const wasActive = active;
if (wasActive) {
node.addEventListener("click", swallowClick, { capture: true, once: true });
node.style.transition = "transform 160ms ease-out";
node.style.transform = "";
node.classList.remove("bcal-dragging");
window.setTimeout(() => {
node.style.transition = "";
node.style.willChange = "";
}, 180);
}
node.style.touchAction = "";
const landed = zone;
active = false;
armed = false;
pointerId = null;
dragging = null;
zone = null;
if (commit && wasActive && landed) {
current.ondrop(landed.target, landed.after);
}
};
const onpointerdown = (event: PointerEvent): void => {
if (event.button !== 0) return;
if (current.enabled && !current.enabled()) return;
if ((event.target as HTMLElement).closest("[data-nodrag], a")) return;
armed = true;
pointerId = event.pointerId;
startX = event.clientX;
startY = event.clientY;
lastX = startX;
lastY = startY;
if (event.pointerType === "touch") {
holdTimer = window.setTimeout(() => {
holdTimer = null;
if (armed) begin();
}, HOLD_MS);
}
};
const onpointermove = (event: PointerEvent): void => {
if (!armed || event.pointerId !== pointerId) return;
if (!active) {
const moved =
Math.abs(event.clientX - startX) > MOVE_THRESHOLD ||
Math.abs(event.clientY - startY) > MOVE_THRESHOLD;
if (!moved) return;
if (event.pointerType === "touch") {
armed = false;
clearHold();
return;
}
begin();
}
event.preventDefault();
lastX = event.clientX;
lastY = event.clientY;
if (frame === null) frame = requestAnimationFrame(paint);
const previous = node.style.pointerEvents;
node.style.pointerEvents = "none";
const under = document.elementFromPoint(event.clientX, event.clientY);
node.style.pointerEvents = previous;
const target = under?.closest<HTMLElement>("[data-drop]");
if (!target) {
zone = null;
return;
}
const box = target.getBoundingClientRect();
zone = {
target: target.dataset.drop as string,
after: event.clientY > box.top + box.height / 2,
};
};
const onpointerup = (event: PointerEvent): void => {
if (event.pointerId !== pointerId) return;
finish(true);
};
const onpointercancel = (event: PointerEvent): void => {
if (event.pointerId !== pointerId) return;
finish(false);
};
node.addEventListener("pointerdown", onpointerdown);
node.addEventListener("pointermove", onpointermove);
node.addEventListener("pointerup", onpointerup);
node.addEventListener("pointercancel", onpointercancel);
return {
update(next: DraggableOptions) {
current = next;
},
destroy() {
clearHold();
if (frame !== null) cancelAnimationFrame(frame);
node.removeEventListener("pointerdown", onpointerdown);
node.removeEventListener("pointermove", onpointermove);
node.removeEventListener("pointerup", onpointerup);
node.removeEventListener("pointercancel", onpointercancel);
},
};
}
+57
View File
@@ -0,0 +1,57 @@
const monthDay = new Intl.DateTimeFormat("en-US", {
day: "numeric",
month: "short",
});
export function localKey(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
export function today(): string {
return localKey(new Date());
}
export function fromKey(key: string): Date {
const [year, month, day] = key.split("-").map(Number);
return new Date(year, month - 1, day);
}
export function shiftDays(key: string, days: number): string {
const date = fromKey(key);
date.setDate(date.getDate() + days);
return localKey(date);
}
export function daysFromToday(key: string): number {
const target = fromKey(key);
const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.round((target.getTime() - now.getTime()) / 86_400_000);
}
export function dueLabel(key: string | null): string {
if (!key) return "";
const diff = daysFromToday(key);
if (diff === 0) return "today";
if (diff === 1) return "tomorrow";
if (diff === -1) return "yesterday";
return monthDay.format(fromKey(key));
}
export type DueTone = "overdue" | "soon" | "normal" | "none";
export function dueTone(key: string | null): DueTone {
if (!key) return "none";
const diff = daysFromToday(key);
if (diff < 0) return "overdue";
if (diff <= 1) return "soon";
return "normal";
}
export function nextFriday(): string {
const date = new Date();
date.setDate(date.getDate() + ((5 - date.getDay() + 7) % 7 || 7));
return localKey(date);
}
+9
View File
@@ -0,0 +1,9 @@
const LABEL_HUES = [200, 155, 75, 300, 25, 260];
export function labelHue(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i += 1) {
hash = (hash * 31 + name.charCodeAt(i)) % 997;
}
return LABEL_HUES[hash % LABEL_HUES.length];
}
+12
View File
@@ -0,0 +1,12 @@
export function laneKey(path: string, project: string): string {
return `${encodeURIComponent(path)} ${encodeURIComponent(project)}`;
}
export function parseLaneKey(key: string): { path: string; project: string } | null {
const at = key.indexOf(" ");
if (at === -1) return null;
return {
path: decodeURIComponent(key.slice(0, at)),
project: decodeURIComponent(key.slice(at + 1)),
};
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+36
View File
@@ -0,0 +1,36 @@
import Circle from "@lucide/svelte/icons/circle";
import CircleCheck from "@lucide/svelte/icons/circle-check";
import CircleDot from "@lucide/svelte/icons/circle-dot";
import CircleSlash from "@lucide/svelte/icons/circle-slash";
import type { Component } from "svelte";
import type { Priority, Status } from "../model/types";
export interface StatusMeta {
icon: Component;
color: string;
label: string;
}
export const STATUS_META: Record<Status, StatusMeta> = {
" ": { icon: Circle, color: "var(--muted-foreground)", label: "Todo" },
"/": { icon: CircleDot, color: "var(--status-reply)", label: "In progress" },
x: { icon: CircleCheck, color: "var(--status-done)", label: "Done" },
"-": { icon: CircleSlash, color: "var(--status-skip)", label: "Cancelled" },
};
export const PRIORITY_LABEL: Record<Priority, string> = {
0: "Lowest",
1: "Low",
2: "No priority",
3: "Medium",
4: "High",
5: "Highest",
};
export const PRIORITY_ORDER: Priority[] = [5, 4, 3, 2, 1, 0];
export function isHot(priority: Priority): boolean {
return priority === 5;
}
export const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+120
View File
@@ -0,0 +1,120 @@
import { Notice, Plugin, TFile } from "obsidian";
import {
BeaverCalendarSettingsTab,
DEFAULT_SETTINGS,
} from "./settings";
import type { BeaverCalendarSettings } from "./settings";
import { BoardStore } from "./vault/store.svelte";
import { archiveDone } from "./vault/mutate";
import { BoardsView, VIEW_TYPE_BOARDS } from "./view";
export default class BeaverCalendarPlugin extends Plugin {
settings: BeaverCalendarSettings = { ...DEFAULT_SETTINGS };
store!: BoardStore;
async onload(): Promise<void> {
await this.loadSettings();
this.store = new BoardStore(this.app, this.settings, () => {
void this.saveSettings();
});
this.addSettingTab(new BeaverCalendarSettingsTab(this.app, this));
this.registerView(
VIEW_TYPE_BOARDS,
(leaf) => new BoardsView(leaf, this),
);
this.addRibbonIcon("calendar-check", "Tasks", () => {
void this.activateView();
});
this.addCommand({
id: "open-boards",
name: "Open tasks",
callback: () => void this.activateView(),
});
this.addCommand({
id: "archive-done",
name: "Archive completed tasks",
callback: () => void this.archiveAll(),
});
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (file instanceof TFile) this.store.queueReload(file.path);
}),
);
this.registerEvent(
this.app.vault.on("create", (file) => {
if (file instanceof TFile) this.store.queueReload(file.path);
}),
);
this.registerEvent(
this.app.vault.on("delete", (file) => {
this.store.drop(file.path);
}),
);
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) => {
this.store.drop(oldPath);
if (file instanceof TFile) this.store.queueReload(file.path);
}),
);
}
onunload(): void {
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
this.store?.updateSettings(this.settings);
}
private async activateView(): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_BOARDS);
if (existing.length > 0) {
await this.app.workspace.revealLeaf(existing[0]);
return;
}
const leaf = this.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: VIEW_TYPE_BOARDS, active: true });
await this.app.workspace.revealLeaf(leaf);
}
private async archiveAll(): Promise<void> {
const folder = this.settings.boardsFolder;
if (!folder) {
new Notice(
"Beaver Calendar: no boards folder is set, archiving cancelled",
6000,
);
return;
}
const files = this.app.vault
.getMarkdownFiles()
.filter((file) => file.path.startsWith(`${folder}/`));
if (files.length === 0) {
new Notice("Beaver Calendar: no boards found");
return;
}
let moved = 0;
for (const file of files) {
moved += await archiveDone(
this.app,
file.path,
this.settings.archiveHeading,
);
}
new Notice(
moved > 0
? `Beaver Calendar: archived ${moved} task(s)`
: "Beaver Calendar: nothing to archive",
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import { parseTaskLine, serializeTaskLine } from "./serialize";
import type { Board, Lane, Task } from "./types";
const HEADING = /^##\s+(.*?)\s*$/u;
const ARCHIVE_SEPARATOR = /^\*\*\*\s*$/u;
const SETTINGS_OPEN = /^%%\s*kanban:settings\s*$/u;
export function sphereOf(path: string): string {
const base = path.slice(path.lastIndexOf("/") + 1);
return base.endsWith(".md") ? base.slice(0, -3) : base;
}
function isCardBody(line: string): boolean {
return /^\t/u.test(line);
}
export function parseBoard(path: string, content: string): Board {
const lines = content.split("\n");
const sphere = sphereOf(path);
const lanes: Lane[] = [];
const tasks: Task[] = [];
let index = 0;
if (lines[0] === "---") {
const close = lines.indexOf("---", 1);
index = close === -1 ? lines.length : close + 1;
}
const bodyStart = index;
let settingsStart = lines.length;
for (let i = bodyStart; i < lines.length; i += 1) {
if (SETTINGS_OPEN.test(lines[i])) {
settingsStart = i;
break;
}
}
let inArchive = false;
let current: Lane | null = null;
const closeLane = (end: number): void => {
if (!current) return;
current.end = end;
lanes.push(current);
current = null;
};
for (let i = bodyStart; i < settingsStart; i += 1) {
const line = lines[i];
if (ARCHIVE_SEPARATOR.test(line)) {
closeLane(i);
inArchive = true;
continue;
}
const heading = HEADING.exec(line);
if (heading) {
closeLane(i);
current = {
name: heading[1],
inArchive,
headingLine: i,
start: i + 1,
end: i + 1,
};
continue;
}
if (!current) continue;
const parsed = parseTaskLine(line);
if (!parsed) continue;
const body: string[] = [];
let cursor = i + 1;
while (cursor < settingsStart && isCardBody(lines[cursor])) {
body.push(lines[cursor]);
cursor += 1;
}
tasks.push({
...parsed,
id: `${path}#${i}`,
path,
sphere,
project: current.name,
inArchive: current.inArchive,
line: i,
raw: line,
body,
});
i = cursor - 1;
}
closeLane(settingsStart);
return { path, sphere, lines, lanes, tasks };
}
export function renderBoard(board: Board): string {
return board.lines.join("\n");
}
export function taskLine(task: Task): string {
return serializeTaskLine(task);
}
export function findLane(
board: Board,
project: string,
inArchive = false,
): Lane | undefined {
return board.lanes.find(
(lane) => lane.name === project && lane.inArchive === inArchive,
);
}
export function laneInsertPoint(board: Board, lane: Lane): number {
let end = lane.end;
while (end > lane.start && board.lines[end - 1].trim() === "") {
end -= 1;
}
return end;
}
+232
View File
@@ -0,0 +1,232 @@
import { NORMAL_PRIORITY } from "./types";
import type { Priority, Status, Task } from "./types";
const DATE = String.raw`(\d{4}-\d{2}-\d{2})`;
const DUE = "(?:\\u{1F4C5}|\\u{1F4C6}|\\u{1F5D3}\\u{FE0F}?)";
const SCHEDULED = "(?:\\u{23F3}|\\u{231B})";
const START = "\\u{1F6EB}";
const CREATED = "\\u{2795}";
const DONE = "\\u{2705}";
const CANCELLED = "\\u{274C}";
const RECURRENCE = "\\u{1F501}";
const TASK_ID = "\\u{1F194}";
const DEPENDS_ON = "\\u{26D4}";
const ON_COMPLETION = "\\u{1F3C1}";
const PRIORITY_EMOJI: Record<Exclude<Priority, 2>, string> = {
0: "\u{23EC}",
1: "\u{1F53D}",
3: "\u{1F53C}",
4: "\u{23EB}",
5: "\u{1F53A}",
};
const PRIORITY_BY_EMOJI = new Map<string, Priority>(
Object.entries(PRIORITY_EMOJI).map(([value, emoji]) => [
emoji,
Number(value) as Priority,
]),
);
function tail(pattern: string): RegExp {
return new RegExp(`[ \\t]*${pattern}[ \\t]*$`, "u");
}
interface Tail {
priority: Priority;
recurrence: string | null;
created: string | null;
start: string | null;
scheduled: string | null;
due: string | null;
cancelled: string | null;
done: string | null;
dependsOn: string[];
taskId: string | null;
onCompletion: string | null;
blockLink: string | null;
}
interface Matcher {
re: RegExp;
apply: (into: Tail, capture: string) => void;
}
const MATCHERS: Matcher[] = [
{
re: tail(`(\\^[a-zA-Z0-9-]+)`),
apply: (into, value) => {
into.blockLink = value;
},
},
{
re: tail(`${DONE}[ \\t]*${DATE}`),
apply: (into, value) => {
into.done = value;
},
},
{
re: tail(`${CANCELLED}[ \\t]*${DATE}`),
apply: (into, value) => {
into.cancelled = value;
},
},
{
re: tail(`${DUE}[ \\t]*${DATE}`),
apply: (into, value) => {
into.due = value;
},
},
{
re: tail(`${SCHEDULED}[ \\t]*${DATE}`),
apply: (into, value) => {
into.scheduled = value;
},
},
{
re: tail(`${START}[ \\t]*${DATE}`),
apply: (into, value) => {
into.start = value;
},
},
{
re: tail(`${CREATED}[ \\t]*${DATE}`),
apply: (into, value) => {
into.created = value;
},
},
{
re: tail(`${TASK_ID}[ \\t]*([a-zA-Z0-9_-]+)`),
apply: (into, value) => {
into.taskId = value;
},
},
{
re: tail(`${DEPENDS_ON}[ \\t]*([a-zA-Z0-9_,\\- ]+?)`),
apply: (into, value) => {
into.dependsOn = value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
},
},
{
re: tail(`${ON_COMPLETION}[ \\t]*([a-zA-Z]+)`),
apply: (into, value) => {
into.onCompletion = value;
},
},
{
re: tail(`([\\u{23EC}\\u{1F53D}\\u{1F53C}\\u{23EB}\\u{1F53A}])`),
apply: (into, value) => {
into.priority = PRIORITY_BY_EMOJI.get(value) ?? NORMAL_PRIORITY;
},
},
{
re: tail(`${RECURRENCE}[ \\t]*(.+?)`),
apply: (into, value) => {
into.recurrence = value.trim();
},
},
];
const CHECKBOX = /^([ \t]*)- \[(.)\](?:[ \t](.*))?$/u;
export interface ParsedLine extends Tail {
indent: string;
status: Status;
text: string;
tags: string[];
}
const TAG = /(?:^|\s)(#[^\s#[\]()]+)/gu;
function collectTags(text: string): string[] {
const found: string[] = [];
for (const match of text.matchAll(TAG)) {
found.push(match[1]);
}
return found;
}
export function parseTaskLine(raw: string): ParsedLine | null {
const checkbox = CHECKBOX.exec(raw);
if (!checkbox) return null;
const [, indent, symbol, rest = ""] = checkbox;
const parsed: Tail = {
priority: NORMAL_PRIORITY,
recurrence: null,
created: null,
start: null,
scheduled: null,
due: null,
cancelled: null,
done: null,
dependsOn: [],
taskId: null,
onCompletion: null,
blockLink: null,
};
let text = rest;
let matched = true;
while (matched) {
matched = false;
for (const matcher of MATCHERS) {
const hit = matcher.re.exec(text);
if (!hit) continue;
matcher.apply(parsed, hit[1]);
text = text.slice(0, hit.index);
matched = true;
break;
}
}
return {
...parsed,
indent,
status: ([" ", "/", "x", "-"].includes(symbol) ? symbol : " ") as Status,
text: text.trimEnd(),
tags: collectTags(text),
};
}
export type TaskFields = Omit<
Task,
| "id"
| "path"
| "sphere"
| "project"
| "inArchive"
| "line"
| "raw"
| "body"
| "tags"
>;
export function serializeTaskLine(task: TaskFields): string {
const parts: string[] = [];
if (task.text) parts.push(task.text);
if (task.priority !== NORMAL_PRIORITY) {
parts.push(PRIORITY_EMOJI[task.priority as Exclude<Priority, 2>]);
}
if (task.recurrence) parts.push(`\u{1F501} ${task.recurrence}`);
if (task.onCompletion) parts.push(`\u{1F3C1} ${task.onCompletion}`);
if (task.created) parts.push(`\u{2795} ${task.created}`);
if (task.start) parts.push(`\u{1F6EB} ${task.start}`);
if (task.scheduled) parts.push(`\u{23F3} ${task.scheduled}`);
if (task.due) parts.push(`\u{1F4C5} ${task.due}`);
if (task.cancelled) parts.push(`\u{274C} ${task.cancelled}`);
if (task.done) parts.push(`\u{2705} ${task.done}`);
if (task.dependsOn.length > 0) {
parts.push(`\u{26D4} ${task.dependsOn.join(",")}`);
}
if (task.taskId) parts.push(`\u{1F194} ${task.taskId}`);
if (task.blockLink) parts.push(task.blockLink);
const body = parts.join(" ");
const head = `${task.indent}- [${task.status}]`;
return body ? `${head} ${body}` : head;
}
+66
View File
@@ -0,0 +1,66 @@
export type Status = " " | "/" | "x" | "-";
export const STATUS_ORDER: Status[] = [" ", "/", "x", "-"];
export function isOpen(status: Status): boolean {
return status !== "x" && status !== "-";
}
export type Priority = 0 | 1 | 2 | 3 | 4 | 5;
export const NORMAL_PRIORITY: Priority = 2;
export type DateField =
| "created"
| "start"
| "scheduled"
| "due"
| "cancelled"
| "done";
export interface Task {
id: string;
path: string;
sphere: string;
project: string;
inArchive: boolean;
line: number;
raw: string;
body: string[];
indent: string;
status: Status;
text: string;
priority: Priority;
recurrence: string | null;
created: string | null;
start: string | null;
scheduled: string | null;
due: string | null;
cancelled: string | null;
done: string | null;
dependsOn: string[];
taskId: string | null;
onCompletion: string | null;
tags: string[];
blockLink: string | null;
}
export function anchorDate(task: Task): string | null {
return task.due ?? task.scheduled;
}
export interface Lane {
name: string;
inArchive: boolean;
headingLine: number;
start: number;
end: number;
}
export interface Board {
path: string;
sphere: string;
lines: string[];
lanes: Lane[];
tasks: Task[];
}
+111
View File
@@ -0,0 +1,111 @@
import { PluginSettingTab, Setting } from "obsidian";
import type { App } from "obsidian";
import type BeaverCalendarPlugin from "./main";
export type ViewMode = "list" | "calendar";
export type GroupBy = "sphere" | "project" | "status" | "due";
export type SortBy = "manual" | "priority" | "due";
export interface BeaverCalendarSettings {
boardsFolder: string;
archiveHeading: string;
view: ViewMode;
groupBy: GroupBy;
listSort: SortBy;
calendarSort: SortBy;
showDone: boolean;
includeArchive: boolean;
railCollapsed: boolean;
undatedCollapsed: boolean;
}
export const DEFAULT_SETTINGS: BeaverCalendarSettings = {
boardsFolder: "Boards",
archiveHeading: "Archive",
view: "list",
groupBy: "sphere",
listSort: "due",
calendarSort: "priority",
showDone: false,
includeArchive: false,
railCollapsed: false,
undatedCollapsed: false,
};
export function normalizeFolder(value: string): string {
return value.trim().replace(/^\/+/u, "").replace(/\/+$/u, "");
}
export class BeaverCalendarSettingsTab extends PluginSettingTab {
constructor(
app: App,
private readonly plugin: BeaverCalendarPlugin,
) {
super(app, plugin);
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Boards folder")
.setDesc(
"Every markdown file in this folder is a sphere, and every second-level heading inside it is a project.",
)
.addText((text) =>
text
.setPlaceholder("Boards")
.setValue(this.plugin.settings.boardsFolder)
.onChange(async (value) => {
this.plugin.settings.boardsFolder = normalizeFolder(value);
await this.plugin.saveSettings();
await this.plugin.store.reloadAll();
}),
);
new Setting(containerEl)
.setName("Archive heading")
.setDesc("The lane completed tasks are moved to by the archive command.")
.addText((text) =>
text
.setPlaceholder("Archive")
.setValue(this.plugin.settings.archiveHeading)
.onChange(async (value) => {
this.plugin.settings.archiveHeading = value.trim() || "Archive";
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Include the archive")
.setDesc(
"Widen the show-completed toggle to reach tasks below the archive " +
"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.",
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.includeArchive)
.onChange(async (value) => {
this.plugin.settings.includeArchive = value;
this.plugin.store.includeArchive = value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Show completed")
.setDesc("Completed tasks are hidden from the list and the calendar by default.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showDone)
.onChange(async (value) => {
this.plugin.settings.showDone = value;
this.plugin.store.showDone = value;
await this.plugin.saveSettings();
}),
);
}
}
+268
View File
@@ -0,0 +1,268 @@
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities) important;
@source "./";
@custom-variant dark (&:where(.theme-dark, .theme-dark *));
.bcal-root {
--background: var(--background-primary);
--foreground: var(--text-normal);
--card: var(--background-primary-alt);
--card-foreground: var(--text-normal);
--popover: var(--background-secondary);
--popover-foreground: var(--text-normal);
--primary: var(--color-accent);
--primary-foreground: var(--text-on-accent);
--secondary: var(--background-modifier-hover);
--secondary-foreground: var(--text-normal);
--muted: var(--background-modifier-hover);
--muted-foreground: var(--text-muted);
--accent: var(--background-modifier-hover);
--accent-foreground: var(--text-normal);
--destructive: var(--text-error);
--destructive-foreground: var(--text-on-accent);
--border: var(--background-modifier-border);
--input: var(--background-modifier-border);
--ring: var(--color-accent);
--icon: var(--text-muted);
--signal: var(--color-accent);
--note: oklch(0.52 0.13 78);
--status-new: var(--color-accent);
--status-done: oklch(0.55 0.14 155);
--status-skip: var(--text-faint);
--status-reply: oklch(0.55 0.14 235);
--status-snooze: oklch(0.66 0.13 75);
--status-meeting: oklch(0.5 0.2 300);
--status-work: oklch(0.55 0.13 195);
--sidebar: var(--background-secondary);
--radius: 0.45rem;
}
.theme-dark .bcal-root {
--note: oklch(0.84 0.14 88);
--status-done: oklch(0.74 0.14 155);
--status-reply: oklch(0.72 0.13 235);
--status-snooze: oklch(0.8 0.13 75);
--status-meeting: oklch(0.72 0.16 300);
--status-work: oklch(0.75 0.12 195);
}
@theme inline {
--font-sans: var(--font-interface);
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-lg: 1rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--color-icon: var(--icon);
--color-signal: var(--signal);
--color-note: var(--note);
--color-sidebar: var(--sidebar);
--color-status-new: var(--status-new);
--color-status-done: var(--status-done);
--color-status-skip: var(--status-skip);
--color-status-reply: var(--status-reply);
--color-status-snooze: var(--status-snooze);
--color-status-meeting: var(--status-meeting);
--color-status-work: var(--status-work);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
}
.bcal-root {
height: 100%;
font-family: var(--font-interface);
font-size: var(--text-sm);
color: var(--foreground);
background: var(--background);
}
.bcal-app {
container-type: inline-size;
display: flex;
height: 100%;
min-height: 0;
overflow: hidden;
}
.bcal-root.bcal-root *,
.bcal-root.bcal-root *::before,
.bcal-root.bcal-root *::after {
box-sizing: border-box;
border: 0 solid var(--border);
}
.bcal-root.bcal-root :is(h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd) {
margin: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
.bcal-root.bcal-root :is(ul, ol) {
margin: 0;
padding: 0;
list-style: none;
}
.bcal-root.bcal-root :is(button, input, select, textarea) {
height: auto;
min-height: 0;
margin: 0;
padding: 0;
font: inherit;
color: inherit;
letter-spacing: inherit;
background: transparent;
border: 0 solid var(--border);
border-radius: 0;
box-shadow: none;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.bcal-root.bcal-root :is(button, input, select, textarea):hover,
.bcal-root.bcal-root :is(button, input, select, textarea):focus,
.bcal-root.bcal-root :is(button, input, select, textarea):active {
box-shadow: none;
}
.bcal-root.bcal-root button {
cursor: pointer;
text-align: inherit;
}
.bcal-root.bcal-root button:hover {
background: transparent;
}
.bcal-root.bcal-root button:disabled {
cursor: default;
}
.bcal-root.bcal-root input::placeholder,
.bcal-root.bcal-root textarea::placeholder {
color: var(--muted-foreground);
}
.bcal-root.bcal-root input[type="search"]::-webkit-search-cancel-button,
.bcal-root.bcal-root input[type="search"]::-webkit-search-decoration {
display: none;
-webkit-appearance: none;
}
.bcal-root.bcal-root svg {
display: block;
flex-shrink: 0;
}
.bcal-root.bcal-root a,
.bcal-root.bcal-root a:hover {
color: inherit;
text-decoration: inherit;
}
.bcal-root.bcal-root img {
display: block;
max-width: 100%;
height: auto;
}
.bcal-root.bcal-root .bcal-md > * {
display: inline;
}
.bcal-root.bcal-root .bcal-md a {
color: var(--link-color, var(--color-accent));
text-decoration: none;
}
.bcal-root.bcal-root .bcal-md a:hover {
text-decoration: underline;
}
.bcal-root.bcal-root .bcal-md a.is-unresolved {
color: var(--link-unresolved-color, var(--muted-foreground));
opacity: 0.75;
}
.bcal-root.bcal-root .bcal-md code {
padding: 0 0.25em;
font-size: 0.9em;
background: var(--secondary);
border-radius: var(--radius-sm);
}
.bcal-root.bcal-root .bcal-md :is(img, .internal-embed) {
display: inline-block;
max-height: 1.4em;
vertical-align: text-bottom;
}
.bcal-root.bcal-root .bcal-dragging {
position: relative;
z-index: 40;
cursor: grabbing;
box-shadow: 0 12px 28px -8px rgb(0 0 0 / 45%);
transition: none;
}
@utility touch-shown {
@media (hover: none) {
opacity: 1;
}
}
@utility tabular {
font-variant-numeric: tabular-nums;
}
@utility hue-chip {
color: oklch(0.45 0.13 var(--hue));
background: oklch(0.6 0.09 var(--hue) / 0.12);
border-color: oklch(0.6 0.09 var(--hue) / 0.4);
.theme-dark & {
color: oklch(0.82 0.08 var(--hue));
}
}
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Notice } from "obsidian";
import type { App } from "obsidian";
interface TasksApiV1 {
createTaskLineModal(): Promise<string>;
editTaskLineModal(taskLine: string): Promise<string>;
executeToggleTaskDoneCommand(line: string, path: string): string;
}
interface PluginHost {
plugins?: { plugins?: Record<string, { apiV1?: TasksApiV1 } | undefined> };
}
const TASKS_ID = "obsidian-tasks-plugin";
let warned = false;
export function tasksApi(app: App): TasksApiV1 | null {
const host = app as unknown as PluginHost;
return host.plugins?.plugins?.[TASKS_ID]?.apiV1 ?? null;
}
export function warnMissingTasks(): void {
if (warned) return;
warned = true;
new Notice(
"Beaver Calendar: the Tasks plugin was not found. Done dates are " +
"stamped locally and recurring tasks are not expanded.",
8000,
);
}
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import type { App, Component } from "obsidian";
import type { BeaverCalendarSettings } from "../settings";
import type { BoardStore } from "../vault/store.svelte";
import { createTask } from "../vault/mutate";
import { provideView } from "./context";
import SphereRail from "./sphere-rail.svelte";
import TaskCalendar from "./task-calendar.svelte";
import TaskList from "./task-list.svelte";
import Toolbar from "./toolbar.svelte";
interface Props {
app: App;
component: Component;
settings: BeaverCalendarSettings;
store: BoardStore;
}
let { app, store, settings, component }: Props = $props();
let root = $state<HTMLElement | null>(null);
provideView({
app,
store,
settings,
component,
portal: () => root as HTMLElement,
});
let creating = $state(false);
let title = $state("");
const scope = $derived.by(() => {
const sphere =
store.spheres.find((item) => item.path === store.sphereFilter) ??
store.spheres[0];
if (!sphere) return null;
const project =
(store.projectFilter && sphere.projects.includes(store.projectFilter)
? store.projectFilter
: undefined) ?? sphere.projects[0];
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
});
async function commit() {
const text = title.trim();
title = "";
creating = false;
if (!text || !scope) return;
await createTask(app, scope.path, scope.project, text, null);
}
function focus(node: HTMLInputElement) {
node.focus();
}
</script>
<div bind:this={root} class="bcal-root">
<div class="bcal-app">
{#if !store.railCollapsed}
<SphereRail />
{/if}
<div class="flex min-w-0 flex-1 flex-col">
<Toolbar
oncreate={() => {
creating = true;
title = "";
}}
/>
{#if creating}
<div class="shrink-0 border-border border-b px-3 py-2">
<input
class="h-8 w-full rounded-md border border-ring bg-background px-2 text-sm outline-none"
onblur={commit}
onkeydown={(event) => {
if (event.key === "Enter") void commit();
else if (event.key === "Escape") {
creating = false;
title = "";
}
}}
placeholder={scope
? `Task in ${scope.sphere} / ${scope.project}`
: "Create a board in the boards folder first"}
use:focus
bind:value={title}
/>
</div>
{/if}
{#if store.view === "calendar"}
<TaskCalendar />
{:else}
<TaskList />
{/if}
</div>
</div>
</div>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import type { Snippet } from "svelte";
import MenuBody, { MENU_CONTENT } from "./menu-body.svelte";
import type { MenuItem } from "./menu-body.svelte";
import { useView } from "./context";
interface Props {
children: Snippet;
class?: string;
items: MenuItem[];
[key: string]: unknown;
}
let { children, items, class: className, ...rest }: Props = $props();
const view = useView();
let menuOpen = $state(false);
</script>
<ContextMenu.Root bind:open={menuOpen}>
<ContextMenu.Trigger class={className} {...rest}>
{@render children()}
</ContextMenu.Trigger>
<ContextMenu.Portal to={view.portal()}>
<ContextMenu.Content class={MENU_CONTENT}>
<MenuBody
close={() => {
menuOpen = false;
}}
{items}
/>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
+22
View File
@@ -0,0 +1,22 @@
import { getContext, setContext } from "svelte";
import type { App, Component } from "obsidian";
import type { BoardStore } from "../vault/store.svelte";
import type { BeaverCalendarSettings } from "../settings";
export interface ViewContext {
app: App;
store: BoardStore;
settings: BeaverCalendarSettings;
component: Component;
portal: () => HTMLElement;
}
const KEY = Symbol("beaver-calendar");
export function provideView(context: ViewContext): void {
setContext(KEY, context);
}
export function useView(): ViewContext {
return getContext<ViewContext>(KEY);
}
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts" module>
export type DayTone = "none" | "inside" | "start" | "end" | "solo";
</script>
<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 { WEEKDAYS } from "../lib/vocab";
import { cn } from "../lib/utils";
interface Props {
anchor: string | null;
onhover?: (key: string) => void;
onpick: (key: string) => void;
tone: (key: string) => DayTone;
}
let { anchor, tone, onpick, onhover }: Props = $props();
const DAY = 86_400_000;
const monthName = new Intl.DateTimeFormat("en-US", { month: "long" });
const todayKey = localKey(new Date());
let picked = $state<Date | null>(null);
const month = $derived.by(() => {
if (picked) return picked;
const base = anchor ? fromKey(anchor) : new Date();
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 {
date,
inMonth: date.getMonth() === month.getMonth(),
key: localKey(date),
};
});
});
function shiftMonth(delta: number) {
picked = new Date(month.getFullYear(), month.getMonth() + delta, 1);
}
</script>
<div class="w-60 select-none">
<div class="flex items-center gap-1 px-1 pb-1">
<button
aria-label="Previous month"
class="grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => shiftMonth(-1)}
type="button"
>
<ChevronLeft aria-hidden="true" class="size-3.5" />
</button>
<span class="flex-1 text-center font-medium text-xs first-letter:uppercase">
{monthName.format(month)}
{month.getFullYear()}
</span>
<button
aria-label="Next month"
class="grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => shiftMonth(1)}
type="button"
>
<ChevronRight aria-hidden="true" class="size-3.5" />
</button>
</div>
<div class="grid grid-cols-7 pb-0.5">
{#each WEEKDAYS as weekday (weekday)}
<span class="py-0.5 text-center text-[10px] text-muted-foreground">
{weekday}
</span>
{/each}
</div>
<div class="grid grid-cols-7 gap-y-px">
{#each days as day (day.key)}
{@const shape = tone(day.key)}
<button
aria-label={day.date.toLocaleDateString("en-US")}
aria-pressed={shape !== "none"}
class={cn(
"h-8 text-xs outline-none transition-colors tabular focus-visible:ring-2 focus-visible:ring-ring",
day.inMonth ? "text-foreground" : "text-muted-foreground/50",
shape === "none" && "rounded-md hover:bg-muted",
shape === "inside" && "bg-secondary",
shape !== "none" &&
shape !== "inside" &&
"bg-primary font-medium text-primary-foreground",
shape === "solo" && "rounded-md",
shape === "start" && "rounded-l-md",
shape === "end" && "rounded-r-md",
day.key === todayKey && shape === "none" && "font-medium text-signal",
)}
onclick={() => onpick(day.key)}
onmouseenter={() => onhover?.(day.key)}
type="button"
>
{day.date.getDate()}
</button>
{/each}
</div>
</div>
+129
View File
@@ -0,0 +1,129 @@
import { anchorDate } from "../model/types";
import type { Task } from "../model/types";
import { shiftDays, today } from "../lib/due";
import { laneKey, parseLaneKey } from "../lib/keys";
import { moveTask, reorderLane, setDate } from "../vault/mutate";
import type { ViewContext } from "./context";
export const dropTarget = {
task: (id: string) => `task:${id}`,
day: (key: string) => `day:${key}`,
lane: (path: string, project: string) => `lane:${laneKey(path, project)}`,
sphere: (path: string) => `sphere:${encodeURIComponent(path)}`,
undated: "undated",
};
function dateField(task: Task): "due" | "scheduled" {
return !task.due && task.scheduled ? "scheduled" : "due";
}
export async function handleDrop(
view: ViewContext,
dragged: Task,
target: string,
after: boolean,
): Promise<void> {
const { app, store } = view;
if (target === dropTarget.undated) {
await setDate(app, dragged, dateField(dragged), null);
return;
}
if (target.startsWith("group:")) {
await handleGroupDrop(view, dragged, target.slice(6));
return;
}
if (target.startsWith("day:")) {
const key = target.slice(4);
if (anchorDate(dragged) === key) return;
await setDate(app, dragged, dateField(dragged), key);
return;
}
if (target.startsWith("sphere:")) {
const path = decodeURIComponent(target.slice(7));
if (path === dragged.path) return;
const sphere = store.spheres.find((item) => item.path === path);
const project = sphere?.projects[0];
if (!project) return;
await moveTask(app, dragged, { path, project });
return;
}
if (target.startsWith("lane:")) {
const lane = parseLaneKey(target.slice(5));
if (!lane) return;
if (lane.path === dragged.path && lane.project === dragged.project) return;
await moveTask(app, dragged, lane);
return;
}
if (target.startsWith("task:")) {
const id = target.slice(5);
if (id === dragged.id) return;
const onto = store.all.find((task) => task.id === id);
if (!onto) return;
if (onto.path === dragged.path && onto.project === dragged.project) {
const board = store.boardFor(onto.path);
if (!board) return;
const lane = board.tasks.filter(
(task) => task.project === onto.project && !task.inArchive,
);
const rest = lane
.map((task) => task.id)
.filter((each) => each !== dragged.id);
const at = rest.indexOf(onto.id);
if (at === -1) return;
rest.splice(after ? at + 1 : at, 0, dragged.id);
store.sortBy = "manual";
await reorderLane(app, board, onto.project, rest);
return;
}
const board = store.boardFor(onto.path);
const siblings =
board?.tasks.filter(
(task) => task.project === onto.project && !task.inArchive,
) ?? [];
const at = siblings.findIndex((task) => task.id === onto.id);
await moveTask(app, dragged, {
path: onto.path,
project: onto.project,
index: at === -1 ? undefined : at + (after ? 1 : 0),
});
}
}
export async function handleGroupDrop(
view: ViewContext,
dragged: Task,
groupKey: string,
): Promise<void> {
const { app, store } = view;
if (store.groupBy === "due") {
if (groupKey === "today") {
await setDate(app, dragged, dateField(dragged), today());
} else if (groupKey === "tomorrow") {
await setDate(app, dragged, dateField(dragged), shiftDays(today(), 1));
} else if (groupKey === "none") {
await setDate(app, dragged, dateField(dragged), null);
}
return;
}
if (store.groupBy === "sphere") {
await handleDrop(view, dragged, dropTarget.sphere(groupKey), false);
return;
}
if (store.groupBy === "project") {
const lane = parseLaneKey(groupKey);
if (lane) {
await handleDrop(view, dragged, dropTarget.lane(lane.path, lane.project), false);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import CalendarDays from "@lucide/svelte/icons/calendar-days";
import Repeat from "@lucide/svelte/icons/repeat";
import { Popover } from "bits-ui";
import { dueLabel, dueTone, nextFriday, shiftDays, today } from "../lib/due";
import { cn } from "../lib/utils";
import DayGrid from "./day-grid.svelte";
import type { DayTone } from "./day-grid.svelte";
import { useView } from "./context";
interface Props {
class?: string;
field?: "due" | "scheduled";
onclear: () => void;
onpick: (key: string) => void;
onrepeat?: (rule: string | null) => void;
recurrence?: string | null;
value: string | null;
}
let {
value,
recurrence = null,
onpick,
onclear,
onrepeat,
field = "due",
class: className,
}: Props = $props();
const view = useView();
let open = $state(false);
const tone = $derived(dueTone(value));
const label = $derived(dueLabel(value));
const RULES = [
{ label: "None", value: "" },
{ label: "Daily", value: "every day" },
{ label: "Weekly", value: "every week" },
{ label: "Monthly", value: "every month" },
];
const QUICK = $derived([
{ label: "Today", key: today() },
{ label: "Tomorrow", key: shiftDays(today(), 1) },
{ label: "Friday", key: nextFriday() },
{ label: "In a week", key: shiftDays(today(), 7) },
]);
function dayTone(key: string): DayTone {
return key === value ? "solo" : "none";
}
function pick(key: string) {
onpick(key);
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
aria-label={field === "due" ? "Due" : "Scheduled"}
class={cn(
"inline-flex h-6 items-center gap-1 rounded-md px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
tone === "overdue" && "text-destructive",
tone === "soon" && "text-status-snooze",
tone === "none" && "text-muted-foreground",
className,
)}
data-nodrag
>
{#if recurrence}
<Repeat aria-hidden="true" class="size-3" />
{:else}
<CalendarDays aria-hidden="true" class="size-3" />
{/if}
{#if label}
<span class="tabular">{label}</span>
{/if}
</Popover.Trigger>
<Popover.Portal to={view.portal()}>
<Popover.Content
align="start"
class="z-50 w-fit rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
sideOffset={6}
>
<div class="grid grid-cols-2 gap-1 pb-2">
{#each QUICK as quick (quick.label)}
<button
class="h-7 rounded-md border border-border px-2 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => pick(quick.key)}
type="button"
>
{quick.label}
</button>
{/each}
</div>
<DayGrid anchor={value} onpick={pick} tone={dayTone} />
{#if onrepeat}
{@const known = RULES.some((rule) => rule.value === (recurrence ?? ""))}
<div class="mt-2 border-border border-t pt-2">
<div class="grid grid-cols-4 gap-1">
{#each RULES as rule (rule.value)}
<button
aria-pressed={(recurrence ?? "") === rule.value}
class="h-7 rounded-md border border-border px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-secondary aria-pressed:text-foreground"
onclick={() => onrepeat?.(rule.value || null)}
type="button"
>
{rule.label}
</button>
{/each}
</div>
{#if recurrence && !known}
<p class="pt-1.5 text-center text-muted-foreground text-[11px]">
Custom rule: {recurrence}
</p>
{/if}
</div>
{/if}
{#if value}
<button
class="mt-2 h-7 w-full rounded-md text-muted-foreground text-xs outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => {
onclear();
open = false;
}}
type="button"
>
Clear date
</button>
{/if}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { laneKey, parseLaneKey } from "../lib/keys";
import { cn } from "../lib/utils";
import Picker from "./picker.svelte";
import { useView } from "./context";
interface Props {
class?: string;
onpick: (path: string, project: string) => void;
path: string;
project: string;
trigger: Snippet<[{ open: boolean }]>;
}
let { path, project, onpick, trigger, class: className }: Props = $props();
const renderTrigger = $derived(trigger);
const view = useView();
const options = $derived(
view.store.spheres.flatMap((sphere) =>
sphere.projects.map((name) => ({
label: name,
hint: sphere.name,
value: laneKey(sphere.path, name),
})),
),
);
</script>
<Picker
label="Move to"
onselect={(value) => {
const lane = parseLaneKey(value);
if (lane) onpick(lane.path, lane.project);
}}
{options}
searchable
triggerClass={cn("max-w-full", className)}
value={laneKey(path, project)}
>
{#snippet trigger(state)}
{@render renderTrigger(state)}
{/snippet}
</Picker>
+72
View File
@@ -0,0 +1,72 @@
import { MarkdownRenderer } from "obsidian";
import type { App, Component } from "obsidian";
export interface MarkdownOptions {
app: App;
component: Component;
sourcePath: string;
text: string;
}
function unwrap(node: HTMLElement): void {
let child = node.firstElementChild;
while (
node.childElementCount === 1 &&
child &&
(child.tagName === "P" || child.tagName === "DIV")
) {
child.replaceWith(...child.childNodes);
child = node.firstElementChild;
}
}
export function markdown(node: HTMLElement, options: MarkdownOptions) {
let current = options;
let generation = 0;
const render = async (): Promise<void> => {
const mine = (generation += 1);
const { app, component, sourcePath, text } = current;
if (typeof MarkdownRenderer?.render !== "function") {
node.textContent = text;
return;
}
const staging = document.createElement("div");
await MarkdownRenderer.render(app, text, staging, sourcePath, component);
if (mine !== generation) return;
unwrap(staging);
node.replaceChildren(...staging.childNodes);
};
void render();
return {
update(next: MarkdownOptions) {
const changed =
next.text !== current.text || next.sourcePath !== current.sourcePath;
current = next;
if (changed) void render();
},
destroy() {
generation += 1;
},
};
}
export function followLink(
app: App,
event: MouseEvent,
sourcePath: string,
): boolean {
const anchor = (event.target as HTMLElement).closest("a");
if (!anchor) return false;
event.preventDefault();
event.stopPropagation();
const href = anchor.getAttribute("href") ?? "";
if (anchor.classList.contains("internal-link")) {
void app.workspace.openLinkText(href, sourcePath, event.ctrlKey || event.metaKey);
} else if (href) {
window.open(href, "_blank");
}
return true;
}
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts" module>
export interface MenuItem {
confirm?: string;
danger?: boolean;
disabled?: boolean;
dot?: string;
hint?: string;
icon?: Component;
label: string;
onselect: () => void;
}
export const MENU_CONTENT =
"z-50 w-56 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none";
</script>
<script lang="ts">
import type { Component } from "svelte";
import { cn } from "../lib/utils";
interface Props {
close: () => void;
items: MenuItem[];
}
let { items, close }: Props = $props();
let armed = $state("");
function choose(item: MenuItem) {
if (item.confirm && armed !== item.label) {
armed = item.label;
return;
}
armed = "";
close();
item.onselect();
}
</script>
<div role="presentation">
{#each items as item (item.label)}
{@const arming = armed === item.label}
<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 hover:bg-muted focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-40",
item.danger && "text-destructive hover:bg-destructive/10",
arming && "bg-destructive/10 font-medium",
)}
disabled={item.disabled}
onclick={() => choose(item)}
type="button"
>
{#if item.dot}
<span
aria-hidden="true"
class="size-2 shrink-0 rounded-full"
style="background: {item.dot}"
></span>
{:else if item.icon}
<item.icon aria-hidden="true" class="size-3.5 shrink-0" />
{/if}
<span class="min-w-0 flex-1 truncate">
{arming ? item.confirm : item.label}
</span>
{#if item.hint && !arming}
<span class="shrink-0 text-muted-foreground text-xs">{item.hint}</span>
{/if}
</button>
{/each}
</div>
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts" module>
export interface PickerOption {
color?: string;
hint?: string;
icon?: Component;
label: string;
value: string;
}
</script>
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import { Popover } from "bits-ui";
import type { Component, Snippet } from "svelte";
import { cn } from "../lib/utils";
import { useView } from "./context";
interface Props {
footer?: Snippet<[{ dismiss: () => void; query: string }]>;
label: string;
onselect: (value: string) => void;
open?: boolean;
options: PickerOption[];
searchable?: boolean;
title?: string;
trigger: Snippet<[{ open: boolean }]>;
triggerClass?: string;
value?: string | null;
width?: string;
}
let {
options,
footer,
value = null,
onselect,
label,
trigger,
triggerClass,
searchable = false,
title,
width,
open: isOpen = $bindable(false),
}: Props = $props();
const view = useView();
let query = $state("");
let active = $state(0);
const shown = $derived(
query.trim()
? options.filter((option) =>
option.label.toLowerCase().includes(query.trim().toLowerCase()),
)
: options,
);
$effect(() => {
if (!isOpen) {
query = "";
active = 0;
}
});
function choose(option: PickerOption) {
onselect(option.value);
isOpen = false;
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
active = Math.max(0, Math.min(shown.length - 1, active + delta));
} else if (event.key === "Enter" && shown[active]) {
event.preventDefault();
choose(shown[active]);
}
}
</script>
<Popover.Root bind:open={isOpen}>
<Popover.Trigger
aria-label={label}
class={cn(
"inline-flex items-center outline-none focus-visible:ring-2 focus-visible:ring-ring",
triggerClass,
)}
data-nodrag
{title}
>
{@render trigger({ open: isOpen })}
</Popover.Trigger>
<Popover.Portal to={view.portal()}>
<Popover.Content
align="start"
class={cn(
"z-50 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none",
width ?? (footer ? "w-72" : "w-56"),
)}
sideOffset={6}
>
{#if searchable}
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
class="mb-1 h-8 w-full rounded-md bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
{onkeydown}
placeholder={label}
bind:value={query}
/>
{/if}
{#if options.length > 0}
<div class="max-h-64 overflow-y-auto">
{#each shown as option, index (option.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 hover:bg-muted",
index === active && "bg-muted",
)}
onclick={() => choose(option)}
onmouseenter={() => {
active = index;
}}
type="button"
>
{#if option.icon}
<option.icon
aria-hidden="true"
class="size-4 shrink-0"
style={option.color ? `color: ${option.color}` : undefined}
/>
{/if}
<span class="min-w-0 flex-1 truncate">{option.label}</span>
{#if option.hint}
<span class="shrink-0 text-muted-foreground text-xs tabular">
{option.hint}
</span>
{/if}
{#if option.value === value}
<Check aria-hidden="true" class="size-3.5 shrink-0 text-signal" />
{/if}
</button>
{:else}
<p class="px-2 py-3 text-center text-muted-foreground text-xs">
Nothing found
</p>
{/each}
</div>
{/if}
{#if footer}
<div class={options.length > 0 ? "mt-1 border-border border-t pt-1" : ""}>
{@render footer({
dismiss: () => {
isOpen = false;
},
query: query.trim(),
})}
</div>
{/if}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import ChevronsDown from "@lucide/svelte/icons/chevrons-down";
import Flame from "@lucide/svelte/icons/flame";
import { isHot, PRIORITY_LABEL } from "../lib/vocab";
import type { Priority } from "../model/types";
import { cn } from "../lib/utils";
interface Props {
class?: string;
priority: Priority;
}
let { priority, class: className }: Props = $props();
const bars = $derived(Math.max(0, priority - 2));
</script>
<span
class={cn(
"inline-flex items-end gap-px",
className,
isHot(priority) && "text-destructive",
)}
title={PRIORITY_LABEL[priority]}
>
{#if isHot(priority)}
<Flame aria-hidden="true" class="size-3.5" />
{:else if priority === 1}
<ChevronDown aria-hidden="true" class="size-3.5" />
{:else if priority === 0}
<ChevronsDown aria-hidden="true" class="size-3.5" />
{:else}
{#each [3, 5, 7] as height, index (height)}
<span
aria-hidden="true"
class={cn(
"w-[3px] rounded-[1px] bg-current transition-opacity",
index < bars ? "opacity-100" : "opacity-25",
)}
style="height: {height}px"
></span>
{/each}
{/if}
<span class="sr-only">{PRIORITY_LABEL[priority]}</span>
</span>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import { PRIORITY_LABEL, PRIORITY_ORDER } from "../lib/vocab";
import type { Priority } from "../model/types";
import { cn } from "../lib/utils";
import Picker from "./picker.svelte";
import PriorityBars from "./priority-bars.svelte";
interface Props {
class?: string;
onchange: (priority: Priority) => void;
priority: Priority;
}
let { priority, onchange, class: className }: Props = $props();
const options = PRIORITY_ORDER.map((value) => ({
label: PRIORITY_LABEL[value],
value: String(value),
}));
</script>
<Picker
label="Priority"
onselect={(value) => onchange(Number(value) as Priority)}
{options}
triggerClass={cn(
"h-6 gap-1 rounded-md px-1 text-muted-foreground transition-colors hover:bg-muted",
className,
)}
value={String(priority)}
>
{#snippet trigger()}
<PriorityBars {priority} />
{/snippet}
</Picker>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import Ellipsis from "@lucide/svelte/icons/ellipsis";
import { DropdownMenu } from "bits-ui";
import { cn } from "../lib/utils";
import MenuBody, { MENU_CONTENT } from "./menu-body.svelte";
import type { MenuItem } from "./menu-body.svelte";
import { useView } from "./context";
interface Props {
class?: string;
items: MenuItem[];
label?: string;
}
let { items, label = "Task menu", class: className }: Props = $props();
const view = useView();
let open = $state(false);
</script>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger
aria-label={label}
class={cn(
"grid size-5 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
data-nodrag
>
<Ellipsis aria-hidden="true" class="size-3.5" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal to={view.portal()}>
<DropdownMenu.Content align="end" class={MENU_CONTENT} sideOffset={4}>
<MenuBody
close={() => {
open = false;
}}
{items}
/>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { labelHue } from "../lib/hue";
import { dropZone } from "../lib/dnd.svelte";
import { laneKey } from "../lib/keys";
import { cn } from "../lib/utils";
import { dropTarget } from "./drop";
import { useView } from "./context";
const view = useView();
const store = view.store;
let expanded = $state<Set<string>>(new Set());
const zone = $derived(dropZone());
function toggle(path: string) {
const next = new Set(expanded);
if (next.has(path)) next.delete(path);
else next.add(path);
expanded = next;
}
function selectSphere(path: string) {
store.sphereFilter = store.sphereFilter === path ? null : path;
store.projectFilter = null;
}
function selectProject(path: string, project: string) {
store.sphereFilter = path;
store.projectFilter = store.projectFilter === project ? null : project;
}
</script>
<nav
class="flex w-52 shrink-0 flex-col overflow-y-auto border-border border-r bg-sidebar/50 py-2"
>
<button
aria-current={store.sphereFilter === null}
class="mx-2 flex h-7 items-center rounded-md px-2 text-left font-medium text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary"
onclick={() => {
store.sphereFilter = null;
store.projectFilter = null;
}}
type="button"
>
<span class="flex-1 truncate">All spheres</span>
<span class="text-muted-foreground tabular">{store.all.length}</span>
</button>
{#each store.spheres as sphere (sphere.path)}
{@const open = expanded.has(sphere.path)}
{@const target = dropTarget.sphere(sphere.path)}
<div class="mt-1 px-2">
<div
class={cn(
"flex h-7 items-center gap-0.5 rounded-md transition-colors",
zone?.target === target && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
)}
data-drop={target}
>
<button
aria-label={open ? "Collapse" : "Expand"}
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => toggle(sphere.path)}
type="button"
>
<ChevronRight
aria-hidden="true"
class={cn("size-3 transition-transform", open && "rotate-90")}
/>
</button>
<button
aria-current={store.sphereFilter === sphere.path &&
store.projectFilter === null}
class="flex h-7 min-w-0 flex-1 items-center rounded-md px-1 text-left font-medium text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary"
onclick={() => selectSphere(sphere.path)}
type="button"
>
<span class="min-w-0 flex-1 truncate">{sphere.name}</span>
<span class="shrink-0 text-muted-foreground tabular">
{store.inScopeCount.byPath.get(sphere.path) ?? 0}
</span>
</button>
</div>
{#if open}
<div class="flex flex-col pt-0.5 pl-5">
{#each sphere.projects as project (project)}
{@const laneTarget = dropTarget.lane(sphere.path, project)}
<button
aria-current={store.sphereFilter === sphere.path &&
store.projectFilter === project}
class={cn(
"flex h-6 items-center gap-1.5 rounded-md px-1 text-left text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary",
zone?.target === laneTarget && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
)}
data-drop={laneTarget}
onclick={() => selectProject(sphere.path, project)}
type="button"
>
<span
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full"
style="background: oklch(0.6 0.12 {labelHue(project)})"
></span>
<span class="min-w-0 flex-1 truncate">{project}</span>
<span class="shrink-0 text-muted-foreground tabular">
{store.inScopeCount.byProject.get(laneKey(sphere.path, project)) ?? 0}
</span>
</button>
{/each}
</div>
{/if}
</div>
{/each}
</nav>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import { STATUS_META } from "../lib/vocab";
import { STATUS_ORDER } from "../model/types";
import type { Status } from "../model/types";
import { cn } from "../lib/utils";
import Picker from "./picker.svelte";
interface Props {
class?: string;
onchange: (status: Status) => void;
showLabel?: boolean;
status: Status;
}
let { status, onchange, showLabel = false, class: className }: Props =
$props();
const options = STATUS_ORDER.map((symbol) => ({
color: STATUS_META[symbol].color,
icon: STATUS_META[symbol].icon,
label: STATUS_META[symbol].label,
value: symbol,
}));
const meta = $derived(STATUS_META[status]);
</script>
<Picker
label="Status"
onselect={(value) => onchange(value as Status)}
{options}
triggerClass={cn(
"h-6 gap-1.5 rounded-md px-1 transition-colors hover:bg-muted",
className,
)}
value={status}
>
{#snippet trigger()}
<meta.icon
aria-hidden="true"
class="size-4 shrink-0"
style="color: {meta.color}"
/>
{#if showLabel}
<span class="truncate text-sm">{meta.label}</span>
{/if}
<span class="sr-only">Status: {meta.label}</span>
{/snippet}
</Picker>
+304
View File
@@ -0,0 +1,304 @@
<script lang="ts">
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import Archive from "@lucide/svelte/icons/archive";
import Plus from "@lucide/svelte/icons/plus";
import { anchorDate, isOpen } from "../model/types";
import type { Task } from "../model/types";
import type { DaySlot } from "../vault/store.svelte";
import { draggable, dropZone } from "../lib/dnd.svelte";
import { labelHue } from "../lib/hue";
import { cn } from "../lib/utils";
import { STATUS_META, WEEKDAYS } from "../lib/vocab";
import { createTask, setStatus } from "../vault/mutate";
import ContextArea from "./context-area.svelte";
import RowMenu from "./row-menu.svelte";
import { dropTarget, handleDrop } from "./drop";
import { followLink, markdown } from "./markdown";
import { taskMenu } from "./task-menu";
import { useView } from "./context";
const view = useView();
const store = view.store;
let addingDay = $state<string | null>(null);
let dayTitle = $state("");
const zone = $derived(dropZone());
const scope = $derived.by(() => {
const sphere =
store.spheres.find((item) => item.path === store.sphereFilter) ??
store.spheres[0];
if (!sphere) return null;
const project =
(store.projectFilter && sphere.projects.includes(store.projectFilter)
? store.projectFilter
: undefined) ?? sphere.projects[0];
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
});
async function addOnDay(key: string) {
const title = dayTitle.trim();
dayTitle = "";
addingDay = null;
if (!title || !scope) return;
await createTask(view.app, scope.path, scope.project, title, key);
}
function focus(node: HTMLInputElement) {
node.focus();
}
</script>
{#snippet card(task: Task)}
{@const meta = STATUS_META[task.status]}
<ContextArea class="contents" items={taskMenu(view, task)}>
<div
class={cn(
"group/card flex min-w-0 cursor-grab select-none flex-col gap-1 rounded-md border border-border bg-card p-1.5 transition-colors hover:border-border/90 hover:bg-secondary/40",
!anchorDate(task) && "border-dashed hover:border-solid",
!isOpen(task.status) && "opacity-60 hover:opacity-100",
)}
use:draggable={{
payload: () => ({ id: task.id, kind: "task" }),
ondrop: (to, after) => void handleDrop(view, task, to, after),
}}
>
<div class="flex min-w-0 items-start gap-1">
<button
aria-label={`Status: ${meta.label}`}
class="grid size-4 shrink-0 place-items-center rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-nodrag
onclick={() =>
void setStatus(view.app, task, isOpen(task.status) ? "x" : " ")}
type="button"
>
<meta.icon aria-hidden="true" class="size-3.5" style="color: {meta.color}" />
</button>
<div
class={cn(
"bcal-md line-clamp-2 min-w-0 flex-1 font-medium text-xs leading-snug",
!isOpen(task.status) && "line-through decoration-muted-foreground/50",
)}
onclick={(event) => followLink(view.app, event, task.path)}
onkeydown={() => {}}
role="presentation"
title={task.text}
use:markdown={{
app: view.app,
component: view.component,
sourcePath: task.path,
text: task.text,
}}
></div>
<RowMenu
class="size-4 opacity-0 transition-opacity group-hover/card:opacity-100 touch-shown"
items={taskMenu(view, task)}
/>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-1 pl-5">
<span
class="max-w-20 shrink-0 truncate rounded border border-border bg-secondary/50 px-1 text-[10px] text-muted-foreground leading-[1.4]"
>
{task.sphere}
</span>
{#if task.inArchive}
<span
class="inline-flex max-w-24 shrink-0 items-center gap-0.5 truncate rounded border border-border border-dashed px-1 text-[10px] text-muted-foreground leading-[1.4]"
>
<Archive aria-hidden="true" class="size-2.5" />
{task.project}
</span>
{:else}
<span
class="hue-chip max-w-24 truncate rounded border px-1 text-[10px] leading-[1.4]"
style="--hue: {labelHue(task.project)}"
>
{task.project}
</span>
{/if}
</div>
</div>
</ContextArea>
{/snippet}
{#snippet dayCell(day: DaySlot)}
{@const target = dropTarget.day(day.key)}
<div
class={cn(
"group/day flex min-h-[7rem] min-w-0 flex-col gap-1 border-border/40 border-r border-b p-1.5 transition-colors last:border-r-0",
day.inMonth ? "bg-transparent" : "bg-secondary/25",
day.isToday && "bg-primary/[0.06]",
zone?.target === target &&
"bg-secondary/70 inset-ring-2 inset-ring-signal/70",
)}
data-drop={target}
>
<div class="flex items-center gap-1">
<span
class={cn(
"rounded-md px-1 text-xs tabular",
day.isToday && "bg-primary font-semibold text-primary-foreground",
!day.inMonth && "text-muted-foreground/40",
)}
>
{day.date.getDate()}
</span>
<button
aria-label="Add task"
class="ml-auto grid size-5 place-items-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover/day:opacity-100 touch-shown"
onclick={() => {
addingDay = day.key;
dayTitle = "";
}}
type="button"
>
<Plus aria-hidden="true" class="size-3.5" />
</button>
</div>
{#if addingDay === day.key}
<input
class="h-6 w-full shrink-0 rounded-md border border-ring bg-background px-1.5 text-xs outline-none"
onblur={() => void addOnDay(day.key)}
onkeydown={(event) => {
if (event.key === "Enter") void addOnDay(day.key);
else if (event.key === "Escape") {
addingDay = null;
dayTitle = "";
}
}}
placeholder={scope ? `Task in ${scope.project}` : "Task"}
use:focus
bind:value={dayTitle}
/>
{/if}
{#each day.tasks as task (task.id)}
{@render card(task)}
{/each}
</div>
{/snippet}
<div class="flex min-h-0 flex-1 flex-col">
<div class="flex shrink-0 items-center gap-1 px-3 py-2">
<h2 class="font-semibold text-sm first-letter:uppercase">
{store.monthLabel}
</h2>
<div class="ml-auto flex items-center gap-0.5">
<button
aria-label="Previous month"
class="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => store.stepMonth(-1)}
type="button"
>
<ChevronLeft aria-hidden="true" class="size-4" />
</button>
<button
class="h-7 rounded-4xl border border-border px-2.5 font-medium text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => store.goToday()}
type="button"
>
Today
</button>
<button
aria-label="Next month"
class="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => store.stepMonth(1)}
type="button"
>
<ChevronRight aria-hidden="true" class="size-4" />
</button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto @min-[46rem]:hidden">
{#each store.agendaDays as day (day.key)}
<div class="px-3">
<h3
class="sticky top-0 z-10 flex items-center gap-2 bg-background/95 py-1.5 font-medium text-muted-foreground text-xs backdrop-blur"
data-drop={dropTarget.day(day.key)}
>
<span class={cn("tabular", day.isToday && "text-signal")}>
{day.date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
weekday: "short",
})}
</span>
<span class="ml-auto tabular">{day.tasks.length}</span>
</h3>
<div class="flex flex-col gap-1.5 pb-2">
{#each day.tasks as task (task.id)}
{@render card(task)}
{/each}
</div>
</div>
{:else}
<p class="px-3 py-6 text-center text-muted-foreground text-xs">
Nothing scheduled this month
</p>
{/each}
</div>
<div class="hidden min-h-0 flex-1 flex-col px-3 pb-3 @min-[46rem]:flex">
<div
class="grid shrink-0 grid-cols-7 border-border border-b pb-1.5 text-muted-foreground text-xs"
>
{#each WEEKDAYS as weekday (weekday)}
<span class="text-center">{weekday}</span>
{/each}
</div>
<div
class="min-h-0 flex-1 overflow-y-auto rounded-b-lg border-border border-x border-b"
>
{#each store.monthWeeks as week, index (index)}
<div class="grid grid-cols-7">
{#each week as day (day.key)}
{@render dayCell(day)}
{/each}
</div>
{/each}
</div>
</div>
{#if store.undated.length > 0}
<div
class={cn(
"shrink-0 border-border border-t bg-sidebar/40 transition-colors",
zone?.target === dropTarget.undated && "bg-secondary/70",
)}
data-drop={dropTarget.undated}
>
<button
aria-expanded={!store.undatedCollapsed}
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left font-medium text-muted-foreground text-xs outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => {
store.undatedCollapsed = !store.undatedCollapsed;
store.savePrefs();
}}
type="button"
>
<ChevronRight
aria-hidden="true"
class={cn(
"size-3 transition-transform",
!store.undatedCollapsed && "rotate-90",
)}
/>
<span>No date</span>
<span class="tabular">{store.undated.length}</span>
</button>
{#if !store.undatedCollapsed}
<div
class="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto px-3 pt-0.5 pb-2"
>
{#each store.undated as task (task.id)}
<div class="w-48">{@render card(task)}</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import { dropZone } from "../lib/dnd.svelte";
import { cn } from "../lib/utils";
import TaskRow from "./task-row.svelte";
import { useView } from "./context";
const view = useView();
const store = view.store;
const zone = $derived(dropZone());
function groupTarget(key: string): string {
return `group:${key}`;
}
</script>
<div class="min-h-0 flex-1 overflow-y-auto">
{#each store.groups as group (group.key)}
<section>
<h2
class={cn(
"sticky top-0 z-10 flex items-center gap-2 border-border/60 border-b bg-background/95 px-3 py-1.5 font-medium text-muted-foreground text-xs backdrop-blur transition-colors",
zone?.target === groupTarget(group.key) && "bg-secondary/70",
)}
data-drop={groupTarget(group.key)}
>
<span class="truncate">{group.label}</span>
{#if group.hint}
<span class="truncate text-muted-foreground/60">{group.hint}</span>
{/if}
<span class="ml-auto tabular">{group.tasks.length}</span>
</h2>
{#each group.tasks as task (task.id)}
<TaskRow
showProject={store.groupBy !== "project"}
showSphere={store.groupBy !== "sphere"}
{task}
/>
{/each}
</section>
{:else}
<p class="px-3 py-8 text-center text-muted-foreground text-xs">
{store.search.trim() ? "Nothing matches" : "No tasks"}
</p>
{/each}
</div>
+70
View File
@@ -0,0 +1,70 @@
import { Notice, TFile } from "obsidian";
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 { STATUS_META } from "../lib/vocab";
import { STATUS_ORDER } from "../model/types";
import type { Task } from "../model/types";
import { replaceRaw, setStatus } from "../vault/mutate";
import { tasksApi, warnMissingTasks } from "../tasks-api";
import type { MenuItem } from "./menu-body.svelte";
import type { ViewContext } from "./context";
async function revealInFile(view: ViewContext, task: Task): Promise<void> {
const file = view.app.vault.getAbstractFileByPath(task.path);
if (!(file instanceof TFile)) return;
const leaf = view.app.workspace.getLeaf("tab");
await leaf.openFile(file);
const editor = view.app.workspace.activeEditor?.editor;
if (editor) {
editor.setCursor({ line: task.line, ch: 0 });
editor.scrollIntoView(
{ from: { line: task.line, ch: 0 }, to: { line: task.line, ch: 0 } },
true,
);
}
}
async function editInTasks(view: ViewContext, task: Task): Promise<void> {
const api = tasksApi(view.app);
if (!api) {
warnMissingTasks();
return;
}
const edited = await api.editTaskLineModal(task.raw);
if (!edited || edited === task.raw) return;
await replaceRaw(view.app, task, edited);
}
export function taskMenu(view: ViewContext, task: Task): MenuItem[] {
const statuses: MenuItem[] = STATUS_ORDER.filter(
(symbol) => symbol !== task.status,
).map((symbol) => ({
dot: STATUS_META[symbol].color,
label: STATUS_META[symbol].label,
onselect: () => void setStatus(view.app, task, symbol),
}));
return [
...statuses,
{
icon: PenLine,
label: "Edit in Tasks",
onselect: () => void editInTasks(view, task),
},
{
icon: FileText,
label: "Reveal in file",
hint: task.sphere,
onselect: () => void revealInFile(view, task),
},
{
icon: ExternalLink,
label: "Copy raw line",
onselect: () => {
void navigator.clipboard.writeText(task.raw);
new Notice("Beaver Calendar: line copied");
},
},
];
}
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import Archive from "@lucide/svelte/icons/archive";
import { anchorDate, isOpen } from "../model/types";
import type { Task } from "../model/types";
import { draggable, dropZone } from "../lib/dnd.svelte";
import { labelHue } from "../lib/hue";
import { cn } from "../lib/utils";
import { STATUS_META } from "../lib/vocab";
import {
moveTask,
setDate,
setRecurrence,
setStatus,
setText,
} from "../vault/mutate";
import ContextArea from "./context-area.svelte";
import DuePicker from "./due-picker.svelte";
import LanePicker from "./lane-picker.svelte";
import PriorityBars from "./priority-bars.svelte";
import RowMenu from "./row-menu.svelte";
import { dropTarget, handleDrop } from "./drop";
import { followLink, markdown } from "./markdown";
import { taskMenu } from "./task-menu";
import { useView } from "./context";
interface Props {
showProject?: boolean;
showSphere?: boolean;
task: Task;
}
let { task, showSphere = true, showProject = true }: Props = $props();
const view = useView();
let editing = $state(false);
let draft = $state("");
const meta = $derived(STATUS_META[task.status]);
const target = $derived(dropTarget.task(task.id));
const zone = $derived(dropZone());
const isDropTarget = $derived(zone?.target === target);
const date = $derived(anchorDate(task));
const field = $derived(!task.due && task.scheduled ? "scheduled" : "due");
function startEdit() {
draft = task.text;
editing = true;
}
async function commitEdit() {
if (!editing) return;
editing = false;
await setText(view.app, task, draft);
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
void commitEdit();
} else if (event.key === "Escape") {
event.preventDefault();
editing = false;
}
}
async function toggle() {
await setStatus(view.app, task, isOpen(task.status) ? "x" : " ");
}
</script>
<ContextArea class="contents" items={taskMenu(view, task)}>
<div
class={cn(
"group/task grid select-none items-center gap-2 border-border/60 border-b px-3 transition-colors hover:bg-secondary/50",
!isOpen(task.status) && "opacity-60 hover:opacity-100",
isDropTarget &&
(zone?.after
? "shadow-[inset_0_-2px_0_0_var(--signal)]"
: "shadow-[inset_0_2px_0_0_var(--signal)]"),
)}
data-drop={target}
style="grid-template-columns: 1.25rem minmax(0, 1fr) auto;"
use:draggable={{
payload: () => ({ id: task.id, kind: "task" }),
ondrop: (to, after) => void handleDrop(view, task, to, after),
enabled: () => !editing,
}}
>
<button
aria-label={`Status: ${meta.label}`}
class="grid size-5 place-items-center rounded-md outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
data-nodrag
onclick={toggle}
title={meta.label}
type="button"
>
<meta.icon aria-hidden="true" class="size-4" style="color: {meta.color}" />
</button>
<div class="flex min-w-0 items-center gap-1.5 py-2.5">
{#if showSphere}
<span
class="hidden shrink-0 rounded-md border border-border bg-secondary/40 px-1.5 py-0.5 font-medium text-[11px] text-muted-foreground @min-[30rem]:inline-block"
>
{task.sphere}
</span>
{/if}
{#if task.inArchive}
<span
class="hidden shrink-0 items-center gap-1 rounded-md border border-border border-dashed px-1.5 py-0.5 text-[11px] text-muted-foreground @min-[24rem]:inline-flex"
title={task.project}
>
<Archive aria-hidden="true" class="size-3" />
{task.project}
</span>
{:else if showProject}
<LanePicker
class="hidden shrink-0 @min-[24rem]:inline-flex"
onpick={(path, project) =>
void moveTask(view.app, task, { path, project })}
path={task.path}
project={task.project}
>
{#snippet trigger()}
<span
class="hue-chip max-w-32 truncate rounded-md border px-1.5 py-0.5 text-[11px]"
style="--hue: {labelHue(task.project)}"
>
{task.project}
</span>
{/snippet}
</LanePicker>
{/if}
{#if editing}
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
class="min-w-0 flex-1 rounded-md border border-ring bg-background px-1.5 py-0.5 text-sm outline-none"
onblur={commitEdit}
{onkeydown}
bind:value={draft}
/>
{:else}
<div
class={cn(
"bcal-md min-w-0 flex-1 cursor-text truncate font-medium text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
!isOpen(task.status) && "line-through decoration-muted-foreground/50",
)}
onclick={(event) => {
if (!followLink(view.app, event, task.path)) startEdit();
}}
onkeydown={(event) => {
if (event.key === "Enter") startEdit();
}}
role="button"
tabindex="0"
title={task.text}
use:markdown={{
app: view.app,
component: view.component,
sourcePath: task.path,
text: task.text,
}}
></div>
{/if}
</div>
<div class="flex items-center gap-1">
{#if task.priority !== 2}
<PriorityBars class="shrink-0" priority={task.priority} />
{/if}
<div class="flex justify-end @min-[22rem]:w-24">
<DuePicker
class={cn(
!date &&
"opacity-0 transition-opacity group-hover/task:opacity-100 focus-visible:opacity-100 touch-shown",
)}
{field}
onclear={() => void setDate(view.app, task, field, null)}
onpick={(key) => void setDate(view.app, task, field, key)}
onrepeat={(rule) => void setRecurrence(view.app, task, rule)}
recurrence={task.recurrence}
value={date}
/>
</div>
<RowMenu
class="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/task:opacity-100 touch-shown"
items={taskMenu(view, task)}
/>
</div>
</div>
</ContextArea>
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import ArrowDownWideNarrow from "@lucide/svelte/icons/arrow-down-wide-narrow";
import CalendarDays from "@lucide/svelte/icons/calendar-days";
import CircleCheck from "@lucide/svelte/icons/circle-check";
import Group from "@lucide/svelte/icons/group";
import List from "@lucide/svelte/icons/list";
import PanelLeft from "@lucide/svelte/icons/panel-left";
import Plus from "@lucide/svelte/icons/plus";
import Search from "@lucide/svelte/icons/search";
import { cn } from "../lib/utils";
import type { GroupBy, SortBy, ViewMode } from "../settings";
import Picker from "./picker.svelte";
import { useView } from "./context";
interface Props {
oncreate: () => void;
}
let { oncreate }: Props = $props();
const view = useView();
const store = view.store;
const VIEWS: { icon: typeof List; id: ViewMode; label: string }[] = [
{ icon: List, id: "list", label: "List" },
{ icon: CalendarDays, id: "calendar", label: "Calendar" },
];
const GROUPS: { label: string; value: GroupBy }[] = [
{ label: "Spheres", value: "sphere" },
{ label: "Projects", value: "project" },
{ label: "Status", value: "status" },
{ label: "Due", value: "due" },
];
const SORTS = $derived(
[
{ label: "Manual", value: "manual" as SortBy },
{ label: "Priority", value: "priority" as SortBy },
{ label: "Due", value: "due" as SortBy },
].filter((option) => store.view === "list" || option.value !== "due"),
);
const PILL =
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 font-medium text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring";
</script>
<div
class="flex shrink-0 items-center gap-2 overflow-x-auto border-border border-b px-3 py-2 scrollbar-none"
>
<button
aria-label="Toggle spheres"
aria-pressed={!store.railCollapsed}
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
onclick={() => {
store.railCollapsed = !store.railCollapsed;
store.savePrefs();
}}
type="button"
>
<PanelLeft aria-hidden="true" class="size-3.5" />
</button>
<div
class="flex shrink-0 items-center gap-0.5 rounded-4xl border border-border p-0.5"
role="tablist"
>
{#each VIEWS as mode (mode.id)}
<button
aria-selected={store.view === mode.id}
class="inline-flex h-6 items-center gap-1.5 rounded-4xl px-2.5 font-medium text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring aria-selected:bg-primary aria-selected:text-primary-foreground"
onclick={() => {
store.view = mode.id;
store.savePrefs();
}}
role="tab"
type="button"
>
<mode.icon aria-hidden="true" class="size-3.5" />
<span class="hidden @min-[34rem]:inline">{mode.label}</span>
</button>
{/each}
</div>
{#if store.view === "list"}
<Picker
label="Group by"
onselect={(value) => {
store.groupBy = value as GroupBy;
store.savePrefs();
}}
options={GROUPS}
triggerClass={PILL}
value={store.groupBy}
>
{#snippet trigger()}
<Group aria-hidden="true" class="size-3.5" />
<span class="hidden @min-[40rem]:inline">
{GROUPS.find((item) => item.value === store.groupBy)?.label}
</span>
{/snippet}
</Picker>
{/if}
<Picker
label="Sort by"
onselect={(value) => {
store.sortBy = value as SortBy;
}}
options={SORTS}
triggerClass={PILL}
value={store.sortBy}
>
{#snippet trigger()}
<ArrowDownWideNarrow aria-hidden="true" class="size-3.5" />
<span class="hidden @min-[40rem]:inline">
{SORTS.find((item) => item.value === store.sortBy)?.label}
</span>
{/snippet}
</Picker>
<button
aria-label="Show completed"
aria-pressed={store.showDone}
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
onclick={() => {
store.showDone = !store.showDone;
store.savePrefs();
}}
title="Show completed"
type="button"
>
<CircleCheck aria-hidden="true" class="size-3.5" />
</button>
<label
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 focus-within:ring-2 focus-within:ring-ring"
>
<Search aria-hidden="true" class="size-3.5 text-muted-foreground" />
<input
class="w-24 min-w-0 bg-transparent text-xs outline-none transition-[width] placeholder:text-muted-foreground focus:w-40"
placeholder="Search"
type="text"
bind:value={store.search}
/>
</label>
<button
aria-label="New task"
class={cn(PILL, "ml-auto bg-primary text-primary-foreground hover:bg-primary/90")}
onclick={oncreate}
type="button"
>
<Plus aria-hidden="true" class="size-3.5" />
<span class="hidden @min-[34rem]:inline">Task</span>
</button>
</div>
+146
View File
@@ -0,0 +1,146 @@
import { findLane, laneInsertPoint, parseBoard } from "../model/board";
import type { Task } from "../model/types";
export class StaleTaskError extends Error {
constructor(public readonly task: Task) {
super(`line no longer present in ${task.path}`);
this.name = "StaleTaskError";
}
}
export class MissingLaneError extends Error {
constructor(
public readonly path: string,
public readonly project: string,
) {
super(`no lane "${project}" in ${path}`);
this.name = "MissingLaneError";
}
}
export function locate(lines: string[], task: Task): number {
if (lines[task.line] === task.raw) return task.line;
let best = -1;
let bestDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < lines.length; i += 1) {
if (lines[i] !== task.raw) continue;
const distance = Math.abs(i - task.line);
if (distance < bestDistance) {
best = i;
bestDistance = distance;
}
}
if (best === -1) throw new StaleTaskError(task);
return best;
}
export function extent(lines: string[], at: number, task: Task): number {
let count = 1;
while (count <= task.body.length && lines[at + count] === task.body[count - 1]) {
count += 1;
}
return count;
}
export function replaceLine(lines: string[], task: Task, text: string): void {
const at = locate(lines, task);
lines.splice(at, 1, ...text.split("\n"));
}
export function cutTask(lines: string[], task: Task): string[] {
const at = locate(lines, task);
const block = lines.splice(at, extent(lines, at, task));
while (lines[at] === "" && lines[at - 1] === "" && lines[at + 1] !== undefined) {
lines.splice(at, 1);
}
return block;
}
export interface InsertTarget {
project: string;
index?: number;
}
export function insertBlock(
lines: string[],
path: string,
target: InsertTarget,
block: string[],
): void {
const board = parseBoard(path, lines.join("\n"));
const lane = findLane(board, target.project);
if (!lane) throw new MissingLaneError(path, target.project);
const siblings = board.tasks.filter(
(task) => task.project === target.project && !task.inArchive,
);
const index = target.index ?? siblings.length;
const at =
index < siblings.length ? siblings[index].line : laneInsertPoint(board, lane);
lines.splice(at, 0, ...block);
}
export function applyReorder(
lines: string[],
inLane: Task[],
ordered: Task[],
): void {
if (inLane.length < 2 || ordered.length !== inLane.length) return;
const slots = inLane.map((task) => {
const at = locate(lines, task);
return { at, size: extent(lines, at, task) };
});
const blocks = ordered.map((task) => {
const at = locate(lines, task);
return lines.slice(at, at + extent(lines, at, task));
});
for (let i = slots.length - 1; i >= 0; i -= 1) {
lines.splice(slots[i].at, slots[i].size);
}
for (let i = 0; i < slots.length; i += 1) {
lines.splice(slots[i].at, 0, ...blocks[i]);
}
}
const SETTINGS_OPEN = /^%%\s*kanban:settings\s*$/u;
export function applyArchive(
lines: string[],
path: string,
archiveHeading: string,
): number {
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[][] = [];
for (const task of [...done].reverse()) {
const at = locate(lines, task);
blocks.unshift(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;
while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1;
lines.splice(tail, 0, "", "***", "", heading, "");
archiveAt = tail + 3;
}
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;
}
+266
View File
@@ -0,0 +1,266 @@
import { Notice, TFile } from "obsidian";
import type { App } from "obsidian";
import { serializeTaskLine } from "../model/serialize";
import { NORMAL_PRIORITY } from "../model/types";
import type { Board, DateField, Priority, Status, Task } from "../model/types";
import { tasksApi, warnMissingTasks } from "../tasks-api";
import { today } from "../lib/due";
import {
applyArchive,
applyReorder,
cutTask,
extent,
insertBlock,
locate,
MissingLaneError,
replaceLine,
StaleTaskError,
} from "./edits";
async function edit(
app: App,
path: string,
mutate: (lines: string[]) => void,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
new Notice(`Beaver Calendar: file not found - ${path}`, 6000);
return false;
}
let ok = true;
await app.vault.process(file, (data) => {
const lines = data.split("\n");
try {
mutate(lines);
} catch (err) {
ok = false;
if (err instanceof StaleTaskError) {
new Notice(
"Beaver Calendar: the file changed, the edit was not applied",
6000,
);
return data;
}
if (err instanceof MissingLaneError) {
new Notice(
`Beaver Calendar: no lane "${err.project}" in ${err.path}`,
6000,
);
return data;
}
throw err;
}
return lines.join("\n");
});
return ok;
}
export async function setDate(
app: App,
task: Task,
field: DateField,
value: string | null,
): Promise<void> {
await edit(app, task.path, (lines) => {
replaceLine(lines, task, serializeTaskLine({ ...task, [field]: value }));
});
}
export async function setText(
app: App,
task: Task,
text: string,
): Promise<void> {
const trimmed = text.trim();
if (!trimmed || trimmed === task.text) return;
await edit(app, task.path, (lines) => {
replaceLine(lines, task, serializeTaskLine({ ...task, text: trimmed }));
});
}
export async function setPriority(
app: App,
task: Task,
priority: Priority,
): Promise<void> {
await edit(app, task.path, (lines) => {
replaceLine(lines, task, serializeTaskLine({ ...task, priority }));
});
}
export async function setRecurrence(
app: App,
task: Task,
recurrence: string | null,
): Promise<void> {
await edit(app, task.path, (lines) => {
replaceLine(lines, task, serializeTaskLine({ ...task, recurrence }));
});
}
export async function setStatus(
app: App,
task: Task,
status: Status,
): Promise<void> {
const wasClosed = task.status === "x" || task.status === "-";
const willClose = status === "x" || status === "-";
if (!wasClosed && willClose) {
const api = tasksApi(app);
if (api && status === "x") {
const replacement = api.executeToggleTaskDoneCommand(task.raw, task.path);
await edit(app, task.path, (lines) => {
replaceLine(lines, task, replacement);
});
return;
}
if (!api) warnMissingTasks();
const stamp = today();
const next =
status === "x"
? { ...task, status, done: stamp }
: { ...task, status, cancelled: stamp };
await edit(app, task.path, (lines) => {
replaceLine(lines, task, serializeTaskLine(next));
});
return;
}
const next = willClose
? { ...task, status }
: { ...task, status, done: null, cancelled: null };
await edit(app, task.path, (lines) => {
replaceLine(lines, task, serializeTaskLine(next));
});
}
export async function replaceRaw(
app: App,
task: Task,
raw: string,
): Promise<void> {
await edit(app, task.path, (lines) => {
replaceLine(lines, task, raw);
});
}
export interface MoveTarget {
path: string;
project: string;
index?: number;
}
export async function moveTask(
app: App,
task: Task,
target: MoveTarget,
): Promise<void> {
if (task.path === target.path) {
await edit(app, task.path, (lines) => {
const block = cutTask(lines, task);
insertBlock(lines, task.path, target, block);
});
return;
}
const source = app.vault.getAbstractFileByPath(task.path);
if (!(source instanceof TFile)) {
new Notice(`Beaver Calendar: file not found - ${task.path}`, 6000);
return;
}
const lines = (await app.vault.read(source)).split("\n");
let block: string[];
try {
const at = locate(lines, task);
block = lines.slice(at, at + extent(lines, at, task));
} catch {
new Notice("Beaver Calendar: the file changed, the edit was not applied", 6000);
return;
}
const pasted = await edit(app, target.path, (into) => {
insertBlock(into, target.path, target, block);
});
if (!pasted) return;
await edit(app, task.path, (from) => {
cutTask(from, task);
});
}
export async function reorderLane(
app: App,
board: Board,
project: string,
ids: string[],
): Promise<void> {
const inLane = board.tasks.filter(
(task) => task.project === project && !task.inArchive,
);
if (inLane.length < 2) return;
const byId = new Map(inLane.map((task) => [task.id, task]));
const ordered = ids
.map((id) => byId.get(id))
.filter((task): task is Task => task !== undefined);
if (ordered.length !== inLane.length) return;
await edit(app, board.path, (lines) => {
applyReorder(lines, inLane, ordered);
});
}
export async function createTask(
app: App,
path: string,
project: string,
text: string,
due: string | null,
): Promise<void> {
const trimmed = text.trim();
if (!trimmed) return;
const line = serializeTaskLine({
indent: "",
status: " ",
text: trimmed,
priority: NORMAL_PRIORITY,
recurrence: null,
created: null,
start: null,
scheduled: null,
due,
cancelled: null,
done: null,
dependsOn: [],
taskId: null,
onCompletion: null,
blockLink: null,
});
await edit(app, path, (lines) => {
insertBlock(lines, path, { project }, [line]);
});
}
export async function appendRawTask(
app: App,
path: string,
project: string,
raw: string,
): Promise<void> {
await edit(app, path, (lines) => {
insertBlock(lines, path, { project }, raw.split("\n"));
});
}
export async function archiveDone(
app: App,
path: string,
archiveHeading: string,
): Promise<number> {
let moved = 0;
await edit(app, path, (lines) => {
moved = applyArchive(lines, path, archiveHeading);
});
return moved;
}
+358
View File
@@ -0,0 +1,358 @@
import { TFile } from "obsidian";
import type { App } from "obsidian";
import { parseBoard, sphereOf } from "../model/board";
import { anchorDate, isOpen, STATUS_ORDER } from "../model/types";
import type { Board, Status, Task } from "../model/types";
import { daysFromToday, localKey } from "../lib/due";
import { laneKey } from "../lib/keys";
import type {
BeaverCalendarSettings,
GroupBy,
SortBy,
ViewMode,
} from "../settings";
const DAY = 86_400_000;
export interface Group {
key: string;
label: string;
hint: string;
rank: number;
tasks: Task[];
lane: { path: string; project: string } | null;
}
export interface DaySlot {
date: Date;
key: string;
inMonth: boolean;
isToday: boolean;
tasks: Task[];
}
function dueBucket(key: string | null): { key: string; label: string; rank: number } {
if (!key) return { key: "none", label: "No date", rank: 5 };
const diff = daysFromToday(key);
if (diff < 0) return { key: "overdue", label: "Overdue", rank: 0 };
if (diff === 0) return { key: "today", label: "Today", rank: 1 };
if (diff === 1) return { key: "tomorrow", label: "Tomorrow", rank: 2 };
if (diff <= 7) return { key: "week", label: "This week", rank: 3 };
return { key: "later", label: "Later", rank: 4 };
}
const STATUS_LABEL: Record<Status, string> = {
" ": "Todo",
"/": "In progress",
x: "Done",
"-": "Cancelled",
};
function compareTasks(sortBy: SortBy, a: Task, b: Task): number {
if (sortBy === "priority") return b.priority - a.priority;
if (sortBy === "due") {
return (anchorDate(a) ?? "￿").localeCompare(anchorDate(b) ?? "￿");
}
return 0;
}
export class BoardStore {
private readonly boards = new Map<string, Board>();
private reloadTimer: number | null = null;
private readonly pending = new Set<string>();
version = $state(0);
view = $state<ViewMode>("list");
groupBy = $state<GroupBy>("sphere");
listSortBy = $state<SortBy>("due");
calendarSortBy = $state<SortBy>("priority");
showDone = $state(false);
includeArchive = $state(false);
railCollapsed = $state(false);
undatedCollapsed = $state(false);
search = $state("");
sphereFilter = $state<string | null>(null);
projectFilter = $state<string | null>(null);
statusFilter = $state<Status[]>([]);
monthKey = $state(localKey(new Date()));
selected = $state<string | null>(null);
constructor(
private readonly app: App,
private settings: BeaverCalendarSettings,
private readonly persist: () => void,
) {
this.view = settings.view;
this.groupBy = settings.groupBy;
this.listSortBy = settings.listSort;
this.calendarSortBy = settings.calendarSort;
this.showDone = settings.showDone;
this.includeArchive = settings.includeArchive;
this.railCollapsed = settings.railCollapsed;
this.undatedCollapsed = settings.undatedCollapsed;
}
get sortBy(): SortBy {
return this.view === "calendar" ? this.calendarSortBy : this.listSortBy;
}
set sortBy(value: SortBy) {
if (this.view === "calendar") {
this.calendarSortBy = value;
this.settings.calendarSort = value;
} else {
this.listSortBy = value;
this.settings.listSort = value;
}
this.persist();
}
savePrefs(): void {
this.settings.view = this.view;
this.settings.groupBy = this.groupBy;
this.settings.showDone = this.showDone;
this.settings.includeArchive = this.includeArchive;
this.settings.railCollapsed = this.railCollapsed;
this.settings.undatedCollapsed = this.undatedCollapsed;
this.persist();
}
updateSettings(settings: BeaverCalendarSettings): void {
this.settings = settings;
}
private inScope(path: string): boolean {
const folder = this.settings.boardsFolder;
if (!folder) return path.endsWith(".md");
return path.startsWith(`${folder}/`) && path.endsWith(".md");
}
async reloadAll(): Promise<void> {
this.boards.clear();
const files = this.app.vault
.getMarkdownFiles()
.filter((file) => this.inScope(file.path));
await Promise.all(files.map((file) => this.load(file)));
this.version += 1;
}
private async load(file: TFile): Promise<void> {
const content = await this.app.vault.cachedRead(file);
this.boards.set(file.path, parseBoard(file.path, content));
}
queueReload(path: string): void {
if (!this.inScope(path)) return;
this.pending.add(path);
if (this.reloadTimer !== null) window.clearTimeout(this.reloadTimer);
this.reloadTimer = window.setTimeout(() => {
this.reloadTimer = null;
void this.flush();
}, 150);
}
private async flush(): Promise<void> {
const paths = [...this.pending];
this.pending.clear();
for (const path of paths) {
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
this.boards.delete(path);
continue;
}
const content = await this.app.vault.cachedRead(file);
this.boards.set(path, parseBoard(path, content));
}
this.version += 1;
}
drop(path: string): void {
if (this.boards.delete(path)) this.version += 1;
}
boardFor(path: string): Board | undefined {
return this.boards.get(path);
}
all = $derived.by<Task[]>(() => {
void this.version;
const out: Task[] = [];
for (const board of this.boards.values()) {
for (const task of board.tasks) {
if (this.includeArchive || !task.inArchive) out.push(task);
}
}
return out;
});
spheres = $derived.by(() => {
void this.version;
return [...this.boards.values()]
.map((board) => ({
path: board.path,
name: board.sphere,
projects: board.lanes
.filter((lane) => !lane.inArchive)
.map((lane) => lane.name),
}))
.sort((a, b) => a.name.localeCompare(b.name));
});
private matches(task: Task): boolean {
if (!this.showDone && !isOpen(task.status)) return false;
if (this.statusFilter.length > 0 && !this.statusFilter.includes(task.status)) {
return false;
}
const query = this.search.trim().toLowerCase();
if (query) {
const haystack =
`${task.sphere} ${task.project} ${task.text}`.toLowerCase();
if (!haystack.includes(query)) return false;
}
return true;
}
visible = $derived.by<Task[]>(() =>
this.all
.filter((task) => this.matches(task))
.filter(
(task) =>
(!this.sphereFilter || task.path === this.sphereFilter) &&
(!this.projectFilter || task.project === this.projectFilter),
)
.sort(
(a, b) =>
Number(isOpen(b.status)) - Number(isOpen(a.status)) ||
compareTasks(this.sortBy, a, b),
),
);
inScopeCount = $derived.by(() => {
const byPath = new Map<string, number>();
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);
byProject.set(key, (byProject.get(key) ?? 0) + 1);
}
return { byPath, byProject };
});
groups = $derived.by<Group[]>(() => {
const buckets = new Map<string, Group>();
for (const task of this.visible) {
const slot = this.slotFor(task);
let bucket = buckets.get(slot.key);
if (!bucket) {
bucket = { ...slot, tasks: [], lane: null };
buckets.set(slot.key, bucket);
}
bucket.tasks.push(task);
}
const groups = [...buckets.values()].sort(
(a, b) => a.rank - b.rank || a.label.localeCompare(b.label),
);
for (const group of groups) {
const first = group.tasks[0];
const single = group.tasks.every(
(task) => task.path === first.path && task.project === first.project,
);
group.lane = single
? { path: first.path, project: first.project }
: null;
}
return groups;
});
private slotFor(task: Task): Omit<Group, "tasks" | "lane"> {
if (this.groupBy === "project") {
return {
key: laneKey(task.path, task.project),
label: task.project,
hint: task.sphere,
rank: 0,
};
}
if (this.groupBy === "status") {
return {
key: task.status,
label: STATUS_LABEL[task.status],
hint: "",
rank: STATUS_ORDER.indexOf(task.status),
};
}
if (this.groupBy === "due") {
const bucket = dueBucket(anchorDate(task));
return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank };
}
return { key: task.path, label: task.sphere, hint: "", rank: 0 };
}
byDay = $derived.by(() => {
const map = new Map<string, Task[]>();
for (const task of this.visible) {
const key = anchorDate(task);
if (!key) continue;
const list = map.get(key);
if (list) list.push(task);
else map.set(key, [task]);
}
return map;
});
undated = $derived.by(() => this.visible.filter((task) => !anchorDate(task)));
monthDays = $derived.by<DaySlot[]>(() => {
const anchor = new Date(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 = localKey(new Date());
const byDay = this.byDay;
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start.getTime() + index * DAY);
date.setHours(0, 0, 0, 0);
const key = localKey(date);
return {
date,
key,
inMonth: date.getMonth() === first.getMonth(),
isToday: key === todayKey,
tasks: byDay.get(key) ?? [],
};
});
});
monthWeeks = $derived.by<DaySlot[][]>(() => {
const weeks: DaySlot[][] = [];
for (let i = 0; i < 42; i += 7) weeks.push(this.monthDays.slice(i, i + 7));
return weeks;
});
agendaDays = $derived.by<DaySlot[]>(() =>
this.monthDays.filter((day) => day.inMonth && day.tasks.length > 0),
);
monthLabel = $derived.by(() =>
new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
}).format(new Date(this.monthKey)),
);
stepMonth(delta: number): void {
const anchor = new Date(this.monthKey);
this.monthKey = localKey(
new Date(anchor.getFullYear(), anchor.getMonth() + delta, 1),
);
}
goToday(): void {
this.monthKey = localKey(new Date());
}
sphereName(path: string): string {
return this.boards.get(path)?.sphere ?? sphereOf(path);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { ItemView } from "obsidian";
import type { WorkspaceLeaf } from "obsidian";
import { mount, unmount } from "svelte";
import App from "./ui/app.svelte";
import type BeaverCalendarPlugin from "./main";
export const VIEW_TYPE_BOARDS = "beaver-calendar-boards";
export class BoardsView extends ItemView {
private root: Record<string, unknown> | null = null;
constructor(
leaf: WorkspaceLeaf,
private readonly plugin: BeaverCalendarPlugin,
) {
super(leaf);
this.navigation = false;
}
getViewType(): string {
return VIEW_TYPE_BOARDS;
}
getDisplayText(): string {
return "Tasks";
}
getIcon(): string {
return "calendar-check";
}
async onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.style.padding = "0";
this.contentEl.style.overflow = "hidden";
await this.plugin.store.reloadAll();
this.root = mount(App, {
target: this.contentEl,
props: {
app: this.app,
store: this.plugin.store,
settings: this.plugin.settings,
component: this,
},
});
}
async onClose(): Promise<void> {
if (this.root) {
await unmount(this.root);
this.root = null;
}
this.contentEl.empty();
}
}
+272
View File
@@ -0,0 +1,272 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBoard } from "../src/model/board";
import { serializeTaskLine } from "../src/model/serialize";
import type { Task } from "../src/model/types";
import {
applyArchive,
applyReorder,
cutTask,
insertBlock,
locate,
MissingLaneError,
replaceLine,
StaleTaskError,
} from "../src/vault/edits";
const FIXTURES = join(import.meta.dirname, "fixtures");
function board(name: string) {
const content = readFileSync(join(FIXTURES, name), "utf8");
return { content, board: parseBoard(`Boards/${name}`, content) };
}
function landmarks(text: string) {
const lines = text.split("\n");
return {
settings: lines.filter((line) => line.startsWith("%% kanban:settings")).length,
fences: lines.filter((line) => line.trim() === "***").length,
headings: lines.filter((line) => line.startsWith("## ")),
frontmatter: text.startsWith("---\n")
? text.slice(0, text.indexOf("\n---\n", 4))
: "",
};
}
function edited(name: string, mutate: (lines: string[]) => void) {
const { content } = board(name);
const lines = content.split("\n");
mutate(lines);
return lines.join("\n");
}
describe("locate", () => {
it("finds a line that has shifted", () => {
const { board: parsed } = board("personal.md");
const task = parsed.tasks.find((item) => !item.inArchive) as Task;
const lines = readFileSync(join(FIXTURES, "personal.md"), "utf8").split("\n");
lines.unshift("", "", "");
expect(locate(lines, task)).toBe(task.line + 3);
});
it("refuses when the line is gone", () => {
const { board: parsed } = board("personal.md");
expect(() => locate(["nothing", "here"], parsed.tasks[0])).toThrow(
StaleTaskError,
);
});
it("picks the nearest of several identical lines", () => {
const { board: parsed } = board("work.md");
const counts = new Map<string, Task[]>();
for (const task of parsed.tasks) {
const list = counts.get(task.raw) ?? [];
list.push(task);
counts.set(task.raw, list);
}
const dupes = [...counts.values()].find((list) => list.length > 1);
expect(dupes, "work.md should carry duplicate archived lines").toBeDefined();
const lines = readFileSync(join(FIXTURES, "work.md"), "utf8").split("\n");
for (const task of dupes as Task[]) {
expect(locate(lines, task)).toBe(task.line);
}
});
});
describe("replaceLine", () => {
it("changes only the target line", () => {
const { content, board: parsed } = board("personal.md");
const task = parsed.tasks.find(
(item) => item.text === "renew the gym membership",
) as Task;
expect(task).toBeDefined();
const next = edited("personal.md", (lines) => {
replaceLine(lines, task, serializeTaskLine({ ...task, due: "2026-09-01" }));
});
const before = content.split("\n");
const after = next.split("\n");
expect(after).toHaveLength(before.length);
expect(after.filter((line, index) => line !== before[index])).toEqual([
"- [ ] renew the gym membership 📅 2026-09-01",
]);
});
it("expands a recurring toggle into two lines", () => {
const { board: parsed } = board("personal.md");
const task = parsed.tasks.find(
(item) => !item.inArchive && item.recurrence,
) as Task;
const next = edited("personal.md", (lines) => {
replaceLine(
lines,
task,
`- [ ] ${task.text} 🔁 every day 📅 2026-08-09\n- [x] ${task.text} 🔁 every day 📅 2026-08-08 ✅ 2026-08-08`,
);
});
expect(next).toContain("📅 2026-08-09");
expect(next).toContain("✅ 2026-08-08");
expect(landmarks(next).settings).toBe(1);
});
});
describe("cutTask and insertBlock", () => {
it("moves a card between lanes of the same board", () => {
const { content, board: parsed } = board("personal.md");
const task = parsed.tasks.find(
(item) => !item.inArchive && item.project === "errands",
) as Task;
const next = edited("personal.md", (lines) => {
const block = cutTask(lines, task);
insertBlock(lines, "personal.md", { project: "someday" }, block);
});
const reparsed = parseBoard("personal.md", next);
const moved = reparsed.tasks.find((item) => item.text === task.text) as Task;
expect(moved.project).toBe("someday");
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
const before = landmarks(content);
const after = landmarks(next);
expect(after.settings).toBe(before.settings);
expect(after.fences).toBe(before.fences);
expect(after.headings).toEqual(before.headings);
});
it("carries a multi-line card body along", () => {
const { content, board: parsed } = board("work.md");
const task = parsed.tasks.find((item) => item.body.length > 1) as Task;
expect(task).toBeDefined();
const lines = content.split("\n");
const before = lines.length;
const block = cutTask(lines, task);
expect(block).toHaveLength(1 + task.body.length);
expect(block.slice(1)).toEqual(task.body);
expect(lines.join("\n")).not.toContain(block.join("\n"));
expect(before - lines.length).toBeGreaterThanOrEqual(block.length);
});
it("reports a missing lane instead of writing", () => {
const lines = board("personal.md").content.split("\n");
expect(() =>
insertBlock(lines, "personal.md", { project: "nope" }, ["- [ ] x"]),
).toThrow(MissingLaneError);
});
it("appends after the last card, before the lane's blank tail", () => {
const next = edited("personal.md", (lines) => {
insertBlock(lines, "personal.md", { project: "errands" }, [
"- [ ] new one 📅 2026-08-08",
]);
});
const lane = parseBoard("personal.md", next).tasks.filter(
(item) => item.project === "errands" && !item.inArchive,
);
expect(lane[lane.length - 1].text).toBe("new one");
expect(landmarks(next).settings).toBe(1);
});
});
describe("applyReorder", () => {
it("reverses a lane without disturbing the rest of the file", () => {
const { content, board: parsed } = board("personal.md");
const inLane = parsed.tasks.filter(
(item) => item.project === "errands" && !item.inArchive,
);
expect(inLane.length).toBeGreaterThan(2);
const next = edited("personal.md", (lines) => {
applyReorder(lines, inLane, [...inLane].reverse());
});
const after = parseBoard("personal.md", next)
.tasks.filter((item) => item.project === "errands" && !item.inArchive)
.map((item) => item.text);
expect(after).toEqual([...inLane].reverse().map((item) => item.text));
expect(next.split("\n").sort()).toEqual(content.split("\n").sort());
expect(landmarks(next)).toEqual(landmarks(content));
});
});
describe("applyArchive", () => {
it("moves done cards under the archive heading", () => {
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" }),
);
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`, () => {
const { content, board: parsed } = board(name);
const lines = content.split("\n");
for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
replaceLine(
lines,
task,
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
);
}
applyArchive(lines, name, "Archive");
const next = lines.join("\n");
const reparsed = parseBoard(name, next);
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true);
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.headings, `${heading} must survive`).toContain(heading);
}
});
}
});
+17
View File
@@ -0,0 +1,17 @@
---
kanban-plugin: board
---
## repairs
- [ ] replace the kitchen tap 📅 2026-08-20
- [ ] fix the squeaky door
%% kanban:settings
```
{"kanban-plugin":"board","list-collapse":[false]}
```
%%
+34
View File
@@ -0,0 +1,34 @@
---
kanban-plugin: board
---
## errands
- [ ] renew the gym membership 📅 2026-08-08
- [ ] book a dentist appointment ⏳ 2026-08-09 📅 2026-08-12
- [ ] water the plants 🔁 every day 📅 2026-08-08
- [ ] call [[Alex Rivera|Alex]] about the move 📅 2026-08-09
## someday
- [ ] learn to bake sourdough
- [ ] read **Thinking in Systems**
***
## Archive
- [x] pick up the parcel 📅 2026-07-30 ✅ 2026-07-30
- [x] pay the electricity bill ⏳ 2026-07-28 📅 2026-07-29 ✅ 2026-07-28
- [x] water the plants 🔁 every day 📅 2026-07-27 ✅ 2026-07-27
- [x] water the plants 🔁 every day 📅 2026-07-26 ✅ 2026-07-27
%% kanban:settings
```
{"kanban-plugin":"board","list-collapse":[false,false]}
```
%%
+24
View File
@@ -0,0 +1,24 @@
---
kanban-plugin: board
---
## queue
- [ ] ![[cover.png]]
- [ ] compare the pricing tiers https://example.com/pricing#plans
- [ ] finish the chapter on caching 📅 2026-08-15
***
## Archive
- [x] skim the release notes ✅ 2026-07-15
%% kanban:settings
```
{"kanban-plugin":"board","list-collapse":[false,false]}
```
%%
+43
View File
@@ -0,0 +1,43 @@
---
planner:
log: []
kanban-plugin: board
TQ_explain:
TQ_short_mode:
TQ_show_due_date:
TQ_show_tags:
---
## website
- [ ] fix the mobile nav overlap ⏫ 📅 2026-08-10
- [ ] compress the hero images https://example.com/tools/images
## backlog
## notes
- [x] rewrite the onboarding copy ✅ 2026-08-01
- shorter intro
- drop the second screenshot
- link to the docs instead
- [x] audit the third-party scripts ✅ 2026-07-31
follow-up: check the analytics bundle size
***
## Archive
- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21
- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21
%% kanban:settings
```
{"kanban-plugin":"board","list-collapse":[false,false,false,false]}
```
%%
+57
View File
@@ -0,0 +1,57 @@
export class TFile {
constructor(
public path: string,
public extension = "md",
) {}
}
export class TFolder {
constructor(public path: string) {}
}
export const notices: string[] = [];
export class Notice {
constructor(message: string) {
notices.push(message);
}
hide(): void {}
}
export class Plugin {}
export class ItemView {}
export class PluginSettingTab {}
export class Setting {}
export class Component {}
export const moment = () => undefined;
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export const MarkdownRenderer = {
async render(
_app: unknown,
markdown: string,
el: HTMLElement,
_sourcePath: string,
_component: unknown,
): Promise<void> {
const html = escapeHtml(markdown)
.replace(
/!?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_full, target: string, alias?: string) =>
`<a class="internal-link" href="${target}">${alias ?? target}</a>`,
)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(
/(^|\s)(https?:\/\/[^\s]+)/g,
(_full, lead: string, url: string) =>
`${lead}<a class="external-link" href="${url}">${url}</a>`,
);
el.innerHTML = `<p>${html}</p>`;
},
};
+106
View File
@@ -0,0 +1,106 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBoard, renderBoard } from "../src/model/board";
import { serializeTaskLine } from "../src/model/serialize";
const FIXTURES = join(import.meta.dirname, "fixtures");
const BOARDS = readdirSync(FIXTURES).filter((name) => name.endsWith(".md"));
describe("board round-trip", () => {
it("has boards to work with", () => {
expect(BOARDS.length).toBeGreaterThan(0);
});
for (const name of BOARDS) {
const content = readFileSync(join(FIXTURES, name), "utf8");
const board = parseBoard(`Boards/${name}`, content);
it(`${name}: re-renders byte for byte`, () => {
expect(renderBoard(board)).toBe(content);
});
it(`${name}: every task line survives parse then serialize`, () => {
for (const task of board.tasks) {
expect(serializeTaskLine(task), `line ${task.line + 1}`).toBe(task.raw);
}
});
it(`${name}: finds tasks and lanes`, () => {
expect(board.lanes.length).toBeGreaterThan(0);
expect(board.tasks.length).toBeGreaterThan(0);
for (const task of board.tasks) {
expect(board.lines[task.line]).toBe(task.raw);
}
});
}
});
describe("field extraction", () => {
const parse = (line: string) => {
const board = parseBoard("x.md", `## lane\n\n${line}\n`);
return board.tasks[0];
};
it("reads recurrence followed by dates", () => {
const task = parse("- [ ] water the plants 🔁 every day 📅 2026-08-08");
expect(task.text).toBe("water the plants");
expect(task.recurrence).toBe("every day");
expect(task.due).toBe("2026-08-08");
expect(task.scheduled).toBeNull();
});
it("reads scheduled, due and done together", () => {
const task = parse(
"- [x] pay the electricity bill ⏳ 2026-07-28 📅 2026-07-29 ✅ 2026-07-28",
);
expect(task.status).toBe("x");
expect(task.text).toBe("pay the electricity bill");
expect(task.scheduled).toBe("2026-07-28");
expect(task.due).toBe("2026-07-29");
expect(task.done).toBe("2026-07-28");
});
it("keeps wikilinks and embeds inside the description", () => {
const task = parse("- [ ] call [[Alex Rivera|Alex]] about the move 📅 2026-08-09");
expect(task.text).toBe("call [[Alex Rivera|Alex]] about the move");
expect(task.due).toBe("2026-08-09");
});
it("does not mistake a url fragment for a tag", () => {
const task = parse("- [ ] compare tiers https://example.com/pricing#plans");
expect(task.tags).toEqual([]);
expect(task.text).toBe("compare tiers https://example.com/pricing#plans");
});
it("reads priority and writes it back in Tasks' order", () => {
const task = parse("- [ ] fix the mobile nav ⏫ 📅 2026-08-10");
expect(task.priority).toBe(4);
expect(serializeTaskLine(task)).toBe("- [ ] fix the mobile nav ⏫ 📅 2026-08-10");
});
it("captures a multi-line kanban card body", () => {
const board = parseBoard(
"x.md",
"## lane\n\n- [x] rewrite the copy ✅ 2026-08-01\n\t- shorter intro\n\t- link the docs\n",
);
expect(board.tasks[0].body).toEqual(["\t- shorter intro", "\t- link the docs"]);
});
it("marks archive lanes", () => {
const board = parseBoard(
"x.md",
"## live\n\n- [ ] a\n\n***\n\n## Archive\n\n- [x] b ✅ 2026-01-01\n",
);
expect(board.tasks[0].inArchive).toBe(false);
expect(board.tasks[1].inArchive).toBe(true);
});
it("ignores anything inside the kanban settings block", () => {
const board = parseBoard(
"x.md",
'## lane\n\n- [ ] real\n\n%% kanban:settings\n```\n{"kanban-plugin":"board"}\n```\n%%\n',
);
expect(board.tasks).toHaveLength(1);
});
});
+203
View File
@@ -0,0 +1,203 @@
import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import esbuild from "esbuild";
import sveltePlugin from "esbuild-svelte";
import { JSDOM } from "jsdom";
const root = new URL("..", import.meta.url).pathname;
const fixtures = join(root, "tests/fixtures");
const FOLDER = "Boards";
const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs");
writeFileSync(
join(root, "tests/.entry.ts"),
`export { default as App } from "../src/ui/app.svelte";
export { BoardStore } from "../src/vault/store.svelte";
export { mount, unmount, flushSync } from "svelte";
export { TFile, Component } from "obsidian";
`,
);
await esbuild.build({
entryPoints: [join(root, "tests/.entry.ts")],
bundle: true,
format: "esm",
target: "es2022",
outfile: out,
logLevel: "error",
conditions: ["svelte", "browser"],
mainFields: ["svelte", "browser", "module", "main"],
alias: { obsidian: join(root, "tests/obsidian-stub.ts") },
plugins: [sveltePlugin({ compilerOptions: { css: "injected", runes: true } })],
});
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (key.startsWith("_")) continue;
if (globalThis[key] !== undefined) continue;
try {
globalThis[key] = dom.window[key];
} catch {
/* getter-only window properties are fine to skip */
}
}
globalThis.window = dom.window;
globalThis.document = dom.window.document;
dom.window.Element.prototype.getBoundingClientRect = () => ({
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
toJSON: () => ({}),
});
dom.window.Element.prototype.setPointerCapture = () => {};
dom.window.Element.prototype.releasePointerCapture = () => {};
dom.window.Element.prototype.hasPointerCapture = () => false;
dom.window.Element.prototype.scrollIntoView = () => {};
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
const { App, BoardStore, mount, unmount, flushSync, TFile, Component } =
await import(pathToFileURL(out).href);
const names = readdirSync(fixtures).filter((name) => name.endsWith(".md"));
const files = new Map(
names.map((name) => [
`${FOLDER}/${name}`,
readFileSync(join(fixtures, name), "utf8"),
]),
);
const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)]));
const writes = [];
const app = {
vault: {
getMarkdownFiles: () => [...handles.values()],
getAbstractFileByPath: (path) => handles.get(path) ?? null,
cachedRead: async (file) => files.get(file.path),
read: async (file) => files.get(file.path),
process: async (file, fn) => {
const next = fn(files.get(file.path));
writes.push({ path: file.path, content: next });
files.set(file.path, next);
return next;
},
on: () => ({}),
},
workspace: { getLeaf: () => ({}), activeEditor: null },
plugins: { plugins: {} },
};
const settings = {
boardsFolder: FOLDER,
archiveHeading: "Archive",
view: "list",
groupBy: "sphere",
listSort: "due",
calendarSort: "priority",
showDone: false,
includeArchive: false,
railCollapsed: false,
undatedCollapsed: false,
};
const failures = [];
function check(name, condition, detail = "") {
if (condition) console.log(` ok ${name}`);
else {
failures.push(name);
console.log(` FAIL ${name}${detail ? ` -- ${detail}` : ""}`);
}
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
const store = new BoardStore(app, settings, () => {});
await store.reloadAll();
// Every task outside the archive, done ones included; the done filter
// applies to `visible`, not to the pool.
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}`);
const target = dom.window.document.body;
const instance = mount(App, {
target,
props: { app, store, settings, component: new Component() },
});
flushSync();
await settle();
flushSync();
const text = () => target.textContent ?? "";
check("list rendered a task", text().includes("renew the gym membership"));
check("sphere rail rendered", text().includes("All spheres"));
check("group header rendered", text().includes("personal"));
check("project chip rendered", text().includes("errands"));
check("no crash markers", !text().includes("undefined"));
const links = target.querySelectorAll("a.internal-link");
check("wikilink rendered as a link", links.length > 0, `${links.length} links`);
check("alias is displayed, not the target", text().includes("Alex"));
check("no raw wikilink syntax leaked", !text().includes("[["));
store.includeArchive = true;
store.showDone = true;
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"));
store.includeArchive = false;
flushSync();
check("archive hidden again", !text().includes("pick up the parcel"));
store.showDone = false;
flushSync();
store.view = "calendar";
flushSync();
await settle();
check("calendar rendered a month label", /\d{4}/.test(store.monthLabel));
check("calendar rendered the undated tray", text().includes("No date"));
store.view = "list";
store.groupBy = "due";
flushSync();
const BUCKETS = ["Overdue", "Today", "Tomorrow", "This week", "Later", "No date"];
check(
"due grouping produced known buckets",
store.groups.length > 0 && store.groups.every((g) => BUCKETS.includes(g.label)),
store.groups.map((g) => g.label).join(", "),
);
const button = target.querySelector('button[aria-label^="Status"]');
check("status button present", button !== null);
if (button) {
button.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
await settle();
check("clicking status wrote to the vault", writes.length > 0, `${writes.length}`);
if (writes.length > 0) {
const after = writes[writes.length - 1];
const before = readFileSync(
join(fixtures, after.path.slice(FOLDER.length + 1)),
"utf8",
).split("\n");
const now = after.content.split("\n");
const diff = now.filter((line, index) => line !== before[index]);
check("exactly one line changed", diff.length === 1, JSON.stringify(diff));
check("line is now done", diff[0]?.startsWith("- [x] "), diff[0]);
check("done date stamped", /✅ \d{4}-\d{2}-\d{2}$/u.test(diff[0] ?? ""), diff[0]);
check("settings block intact", after.content.includes("%% kanban:settings"));
}
}
await unmount(instance);
console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`);
process.exit(failures.length === 0 ? 0 : 1);
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const source = readFileSync(
join(import.meta.dirname, "../src/tailwind.css"),
"utf8",
);
function block(selector: string): string {
const at = source.indexOf(`\n${selector} {`);
if (at === -1) return "";
const open = source.indexOf("{", at);
const close = source.indexOf("\n}", open);
return source.slice(open, close);
}
describe("theme aliases", () => {
const OBSIDIAN_VARS = [
"--background-primary",
"--background-secondary",
"--background-modifier-border",
"--text-normal",
"--text-muted",
"--color-accent",
"--text-on-accent",
"--text-error",
];
it("are declared on .bcal-root, not :root", () => {
const root = block(".bcal-root");
expect(root).not.toBe("");
for (const name of OBSIDIAN_VARS) {
expect(root, `${name} must be aliased on .bcal-root`).toContain(name);
}
});
it("never reference Obsidian variables from :root", () => {
const rootBlocks = [...source.matchAll(/(^|\n):root\s*\{([^}]*)\}/g)];
for (const [, , body] of rootBlocks) {
for (const name of OBSIDIAN_VARS) {
expect(body, `:root must not reference ${name}`).not.toContain(name);
}
}
});
it("scopes the dark override under the view root", () => {
expect(source).toContain(".theme-dark .bcal-root");
expect(source).not.toMatch(/\n\.theme-dark\s*\{/);
});
});
describe("reset specificity", () => {
it("does not use :where() for element resets", () => {
expect(source).not.toMatch(/\.bcal-root\s+:where\(/);
});
it("uses a doubled class to outrank app and theme styles", () => {
expect(source).toContain(".bcal-root.bcal-root");
});
it("neutralises the native search affordance", () => {
expect(source).toContain("-webkit-search-cancel-button");
});
});
describe("cascade against Obsidian", () => {
it("marks utilities important so they outrank app.css", () => {
expect(source).toMatch(
/@import "tailwindcss\/utilities\.css"[^;]*\bimportant\b/,
);
});
it("keeps the element reset out of a cascade layer", () => {
const reset = source.indexOf(".bcal-root.bcal-root :is(button");
expect(reset).toBeGreaterThan(-1);
const before = source.slice(0, reset);
const opens = (before.match(/@layer[^;{]*\{/g) ?? []).length;
const closes = (before.match(/\n\}/g) ?? []).length;
expect(opens, "reset must be unlayered").toBeLessThanOrEqual(closes);
});
});
describe("preflight stays out", () => {
it("imports only the theme and utility layers", () => {
expect(source).toContain('@import "tailwindcss/theme.css"');
expect(source).toContain('@import "tailwindcss/utilities.css"');
expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/);
expect(source).not.toMatch(/@import\s+"tailwindcss\/preflight/);
});
});
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2022",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "Bundler",
"importHelpers": true,
"isolatedModules": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"types": ["node", "svelte"],
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"paths": {
"$lib/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.svelte", "tests/**/*.ts"]
}
+3
View File
@@ -0,0 +1,3 @@
{
"0.1.0": "1.5.0"
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
// ``obsidian`` is provided by the host app at runtime and has no
// installable implementation, so tests link against a stub.
alias: {
obsidian: new URL("./tests/obsidian-stub.ts", import.meta.url).pathname,
},
},
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});