mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
fix bug on hour fold update
This commit is contained in:
1 parent
f7d3505ab2
commit
af8e66e84d
13 files changed
+243
-65
No files matched your search
@@ -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
@@ -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.
|
||||||
|
|||||||
Generated
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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))}
|
||||||
|
|||||||
@@ -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.
@@ -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([]);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
------------------------------------------------------------------------------------------- */
|
------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user