fix bug on hour fold update

This commit is contained in:
pj committed 2026-09-02 13:08:02 +05:30
1 parent f7d3505ab2
commit af8e66e84d
13 files changed
+243 -65

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
## Project Guidelines
- Do not call the task done until it is fully complete and tested.
- Do not dismiss bug as a pre-existing" issue even if it was present before your change. It does not matter, it's still your responsibility to fix it. When you see a bug, fix it. Don't ignore it.
## Coding Guidelines
- Keep code simple and easy to read.
- Avoid excessive comments. Only comment when absolutely necessary. Code should be readable and not require comments to understand it.
## Git Commit Rules
- Do not make branches, commit in main only
- Commit message is one plain lowercase line. No type prefix, no scope, no body.
- Never use `git add .` or `git add -A`. Always stage specific files by name.
- Don't batch multiple unrelated changes into one commit.
+9 -2
View File
@@ -23,8 +23,7 @@ always fits exactly.
The visible range is computed from the events in the span you are looking at: floor to the The visible range is computed from the events in the span you are looking at: floor to the
hour before your earliest event, ceil to the hour after your latest, clamped to a minimum of hour before your earliest event, ceil to the hour after your latest, clamped to a minimum of
eight hours so a quiet week does not render four enormous rows. Empty bands at the top and eight hours so a quiet week does not render four enormous rows. Empty bands at the top and
bottom fold into thin strips labelled with their range and a count of anything hiding inside. bottom fold into thin strips labelled with their range. Click or press `z` to unfold one.
Click or press `z` to unfold one.
The range expands immediately when it needs to, but only contracts when it would shrink by two The range expands immediately when it needs to, but only contracts when it would shrink by two
hours or more. Without that hysteresis the axis flickers as you page through weeks, and a hours or more. Without that hysteresis the axis flickers as you page through weeks, and a
@@ -44,6 +43,14 @@ make a packed day look identical to an open one. But you can fold one deliberate
and it stays folded across navigation until you unfold it. The fold is per range, remembered and it stays folded across navigation until you unfold it. The fold is per range, remembered
in local state, not derived from the data. in local state, not derived from the data.
A fold only ever hides empty hours. The range is what is remembered; what the strip hides is
that range minus whatever the events on screen cover. Create an event at four inside a fold of
two to five, by any route, and the strip shrinks to two to four with the event at full scale
under it. An event in the middle of a fold splits it into a strip either side. Page to a week
where the hour is empty again and the whole range folds back. Expanding a strip takes exactly
that strip out of its fold: another strip the same fold was showing stays, and the hours an
event was covering are not kept to come back later.
One consequence worth stating plainly: within the unfolded region the scale is strictly One consequence worth stating plainly: within the unfolded region the scale is strictly
linear, so a block twice as tall is an event twice as long, always. That property is why the linear, so a block twice as tall is an event twice as long, always. That property is why the
grid is worth having at all, and it is the thing automatic gap-folding would have cost. grid is worth having at all, and it is the thing automatic gap-folding would have cost.
+1 -1
View File
@@ -2276,7 +2276,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "margin-calendar" name = "margin-calendar"
version = "0.0.1" version = "0.0.2"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"block2", "block2",
+2 -4
View File
@@ -39,15 +39,13 @@ export const GridDay = memo(function GridDay({
{packed.map((p) => { {packed.map((p) => {
const { startMin, endMin } = dayMinutes(p.event, dayStart); const { startMin, endMin } = dayMinutes(p.event, dayStart);
const top = timeToY(layout, startMin); const top = timeToY(layout, startMin);
const raw = timeToY(layout, endMin) - top; const height = Math.max(timeToY(layout, endMin) - top, MIN_BLOCK_H);
// Both ends inside the same strip: it is hiding in there, and the strip carries the count.
if (raw < 1) return null;
return ( return (
<GridEvent <GridEvent
key={p.event.id} key={p.event.id}
item={p.event} item={p.event}
top={top} top={top}
height={Math.max(raw, MIN_BLOCK_H)} height={height}
left={p.left} left={p.left}
width={p.width} width={p.width}
selected={sameKey(selected, keyOf(p.event.instance))} selected={sameKey(selected, keyOf(p.event.instance))}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import type { Instance } from "../ipc";
import { bandAt, busyHours, type Placed } from "./GridModel";
const at = (hour: number, minute = 0) => new Date(2026, 0, 5, hour, minute).getTime();
/** Only the times matter to the hour scan, so the instance behind them is left blank. */
const placed = (from: [number, number], to: [number, number]): Placed => ({
instance: {} as Instance,
id: `${from[0]}:${from[1]}`,
startMs: at(...from),
endMs: at(...to),
});
describe("busyHours", () => {
it("marks every whole hour an event touches", () => {
const busy = busyHours([placed([14, 30], [16, 0]), placed([16, 0], [16, 15])]);
const hours = busy.flatMap((b, h) => (b ? [h] : []));
expect(hours).toEqual([14, 15, 16]);
});
});
describe("bandAt", () => {
const items = [placed([9, 0], [9, 30]), placed([18, 0], [18, 30])];
const bounds = { start: 7, end: 20 };
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 });
});
it("offers nothing on a busy hour, since a fold there would come straight back", () => {
expect(bandAt(items, bounds, 9)).toBeNull();
});
});
Binary file not shown.
+6 -12
View File
@@ -9,14 +9,13 @@ import { create } from "zustand";
import { import {
DEFAULT_BOUNDS, DEFAULT_BOUNDS,
addFold, addFold,
foldAt, unfoldStrip,
normalizeFolds,
type Bounds, type Bounds,
type FitLayout, type FitLayout,
type Fold, type Fold,
} from "../grid/fit"; } from "../grid/fit";
import { loadFolds, saveFolds } from "../grid/folds"; import { loadFolds, saveFolds } from "../grid/folds";
import { bandAt, type Placed } from "./GridModel"; import { bandAt, busyHours, type Placed } from "./GridModel";
export type DragMode = "create" | "move" | "resize-start" | "resize-end"; export type DragMode = "create" | "move" | "resize-start" | "resize-end";
@@ -82,9 +81,7 @@ export const useGrid = create<GridState>((set, get) => ({
set({ folds }); set({ folds });
}, },
unfold: (range) => { unfold: (range) => {
const folds = saveFolds( const folds = saveFolds(unfoldStrip(get().folds, busyHours(get().items), range));
normalizeFolds(get().folds).filter((f) => f.end <= range.start || f.start >= range.end),
);
// 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({
@@ -93,7 +90,7 @@ export const useGrid = create<GridState>((set, get) => ({
}); });
}, },
toggleFold: (hour) => { toggleFold: (hour) => {
const { layout, hoverHour, items, folds } = get(); const { layout, hoverHour, items } = 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;
@@ -103,11 +100,8 @@ 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;
} }
if (foldAt(folds, h)) { const band = bandAt(items, layout.bounds, h);
get().unfold({ start: h, end: h + 1 }); if (band) get().fold(band);
return;
}
get().fold(bandAt(items, layout.bounds, h));
}, },
unfoldAll: () => { unfoldAll: () => {
saveFolds([]); saveFolds([]);
+4 -5
View File
@@ -1,7 +1,8 @@
// A folded band. The empty hours outside the bounds fold themselves; an interior band only folds // A folded band. The empty hours outside the bounds fold themselves; an interior band only folds
// because you asked for it, with `z` or with the chip that hangs off an empty run. Either way the // because you asked for it, with `z` or with the chip that hangs off an empty run. Either way the
// strip says what it covers, how much is hiding in there, and which way it goes: the whole band is // strip says what it covers and which way it goes: the whole band is the button, and the mark next
// the button, and the mark next to its range says Expand. // to its range says Expand. It never has anything to count: an hour with an event in it is not
// folded, whatever fold it sits inside.
import { memo } from "react"; import { memo } from "react";
import type { Segment } from "../grid/fit"; import type { Segment } from "../grid/fit";
@@ -11,11 +12,10 @@ import { Icon } from "./Icon";
interface GridStripProps { interface GridStripProps {
segment: Segment; segment: Segment;
hidden: number;
onUnfold: (range: { start: number; end: number }) => void; onUnfold: (range: { start: number; end: number }) => void;
} }
export const GridStrip = memo(function GridStrip({ segment, hidden, onUnfold }: GridStripProps) { export const GridStrip = memo(function GridStrip({ segment, onUnfold }: GridStripProps) {
const range = { start: segment.start / 60, end: segment.end / 60 }; const range = { start: segment.start / 60, end: segment.end / 60 };
const label = `Expand ${rangeLabel(range)}`; const label = `Expand ${rangeLabel(range)}`;
return ( return (
@@ -34,7 +34,6 @@ export const GridStrip = memo(function GridStrip({ segment, hidden, onUnfold }:
<span className="grid-fold-verb">Expand</span> <span className="grid-fold-verb">Expand</span>
</span> </span>
<span className="grid-strip-rule" /> <span className="grid-strip-rule" />
{hidden > 0 && <span className="grid-strip-count">{hidden}</span>}
</button> </button>
); );
}); });
+8 -18
View File
@@ -24,6 +24,7 @@ import {
computeBounds, computeBounds,
computeFit, computeFit,
timeToY, timeToY,
trimFolds,
yToTime, yToTime,
type Bounds, type Bounds,
type Fold, type Fold,
@@ -51,6 +52,7 @@ 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,
hourLabel, hourLabel,
@@ -264,7 +266,10 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
// What the events and the user asked for is what the hysteresis remembers. The clock's hour is // 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. // 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;
if (!nowBand) return computeFit({ bounds, folds, viewportHeight: viewportH }); // 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 // 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. // one row longer and one strip taller, rather than four rows of empty evening shorter.
@@ -276,7 +281,7 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
start: Math.min(bounds.start, nowBand.start), start: Math.min(bounds.start, nowBand.start),
end: Math.max(bounds.end, nowBand.end), end: Math.max(bounds.end, nowBand.end),
}; };
return computeFit({ bounds: axis, folds: [...folds, ...reach], viewportHeight: viewportH }); return computeFit({ bounds: axis, folds: [...shown, ...reach], viewportHeight: viewportH });
}, [timed, folds, floor, viewportH, nowBand]); }, [timed, folds, floor, viewportH, nowBand]);
useEffect(() => { useEffect(() => {
@@ -320,16 +325,6 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
return out; return out;
}, [layout]); }, [layout]);
/** What each strip is hiding: events with nowhere to show, not events it merely clips. */
const hiddenIn = useCallback(
(start: number, end: number) =>
timed.filter((item) => {
const { startMin, endMin } = dayMinutes(item, startOfDay(item.startMs));
return startMin >= start && endMin <= end;
}).length,
[timed],
);
const unfold = useGrid((s) => s.unfold); const unfold = useGrid((s) => s.unfold);
const onUnfold = useCallback((range: Fold) => unfold(range), [unfold]); const onUnfold = useCallback((range: Fold) => unfold(range), [unfold]);
@@ -781,12 +776,7 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
{layout.segments.map((segment) => {layout.segments.map((segment) =>
segment.kind === "strip" ? ( segment.kind === "strip" ? (
<GridStrip <GridStrip key={`s${segment.start}`} segment={segment} onUnfold={onUnfold} />
key={`s${segment.start}`}
segment={segment}
hidden={hiddenIn(segment.start, segment.end)}
onUnfold={onUnfold}
/>
) : null, ) : null,
)} )}
+58 -9
View File
@@ -8,12 +8,12 @@ import {
computeBounds, computeBounds,
computeFit, computeFit,
eventSpan, eventSpan,
foldAt,
isFolded, isFolded,
normalizeFolds, normalizeFolds,
rawBounds, rawBounds,
removeFoldAt,
timeToY, timeToY,
trimFolds,
unfoldStrip,
widen, widen,
yToTime, yToTime,
type FitEvent, type FitEvent,
@@ -168,13 +168,62 @@ describe("folds", () => {
).toEqual([]); ).toEqual([]);
}); });
it("adds and removes by the hour under the cursor", () => { it("adds a band and keeps the set normalized", () => {
const folds = addFold([{ start: 9, end: 11 }], { start: 14, end: 16 }); expect(addFold([{ start: 9, end: 11 }], { start: 14, end: 16 })).toEqual([
expect(folds).toHaveLength(2); { start: 9, end: 11 },
expect(foldAt(folds, 15)).toEqual({ start: 14, end: 16 }); { start: 14, end: 16 },
expect(foldAt(folds, 16)).toBeNull(); ]);
expect(removeFoldAt(folds, 15)).toEqual([{ start: 9, end: 11 }]); expect(addFold([{ start: 9, end: 11 }], { start: 11, end: 12 })).toEqual([{ start: 9, end: 12 }]);
expect(removeFoldAt(folds, 13)).toEqual(folds); });
});
/** Hours with something in them, the way `busyHours` reports them. */
const busyAt = (...hours: number[]): boolean[] => {
const busy = new Array<boolean>(24).fill(false);
for (const h of hours) busy[h] = true;
return busy;
};
describe("trimFolds", () => {
it("gives back the hour an event landed in and keeps the rest folded", () => {
expect(trimFolds([{ start: 14, end: 17 }], busyAt(16))).toEqual([{ start: 14, end: 16 }]);
expect(trimFolds([{ start: 14, end: 17 }], busyAt(14))).toEqual([{ start: 15, end: 17 }]);
});
it("splits a fold around an event in the middle of it", () => {
expect(trimFolds([{ start: 14, end: 18 }], busyAt(16))).toEqual([
{ start: 14, end: 16 },
{ start: 17, end: 18 },
]);
});
it("drops a fold with nothing empty left in it, and leaves an empty one alone", () => {
expect(trimFolds([{ start: 14, end: 16 }], busyAt(14, 15))).toEqual([]);
expect(trimFolds([{ start: 14, end: 16 }], busyAt(13, 16))).toEqual([{ start: 14, end: 16 }]);
});
});
describe("unfoldStrip", () => {
const folds = [
{ start: 9, end: 11 },
{ start: 14, end: 18 },
];
it("takes the strip out and leaves the other folds alone", () => {
expect(unfoldStrip(folds, busyAt(), { start: 14, end: 18 })).toEqual([{ start: 9, end: 11 }]);
expect(unfoldStrip(folds, busyAt(), { start: 0, end: 8 })).toEqual(folds);
});
it("keeps the other strip of a split fold and drops what the event was covering", () => {
expect(unfoldStrip(folds, busyAt(16), { start: 14, end: 16 })).toEqual([
{ start: 9, end: 11 },
{ start: 17, end: 18 },
]);
});
it("takes a fold merged into an end strip with it", () => {
expect(unfoldStrip([{ start: 6, end: 10 }], busyAt(), { start: 0, end: 10 })).toEqual([]);
expect(unfoldStrip([{ start: 18, end: 22 }], busyAt(), { start: 18, end: 24 })).toEqual([]);
}); });
}); });
+35 -5
View File
@@ -203,13 +203,43 @@ export function addFold(folds: readonly Fold[], range: Fold): Fold[] {
return normalizeFolds([...folds, range]); return normalizeFolds([...folds, range]);
} }
/** Unfolds whatever band covers `hour`. Nothing there is not an error. */ /**
export function removeFoldAt(folds: readonly Fold[], hour: number): Fold[] { * The folds with the busy hours taken out of them. A fold hides empty time and nothing else, so
return normalizeFolds(folds).filter((f) => hour < f.start || hour >= f.end); * 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.
*/
export function trimFolds(folds: readonly Fold[], busy: 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 (h > start) out.push({ start, end: h });
start = h + 1;
}
if (f.end > start) out.push({ start, end: f.end });
}
return out;
} }
export function foldAt(folds: readonly Fold[], hour: number): Fold | null { const overlaps = (a: HourRange, b: HourRange): boolean => a.start < b.end && a.end > b.start;
return normalizeFolds(folds).find((f) => hour >= f.start && hour < f.end) ?? null;
/**
* Takes an expanded strip out of the folds. The fold it came from keeps the other strips it was
* 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[] {
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);
}
return out;
} }
/** /**
-9
View File
@@ -418,15 +418,6 @@
background: var(--fold-line); background: var(--fold-line);
} }
.grid-strip-count {
flex: none;
padding: 1px 6px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
/* Columns /* Columns
------------------------------------------------------------------------------------------- */ ------------------------------------------------------------------------------------------- */
+69
View File
@@ -11,9 +11,11 @@ import {
blocks, blocks,
box, box,
clockAt, clockAt,
columnX,
gridFit, gridFit,
gridReady, gridReady,
headerDates, headerDates,
hourY,
openApp, openApp,
settle, settle,
} from "./app"; } from "./app";
@@ -147,6 +149,73 @@ test.describe("the axis holds still", () => {
}); });
}); });
test.describe("folds", () => {
test("an event created inside a fold takes its hour back and leaves the rest folded", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
// The fixture's last event ends at half six, so the evening is the one empty run long enough
// to fold and then land an event in. Hold it open first, then fold it by hand.
await page.locator(".grid-strip", { hasText: /to 12am/ }).click();
await settle(page);
const nine = await hourY(page, "9pm");
expect(nine).not.toBeNull();
const fit = await gridFit(page);
await page.mouse.move(await columnX(page, 3), nine! + fit.rowHeight / 2);
await page.keyboard.press("z");
await settle(page);
await expect(page.locator(".grid-strip", { hasText: "7pm to 12am" })).toHaveCount(1);
await page.keyboard.press("ControlOrMeta+k");
await page.keyboard.type("Yoga 11pm");
await page.keyboard.press("Enter");
await settle(page);
// The hour the event landed in is back on the axis with the event in it at full scale, and
// the empty hours before it are still folded.
const yoga = page.locator(".grid-event", { hasText: "Yoga" });
await expect(yoga).toBeVisible();
expect((await box(yoga)).height).toBeGreaterThan(fit.rowHeight / 2);
const hours = (await axis(page)).map((entry) => entry.text);
expect(hours).toContain("11pm");
expect(hours).not.toContain("8pm");
await expect(page.locator(".grid-strip", { hasText: "7pm to 11pm" })).toHaveCount(1);
await expect(page.locator(".grid-strip", { hasText: "7pm to 12am" })).toHaveCount(0);
});
test("a remembered fold over a busy hour shows that hour, and only that hour", async ({
page,
}) => {
// 4pm has a quarter hour block on every workday; 5pm has nothing on any day.
await openApp(page, {
now: MIDDAY(),
storage: { "margincal-folds": '[{"start":16,"end":18}]' },
});
const hours = (await axis(page)).map((entry) => entry.text);
expect(hours).toContain("4pm");
expect(hours).not.toContain("5pm");
await expect(page.locator(".grid-strip", { hasText: "5pm to 6pm" })).toHaveCount(1);
await expect(page.locator(".grid-strip", { hasText: "4pm to 6pm" })).toHaveCount(0);
});
test("expanding one strip of a split fold leaves the other where it was", async ({ page }) => {
// 6pm has a block on every day, so a fold from 5pm to 9pm shows as a strip either side of it,
// the later one merged into the strip at the end of the day.
await openApp(page, {
now: MIDDAY(),
storage: { "margincal-folds": '[{"start":17,"end":21}]' },
});
const five = page.locator(".grid-strip", { hasText: "5pm to 6pm" });
await expect(five).toHaveCount(1);
await expect(page.locator(".grid-strip", { hasText: "7pm to 12am" })).toHaveCount(1);
await five.click();
await settle(page);
expect((await axis(page)).map((entry) => entry.text)).toContain("5pm");
await expect(page.locator(".grid-strip", { hasText: "7pm to 12am" })).toHaveCount(1);
});
});
test.describe("the hour it is now", () => { test.describe("the hour it is now", () => {
// Half eleven at night, which the fixture leaves empty: the axis the events drew stops hours // Half eleven at night, which the fixture leaves empty: the axis the events drew stops hours
// earlier, so without the pin there is nowhere for the line to be. // earlier, so without the pin there is nowhere for the line to be.