Completion Pie Charts
This commit is contained in:
parent
5a681bd444
commit
37631df620
|
|
@ -17,11 +17,12 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { getGroupColor } from "@/lib/colors";
|
import { getComplementaryColor, getGroupColor } from "@/lib/colors";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
import { NotesDialog } from "@/components/board/notes-dialog";
|
import { NotesDialog } from "@/components/board/notes-dialog";
|
||||||
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
||||||
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
|
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 { EditGroupDialog } from "@/components/board/edit-group-dialog";
|
||||||
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||||
import type { GroupDTO, TodoDTO } from "@/types/board";
|
import type { GroupDTO, TodoDTO } from "@/types/board";
|
||||||
|
|
@ -62,10 +63,17 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const backgroundColor = isDark
|
const backgroundColor = isDark
|
||||||
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
|
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
|
||||||
: `color-mix(in srgb, ${color.light} 12%, 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
|
// 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.
|
// 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 canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed);
|
||||||
|
const completedCount = group.todos.filter((t) => t.completed).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -78,7 +86,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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",
|
"hover:-translate-y-0.5 hover:shadow-md",
|
||||||
isDragging && "opacity-50 shadow-lg",
|
isDragging && "opacity-50 shadow-lg",
|
||||||
isDropTarget && "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
isDropTarget && "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||||
|
|
@ -180,6 +188,12 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
Archive
|
Archive
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<TodoProgressPie
|
||||||
|
completed={completedCount}
|
||||||
|
total={group.todos.length}
|
||||||
|
accentColor={pieAccentColor}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingTodo && (
|
{editingTodo && (
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={`${pct}% of to-dos completed`}
|
||||||
|
tabIndex={0}
|
||||||
|
className="absolute -right-1.5 -bottom-1.5 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
|
style={{
|
||||||
|
borderColor: accentColor,
|
||||||
|
background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent side="top">{pct}% To-Dos Completed</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,3 +44,90 @@ export const DEFAULT_GROUP_COLOR_KEY: GroupColorKey = GROUP_COLORS[0].key;
|
||||||
export function getGroupColor(key: string): GroupColor {
|
export function getGroupColor(key: string): GroupColor {
|
||||||
return GROUP_COLOR_MAP[key] ?? GROUP_COLORS[0];
|
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<string, string>();
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue