feat(dashboard): tighten explainability detail views
This commit is contained in:
@@ -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";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface WidgetContainerProps {
|
||||
@@ -56,7 +57,7 @@ export function WidgetContainer({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{description && (
|
||||
{showDetails && description && (
|
||||
<p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</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;
|
||||
};
|
||||
|
||||
type PeakCalendarLocation = {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resourceCount: number;
|
||||
effectiveAvailableHours: number;
|
||||
};
|
||||
|
||||
type PeakPeriodRow = {
|
||||
period: string;
|
||||
label: string;
|
||||
bookedHours: number;
|
||||
capacityHours: number;
|
||||
baseAvailableHours: number;
|
||||
holidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
remainingHours: number;
|
||||
overbookedHours: number;
|
||||
utilizationPct: number;
|
||||
isCurrentPeriod: boolean;
|
||||
calendarContextCount: number;
|
||||
calendarLocations: PeakCalendarLocation[];
|
||||
groups: PeakDepartmentRow[];
|
||||
};
|
||||
|
||||
@@ -39,6 +54,10 @@ function formatHours(value: number): string {
|
||||
}).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 {
|
||||
if (!periodStart) {
|
||||
return fallback;
|
||||
@@ -68,6 +87,49 @@ function utilizationTextTone(utilizationPct: number): string {
|
||||
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[] {
|
||||
if (rows.length <= limit) {
|
||||
return rows;
|
||||
@@ -95,6 +157,7 @@ function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepa
|
||||
}
|
||||
|
||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const showDetails = config.showDetails === true;
|
||||
const now = new Date();
|
||||
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
|
||||
const endDate = new Date(
|
||||
@@ -114,6 +177,10 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const derivation = period.derivation;
|
||||
const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours;
|
||||
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 =
|
||||
period.remainingHours ??
|
||||
derivation.remainingCapacityHours ??
|
||||
@@ -132,10 +199,16 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period),
|
||||
bookedHours,
|
||||
capacityHours,
|
||||
baseAvailableHours,
|
||||
holidayHoursDeduction,
|
||||
absenceDayEquivalent,
|
||||
absenceHoursDeduction,
|
||||
remainingHours,
|
||||
overbookedHours,
|
||||
utilizationPct,
|
||||
isCurrentPeriod: period.period === currentPeriodKey,
|
||||
calendarContextCount: derivation.calendarContextCount ?? 0,
|
||||
calendarLocations: derivation.calendarLocations ?? [],
|
||||
groups: (period.groups ?? [])
|
||||
.map((group) => {
|
||||
const groupCapacityHours = group.capacityHours ?? 0;
|
||||
@@ -191,9 +264,9 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
if (isLoading && periods.length === 0) {
|
||||
return (
|
||||
<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) => (
|
||||
<div key={index} className="h-14 rounded-2xl shimmer-skeleton" />
|
||||
<div key={index} className="h-10 flex-1 rounded-full shimmer-skeleton" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 rounded-[22px] shimmer-skeleton" />
|
||||
@@ -205,60 +278,45 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 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">
|
||||
Current
|
||||
<div className="flex min-w-0 flex-1 flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "Current", row: currentPeriodRow, fallback: "No data" },
|
||||
{ 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 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>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
The top chart shows total booked load against effective capacity.<br />
|
||||
The current month is marked with a blue accent.<br />
|
||||
Hover any month to inspect details and click to pin the department breakdown.
|
||||
</span>
|
||||
}
|
||||
width="w-80"
|
||||
position="bottom"
|
||||
/>
|
||||
{showDetails ? (
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
The top chart shows total booked load against effective capacity.<br />
|
||||
Effective capacity is base availability minus regional holidays and approved absences.<br />
|
||||
Hover any month to inspect details and click to pin the department breakdown.
|
||||
</span>
|
||||
}
|
||||
width="w-80"
|
||||
position="bottom"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Department Utilization
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{selectedPeriodRow?.label ?? "No active month"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<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.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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
|
||||
{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";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.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 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) {
|
||||
const limit = (config.limit as number) || 10;
|
||||
const { chapters } = useWidgetFilterOptions({ chapters: true });
|
||||
@@ -36,7 +95,20 @@ export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const chapter = (config.chapter as string) ?? "";
|
||||
|
||||
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;
|
||||
return all.filter((r) => r.chapter === chapter);
|
||||
}, [data, chapter]);
|
||||
@@ -159,10 +231,19 @@ export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<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 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-right">
|
||||
<span
|
||||
title={formatScoreTooltip(r)}
|
||||
aria-label={formatScoreTooltip(r)}
|
||||
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
|
||||
(r.valueScore ?? 0) >= 70
|
||||
? "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);
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,23 @@ function saveToStorage(config: DashboardLayoutConfig) {
|
||||
} 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() {
|
||||
const [config, setConfig] = useState<DashboardLayoutConfig>(() => loadFromStorage() ?? createDefaultDashboardLayout());
|
||||
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
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (dbData?.layout) {
|
||||
const dbConfig = normalizeDashboardLayout(dbData.layout);
|
||||
setConfig(dbConfig);
|
||||
saveToStorage(dbConfig);
|
||||
const remoteLayout = dbData?.layout ?? null;
|
||||
if (remoteLayout === null || hasHydratedFromDbRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((nextConfig: DashboardLayoutConfig) => {
|
||||
if (!hasHydratedFromDbRef.current) {
|
||||
hasLocalChangesBeforeHydrationRef.current = true;
|
||||
}
|
||||
const newConfig = normalizeDashboardLayout(nextConfig);
|
||||
saveToStorage(newConfig);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
|
||||
Reference in New Issue
Block a user