From af8e66e84d81f08e91dac5d02116c9c9be09bf8b Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 2 Sep 2026 13:08:02 +0530 Subject: [PATCH] fix bug on hour fold update --- CLAUDE.md | 16 +++++++ docs/design.md | 11 ++++- src-tauri/Cargo.lock | 2 +- src/components/GridDay.tsx | 6 +-- src/components/GridModel.test.ts | 35 ++++++++++++++++ src/components/GridModel.tsx | Bin 14284 -> 14325 bytes src/components/GridStore.tsx | 18 +++----- src/components/GridStrip.tsx | 9 ++-- src/components/GridView.tsx | 26 ++++-------- src/grid/fit.test.ts | 67 ++++++++++++++++++++++++++---- src/grid/fit.ts | 40 +++++++++++++++--- src/styles/grid.css | 9 ---- tests/grid.spec.ts | 69 +++++++++++++++++++++++++++++++ 13 files changed, 243 insertions(+), 65 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/components/GridModel.test.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e4de9e9 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/docs/design.md b/docs/design.md index 71f144b..149835f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 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 -bottom fold into thin strips labelled with their range and a count of anything hiding inside. -Click or press `z` to unfold one. +bottom fold into thin strips labelled with their range. Click or press `z` to unfold one. 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 @@ -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 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 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. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a2e56ad..2856c0f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2276,7 +2276,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "margin-calendar" -version = "0.0.1" +version = "0.0.2" dependencies = [ "base64 0.22.1", "block2", diff --git a/src/components/GridDay.tsx b/src/components/GridDay.tsx index 8c4dd68..cb9a249 100644 --- a/src/components/GridDay.tsx +++ b/src/components/GridDay.tsx @@ -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 ( 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(); + }); +}); diff --git a/src/components/GridModel.tsx b/src/components/GridModel.tsx index b45d5535faec2e13878e3fab24e9805e5cfa3b72..488e96638f4c56b0ef927c6fddf6e0d61aaacd43 100644 GIT binary patch delta 164 zcmW+wF%H5o5F3CFyuvd=Rr>%Qz``HWHZ&*FT$Nm?Dk1R$mQMTviAS*T2VTKW%3#U1 zY}s%A^5b4ya~z_zUxB!N4)qYEIE0UQufVm8wrU_D-}X_Gncf7HMPp6u0aGmwrsP+%0GFe3h&02QB|P Al>h($ delta 122 zcmeyGe^7CoK%I3{L&(Y@{H6xE(I-x;{4pyk_@1PLT0f- zVop(NVoD`QaZ#$Cf@fYyYEdRob9!Py@nmVkzR4R5ZRDyIic1oUO02-<=qRM-r9hYp P+6smWwVOW}va$gH{KPBk diff --git a/src/components/GridStore.tsx b/src/components/GridStore.tsx index b9a24c6..f3523f5 100644 --- a/src/components/GridStore.tsx +++ b/src/components/GridStore.tsx @@ -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((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((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((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([]); diff --git a/src/components/GridStrip.tsx b/src/components/GridStrip.tsx index 4c01597..3817bac 100644 --- a/src/components/GridStrip.tsx +++ b/src/components/GridStrip.tsx @@ -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 }: Expand - {hidden > 0 && {hidden}} ); }); diff --git a/src/components/GridView.tsx b/src/components/GridView.tsx index 0f7f4d9..d2ef828 100644 --- a/src/components/GridView.tsx +++ b/src/components/GridView.tsx @@ -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" ? ( -