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 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 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 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 when today is on screen that hour is held at full scale exactly as an hour with an event in it
to get there: the axis grows by a row and a strip, not by an evening. Page to a week that does is: whatever strip reaches over it splits around it, the one outside the range and any fold
not contain today and it goes again, which is the one place the axis is allowed to move under you made alike. The axis grows by a row and a strip, not by an evening, and the range itself
you. 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 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 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 { memo, useMemo } from "react";
import { isFolded, timeToY, type Fold, type FitLayout } from "../grid/fit"; import { isFolded, timeToY, type Fold, type FitLayout } from "../grid/fit";
import { TOUCH_QUERY, useMediaQuery } from "../useMedia"; import { TOUCH_QUERY, useMediaQuery } from "../useMedia";
import { busyHours, rangeLabel, vars } from "./GridModel"; import { rangeLabel, vars } from "./GridModel";
import { useGrid } from "./GridStore"; import { useGrid } from "./GridStore";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@@ -53,17 +53,18 @@ export const FOLD_ICON_SIZE = 16;
const MIN_GAP_H = 24; const MIN_GAP_H = 24;
/** /**
* The empty run around `hour`: nothing scheduled on any visible day, nothing already folded, both * The empty run around `hour`: nothing scheduled on any visible day, not the hour it is now,
* ends stopped by the bounds. Null when the hour is busy, folded or off the axis. * 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 * 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 * 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. * 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; const { start: lo, end: hi } = layout.bounds;
if (hour < lo || hour >= hi) return null; 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; if (!free(hour)) return null;
let start = hour; let start = hour;
let end = hour + 1; 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. */ /** 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[] = []; const out: Fold[] = [];
let hour = layout.bounds.start; let hour = layout.bounds.start;
while (hour < layout.bounds.end) { while (hour < layout.bounds.end) {
const gap = gapAt(layout, busy, hour); const gap = gapAt(layout, held, hour);
if (!gap) { if (!gap) {
hour++; hour++;
continue; continue;
@@ -90,20 +91,19 @@ export function allGaps(layout: FitLayout, busy: readonly boolean[]): Fold[] {
export const GridGaps = memo(function GridGaps() { export const GridGaps = memo(function GridGaps() {
const layout = useGrid((s) => s.layout); const layout = useGrid((s) => s.layout);
const items = useGrid((s) => s.items); const held = useGrid((s) => s.held);
const hoverHour = useGrid((s) => s.hoverHour); const hoverHour = useGrid((s) => s.hoverHour);
// Booleans rather than the objects themselves: this must not repaint on every pointermove. // Booleans rather than the objects themselves: this must not repaint on every pointermove.
const gesturing = useGrid((s) => s.drag !== null || s.draft !== null); const gesturing = useGrid((s) => s.drag !== null || s.draft !== null);
const fold = useGrid((s) => s.fold); const fold = useGrid((s) => s.fold);
const touch = useMediaQuery(TOUCH_QUERY); const touch = useMediaQuery(TOUCH_QUERY);
const busy = useMemo(() => busyHours(items), [items]);
const gaps = useMemo(() => { const gaps = useMemo(() => {
if (!layout) return []; if (!layout) return [];
if (touch) return allGaps(layout, busy); if (touch) return allGaps(layout, held);
const hovered = hoverHour === null ? null : gapAt(layout, busy, hoverHour); const hovered = hoverHour === null ? null : gapAt(layout, held, hoverHour);
return hovered ? [hovered] : []; return hovered ? [hovered] : [];
}, [layout, busy, hoverHour, touch]); }, [layout, held, hoverHour, touch]);
if (!layout || gesturing) return null; if (!layout || gesturing) return null;
+26 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Instance } from "../ipc"; 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(); 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", () => { describe("bandAt", () => {
const items = [placed([9, 0], [9, 30]), placed([18, 0], [18, 30])]; const items = [placed([9, 0], [9, 30]), placed([18, 0], [18, 30])];
const bounds = { start: 7, end: 20 }; 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", () => { 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(held, bounds, 12)).toEqual({ start: 10, end: 18 });
expect(bandAt(items, bounds, 19)).toEqual({ start: 19, end: 20 }); 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", () => { 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 * The hours the grid keeps at full scale whatever fold or bound covers them: every hour an event
* a fold hides empty time and an hour with an event in it would come straight back. Interior gaps * touches, and the hour it is now when today is one of the columns. This is the one definition,
* never fold themselves, so this only runs on demand. * 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 { export function heldHours(items: readonly Placed[], nowHour: number | null): boolean[] {
const busy = busyHours(items); const held = busyHours(items);
if (busy[hour]) return null; 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 start = hour;
let end = hour + 1; let end = hour + 1;
while (start > bounds.start && !busy[start - 1]) start--; while (start > bounds.start && !held[start - 1]) start--;
while (end < bounds.end && !busy[end]) end++; while (end < bounds.end && !held[end]) end++;
return { start, end }; return { start, end };
} }
+9 -8
View File
@@ -15,7 +15,7 @@ import {
type Fold, type Fold,
} from "../grid/fit"; } from "../grid/fit";
import { loadFolds, saveFolds } from "../grid/folds"; 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"; export type DragMode = "create" | "move" | "resize-start" | "resize-end";
@@ -44,12 +44,13 @@ interface GridState {
floor: Bounds | null; floor: Bounds | null;
layout: FitLayout | null; layout: FitLayout | null;
days: number[]; 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; hoverHour: number | null;
drag: Drag | null; drag: Drag | null;
draft: Draft | null; draft: Draft | null;
saving: boolean; saving: boolean;
publish: (layout: FitLayout, days: number[], items: Placed[]) => void; publish: (layout: FitLayout, days: number[], held: boolean[]) => void;
setHoverHour: (hour: number | null) => void; setHoverHour: (hour: number | null) => void;
setDrag: (drag: Drag | null) => void; setDrag: (drag: Drag | null) => void;
setDraft: (draft: Draft | null) => void; setDraft: (draft: Draft | null) => void;
@@ -66,12 +67,12 @@ export const useGrid = create<GridState>((set, get) => ({
floor: null, floor: null,
layout: null, layout: null,
days: [], days: [],
items: [], held: heldHours([], null),
hoverHour: null, hoverHour: null,
drag: null, drag: null,
draft: null, draft: null,
saving: false, 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 })), setHoverHour: (hoverHour) => set((s) => (s.hoverHour === hoverHour ? {} : { hoverHour })),
setDrag: (drag) => set({ drag }), setDrag: (drag) => set({ drag }),
setDraft: (draft) => set({ draft }), setDraft: (draft) => set({ draft }),
@@ -81,7 +82,7 @@ export const useGrid = create<GridState>((set, get) => ({
set({ folds }); set({ folds });
}, },
unfold: (range) => { 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. // 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; const base = get().floor ?? get().layout?.bounds ?? DEFAULT_BOUNDS;
set({ set({
@@ -90,7 +91,7 @@ export const useGrid = create<GridState>((set, get) => ({
}); });
}, },
toggleFold: (hour) => { toggleFold: (hour) => {
const { layout, hoverHour, items } = get(); const { layout, hoverHour, held } = get();
if (!layout) return; if (!layout) return;
const at = hour ?? hoverHour; const at = hour ?? hoverHour;
if (at === null || at === undefined) return; 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 }); get().unfold({ start: segment.start / 60, end: segment.end / 60 });
return; return;
} }
const band = bandAt(items, layout.bounds, h); const band = bandAt(held, layout.bounds, h);
if (band) get().fold(band); if (band) get().fold(band);
}, },
unfoldAll: () => { unfoldAll: () => {
+18 -45
View File
@@ -20,15 +20,7 @@ import {
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from "react"; } from "react";
import { eventCreate, eventUpdate } from "../api/events"; import { eventCreate, eventUpdate } from "../api/events";
import { import { computeBounds, computeFit, timeToY, yToTime, type Bounds, type Fold } from "../grid/fit";
computeBounds,
computeFit,
timeToY,
trimFolds,
yToTime,
type Bounds,
type Fold,
} from "../grid/fit";
import { loadBounds, saveBounds } from "../grid/folds"; import { loadBounds, saveBounds } from "../grid/folds";
import { useCalendarView } from "../store/useCalendarView"; import { useCalendarView } from "../store/useCalendarView";
import { notify } from "../store/useToast"; import { notify } from "../store/useToast";
@@ -52,9 +44,9 @@ import { GridGaps } from "./GridGaps";
import { GridGhost } from "./GridGhost"; import { GridGhost } from "./GridGhost";
import { import {
MIN_EVENT_MINUTES, MIN_EVENT_MINUTES,
busyHours,
clampMinutes, clampMinutes,
dayMinutes, dayMinutes,
heldHours,
hourLabel, hourLabel,
inBand, inBand,
isVisible, isVisible,
@@ -240,21 +232,19 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
const timed = useMemo(() => byDay.flat(), [byDay]); const timed = useMemo(() => byDay.flat(), [byDay]);
// The hour the clock is in, when today is one of the columns. // 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
// The axis is drawn from the events, and at nine in the evening the events are usually behind // its own tick, in `GridNowLine`.
// 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`.
const hourStart = useHourStart(); const hourStart = useHourStart();
const nowBand = useMemo((): Bounds | null => { const nowHour = useMemo(
if (!days.includes(startOfDay(hourStart))) return null; () => (days.includes(startOfDay(hourStart)) ? new Date(hourStart).getHours() : null),
const hour = new Date(hourStart).getHours(); [days, hourStart],
return { start: hour, end: hour + 1 }; );
}, [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: // 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. // 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 const bounds = floor
? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) } ? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) }
: auto; : 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; previous.current = bounds;
// A fold hides empty hours. Any hour an event on screen covers is given back at full scale, return computeFit({ bounds, folds, hold: held, viewportHeight: viewportH });
// however it got there: typed into the palette, moved in the editor, or synced in from Google. }, [timed, folds, floor, viewportH, held]);
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]);
useEffect(() => { useEffect(() => {
const signature = `${layout.bounds.start} ${layout.bounds.end}`; const signature = `${layout.bounds.start} ${layout.bounds.end}`;
@@ -293,8 +266,8 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
const publish = useGrid((s) => s.publish); const publish = useGrid((s) => s.publish);
useEffect(() => { useEffect(() => {
if (viewportH > 0) publish(layout, days, timed); if (viewportH > 0) publish(layout, days, held);
}, [publish, layout, days, timed, viewportH]); }, [publish, layout, days, held, viewportH]);
const todayMs = today(); const todayMs = today();
const todayIndex = days.findIndex((d) => d === todayMs); const todayIndex = days.findIndex((d) => d === todayMs);
+59
View File
@@ -333,6 +333,65 @@ describe("computeFit", () => {
}); });
expect(layout.bounds).toEqual({ start: 8, end: 18 }); 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", () => { describe("timeToY and yToTime", () => {
+19 -21
View File
@@ -60,6 +60,11 @@ export interface FitInput {
previous?: Bounds; previous?: Bounds;
/** Interior bands the user folded with `z`, in hours. Order and overlap do not matter. */ /** Interior bands the user folded with `z`, in hours. Order and overlap do not matter. */
folds?: readonly Fold[]; 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. */ /** Pixels available to the grid body, below the day bar and the all-day band. */
viewportHeight: number; viewportHeight: number;
/** Pins the axis, skipping both the event scan and hysteresis. Still clamped and widened. */ /** 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 * 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 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[] = []; const out: Fold[] = [];
for (const f of normalizeFolds(folds)) { for (const f of normalizeFolds(folds)) {
let start = f.start; let start = f.start;
for (let h = f.start; h < f.end; h++) { 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 }); if (h > start) out.push({ start, end: h });
start = h + 1; 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 * 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. * 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[] = []; const out: Fold[] = [];
for (const f of normalizeFolds(folds)) { for (const f of normalizeFolds(folds)) {
if (!overlaps(f, strip)) { if (!overlaps(f, strip)) {
out.push(f); out.push(f);
continue; 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; 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. * 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 * 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 * they sit, and the hours left over share whatever height remains. A held hour is never inside a
* `minRowHeight` the row height sticks there and `overflow` says the grid has to scroll. * 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 { export function computeFit(input: FitInput): FitLayout {
const stripHeight = input.stripHeight ?? STRIP_H; const stripHeight = input.stripHeight ?? STRIP_H;
@@ -258,19 +265,10 @@ export function computeFit(input: FitInput): FitLayout {
? widen(input.bounds.start, input.bounds.end) ? widen(input.bounds.start, input.bounds.end)
: computeBounds(input.events ?? [], input.previous ?? DEFAULT_BOUNDS); : computeBounds(input.events ?? [], input.previous ?? DEFAULT_BOUNDS);
const boundStart = bounds.start * 60; const folded = trimFolds(
const boundEnd = bounds.end * 60; [{ start: 0, end: bounds.start }, ...(input.folds ?? []), { start: bounds.end, end: 24 }],
input.hold ?? [],
const interior = normalizeFolds(input.folds ?? []).map((f) => ({ ).map((f) => ({ start: f.start * 60, end: f.end * 60 }));
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 },
]);
let foldedMinutes = 0; let foldedMinutes = 0;
for (const f of folded) foldedMinutes += f.end - f.start; 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); 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 }) => { test("is gone again on a week that does not contain today", async ({ page }) => {
await openApp(page, { now: LATE() }); await openApp(page, { now: LATE() });
const before = (await axis(page)).map((entry) => entry.text); const before = (await axis(page)).map((entry) => entry.text);