Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions apps/calendar-web/src/common/utils/grid/grid.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,37 @@ export const SCROLL_TO_NOW_BUFFER_PX = 150;

const SCROLL_TO_NOW_TOLERANCE_PX = 2;

/** When the now line is visible and scroll is within this window, "t" pulses. */
export const SCROLL_TO_NOW_PULSE_NEAR_MINUTES = 5;

export const getScrollToNowTop = (clientHeight: number) =>
getCurrentMinute() * getMinuteHeight(clientHeight) - SCROLL_TO_NOW_BUFFER_PX;
Math.max(
0,
getCurrentMinute() * getMinuteHeight(clientHeight) -
SCROLL_TO_NOW_BUFFER_PX,
);

export const isTimedGridScrolledToNow = (timedGrid: HTMLElement) => {
export const getScrollToNowDelta = (timedGrid: HTMLElement) => {
const targetTop = getScrollToNowTop(timedGrid.clientHeight);
return (
Math.abs(timedGrid.scrollTop - targetTop) <= SCROLL_TO_NOW_TOLERANCE_PX
);
return Math.abs(timedGrid.scrollTop - targetTop);
};

export const isTimedGridScrolledToNow = (timedGrid: HTMLElement) => {
return getScrollToNowDelta(timedGrid) <= SCROLL_TO_NOW_TOLERANCE_PX;
};

export const isTimedGridNearScrollToNow = (timedGrid: HTMLElement) => {
const nearPx =
getMinuteHeight(timedGrid.clientHeight) * SCROLL_TO_NOW_PULSE_NEAR_MINUTES;
return getScrollToNowDelta(timedGrid) <= nearPx;
};

/** Whether the live now line sits inside the timed grid's visible scroll area. */
export const isNowLineInTimedGridViewport = (timedGrid: HTMLElement) => {
const nowLineY = getCurrentMinute() * getMinuteHeight(timedGrid.clientHeight);
const viewTop = timedGrid.scrollTop;
const viewBottom = viewTop + timedGrid.clientHeight;
return nowLineY >= viewTop && nowLineY <= viewBottom;
};

/** Returns true when the grid scroll position changed. */
Expand Down
3 changes: 3 additions & 0 deletions apps/calendar-web/src/components/SelectView/SelectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Z_INDEX_FLOATING_MENU } from "@web/common/constants/web.constants";
import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
import {
selectNowCueActive,
selectNowCuePulseGeneration,
useNowCueStore,
} from "@web/grid/now-cue/now-cue.store";
import { useFloatingLayer } from "@web/shortcuts/floating-layer";
Expand Down Expand Up @@ -52,6 +53,7 @@ export const SelectView = ({
const listRef = useRef<Array<HTMLElement | null>>([]);
useFloatingLayer("viewSelect", isOpen);
const pulseDayLabel = useNowCueStore(selectNowCueActive) && emphasizeDayLabel;
const pulseGeneration = useNowCueStore(selectNowCuePulseGeneration);

const getCurrentView = (): "Day" | "Week" | "Life" => {
const pathname = location.pathname;
Expand Down Expand Up @@ -190,6 +192,7 @@ export const SelectView = ({
pulseDayLabel &&
"c-now-cue-label-pulse motion-reduce:transition-none",
)}
key={emphasizeDayLabel ? pulseGeneration : undefined}
>
{label}
</span>
Expand Down
8 changes: 4 additions & 4 deletions apps/calendar-web/src/grid/components/TimedGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { useGridMarginLeft } from "@web/grid/grid-margin";
import {
selectNowCueActive,
selectNowCuePulseGeneration,
useNowCueStore,
} from "@web/grid/now-cue/now-cue.store";
import { type GridVisibleDate } from "@web/grid/types/grid.types";
Expand Down Expand Up @@ -173,19 +174,18 @@ const CalendarNowLine = ({
useMinuteTick();
const percentOfDay = getCurrentPercentOfDay(effectiveTimeZone);
const pulseNowLine = useNowCueStore(selectNowCueActive);
const pulseGeneration = useNowCueStore(selectNowCuePulseGeneration);

return (
<div
aria-hidden="true"
className={cn(
"absolute h-px motion-reduce:transition-none",
"c-now-line-rim absolute h-px motion-reduce:transition-none",
pulseNowLine && "c-now-cue-pulse",
)}
key={pulseGeneration}
style={{
background: accentGradient,
// Paper-colored rim: invisible on the grid, keeps the line readable
// where it crosses an event card filled with the accent color.
boxShadow: "0 0 0 1px var(--background)",
top: `${percentOfDay}%`,
left: `calc(${columnIndex} * 100% / ${columnCount})`,
width: `calc(100% / ${columnCount})`,
Expand Down
48 changes: 48 additions & 0 deletions apps/calendar-web/src/grid/now-cue/now-cue.store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
NOW_CUE_PULSE_MS,
nowCueActions,
useNowCueStore,
} from "@web/grid/now-cue/now-cue.store";
import { afterEach, describe, expect, it } from "bun:test";

describe("nowCueActions.pulse", () => {
afterEach(() => {
nowCueActions.resetForTests();
});

it("activates the cue and clears after the pulse duration", () => {
nowCueActions.pulse();

expect(useNowCueStore.getState().active).toBe(true);
expect(useNowCueStore.getState().pulseGeneration).toBe(1);

nowCueActions.resetForTests();
expect(useNowCueStore.getState().active).toBe(false);
});

it("bumps pulseGeneration on every press so animations can restart", () => {
nowCueActions.pulse();
nowCueActions.pulse();
nowCueActions.pulse();

expect(useNowCueStore.getState().pulseGeneration).toBe(3);
expect(useNowCueStore.getState().active).toBe(true);
});

it("extends the active window when pulsed again before it ends", () => {
nowCueActions.pulse();
nowCueActions.pulse();

expect(useNowCueStore.getState().pulseGeneration).toBe(2);

nowCueActions.resetForTests();
expect(useNowCueStore.getState().active).toBe(false);
expect(useNowCueStore.getState().pulseGeneration).toBe(0);
});
});

describe("NOW_CUE_PULSE_MS", () => {
it("matches the CSS animation duration", () => {
expect(NOW_CUE_PULSE_MS).toBe(700);
});
});
26 changes: 20 additions & 6 deletions apps/calendar-web/src/grid/now-cue/now-cue.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ export const NOW_CUE_PULSE_MS = 700;
type NowCueState = {
/** True for {@link NOW_CUE_PULSE_MS} after an already-at-now teach pulse. */
active: boolean;
/** Increments on every pulse so CSS animations can restart on repeated presses. */
pulseGeneration: number;
};

export const initialNowCueState: NowCueState = { active: false };
export const initialNowCueState: NowCueState = {
active: false,
pulseGeneration: 0,
};

let pulseTimer: ReturnType<typeof globalThis.setTimeout> | undefined;

Expand All @@ -26,14 +31,21 @@ const clearPulseTimer = () => {
pulseTimer = undefined;
};

const schedulePulseEnd = () => {
pulseTimer = globalThis.setTimeout(() => {
pulseTimer = undefined;
useNowCueStore.setState({ active: false }, false, { type: "pulse-end" });
}, NOW_CUE_PULSE_MS);
};

export const nowCueActions = {
pulse: () => {
clearPulseTimer();
useNowCueStore.setState({ active: true }, false, { type: "pulse" });
pulseTimer = globalThis.setTimeout(() => {
pulseTimer = undefined;
useNowCueStore.setState({ active: false }, false, { type: "pulse-end" });
}, NOW_CUE_PULSE_MS);
const pulseGeneration = useNowCueStore.getState().pulseGeneration + 1;
useNowCueStore.setState({ active: true, pulseGeneration }, false, {
type: "pulse",
});
schedulePulseEnd();
},
resetForTests: () => {
clearPulseTimer();
Expand All @@ -42,3 +54,5 @@ export const nowCueActions = {
};

export const selectNowCueActive = (state: NowCueState) => state.active;
export const selectNowCuePulseGeneration = (state: NowCueState) =>
state.pulseGeneration;
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getScrollToNowTop } from "@web/common/utils/grid/grid.util";
import {
getMinuteHeight,
getScrollToNowTop,
} from "@web/common/utils/grid/grid.util";
import { nowCueActions, useNowCueStore } from "@web/grid/now-cue/now-cue.store";
import { scrollToNowOrPulse } from "@web/grid/now-cue/scroll-to-now-or-pulse";
import { afterEach, describe, expect, it, mock, setSystemTime } from "bun:test";
Expand All @@ -24,5 +27,44 @@ describe("scrollToNowOrPulse", () => {

expect(scroll).not.toHaveBeenCalled();
expect(useNowCueStore.getState().active).toBe(true);
expect(useNowCueStore.getState().pulseGeneration).toBe(1);
});

it("pulses when the now line is in view and scroll is near the teach target", () => {
setSystemTime(new Date("2026-02-05T12:00:00.000Z"));

const clientHeight = 1440;
const scroll = mock(() => {});
const targetTop = getScrollToNowTop(clientHeight);
const minuteHeight = getMinuteHeight(clientHeight);
const grid = {
clientHeight,
scrollTop: targetTop + minuteHeight * 2,
scroll,
} as unknown as HTMLElement;

scrollToNowOrPulse(grid);

expect(scroll).toHaveBeenCalled();
expect(useNowCueStore.getState().active).toBe(true);
expect(useNowCueStore.getState().pulseGeneration).toBe(1);
});

it("increments pulseGeneration on repeated already-at-now presses", () => {
setSystemTime(new Date("2026-02-05T12:00:00.000Z"));

const clientHeight = 1440;
const scroll = mock(() => {});
const grid = {
clientHeight,
scrollTop: getScrollToNowTop(clientHeight),
scroll,
} as unknown as HTMLElement;

scrollToNowOrPulse(grid);
scrollToNowOrPulse(grid);

expect(scroll).not.toHaveBeenCalled();
expect(useNowCueStore.getState().pulseGeneration).toBe(2);
});
});
21 changes: 19 additions & 2 deletions apps/calendar-web/src/grid/now-cue/scroll-to-now-or-pulse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { scrollTimedGridToNow } from "@web/common/utils/grid/grid.util";
import {
isNowLineInTimedGridViewport,
isTimedGridNearScrollToNow,
isTimedGridScrolledToNow,
scrollTimedGridToNow,
} from "@web/common/utils/grid/grid.util";
import { nowCueActions } from "@web/grid/now-cue/now-cue.store";

export const scrollToNowOrPulse = (
Expand All @@ -7,7 +12,19 @@ export const scrollToNowOrPulse = (
) => {
if (!timedGrid) return;

if (!scrollTimedGridToNow(timedGrid, behavior)) {
const strictAtNow = isTimedGridScrolledToNow(timedGrid);
const teachPulse =
strictAtNow ||
(isNowLineInTimedGridViewport(timedGrid) &&
isTimedGridNearScrollToNow(timedGrid));

if (teachPulse) {
if (!strictAtNow) {
scrollTimedGridToNow(timedGrid, behavior);
}
nowCueActions.pulse();
return;
}

scrollTimedGridToNow(timedGrid, behavior);
};
14 changes: 9 additions & 5 deletions apps/calendar-web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@
@keyframes nowCuePulse {
0%,
100% {
box-shadow: none;
box-shadow: 0 0 0 1px var(--color-background);
opacity: 1;
}
45% {
Expand All @@ -281,12 +281,12 @@
@keyframes nowCueLabelPulse {
0%,
100% {
color: inherit;
color: var(--color-accent);
filter: brightness(1);
}
45% {
color: var(--color-accent);
filter: brightness(1.2);
color: var(--color-accent-hover);
filter: brightness(1.35);
}
}

Expand All @@ -297,7 +297,7 @@
filter: brightness(1);
}
45% {
filter: brightness(1.35);
filter: brightness(1.45);
}
}

Expand Down Expand Up @@ -789,6 +789,10 @@
@apply motion-reduce:animate-none;
}

@utility c-now-line-rim {
box-shadow: 0 0 0 1px var(--color-background);
}

@utility c-now-cue-pulse {
animation: var(--animate-now-cue-pulse);
@apply motion-reduce:animate-none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -758,10 +758,17 @@ describe("DayCalendarGrid", () => {
});

it("scrolls the Day timed grid to now when the Day view requests it", () => {
setSystemTime(new Date("2026-02-05T12:00:00.000Z"));
const scroll = mock();

HTMLElement.prototype.scroll = scroll;
renderDayCalendarGrid();
const mainGrid = document.getElementById("mainGrid");
if (!(mainGrid instanceof HTMLElement)) {
throw new Error("expected main grid");
}
Object.defineProperty(mainGrid, "clientHeight", { value: 1440 });
mainGrid.scrollTop = 0;
scroll.mockClear();

emitViewCommand("SCROLL_TO_NOW_LINE");
Expand Down
34 changes: 31 additions & 3 deletions apps/calendar-web/src/views/Week/WeekView.render.test.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,51 @@
import { type FC, useRef } from "react";
import { describe, expect, it, spyOn } from "bun:test";
import {
afterEach,
describe,
expect,
it,
setSystemTime,
spyOn,
} from "bun:test";
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import { useScroll } from "@web/views/Week/hooks/grid/useScroll";

describe("Scroll", () => {
afterEach(() => {
setSystemTime();
});

it("only scrolls once", () => {
setSystemTime(new Date("2026-02-05T12:00:00.000Z"));

const ScrollHarness: FC = () => {
const ref = useRef<HTMLDivElement | null>(null);
useScroll(ref);
return <div ref={ref} />;
};

const clientHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"clientHeight",
);
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get: () => 1440,
});

const scrollSpy = spyOn(window.HTMLElement.prototype, "scroll");

render(<ScrollHarness />);

expect(scrollSpy).toHaveBeenCalled();
// A prototype spy outlives this file unless it is put back.
expect(scrollSpy).toHaveBeenCalledTimes(1);
scrollSpy.mockRestore();
if (clientHeight) {
Object.defineProperty(
HTMLElement.prototype,
"clientHeight",
clientHeight,
);
}
});
});
Loading
Loading