feat(ui,store): live today clock, rail as lane filter, b toggle, quick look on space
This commit is contained in:
@@ -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("h", { code: "KeyH" }), RAIL)).toBe("railFold");
|
||||
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", () => {
|
||||
|
||||
+124
-6
@@ -228,8 +228,7 @@ if (button) {
|
||||
// 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.
|
||||
store.view = "calendar";
|
||||
store.sphereFilter = null;
|
||||
store.projectFilter = null;
|
||||
store.filterAll();
|
||||
flushSync();
|
||||
await settle();
|
||||
|
||||
@@ -338,22 +337,74 @@ flushSync();
|
||||
check("u again handed it back", store.zone === "day", store.zone);
|
||||
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.railCollapsed = false;
|
||||
key(viewRoot, { key: "b", code: "KeyB" });
|
||||
flushSync();
|
||||
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: "Enter", code: "Enter" });
|
||||
flushSync();
|
||||
check("enter filtered by the row", store.sphereFilter !== null, `${store.sphereFilter}`);
|
||||
check("and stepped back out", store.zone !== "rail", store.zone);
|
||||
check("enter on a sphere shows only that sphere", store.railState === "some" && store.visible.length < everything, `${store.visible.length} of ${everything}`);
|
||||
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.
|
||||
store.view = "list";
|
||||
store.railCollapsed = false;
|
||||
store.leaveToMain();
|
||||
store.cursorTask = null;
|
||||
store.sphereFilter = null;
|
||||
store.filterAll();
|
||||
flushSync();
|
||||
await settle();
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.
|
||||
store.request = "priority";
|
||||
store.cursorTask = "nothing/at/all#0";
|
||||
|
||||
Reference in New Issue
Block a user