"use client"; import { CalendarIcon, CheckIcon, ChevronDownIcon } from "lucide-react"; import { AnimatePresence, animate, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { Menu, MenuGroup, MenuGroupLabel, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "@/components/ui/menu"; import { cn } from "@/lib/utils"; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; function Face({ n, turn }: { n: number; turn: MotionValue }) { const y = useTransform(turn, (t) => { let offset = (((n - t) % 10) + 10) % 10; if (offset > 5) offset -= 10; return `${offset * 100}%`; }); return ( {n} ); } function Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) { const reduce = useReducedMotion(); const turn = useMotionValue(digit); const target = useRef(digit); useEffect(() => { let step = digit - (((target.current % 10) + 10) % 10); if (step > 5) step -= 10; else if (step < -5) step += 10; target.current += step; if (reduce || instant) turn.jump(target.current); else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current)) animate(turn, target.current, { type: "spring", stiffness: 280, damping: 34, delay }); }, [digit, instant, delay, reduce, turn]); useEffect(() => () => turn.stop(), [turn]); return ( 0 {DIGITS.map((n) => ( ))} ); } function Roll({ text, className, instant = false, delay = 0, }: { text: string; className?: string; instant?: boolean; delay?: number; }) { let placeFromRight = 0; let other = 0; const glyphs = [...text] .reverse() .map((char) => { const isDigit = char >= "0" && char <= "9"; return { char, isDigit, key: isDigit ? `d${placeFromRight++}` : `s${other++}${char}` }; }) .reverse(); return ( {text} {glyphs.map((g) => g.isDigit ? ( ) : ( {g.char === " " ? " " : g.char} ), )} ); } export type ProjectStatus = "on-track" | "at-risk" | "off-track" | "done"; export interface ProjectMember { id: string; name: string; } export interface ProjectTask { id: string; title: string; assignee: string; done: boolean; } export interface ProjectPulseProps { name: string; due: string; members: ProjectMember[]; tasks: ProjectTask[]; defaultStatus?: ProjectStatus; defaultOpen?: boolean; onStatusChange?: (status: ProjectStatus) => void; onTasksChange?: (tasks: ProjectTask[]) => void; className?: string; } const STATUS: Record = { "on-track": { label: "On track", dot: "bg-success", chip: "bg-success/10 text-success-foreground" }, "at-risk": { label: "At risk", dot: "bg-warning", chip: "bg-warning/12 text-warning-foreground" }, "off-track": { label: "Off track", dot: "bg-destructive", chip: "bg-destructive/10 text-destructive-foreground" }, done: { label: "Done", dot: "bg-info", chip: "bg-info/10 text-info-foreground" }, }; type Tone = { stroke: string; fill: string; soft: string }; const PALETTE: Tone[] = [ { stroke: "stroke-violet-500 dark:stroke-violet-400", fill: "bg-violet-500", soft: "bg-violet-500/16 text-violet-800 dark:text-violet-200" }, { stroke: "stroke-sky-500 dark:stroke-sky-400", fill: "bg-sky-500", soft: "bg-sky-500/16 text-sky-800 dark:text-sky-200" }, { stroke: "stroke-amber-500 dark:stroke-amber-400", fill: "bg-amber-500", soft: "bg-amber-500/18 text-amber-800 dark:text-amber-200" }, { stroke: "stroke-emerald-500 dark:stroke-emerald-400", fill: "bg-emerald-500", soft: "bg-emerald-500/16 text-emerald-800 dark:text-emerald-200" }, { stroke: "stroke-rose-500 dark:stroke-rose-400", fill: "bg-rose-500", soft: "bg-rose-500/16 text-rose-800 dark:text-rose-200" }, ]; function initials(name: string) { return name .split(" ") .map((p) => p[0]) .join("") .slice(0, 2) .toUpperCase(); } function arc(cx: number, cy: number, r: number, start: number, end: number) { const toXY = (deg: number) => { const rad = ((deg - 90) * Math.PI) / 180; return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; }; const [x0, y0] = toXY(start); const [x1, y1] = toXY(end); const large = end - start > 180 ? 1 : 0; return `M${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)}`; } function SegmentRing({ tasks, colorOf, focus, size = 48, }: { tasks: ProjectTask[]; colorOf: (id: string) => Tone; focus: string | null; size?: number; }) { const reduce = useReducedMotion(); const stroke = 4.5; const r = (size - stroke) / 2; const c = size / 2; const n = Math.max(tasks.length, 1); const step = 360 / n; const gap = Math.min(10, 160 / n); return ( {tasks.map((task, i) => { const d = arc(c, c, r, i * step + gap / 2, (i + 1) * step - gap / 2); const faded = focus !== null && task.assignee !== focus; return ( ); })} ); } export function ProjectPulse({ name, due, members, tasks: initialTasks, defaultStatus = "on-track", defaultOpen = false, onStatusChange, onTasksChange, className, }: ProjectPulseProps) { const reduce = useReducedMotion(); const uid = useId().replace(/:/g, ""); const [tasks, setTasks] = useState(initialTasks); const [status, setStatus] = useState(defaultStatus); const [filter, setFilter] = useState(null); const [open, setOpen] = useState(defaultOpen); const colorOf = (id: string) => PALETTE[Math.max(0, members.findIndex((m) => m.id === id)) % PALETTE.length]; const memberOf = (id: string) => members.find((m) => m.id === id); const done = tasks.filter((t) => t.done).length; const visible = filter ? tasks.filter((t) => t.assignee === filter) : tasks; const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 300, damping: 30 }; const toggleTask = (id: string) => { const next = tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)); setTasks(next); onTasksChange?.(next); }; const meta = STATUS[status]; return (
{meta.label} Project status { setStatus(v as ProjectStatus); onStatusChange?.(v as ProjectStatus); }} value={status} > {(Object.keys(STATUS) as ProjectStatus[]).map((key) => ( {STATUS[key].label} ))} of {tasks.length} tasks {due}
{members.slice(0, 3).map((m) => ( {initials(m.name)} ))} {members.length > 3 && +{members.length - 3}}
{open && (
{members.map((m) => { const active = filter === m.id; const count = tasks.filter((t) => t.assignee === m.id && !t.done).length; return ( setFilter(active ? null : m.id)} title={m.name} transition={spring} type="button" > {initials(m.name)} {count > 0 && ( {count} )} ); })}
{visible.map((task) => { const member = memberOf(task.assignee); return ( ); })}
)}
); }