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

+2 -4
View File
@@ -39,15 +39,13 @@ export const GridDay = memo(function GridDay({
{packed.map((p) => {
const { startMin, endMin } = dayMinutes(p.event, dayStart);
const top = timeToY(layout, startMin);
const raw = timeToY(layout, endMin) - top;
// Both ends inside the same strip: it is hiding in there, and the strip carries the count.
if (raw < 1) return null;
const height = Math.max(timeToY(layout, endMin) - top, MIN_BLOCK_H);
return (
<GridEvent
key={p.event.id}
item={p.event}
top={top}
height={Math.max(raw, MIN_BLOCK_H)}
height={height}
left={p.left}
width={p.width}
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 {
DEFAULT_BOUNDS,
addFold,
foldAt,
normalizeFolds,
unfoldStrip,
type Bounds,
type FitLayout,
type Fold,
} from "../grid/fit";
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";
@@ -82,9 +81,7 @@ export const useGrid = create<GridState>((set, get) => ({
set({ folds });
},
unfold: (range) => {
const folds = saveFolds(
normalizeFolds(get().folds).filter((f) => f.end <= range.start || f.start >= range.end),
);
const folds = saveFolds(unfoldStrip(get().folds, busyHours(get().items), 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({
@@ -93,7 +90,7 @@ export const useGrid = create<GridState>((set, get) => ({
});
},
toggleFold: (hour) => {
const { layout, hoverHour, items, folds } = get();
const { layout, hoverHour, items } = get();
if (!layout) return;
const at = hour ?? hoverHour;
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 });
return;
}
if (foldAt(folds, h)) {
get().unfold({ start: h, end: h + 1 });
return;
}
get().fold(bandAt(items, layout.bounds, h));
const band = bandAt(items, layout.bounds, h);
if (band) get().fold(band);
},
unfoldAll: () => {
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
// 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
// the button, and the mark next to its range says Expand.
// strip says what it covers and which way it goes: the whole band is the button, and the mark next
// 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 type { Segment } from "../grid/fit";
@@ -11,11 +12,10 @@ import { Icon } from "./Icon";
interface GridStripProps {
segment: Segment;
hidden: number;
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 label = `Expand ${rangeLabel(range)}`;
return (
@@ -34,7 +34,6 @@ export const GridStrip = memo(function GridStrip({ segment, hidden, onUnfold }:
<span className="grid-fold-verb">Expand</span>
</span>
<span className="grid-strip-rule" />
{hidden > 0 && <span className="grid-strip-count">{hidden}</span>}
</button>
);
});
+8 -18
View File
@@ -24,6 +24,7 @@ import {
computeBounds,
computeFit,
timeToY,
trimFolds,
yToTime,
type Bounds,
type Fold,
@@ -51,6 +52,7 @@ import { GridGaps } from "./GridGaps";
import { GridGhost } from "./GridGhost";
import {
MIN_EVENT_MINUTES,
busyHours,
clampMinutes,
dayMinutes,
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
// a pin laid over the top of it every hour, so it never accumulates into the axis it pins.
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
// 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),
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]);
useEffect(() => {
@@ -320,16 +325,6 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
return out;
}, [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 onUnfold = useCallback((range: Fold) => unfold(range), [unfold]);
@@ -781,12 +776,7 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
{layout.segments.map((segment) =>
segment.kind === "strip" ? (
<GridStrip
key={`s${segment.start}`}
segment={segment}
hidden={hiddenIn(segment.start, segment.end)}
onUnfold={onUnfold}
/>
<GridStrip key={`s${segment.start}`} segment={segment} onUnfold={onUnfold} />
) : null,
)}
+58 -9
View File
@@ -8,12 +8,12 @@ import {
computeBounds,
computeFit,
eventSpan,
foldAt,
isFolded,
normalizeFolds,
rawBounds,
removeFoldAt,
timeToY,
trimFolds,
unfoldStrip,
widen,
yToTime,
type FitEvent,
@@ -168,13 +168,62 @@ describe("folds", () => {
).toEqual([]);
});
it("adds and removes by the hour under the cursor", () => {
const folds = addFold([{ start: 9, end: 11 }], { start: 14, end: 16 });
expect(folds).toHaveLength(2);
expect(foldAt(folds, 15)).toEqual({ start: 14, end: 16 });
expect(foldAt(folds, 16)).toBeNull();
expect(removeFoldAt(folds, 15)).toEqual([{ start: 9, end: 11 }]);
expect(removeFoldAt(folds, 13)).toEqual(folds);
it("adds a band and keeps the set normalized", () => {
expect(addFold([{ start: 9, end: 11 }], { start: 14, end: 16 })).toEqual([
{ start: 9, end: 11 },
{ start: 14, end: 16 },
]);
expect(addFold([{ start: 9, end: 11 }], { start: 11, end: 12 })).toEqual([{ start: 9, end: 12 }]);
});
});
/** 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]);
}
/** Unfolds whatever band covers `hour`. Nothing there is not an error. */
export function removeFoldAt(folds: readonly Fold[], hour: number): Fold[] {
return normalizeFolds(folds).filter((f) => hour < f.start || hour >= f.end);
/**
* The folds with the busy 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.
*/
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 {
return normalizeFolds(folds).find((f) => hour >= f.start && hour < f.end) ?? null;
const overlaps = (a: HourRange, b: HourRange): boolean => a.start < b.end && a.end > b.start;
/**
* 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);
}
.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
------------------------------------------------------------------------------------------- */