mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
Keep the hour it is now on the axis
The visible range is drawn from the events in view, and in the evening the events are behind you: the bounds end at six, everything after that is in the trailing strip, and the now line has nowhere to land. The app stops saying where in the day you are, which is most of what it is open for. Same at seven in the morning, from the other end. So when today is one of the columns, the axis takes that one hour in, and everything the widening reached over folds behind it. At half eleven at night the fixture week reads 7am to 8pm as usual, then an "8pm to 11pm" strip, then one 11pm row with the line in it. A row and a strip, not four rows of empty evening. Only the hour itself is opened, so how far the clock has drifted past the last event costs nothing. The pin sits on top of the hysteresis rather than inside it. previous.current still holds what the events and the user asked for, so an hour pinned open tonight cannot accumulate into the bounds the grid remembers tomorrow. One consequence, and it is the only place the axis is allowed to move under you: page to a week that does not contain today and the row goes again, along with the strip it was sitting under. The tick that drives it is an hour long, not a minute, because that is how often the answer changes. It lives in a new useClock alongside the minute tick the now line already had, which moves there out of GridNowLine. Both schedule off the clock rather than off an interval, so a machine that was asleep catches up on the next tick instead of drifting further out every hour. The browser suite has to say what time it is now. The shape of the axis depends on the hour a run happens at, so anything measuring a row height or counting strips pins the clock to the middle of the working day, the same way it already pins the theme and the week start. The three tests that are about the clock ask for half eleven at night, which the fixture leaves empty.
This commit is contained in:
1 parent
3323496a34
commit
67879fd6c3
8 files changed
+223
-32
No files matched your search
@@ -1,30 +1,12 @@
|
||||
// The now line, and the wash over the part of today that has already gone. A minute is as fine as
|
||||
// either needs to be, and the tick lives here so the rest of the grid never rerenders for it.
|
||||
// either needs to be, and this is the only thing on the tick, so the rest of the grid never
|
||||
// rerenders for it.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { isFolded, timeToY, type FitLayout } from "../grid/fit";
|
||||
import { isSameDay, minutesFromMidnight } from "../time";
|
||||
import { useMinuteTick } from "../useClock";
|
||||
import { vars } from "./GridModel";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
/** Rounds up to the next wall-clock minute, so the line moves when the clock does. */
|
||||
function useMinuteTick(): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
let timer = 0;
|
||||
const schedule = () => {
|
||||
timer = window.setTimeout(() => {
|
||||
setNow(Date.now());
|
||||
schedule();
|
||||
}, MINUTE - (Date.now() % MINUTE));
|
||||
};
|
||||
schedule();
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
interface GridNowLineProps {
|
||||
layout: FitLayout;
|
||||
dayStart: number;
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
toOffsetIso,
|
||||
today,
|
||||
} from "../time";
|
||||
import { useHourStart } from "../useClock";
|
||||
import { PHONE_QUERY, useMediaQuery } from "../useMedia";
|
||||
import "../styles/grid.css";
|
||||
import "../styles/folds.css";
|
||||
@@ -237,6 +238,22 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
|
||||
|
||||
const timed = useMemo(() => byDay.flat(), [byDay]);
|
||||
|
||||
// The hour the clock is in, when today is one of the columns.
|
||||
//
|
||||
// The axis is drawn from the events, and at nine in the evening the events are usually behind
|
||||
// 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 nowBand = useMemo((): Bounds | null => {
|
||||
if (!days.includes(startOfDay(hourStart))) return null;
|
||||
const hour = new Date(hourStart).getHours();
|
||||
return { start: hour, end: hour + 1 };
|
||||
}, [days, hourStart]);
|
||||
|
||||
// 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.
|
||||
const layout = useMemo(() => {
|
||||
@@ -244,9 +261,23 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
|
||||
const bounds = floor
|
||||
? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) }
|
||||
: 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;
|
||||
return computeFit({ bounds, folds, viewportHeight: viewportH });
|
||||
}, [timed, folds, floor, viewportH]);
|
||||
if (!nowBand) return computeFit({ bounds, folds, 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: [...folds, ...reach], viewportHeight: viewportH });
|
||||
}, [timed, folds, floor, viewportH, nowBand]);
|
||||
|
||||
useEffect(() => {
|
||||
const signature = `${layout.bounds.start} ${layout.bounds.end}`;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// The wall clock, at the two granularities anything on the grid cares about: the minute the now
|
||||
// line moves on, and the hour the fit holds open around it.
|
||||
//
|
||||
// Both schedule off the clock rather than off an interval, so a tab that was asleep and a machine
|
||||
// that woke up catch up on the next tick instead of drifting further out every hour.
|
||||
//
|
||||
// They are separate hooks because they are separate rerenders. The minute belongs to the one
|
||||
// component that draws the line; putting the fit on that tick would solve the whole axis sixty
|
||||
// times an hour to answer a question that changes once.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
/** The top of the local hour `ms` falls in. Not `ms - ms % HOUR`: not every zone is on the hour. */
|
||||
function hourStart(ms: number): number {
|
||||
const d = new Date(ms);
|
||||
d.setMinutes(0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/** The next one. `setMinutes(60)` rolls the date over for us, daylight saving and all. */
|
||||
function nextHourStart(ms: number): number {
|
||||
const d = new Date(ms);
|
||||
d.setMinutes(60, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function useTick<T>(read: (now: number) => T, next: (now: number) => number): T {
|
||||
const [value, setValue] = useState(() => read(Date.now()));
|
||||
useEffect(() => {
|
||||
let timer = 0;
|
||||
const schedule = () => {
|
||||
const now = Date.now();
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
setValue(read(Date.now()));
|
||||
schedule();
|
||||
},
|
||||
Math.max(0, next(now) - now),
|
||||
);
|
||||
};
|
||||
schedule();
|
||||
return () => window.clearTimeout(timer);
|
||||
// Both callbacks are module-level functions in every caller, so there is nothing to rebind.
|
||||
}, [read, next]);
|
||||
return value;
|
||||
}
|
||||
|
||||
const now = (ms: number) => ms;
|
||||
const nextMinute = (ms: number) => ms + MINUTE - (ms % MINUTE);
|
||||
|
||||
/** Now, refreshed on every wall-clock minute. */
|
||||
export function useMinuteTick(): number {
|
||||
return useTick(now, nextMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top of the hour it is now, refreshed when the clock turns over into the next one.
|
||||
*
|
||||
* Returning the hour rather than the instant is what makes this cheap to depend on: the value is
|
||||
* identical for a whole hour, so a memo keyed on it recomputes once, when the answer changed.
|
||||
*/
|
||||
export function useHourStart(): number {
|
||||
return useTick(hourStart, nextHourStart);
|
||||
}
|
||||
Reference in new issue
Block a user