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
+155
View File
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
type HorizontalAlign = "start" | "end" | "center";
type VerticalAlign = "start" | "end" | "center";
type OverlaySide = "bottom" | "right";
interface UseAnchoredOverlayOptions<TTrigger extends HTMLElement> {
open: boolean;
onClose: () => void;
offset?: number;
viewportPadding?: number;
side?: OverlaySide;
align?: HorizontalAlign;
crossAlign?: VerticalAlign;
matchTriggerWidth?: boolean;
triggerRef?: RefObject<TTrigger | null>;
}
interface OverlayPosition {
top: number;
left: number;
minWidth?: number;
}
export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
open,
onClose,
offset = 8,
viewportPadding = 16,
side = "bottom",
align = "start",
crossAlign = "start",
matchTriggerWidth = false,
triggerRef: externalTriggerRef,
}: UseAnchoredOverlayOptions<TTrigger>) {
const internalTriggerRef = useRef<TTrigger | null>(null);
const triggerRef = externalTriggerRef ?? internalTriggerRef;
const panelRef = useRef<HTMLDivElement | null>(null);
const [position, setPosition] = useState<OverlayPosition>({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) {
return;
}
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const panelHeight = panelRef.current?.offsetHeight ?? 0;
let nextTop = rect.bottom + offset;
let nextLeft = rect.left;
if (side === "right") {
nextLeft = rect.right + offset;
if (crossAlign === "center") {
nextTop = rect.top + rect.height / 2 - panelHeight / 2;
} else if (crossAlign === "end") {
nextTop = rect.bottom - panelHeight;
} else {
nextTop = rect.top;
}
} else {
if (align === "end") {
nextLeft = rect.right - panelWidth;
} else if (align === "center") {
nextLeft = rect.left + rect.width / 2 - panelWidth / 2;
}
nextTop = rect.bottom + offset;
const nextBottom = nextTop + panelHeight;
const flippedTop = rect.top - panelHeight - offset;
if (panelHeight > 0 && nextBottom > window.innerHeight - viewportPadding && flippedTop >= viewportPadding) {
nextTop = flippedTop;
}
}
const boundedLeft = Math.min(
Math.max(nextLeft, viewportPadding),
Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding),
);
const boundedTop = Math.min(
Math.max(nextTop, viewportPadding),
Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding),
);
setPosition({
top: boundedTop,
left: boundedLeft,
...(matchTriggerWidth ? { minWidth: rect.width } : {}),
});
}, [align, crossAlign, matchTriggerWidth, offset, side, triggerRef, viewportPadding]);
useEffect(() => {
if (!open) {
return;
}
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (triggerRef.current?.contains(target) || panelRef.current?.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);
};
}, [onClose, open]);
useEffect(() => {
if (!open) {
return;
}
updatePosition();
const rafId = window.requestAnimationFrame(updatePosition);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open, updatePosition]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (nextOpen) {
updatePosition();
return;
}
onClose();
}, [onClose, updatePosition]);
return {
triggerRef,
panelRef,
position,
updatePosition,
handleOpenChange,
};
}
+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 };
}