diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx index a78b581..f6385f2 100644 --- a/components/board/group-card.tsx +++ b/components/board/group-card.tsx @@ -17,11 +17,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { getGroupColor } from "@/lib/colors"; +import { getComplementaryColor, getGroupColor } from "@/lib/colors"; import { useBoard } from "@/components/board/board-context"; import { NotesDialog } from "@/components/board/notes-dialog"; import { TodoCreatePopover } from "@/components/board/todo-create-popover"; import { TodoEditDialog } from "@/components/board/todo-edit-dialog"; +import { TodoProgressPie } from "@/components/board/todo-progress-pie"; import { EditGroupDialog } from "@/components/board/edit-group-dialog"; import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; import type { GroupDTO, TodoDTO } from "@/types/board"; @@ -62,10 +63,17 @@ export function GroupCard({ group }: { group: GroupDTO }) { const backgroundColor = isDark ? `color-mix(in srgb, ${color.dark} 18%, var(--card))` : `color-mix(in srgb, ${color.light} 12%, var(--card))`; + // Opposing hue from the group's own color, for the progress pie below -- + // so it reads as a distinct accent rather than blending into the border. + const pieAccentColor = getComplementaryColor( + isDark ? color.dark : color.light, + isDark ? "dark" : "light" + ); // Only offer archiving once there's actually something done -- a group // with no to-dos yet, or with any still open, isn't "finished" yet. const canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed); + const completedCount = group.todos.filter((t) => t.completed).length; return ( <> @@ -78,7 +86,7 @@ export function GroupCard({ group }: { group: GroupDTO }) { backgroundColor, }} className={cn( - "rounded-xl border-[3px] p-3 shadow-sm transition-shadow", + "relative rounded-xl border-[3px] p-3 shadow-sm transition-shadow", "hover:-translate-y-0.5 hover:shadow-md", isDragging && "opacity-50 shadow-lg", isDropTarget && "ring-2 ring-primary ring-offset-2 ring-offset-background" @@ -180,6 +188,12 @@ export function GroupCard({ group }: { group: GroupDTO }) { Archive )} + + {editingTodo && ( diff --git a/components/board/todo-progress-pie.tsx b/components/board/todo-progress-pie.tsx new file mode 100644 index 0000000..373a480 --- /dev/null +++ b/components/board/todo-progress-pie.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +/** + * Small pie-chart badge pinned to a Group card's bottom-right corner, + * showing what fraction of its to-dos are complete. `completed`/`total` + * come straight from the group's own to-do list, so it's recalculated and + * redrawn on every render -- any add/toggle/delete that changes those + * numbers repaints this automatically, with no separate update path to + * keep in sync. + */ +export function TodoProgressPie({ + completed, + total, + accentColor, +}: { + completed: number; + total: number; + accentColor: string; +}) { + if (total === 0) return null; + const pct = Math.round((completed / total) * 100); + // Same hue as the stroke and the "completed" wedge, just faded into the + // card surface -- so the remaining wedge reads as an absence of + // progress rather than a second, competing color. + const emptyColor = `color-mix(in srgb, ${accentColor} 22%, var(--card))`; + + return ( + + + } + /> + {pct}% To-Dos Completed + + ); +} diff --git a/lib/colors.ts b/lib/colors.ts index 2833926..fa66053 100644 --- a/lib/colors.ts +++ b/lib/colors.ts @@ -44,3 +44,90 @@ export const DEFAULT_GROUP_COLOR_KEY: GroupColorKey = GROUP_COLORS[0].key; export function getGroupColor(key: string): GroupColor { return GROUP_COLOR_MAP[key] ?? GROUP_COLORS[0]; } + +// --- Complementary accent (for the to-do progress pie on a Group card) --- +// +// Derived at runtime by rotating a base color's hue 180° and pulling the +// result into a narrow saturation/lightness band, rather than a second +// hand-picked palette -- so every group (including any added later) gets a +// legible, opposing accent for free, without a parallel list to maintain +// here. The band keeps the result a calm, consistent "accent" regardless of +// how light, dark, or saturated the base color happens to be. + +function hexToRgb(hex: string): [number, number, number] { + const clean = hex.replace("#", ""); + return [ + parseInt(clean.slice(0, 2), 16), + parseInt(clean.slice(2, 4), 16), + parseInt(clean.slice(4, 6), 16), + ]; +} + +function rgbToHsl(r: number, g: number, b: number): [number, number, number] { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + const d = max - min; + const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); + let h = 0; + if (d !== 0) { + switch (max) { + case r: + h = ((g - b) / d) % 6; + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + break; + } + h *= 60; + if (h < 0) h += 360; + } + return [h, s * 100, l * 100]; +} + +function hslToHex(h: number, s: number, l: number): string { + h = ((h % 360) + 360) % 360; + s /= 100; + l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, + g = 0, + b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + const toHex = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +const complementaryCache = new Map(); + +/** The opposing accent color for a given base hex, tuned per light/dark theme. */ +export function getComplementaryColor(hex: string, variant: "light" | "dark"): string { + const cacheKey = `${hex}:${variant}`; + const cached = complementaryCache.get(cacheKey); + if (cached) return cached; + + const [hue, saturation] = rgbToHsl(...hexToRgb(hex)); + const result = hslToHex( + hue + 180, + Math.min(Math.max(saturation, 55), 75), + variant === "dark" ? 68 : 46 + ); + complementaryCache.set(cacheKey, result); + return result; +}