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;
+}