Compare commits

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

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

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

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

The preview had no viewport meta, so a 390px window still laid out at 980px:
every "mobile" screenshot was a desktop one at a phone's aspect, and
coarse-pointer rules could not apply. Which is why the tap targets looked
fine here and were not.
2026-09-05 00:47:21 +02:00
16 changed files with 425 additions and 74 deletions

No files matched your search

+5 -3
View File
@@ -1,9 +1,11 @@
# beaver-calendar # beaver-calendar
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net) [![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
Calendar and task list for Obsidian Kanban+Tasks A calendar and a task list for Obsidian on top of Kanban boards and the Tasks plugin. A sphere is a board file, a project is an h2 inside it; every task keeps living in its board, the plugin only shows them together - by deadline, by sphere, or on a month grid - and lets you drag, edit and close them without opening the board.
Sphere - md-file in Kanban boards directory. Project - h2 inside the file. ![The Calendar tab: a month with tasks by day, the undated ones below](docs/calendar.webp)
![The List tab: tasks grouped by deadline, the rail with spheres and projects on the left](docs/list.webp)
## Archiving ## Archiving
@@ -49,5 +51,5 @@ VAULT=/path/to/vault npm run preview # the vault's theme
BOARDS=/path/to/vault/Boards npm run preview # real boards, not fixtures BOARDS=/path/to/vault/Boards npm run preview # real boards, not fixtures
EXTRA_CSS=/path/to/other-plugin/styles.css … # another plugin's css, loaded after ours EXTRA_CSS=/path/to/other-plugin/styles.css … # another plugin's css, loaded after ours
open 'http://localhost:4173/?groupBy=due&railCollapsed=1#edit-date' # prefs + scene open 'http://localhost:4173/?groupBy=due&railCollapsed=1#edit-date' # prefs + scene
node preview/shot.mjs out.png 'http://localhost:4173/#date' # screenshot + console node preview/shot.mjs out.png 'http://localhost:4173/#cal' # screenshot + console
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+11
View File
@@ -105,6 +105,17 @@ async function main(): Promise<void> {
} }
if (scene === "rail-none") store.filterNone(); if (scene === "rail-none") store.filterNone();
} }
// The month grid on its own, opened on the month the fixtures are busiest in.
if (scene === "cal") {
const months = new Map<string, number>();
for (const item of store.visible) {
const key = (item.due ?? item.scheduled)?.slice(0, 7);
if (key) months.set(key, (months.get(key) ?? 0) + 1);
}
const busiest = [...months.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
store.view = "calendar";
if (busiest) store.focusDay(`${busiest}-15`);
}
if (scene.startsWith("peek")) { if (scene.startsWith("peek")) {
if (scene === "peek-cal") store.view = "calendar"; if (scene === "peek-cal") store.view = "calendar";
if (scene === "peek-notes") store.showDone = true; if (scene === "peek-notes") store.showDone = true;
+3
View File
@@ -2,6 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<!-- Without this a phone-sized window still lays out at 980px, so a
"mobile" screenshot is not one and coarse-pointer rules never apply. -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>beaver-calendar preview</title> <title>beaver-calendar preview</title>
<link rel="stylesheet" href="/app.css" /> <link rel="stylesheet" href="/app.css" />
<link rel="stylesheet" href="/theme.css" /> <link rel="stylesheet" href="/theme.css" />
+8 -4
View File
@@ -29,10 +29,14 @@ export function shiftDays(key: string, days: number): string {
return localKey(date); return localKey(date);
} }
/** Milliseconds until the next local midnight, with a little slack past it. */ export function monthGrid(month: Date): Date[] {
export function untilMidnight(now = new Date()): number { const year = month.getFullYear();
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); const index = month.getMonth();
return Math.max(1000, next.getTime() - now.getTime() + 500); const offset = (new Date(year, index, 1).getDay() + 6) % 7;
return Array.from(
{ length: 42 },
(_, cell) => new Date(year, index, 1 - offset + cell),
);
} }
export function daysFromToday(key: string, from = today()): number { export function daysFromToday(key: string, from = today()): number {
+54
View File
@@ -0,0 +1,54 @@
/**
* How much of the view's bottom is covered by something the operator cannot
* move: Obsidian's desktop status bar, a phone's navbar (which paints over a
* leaf instead of shortening it), and the software keyboard, which shrinks the
* visual viewport while the layout keeps its full height.
*
* Pure on purpose - the view feeds it live rectangles, the tests feed it the
* numbers a phone actually reports.
*/
export interface Rect {
bottom: number;
height: number;
left: number;
right: number;
top: number;
}
export interface Visible {
/** `visualViewport.height`: what is left once the keyboard is up. */
height: number;
/** `visualViewport.offsetTop`: where the visible area starts. */
offsetTop: number;
}
/** Bars that float over a leaf rather than making room for it. */
export const BOTTOM_BARS = [
".status-bar",
".mobile-navbar",
".mobile-toolbar",
] as const;
export function insetFor(
leaf: Rect,
bars: Rect[],
visible: Visible | null,
): number {
if (leaf.height <= 0) return 0;
let overlap = 0;
for (const bar of bars) {
// A bar that does not sit over this leaf horizontally covers nothing of
// it - the desktop status bar beside a left-hand sidedock, say.
if (bar.height > 0 && bar.left < leaf.right && bar.right > leaf.left) {
overlap = Math.max(overlap, leaf.bottom - bar.top);
}
}
if (visible) {
// While the keyboard is up the navbar is behind it: the deeper of the two
// is the inset, and summing them would push the view twice as far.
overlap = Math.max(overlap, leaf.bottom - (visible.offsetTop + visible.height));
}
return Math.max(0, Math.round(overlap));
}
+24 -2
View File
@@ -24,13 +24,35 @@ export interface Viewport {
/** /**
* What is actually visible, which on a phone is not the window: the software * What is actually visible, which on a phone is not the window: the software
* keyboard shrinks the visual viewport and leaves `innerHeight` alone, so a * keyboard shrinks the visual viewport and leaves `innerHeight` alone, so a
* panel placed against the window lands behind the keys. * panel placed against the window lands behind the keys. The navbar is not in
* the viewport at all - it floats over it - so it is subtracted by hand.
*/ */
export function viewport(win: Window = window): Viewport { export function viewport(win: Window = window): Viewport {
const vv = win.visualViewport; const vv = win.visualViewport;
return vv const base: Viewport = vv
? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft } ? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft }
: { width: win.innerWidth, height: win.innerHeight }; : { width: win.innerWidth, height: win.innerHeight };
return { ...base, height: base.height - floating(win, base) };
}
/**
* How much of the bottom of `view` a floating bar covers. Obsidian's phone
* navbar paints over every leaf, so a panel that ends under it cannot be
* reached even though it is inside the viewport.
*/
function floating(win: Window, view: Viewport): number {
const doc = win.document;
const bottom = (view.top ?? 0) + view.height;
let covered = 0;
for (const selector of [".mobile-navbar", ".mobile-toolbar", ".status-bar"]) {
const node = doc.querySelector(selector) as HTMLElement | null;
if (!node || node.offsetParent === null) continue;
const box = node.getBoundingClientRect();
if (box.height > 0 && box.top < bottom) {
covered = Math.max(covered, bottom - box.top);
}
}
return Math.min(covered, Math.max(0, view.height - 120));
} }
/** The tallest a panel may be here before it has to scroll its own body. */ /** The tallest a panel may be here before it has to scroll its own body. */
+5
View File
@@ -102,7 +102,12 @@
} }
.bcal-root { .bcal-root {
box-sizing: border-box;
height: 100%; height: 100%;
/* The phone's navbar floats over the leaf rather than shortening it, so the
last row of the list would sit underneath it. The view measures how much
and sets this; on a desktop it stays 0. */
padding-bottom: var(--bcal-inset-bottom, 0px);
font-family: var(--font-interface); font-family: var(--font-interface);
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--foreground); color: var(--foreground);
+56 -10
View File
@@ -27,8 +27,18 @@
const view = useView(); const view = useView();
// iOS animates the keyboard in; the viewport only tells the truth after. // The keyboard animates both ways: rather than guess how long that takes,
const KEYBOARD_SETTLE_MS = 350; // the panel re-places every frame until its position holds still. Long enough
// to outlast Obsidian hiding its navbar, which moves the ground a second time.
const STEADY_FRAMES = 12;
/** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */
const KEYBOARD_EVENTS = [
"keyboardWillShow",
"keyboardDidShow",
"keyboardWillHide",
"keyboardDidHide",
];
let panel = $state<HTMLElement | null>(null); let panel = $state<HTMLElement | null>(null);
let at = $state({ top: 0, left: 0 }); let at = $state({ top: 0, left: 0 });
@@ -74,14 +84,46 @@
if (!node.contains(event.target as Node)) onclose(); if (!node.contains(event.target as Node)) onclose();
}; };
const reflow = (): void => measure(); const reflow = (): void => measure();
/**
* Re-place every frame until the position has held still for a while. The
* keyboard animates, and Obsidian hides its navbar *after* it is up, which
* moves the ground under the panel a second time - a short "steady" run
* exits between the two and leaves the panel where neither wanted it. In a
* sidedock no resize event fires at all, so a frame loop is the only thing
* that notices either move.
*/
let frame: number | null = null;
const settle = (): void => {
let last = Number.NaN;
let steady = 0;
if (frame !== null) win.cancelAnimationFrame(frame);
const tick = (): void => {
measure();
steady = at.top === last ? steady + 1 : 0;
last = at.top;
if (steady >= STEADY_FRAMES) {
frame = null;
return;
}
frame = win.requestAnimationFrame(tick);
};
frame = win.requestAnimationFrame(tick);
};
document.addEventListener("pointerdown", outside, true); document.addEventListener("pointerdown", outside, true);
window.addEventListener("resize", reflow); window.addEventListener("resize", reflow);
// The keyboard changes the visual viewport without resizing the window, // The keyboard changes the visual viewport without resizing the window.
// and its animation takes a moment to settle.
const vv = win.visualViewport; const vv = win.visualViewport;
vv?.addEventListener("resize", reflow); vv?.addEventListener("resize", settle);
vv?.addEventListener("scroll", reflow); vv?.addEventListener("scroll", settle);
const settle = win.setTimeout(reflow, KEYBOARD_SETTLE_MS); // Obsidian's own keyboard events (Capacitor, mobile only): they fire before
// the animation and again as the navbar comes back, which `visualViewport`
// on its own does not always report.
for (const name of KEYBOARD_EVENTS) {
win.addEventListener(name, settle);
} // The panel's own input takes focus a beat before the keyboard is up, and
// swapping fields re-opens it.
node.addEventListener("focusin", settle);
settle();
// A panel whose body grew (an option list opened) re-places itself. Only // A panel whose body grew (an option list opened) re-places itself. Only
// the content height is watched: measure() sets max-height, so observing // the content height is watched: measure() sets max-height, so observing
// the panel itself would feed its own change back in. // the panel itself would feed its own change back in.
@@ -97,9 +139,13 @@
destroy() { destroy() {
document.removeEventListener("pointerdown", outside, true); document.removeEventListener("pointerdown", outside, true);
window.removeEventListener("resize", reflow); window.removeEventListener("resize", reflow);
vv?.removeEventListener("resize", reflow); vv?.removeEventListener("resize", settle);
vv?.removeEventListener("scroll", reflow); vv?.removeEventListener("scroll", settle);
win.clearTimeout(settle); for (const name of KEYBOARD_EVENTS) {
win.removeEventListener(name, settle);
}
node.removeEventListener("focusin", settle);
if (frame !== null) win.cancelAnimationFrame(frame);
watcher?.disconnect(); watcher?.disconnect();
win.removeEventListener("keydown", keys, true); win.removeEventListener("keydown", keys, true);
panel = null; panel = null;
+8 -16
View File
@@ -5,7 +5,7 @@
<script lang="ts"> <script lang="ts">
import ChevronLeft from "@lucide/svelte/icons/chevron-left"; import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ChevronRight from "@lucide/svelte/icons/chevron-right"; import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { fromKey, localKey } from "../lib/due"; import { fromKey, localKey, monthGrid } from "../lib/due";
import { WEEKDAYS } from "../lib/vocab"; import { WEEKDAYS } from "../lib/vocab";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -20,7 +20,6 @@
let { anchor, tone, onpick, onhover, today }: Props = $props(); let { anchor, tone, onpick, onhover, today }: Props = $props();
const DAY = 86_400_000;
const monthName = new Intl.DateTimeFormat("en-US", { month: "long" }); const monthName = new Intl.DateTimeFormat("en-US", { month: "long" });
const todayKey = $derived(today ?? localKey(new Date())); const todayKey = $derived(today ?? localKey(new Date()));
@@ -32,20 +31,13 @@
return new Date(base.getFullYear(), base.getMonth(), 1); return new Date(base.getFullYear(), base.getMonth(), 1);
}); });
const days = $derived.by(() => { const days = $derived(
const offset = (month.getDay() + 6) % 7; monthGrid(month).map((date) => ({
const start = new Date(month.getTime() - offset * DAY); date,
start.setHours(0, 0, 0, 0); inMonth: date.getMonth() === month.getMonth(),
return Array.from({ length: 42 }, (_, index) => { key: localKey(date),
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) { function shiftMonth(delta: number) {
picked = new Date(month.getFullYear(), month.getMonth() + delta, 1); picked = new Date(month.getFullYear(), month.getMonth() + delta, 1);
+23 -30
View File
@@ -4,7 +4,7 @@ import { isArchivePath, toBoardPath } from "../model/archive";
import { parseBoard, sphereOf } from "../model/board"; import { parseBoard, sphereOf } from "../model/board";
import { anchorDate, isOpen, STATUS_ORDER } from "../model/types"; import { anchorDate, isOpen, STATUS_ORDER } from "../model/types";
import type { Board, Status, Task } from "../model/types"; import type { Board, Status, Task } from "../model/types";
import { daysFromToday, fromKey, localKey, shiftDays, untilMidnight } from "../lib/due"; import { daysFromToday, fromKey, localKey, monthGrid, shiftDays } from "../lib/due";
import { laneKey, parseLaneKey } from "../lib/keys"; import { laneKey, parseLaneKey } from "../lib/keys";
import type { import type {
BeaverCalendarSettings, BeaverCalendarSettings,
@@ -13,7 +13,6 @@ import type {
ViewMode, ViewMode,
} from "../settings"; } from "../settings";
const DAY = 86_400_000;
export interface Group { export interface Group {
key: string; key: string;
@@ -197,37 +196,35 @@ export class BoardStore {
this.settings = settings; this.settings = settings;
} }
/** Re-reads the wall clock; a no-op while the day has not turned. */ /**
* Re-reads the wall clock; a no-op while the day has not turned. A cursor
* left on the old today, and a month showing it, follow the day over.
*/
tick(): void { tick(): void {
const now = localKey(new Date()); const now = localKey(new Date());
if (now !== this.today) this.today = now; const was = this.today;
if (now === was) return;
this.today = now;
if (this.cursorDay === was && this.cursorTask === null) this.cursorDay = now;
if (this.monthKey.slice(0, 7) === was.slice(0, 7)) this.monthKey = now;
} }
/** /**
* Keeps `today` honest for as long as the view lives: a timer for the * Keeps `today` honest for as long as the view lives. The clock is polled
* midnight crossing, and a re-read when the window wakes up, since a * rather than timed to midnight: Chromium's timers stop while a Mac sleeps,
* sleeping laptop fires no timers. * so a midnight timeout fires hours late, and a window that stayed focused
* through the sleep sends no focus event on waking. Waking still re-reads
* at once where the events do come.
*/ */
watchClock(): () => void { watchClock(): () => void {
let timer = 0; const tick = (): void => this.tick();
const arm = (): void => { const timer = window.setInterval(tick, 30_000);
window.clearTimeout(timer); window.addEventListener("focus", tick);
timer = window.setTimeout(() => { document.addEventListener("visibilitychange", tick);
this.tick();
arm();
}, untilMidnight());
};
const wake = (): void => {
this.tick();
arm();
};
arm();
window.addEventListener("focus", wake);
document.addEventListener("visibilitychange", wake);
return () => { return () => {
window.clearTimeout(timer); window.clearInterval(timer);
window.removeEventListener("focus", wake); window.removeEventListener("focus", tick);
document.removeEventListener("visibilitychange", wake); document.removeEventListener("visibilitychange", tick);
}; };
} }
@@ -451,13 +448,9 @@ export class BoardStore {
monthDays = $derived.by<DaySlot[]>(() => { monthDays = $derived.by<DaySlot[]>(() => {
const anchor = fromKey(this.monthKey); const anchor = fromKey(this.monthKey);
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1); const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const offset = (first.getDay() + 6) % 7;
const start = new Date(first.getTime() - offset * DAY);
const todayKey = this.today; const todayKey = this.today;
const byDay = this.byDay; const byDay = this.byDay;
return Array.from({ length: 42 }, (_, index) => { return monthGrid(first).map((date) => {
const date = new Date(start.getTime() + index * DAY);
date.setHours(0, 0, 0, 0);
const key = localKey(date); const key = localKey(date);
return { return {
date, date,
+93
View File
@@ -1,13 +1,29 @@
import { ItemView } from "obsidian"; import { ItemView } from "obsidian";
import type { WorkspaceLeaf } from "obsidian"; import type { WorkspaceLeaf } from "obsidian";
import { mount, unmount } from "svelte"; import { mount, unmount } from "svelte";
import { BOTTOM_BARS, insetFor } from "./lib/inset";
import type { Rect } from "./lib/inset";
import App from "./ui/app.svelte"; import App from "./ui/app.svelte";
import type BeaverCalendarPlugin from "./main"; import type BeaverCalendarPlugin from "./main";
export const VIEW_TYPE_BOARDS = "beaver-calendar-boards"; export const VIEW_TYPE_BOARDS = "beaver-calendar-boards";
// The keyboard and the navbar both animate, and Obsidian hides the navbar
// *after* the keyboard is up: the inset is re-measured every frame until the
// reading has held still long enough to outlast both moves.
const STEADY_FRAMES = 12;
/** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */
const KEYBOARD_EVENTS = [
"keyboardWillShow",
"keyboardDidShow",
"keyboardWillHide",
"keyboardDidHide",
] as const;
export class BoardsView extends ItemView { export class BoardsView extends ItemView {
private root: Record<string, unknown> | null = null; private root: Record<string, unknown> | null = null;
private settling: number | null = null;
constructor( constructor(
leaf: WorkspaceLeaf, leaf: WorkspaceLeaf,
@@ -51,6 +67,30 @@ export class BoardsView extends ItemView {
}), }),
); );
this.registerEvent(this.app.workspace.on("resize", () => this.settle()));
this.registerEvent(
this.app.workspace.on("layout-change", () => this.settle()),
);
// The keyboard shrinks the visual viewport without resizing the layout, so
// no workspace event fires - and in a sidedock none fires at all.
const vv = window.visualViewport;
if (vv) {
const onViewport = () => this.settle();
vv.addEventListener("resize", onViewport);
vv.addEventListener("scroll", onViewport);
this.register(() => {
vv.removeEventListener("resize", onViewport);
vv.removeEventListener("scroll", onViewport);
});
}
this.registerDomEvent(this.contentEl, "focusin", () => this.settle());
this.registerDomEvent(this.contentEl, "focusout", () => this.settle());
for (const name of KEYBOARD_EVENTS) {
const onKeyboard = () => this.settle();
window.addEventListener(name, onKeyboard);
this.register(() => window.removeEventListener(name, onKeyboard));
}
await this.plugin.store.reloadAll(); await this.plugin.store.reloadAll();
this.root = mount(App, { this.root = mount(App, {
target: this.contentEl, target: this.contentEl,
@@ -61,6 +101,59 @@ export class BoardsView extends ItemView {
component: this, component: this,
}, },
}); });
this.settle();
}
/**
* Re-measure until the viewport stops moving: the keyboard animates both
* ways, Obsidian hides its navbar while it is up, and a frame loop needs
* neither a duration to guess nor an event that a sidedock never sends.
*/
private settle(): void {
let last = Number.NaN;
let steady = 0;
// A fresh trigger restarts the watch rather than being dropped: the navbar
// coming back is a second move, after the first one has settled.
if (this.settling !== null) cancelAnimationFrame(this.settling);
const tick = () => {
const inset = this.inset();
steady = inset === last ? steady + 1 : 0;
last = inset;
if (steady >= STEADY_FRAMES) {
this.settling = null;
return;
}
this.settling = requestAnimationFrame(tick);
};
this.settling = requestAnimationFrame(tick);
this.register(() => {
if (this.settling !== null) {
cancelAnimationFrame(this.settling);
this.settling = null;
}
});
}
/** Pads the view clear of the navbar and the keyboard. */
private inset(): number {
const box = this.contentEl.getBoundingClientRect();
if (box.height <= 0) return Number.NaN;
const bars: Rect[] = [];
for (const selector of BOTTOM_BARS) {
const node = document.querySelector<HTMLElement>(selector);
if (node && node.offsetParent !== null) {
bars.push(node.getBoundingClientRect());
}
}
const vv = window.visualViewport;
const inset = insetFor(
box,
bars,
vv ? { height: vv.height, offsetTop: vv.offsetTop } : null,
);
this.contentEl.style.setProperty("--bcal-inset-bottom", `${inset}px`);
return inset;
} }
async onClose(): Promise<void> { async onClose(): Promise<void> {
+28 -8
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { import {
daysFromToday, daysFromToday,
dueLabel, dueLabel,
dueTone, dueTone,
localKey,
monthGrid,
nextFriday, nextFriday,
untilMidnight,
} from "../src/lib/due"; } from "../src/lib/due";
describe("due helpers read the day they are given", () => { describe("due helpers read the day they are given", () => {
@@ -26,12 +27,31 @@ describe("due helpers read the day they are given", () => {
expect(nextFriday("2026-08-23")).toBe("2026-08-28"); expect(nextFriday("2026-08-23")).toBe("2026-08-28");
expect(nextFriday("2026-08-28")).toBe("2026-09-04"); expect(nextFriday("2026-08-28")).toBe("2026-09-04");
}); });
});
it("arms the midnight timer for the coming midnight", () => { describe("monthGrid steps by calendar day", () => {
const late = new Date(2026, 7, 23, 23, 59, 0); const zone = process.env.TZ;
expect(untilMidnight(late)).toBeGreaterThan(60_000); beforeAll(() => {
expect(untilMidnight(late)).toBeLessThan(62_000); process.env.TZ = "Europe/Berlin";
const early = new Date(2026, 7, 23, 0, 0, 1); });
expect(untilMidnight(early)).toBeGreaterThan(23 * 3_600_000); afterAll(() => {
process.env.TZ = zone;
});
it("keeps every day once across the autumn clock change", () => {
const keys = monthGrid(new Date(2026, 9, 1)).map(localKey);
expect(keys).toHaveLength(42);
expect(new Set(keys).size).toBe(42);
expect(keys[0]).toBe("2026-09-28");
expect(keys[27]).toBe("2026-10-25");
expect(keys[28]).toBe("2026-10-26");
expect(keys[41]).toBe("2026-11-08");
});
it("keeps every day once across the spring clock change", () => {
const keys = monthGrid(new Date(2027, 2, 1)).map(localKey);
expect(new Set(keys).size).toBe(42);
expect(keys).toContain("2027-03-28");
expect(keys).toContain("2027-03-29");
}); });
}); });
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { BOTTOM_BARS, insetFor } from "../src/lib/inset";
import type { Rect } from "../src/lib/inset";
const rect = (over: Partial<Rect> = {}): Rect => ({
top: 0,
left: 0,
right: 390,
bottom: 780,
height: 780,
...over,
});
// A phone: 780 tall, a 56px navbar floating over the bottom, 340px of keyboard.
const NAVBAR = rect({ top: 724, bottom: 780, height: 56 });
describe("insetFor", () => {
it("clears nothing when the view stands alone", () => {
expect(insetFor(rect(), [], null)).toBe(0);
});
it("clears the navbar that paints over the view", () => {
expect(insetFor(rect(), [NAVBAR], null)).toBe(56);
});
it("clears the keyboard, which shortens no layout", () => {
expect(insetFor(rect(), [], { height: 440, offsetTop: 0 })).toBe(340);
});
it("takes the deeper of the two rather than their sum", () => {
// The navbar hides behind the keyboard while it is up.
expect(insetFor(rect(), [NAVBAR], { height: 440, offsetTop: 0 })).toBe(340);
});
it("comes back to the navbar once the keyboard goes", () => {
expect(insetFor(rect(), [NAVBAR], { height: 780, offsetTop: 0 })).toBe(56);
});
it("ignores a bar that sits beside the view, not over it", () => {
const sidedock = rect({ right: 300 });
const statusBar = rect({
top: 760,
bottom: 780,
height: 20,
left: 700,
right: 980,
});
expect(insetFor(sidedock, [statusBar], null)).toBe(0);
});
it("measures from where the visible area starts", () => {
expect(insetFor(rect(), [], { height: 440, offsetTop: 100 })).toBe(240);
});
it("says nothing about a view with no height yet", () => {
expect(insetFor(rect({ height: 0, bottom: 0 }), [NAVBAR], null)).toBe(0);
});
it("never returns a negative inset", () => {
const short = rect({ bottom: 300, height: 300 });
expect(insetFor(short, [], { height: 780, offsetTop: 0 })).toBe(0);
});
it("names the bars that float rather than make room", () => {
expect(BOTTOM_BARS).toContain(".mobile-navbar");
expect(BOTTOM_BARS).toContain(".status-bar");
});
});
+39 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { onScreen, place, roomFor } from "../src/lib/place"; import { onScreen, place, roomFor, viewport } from "../src/lib/place";
import type { Box } from "../src/lib/place"; import type { Box } from "../src/lib/place";
const VIEW = { width: 1280, height: 800 }; const VIEW = { width: 1280, height: 800 };
@@ -95,3 +95,41 @@ describe("roomFor", () => {
expect(roomFor({ width: 390, height: 60 })).toBeGreaterThan(100); expect(roomFor({ width: 390, height: 60 })).toBeGreaterThan(100);
}); });
}); });
// Obsidian's phone navbar is not part of the visual viewport - it floats over
// it - so a panel sized to the viewport still ends up underneath it.
describe("viewport minus the floating navbar", () => {
function fakeWin(navbarTop: number | null, height = 780): Window {
const bar = {
getBoundingClientRect: () => ({
top: navbarTop ?? 0,
bottom: height,
height: height - (navbarTop ?? 0),
left: 0,
right: 390,
}),
offsetParent: {},
};
return {
innerWidth: 390,
innerHeight: height,
visualViewport: { width: 390, height, offsetTop: 0, offsetLeft: 0 },
document: {
querySelector: (selector: string) =>
selector === ".mobile-navbar" && navbarTop !== null ? bar : null,
},
} as unknown as Window;
}
it("takes the navbar off the bottom", () => {
expect(viewport(fakeWin(724)).height).toBe(724);
});
it("leaves the height alone with no navbar in the way", () => {
expect(viewport(fakeWin(null)).height).toBe(780);
});
it("still leaves a usable strip if a bar somehow covers everything", () => {
expect(viewport(fakeWin(0)).height).toBeGreaterThanOrEqual(120);
});
});