feat(dashboard): tighten explainability detail views

This commit is contained in:
2026-03-31 22:50:47 +02:00
parent db50e2e555
commit 7ace137d16
11 changed files with 580 additions and 65 deletions
@@ -0,0 +1,39 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { WidgetContainer } from "./WidgetContainer.js";
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
},
}));
describe("WidgetContainer", () => {
it("hides the description while details are off", () => {
const html = renderToStaticMarkup(
<WidgetContainer title="Utilization" description="Context text" onRemove={() => {}}>
<div>Body</div>
</WidgetContainer>,
);
expect(html).not.toContain("Context text");
});
it("shows the description while details are on", () => {
const html = renderToStaticMarkup(
<WidgetContainer
title="Utilization"
description="Context text"
onRemove={() => {}}
showDetails
>
<div>Body</div>
</WidgetContainer>,
);
expect(html).toContain("Context text");
});
});
@@ -1,5 +1,6 @@
"use client"; "use client";
import React from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
interface WidgetContainerProps { interface WidgetContainerProps {
@@ -56,7 +57,7 @@ export function WidgetContainer({
</span> </span>
) : null} ) : null}
</div> </div>
{description && ( {showDetails && description && (
<p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400"> <p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{description} {description}
</p> </p>
@@ -0,0 +1,105 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { PeakTimesWidget } from "./PeakTimesWidget.js";
const { useQueryMock } = vi.hoisted(() => ({
useQueryMock: vi.fn(),
}));
vi.mock("next/dynamic", () => ({
default: () => () => <div>chart</div>,
}));
vi.mock("~/lib/trpc/client.js", () => ({
trpc: {
dashboard: {
getPeakTimes: {
useQuery: useQueryMock,
},
},
},
}));
vi.mock("~/components/ui/InfoTooltip.js", () => ({
InfoTooltip: () => <span>i</span>,
}));
describe("PeakTimesWidget", () => {
it("shows calendar basis only when details are enabled", () => {
globalThis.React = React;
useQueryMock.mockReturnValue({
data: [
{
period: "2026-03",
periodStart: "2026-03-01",
totalHours: 120,
capacityHours: 160,
bookedHours: 120,
remainingHours: 40,
overbookedHours: 0,
utilizationPct: 75,
groups: [
{
name: "Delivery",
hours: 120,
capacityHours: 160,
remainingHours: 40,
overbookedHours: 0,
utilizationPct: 75,
},
],
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 2,
resourceCount: 2,
groupCount: 1,
baseAvailableHours: 176,
effectiveAvailableHours: 160,
publicHolidayHoursDeduction: 8,
absenceDayEquivalent: 1,
absenceHoursDeduction: 8,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
resourceCount: 1,
effectiveAvailableHours: 80,
},
{
countryCode: "DE",
countryName: "Germany",
federalState: "HH",
metroCityName: "Hamburg",
resourceCount: 1,
effectiveAvailableHours: 80,
},
],
bookedHours: 120,
capacityHours: 160,
remainingCapacityHours: 40,
overbookedHours: 0,
utilizationPct: 75,
},
},
],
isLoading: false,
});
const compactHtml = renderToStaticMarkup(
<PeakTimesWidget config={{ showDetails: false }} onConfigChange={() => {}} />,
);
const detailedHtml = renderToStaticMarkup(
<PeakTimesWidget config={{ showDetails: true }} onConfigChange={() => {}} />,
);
expect(compactHtml).not.toContain("calendar bases");
expect(compactHtml).not.toContain("DE / BY / Munich");
expect(detailedHtml).toContain("2 calendar bases");
expect(detailedHtml).toContain("DE / BY / Munich (1x");
expect(detailedHtml).toContain("DE / HH / Hamburg (1x");
});
});
@@ -20,15 +20,30 @@ type PeakDepartmentRow = {
utilizationPct: number; utilizationPct: number;
}; };
type PeakCalendarLocation = {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
resourceCount: number;
effectiveAvailableHours: number;
};
type PeakPeriodRow = { type PeakPeriodRow = {
period: string; period: string;
label: string; label: string;
bookedHours: number; bookedHours: number;
capacityHours: number; capacityHours: number;
baseAvailableHours: number;
holidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
remainingHours: number; remainingHours: number;
overbookedHours: number; overbookedHours: number;
utilizationPct: number; utilizationPct: number;
isCurrentPeriod: boolean; isCurrentPeriod: boolean;
calendarContextCount: number;
calendarLocations: PeakCalendarLocation[];
groups: PeakDepartmentRow[]; groups: PeakDepartmentRow[];
}; };
@@ -39,6 +54,10 @@ function formatHours(value: number): string {
}).format(value); }).format(value);
} }
function formatDayEquivalent(value: number): string {
return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
}
function formatMonthLabel(periodStart: string | undefined, fallback: string): string { function formatMonthLabel(periodStart: string | undefined, fallback: string): string {
if (!periodStart) { if (!periodStart) {
return fallback; return fallback;
@@ -68,6 +87,49 @@ function utilizationTextTone(utilizationPct: number): string {
return "text-emerald-600 dark:text-emerald-300"; return "text-emerald-600 dark:text-emerald-300";
} }
function compactMetricLabel(row: PeakPeriodRow | null | undefined, fallback: string): string {
if (!row) {
return fallback;
}
return `${row.label} · ${row.utilizationPct}%`;
}
function formatLocation(location: PeakCalendarLocation): string {
const parts = [location.countryCode ?? location.countryName, location.federalState, location.metroCityName]
.filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
function formatCapacityBasis(row: PeakPeriodRow | null | undefined): string {
if (!row) {
return "No capacity basis";
}
return [
`${formatHours(row.baseAvailableHours)}h base`,
`- ${formatHours(row.holidayHoursDeduction)}h holidays`,
`- ${formatHours(row.absenceHoursDeduction)}h absences (${formatDayEquivalent(row.absenceDayEquivalent)}d)`,
`= ${formatHours(row.capacityHours)}h effective`,
].join(" ");
}
function formatCalendarBasis(row: PeakPeriodRow | null | undefined): string {
if (!row) {
return "No calendar basis";
}
if (row.calendarLocations.length === 0) {
return "No location-based calendar basis";
}
return row.calendarLocations
.slice(0, 3)
.map((location) =>
`${formatLocation(location)} (${location.resourceCount}x · ${formatHours(location.effectiveAvailableHours)}h)`)
.join(" · ");
}
function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepartmentRow[] { function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepartmentRow[] {
if (rows.length <= limit) { if (rows.length <= limit) {
return rows; return rows;
@@ -95,6 +157,7 @@ function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepa
} }
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) { export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const now = new Date(); const now = new Date();
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(); const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
const endDate = new Date( const endDate = new Date(
@@ -114,6 +177,10 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const derivation = period.derivation; const derivation = period.derivation;
const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours; const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours;
const capacityHours = period.capacityHours ?? derivation.capacityHours ?? 0; const capacityHours = period.capacityHours ?? derivation.capacityHours ?? 0;
const baseAvailableHours = derivation.baseAvailableHours ?? capacityHours;
const holidayHoursDeduction = derivation.publicHolidayHoursDeduction ?? 0;
const absenceDayEquivalent = derivation.absenceDayEquivalent ?? 0;
const absenceHoursDeduction = derivation.absenceHoursDeduction ?? 0;
const remainingHours = const remainingHours =
period.remainingHours ?? period.remainingHours ??
derivation.remainingCapacityHours ?? derivation.remainingCapacityHours ??
@@ -132,10 +199,16 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period), label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period),
bookedHours, bookedHours,
capacityHours, capacityHours,
baseAvailableHours,
holidayHoursDeduction,
absenceDayEquivalent,
absenceHoursDeduction,
remainingHours, remainingHours,
overbookedHours, overbookedHours,
utilizationPct, utilizationPct,
isCurrentPeriod: period.period === currentPeriodKey, isCurrentPeriod: period.period === currentPeriodKey,
calendarContextCount: derivation.calendarContextCount ?? 0,
calendarLocations: derivation.calendarLocations ?? [],
groups: (period.groups ?? []) groups: (period.groups ?? [])
.map((group) => { .map((group) => {
const groupCapacityHours = group.capacityHours ?? 0; const groupCapacityHours = group.capacityHours ?? 0;
@@ -191,9 +264,9 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
if (isLoading && periods.length === 0) { if (isLoading && periods.length === 0) {
return ( return (
<div className="flex h-full flex-col gap-3 pt-2"> <div className="flex h-full flex-col gap-3 pt-2">
<div className="grid grid-cols-3 gap-2"> <div className="flex gap-2">
{[...Array(3)].map((_, index) => ( {[...Array(3)].map((_, index) => (
<div key={index} className="h-14 rounded-2xl shimmer-skeleton" /> <div key={index} className="h-10 flex-1 rounded-full shimmer-skeleton" />
))} ))}
</div> </div>
<div className="flex-1 rounded-[22px] shimmer-skeleton" /> <div className="flex-1 rounded-[22px] shimmer-skeleton" />
@@ -205,60 +278,45 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
return ( return (
<div className="flex h-full flex-col gap-2 overflow-hidden"> <div className="flex h-full flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2"> <div className="flex min-w-0 flex-1 flex-wrap gap-2">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60"> {[
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400"> { label: "Current", row: currentPeriodRow, fallback: "No data" },
Current { label: "Pinned", row: selectedPeriodRow, fallback: "Hover or pin" },
{ label: "Peak", row: peakPeriodRow, fallback: "No data" },
].map((metric) => (
<div
key={metric.label}
className="min-w-0 flex-1 rounded-full border border-slate-200/80 bg-white/85 px-3 py-1.5 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60"
>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
{metric.label}
</span>
<span className={`shrink-0 text-xs font-semibold ${utilizationTextTone(metric.row?.utilizationPct ?? 0)}`}>
{metric.row?.utilizationPct ?? 0}%
</span>
</div>
{showDetails ? (
<div className="mt-0.5 truncate text-[10px] text-slate-500 dark:text-slate-400">
{compactMetricLabel(metric.row, metric.fallback)}
</div>
) : null}
</div> </div>
<div className="mt-0.5 flex items-baseline justify-between gap-3"> ))}
<span className={`text-base font-semibold ${utilizationTextTone(currentPeriodRow?.utilizationPct ?? 0)}`}>
{currentPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{currentPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Selected
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(selectedPeriodRow?.utilizationPct ?? 0)}`}>
{selectedPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "Hover or pin"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Peak
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(peakPeriodRow?.utilizationPct ?? 0)}`}>
{peakPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{peakPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
</div> </div>
<InfoTooltip {showDetails ? (
content={ <InfoTooltip
<span> content={
The top chart shows total booked load against effective capacity.<br /> <span>
The current month is marked with a blue accent.<br /> The top chart shows total booked load against effective capacity.<br />
Hover any month to inspect details and click to pin the department breakdown. Effective capacity is base availability minus regional holidays and approved absences.<br />
</span> Hover any month to inspect details and click to pin the department breakdown.
} </span>
width="w-80" }
position="bottom" width="w-80"
/> position="bottom"
/>
) : null}
</div> </div>
<div className="min-h-0 flex-1 lg:grid lg:grid-cols-[minmax(0,1.85fr)_minmax(18rem,0.95fr)] lg:gap-3"> <div className="min-h-0 flex-1 lg:grid lg:grid-cols-[minmax(0,1.85fr)_minmax(18rem,0.95fr)] lg:gap-3">
@@ -277,15 +335,28 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400"> <div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Department Utilization Department Utilization
</div> </div>
{showDetails ? (
<div className="text-xs text-slate-500 dark:text-slate-400"> <div className="text-xs text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "No active month"} {selectedPeriodRow?.label ?? "No active month"}
</div> </div>
</div> ) : null}
</div>
{showDetails ? (
<div className="text-right text-[11px] text-slate-500 dark:text-slate-400"> <div className="text-right text-[11px] text-slate-500 dark:text-slate-400">
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}</div> <div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}</div>
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}</div> <div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}</div>
{selectedPeriodRow ? (
<div>{selectedPeriodRow.calendarContextCount} calendar base{selectedPeriodRow.calendarContextCount === 1 ? "" : "s"}</div>
) : null}
{selectedPeriodRow ? (
<div>{formatCapacityBasis(selectedPeriodRow)}</div>
) : null}
{selectedPeriodRow ? (
<div>{formatCalendarBasis(selectedPeriodRow)}</div>
) : null}
</div> </div>
</div> ) : null}
</div>
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1"> <div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
{departmentRows.length > 0 ? ( {departmentRows.length > 0 ? (
@@ -0,0 +1,69 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { TopValueWidget } from "./TopValueWidget.js";
const { useQueryMock } = vi.hoisted(() => ({
useQueryMock: vi.fn(),
}));
vi.mock("~/lib/trpc/client.js", () => ({
trpc: {
dashboard: {
getTopValueResources: {
useQuery: useQueryMock,
},
},
},
}));
vi.mock("~/components/ui/InfoTooltip.js", () => ({
InfoTooltip: () => <span>i</span>,
}));
vi.mock("~/components/dashboard/WidgetFilterBar.js", () => ({
WidgetFilterBar: () => <div>filters</div>,
}));
vi.mock("~/hooks/useWidgetFilterOptions.js", () => ({
useWidgetFilterOptions: () => ({ chapters: [] }),
}));
describe("TopValueWidget", () => {
it("adds score breakdown explainability to the score badge hover text", () => {
useQueryMock.mockReturnValue({
data: [
{
id: "res_1",
eid: "pparker",
displayName: "Peter Parker",
chapter: "Delivery",
lcrCents: 9_500,
valueScore: 91,
valueScoreBreakdown: {
skillDepth: 85,
skillBreadth: 74,
costEfficiency: 93,
chargeability: 78,
experience: 88,
total: 91,
},
valueScoreUpdatedAt: "2026-03-03T00:00:00.000Z",
countryName: "Germany",
federalState: "BY",
metroCityName: "Augsburg",
},
],
isLoading: false,
});
const html = renderToStaticMarkup(
<TopValueWidget config={{ limit: 10 }} onConfigChange={() => {}} />,
);
expect(html).toContain("Peter Parker: Value score 91");
expect(html).toContain("Skill depth 85");
expect(html).toContain("Cost efficiency 93");
expect(html).toContain("Updated Mar 3, 2026");
});
});
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -9,6 +9,65 @@ import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr"; type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
type ValueScoreBreakdown = {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
total: number;
};
function formatLocation(resource: {
countryName?: string | null;
countryCode?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}): string | null {
const parts = [
resource.countryName ?? resource.countryCode,
resource.federalState,
resource.metroCityName,
]
.filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : null;
}
function formatScoreTooltip(resource: {
displayName: string;
valueScore: number | null;
valueScoreBreakdown?: ValueScoreBreakdown | null;
valueScoreUpdatedAt?: string | null;
}) {
if (!resource.valueScoreBreakdown) {
return `${resource.displayName}: Value score ${resource.valueScore ?? "n/a"}`;
}
const updatedAt = resource.valueScoreUpdatedAt
? new Date(resource.valueScoreUpdatedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
const breakdown = resource.valueScoreBreakdown;
const parts = [
`${resource.displayName}: Value score ${resource.valueScore ?? breakdown.total}`,
`Skill depth ${breakdown.skillDepth}`,
`Skill breadth ${breakdown.skillBreadth}`,
`Cost efficiency ${breakdown.costEfficiency}`,
`Chargeability ${breakdown.chargeability}`,
`Experience ${breakdown.experience}`,
];
if (updatedAt) {
parts.push(`Updated ${updatedAt}`);
}
return parts.join(" | ");
}
export function TopValueWidget({ config, onConfigChange }: WidgetProps) { export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
const limit = (config.limit as number) || 10; const limit = (config.limit as number) || 10;
const { chapters } = useWidgetFilterOptions({ chapters: true }); const { chapters } = useWidgetFilterOptions({ chapters: true });
@@ -36,7 +95,20 @@ export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
const chapter = (config.chapter as string) ?? ""; const chapter = (config.chapter as string) ?? "";
const list = useMemo(() => { const list = useMemo(() => {
const all = (data ?? []) as Array<{ id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number; valueScore: number | null }>; const all = (data ?? []) as Array<{
id: string;
eid: string;
displayName: string;
chapter: string | null;
lcrCents: number;
valueScore: number | null;
valueScoreBreakdown?: ValueScoreBreakdown | null;
valueScoreUpdatedAt?: string | null;
countryName?: string | null;
countryCode?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}>;
if (!chapter) return all; if (!chapter) return all;
return all.filter((r) => r.chapter === chapter); return all.filter((r) => r.chapter === chapter);
}, [data, chapter]); }, [data, chapter]);
@@ -159,10 +231,19 @@ export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
<tr key={r.id} className="hover:bg-gray-50"> <tr key={r.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td> <td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td> <td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td> <td className="px-3 py-2">
<div className="font-medium text-gray-900">{r.displayName}</div>
{formatLocation(r) ? (
<div className="mt-0.5 text-[10px] leading-4 text-gray-500">
{formatLocation(r)}
</div>
) : null}
</td>
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "\u2014"}</td> <td className="px-3 py-2 text-gray-500">{r.chapter ?? "\u2014"}</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<span <span
title={formatScoreTooltip(r)}
aria-label={formatScoreTooltip(r)}
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${ className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
(r.valueScore ?? 0) >= 70 (r.valueScore ?? 0) >= 70
? "bg-green-100 text-green-700" ? "bg-green-100 text-green-700"
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { createDefaultDashboardLayout } from "@capakraken/shared/schemas";
import { shouldHydrateDashboardFromDb } from "./useDashboardLayout.js";
describe("shouldHydrateDashboardFromDb", () => {
it("hydrates from the database on first load when no local changes happened yet", () => {
expect(shouldHydrateDashboardFromDb({
remoteLayout: createDefaultDashboardLayout(),
hasHydratedFromDb: false,
hasLocalChangesBeforeHydration: false,
})).toBe(true);
});
it("skips initial database hydration after a local first-load change", () => {
expect(shouldHydrateDashboardFromDb({
remoteLayout: createDefaultDashboardLayout(),
hasHydratedFromDb: false,
hasLocalChangesBeforeHydration: true,
})).toBe(false);
});
it("does not re-hydrate once the initial database handoff already happened", () => {
expect(shouldHydrateDashboardFromDb({
remoteLayout: createDefaultDashboardLayout(),
hasHydratedFromDb: true,
hasLocalChangesBeforeHydration: false,
})).toBe(false);
});
it("does not hydrate when the backend has no stored layout", () => {
expect(shouldHydrateDashboardFromDb({
remoteLayout: null,
hasHydratedFromDb: false,
hasLocalChangesBeforeHydration: false,
})).toBe(false);
});
});
+41 -4
View File
@@ -37,9 +37,23 @@ function saveToStorage(config: DashboardLayoutConfig) {
} catch {} } catch {}
} }
export function shouldHydrateDashboardFromDb(params: {
remoteLayout: DashboardLayoutConfig | null | undefined;
hasHydratedFromDb: boolean;
hasLocalChangesBeforeHydration: boolean;
}): boolean {
const { remoteLayout, hasHydratedFromDb, hasLocalChangesBeforeHydration } = params;
return remoteLayout !== null
&& remoteLayout !== undefined
&& !hasHydratedFromDb
&& !hasLocalChangesBeforeHydration;
}
export function useDashboardLayout() { export function useDashboardLayout() {
const [config, setConfig] = useState<DashboardLayoutConfig>(() => loadFromStorage() ?? createDefaultDashboardLayout()); const [config, setConfig] = useState<DashboardLayoutConfig>(() => loadFromStorage() ?? createDefaultDashboardLayout());
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasHydratedFromDbRef = useRef(false);
const hasLocalChangesBeforeHydrationRef = useRef(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, { const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, {
@@ -50,14 +64,37 @@ export function useDashboardLayout() {
// Sync from DB on load (DB wins if it has data) // Sync from DB on load (DB wins if it has data)
useEffect(() => { useEffect(() => {
if (dbData?.layout) { const remoteLayout = dbData?.layout ?? null;
const dbConfig = normalizeDashboardLayout(dbData.layout); if (remoteLayout === null || hasHydratedFromDbRef.current) {
setConfig(dbConfig); return;
saveToStorage(dbConfig);
} }
if (!shouldHydrateDashboardFromDb({
remoteLayout,
hasHydratedFromDb: hasHydratedFromDbRef.current,
hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current,
})) {
hasHydratedFromDbRef.current = true;
return;
}
const dbConfig = normalizeDashboardLayout(remoteLayout);
hasHydratedFromDbRef.current = true;
setConfig(dbConfig);
saveToStorage(dbConfig);
}, [dbData]); }, [dbData]);
useEffect(() => () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
}, []);
const persist = useCallback((nextConfig: DashboardLayoutConfig) => { const persist = useCallback((nextConfig: DashboardLayoutConfig) => {
if (!hasHydratedFromDbRef.current) {
hasLocalChangesBeforeHydrationRef.current = true;
}
const newConfig = normalizeDashboardLayout(nextConfig); const newConfig = normalizeDashboardLayout(nextConfig);
saveToStorage(newConfig); saveToStorage(newConfig);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
@@ -156,7 +156,16 @@ describe("dashboard procedure support", () => {
recentActivity: [], recentActivity: [],
}); });
vi.mocked(getDashboardPeakTimes).mockResolvedValue([ vi.mocked(getDashboardPeakTimes).mockResolvedValue([
{ period: "2026-03", totalHours: 160.34, capacityHours: 200.12, utilizationPct: 80 }, {
period: "2026-03",
totalHours: 160.34,
capacityHours: 200.12,
utilizationPct: 80,
derivation: {
calendarContextCount: 1,
calendarLocations: [{ countryCode: "DE", federalState: "BY", metroCityName: "Munich" }],
},
},
]); ]);
vi.mocked(getDashboardTopValueResources).mockResolvedValue([ vi.mocked(getDashboardTopValueResources).mockResolvedValue([
{ {
@@ -166,7 +175,17 @@ describe("dashboard procedure support", () => {
chapter: "CGI", chapter: "CGI",
lcrCents: 12345, lcrCents: 12345,
valueScore: 98, valueScore: 98,
valueScoreBreakdown: {
skillDepth: 94,
skillBreadth: 86,
costEfficiency: 82,
chargeability: 88,
experience: 96,
total: 98,
},
valueScoreUpdatedAt: new Date("2026-03-05T00:00:00.000Z"),
countryCode: "DE", countryCode: "DE",
countryName: "Germany",
federalState: "BY", federalState: "BY",
metroCityName: "Munich", metroCityName: "Munich",
}, },
@@ -193,6 +212,8 @@ describe("dashboard procedure support", () => {
totalHoursPerDay: 160.3, totalHoursPerDay: 160.3,
capacityHours: 200.1, capacityHours: 200.1,
utilizationPct: 80, utilizationPct: 80,
calendarContextCount: 1,
calendarLocations: [{ countryCode: "DE", federalState: "BY", metroCityName: "Munich" }],
}, },
], ],
topResources: [ topResources: [
@@ -202,7 +223,17 @@ describe("dashboard procedure support", () => {
chapter: "CGI", chapter: "CGI",
lcr: "123,45 EUR", lcr: "123,45 EUR",
valueScore: 98, valueScore: 98,
valueScoreBreakdown: {
skillDepth: 94,
skillBreadth: 86,
costEfficiency: 82,
chargeability: 88,
experience: 96,
total: 98,
},
valueScoreUpdatedAt: "2026-03-05T00:00:00.000Z",
countryCode: "DE", countryCode: "DE",
countryName: "Germany",
federalState: "BY", federalState: "BY",
metroCityName: "Munich", metroCityName: "Munich",
}, },
@@ -467,6 +467,15 @@ describe("dashboard router", () => {
displayName: "Alice", displayName: "Alice",
chapter: "Delivery", chapter: "Delivery",
valueScore: 95, valueScore: 95,
valueScoreBreakdown: {
skillDepth: 88,
skillBreadth: 79,
costEfficiency: 84,
chargeability: 76,
experience: 90,
total: 95,
},
valueScoreUpdatedAt: new Date("2026-03-01T00:00:00.000Z"),
lcrCents: 12_300, lcrCents: 12_300,
countryCode: "DE", countryCode: "DE",
countryName: "Germany", countryName: "Germany",
@@ -479,6 +488,15 @@ describe("dashboard router", () => {
displayName: "Bob", displayName: "Bob",
chapter: "Data", chapter: "Data",
valueScore: 88, valueScore: 88,
valueScoreBreakdown: {
skillDepth: 80,
skillBreadth: 77,
costEfficiency: 91,
chargeability: 72,
experience: 81,
total: 88,
},
valueScoreUpdatedAt: new Date("2026-03-02T00:00:00.000Z"),
lcrCents: 10_800, lcrCents: 10_800,
countryCode: "US", countryCode: "US",
countryName: "United States", countryName: "United States",
@@ -748,6 +766,15 @@ describe("dashboard router", () => {
displayName: "Peter Parker", displayName: "Peter Parker",
chapter: "Delivery", chapter: "Delivery",
valueScore: 91, valueScore: 91,
valueScoreBreakdown: {
skillDepth: 85,
skillBreadth: 74,
costEfficiency: 93,
chargeability: 78,
experience: 88,
total: 91,
},
valueScoreUpdatedAt: new Date("2026-03-03T00:00:00.000Z"),
lcrCents: 9_500, lcrCents: 9_500,
countryCode: "DE", countryCode: "DE",
countryName: "Germany", countryName: "Germany",
@@ -788,6 +815,8 @@ describe("dashboard router", () => {
totalHoursPerDay: 320.4, totalHoursPerDay: 320.4,
capacityHours: 400.2, capacityHours: 400.2,
utilizationPct: 80, utilizationPct: 80,
calendarContextCount: 0,
calendarLocations: [],
}, },
], ],
topResources: [ topResources: [
@@ -797,6 +826,15 @@ describe("dashboard router", () => {
chapter: "Delivery", chapter: "Delivery",
lcr: "95,00 EUR", lcr: "95,00 EUR",
valueScore: 91, valueScore: 91,
valueScoreBreakdown: {
skillDepth: 85,
skillBreadth: 74,
costEfficiency: 93,
chargeability: 78,
experience: 88,
total: 91,
},
valueScoreUpdatedAt: "2026-03-03T00:00:00.000Z",
countryCode: "DE", countryCode: "DE",
countryName: "Germany", countryName: "Germany",
federalState: "BY", federalState: "BY",
@@ -31,6 +31,8 @@ type TopValueResourceRow = {
displayName: string; displayName: string;
chapter: string | null; chapter: string | null;
valueScore: number | null; valueScore: number | null;
valueScoreBreakdown: import("@capakraken/shared").ValueScoreBreakdown | null;
valueScoreUpdatedAt: Date | null;
lcrCents: number; lcrCents: number;
countryCode: string | null; countryCode: string | null;
countryName: string | null; countryName: string | null;
@@ -238,6 +240,8 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input:
totalHoursPerDay: round1(entry.totalHours), totalHoursPerDay: round1(entry.totalHours),
capacityHours: round1(entry.capacityHours), capacityHours: round1(entry.capacityHours),
utilizationPct: entry.utilizationPct ?? null, utilizationPct: entry.utilizationPct ?? null,
calendarContextCount: entry.derivation?.calendarContextCount ?? 0,
calendarLocations: entry.derivation?.calendarLocations ?? [],
})); }));
} }
@@ -249,6 +253,8 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input:
chapter: resource.chapter ?? null, chapter: resource.chapter ?? null,
lcr: fmtEur(resource.lcrCents), lcr: fmtEur(resource.lcrCents),
valueScore: resource.valueScore ?? null, valueScore: resource.valueScore ?? null,
valueScoreBreakdown: resource.valueScoreBreakdown ?? null,
valueScoreUpdatedAt: resource.valueScoreUpdatedAt?.toISOString() ?? null,
countryCode: resource.countryCode ?? null, countryCode: resource.countryCode ?? null,
countryName: resource.countryName ?? null, countryName: resource.countryName ?? null,
federalState: resource.federalState ?? null, federalState: resource.federalState ?? null,