feat(ui,store): live today clock, rail as lane filter, b toggle, quick look on space
This commit is contained in:
@@ -50,6 +50,46 @@ async function main(): Promise<void> {
|
|||||||
component: new Component() as never,
|
component: new Component() as never,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scenes for screenshots: open the page with a hash to land in a state.
|
||||||
|
const scene = location.hash.slice(1);
|
||||||
|
if (!scene) return;
|
||||||
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
await wait(50);
|
||||||
|
store.keyboard = true;
|
||||||
|
const first = store.spheres[0];
|
||||||
|
if (scene.startsWith("rail")) {
|
||||||
|
store.toggleRail();
|
||||||
|
store.railExpanded = new Set([first.path, store.spheres[1].path]);
|
||||||
|
if (scene === "rail-some") {
|
||||||
|
store.toggleSphere(store.spheres[2].path);
|
||||||
|
store.toggleLane(first.path, first.projects[0]);
|
||||||
|
store.cursorRail = `lane:${encodeURIComponent(first.path)} ${encodeURIComponent(first.projects[0])}`;
|
||||||
|
}
|
||||||
|
if (scene === "rail-none") store.filterNone();
|
||||||
|
}
|
||||||
|
if (scene.startsWith("peek")) {
|
||||||
|
if (scene === "peek-cal") store.view = "calendar";
|
||||||
|
if (scene === "peek-notes") store.showDone = true;
|
||||||
|
await wait(20);
|
||||||
|
const task = store.visible.find((item) => item.body.length > 0) ?? store.visible[0];
|
||||||
|
if (scene === "peek-cal") {
|
||||||
|
const key = task.due ?? task.scheduled;
|
||||||
|
if (key) store.focusDay(key);
|
||||||
|
}
|
||||||
|
store.zone = "task";
|
||||||
|
store.cursorTask = task.id;
|
||||||
|
await wait(50);
|
||||||
|
store.peek();
|
||||||
|
}
|
||||||
|
if (scene === "date") {
|
||||||
|
store.write(null, "[data-composer='toolbar']");
|
||||||
|
await wait(50);
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||||
|
await wait(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main();
|
void main();
|
||||||
|
|||||||
+20
-11
@@ -9,6 +9,11 @@ export function localKey(date: Date): string {
|
|||||||
return `${date.getFullYear()}-${month}-${day}`;
|
return `${date.getFullYear()}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wall clock, read now. Anything drawn on screen should take the key
|
||||||
|
* from the store's `today` instead, which is reactive and survives a view
|
||||||
|
* that stays open for weeks; this is for one-shot writes.
|
||||||
|
*/
|
||||||
export function today(): string {
|
export function today(): string {
|
||||||
return localKey(new Date());
|
return localKey(new Date());
|
||||||
}
|
}
|
||||||
@@ -24,16 +29,19 @@ export function shiftDays(key: string, days: number): string {
|
|||||||
return localKey(date);
|
return localKey(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function daysFromToday(key: string): number {
|
/** Milliseconds until the next local midnight, with a little slack past it. */
|
||||||
const target = fromKey(key);
|
export function untilMidnight(now = new Date()): number {
|
||||||
const now = new Date();
|
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||||
now.setHours(0, 0, 0, 0);
|
return Math.max(1000, next.getTime() - now.getTime() + 500);
|
||||||
return Math.round((target.getTime() - now.getTime()) / 86_400_000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dueLabel(key: string | null): string {
|
export function daysFromToday(key: string, from = today()): number {
|
||||||
|
return Math.round((fromKey(key).getTime() - fromKey(from).getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dueLabel(key: string | null, from = today()): string {
|
||||||
if (!key) return "";
|
if (!key) return "";
|
||||||
const diff = daysFromToday(key);
|
const diff = daysFromToday(key, from);
|
||||||
if (diff === 0) return "today";
|
if (diff === 0) return "today";
|
||||||
if (diff === 1) return "tomorrow";
|
if (diff === 1) return "tomorrow";
|
||||||
if (diff === -1) return "yesterday";
|
if (diff === -1) return "yesterday";
|
||||||
@@ -42,16 +50,17 @@ export function dueLabel(key: string | null): string {
|
|||||||
|
|
||||||
export type DueTone = "overdue" | "soon" | "normal" | "none";
|
export type DueTone = "overdue" | "soon" | "normal" | "none";
|
||||||
|
|
||||||
export function dueTone(key: string | null): DueTone {
|
export function dueTone(key: string | null, from = today()): DueTone {
|
||||||
if (!key) return "none";
|
if (!key) return "none";
|
||||||
const diff = daysFromToday(key);
|
const diff = daysFromToday(key, from);
|
||||||
if (diff < 0) return "overdue";
|
if (diff < 0) return "overdue";
|
||||||
if (diff <= 1) return "soon";
|
if (diff <= 1) return "soon";
|
||||||
return "normal";
|
return "normal";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nextFriday(): string {
|
/** Strictly the next one, so on a Friday it means the week after. */
|
||||||
const date = new Date();
|
export function nextFriday(from = today()): string {
|
||||||
|
const date = fromKey(from);
|
||||||
date.setDate(date.getDate() + ((5 - date.getDay() + 7) % 7 || 7));
|
date.setDate(date.getDate() + ((5 - date.getDay() + 7) % 7 || 7));
|
||||||
return localKey(date);
|
return localKey(date);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-5
@@ -19,6 +19,7 @@ export type Action =
|
|||||||
| "taskEdit"
|
| "taskEdit"
|
||||||
| "taskEditRaw"
|
| "taskEditRaw"
|
||||||
| "taskPriority"
|
| "taskPriority"
|
||||||
|
| "taskPeek"
|
||||||
| "taskBack"
|
| "taskBack"
|
||||||
| "taskForward"
|
| "taskForward"
|
||||||
| "taskWeekBack"
|
| "taskWeekBack"
|
||||||
@@ -32,6 +33,8 @@ export type Action =
|
|||||||
| "railFold"
|
| "railFold"
|
||||||
| "railUnfold"
|
| "railUnfold"
|
||||||
| "railChoose"
|
| "railChoose"
|
||||||
|
| "railToggleRow"
|
||||||
|
| "railLeave"
|
||||||
| "trayLeft"
|
| "trayLeft"
|
||||||
| "trayRight"
|
| "trayRight"
|
||||||
| "trayUp"
|
| "trayUp"
|
||||||
@@ -190,7 +193,7 @@ export const SHORTCUTS: Shortcut[] = [
|
|||||||
{
|
{
|
||||||
combos: ["b"],
|
combos: ["b"],
|
||||||
keys: ["b"],
|
keys: ["b"],
|
||||||
label: "To the spheres, and back",
|
label: "Spheres: open and in, or shut and out",
|
||||||
action: "toggleRail",
|
action: "toggleRail",
|
||||||
scopes: ["view"],
|
scopes: ["view"],
|
||||||
group: "Moving",
|
group: "Moving",
|
||||||
@@ -256,16 +259,24 @@ export const SHORTCUTS: Shortcut[] = [
|
|||||||
{
|
{
|
||||||
combos: ["enter"],
|
combos: ["enter"],
|
||||||
keys: ["⏎"],
|
keys: ["⏎"],
|
||||||
label: "Filter by the row, and step out",
|
label: "Only this row; again, everything",
|
||||||
action: "railChoose",
|
action: "railChoose",
|
||||||
scopes: ["rail"],
|
scopes: ["rail"],
|
||||||
group: "Panels",
|
group: "Panels",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
combos: ["space"],
|
||||||
|
keys: ["␣"],
|
||||||
|
label: "Flip the row, keep the rest",
|
||||||
|
action: "railToggleRow",
|
||||||
|
scopes: ["rail"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
combos: ["escape"],
|
combos: ["escape"],
|
||||||
keys: ["esc"],
|
keys: ["esc"],
|
||||||
label: "Leave the spheres",
|
label: "Back to the tasks",
|
||||||
action: "toggleRail",
|
action: "railLeave",
|
||||||
scopes: ["rail"],
|
scopes: ["rail"],
|
||||||
group: "Panels",
|
group: "Panels",
|
||||||
},
|
},
|
||||||
@@ -481,6 +492,14 @@ export const SHORTCUTS: Shortcut[] = [
|
|||||||
scopes: ["task"],
|
scopes: ["task"],
|
||||||
group: "The selected task",
|
group: "The selected task",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
combos: ["space"],
|
||||||
|
keys: ["␣"],
|
||||||
|
label: "Quick look",
|
||||||
|
action: "taskPeek",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
combos: ["enter"],
|
combos: ["enter"],
|
||||||
keys: ["⏎"],
|
keys: ["⏎"],
|
||||||
@@ -496,6 +515,7 @@ export const SHORTCUTS: Shortcut[] = [
|
|||||||
* Cyrillic layout where `event.key` reports "н" instead of "n".
|
* Cyrillic layout where `event.key` reports "н" instead of "n".
|
||||||
*/
|
*/
|
||||||
const BY_CODE: Record<string, string> = {
|
const BY_CODE: Record<string, string> = {
|
||||||
|
Space: "space",
|
||||||
BracketLeft: "[",
|
BracketLeft: "[",
|
||||||
BracketRight: "]",
|
BracketRight: "]",
|
||||||
Slash: "/",
|
Slash: "/",
|
||||||
@@ -523,7 +543,7 @@ function withModifiers(event: KeyboardEvent, key: string, shift: boolean): strin
|
|||||||
export function combos(event: KeyboardEvent): string[] {
|
export function combos(event: KeyboardEvent): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key === " " ? "space" : event.key.toLowerCase();
|
||||||
if (key !== "shift" && key !== "control" && key !== "meta" && key !== "alt") {
|
if (key !== "shift" && key !== "control" && key !== "meta" && key !== "alt") {
|
||||||
// Punctuation already carries the shift in the character itself.
|
// Punctuation already carries the shift in the character itself.
|
||||||
out.push(withModifiers(event, key, event.shiftKey && /^[a-z]$/u.test(key)));
|
out.push(withModifiers(event, key, event.shiftKey && /^[a-z]$/u.test(key)));
|
||||||
|
|||||||
@@ -243,6 +243,62 @@
|
|||||||
vertical-align: text-bottom;
|
vertical-align: text-bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The quick look reads the whole card, so here markdown keeps its blocks. */
|
||||||
|
.bcal-root.bcal-root .bcal-prose > * + * {
|
||||||
|
margin-top: 0.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose :is(ul, ol) {
|
||||||
|
padding-left: 1.25em;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose li + li {
|
||||||
|
margin-top: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose li.task-list-item {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: -1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose input.task-list-item-checkbox {
|
||||||
|
margin: 0 0.4em 0 0;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose a {
|
||||||
|
color: var(--link-color, var(--color-accent));
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose code {
|
||||||
|
padding: 0 0.25em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose :is(img, .internal-embed) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcal-root.bcal-root .bcal-prose blockquote {
|
||||||
|
padding-left: 0.75em;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
.bcal-root.bcal-root .bcal-dragging {
|
.bcal-root.bcal-root .bcal-dragging {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
@@ -261,6 +317,18 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A lane's gate in the rail: the project's hue as a filled check. */
|
||||||
|
@utility gate-hue {
|
||||||
|
color: white;
|
||||||
|
background: oklch(0.58 0.11 var(--hue));
|
||||||
|
border-color: oklch(0.58 0.11 var(--hue));
|
||||||
|
.theme-dark & {
|
||||||
|
background: oklch(0.66 0.11 var(--hue));
|
||||||
|
border-color: oklch(0.66 0.11 var(--hue));
|
||||||
|
color: oklch(0.2 0.02 var(--hue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@utility hue-chip {
|
@utility hue-chip {
|
||||||
color: oklch(0.45 0.13 var(--hue));
|
color: oklch(0.45 0.13 var(--hue));
|
||||||
background: oklch(0.6 0.09 var(--hue) / 0.12);
|
background: oklch(0.6 0.09 var(--hue) / 0.12);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import TaskCalendar from "./task-calendar.svelte";
|
import TaskCalendar from "./task-calendar.svelte";
|
||||||
import TaskEditor from "./task-editor.svelte";
|
import TaskEditor from "./task-editor.svelte";
|
||||||
import TaskList from "./task-list.svelte";
|
import TaskList from "./task-list.svelte";
|
||||||
|
import TaskPeek from "./task-peek.svelte";
|
||||||
import Toolbar from "./toolbar.svelte";
|
import Toolbar from "./toolbar.svelte";
|
||||||
import { handleKey } from "./keyboard";
|
import { handleKey } from "./keyboard";
|
||||||
|
|
||||||
@@ -47,8 +48,12 @@
|
|||||||
search = node;
|
search = node;
|
||||||
},
|
},
|
||||||
focusRoot: () => root?.focus({ preventScroll: true }),
|
focusRoot: () => root?.focus({ preventScroll: true }),
|
||||||
|
rootBox: () => root?.getBoundingClientRect() ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The view can stay open for weeks; the clock keeps "today" meaning today.
|
||||||
|
$effect(() => store.watchClock());
|
||||||
|
|
||||||
// Bound by hand rather than with onkeydown so the container stays a plain
|
// Bound by hand rather than with onkeydown so the container stays a plain
|
||||||
// element: the view has just been opened, so it may as well answer keys.
|
// element: the view has just been opened, so it may as well answer keys.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -92,6 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<CursorPicker />
|
<CursorPicker />
|
||||||
<TaskEditor />
|
<TaskEditor />
|
||||||
|
<TaskPeek />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if store.helpOpen}
|
{#if store.helpOpen}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface ViewContext {
|
|||||||
registerSearch: (node: HTMLInputElement | null) => void;
|
registerSearch: (node: HTMLInputElement | null) => void;
|
||||||
/** Hands the keyboard back to the view after a layer above it closes. */
|
/** Hands the keyboard back to the view after a layer above it closes. */
|
||||||
focusRoot: () => void;
|
focusRoot: () => void;
|
||||||
|
/** Where the view sits on screen, for layers that centre on it. */
|
||||||
|
rootBox: () => DOMRect | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = Symbol("beaver-calendar");
|
const KEY = Symbol("beaver-calendar");
|
||||||
|
|||||||
@@ -13,14 +13,16 @@
|
|||||||
anchor: string | null;
|
anchor: string | null;
|
||||||
onhover?: (key: string) => void;
|
onhover?: (key: string) => void;
|
||||||
onpick: (key: string) => void;
|
onpick: (key: string) => void;
|
||||||
|
/** The store's key for today, so the mark moves with the clock. */
|
||||||
|
today?: string;
|
||||||
tone: (key: string) => DayTone;
|
tone: (key: string) => DayTone;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { anchor, tone, onpick, onhover }: Props = $props();
|
let { anchor, tone, onpick, onhover, today }: Props = $props();
|
||||||
|
|
||||||
const DAY = 86_400_000;
|
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 = localKey(new Date());
|
const todayKey = $derived(today ?? localKey(new Date()));
|
||||||
|
|
||||||
let picked = $state<Date | null>(null);
|
let picked = $state<Date | null>(null);
|
||||||
|
|
||||||
|
|||||||
+34
-2
@@ -67,6 +67,14 @@ function stepTray(view: ViewContext, direction: Direction): void {
|
|||||||
if (direction === "up") store.toggleUndated();
|
if (direction === "up") store.toggleUndated();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one action as if its key had been pressed, so a button drawn anywhere
|
||||||
|
* does exactly what the sheet says the key does.
|
||||||
|
*/
|
||||||
|
export function perform(view: ViewContext, action: Action): Promise<void> {
|
||||||
|
return run(view, action);
|
||||||
|
}
|
||||||
|
|
||||||
async function run(view: ViewContext, action: Action): Promise<void> {
|
async function run(view: ViewContext, action: Action): Promise<void> {
|
||||||
const { app, store } = view;
|
const { app, store } = view;
|
||||||
const task = store.taskById(store.cursorTask);
|
const task = store.taskById(store.cursorTask);
|
||||||
@@ -100,7 +108,10 @@ async function run(view: ViewContext, action: Action): Promise<void> {
|
|||||||
else store.cursorTask = null;
|
else store.cursorTask = null;
|
||||||
return;
|
return;
|
||||||
case "taskOpen":
|
case "taskOpen":
|
||||||
if (task) await revealInFile(view, task);
|
if (task) {
|
||||||
|
store.peeking = false;
|
||||||
|
await revealInFile(view, task);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case "taskToggle":
|
case "taskToggle":
|
||||||
if (task) await setStatus(app, task, isOpen(task.status) ? "x" : " ");
|
if (task) await setStatus(app, task, isOpen(task.status) ? "x" : " ");
|
||||||
@@ -109,11 +120,17 @@ async function run(view: ViewContext, action: Action): Promise<void> {
|
|||||||
if (task) store.edit(task.id);
|
if (task) store.edit(task.id);
|
||||||
return;
|
return;
|
||||||
case "taskEditRaw":
|
case "taskEditRaw":
|
||||||
if (task) await editInTasks(view, task);
|
if (task) {
|
||||||
|
store.peeking = false;
|
||||||
|
await editInTasks(view, task);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case "taskPriority":
|
case "taskPriority":
|
||||||
if (task) store.request = "priority";
|
if (task) store.request = "priority";
|
||||||
return;
|
return;
|
||||||
|
case "taskPeek":
|
||||||
|
store.peek();
|
||||||
|
return;
|
||||||
case "taskBack":
|
case "taskBack":
|
||||||
if (task) await shift(view, task, -1);
|
if (task) await shift(view, task, -1);
|
||||||
return;
|
return;
|
||||||
@@ -161,6 +178,12 @@ async function run(view: ViewContext, action: Action): Promise<void> {
|
|||||||
case "railChoose":
|
case "railChoose":
|
||||||
store.chooseRail();
|
store.chooseRail();
|
||||||
return;
|
return;
|
||||||
|
case "railToggleRow":
|
||||||
|
store.toggleRailRow();
|
||||||
|
return;
|
||||||
|
case "railLeave":
|
||||||
|
store.leaveRail();
|
||||||
|
return;
|
||||||
|
|
||||||
case "create":
|
case "create":
|
||||||
openComposer(view);
|
openComposer(view);
|
||||||
@@ -258,6 +281,15 @@ export function handleKey(view: ViewContext, event: KeyboardEvent): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The quick look is a lens, not a layer: every key still reaches the task
|
||||||
|
// underneath, and only Escape is taken to put the lens away.
|
||||||
|
if (store.peeking && event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
store.peeking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const action = resolve(event, scopesFor(view));
|
const action = resolve(event, scopesFor(view));
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
+186
-92
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Check from "@lucide/svelte/icons/check";
|
||||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import Minus from "@lucide/svelte/icons/minus";
|
||||||
import { labelHue } from "../lib/hue";
|
import { labelHue } from "../lib/hue";
|
||||||
import { dropZone } from "../lib/dnd.svelte";
|
import { dropZone } from "../lib/dnd.svelte";
|
||||||
import { laneKey } from "../lib/keys";
|
import { laneKey } from "../lib/keys";
|
||||||
@@ -28,117 +30,209 @@
|
|||||||
return steering && store.cursorRail === id;
|
return steering && store.cursorRail === id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(path: string) {
|
function unfold(path: string) {
|
||||||
const next = new Set(store.railExpanded);
|
const next = new Set(store.railExpanded);
|
||||||
if (next.has(path)) next.delete(path);
|
if (next.has(path)) next.delete(path);
|
||||||
else next.add(path);
|
else next.add(path);
|
||||||
store.railExpanded = next;
|
store.railExpanded = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectSphere(path: string) {
|
/** The name solos; with a modifier held it flips instead, like the box. */
|
||||||
store.sphereFilter = store.sphereFilter === path ? null : path;
|
function additive(event: MouseEvent): boolean {
|
||||||
store.projectFilter = null;
|
return event.metaKey || event.ctrlKey || event.shiftKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectProject(path: string, project: string) {
|
function onSphere(event: MouseEvent, path: string) {
|
||||||
store.sphereFilter = path;
|
if (additive(event)) store.toggleSphere(path);
|
||||||
store.projectFilter = store.projectFilter === project ? null : project;
|
else store.soloSphere(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onLane(event: MouseEvent, path: string, project: string) {
|
||||||
|
if (additive(event)) store.toggleLane(path, project);
|
||||||
|
else store.soloLane(path, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOD = /Mac|iPhone|iPad/u.test(navigator.userAgent) ? "⌘" : "Ctrl";
|
||||||
|
|
||||||
const CURSOR = "bg-secondary/70 inset-ring-1 inset-ring-ring";
|
const CURSOR = "bg-secondary/70 inset-ring-1 inset-ring-ring";
|
||||||
|
const ROW =
|
||||||
|
"flex h-7 min-w-0 flex-1 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";
|
||||||
|
const BOX =
|
||||||
|
"grid size-[15px] shrink-0 place-items-center rounded-[4px] border outline-none transition-[background-color,border-color,transform] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-90";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet gate(state: "all" | "some" | "none", hue: number | null, label: string, flip: () => void)}
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={state !== "none"}
|
||||||
|
class={cn(
|
||||||
|
BOX,
|
||||||
|
state === "none" && "border-border bg-transparent hover:border-foreground/40",
|
||||||
|
state !== "none" && hue === null && "border-primary bg-primary text-primary-foreground",
|
||||||
|
state !== "none" && hue !== null && "gate-hue",
|
||||||
|
)}
|
||||||
|
data-nodrag
|
||||||
|
onclick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
flip();
|
||||||
|
}}
|
||||||
|
style={hue !== null ? `--hue: ${hue}` : undefined}
|
||||||
|
title={label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if state === "all"}
|
||||||
|
<Check aria-hidden="true" class="size-2.5" strokeWidth={3} />
|
||||||
|
{:else if state === "some"}
|
||||||
|
<Minus aria-hidden="true" class="size-2.5" strokeWidth={3} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
bind:this={box}
|
bind:this={box}
|
||||||
class="flex w-52 shrink-0 flex-col overflow-y-auto border-border border-r bg-sidebar/50 py-2"
|
class="flex w-52 shrink-0 flex-col border-border border-r bg-sidebar/50"
|
||||||
>
|
>
|
||||||
<button
|
<div class="min-h-0 flex-1 overflow-y-auto py-2">
|
||||||
aria-current={store.sphereFilter === null}
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"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",
|
"mx-2 flex h-7 items-center gap-1.5 rounded-md pr-2 pl-[1.6rem] transition-colors",
|
||||||
here("all") && CURSOR,
|
here("all") && CURSOR,
|
||||||
)}
|
)}
|
||||||
data-rail-cursor={here("all")}
|
data-rail-cursor={here("all")}
|
||||||
onclick={() => {
|
>
|
||||||
store.sphereFilter = null;
|
{@render gate(
|
||||||
store.projectFilter = null;
|
store.railState,
|
||||||
}}
|
null,
|
||||||
type="button"
|
store.railState === "all" ? "Show nothing" : "Show everything",
|
||||||
>
|
() => store.toggleRailRow(),
|
||||||
<span class="flex-1 truncate">All spheres</span>
|
)}
|
||||||
<span class="text-muted-foreground tabular">{store.all.length}</span>
|
<button
|
||||||
</button>
|
class="flex h-7 min-w-0 flex-1 items-center rounded-md text-left font-medium text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => store.filterAll()}
|
||||||
{#each store.spheres as sphere (sphere.path)}
|
title="Everything through"
|
||||||
{@const open = store.railExpanded.has(sphere.path)}
|
type="button"
|
||||||
{@const target = dropTarget.sphere(sphere.path)}
|
|
||||||
{@const row = `sphere:${sphere.path}`}
|
|
||||||
<div class="mt-1 px-2">
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
"flex h-7 items-center gap-0.5 rounded-md transition-colors",
|
|
||||||
here(row) && CURSOR,
|
|
||||||
zone?.target === target && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
|
||||||
)}
|
|
||||||
data-drop={target}
|
|
||||||
data-rail-cursor={here(row)}
|
|
||||||
>
|
>
|
||||||
<button
|
<span class="flex-1 truncate">All spheres</span>
|
||||||
aria-label={open ? "Collapse" : "Expand"}
|
<span class="text-muted-foreground tabular">{store.all.length}</span>
|
||||||
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"
|
</button>
|
||||||
onclick={() => toggle(sphere.path)}
|
</div>
|
||||||
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}
|
{#each store.spheres as sphere (sphere.path)}
|
||||||
<div class="flex flex-col pt-0.5 pl-5">
|
{@const open = store.railExpanded.has(sphere.path)}
|
||||||
{#each sphere.projects as project (project)}
|
{@const target = dropTarget.sphere(sphere.path)}
|
||||||
{@const laneTarget = dropTarget.lane(sphere.path, project)}
|
{@const row = `sphere:${sphere.path}`}
|
||||||
{@const laneRow = `lane:${laneKey(sphere.path, project)}`}
|
{@const state = store.sphereState(sphere.path)}
|
||||||
<button
|
<div class="mt-1 px-2">
|
||||||
aria-current={store.sphereFilter === sphere.path &&
|
<div
|
||||||
store.projectFilter === project}
|
class={cn(
|
||||||
class={cn(
|
"flex h-7 items-center gap-0.5 rounded-md transition-colors",
|
||||||
"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",
|
here(row) && CURSOR,
|
||||||
here(laneRow) && CURSOR,
|
zone?.target === target && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||||
zone?.target === laneTarget && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
)}
|
||||||
)}
|
data-drop={target}
|
||||||
data-drop={laneTarget}
|
data-rail-cursor={here(row)}
|
||||||
data-rail-cursor={here(laneRow)}
|
>
|
||||||
onclick={() => selectProject(sphere.path, project)}
|
<button
|
||||||
type="button"
|
aria-expanded={open}
|
||||||
>
|
aria-label={open ? "Collapse" : "Expand"}
|
||||||
<span
|
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"
|
||||||
aria-hidden="true"
|
onclick={() => unfold(sphere.path)}
|
||||||
class="size-1.5 shrink-0 rounded-full"
|
type="button"
|
||||||
style="background: oklch(0.6 0.12 {labelHue(project)})"
|
>
|
||||||
></span>
|
<ChevronRight
|
||||||
<span class="min-w-0 flex-1 truncate">{project}</span>
|
aria-hidden="true"
|
||||||
<span class="shrink-0 text-muted-foreground tabular">
|
class={cn("size-3 transition-transform", open && "rotate-90")}
|
||||||
{store.inScopeCount.byProject.get(laneKey(sphere.path, project)) ?? 0}
|
/>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
{@render gate(
|
||||||
{/each}
|
state,
|
||||||
|
null,
|
||||||
|
state === "all" ? `Hide ${sphere.name}` : `Show ${sphere.name}`,
|
||||||
|
() => store.toggleSphere(sphere.path),
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
class={cn(ROW, "font-medium", state === "none" && "text-muted-foreground")}
|
||||||
|
onclick={(event) => onSphere(event, sphere.path)}
|
||||||
|
title={`Only ${sphere.name} · ${MOD}-click to add or remove`}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="flex flex-col pt-0.5 pl-[1.6rem]">
|
||||||
|
{#each sphere.projects as project (project)}
|
||||||
|
{@const laneTarget = dropTarget.lane(sphere.path, project)}
|
||||||
|
{@const laneRow = `lane:${laneKey(sphere.path, project)}`}
|
||||||
|
{@const on = store.laneState(sphere.path, project)}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"flex h-6 items-center gap-1.5 rounded-md pl-[3px] transition-colors",
|
||||||
|
here(laneRow) && CURSOR,
|
||||||
|
zone?.target === laneTarget && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||||
|
)}
|
||||||
|
data-drop={laneTarget}
|
||||||
|
data-rail-cursor={here(laneRow)}
|
||||||
|
>
|
||||||
|
{@render gate(
|
||||||
|
on ? "all" : "none",
|
||||||
|
labelHue(project),
|
||||||
|
on ? `Hide ${project}` : `Show ${project}`,
|
||||||
|
() => store.toggleLane(sphere.path, project),
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
class={cn(ROW, "h-6", !on && "text-muted-foreground")}
|
||||||
|
onclick={(event) => onLane(event, sphere.path, project)}
|
||||||
|
title={`Only ${project} · ${MOD}-click to add or remove`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- What the gate is doing, and while steering, the three keys that work it. -->
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 flex-col gap-1 border-border/70 border-t px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if store.railState === "all"}
|
||||||
|
<span class="truncate">Everything through</span>
|
||||||
|
{:else if store.railState === "none"}
|
||||||
|
<span class="truncate">Nothing through</span>
|
||||||
|
{:else}
|
||||||
|
<span class="truncate tabular">
|
||||||
|
{store.railCount.on} of {store.railCount.all} lanes
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if store.railState !== "all"}
|
||||||
|
<button
|
||||||
|
class="ml-auto shrink-0 rounded px-1 text-foreground/80 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => store.filterAll()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{#if steering}
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||||
|
<span class="whitespace-nowrap"><kbd class="kbd">⏎</kbd> only</span>
|
||||||
|
<span class="whitespace-nowrap"><kbd class="kbd">␣</kbd> flip</span>
|
||||||
|
<span class="whitespace-nowrap"><kbd class="kbd">b</kbd> shut</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+25
-17
@@ -5,7 +5,7 @@
|
|||||||
import Repeat from "@lucide/svelte/icons/repeat";
|
import Repeat from "@lucide/svelte/icons/repeat";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
import { cubicOut } from "svelte/easing";
|
import { cubicOut } from "svelte/easing";
|
||||||
import { dueLabel, dueTone, fromKey, nextFriday, shiftDays, today } from "../lib/due";
|
import { dueLabel, dueTone, fromKey, nextFriday, shiftDays } from "../lib/due";
|
||||||
import { labelHue } from "../lib/hue";
|
import { labelHue } from "../lib/hue";
|
||||||
import { listStep, MOD } from "../lib/keymap";
|
import { listStep, MOD } from "../lib/keymap";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
});
|
});
|
||||||
|
const monthDay = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short" });
|
||||||
|
|
||||||
// ── what is on offer in each field ───────────────────────────────────────
|
// ── what is on offer in each field ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -72,17 +73,23 @@
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const dateOptions = $derived<Option[]>([
|
// Read off the store's clock, not the wall: a view that stays open for
|
||||||
{ value: "", label: "No date" },
|
// weeks would otherwise keep offering the day it was opened on.
|
||||||
{ value: today(), label: "Today", hint: dueLabel(today()) },
|
const dateOptions = $derived.by<Option[]>(() => {
|
||||||
{ value: shiftDays(today(), 1), label: "Tomorrow" },
|
const now = store.today;
|
||||||
{ value: nextFriday(), label: "Friday", hint: dueLabel(nextFriday()) },
|
const on = (key: string, label: string): Option => ({
|
||||||
{
|
value: key,
|
||||||
value: shiftDays(today(), 7),
|
label,
|
||||||
label: "In a week",
|
hint: monthDay.format(fromKey(key)),
|
||||||
hint: dueLabel(shiftDays(today(), 7)),
|
});
|
||||||
},
|
return [
|
||||||
]);
|
{ value: "", label: "No date" },
|
||||||
|
on(now, "Today"),
|
||||||
|
on(shiftDays(now, 1), "Tomorrow"),
|
||||||
|
on(nextFriday(now), "Friday"),
|
||||||
|
on(shiftDays(now, 7), "In a week"),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const repeatOptions = $derived<Option[]>([
|
const repeatOptions = $derived<Option[]>([
|
||||||
{ value: "", label: "No repeat" },
|
{ value: "", label: "No repeat" },
|
||||||
@@ -95,7 +102,7 @@
|
|||||||
/** What the words in the field add up to, shown before they are taken. */
|
/** What the words in the field add up to, shown before they are taken. */
|
||||||
const read = $derived.by(() => {
|
const read = $derived.by(() => {
|
||||||
if (field === "date") {
|
if (field === "date") {
|
||||||
const key = parseWhen(query);
|
const key = parseWhen(query, fromKey(store.today));
|
||||||
return key ? { value: key, label: long.format(fromKey(key)) } : null;
|
return key ? { value: key, label: long.format(fromKey(key)) } : null;
|
||||||
}
|
}
|
||||||
if (field === "repeat") {
|
if (field === "repeat") {
|
||||||
@@ -409,7 +416,7 @@
|
|||||||
<input
|
<input
|
||||||
bind:this={inputs.date}
|
bind:this={inputs.date}
|
||||||
class={ENTRY}
|
class={ENTRY}
|
||||||
placeholder={due ? dueLabel(due) : "When"}
|
placeholder={due ? dueLabel(due, store.today) : "When"}
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -419,8 +426,8 @@
|
|||||||
CHIP,
|
CHIP,
|
||||||
IDLE,
|
IDLE,
|
||||||
"w-auto shrink-0",
|
"w-auto shrink-0",
|
||||||
due && dueTone(due) === "overdue" && "text-destructive",
|
due && dueTone(due, store.today) === "overdue" && "text-destructive",
|
||||||
due && dueTone(due) === "soon" && "text-status-snooze",
|
due && dueTone(due, store.today) === "soon" && "text-status-snooze",
|
||||||
)}
|
)}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
field = "date";
|
field = "date";
|
||||||
@@ -432,7 +439,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<CalendarOff aria-hidden="true" class="size-3" />
|
<CalendarOff aria-hidden="true" class="size-3" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="tabular">{due ? dueLabel(due) : "No date"}</span>
|
<span class="tabular">{due ? dueLabel(due, store.today) : "No date"}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -490,6 +497,7 @@
|
|||||||
<div class="border-border/70 border-t px-1 pt-2 pb-1">
|
<div class="border-border/70 border-t px-1 pt-2 pb-1">
|
||||||
<DayGrid
|
<DayGrid
|
||||||
anchor={due}
|
anchor={due}
|
||||||
|
today={store.today}
|
||||||
onpick={(key) => {
|
onpick={(key) => {
|
||||||
due = key;
|
due = key;
|
||||||
query = "";
|
query = "";
|
||||||
|
|||||||
@@ -49,7 +49,14 @@
|
|||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="px-3 py-8 text-center text-muted-foreground text-xs">
|
<p class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||||
{store.search.trim() ? "Nothing matches" : "No tasks"}
|
{#if store.railState === "none"}
|
||||||
|
Nothing let through — tick a sphere in the rail, or press
|
||||||
|
<kbd class="kbd">b</kbd> and <kbd class="kbd">⏎</kbd> for everything
|
||||||
|
{:else if store.search.trim()}
|
||||||
|
Nothing matches
|
||||||
|
{:else}
|
||||||
|
No tasks
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Notice, TFile } from "obsidian";
|
|||||||
import ExternalLink from "@lucide/svelte/icons/external-link";
|
import ExternalLink from "@lucide/svelte/icons/external-link";
|
||||||
import FileText from "@lucide/svelte/icons/file-text";
|
import FileText from "@lucide/svelte/icons/file-text";
|
||||||
import PenLine from "@lucide/svelte/icons/pen-line";
|
import PenLine from "@lucide/svelte/icons/pen-line";
|
||||||
|
import ScanEye from "@lucide/svelte/icons/scan-eye";
|
||||||
import { STATUS_META } from "../lib/vocab";
|
import { STATUS_META } from "../lib/vocab";
|
||||||
import { STATUS_ORDER } from "../model/types";
|
import { STATUS_ORDER } from "../model/types";
|
||||||
import type { Task } from "../model/types";
|
import type { Task } from "../model/types";
|
||||||
@@ -47,6 +48,16 @@ export function taskMenu(view: ViewContext, task: Task): MenuItem[] {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
...statuses,
|
...statuses,
|
||||||
|
{
|
||||||
|
icon: ScanEye,
|
||||||
|
label: "Quick look",
|
||||||
|
hint: "space",
|
||||||
|
onselect: () => {
|
||||||
|
view.store.cursorTask = task.id;
|
||||||
|
view.store.keyboard = true;
|
||||||
|
if (!view.store.peeking) view.store.peek();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: PenLine,
|
icon: PenLine,
|
||||||
label: "Edit in Tasks",
|
label: "Edit in Tasks",
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Portal } from "bits-ui";
|
||||||
|
import Archive from "@lucide/svelte/icons/archive";
|
||||||
|
import CalendarCheck from "@lucide/svelte/icons/calendar-check";
|
||||||
|
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
|
||||||
|
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||||
|
import CalendarOff from "@lucide/svelte/icons/calendar-off";
|
||||||
|
import CalendarPlus from "@lucide/svelte/icons/calendar-plus";
|
||||||
|
import Hash from "@lucide/svelte/icons/hash";
|
||||||
|
import Play from "@lucide/svelte/icons/play";
|
||||||
|
import Repeat from "@lucide/svelte/icons/repeat";
|
||||||
|
import { cubicIn } from "svelte/easing";
|
||||||
|
import type { TransitionConfig } from "svelte/transition";
|
||||||
|
import { dueLabel, dueTone, fromKey } from "../lib/due";
|
||||||
|
import { labelHue } from "../lib/hue";
|
||||||
|
import type { Action } from "../lib/keymap";
|
||||||
|
import { onScreen } from "../lib/place";
|
||||||
|
import type { Box } from "../lib/place";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { PRIORITY_LABEL, STATUS_META } from "../lib/vocab";
|
||||||
|
import { isOpen } from "../model/types";
|
||||||
|
import { handleKey, perform } from "./keyboard";
|
||||||
|
import { markdown } from "./markdown";
|
||||||
|
import PriorityBars from "./priority-bars.svelte";
|
||||||
|
import { useView } from "./context";
|
||||||
|
|
||||||
|
const view = useView();
|
||||||
|
const store = view.store;
|
||||||
|
|
||||||
|
const task = $derived(store.taskById(store.cursorTask));
|
||||||
|
const open = $derived(store.peeking && task !== null);
|
||||||
|
|
||||||
|
// The cursor walked off the last task, or the task was written away: a
|
||||||
|
// lens with nothing under it goes away too.
|
||||||
|
$effect(() => {
|
||||||
|
if (store.peeking && !task) store.peeking = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = $derived(task ? STATUS_META[task.status] : null);
|
||||||
|
const long = new Intl.DateTimeFormat("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const short = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short" });
|
||||||
|
|
||||||
|
/** The card's sub-lines, with the one tab of card indent taken off. */
|
||||||
|
const notes = $derived(
|
||||||
|
task ? task.body.map((line) => line.replace(/^\t/u, "")).join("\n").trim() : "",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Says the date twice, once as a day and once against today. */
|
||||||
|
function when(key: string): string {
|
||||||
|
const label = dueLabel(key, store.today);
|
||||||
|
const full = long.format(fromKey(key));
|
||||||
|
return /^[a-z]/u.test(label) ? `${full} · ${label}` : full;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
action: Action;
|
||||||
|
keys: string[];
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = $derived<Row[]>(
|
||||||
|
task
|
||||||
|
? [
|
||||||
|
{ action: "taskToggle", keys: ["x"], label: isOpen(task.status) ? "done" : "reopen" },
|
||||||
|
{ action: "taskEdit", keys: ["e"], label: "edit" },
|
||||||
|
{ action: "taskPriority", keys: ["p"], label: "priority" },
|
||||||
|
{ action: "taskBack", keys: ["⇧h"], label: "−day" },
|
||||||
|
{ action: "taskForward", keys: ["⇧l"], label: "+day" },
|
||||||
|
{ action: "taskToday", keys: ["⇧t"], label: "today" },
|
||||||
|
{ action: "taskOpen", keys: ["⏎"], label: "open note" },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── where it sits, and how it arrives ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centred on the view rather than the window: the view is one pane among
|
||||||
|
* several, and the lens belongs over the tasks it is reading. A touch above
|
||||||
|
* the middle, the way a window feels centred rather than measures it.
|
||||||
|
*/
|
||||||
|
function centre(node: HTMLElement): void {
|
||||||
|
const box = view.rootBox() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
|
||||||
|
const width = node.offsetWidth;
|
||||||
|
const height = node.offsetHeight;
|
||||||
|
const left = box.left + (box.width - width) / 2;
|
||||||
|
const top = box.top + (box.height - height) * 0.42;
|
||||||
|
node.style.left = `${Math.max(8, Math.min(left, window.innerWidth - width - 8))}px`;
|
||||||
|
node.style.top = `${Math.max(8, Math.min(top, window.innerHeight - height - 8))}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row or card under the cursor, wherever the current view draws it. */
|
||||||
|
function origin(): Box | null {
|
||||||
|
return onScreen(
|
||||||
|
[...document.querySelectorAll("[data-cursor='true']")].map((element) =>
|
||||||
|
element.getBoundingClientRect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fast settle with the faintest overshoot, so it lands rather than stops. */
|
||||||
|
function land(t: number): number {
|
||||||
|
const s = 0.55;
|
||||||
|
const x = t - 1;
|
||||||
|
return x * x * ((s + 1) * x + s) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grows out of the task under the cursor and shrinks back into it: the
|
||||||
|
* same gesture Quick Look makes from a Finder row, so the eye never has to
|
||||||
|
* find where the lens came from.
|
||||||
|
*/
|
||||||
|
function zoom(node: HTMLElement, { out = false } = {}): TransitionConfig {
|
||||||
|
centre(node);
|
||||||
|
const to = node.getBoundingClientRect();
|
||||||
|
const from = origin();
|
||||||
|
if (!from || to.width === 0 || to.height === 0) {
|
||||||
|
return {
|
||||||
|
duration: out ? 140 : 200,
|
||||||
|
easing: out ? cubicIn : land,
|
||||||
|
css: (t) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
|
||||||
|
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
|
||||||
|
const sx = Math.max(0.04, from.width / to.width);
|
||||||
|
const sy = Math.max(0.04, from.height / to.height);
|
||||||
|
return {
|
||||||
|
duration: out ? 170 : 260,
|
||||||
|
easing: out ? cubicIn : land,
|
||||||
|
css: (t, u) =>
|
||||||
|
`transform: translate(${dx * u}px, ${dy * u}px) scale(${1 - (1 - sx) * u}, ${1 - (1 - sy) * u});` +
|
||||||
|
`opacity: ${Math.min(1, t * 2.2)};` +
|
||||||
|
(out ? "" : `filter: blur(${6 * u}px);`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(node: HTMLElement) {
|
||||||
|
centre(node);
|
||||||
|
node.focus({ preventScroll: true });
|
||||||
|
|
||||||
|
const watcher =
|
||||||
|
typeof ResizeObserver === "undefined"
|
||||||
|
? null
|
||||||
|
: new ResizeObserver(() => centre(node));
|
||||||
|
watcher?.observe(node);
|
||||||
|
const reflow = (): void => centre(node);
|
||||||
|
window.addEventListener("resize", reflow);
|
||||||
|
|
||||||
|
// A click on another task moves the lens rather than closing it; a click
|
||||||
|
// anywhere else puts it away.
|
||||||
|
const outside = (event: PointerEvent): void => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (!target || node.contains(target)) return;
|
||||||
|
if (target.closest("[data-cursor]")) return;
|
||||||
|
store.peeking = false;
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", outside, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
watcher?.disconnect();
|
||||||
|
window.removeEventListener("resize", reflow);
|
||||||
|
document.removeEventListener("pointerdown", outside, true);
|
||||||
|
// Only take the keyboard back if the lens had it; an editor that has
|
||||||
|
// just opened keeps its caret.
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!active || active === document.body || node.contains(active)) {
|
||||||
|
view.focusRoot();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys pressed with the lens focused go where they always go. */
|
||||||
|
function onkeydown(event: KeyboardEvent): void {
|
||||||
|
handleKey(view, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function act(action: Action): void {
|
||||||
|
void perform(view, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIP =
|
||||||
|
"inline-flex h-[22px] max-w-40 shrink-0 items-center gap-1 truncate rounded-md border px-1.5 text-[11px]";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open && task && meta}
|
||||||
|
<Portal to={view.portal()}>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
aria-label="Quick look"
|
||||||
|
class="fixed z-50 flex max-h-[min(72vh,40rem)] w-[36rem] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_24px_64px_-16px_rgb(0_0_0/55%),0_2px_8px_-2px_rgb(0_0_0/25%)] outline-none will-change-transform"
|
||||||
|
in:zoom
|
||||||
|
out:zoom={{ out: true }}
|
||||||
|
{onkeydown}
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
use:mount
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2.5 px-4 pt-3.5 pb-2">
|
||||||
|
<button
|
||||||
|
aria-label={`Status: ${meta.label}`}
|
||||||
|
class="mt-px grid size-6 shrink-0 place-items-center rounded-md outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => act("taskToggle")}
|
||||||
|
title={meta.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<meta.icon aria-hidden="true" class="size-[18px]" style="color: {meta.color}" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||||
|
<span class={cn(CHIP, "border-border bg-secondary/40 font-medium text-muted-foreground")}>
|
||||||
|
{task.sphere}
|
||||||
|
</span>
|
||||||
|
{#if task.inArchive}
|
||||||
|
<span class={cn(CHIP, "border-border border-dashed text-muted-foreground")}>
|
||||||
|
<Archive aria-hidden="true" class="size-3" />
|
||||||
|
{task.project}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class={cn(CHIP, "hue-chip")} style="--hue: {labelHue(task.project)}">
|
||||||
|
{task.project}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.priority !== 2}
|
||||||
|
<span
|
||||||
|
class={cn(CHIP, "border-transparent pl-0 text-muted-foreground")}
|
||||||
|
title={PRIORITY_LABEL[task.priority]}
|
||||||
|
>
|
||||||
|
<PriorityBars priority={task.priority} />
|
||||||
|
{PRIORITY_LABEL[task.priority]}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="shrink-0 pt-1 text-[10px] text-muted-foreground/70 tabular"
|
||||||
|
title={task.path}
|
||||||
|
>
|
||||||
|
{task.path.slice(task.path.lastIndexOf("/") + 1)}:{task.line + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 overflow-y-auto px-4 pb-3">
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"bcal-prose font-medium text-[15px] leading-[1.5]",
|
||||||
|
!isOpen(task.status) && "text-muted-foreground line-through decoration-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
onclick={(event) => {
|
||||||
|
const anchor = (event.target as HTMLElement).closest("a");
|
||||||
|
if (!anchor) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const href = anchor.getAttribute("href") ?? "";
|
||||||
|
if (anchor.classList.contains("internal-link")) {
|
||||||
|
void view.app.workspace.openLinkText(href, task.path, event.metaKey || event.ctrlKey);
|
||||||
|
} else if (href) {
|
||||||
|
window.open(href, "_blank");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onkeydown={() => {}}
|
||||||
|
role="presentation"
|
||||||
|
use:markdown={{
|
||||||
|
app: view.app,
|
||||||
|
component: view.component,
|
||||||
|
sourcePath: task.path,
|
||||||
|
text: task.text,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{#if notes}
|
||||||
|
<div
|
||||||
|
class="bcal-prose mt-2.5 border-border border-l pl-3 text-[13px] text-foreground/85 leading-relaxed"
|
||||||
|
use:markdown={{
|
||||||
|
app: view.app,
|
||||||
|
component: view.component,
|
||||||
|
sourcePath: task.path,
|
||||||
|
text: notes,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-x-3 gap-y-1 border-border/70 border-t px-4 py-2 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{#if task.due}
|
||||||
|
{@const tone = dueTone(task.due, store.today)}
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
tone === "overdue" && "text-destructive",
|
||||||
|
tone === "soon" && "text-status-snooze",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarDays aria-hidden="true" class="size-3" />
|
||||||
|
<span>due {when(task.due)}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.scheduled}
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<CalendarClock aria-hidden="true" class="size-3" />
|
||||||
|
<span>scheduled {when(task.scheduled)}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.start}
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<Play aria-hidden="true" class="size-3" />
|
||||||
|
<span>from {short.format(fromKey(task.start))}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.recurrence}
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<Repeat aria-hidden="true" class="size-3" />
|
||||||
|
<span>{task.recurrence}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.done}
|
||||||
|
<span class="inline-flex items-center gap-1 text-status-done">
|
||||||
|
<CalendarCheck aria-hidden="true" class="size-3" />
|
||||||
|
<span>done {short.format(fromKey(task.done))}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.created}
|
||||||
|
<span class="inline-flex items-center gap-1 text-muted-foreground/70">
|
||||||
|
<CalendarPlus aria-hidden="true" class="size-3" />
|
||||||
|
<span>added {short.format(fromKey(task.created))}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if !task.due && !task.scheduled && !task.start && !task.recurrence && !task.done}
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<CalendarOff aria-hidden="true" class="size-3" />
|
||||||
|
<span>No date</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.tags.length > 0}
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<Hash aria-hidden="true" class="size-3" />
|
||||||
|
<span class="truncate">{task.tags.join(" ")}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{#each rows as row (row.action)}
|
||||||
|
<button
|
||||||
|
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onclick={() => act(row.action)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#each row.keys as key (key)}
|
||||||
|
<kbd class="kbd">{key}</kbd>
|
||||||
|
{/each}
|
||||||
|
{row.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<span class="ml-auto whitespace-nowrap px-1.5">
|
||||||
|
<kbd class="kbd">␣</kbd> close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
{/if}
|
||||||
@@ -36,7 +36,8 @@
|
|||||||
const zone = $derived(dropZone());
|
const zone = $derived(dropZone());
|
||||||
const isDropTarget = $derived(zone?.target === target);
|
const isDropTarget = $derived(zone?.target === target);
|
||||||
const date = $derived(anchorDate(task));
|
const date = $derived(anchorDate(task));
|
||||||
const tone = $derived(dueTone(date));
|
const tone = $derived(dueTone(date, view.store.today));
|
||||||
|
const label = $derived(dueLabel(date, view.store.today));
|
||||||
/** The editor hangs off the chip that opened it, not the whole row. */
|
/** The editor hangs off the chip that opened it, not the whole row. */
|
||||||
const chip = (field: string): string => `${field}:${task.id}`;
|
const chip = (field: string): string => `${field}:${task.id}`;
|
||||||
const on = $derived(view.store.cursorTask === task.id);
|
const on = $derived(view.store.cursorTask === task.id);
|
||||||
@@ -196,8 +197,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<CalendarDays aria-hidden="true" class="size-3" />
|
<CalendarDays aria-hidden="true" class="size-3" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if dueLabel(date)}
|
{#if label}
|
||||||
<span class="tabular">{dueLabel(date)}</span>
|
<span class="tabular">{label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-1
@@ -53,14 +53,24 @@
|
|||||||
<button
|
<button
|
||||||
aria-label="Toggle spheres"
|
aria-label="Toggle spheres"
|
||||||
aria-pressed={!store.railCollapsed}
|
aria-pressed={!store.railCollapsed}
|
||||||
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
class={cn(PILL, "relative px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
store.railCollapsed = !store.railCollapsed;
|
store.railCollapsed = !store.railCollapsed;
|
||||||
store.savePrefs();
|
store.savePrefs();
|
||||||
}}
|
}}
|
||||||
|
title={store.railState === "all"
|
||||||
|
? "Spheres (b)"
|
||||||
|
: `Spheres (b) · ${store.railCount.on} of ${store.railCount.all} lanes through`}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<PanelLeft aria-hidden="true" class="size-3.5" />
|
<PanelLeft aria-hidden="true" class="size-3.5" />
|
||||||
|
{#if store.railCollapsed && store.railState !== "all"}
|
||||||
|
<!-- The rail is shut but still filtering: say so where it was. -->
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute -top-px -right-px size-2 rounded-full border border-background bg-signal"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
+250
-52
@@ -3,7 +3,7 @@ import type { App } from "obsidian";
|
|||||||
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 } from "../lib/due";
|
import { daysFromToday, fromKey, localKey, shiftDays, untilMidnight } from "../lib/due";
|
||||||
import { laneKey, parseLaneKey } from "../lib/keys";
|
import { laneKey, parseLaneKey } from "../lib/keys";
|
||||||
import type {
|
import type {
|
||||||
BeaverCalendarSettings,
|
BeaverCalendarSettings,
|
||||||
@@ -61,9 +61,12 @@ export interface Editing {
|
|||||||
field: Field;
|
field: Field;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dueBucket(key: string | null): { key: string; label: string; rank: number } {
|
function dueBucket(
|
||||||
|
key: string | null,
|
||||||
|
today: string,
|
||||||
|
): { key: string; label: string; rank: number } {
|
||||||
if (!key) return { key: "none", label: "No date", rank: 5 };
|
if (!key) return { key: "none", label: "No date", rank: 5 };
|
||||||
const diff = daysFromToday(key);
|
const diff = daysFromToday(key, today);
|
||||||
if (diff < 0) return { key: "overdue", label: "Overdue", rank: 0 };
|
if (diff < 0) return { key: "overdue", label: "Overdue", rank: 0 };
|
||||||
if (diff === 0) return { key: "today", label: "Today", rank: 1 };
|
if (diff === 0) return { key: "today", label: "Today", rank: 1 };
|
||||||
if (diff === 1) return { key: "tomorrow", label: "Tomorrow", rank: 2 };
|
if (diff === 1) return { key: "tomorrow", label: "Tomorrow", rank: 2 };
|
||||||
@@ -101,12 +104,22 @@ export class BoardStore {
|
|||||||
railCollapsed = $state(false);
|
railCollapsed = $state(false);
|
||||||
undatedCollapsed = $state(false);
|
undatedCollapsed = $state(false);
|
||||||
search = $state("");
|
search = $state("");
|
||||||
sphereFilter = $state<string | null>(null);
|
/**
|
||||||
projectFilter = $state<string | null>(null);
|
* The lanes the rail lets through, as lane keys. `null` is the open gate:
|
||||||
|
* everything, including lanes that appear later. An empty set shows nothing.
|
||||||
|
*/
|
||||||
|
lanes = $state<Set<string> | null>(null);
|
||||||
statusFilter = $state<Status[]>([]);
|
statusFilter = $state<Status[]>([]);
|
||||||
monthKey = $state(localKey(new Date()));
|
monthKey = $state(localKey(new Date()));
|
||||||
selected = $state<string | null>(null);
|
selected = $state<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today's key as a signal. A view left open for weeks would otherwise keep
|
||||||
|
* the day it was opened on: everything that says "today" reads this, and
|
||||||
|
* `tick` moves it at midnight, on waking, and whenever a panel opens.
|
||||||
|
*/
|
||||||
|
today = $state(localKey(new Date()));
|
||||||
|
|
||||||
/** Keyboard cursor: a day in the grid, plus a task inside it once entered. */
|
/** Keyboard cursor: a day in the grid, plus a task inside it once entered. */
|
||||||
cursorDay = $state(localKey(new Date()));
|
cursorDay = $state(localKey(new Date()));
|
||||||
cursorTask = $state<string | null>(null);
|
cursorTask = $state<string | null>(null);
|
||||||
@@ -121,8 +134,8 @@ export class BoardStore {
|
|||||||
keyboard = $state(false);
|
keyboard = $state(false);
|
||||||
/** Which spheres are unfolded in the rail. */
|
/** Which spheres are unfolded in the rail. */
|
||||||
railExpanded = $state<Set<string>>(new Set());
|
railExpanded = $state<Set<string>>(new Set());
|
||||||
/** Set when `b` had to reveal the rail, so leaving can put it back. */
|
/** The quick-look card over the task under the cursor. */
|
||||||
private railWasHidden = false;
|
peeking = $state(false);
|
||||||
|
|
||||||
/** The open editor, whether it is writing a task or changing one. */
|
/** The open editor, whether it is writing a task or changing one. */
|
||||||
editing = $state<Editing | null>(null);
|
editing = $state<Editing | null>(null);
|
||||||
@@ -180,6 +193,40 @@ export class BoardStore {
|
|||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-reads the wall clock; a no-op while the day has not turned. */
|
||||||
|
tick(): void {
|
||||||
|
const now = localKey(new Date());
|
||||||
|
if (now !== this.today) this.today = 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.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.removeEventListener("focus", wake);
|
||||||
|
document.removeEventListener("visibilitychange", wake);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private inScope(path: string): boolean {
|
private inScope(path: string): boolean {
|
||||||
const folder = this.settings.boardsFolder;
|
const folder = this.settings.boardsFolder;
|
||||||
if (!folder) return path.endsWith(".md");
|
if (!folder) return path.endsWith(".md");
|
||||||
@@ -273,12 +320,7 @@ export class BoardStore {
|
|||||||
|
|
||||||
visible = $derived.by<Task[]>(() =>
|
visible = $derived.by<Task[]>(() =>
|
||||||
this.all
|
this.all
|
||||||
.filter((task) => this.matches(task))
|
.filter((task) => this.matches(task) && this.throughRail(task))
|
||||||
.filter(
|
|
||||||
(task) =>
|
|
||||||
(!this.sphereFilter || task.path === this.sphereFilter) &&
|
|
||||||
(!this.projectFilter || task.project === this.projectFilter),
|
|
||||||
)
|
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Number(isOpen(b.status)) - Number(isOpen(a.status)) ||
|
Number(isOpen(b.status)) - Number(isOpen(a.status)) ||
|
||||||
@@ -342,7 +384,7 @@ export class BoardStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (this.groupBy === "due") {
|
if (this.groupBy === "due") {
|
||||||
const bucket = dueBucket(anchorDate(task));
|
const bucket = dueBucket(anchorDate(task), this.today);
|
||||||
return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank };
|
return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank };
|
||||||
}
|
}
|
||||||
return { key: task.path, label: task.sphere, hint: "", rank: 0 };
|
return { key: task.path, label: task.sphere, hint: "", rank: 0 };
|
||||||
@@ -367,7 +409,7 @@ export class BoardStore {
|
|||||||
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 offset = (first.getDay() + 6) % 7;
|
||||||
const start = new Date(first.getTime() - offset * DAY);
|
const start = new Date(first.getTime() - offset * DAY);
|
||||||
const todayKey = localKey(new Date());
|
const todayKey = this.today;
|
||||||
const byDay = this.byDay;
|
const byDay = this.byDay;
|
||||||
return Array.from({ length: 42 }, (_, index) => {
|
return Array.from({ length: 42 }, (_, index) => {
|
||||||
const date = new Date(start.getTime() + index * DAY);
|
const date = new Date(start.getTime() + index * DAY);
|
||||||
@@ -415,8 +457,9 @@ export class BoardStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
goToday(): void {
|
goToday(): void {
|
||||||
this.monthKey = localKey(new Date());
|
this.tick();
|
||||||
this.focusDay(localKey(new Date()));
|
this.monthKey = this.today;
|
||||||
|
this.focusDay(this.today);
|
||||||
}
|
}
|
||||||
|
|
||||||
sphereName(path: string): string {
|
sphereName(path: string): string {
|
||||||
@@ -430,41 +473,56 @@ export class BoardStore {
|
|||||||
const spheres = this.spheres;
|
const spheres = this.spheres;
|
||||||
if (spheres.length === 0) return null;
|
if (spheres.length === 0) return null;
|
||||||
|
|
||||||
// A sidebar filter is an explicit instruction, so it outranks the memory.
|
const open = this.lanes;
|
||||||
const pinned = this.sphereFilter
|
const through = (path: string, project: string): boolean =>
|
||||||
? spheres.find((sphere) => sphere.path === this.sphereFilter)
|
open === null || open.has(laneKey(path, project));
|
||||||
: undefined;
|
|
||||||
if (pinned) {
|
|
||||||
const project =
|
|
||||||
(this.projectFilter && pinned.projects.includes(this.projectFilter)
|
|
||||||
? this.projectFilter
|
|
||||||
: undefined) ?? pinned.projects[0];
|
|
||||||
if (project) return { path: pinned.path, project, sphere: pinned.name };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// The memory wins while the rail still lets it through; otherwise the
|
||||||
|
// rail's selection is the explicit instruction and the first lane it
|
||||||
|
// shows is where writing lands.
|
||||||
const last = this.lastLane;
|
const last = this.lastLane;
|
||||||
if (last) {
|
if (last) {
|
||||||
const sphere = spheres.find((item) => item.path === last.path);
|
const sphere = spheres.find((item) => item.path === last.path);
|
||||||
if (sphere?.projects.includes(last.project)) {
|
if (sphere?.projects.includes(last.project) && through(last.path, last.project)) {
|
||||||
return { path: sphere.path, project: last.project, sphere: sphere.name };
|
return { path: sphere.path, project: last.project, sphere: sphere.name };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = spheres.find((sphere) => sphere.projects.length > 0);
|
for (const sphere of spheres) {
|
||||||
return first
|
for (const project of sphere.projects) {
|
||||||
? { path: first.path, project: first.projects[0], sphere: first.name }
|
if (through(sphere.path, project)) {
|
||||||
: null;
|
return { path: sphere.path, project, sphere: sphere.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
write(date: string | null, anchor: string): void {
|
write(date: string | null, anchor: string): void {
|
||||||
|
this.tick();
|
||||||
|
this.peeking = false;
|
||||||
this.editing = { mode: "create", taskId: null, date, anchor, field: "title" };
|
this.editing = { mode: "create", taskId: null, date, anchor, field: "title" };
|
||||||
}
|
}
|
||||||
|
|
||||||
edit(taskId: string, field: Field = "title", anchor = "[data-cursor='true']"): void {
|
edit(taskId: string, field: Field = "title", anchor = "[data-cursor='true']"): void {
|
||||||
|
this.tick();
|
||||||
this.request = null;
|
this.request = null;
|
||||||
|
this.peeking = false;
|
||||||
this.editing = { mode: "edit", taskId, date: null, anchor, field };
|
this.editing = { mode: "edit", taskId, date: null, anchor, field };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Space over a task: the quick look, and the same key puts it away. */
|
||||||
|
peek(): void {
|
||||||
|
if (this.peeking) {
|
||||||
|
this.peeking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.taskById(this.cursorTask)) return;
|
||||||
|
this.tick();
|
||||||
|
this.request = null;
|
||||||
|
this.peeking = true;
|
||||||
|
}
|
||||||
|
|
||||||
stopEditing(): void {
|
stopEditing(): void {
|
||||||
this.editing = null;
|
this.editing = null;
|
||||||
}
|
}
|
||||||
@@ -533,31 +591,34 @@ export class BoardStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `b` is the same round trip, except a rail that was already pinned open
|
* `b` is the same round trip with no memory: in the rail it shuts the rail
|
||||||
* stays open: only the one this opened gets put away again.
|
* and steps out, anywhere else it opens the rail and steps in.
|
||||||
*/
|
*/
|
||||||
toggleRail(): boolean {
|
toggleRail(): boolean {
|
||||||
if (this.zone === "rail") {
|
if (this.zone === "rail") {
|
||||||
if (this.railWasHidden) {
|
this.railCollapsed = true;
|
||||||
this.railCollapsed = true;
|
this.savePrefs();
|
||||||
this.savePrefs();
|
|
||||||
}
|
|
||||||
this.leaveToMain();
|
this.leaveToMain();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.railWasHidden = this.railCollapsed;
|
|
||||||
if (this.railCollapsed) {
|
if (this.railCollapsed) {
|
||||||
this.railCollapsed = false;
|
this.railCollapsed = false;
|
||||||
this.savePrefs();
|
this.savePrefs();
|
||||||
}
|
}
|
||||||
this.zone = "rail";
|
this.zone = "rail";
|
||||||
this.request = null;
|
this.request = null;
|
||||||
|
this.peeking = false;
|
||||||
if (!this.cursorRail || !this.railItems.includes(this.cursorRail)) {
|
if (!this.cursorRail || !this.railItems.includes(this.cursorRail)) {
|
||||||
this.cursorRail = this.railItems[0] ?? null;
|
this.cursorRail = this.railItems[0] ?? null;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Escape in the rail: hands the cursor back, and leaves the rail open. */
|
||||||
|
leaveRail(): void {
|
||||||
|
if (this.zone === "rail") this.leaveToMain();
|
||||||
|
}
|
||||||
|
|
||||||
/** Lands the cursor on the first task the search turned up. */
|
/** Lands the cursor on the first task the search turned up. */
|
||||||
focusFirstMatch(): boolean {
|
focusFirstMatch(): boolean {
|
||||||
const first = this.visible[0];
|
const first = this.visible[0];
|
||||||
@@ -630,23 +691,160 @@ export class BoardStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies the row under the rail cursor as the filter, then steps out. */
|
// ── The rail as a filter ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Every lane the rail knows, in the order it draws them. */
|
||||||
|
private allLanes(): string[] {
|
||||||
|
return this.spheres.flatMap((sphere) =>
|
||||||
|
sphere.projects.map((project) => laneKey(sphere.path, project)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A set that lets every lane through is the open gate, so store it as one. */
|
||||||
|
private setLanes(next: Set<string>): void {
|
||||||
|
const all = this.allLanes();
|
||||||
|
const full = all.length > 0 && all.every((key) => next.has(key));
|
||||||
|
this.lanes = full ? null : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private laneOn(key: string): boolean {
|
||||||
|
return this.lanes === null || this.lanes.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
throughRail(task: Task): boolean {
|
||||||
|
if (this.lanes === null) return true;
|
||||||
|
// Archived tasks have no lane of their own; they ride with their sphere.
|
||||||
|
if (task.inArchive) return this.sphereState(task.path) !== "none";
|
||||||
|
return this.lanes.has(laneKey(task.path, task.project));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the gate is fully open, fully shut, or somewhere between. */
|
||||||
|
railState = $derived.by<"all" | "none" | "some">(() => {
|
||||||
|
if (this.lanes === null) return "all";
|
||||||
|
return this.lanes.size === 0 ? "none" : "some";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** How many lanes are let through, over how many there are. */
|
||||||
|
railCount = $derived.by(() => {
|
||||||
|
const all = this.allLanes();
|
||||||
|
const on = this.lanes === null ? all.length : all.filter((key) => this.lanes!.has(key)).length;
|
||||||
|
return { on, all: all.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
sphereState(path: string): "all" | "none" | "some" {
|
||||||
|
if (this.lanes === null) return "all";
|
||||||
|
const sphere = this.spheres.find((item) => item.path === path);
|
||||||
|
if (!sphere || sphere.projects.length === 0) return "none";
|
||||||
|
const on = sphere.projects.filter((project) =>
|
||||||
|
this.lanes!.has(laneKey(path, project)),
|
||||||
|
).length;
|
||||||
|
return on === 0 ? "none" : on === sphere.projects.length ? "all" : "some";
|
||||||
|
}
|
||||||
|
|
||||||
|
laneState(path: string, project: string): boolean {
|
||||||
|
return this.laneOn(laneKey(path, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything through. */
|
||||||
|
filterAll(): void {
|
||||||
|
this.lanes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing through, ready to be filled lane by lane. */
|
||||||
|
filterNone(): void {
|
||||||
|
this.lanes = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only this sphere; or, if it is already alone, everything again. */
|
||||||
|
soloSphere(path: string): void {
|
||||||
|
const sphere = this.spheres.find((item) => item.path === path);
|
||||||
|
if (!sphere) return;
|
||||||
|
const mine = new Set(sphere.projects.map((project) => laneKey(path, project)));
|
||||||
|
if (this.isExactly(mine)) {
|
||||||
|
this.filterAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setLanes(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only this lane; or, if it is already alone, everything again. */
|
||||||
|
soloLane(path: string, project: string): void {
|
||||||
|
const mine = new Set([laneKey(path, project)]);
|
||||||
|
if (this.isExactly(mine)) {
|
||||||
|
this.filterAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setLanes(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isExactly(keys: Set<string>): boolean {
|
||||||
|
const current = this.lanes;
|
||||||
|
if (current === null) return false;
|
||||||
|
if (current.size !== keys.size) return false;
|
||||||
|
for (const key of keys) if (!current.has(key)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sphere goes on as a whole unless it was wholly on, in which case off. */
|
||||||
|
toggleSphere(path: string): void {
|
||||||
|
const sphere = this.spheres.find((item) => item.path === path);
|
||||||
|
if (!sphere) return;
|
||||||
|
const next = new Set(this.lanes ?? this.allLanes());
|
||||||
|
const keys = sphere.projects.map((project) => laneKey(path, project));
|
||||||
|
const whole = keys.length > 0 && keys.every((key) => next.has(key));
|
||||||
|
for (const key of keys) {
|
||||||
|
if (whole) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
}
|
||||||
|
this.setLanes(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLane(path: string, project: string): void {
|
||||||
|
const key = laneKey(path, project);
|
||||||
|
const next = new Set(this.lanes ?? this.allLanes());
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
this.setLanes(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter on a rail row picks it out: a sphere or a lane alone, and pressed
|
||||||
|
* again on the same lone row, everything back. On the top row it swings
|
||||||
|
* the whole gate — all open, or all shut ready to be filled with space.
|
||||||
|
*/
|
||||||
chooseRail(): void {
|
chooseRail(): void {
|
||||||
const row = this.cursorRail ?? "";
|
const row = this.cursorRail ?? "";
|
||||||
if (row === "all") {
|
if (row === "all") {
|
||||||
this.sphereFilter = null;
|
if (this.railState === "all") this.filterNone();
|
||||||
this.projectFilter = null;
|
else this.filterAll();
|
||||||
} else if (row.startsWith("sphere:")) {
|
return;
|
||||||
this.sphereFilter = row.slice(7);
|
}
|
||||||
this.projectFilter = null;
|
if (row.startsWith("sphere:")) {
|
||||||
} else if (row.startsWith("lane:")) {
|
this.soloSphere(row.slice(7));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.startsWith("lane:")) {
|
||||||
const lane = parseLaneKey(row.slice(5));
|
const lane = parseLaneKey(row.slice(5));
|
||||||
if (lane) {
|
if (lane) this.soloLane(lane.path, lane.project);
|
||||||
this.sphereFilter = lane.path;
|
}
|
||||||
this.projectFilter = lane.project;
|
}
|
||||||
}
|
|
||||||
|
/** Space on a rail row flips just that row, leaving the rest alone. */
|
||||||
|
toggleRailRow(): void {
|
||||||
|
const row = this.cursorRail ?? "";
|
||||||
|
if (row === "all") {
|
||||||
|
if (this.railState === "all") this.filterNone();
|
||||||
|
else this.filterAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.startsWith("sphere:")) {
|
||||||
|
this.toggleSphere(row.slice(7));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.startsWith("lane:")) {
|
||||||
|
const lane = parseLaneKey(row.slice(5));
|
||||||
|
if (lane) this.toggleLane(lane.path, lane.project);
|
||||||
}
|
}
|
||||||
this.toggleRail();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stepTask(delta: number): void {
|
stepTask(delta: number): void {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
daysFromToday,
|
||||||
|
dueLabel,
|
||||||
|
dueTone,
|
||||||
|
nextFriday,
|
||||||
|
untilMidnight,
|
||||||
|
} from "../src/lib/due";
|
||||||
|
|
||||||
|
describe("due helpers read the day they are given", () => {
|
||||||
|
it("labels against the given today, not the wall clock", () => {
|
||||||
|
expect(dueLabel("2026-08-23", "2026-08-23")).toBe("today");
|
||||||
|
expect(dueLabel("2026-08-24", "2026-08-23")).toBe("tomorrow");
|
||||||
|
expect(dueLabel("2026-08-22", "2026-08-23")).toBe("yesterday");
|
||||||
|
expect(dueLabel("2026-08-23", "2026-08-30")).toBe("Aug 23");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tones against the given today", () => {
|
||||||
|
expect(dueTone("2026-08-22", "2026-08-23")).toBe("overdue");
|
||||||
|
expect(dueTone("2026-08-24", "2026-08-23")).toBe("soon");
|
||||||
|
expect(dueTone("2026-09-24", "2026-08-23")).toBe("normal");
|
||||||
|
expect(daysFromToday("2026-09-02", "2026-08-23")).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the friday after the given day, never the same one", () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,6 +80,16 @@ describe("resolve", () => {
|
|||||||
expect(resolve(press("l", { code: "KeyL" }), RAIL)).toBe("railUnfold");
|
expect(resolve(press("l", { code: "KeyL" }), RAIL)).toBe("railUnfold");
|
||||||
expect(resolve(press("h", { code: "KeyH" }), RAIL)).toBe("railFold");
|
expect(resolve(press("h", { code: "KeyH" }), RAIL)).toBe("railFold");
|
||||||
expect(resolve(press("Enter", { code: "Enter" }), RAIL)).toBe("railChoose");
|
expect(resolve(press("Enter", { code: "Enter" }), RAIL)).toBe("railChoose");
|
||||||
|
expect(resolve(press(" ", { code: "Space" }), RAIL)).toBe("railToggleRow");
|
||||||
|
expect(resolve(press("Escape", { code: "Escape" }), RAIL)).toBe("railLeave");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the quick look on space over a task, and nowhere else", () => {
|
||||||
|
expect(resolve(press(" ", { code: "Space" }), TASK)).toBe("taskPeek");
|
||||||
|
expect(resolve(press(" ", { code: "Space" }), TRAY)).toBe("taskPeek");
|
||||||
|
expect(resolve(press(" ", { code: "Space" }), DAY)).toBeNull();
|
||||||
|
// Without a code, the key alone still reads as space.
|
||||||
|
expect(resolve(press(" "), TASK)).toBe("taskPeek");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes the panel it opened with the same key", () => {
|
it("closes the panel it opened with the same key", () => {
|
||||||
|
|||||||
+124
-6
@@ -228,8 +228,7 @@ if (button) {
|
|||||||
// The whole point of the composer: pick the day and the lane without ever
|
// The whole point of the composer: pick the day and the lane without ever
|
||||||
// touching the sidebar, and write the line straight into that lane.
|
// touching the sidebar, and write the line straight into that lane.
|
||||||
store.view = "calendar";
|
store.view = "calendar";
|
||||||
store.sphereFilter = null;
|
store.filterAll();
|
||||||
store.projectFilter = null;
|
|
||||||
flushSync();
|
flushSync();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
@@ -338,22 +337,74 @@ flushSync();
|
|||||||
check("u again handed it back", store.zone === "day", store.zone);
|
check("u again handed it back", store.zone === "day", store.zone);
|
||||||
check("and closed the tray", store.undatedCollapsed === true);
|
check("and closed the tray", store.undatedCollapsed === true);
|
||||||
|
|
||||||
// `b` does the same for the spheres, and Enter filters by the row.
|
// `b` opens the spheres and steps in; `b` again shuts them and steps out,
|
||||||
|
// whether or not they were open before.
|
||||||
store.undatedCollapsed = false;
|
store.undatedCollapsed = false;
|
||||||
|
store.railCollapsed = false;
|
||||||
key(viewRoot, { key: "b", code: "KeyB" });
|
key(viewRoot, { key: "b", code: "KeyB" });
|
||||||
flushSync();
|
flushSync();
|
||||||
check("b took the cursor into the rail", store.zone === "rail", store.zone);
|
check("b took the cursor into the rail", store.zone === "rail", store.zone);
|
||||||
|
check("the rail stayed open", store.railCollapsed === false);
|
||||||
|
key(viewRoot, { key: "b", code: "KeyB" });
|
||||||
|
flushSync();
|
||||||
|
check("b again stepped out", store.zone !== "rail", store.zone);
|
||||||
|
check("and shut the rail even though it was open before", store.railCollapsed === true);
|
||||||
|
key(viewRoot, { key: "b", code: "KeyB" });
|
||||||
|
flushSync();
|
||||||
|
check("b reopened the shut rail and stepped in", store.zone === "rail" && !store.railCollapsed);
|
||||||
|
key(viewRoot, { key: "Escape", code: "Escape" });
|
||||||
|
flushSync();
|
||||||
|
check("escape stepped out and left the rail open", store.zone !== "rail" && !store.railCollapsed, store.zone);
|
||||||
|
|
||||||
|
// The rail is a gate over lanes: Enter picks a row out, space flips one.
|
||||||
|
key(viewRoot, { key: "b", code: "KeyB" });
|
||||||
|
flushSync();
|
||||||
|
const everything = store.visible.length;
|
||||||
key(viewRoot, { key: "j", code: "KeyJ" });
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
key(viewRoot, { key: "Enter", code: "Enter" });
|
key(viewRoot, { key: "Enter", code: "Enter" });
|
||||||
flushSync();
|
flushSync();
|
||||||
check("enter filtered by the row", store.sphereFilter !== null, `${store.sphereFilter}`);
|
check("enter on a sphere shows only that sphere", store.railState === "some" && store.visible.length < everything, `${store.visible.length} of ${everything}`);
|
||||||
check("and stepped back out", store.zone !== "rail", store.zone);
|
check("the cursor stayed in the rail", store.zone === "rail", store.zone);
|
||||||
|
check("the first sphere is wholly on", store.sphereState(store.spheres[0].path) === "all");
|
||||||
|
check("the second is off", store.sphereState(store.spheres[1].path) === "none");
|
||||||
|
check("the rail says how much is through", text().includes("lanes") && text().includes("Show all"));
|
||||||
|
key(viewRoot, { key: "Enter", code: "Enter" });
|
||||||
|
flushSync();
|
||||||
|
check("enter again on the same lone sphere opens the gate", store.railState === "all" && store.visible.length === everything);
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
key(viewRoot, { key: " ", code: "Space" });
|
||||||
|
flushSync();
|
||||||
|
check("space flips one sphere off, the rest stays", store.sphereState(store.spheres[1].path) === "none" && store.sphereState(store.spheres[0].path) === "all");
|
||||||
|
key(viewRoot, { key: " ", code: "Space" });
|
||||||
|
flushSync();
|
||||||
|
check("space again flips it back on, and a full set is the open gate", store.railState === "all");
|
||||||
|
key(viewRoot, { key: "k", code: "KeyK" });
|
||||||
|
key(viewRoot, { key: "k", code: "KeyK" });
|
||||||
|
key(viewRoot, { key: "Enter", code: "Enter" });
|
||||||
|
flushSync();
|
||||||
|
check("enter on the top row with everything on shuts the gate", store.railState === "none" && store.visible.length === 0);
|
||||||
|
check("the list says why it is empty", text().includes("Nothing let through") || store.view === "calendar");
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
key(viewRoot, { key: "l", code: "KeyL" });
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
key(viewRoot, { key: " ", code: "Space" });
|
||||||
|
flushSync();
|
||||||
|
check("a lane can be let through on its own", store.railState === "some" && store.visible.length > 0 && store.visible.every((task) => task.path === store.spheres[0].path), `${store.visible.length}`);
|
||||||
|
check("its sphere reads as partly on", store.sphereState(store.spheres[0].path) === "some" || store.spheres[0].projects.length === 1);
|
||||||
|
key(viewRoot, { key: "k", code: "KeyK" });
|
||||||
|
key(viewRoot, { key: "k", code: "KeyK" });
|
||||||
|
key(viewRoot, { key: "Enter", code: "Enter" });
|
||||||
|
flushSync();
|
||||||
|
check("enter on the top row with some on opens the gate", store.railState === "all");
|
||||||
|
key(viewRoot, { key: "b", code: "KeyB" });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
// The list has no day grid, so its cursor starts on a task from the first key.
|
// The list has no day grid, so its cursor starts on a task from the first key.
|
||||||
store.view = "list";
|
store.view = "list";
|
||||||
|
store.railCollapsed = false;
|
||||||
store.leaveToMain();
|
store.leaveToMain();
|
||||||
store.cursorTask = null;
|
store.cursorTask = null;
|
||||||
store.sphereFilter = null;
|
store.filterAll();
|
||||||
flushSync();
|
flushSync();
|
||||||
await settle();
|
await settle();
|
||||||
key(viewRoot, { key: "j", code: "KeyJ" });
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
@@ -491,6 +542,73 @@ for (const [name, init] of [
|
|||||||
check("the task was written with the date those words meant", written?.includes(stamp), written);
|
check("the task was written with the date those words meant", written?.includes(stamp), written);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Space over a task is the quick look: the whole text, and the actions.
|
||||||
|
{
|
||||||
|
store.view = "list";
|
||||||
|
store.leaveToMain();
|
||||||
|
store.cursorTask = null;
|
||||||
|
flushSync();
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
flushSync();
|
||||||
|
const under = store.taskById(store.cursorTask);
|
||||||
|
key(viewRoot, { key: " ", code: "Space" });
|
||||||
|
flushSync();
|
||||||
|
await settle();
|
||||||
|
const lens = dom.window.document.querySelector("[aria-label='Quick look']");
|
||||||
|
check("space opened the quick look", store.peeking && lens !== null);
|
||||||
|
check("it shows the whole task", lens?.textContent.includes(under.text.slice(0, 20)));
|
||||||
|
check("it shows the actions", lens?.textContent.includes("edit") && lens?.textContent.includes("priority"));
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
flushSync();
|
||||||
|
check("the lens follows the cursor", store.peeking && store.cursorTask !== under.id);
|
||||||
|
const toggled = store.cursorTask;
|
||||||
|
const was = store.taskById(toggled).status;
|
||||||
|
key(viewRoot, { key: "x", code: "KeyX" });
|
||||||
|
await settle();
|
||||||
|
flushSync();
|
||||||
|
check("keys still reach the task underneath", store.taskById(toggled)?.status !== was || store.taskById(toggled) === null);
|
||||||
|
key(viewRoot, { key: " ", code: "Space" });
|
||||||
|
flushSync();
|
||||||
|
check("space again puts the lens away", !store.peeking);
|
||||||
|
key(viewRoot, { key: " ", code: "Space" });
|
||||||
|
flushSync();
|
||||||
|
key(viewRoot, { key: "Escape", code: "Escape" });
|
||||||
|
flushSync();
|
||||||
|
check("escape puts it away too, without leaving the task", !store.peeking && store.zone === "task");
|
||||||
|
key(viewRoot, { key: " ", code: "Space" });
|
||||||
|
flushSync();
|
||||||
|
key(viewRoot, { key: "e", code: "KeyE" });
|
||||||
|
flushSync();
|
||||||
|
check("edit closes the lens and opens the editor", !store.peeking && store.editing !== null);
|
||||||
|
panelOf()?.dispatchEvent(new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Escape", code: "Escape" }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clock is a signal: the editor offers the store's today, not the day
|
||||||
|
// the view was opened on.
|
||||||
|
{
|
||||||
|
store.today = "2031-03-09";
|
||||||
|
store.leaveToMain();
|
||||||
|
flushSync();
|
||||||
|
key(viewRoot, { key: "n", code: "KeyN" });
|
||||||
|
flushSync();
|
||||||
|
check("opening the editor re-reads the clock", store.today !== "2031-03-09");
|
||||||
|
store.today = "2031-03-09";
|
||||||
|
flushSync();
|
||||||
|
const panel = panelOf();
|
||||||
|
const tab = () => {
|
||||||
|
(dom.window.document.activeElement ?? panel).dispatchEvent(
|
||||||
|
new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Tab", code: "Tab" }),
|
||||||
|
);
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
tab(); tab(); tab();
|
||||||
|
check("the date field offers the clock's tomorrow", panel.textContent.includes("Mar 10"), panel.textContent.slice(0, 200));
|
||||||
|
panel.dispatchEvent(new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Escape", code: "Escape" }));
|
||||||
|
flushSync();
|
||||||
|
store.tick();
|
||||||
|
}
|
||||||
|
|
||||||
// A request whose task vanished must not leave the view deaf.
|
// A request whose task vanished must not leave the view deaf.
|
||||||
store.request = "priority";
|
store.request = "priority";
|
||||||
store.cursorTask = "nothing/at/all#0";
|
store.cursorTask = "nothing/at/all#0";
|
||||||
|
|||||||
Reference in New Issue
Block a user