hold the hour it is now open through every strip, folds included

This commit is contained in:
pj committed 2026-09-03 11:22:44 +05:30
1 parent 4b3ef9c62c
commit 9462b7920f
9 files changed
+186 -102

No files matched your search

+6 -4
View File
@@ -32,10 +32,12 @@ flickering axis destroys the positional memory that makes a keyboard-driven cale
The hour you are in is the one exception to the range being the events' business. At half
eleven at night the events stopped hours ago, so the now line has nowhere to land and the app
stops telling you where in the day you are, which is most of what a calendar is open for. So
when today is on screen the axis takes that hour in, and folds away everything it reached over
to get there: the axis grows by a row and a strip, not by an evening. Page to a week that does
not contain today and it goes again, which is the one place the axis is allowed to move under
you.
when today is on screen that hour is held at full scale exactly as an hour with an event in it
is: whatever strip reaches over it splits around it, the one outside the range and any fold
you made alike. The axis grows by a row and a strip, not by an evening, and the range itself
does not move, so the pin never leaks into the bounds the grid remembers. Page to a week that
does not contain today and the row goes again, which is the one place the axis is allowed to
move under you.
Interior gaps stay at full scale. A three-hour hole on a Wednesday afternoon is the most
useful thing on the screen, because it is where work goes, and folding it automatically would
+12 -12
View File
@@ -15,7 +15,7 @@
import { memo, useMemo } from "react";
import { isFolded, timeToY, type Fold, type FitLayout } from "../grid/fit";
import { TOUCH_QUERY, useMediaQuery } from "../useMedia";
import { busyHours, rangeLabel, vars } from "./GridModel";
import { rangeLabel, vars } from "./GridModel";
import { useGrid } from "./GridStore";
import { Icon } from "./Icon";
@@ -53,17 +53,18 @@ export const FOLD_ICON_SIZE = 16;
const MIN_GAP_H = 24;
/**
* The empty run around `hour`: nothing scheduled on any visible day, nothing already folded, both
* ends stopped by the bounds. Null when the hour is busy, folded or off the axis.
* The empty run around `hour`: nothing scheduled on any visible day, not the hour it is now,
* nothing already folded, both ends stopped by the bounds. Null when the hour is held, folded or
* off the axis.
*
* This is deliberately not `bandAt`, which is what `z` folds and which walks straight through an
* existing fold. What the mouse offers has to be exactly what the label says, so a run cut in two
* by a fold reads as two runs and collapses as the half you pointed at. Folding merges them anyway.
*/
export function gapAt(layout: FitLayout, busy: readonly boolean[], hour: number): Fold | null {
export function gapAt(layout: FitLayout, held: readonly boolean[], hour: number): Fold | null {
const { start: lo, end: hi } = layout.bounds;
if (hour < lo || hour >= hi) return null;
const free = (h: number) => !busy[h] && !isFolded(layout, h * 60);
const free = (h: number) => !held[h] && !isFolded(layout, h * 60);
if (!free(hour)) return null;
let start = hour;
let end = hour + 1;
@@ -73,11 +74,11 @@ export function gapAt(layout: FitLayout, busy: readonly boolean[], hour: number)
}
/** Every empty run on the axis, which is what a pointer with no hover has to be given instead. */
export function allGaps(layout: FitLayout, busy: readonly boolean[]): Fold[] {
export function allGaps(layout: FitLayout, held: readonly boolean[]): Fold[] {
const out: Fold[] = [];
let hour = layout.bounds.start;
while (hour < layout.bounds.end) {
const gap = gapAt(layout, busy, hour);
const gap = gapAt(layout, held, hour);
if (!gap) {
hour++;
continue;
@@ -90,20 +91,19 @@ export function allGaps(layout: FitLayout, busy: readonly boolean[]): Fold[] {
export const GridGaps = memo(function GridGaps() {
const layout = useGrid((s) => s.layout);
const items = useGrid((s) => s.items);
const held = useGrid((s) => s.held);
const hoverHour = useGrid((s) => s.hoverHour);
// Booleans rather than the objects themselves: this must not repaint on every pointermove.
const gesturing = useGrid((s) => s.drag !== null || s.draft !== null);
const fold = useGrid((s) => s.fold);
const touch = useMediaQuery(TOUCH_QUERY);
const busy = useMemo(() => busyHours(items), [items]);
const gaps = useMemo(() => {
if (!layout) return [];
if (touch) return allGaps(layout, busy);
const hovered = hoverHour === null ? null : gapAt(layout, busy, hoverHour);
if (touch) return allGaps(layout, held);
const hovered = hoverHour === null ? null : gapAt(layout, held, hoverHour);
return hovered ? [hovered] : [];
}, [layout, busy, hoverHour, touch]);
}, [layout, held, hoverHour, touch]);
if (!layout || gesturing) return null;
+26 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Instance } from "../ipc";
import { bandAt, busyHours, type Placed } from "./GridModel";
import { bandAt, busyHours, heldHours, type Placed } from "./GridModel";
const at = (hour: number, minute = 0) => new Date(2026, 0, 5, hour, minute).getTime();
@@ -20,16 +20,38 @@ describe("busyHours", () => {
});
});
describe("heldHours", () => {
const items = [placed([9, 0], [9, 30])];
const hours = (held: boolean[]) => held.flatMap((b, h) => (b ? [h] : []));
it("is the busy hours plus the hour it is now", () => {
expect(hours(heldHours(items, 1))).toEqual([1, 9]);
expect(hours(heldHours(items, 9))).toEqual([9]);
});
it("is just the busy hours when today is not on screen", () => {
expect(hours(heldHours(items, null))).toEqual([9]);
});
});
describe("bandAt", () => {
const items = [placed([9, 0], [9, 30]), placed([18, 0], [18, 30])];
const bounds = { start: 7, end: 20 };
const held = heldHours(items, null);
it("is the whole empty run around the hour, stopped by the events and the bounds", () => {
expect(bandAt(items, bounds, 12)).toEqual({ start: 10, end: 18 });
expect(bandAt(items, bounds, 19)).toEqual({ start: 19, end: 20 });
expect(bandAt(held, bounds, 12)).toEqual({ start: 10, end: 18 });
expect(bandAt(held, bounds, 19)).toEqual({ start: 19, end: 20 });
});
it("offers nothing on a busy hour, since a fold there would come straight back", () => {
expect(bandAt(items, bounds, 9)).toBeNull();
expect(bandAt(held, bounds, 9)).toBeNull();
});
it("stops at the hour it is now and offers nothing on it", () => {
const now = heldHours(items, 13);
expect(bandAt(now, bounds, 12)).toEqual({ start: 10, end: 13 });
expect(bandAt(now, bounds, 15)).toEqual({ start: 14, end: 18 });
expect(bandAt(now, bounds, 13)).toBeNull();
});
});
+19 -8
View File
@@ -294,17 +294,28 @@ export function busyHours(items: readonly Placed[]): boolean[] {
}
/**
* The band `z` would fold at this hour: the whole empty run around it. Null on a busy hour, since
* a fold hides empty time and an hour with an event in it would come straight back. Interior gaps
* never fold themselves, so this only runs on demand.
* The hours the grid keeps at full scale whatever fold or bound covers them: every hour an event
* touches, and the hour it is now when today is one of the columns. This is the one definition,
* and everything that hides an hour or offers to hide one goes through it.
*/
export function bandAt(items: readonly Placed[], bounds: Bounds, hour: number): Fold | null {
const busy = busyHours(items);
if (busy[hour]) return null;
export function heldHours(items: readonly Placed[], nowHour: number | null): boolean[] {
const held = busyHours(items);
if (nowHour !== null && nowHour >= 0 && nowHour < 24) held[nowHour] = true;
return held;
}
/**
* The band `z` would fold at this hour: the whole empty run around it, stopped by the held hours
* and the bounds. Null on a held hour, since a fold hides empty time and a fold over an hour with
* an event in it, or the hour it is now, would come straight back. Interior gaps never fold
* themselves, so this only runs on demand.
*/
export function bandAt(held: readonly boolean[], bounds: Bounds, hour: number): Fold | null {
if (held[hour]) return null;
let start = hour;
let end = hour + 1;
while (start > bounds.start && !busy[start - 1]) start--;
while (end < bounds.end && !busy[end]) end++;
while (start > bounds.start && !held[start - 1]) start--;
while (end < bounds.end && !held[end]) end++;
return { start, end };
}
+9 -8
View File
@@ -15,7 +15,7 @@ import {
type Fold,
} from "../grid/fit";
import { loadFolds, saveFolds } from "../grid/folds";
import { bandAt, busyHours, type Placed } from "./GridModel";
import { bandAt, heldHours } from "./GridModel";
export type DragMode = "create" | "move" | "resize-start" | "resize-end";
@@ -44,12 +44,13 @@ interface GridState {
floor: Bounds | null;
layout: FitLayout | null;
days: number[];
items: Placed[];
/** The hours no fold may hide: see `heldHours`. Published with the layout it was solved for. */
held: boolean[];
hoverHour: number | null;
drag: Drag | null;
draft: Draft | null;
saving: boolean;
publish: (layout: FitLayout, days: number[], items: Placed[]) => void;
publish: (layout: FitLayout, days: number[], held: boolean[]) => void;
setHoverHour: (hour: number | null) => void;
setDrag: (drag: Drag | null) => void;
setDraft: (draft: Draft | null) => void;
@@ -66,12 +67,12 @@ export const useGrid = create<GridState>((set, get) => ({
floor: null,
layout: null,
days: [],
items: [],
held: heldHours([], null),
hoverHour: null,
drag: null,
draft: null,
saving: false,
publish: (layout, days, items) => set({ layout, days, items }),
publish: (layout, days, held) => set({ layout, days, held }),
setHoverHour: (hoverHour) => set((s) => (s.hoverHour === hoverHour ? {} : { hoverHour })),
setDrag: (drag) => set({ drag }),
setDraft: (draft) => set({ draft }),
@@ -81,7 +82,7 @@ export const useGrid = create<GridState>((set, get) => ({
set({ folds });
},
unfold: (range) => {
const folds = saveFolds(unfoldStrip(get().folds, busyHours(get().items), range));
const folds = saveFolds(unfoldStrip(get().folds, get().held, range));
// An out-of-bounds band has no fold to remove, so widening the floor is what holds it open.
const base = get().floor ?? get().layout?.bounds ?? DEFAULT_BOUNDS;
set({
@@ -90,7 +91,7 @@ export const useGrid = create<GridState>((set, get) => ({
});
},
toggleFold: (hour) => {
const { layout, hoverHour, items } = get();
const { layout, hoverHour, held } = get();
if (!layout) return;
const at = hour ?? hoverHour;
if (at === null || at === undefined) return;
@@ -100,7 +101,7 @@ export const useGrid = create<GridState>((set, get) => ({
get().unfold({ start: segment.start / 60, end: segment.end / 60 });
return;
}
const band = bandAt(items, layout.bounds, h);
const band = bandAt(held, layout.bounds, h);
if (band) get().fold(band);
},
unfoldAll: () => {
+18 -45
View File
@@ -20,15 +20,7 @@ import {
type PointerEvent as ReactPointerEvent,
} from "react";
import { eventCreate, eventUpdate } from "../api/events";
import {
computeBounds,
computeFit,
timeToY,
trimFolds,
yToTime,
type Bounds,
type Fold,
} from "../grid/fit";
import { computeBounds, computeFit, timeToY, yToTime, type Bounds, type Fold } from "../grid/fit";
import { loadBounds, saveBounds } from "../grid/folds";
import { useCalendarView } from "../store/useCalendarView";
import { notify } from "../store/useToast";
@@ -52,9 +44,9 @@ import { GridGaps } from "./GridGaps";
import { GridGhost } from "./GridGhost";
import {
MIN_EVENT_MINUTES,
busyHours,
clampMinutes,
dayMinutes,
heldHours,
hourLabel,
inBand,
isVisible,
@@ -240,21 +232,19 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
const timed = useMemo(() => byDay.flat(), [byDay]);
// The hour the clock is in, when today is one of the columns.
//
// The axis is drawn from the events, and at nine in the evening the events are usually behind
// you: the bounds end at six, the rest of the day is in the trailing strip, and the now line has
// nowhere to land. So the hour you are in is taken into the bounds when it falls outside them.
// One hour, and only the hour, so the axis gains a row rather than an evening.
//
// Keyed on the hour rather than the minute, because that is how often the answer changes. The
// line itself moves every minute, on its own tick, in `GridNowLine`.
// The hour the clock is in, when today is one of the columns. Keyed on the hour rather than the
// minute, because that is how often the answer changes. The line itself moves every minute, on
// its own tick, in `GridNowLine`.
const hourStart = useHourStart();
const nowBand = useMemo((): Bounds | null => {
if (!days.includes(startOfDay(hourStart))) return null;
const hour = new Date(hourStart).getHours();
return { start: hour, end: hour + 1 };
}, [days, hourStart]);
const nowHour = useMemo(
() => (days.includes(startOfDay(hourStart)) ? new Date(hourStart).getHours() : null),
[days, hourStart],
);
// The hours nothing may hide: every hour an event on screen covers, and the hour it is now, so
// the now line always has a row to land in. They are carved out of every strip alike, the ones
// outside the bounds and the ones you folded, and the bounds themselves stay the events' business.
const held = useMemo(() => heldHours(timed, nowHour), [timed, nowHour]);
// Hysteresis needs the bounds it adopted last pass, so they live in a ref rather than in state:
// paging a week must not reflow the axis, and a render loop through state would fight that.
@@ -263,26 +253,9 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
const bounds = floor
? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) }
: auto;
// What the events and the user asked for is what the hysteresis remembers. The clock's hour is
// a pin laid over the top of it every hour, so it never accumulates into the axis it pins.
previous.current = bounds;
// A fold hides empty hours. Any hour an event on screen covers is given back at full scale,
// however it got there: typed into the palette, moved in the editor, or synced in from Google.
const shown = trimFolds(folds, busyHours(timed));
if (!nowBand) return computeFit({ bounds, folds: shown, viewportHeight: viewportH });
// Everything the widening reached over folds behind it. At half eleven at night the axis is
// one row longer and one strip taller, rather than four rows of empty evening shorter.
const reach: Fold[] = [
{ start: nowBand.end, end: bounds.start },
{ start: bounds.end, end: nowBand.start },
];
const axis = {
start: Math.min(bounds.start, nowBand.start),
end: Math.max(bounds.end, nowBand.end),
};
return computeFit({ bounds: axis, folds: [...shown, ...reach], viewportHeight: viewportH });
}, [timed, folds, floor, viewportH, nowBand]);
return computeFit({ bounds, folds, hold: held, viewportHeight: viewportH });
}, [timed, folds, floor, viewportH, held]);
useEffect(() => {
const signature = `${layout.bounds.start} ${layout.bounds.end}`;
@@ -293,8 +266,8 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
const publish = useGrid((s) => s.publish);
useEffect(() => {
if (viewportH > 0) publish(layout, days, timed);
}, [publish, layout, days, timed, viewportH]);
if (viewportH > 0) publish(layout, days, held);
}, [publish, layout, days, held, viewportH]);
const todayMs = today();
const todayIndex = days.findIndex((d) => d === todayMs);
+59
View File
@@ -333,6 +333,65 @@ describe("computeFit", () => {
});
expect(layout.bounds).toEqual({ start: 8, end: 18 });
});
it("opens a held hour outside the bounds as one row between two strips", () => {
const layout = computeFit({ bounds: { start: 8, end: 20 }, hold: busyAt(1), viewportHeight: 600 });
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
["strip", 0, 60],
["hours", 60, 120],
["strip", 120, 480],
["hours", 480, 1200],
["strip", 1200, 1440],
]);
// The row is the clock's, not the events': the bounds it reports are still theirs.
expect(layout.bounds).toEqual({ start: 8, end: 20 });
expect(layout.unfoldedHours).toBe(13);
expect(layout.rowHeight).toBeCloseTo((600 - 3 * STRIP_H) / 13, 10);
});
it("opens a held hour at the edge of the day without a strip beyond it", () => {
const layout = computeFit({ bounds: { start: 8, end: 20 }, hold: busyAt(23), viewportHeight: 600 });
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
["strip", 0, 480],
["hours", 480, 1200],
["strip", 1200, 1380],
["hours", 1380, 1440],
]);
});
it("opens a held hour inside a fold that merged into the end strip", () => {
const layout = computeFit({
bounds: { start: 8, end: 20 },
folds: [{ start: 0, end: 8 }],
hold: busyAt(1),
viewportHeight: 600,
});
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
["strip", 0, 60],
["hours", 60, 120],
["strip", 120, 480],
["hours", 480, 1200],
["strip", 1200, 1440],
]);
});
it("opens a held hour inside an interior fold", () => {
const layout = computeFit({
bounds: { start: 8, end: 20 },
folds: [{ start: 12, end: 16 }],
hold: busyAt(14),
viewportHeight: 600,
});
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
["strip", 0, 480],
["hours", 480, 720],
["strip", 720, 840],
["hours", 840, 900],
["strip", 900, 960],
["hours", 960, 1200],
["strip", 1200, 1440],
]);
});
});
describe("timeToY and yToTime", () => {
+19 -21
View File
@@ -60,6 +60,11 @@ export interface FitInput {
previous?: Bounds;
/** Interior bands the user folded with `z`, in hours. Order and overlap do not matter. */
folds?: readonly Fold[];
/**
* Hours kept at full scale whatever covers them, indexed by hour: the ones with an event in
* them, and the hour it is now. A strip that reaches over one of these splits around it.
*/
hold?: readonly boolean[];
/** Pixels available to the grid body, below the day bar and the all-day band. */
viewportHeight: number;
/** Pins the axis, skipping both the event scan and hysteresis. Still clamped and widened. */
@@ -204,17 +209,17 @@ export function addFold(folds: readonly Fold[], range: Fold): Fold[] {
}
/**
* The folds with the busy hours taken out of them. A fold hides empty time and nothing else, so
* The folds with the held hours taken out of them. A fold hides empty time and nothing else, so
* an event landing in a folded hour gives that hour back at once, and a fold with an event in
* the middle of it shows as two strips. The range itself is kept as it was: page to a span where
* the hour is empty again and it folds back.
* the hour is empty again and it folds back. The hour it is now is held the same way.
*/
export function trimFolds(folds: readonly Fold[], busy: readonly boolean[]): Fold[] {
export function trimFolds(folds: readonly Fold[], held: readonly boolean[]): Fold[] {
const out: Fold[] = [];
for (const f of normalizeFolds(folds)) {
let start = f.start;
for (let h = f.start; h < f.end; h++) {
if (!busy[h]) continue;
if (!held[h]) continue;
if (h > start) out.push({ start, end: h });
start = h + 1;
}
@@ -230,14 +235,14 @@ const overlaps = (a: HourRange, b: HourRange): boolean => a.start < b.end && a.e
* showing and nothing else: the hours an event was covering go too, so expanding a strip never
* leaves a piece of fold behind that could come back on its own once the event moves.
*/
export function unfoldStrip(folds: readonly Fold[], busy: readonly boolean[], strip: Fold): Fold[] {
export function unfoldStrip(folds: readonly Fold[], held: readonly boolean[], strip: Fold): Fold[] {
const out: Fold[] = [];
for (const f of normalizeFolds(folds)) {
if (!overlaps(f, strip)) {
out.push(f);
continue;
}
for (const piece of trimFolds([f], busy)) if (!overlaps(piece, strip)) out.push(piece);
for (const piece of trimFolds([f], held)) if (!overlaps(piece, strip)) out.push(piece);
}
return out;
}
@@ -246,8 +251,10 @@ export function unfoldStrip(folds: readonly Fold[], busy: readonly boolean[], st
* Solves the row height for the viewport and lays the day out.
*
* The bands outside the bounds fold into a strip at each end, interior folds become strips where
* they sit, and the hours left over share whatever height remains. When that share falls below
* `minRowHeight` the row height sticks there and `overflow` says the grid has to scroll.
* they sit, and the hours left over share whatever height remains. A held hour is never inside a
* strip: whichever of those reaches over it splits around it. When the hours' share of the height
* falls below `minRowHeight` the row height sticks there and `overflow` says the grid has to
* scroll.
*/
export function computeFit(input: FitInput): FitLayout {
const stripHeight = input.stripHeight ?? STRIP_H;
@@ -258,19 +265,10 @@ export function computeFit(input: FitInput): FitLayout {
? widen(input.bounds.start, input.bounds.end)
: computeBounds(input.events ?? [], input.previous ?? DEFAULT_BOUNDS);
const boundStart = bounds.start * 60;
const boundEnd = bounds.end * 60;
const interior = normalizeFolds(input.folds ?? []).map((f) => ({
start: clamp(f.start * 60, boundStart, boundEnd),
end: clamp(f.end * 60, boundStart, boundEnd),
}));
const folded = mergeRanges([
{ start: 0, end: boundStart },
...interior,
{ start: boundEnd, end: MINUTES_PER_DAY },
]);
const folded = trimFolds(
[{ start: 0, end: bounds.start }, ...(input.folds ?? []), { start: bounds.end, end: 24 }],
input.hold ?? [],
).map((f) => ({ start: f.start * 60, end: f.end * 60 }));
let foldedMinutes = 0;
for (const f of folded) foldedMinutes += f.end - f.start;
+18
View File
@@ -250,6 +250,24 @@ test.describe("the hour it is now", () => {
expect(fit.overflow).toBe(false);
});
test("is on the axis inside a fold you made that covers it", async ({ page }) => {
// A fold over the whole evening, made on some earlier day and remembered since. It used to
// swallow the clock's hour along with the trailing strip it merged into, and the line with it.
await openApp(page, {
now: LATE(),
storage: { "margincal-folds": JSON.stringify([{ start: 19, end: 24 }]) },
});
expect((await axis(page)).map((entry) => entry.text)).toContain("11pm");
await expect(page.locator(".grid-now")).toHaveCount(1);
// The fold is still there, split around the hour it is now rather than dropped.
await expect(page.locator(".grid-strip", { hasText: "7pm to 11pm" })).toHaveCount(1);
await expect(page.locator(".grid-strip", { hasText: /to 12am/ })).toHaveCount(0);
// And the bounds the grid remembers are the events', not the clock's.
const saved = await page.evaluate(() => localStorage.getItem("margincal-bounds"));
expect(JSON.parse(saved ?? "null")).toEqual({ start: 7, end: 20 });
});
test("is gone again on a week that does not contain today", async ({ page }) => {
await openApp(page, { now: LATE() });
const before = (await axis(page)).map((entry) => entry.text);