mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 19:17:04 +00:00
hold the hour it is now open through every strip, folds included
This commit is contained in:
1 parent
4b3ef9c62c
commit
9462b7920f
9 files changed
+186
-102
No files matched your search
+6
-4
@@ -32,10 +32,12 @@ flickering axis destroys the positional memory that makes a keyboard-driven cale
|
|||||||
The hour you are in is the one exception to the range being the events' business. At half
|
The hour you are in is the one exception to the range being the events' business. At half
|
||||||
eleven at night the events stopped hours ago, so the now line has nowhere to land and the app
|
eleven at night the events stopped hours ago, so the now line has nowhere to land and the app
|
||||||
stops telling you where in the day you are, which is most of what a calendar is open for. So
|
stops telling you where in the day you are, which is most of what a calendar is open for. So
|
||||||
when today is on screen the axis takes that hour in, and folds away everything it reached over
|
when today is on screen that hour is held at full scale exactly as an hour with an event in it
|
||||||
to get there: the axis grows by a row and a strip, not by an evening. Page to a week that does
|
is: whatever strip reaches over it splits around it, the one outside the range and any fold
|
||||||
not contain today and it goes again, which is the one place the axis is allowed to move under
|
you made alike. The axis grows by a row and a strip, not by an evening, and the range itself
|
||||||
you.
|
does not move, so the pin never leaks into the bounds the grid remembers. Page to a week that
|
||||||
|
does not contain today and the row goes again, which is the one place the axis is allowed to
|
||||||
|
move under you.
|
||||||
|
|
||||||
Interior gaps stay at full scale. A three-hour hole on a Wednesday afternoon is the most
|
Interior gaps stay at full scale. A three-hour hole on a Wednesday afternoon is the most
|
||||||
useful thing on the screen, because it is where work goes, and folding it automatically would
|
useful thing on the screen, because it is where work goes, and folding it automatically would
|
||||||
|
|||||||
+12
-12
@@ -15,7 +15,7 @@
|
|||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { isFolded, timeToY, type Fold, type FitLayout } from "../grid/fit";
|
import { isFolded, timeToY, type Fold, type FitLayout } from "../grid/fit";
|
||||||
import { TOUCH_QUERY, useMediaQuery } from "../useMedia";
|
import { TOUCH_QUERY, useMediaQuery } from "../useMedia";
|
||||||
import { busyHours, rangeLabel, vars } from "./GridModel";
|
import { rangeLabel, vars } from "./GridModel";
|
||||||
import { useGrid } from "./GridStore";
|
import { useGrid } from "./GridStore";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
@@ -53,17 +53,18 @@ export const FOLD_ICON_SIZE = 16;
|
|||||||
const MIN_GAP_H = 24;
|
const MIN_GAP_H = 24;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The empty run around `hour`: nothing scheduled on any visible day, nothing already folded, both
|
* The empty run around `hour`: nothing scheduled on any visible day, not the hour it is now,
|
||||||
* ends stopped by the bounds. Null when the hour is busy, folded or off the axis.
|
* nothing already folded, both ends stopped by the bounds. Null when the hour is held, folded or
|
||||||
|
* off the axis.
|
||||||
*
|
*
|
||||||
* This is deliberately not `bandAt`, which is what `z` folds and which walks straight through an
|
* This is deliberately not `bandAt`, which is what `z` folds and which walks straight through an
|
||||||
* existing fold. What the mouse offers has to be exactly what the label says, so a run cut in two
|
* existing fold. What the mouse offers has to be exactly what the label says, so a run cut in two
|
||||||
* by a fold reads as two runs and collapses as the half you pointed at. Folding merges them anyway.
|
* by a fold reads as two runs and collapses as the half you pointed at. Folding merges them anyway.
|
||||||
*/
|
*/
|
||||||
export function gapAt(layout: FitLayout, busy: readonly boolean[], hour: number): Fold | null {
|
export function gapAt(layout: FitLayout, held: readonly boolean[], hour: number): Fold | null {
|
||||||
const { start: lo, end: hi } = layout.bounds;
|
const { start: lo, end: hi } = layout.bounds;
|
||||||
if (hour < lo || hour >= hi) return null;
|
if (hour < lo || hour >= hi) return null;
|
||||||
const free = (h: number) => !busy[h] && !isFolded(layout, h * 60);
|
const free = (h: number) => !held[h] && !isFolded(layout, h * 60);
|
||||||
if (!free(hour)) return null;
|
if (!free(hour)) return null;
|
||||||
let start = hour;
|
let start = hour;
|
||||||
let end = hour + 1;
|
let end = hour + 1;
|
||||||
@@ -73,11 +74,11 @@ export function gapAt(layout: FitLayout, busy: readonly boolean[], hour: number)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Every empty run on the axis, which is what a pointer with no hover has to be given instead. */
|
/** Every empty run on the axis, which is what a pointer with no hover has to be given instead. */
|
||||||
export function allGaps(layout: FitLayout, busy: readonly boolean[]): Fold[] {
|
export function allGaps(layout: FitLayout, held: readonly boolean[]): Fold[] {
|
||||||
const out: Fold[] = [];
|
const out: Fold[] = [];
|
||||||
let hour = layout.bounds.start;
|
let hour = layout.bounds.start;
|
||||||
while (hour < layout.bounds.end) {
|
while (hour < layout.bounds.end) {
|
||||||
const gap = gapAt(layout, busy, hour);
|
const gap = gapAt(layout, held, hour);
|
||||||
if (!gap) {
|
if (!gap) {
|
||||||
hour++;
|
hour++;
|
||||||
continue;
|
continue;
|
||||||
@@ -90,20 +91,19 @@ export function allGaps(layout: FitLayout, busy: readonly boolean[]): Fold[] {
|
|||||||
|
|
||||||
export const GridGaps = memo(function GridGaps() {
|
export const GridGaps = memo(function GridGaps() {
|
||||||
const layout = useGrid((s) => s.layout);
|
const layout = useGrid((s) => s.layout);
|
||||||
const items = useGrid((s) => s.items);
|
const held = useGrid((s) => s.held);
|
||||||
const hoverHour = useGrid((s) => s.hoverHour);
|
const hoverHour = useGrid((s) => s.hoverHour);
|
||||||
// Booleans rather than the objects themselves: this must not repaint on every pointermove.
|
// Booleans rather than the objects themselves: this must not repaint on every pointermove.
|
||||||
const gesturing = useGrid((s) => s.drag !== null || s.draft !== null);
|
const gesturing = useGrid((s) => s.drag !== null || s.draft !== null);
|
||||||
const fold = useGrid((s) => s.fold);
|
const fold = useGrid((s) => s.fold);
|
||||||
const touch = useMediaQuery(TOUCH_QUERY);
|
const touch = useMediaQuery(TOUCH_QUERY);
|
||||||
|
|
||||||
const busy = useMemo(() => busyHours(items), [items]);
|
|
||||||
const gaps = useMemo(() => {
|
const gaps = useMemo(() => {
|
||||||
if (!layout) return [];
|
if (!layout) return [];
|
||||||
if (touch) return allGaps(layout, busy);
|
if (touch) return allGaps(layout, held);
|
||||||
const hovered = hoverHour === null ? null : gapAt(layout, busy, hoverHour);
|
const hovered = hoverHour === null ? null : gapAt(layout, held, hoverHour);
|
||||||
return hovered ? [hovered] : [];
|
return hovered ? [hovered] : [];
|
||||||
}, [layout, busy, hoverHour, touch]);
|
}, [layout, held, hoverHour, touch]);
|
||||||
|
|
||||||
if (!layout || gesturing) return null;
|
if (!layout || gesturing) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Instance } from "../ipc";
|
import type { Instance } from "../ipc";
|
||||||
import { bandAt, busyHours, type Placed } from "./GridModel";
|
import { bandAt, busyHours, heldHours, type Placed } from "./GridModel";
|
||||||
|
|
||||||
const at = (hour: number, minute = 0) => new Date(2026, 0, 5, hour, minute).getTime();
|
const at = (hour: number, minute = 0) => new Date(2026, 0, 5, hour, minute).getTime();
|
||||||
|
|
||||||
@@ -20,16 +20,38 @@ describe("busyHours", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("heldHours", () => {
|
||||||
|
const items = [placed([9, 0], [9, 30])];
|
||||||
|
const hours = (held: boolean[]) => held.flatMap((b, h) => (b ? [h] : []));
|
||||||
|
|
||||||
|
it("is the busy hours plus the hour it is now", () => {
|
||||||
|
expect(hours(heldHours(items, 1))).toEqual([1, 9]);
|
||||||
|
expect(hours(heldHours(items, 9))).toEqual([9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is just the busy hours when today is not on screen", () => {
|
||||||
|
expect(hours(heldHours(items, null))).toEqual([9]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("bandAt", () => {
|
describe("bandAt", () => {
|
||||||
const items = [placed([9, 0], [9, 30]), placed([18, 0], [18, 30])];
|
const items = [placed([9, 0], [9, 30]), placed([18, 0], [18, 30])];
|
||||||
const bounds = { start: 7, end: 20 };
|
const bounds = { start: 7, end: 20 };
|
||||||
|
const held = heldHours(items, null);
|
||||||
|
|
||||||
it("is the whole empty run around the hour, stopped by the events and the bounds", () => {
|
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(held, bounds, 12)).toEqual({ start: 10, end: 18 });
|
||||||
expect(bandAt(items, bounds, 19)).toEqual({ start: 19, end: 20 });
|
expect(bandAt(held, bounds, 19)).toEqual({ start: 19, end: 20 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers nothing on a busy hour, since a fold there would come straight back", () => {
|
it("offers nothing on a busy hour, since a fold there would come straight back", () => {
|
||||||
expect(bandAt(items, bounds, 9)).toBeNull();
|
expect(bandAt(held, bounds, 9)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the hour it is now and offers nothing on it", () => {
|
||||||
|
const now = heldHours(items, 13);
|
||||||
|
expect(bandAt(now, bounds, 12)).toEqual({ start: 10, end: 13 });
|
||||||
|
expect(bandAt(now, bounds, 15)).toEqual({ start: 14, end: 18 });
|
||||||
|
expect(bandAt(now, bounds, 13)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -294,17 +294,28 @@ export function busyHours(items: readonly Placed[]): boolean[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The band `z` would fold at this hour: the whole empty run around it. Null on a busy hour, since
|
* The hours the grid keeps at full scale whatever fold or bound covers them: every hour an event
|
||||||
* a fold hides empty time and an hour with an event in it would come straight back. Interior gaps
|
* touches, and the hour it is now when today is one of the columns. This is the one definition,
|
||||||
* never fold themselves, so this only runs on demand.
|
* and everything that hides an hour or offers to hide one goes through it.
|
||||||
*/
|
*/
|
||||||
export function bandAt(items: readonly Placed[], bounds: Bounds, hour: number): Fold | null {
|
export function heldHours(items: readonly Placed[], nowHour: number | null): boolean[] {
|
||||||
const busy = busyHours(items);
|
const held = busyHours(items);
|
||||||
if (busy[hour]) return null;
|
if (nowHour !== null && nowHour >= 0 && nowHour < 24) held[nowHour] = true;
|
||||||
|
return held;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The band `z` would fold at this hour: the whole empty run around it, stopped by the held hours
|
||||||
|
* and the bounds. Null on a held hour, since a fold hides empty time and a fold over an hour with
|
||||||
|
* an event in it, or the hour it is now, would come straight back. Interior gaps never fold
|
||||||
|
* themselves, so this only runs on demand.
|
||||||
|
*/
|
||||||
|
export function bandAt(held: readonly boolean[], bounds: Bounds, hour: number): Fold | null {
|
||||||
|
if (held[hour]) return null;
|
||||||
let start = hour;
|
let start = hour;
|
||||||
let end = hour + 1;
|
let end = hour + 1;
|
||||||
while (start > bounds.start && !busy[start - 1]) start--;
|
while (start > bounds.start && !held[start - 1]) start--;
|
||||||
while (end < bounds.end && !busy[end]) end++;
|
while (end < bounds.end && !held[end]) end++;
|
||||||
return { start, end };
|
return { start, end };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type Fold,
|
type Fold,
|
||||||
} from "../grid/fit";
|
} from "../grid/fit";
|
||||||
import { loadFolds, saveFolds } from "../grid/folds";
|
import { loadFolds, saveFolds } from "../grid/folds";
|
||||||
import { bandAt, busyHours, type Placed } from "./GridModel";
|
import { bandAt, heldHours } from "./GridModel";
|
||||||
|
|
||||||
export type DragMode = "create" | "move" | "resize-start" | "resize-end";
|
export type DragMode = "create" | "move" | "resize-start" | "resize-end";
|
||||||
|
|
||||||
@@ -44,12 +44,13 @@ interface GridState {
|
|||||||
floor: Bounds | null;
|
floor: Bounds | null;
|
||||||
layout: FitLayout | null;
|
layout: FitLayout | null;
|
||||||
days: number[];
|
days: number[];
|
||||||
items: Placed[];
|
/** The hours no fold may hide: see `heldHours`. Published with the layout it was solved for. */
|
||||||
|
held: boolean[];
|
||||||
hoverHour: number | null;
|
hoverHour: number | null;
|
||||||
drag: Drag | null;
|
drag: Drag | null;
|
||||||
draft: Draft | null;
|
draft: Draft | null;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
publish: (layout: FitLayout, days: number[], items: Placed[]) => void;
|
publish: (layout: FitLayout, days: number[], held: boolean[]) => void;
|
||||||
setHoverHour: (hour: number | null) => void;
|
setHoverHour: (hour: number | null) => void;
|
||||||
setDrag: (drag: Drag | null) => void;
|
setDrag: (drag: Drag | null) => void;
|
||||||
setDraft: (draft: Draft | null) => void;
|
setDraft: (draft: Draft | null) => void;
|
||||||
@@ -66,12 +67,12 @@ export const useGrid = create<GridState>((set, get) => ({
|
|||||||
floor: null,
|
floor: null,
|
||||||
layout: null,
|
layout: null,
|
||||||
days: [],
|
days: [],
|
||||||
items: [],
|
held: heldHours([], null),
|
||||||
hoverHour: null,
|
hoverHour: null,
|
||||||
drag: null,
|
drag: null,
|
||||||
draft: null,
|
draft: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
publish: (layout, days, items) => set({ layout, days, items }),
|
publish: (layout, days, held) => set({ layout, days, held }),
|
||||||
setHoverHour: (hoverHour) => set((s) => (s.hoverHour === hoverHour ? {} : { hoverHour })),
|
setHoverHour: (hoverHour) => set((s) => (s.hoverHour === hoverHour ? {} : { hoverHour })),
|
||||||
setDrag: (drag) => set({ drag }),
|
setDrag: (drag) => set({ drag }),
|
||||||
setDraft: (draft) => set({ draft }),
|
setDraft: (draft) => set({ draft }),
|
||||||
@@ -81,7 +82,7 @@ export const useGrid = create<GridState>((set, get) => ({
|
|||||||
set({ folds });
|
set({ folds });
|
||||||
},
|
},
|
||||||
unfold: (range) => {
|
unfold: (range) => {
|
||||||
const folds = saveFolds(unfoldStrip(get().folds, busyHours(get().items), range));
|
const folds = saveFolds(unfoldStrip(get().folds, get().held, range));
|
||||||
// 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({
|
||||||
@@ -90,7 +91,7 @@ export const useGrid = create<GridState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
toggleFold: (hour) => {
|
toggleFold: (hour) => {
|
||||||
const { layout, hoverHour, items } = get();
|
const { layout, hoverHour, held } = 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;
|
||||||
@@ -100,7 +101,7 @@ 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;
|
||||||
}
|
}
|
||||||
const band = bandAt(items, layout.bounds, h);
|
const band = bandAt(held, layout.bounds, h);
|
||||||
if (band) get().fold(band);
|
if (band) get().fold(band);
|
||||||
},
|
},
|
||||||
unfoldAll: () => {
|
unfoldAll: () => {
|
||||||
|
|||||||
+18
-45
@@ -20,15 +20,7 @@ import {
|
|||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { eventCreate, eventUpdate } from "../api/events";
|
import { eventCreate, eventUpdate } from "../api/events";
|
||||||
import {
|
import { computeBounds, computeFit, timeToY, yToTime, type Bounds, type Fold } from "../grid/fit";
|
||||||
computeBounds,
|
|
||||||
computeFit,
|
|
||||||
timeToY,
|
|
||||||
trimFolds,
|
|
||||||
yToTime,
|
|
||||||
type Bounds,
|
|
||||||
type Fold,
|
|
||||||
} from "../grid/fit";
|
|
||||||
import { loadBounds, saveBounds } from "../grid/folds";
|
import { loadBounds, saveBounds } from "../grid/folds";
|
||||||
import { useCalendarView } from "../store/useCalendarView";
|
import { useCalendarView } from "../store/useCalendarView";
|
||||||
import { notify } from "../store/useToast";
|
import { notify } from "../store/useToast";
|
||||||
@@ -52,9 +44,9 @@ 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,
|
||||||
|
heldHours,
|
||||||
hourLabel,
|
hourLabel,
|
||||||
inBand,
|
inBand,
|
||||||
isVisible,
|
isVisible,
|
||||||
@@ -240,21 +232,19 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
|
|||||||
|
|
||||||
const timed = useMemo(() => byDay.flat(), [byDay]);
|
const timed = useMemo(() => byDay.flat(), [byDay]);
|
||||||
|
|
||||||
// The hour the clock is in, when today is one of the columns.
|
// The hour the clock is in, when today is one of the columns. Keyed on the hour rather than the
|
||||||
//
|
// minute, because that is how often the answer changes. The line itself moves every minute, on
|
||||||
// The axis is drawn from the events, and at nine in the evening the events are usually behind
|
// its own tick, in `GridNowLine`.
|
||||||
// you: the bounds end at six, the rest of the day is in the trailing strip, and the now line has
|
|
||||||
// nowhere to land. So the hour you are in is taken into the bounds when it falls outside them.
|
|
||||||
// One hour, and only the hour, so the axis gains a row rather than an evening.
|
|
||||||
//
|
|
||||||
// Keyed on the hour rather than the minute, because that is how often the answer changes. The
|
|
||||||
// line itself moves every minute, on its own tick, in `GridNowLine`.
|
|
||||||
const hourStart = useHourStart();
|
const hourStart = useHourStart();
|
||||||
const nowBand = useMemo((): Bounds | null => {
|
const nowHour = useMemo(
|
||||||
if (!days.includes(startOfDay(hourStart))) return null;
|
() => (days.includes(startOfDay(hourStart)) ? new Date(hourStart).getHours() : null),
|
||||||
const hour = new Date(hourStart).getHours();
|
[days, hourStart],
|
||||||
return { start: hour, end: hour + 1 };
|
);
|
||||||
}, [days, hourStart]);
|
|
||||||
|
// The hours nothing may hide: every hour an event on screen covers, and the hour it is now, so
|
||||||
|
// the now line always has a row to land in. They are carved out of every strip alike, the ones
|
||||||
|
// outside the bounds and the ones you folded, and the bounds themselves stay the events' business.
|
||||||
|
const held = useMemo(() => heldHours(timed, nowHour), [timed, nowHour]);
|
||||||
|
|
||||||
// Hysteresis needs the bounds it adopted last pass, so they live in a ref rather than in state:
|
// Hysteresis needs the bounds it adopted last pass, so they live in a ref rather than in state:
|
||||||
// paging a week must not reflow the axis, and a render loop through state would fight that.
|
// paging a week must not reflow the axis, and a render loop through state would fight that.
|
||||||
@@ -263,26 +253,9 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
|
|||||||
const bounds = floor
|
const bounds = floor
|
||||||
? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) }
|
? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) }
|
||||||
: auto;
|
: auto;
|
||||||
// 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;
|
previous.current = bounds;
|
||||||
// A fold hides empty hours. Any hour an event on screen covers is given back at full scale,
|
return computeFit({ bounds, folds, hold: held, viewportHeight: viewportH });
|
||||||
// however it got there: typed into the palette, moved in the editor, or synced in from Google.
|
}, [timed, folds, floor, viewportH, held]);
|
||||||
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.
|
|
||||||
const reach: Fold[] = [
|
|
||||||
{ start: nowBand.end, end: bounds.start },
|
|
||||||
{ start: bounds.end, end: nowBand.start },
|
|
||||||
];
|
|
||||||
const axis = {
|
|
||||||
start: Math.min(bounds.start, nowBand.start),
|
|
||||||
end: Math.max(bounds.end, nowBand.end),
|
|
||||||
};
|
|
||||||
return computeFit({ bounds: axis, folds: [...shown, ...reach], viewportHeight: viewportH });
|
|
||||||
}, [timed, folds, floor, viewportH, nowBand]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const signature = `${layout.bounds.start} ${layout.bounds.end}`;
|
const signature = `${layout.bounds.start} ${layout.bounds.end}`;
|
||||||
@@ -293,8 +266,8 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
|
|||||||
|
|
||||||
const publish = useGrid((s) => s.publish);
|
const publish = useGrid((s) => s.publish);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewportH > 0) publish(layout, days, timed);
|
if (viewportH > 0) publish(layout, days, held);
|
||||||
}, [publish, layout, days, timed, viewportH]);
|
}, [publish, layout, days, held, viewportH]);
|
||||||
|
|
||||||
const todayMs = today();
|
const todayMs = today();
|
||||||
const todayIndex = days.findIndex((d) => d === todayMs);
|
const todayIndex = days.findIndex((d) => d === todayMs);
|
||||||
|
|||||||
@@ -333,6 +333,65 @@ describe("computeFit", () => {
|
|||||||
});
|
});
|
||||||
expect(layout.bounds).toEqual({ start: 8, end: 18 });
|
expect(layout.bounds).toEqual({ start: 8, end: 18 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a held hour outside the bounds as one row between two strips", () => {
|
||||||
|
const layout = computeFit({ bounds: { start: 8, end: 20 }, hold: busyAt(1), viewportHeight: 600 });
|
||||||
|
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
|
||||||
|
["strip", 0, 60],
|
||||||
|
["hours", 60, 120],
|
||||||
|
["strip", 120, 480],
|
||||||
|
["hours", 480, 1200],
|
||||||
|
["strip", 1200, 1440],
|
||||||
|
]);
|
||||||
|
// The row is the clock's, not the events': the bounds it reports are still theirs.
|
||||||
|
expect(layout.bounds).toEqual({ start: 8, end: 20 });
|
||||||
|
expect(layout.unfoldedHours).toBe(13);
|
||||||
|
expect(layout.rowHeight).toBeCloseTo((600 - 3 * STRIP_H) / 13, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a held hour at the edge of the day without a strip beyond it", () => {
|
||||||
|
const layout = computeFit({ bounds: { start: 8, end: 20 }, hold: busyAt(23), viewportHeight: 600 });
|
||||||
|
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
|
||||||
|
["strip", 0, 480],
|
||||||
|
["hours", 480, 1200],
|
||||||
|
["strip", 1200, 1380],
|
||||||
|
["hours", 1380, 1440],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a held hour inside a fold that merged into the end strip", () => {
|
||||||
|
const layout = computeFit({
|
||||||
|
bounds: { start: 8, end: 20 },
|
||||||
|
folds: [{ start: 0, end: 8 }],
|
||||||
|
hold: busyAt(1),
|
||||||
|
viewportHeight: 600,
|
||||||
|
});
|
||||||
|
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
|
||||||
|
["strip", 0, 60],
|
||||||
|
["hours", 60, 120],
|
||||||
|
["strip", 120, 480],
|
||||||
|
["hours", 480, 1200],
|
||||||
|
["strip", 1200, 1440],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a held hour inside an interior fold", () => {
|
||||||
|
const layout = computeFit({
|
||||||
|
bounds: { start: 8, end: 20 },
|
||||||
|
folds: [{ start: 12, end: 16 }],
|
||||||
|
hold: busyAt(14),
|
||||||
|
viewportHeight: 600,
|
||||||
|
});
|
||||||
|
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
|
||||||
|
["strip", 0, 480],
|
||||||
|
["hours", 480, 720],
|
||||||
|
["strip", 720, 840],
|
||||||
|
["hours", 840, 900],
|
||||||
|
["strip", 900, 960],
|
||||||
|
["hours", 960, 1200],
|
||||||
|
["strip", 1200, 1440],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("timeToY and yToTime", () => {
|
describe("timeToY and yToTime", () => {
|
||||||
|
|||||||
+19
-21
@@ -60,6 +60,11 @@ export interface FitInput {
|
|||||||
previous?: Bounds;
|
previous?: Bounds;
|
||||||
/** Interior bands the user folded with `z`, in hours. Order and overlap do not matter. */
|
/** Interior bands the user folded with `z`, in hours. Order and overlap do not matter. */
|
||||||
folds?: readonly Fold[];
|
folds?: readonly Fold[];
|
||||||
|
/**
|
||||||
|
* Hours kept at full scale whatever covers them, indexed by hour: the ones with an event in
|
||||||
|
* them, and the hour it is now. A strip that reaches over one of these splits around it.
|
||||||
|
*/
|
||||||
|
hold?: readonly boolean[];
|
||||||
/** Pixels available to the grid body, below the day bar and the all-day band. */
|
/** Pixels available to the grid body, below the day bar and the all-day band. */
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
/** Pins the axis, skipping both the event scan and hysteresis. Still clamped and widened. */
|
/** Pins the axis, skipping both the event scan and hysteresis. Still clamped and widened. */
|
||||||
@@ -204,17 +209,17 @@ export function addFold(folds: readonly Fold[], range: Fold): Fold[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The folds with the busy hours taken out of them. A fold hides empty time and nothing else, so
|
* The folds with the held 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
|
* 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 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.
|
* the hour is empty again and it folds back. The hour it is now is held the same way.
|
||||||
*/
|
*/
|
||||||
export function trimFolds(folds: readonly Fold[], busy: readonly boolean[]): Fold[] {
|
export function trimFolds(folds: readonly Fold[], held: readonly boolean[]): Fold[] {
|
||||||
const out: Fold[] = [];
|
const out: Fold[] = [];
|
||||||
for (const f of normalizeFolds(folds)) {
|
for (const f of normalizeFolds(folds)) {
|
||||||
let start = f.start;
|
let start = f.start;
|
||||||
for (let h = f.start; h < f.end; h++) {
|
for (let h = f.start; h < f.end; h++) {
|
||||||
if (!busy[h]) continue;
|
if (!held[h]) continue;
|
||||||
if (h > start) out.push({ start, end: h });
|
if (h > start) out.push({ start, end: h });
|
||||||
start = h + 1;
|
start = h + 1;
|
||||||
}
|
}
|
||||||
@@ -230,14 +235,14 @@ const overlaps = (a: HourRange, b: HourRange): boolean => a.start < b.end && a.e
|
|||||||
* showing and nothing else: the hours an event was covering go too, so expanding a strip never
|
* 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.
|
* 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[] {
|
export function unfoldStrip(folds: readonly Fold[], held: readonly boolean[], strip: Fold): Fold[] {
|
||||||
const out: Fold[] = [];
|
const out: Fold[] = [];
|
||||||
for (const f of normalizeFolds(folds)) {
|
for (const f of normalizeFolds(folds)) {
|
||||||
if (!overlaps(f, strip)) {
|
if (!overlaps(f, strip)) {
|
||||||
out.push(f);
|
out.push(f);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const piece of trimFolds([f], busy)) if (!overlaps(piece, strip)) out.push(piece);
|
for (const piece of trimFolds([f], held)) if (!overlaps(piece, strip)) out.push(piece);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -246,8 +251,10 @@ export function unfoldStrip(folds: readonly Fold[], busy: readonly boolean[], st
|
|||||||
* Solves the row height for the viewport and lays the day out.
|
* Solves the row height for the viewport and lays the day out.
|
||||||
*
|
*
|
||||||
* The bands outside the bounds fold into a strip at each end, interior folds become strips where
|
* The bands outside the bounds fold into a strip at each end, interior folds become strips where
|
||||||
* they sit, and the hours left over share whatever height remains. When that share falls below
|
* they sit, and the hours left over share whatever height remains. A held hour is never inside a
|
||||||
* `minRowHeight` the row height sticks there and `overflow` says the grid has to scroll.
|
* strip: whichever of those reaches over it splits around it. When the hours' share of the height
|
||||||
|
* falls below `minRowHeight` the row height sticks there and `overflow` says the grid has to
|
||||||
|
* scroll.
|
||||||
*/
|
*/
|
||||||
export function computeFit(input: FitInput): FitLayout {
|
export function computeFit(input: FitInput): FitLayout {
|
||||||
const stripHeight = input.stripHeight ?? STRIP_H;
|
const stripHeight = input.stripHeight ?? STRIP_H;
|
||||||
@@ -258,19 +265,10 @@ export function computeFit(input: FitInput): FitLayout {
|
|||||||
? widen(input.bounds.start, input.bounds.end)
|
? widen(input.bounds.start, input.bounds.end)
|
||||||
: computeBounds(input.events ?? [], input.previous ?? DEFAULT_BOUNDS);
|
: computeBounds(input.events ?? [], input.previous ?? DEFAULT_BOUNDS);
|
||||||
|
|
||||||
const boundStart = bounds.start * 60;
|
const folded = trimFolds(
|
||||||
const boundEnd = bounds.end * 60;
|
[{ start: 0, end: bounds.start }, ...(input.folds ?? []), { start: bounds.end, end: 24 }],
|
||||||
|
input.hold ?? [],
|
||||||
const interior = normalizeFolds(input.folds ?? []).map((f) => ({
|
).map((f) => ({ start: f.start * 60, end: f.end * 60 }));
|
||||||
start: clamp(f.start * 60, boundStart, boundEnd),
|
|
||||||
end: clamp(f.end * 60, boundStart, boundEnd),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const folded = mergeRanges([
|
|
||||||
{ start: 0, end: boundStart },
|
|
||||||
...interior,
|
|
||||||
{ start: boundEnd, end: MINUTES_PER_DAY },
|
|
||||||
]);
|
|
||||||
|
|
||||||
let foldedMinutes = 0;
|
let foldedMinutes = 0;
|
||||||
for (const f of folded) foldedMinutes += f.end - f.start;
|
for (const f of folded) foldedMinutes += f.end - f.start;
|
||||||
|
|||||||
@@ -250,6 +250,24 @@ test.describe("the hour it is now", () => {
|
|||||||
expect(fit.overflow).toBe(false);
|
expect(fit.overflow).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("is on the axis inside a fold you made that covers it", async ({ page }) => {
|
||||||
|
// A fold over the whole evening, made on some earlier day and remembered since. It used to
|
||||||
|
// swallow the clock's hour along with the trailing strip it merged into, and the line with it.
|
||||||
|
await openApp(page, {
|
||||||
|
now: LATE(),
|
||||||
|
storage: { "margincal-folds": JSON.stringify([{ start: 19, end: 24 }]) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await axis(page)).map((entry) => entry.text)).toContain("11pm");
|
||||||
|
await expect(page.locator(".grid-now")).toHaveCount(1);
|
||||||
|
// The fold is still there, split around the hour it is now rather than dropped.
|
||||||
|
await expect(page.locator(".grid-strip", { hasText: "7pm to 11pm" })).toHaveCount(1);
|
||||||
|
await expect(page.locator(".grid-strip", { hasText: /to 12am/ })).toHaveCount(0);
|
||||||
|
// And the bounds the grid remembers are the events', not the clock's.
|
||||||
|
const saved = await page.evaluate(() => localStorage.getItem("margincal-bounds"));
|
||||||
|
expect(JSON.parse(saved ?? "null")).toEqual({ start: 7, end: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
test("is gone again on a week that does not contain today", async ({ page }) => {
|
test("is gone again on a week that does not contain today", async ({ page }) => {
|
||||||
await openApp(page, { now: LATE() });
|
await openApp(page, { now: LATE() });
|
||||||
const before = (await axis(page)).map((entry) => entry.text);
|
const before = (await axis(page)).map((entry) => entry.text);
|
||||||
|
|||||||
Reference in new issue
Block a user