feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
type PopoverAnchor =
| { kind: "point"; x: number; y: number }
| { kind: "element"; element: HTMLElement };
type PopoverSide = "bottom" | "right";
type PopoverAlign = "start" | "end" | "center";
interface UseViewportPopoverOptions {
anchor: PopoverAnchor;
width: number;
estimatedHeight: number;
onClose: () => void;
side?: PopoverSide;
align?: PopoverAlign;
offset?: number;
viewportPadding?: number;
ignoreElements?: Array<HTMLElement | null>;
}
export function useViewportPopover({
anchor,
width,
estimatedHeight,
onClose,
side = "bottom",
align = "start",
offset = 8,
viewportPadding = 16,
ignoreElements = [],
}: UseViewportPopoverOptions) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (ref.current?.contains(target)) {
return;
}
if (ignoreElements.some((element) => element?.contains(target))) {
return;
}
onClose();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [ignoreElements, onClose]);
const style = useMemo<CSSProperties>(() => {
let left = 0;
let top = 0;
if (anchor.kind === "element") {
const rect = anchor.element.getBoundingClientRect();
if (side === "right") {
left = rect.right + offset;
if (align === "end") {
top = rect.bottom - estimatedHeight;
} else if (align === "center") {
top = rect.top + rect.height / 2 - estimatedHeight / 2;
} else {
top = rect.top;
}
} else {
left = rect.left;
if (align === "end") {
left = rect.right - width;
} else if (align === "center") {
left = rect.left + rect.width / 2 - width / 2;
}
top = rect.bottom + offset;
}
} else {
left = anchor.x;
top = anchor.y + offset;
if (align === "end") {
left = anchor.x - width;
} else if (align === "center") {
left = anchor.x - width / 2;
}
}
const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedHeight - viewportPadding);
return {
position: "fixed",
left: Math.min(Math.max(left, viewportPadding), maxLeft),
top: Math.min(Math.max(top, viewportPadding), maxTop),
width,
zIndex: 60,
};
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width]);
return { ref, style };
}