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,
)}