diff --git a/dashboard/src/v2/components/tasks/SelfReflectionRatingBadge.tsx b/dashboard/src/v2/components/tasks/SelfReflectionRatingBadge.tsx new file mode 100644 index 0000000000..e9e4a56cb2 --- /dev/null +++ b/dashboard/src/v2/components/tasks/SelfReflectionRatingBadge.tsx @@ -0,0 +1,240 @@ +import type { FunctionComponent } from "preact"; +import { createPortal } from "preact/compat"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { ListChecks, Star } from "lucide-preact"; +import type { TaskSelfReflectionRating } from "../../../../../src/contracts/task-self-reflection-types.js"; +import { + buildSelfReflectionRatingViewModel, + type SelfReflectionStarState, +} from "../../lib/tasks/self-reflection-rating.js"; +import { calculatePosition, type Alignment, type Position } from "../../lib/positioning/index.js"; + +interface SelfReflectionRatingBadgeProps { + rating: TaskSelfReflectionRating | null | undefined; + className?: string; + position?: Position; + align?: Alignment; +} + +export const SelfReflectionRatingBadge: FunctionComponent = ({ + rating, + className = "", + position = "bottom", + align = "center", +}) => { + const viewModel = buildSelfReflectionRatingViewModel(rating); + const [overlayId] = useState(() => `self-reflection-rating-${Math.random().toString(36).slice(2, 10)}`); + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + const overlayRef = useRef(null); + const closeTimeoutRef = useRef(null); + const [coords, setCoords] = useState({ top: 0, left: 0 }); + + const clearCloseTimeout = useCallback(() => { + if (closeTimeoutRef.current !== null) { + window.clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + }, []); + + const openOverlay = useCallback(() => { + clearCloseTimeout(); + setIsOpen(true); + }, [clearCloseTimeout]); + + const closeOverlay = useCallback(() => { + clearCloseTimeout(); + closeTimeoutRef.current = window.setTimeout(() => { + setIsOpen(false); + closeTimeoutRef.current = null; + }, 100); + }, [clearCloseTimeout]); + + const updateOverlayPosition = useCallback(() => { + if (!triggerRef.current || !overlayRef.current) { + return; + } + + const nextCoords = calculatePosition({ + triggerRect: triggerRef.current.getBoundingClientRect(), + contentRect: overlayRef.current.getBoundingClientRect(), + position, + align, + gap: 8, + padding: 12, + }); + + setCoords(nextCoords); + }, [align, position]); + + useLayoutEffect(() => { + if (!isOpen) { + return; + } + + updateOverlayPosition(); + }, [isOpen, updateOverlayPosition]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + clearCloseTimeout(); + setIsOpen(false); + } + }; + + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("resize", updateOverlayPosition); + window.addEventListener("scroll", updateOverlayPosition, { capture: true, passive: true }); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("resize", updateOverlayPosition); + window.removeEventListener("scroll", updateOverlayPosition, { capture: true }); + }; + }, [clearCloseTimeout, isOpen, updateOverlayPosition]); + + useEffect(() => () => clearCloseTimeout(), [clearCloseTimeout]); + + if (!viewModel) { + return null; + } + + return ( + + + + + {viewModel.overallRatingLabel} + + + + {isOpen && typeof document !== "undefined" && document.body ? createPortal( + , + document.body, + ) : null} + + ); +}; + +interface StarStripProps { + states: readonly SelfReflectionStarState[]; + sizeClassName: string; +} + +const StarStrip: FunctionComponent = ({ states, sizeClassName }) => ( + +); + +interface StarGlyphProps { + state: SelfReflectionStarState; + sizeClassName: string; +} + +const StarGlyph: FunctionComponent = ({ state, sizeClassName }) => { + if (state === "half") { + return ( + + + ); + } + + return ( +