Compare commits
6
Commits
b70331f7bd
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3424222dab | ||
|
|
e7178c6d3f | ||
|
|
b415b17007 | ||
|
|
fa36710f0c | ||
|
|
f980abcc1d | ||
|
|
f7ccd21d7b |
No files matched your search
@@ -1,9 +1,11 @@
|
||||
# beaver-calendar
|
||||
[](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.
|
||||

|
||||
|
||||

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