diff --git a/apps/web/src/components/dashboard/WidgetContainer.test.tsx b/apps/web/src/components/dashboard/WidgetContainer.test.tsx new file mode 100644 index 0000000..616417c --- /dev/null +++ b/apps/web/src/components/dashboard/WidgetContainer.test.tsx @@ -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 }) => ( +
{children}
+ ), + }, +})); + +describe("WidgetContainer", () => { + it("hides the description while details are off", () => { + const html = renderToStaticMarkup( + {}}> +
Body
+
, + ); + + expect(html).not.toContain("Context text"); + }); + + it("shows the description while details are on", () => { + const html = renderToStaticMarkup( + {}} + showDetails + > +
Body
+
, + ); + + expect(html).toContain("Context text"); + }); +}); diff --git a/apps/web/src/components/dashboard/WidgetContainer.tsx b/apps/web/src/components/dashboard/WidgetContainer.tsx index a52d11b..cae6422 100644 --- a/apps/web/src/components/dashboard/WidgetContainer.tsx +++ b/apps/web/src/components/dashboard/WidgetContainer.tsx @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import { motion } from "framer-motion"; interface WidgetContainerProps { @@ -56,7 +57,7 @@ export function WidgetContainer({ ) : null} - {description && ( + {showDetails && description && (

{description}

diff --git a/apps/web/src/components/dashboard/widgets/PeakTimesWidget.test.tsx b/apps/web/src/components/dashboard/widgets/PeakTimesWidget.test.tsx new file mode 100644 index 0000000..d147c9a --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/PeakTimesWidget.test.tsx @@ -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: () => () =>
chart
, +})); + +vi.mock("~/lib/trpc/client.js", () => ({ + trpc: { + dashboard: { + getPeakTimes: { + useQuery: useQueryMock, + }, + }, + }, +})); + +vi.mock("~/components/ui/InfoTooltip.js", () => ({ + InfoTooltip: () => i, +})); + +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( + {}} />, + ); + const detailedHtml = renderToStaticMarkup( + {}} />, + ); + + 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"); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx b/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx index 7bc9407..5a1c729 100644 --- a/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx @@ -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 (
-
+
{[...Array(3)].map((_, index) => ( -
+
))}
@@ -205,60 +278,45 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) { return (
-
-
-
- Current +
+ {[ + { label: "Current", row: currentPeriodRow, fallback: "No data" }, + { label: "Pinned", row: selectedPeriodRow, fallback: "Hover or pin" }, + { label: "Peak", row: peakPeriodRow, fallback: "No data" }, + ].map((metric) => ( +
+
+ + {metric.label} + + + {metric.row?.utilizationPct ?? 0}% + +
+ {showDetails ? ( +
+ {compactMetricLabel(metric.row, metric.fallback)} +
+ ) : null}
-
- - {currentPeriodRow?.utilizationPct ?? 0}% - - - {currentPeriodRow?.label ?? "No data"} - -
-
- -
-
- Selected -
-
- - {selectedPeriodRow?.utilizationPct ?? 0}% - - - {selectedPeriodRow?.label ?? "Hover or pin"} - -
-
- -
-
- Peak -
-
- - {peakPeriodRow?.utilizationPct ?? 0}% - - - {peakPeriodRow?.label ?? "No data"} - -
-
+ ))}
- - The top chart shows total booked load against effective capacity.
- The current month is marked with a blue accent.
- Hover any month to inspect details and click to pin the department breakdown. - - } - width="w-80" - position="bottom" - /> + {showDetails ? ( + + The top chart shows total booked load against effective capacity.
+ Effective capacity is base availability minus regional holidays and approved absences.
+ Hover any month to inspect details and click to pin the department breakdown. + + } + width="w-80" + position="bottom" + /> + ) : null}
@@ -277,15 +335,28 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
Department Utilization
+ {showDetails ? (
{selectedPeriodRow?.label ?? "No active month"}
-
+ ) : null} +
+ {showDetails ? (
{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}
{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}
+ {selectedPeriodRow ? ( +
{selectedPeriodRow.calendarContextCount} calendar base{selectedPeriodRow.calendarContextCount === 1 ? "" : "s"}
+ ) : null} + {selectedPeriodRow ? ( +
{formatCapacityBasis(selectedPeriodRow)}
+ ) : null} + {selectedPeriodRow ? ( +
{formatCalendarBasis(selectedPeriodRow)}
+ ) : null}
-
+ ) : null} +
{departmentRows.length > 0 ? ( diff --git a/apps/web/src/components/dashboard/widgets/TopValueWidget.test.tsx b/apps/web/src/components/dashboard/widgets/TopValueWidget.test.tsx new file mode 100644 index 0000000..9410abc --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/TopValueWidget.test.tsx @@ -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: () => i, +})); + +vi.mock("~/components/dashboard/WidgetFilterBar.js", () => ({ + WidgetFilterBar: () =>
filters
, +})); + +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( + {}} />, + ); + + 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"); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx b/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx index b337d83..0c35280 100644 --- a/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx @@ -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) { {i + 1} {r.eid} - {r.displayName} + +
{r.displayName}
+ {formatLocation(r) ? ( +
+ {formatLocation(r)} +
+ ) : null} + {r.chapter ?? "\u2014"} = 70 ? "bg-green-100 text-green-700" diff --git a/apps/web/src/hooks/useDashboardLayout.test.ts b/apps/web/src/hooks/useDashboardLayout.test.ts new file mode 100644 index 0000000..f9a0352 --- /dev/null +++ b/apps/web/src/hooks/useDashboardLayout.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/hooks/useDashboardLayout.ts b/apps/web/src/hooks/useDashboardLayout.ts index e3f00aa..cf9bdb4 100644 --- a/apps/web/src/hooks/useDashboardLayout.ts +++ b/apps/web/src/hooks/useDashboardLayout.ts @@ -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(() => loadFromStorage() ?? createDefaultDashboardLayout()); const saveTimeoutRef = useRef | 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); diff --git a/packages/api/src/__tests__/dashboard-procedure-support.test.ts b/packages/api/src/__tests__/dashboard-procedure-support.test.ts index c808263..94dd89f 100644 --- a/packages/api/src/__tests__/dashboard-procedure-support.test.ts +++ b/packages/api/src/__tests__/dashboard-procedure-support.test.ts @@ -156,7 +156,16 @@ describe("dashboard procedure support", () => { recentActivity: [], }); 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([ { @@ -166,7 +175,17 @@ describe("dashboard procedure support", () => { chapter: "CGI", lcrCents: 12345, 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", + countryName: "Germany", federalState: "BY", metroCityName: "Munich", }, @@ -193,6 +212,8 @@ describe("dashboard procedure support", () => { totalHoursPerDay: 160.3, capacityHours: 200.1, utilizationPct: 80, + calendarContextCount: 1, + calendarLocations: [{ countryCode: "DE", federalState: "BY", metroCityName: "Munich" }], }, ], topResources: [ @@ -202,7 +223,17 @@ describe("dashboard procedure support", () => { chapter: "CGI", lcr: "123,45 EUR", valueScore: 98, + valueScoreBreakdown: { + skillDepth: 94, + skillBreadth: 86, + costEfficiency: 82, + chargeability: 88, + experience: 96, + total: 98, + }, + valueScoreUpdatedAt: "2026-03-05T00:00:00.000Z", countryCode: "DE", + countryName: "Germany", federalState: "BY", metroCityName: "Munich", }, diff --git a/packages/api/src/__tests__/dashboard-router.test.ts b/packages/api/src/__tests__/dashboard-router.test.ts index ad42143..c7f5676 100644 --- a/packages/api/src/__tests__/dashboard-router.test.ts +++ b/packages/api/src/__tests__/dashboard-router.test.ts @@ -467,6 +467,15 @@ describe("dashboard router", () => { displayName: "Alice", chapter: "Delivery", 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, countryCode: "DE", countryName: "Germany", @@ -479,6 +488,15 @@ describe("dashboard router", () => { displayName: "Bob", chapter: "Data", 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, countryCode: "US", countryName: "United States", @@ -748,6 +766,15 @@ describe("dashboard router", () => { displayName: "Peter Parker", chapter: "Delivery", 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, countryCode: "DE", countryName: "Germany", @@ -788,6 +815,8 @@ describe("dashboard router", () => { totalHoursPerDay: 320.4, capacityHours: 400.2, utilizationPct: 80, + calendarContextCount: 0, + calendarLocations: [], }, ], topResources: [ @@ -797,6 +826,15 @@ describe("dashboard router", () => { chapter: "Delivery", lcr: "95,00 EUR", valueScore: 91, + valueScoreBreakdown: { + skillDepth: 85, + skillBreadth: 74, + costEfficiency: 93, + chargeability: 78, + experience: 88, + total: 91, + }, + valueScoreUpdatedAt: "2026-03-03T00:00:00.000Z", countryCode: "DE", countryName: "Germany", federalState: "BY", diff --git a/packages/api/src/router/dashboard-procedure-support.ts b/packages/api/src/router/dashboard-procedure-support.ts index 11c2965..80749d7 100644 --- a/packages/api/src/router/dashboard-procedure-support.ts +++ b/packages/api/src/router/dashboard-procedure-support.ts @@ -31,6 +31,8 @@ type TopValueResourceRow = { displayName: string; chapter: string | null; valueScore: number | null; + valueScoreBreakdown: import("@capakraken/shared").ValueScoreBreakdown | null; + valueScoreUpdatedAt: Date | null; lcrCents: number; countryCode: string | null; countryName: string | null; @@ -238,6 +240,8 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input: totalHoursPerDay: round1(entry.totalHours), capacityHours: round1(entry.capacityHours), 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, lcr: fmtEur(resource.lcrCents), valueScore: resource.valueScore ?? null, + valueScoreBreakdown: resource.valueScoreBreakdown ?? null, + valueScoreUpdatedAt: resource.valueScoreUpdatedAt?.toISOString() ?? null, countryCode: resource.countryCode ?? null, countryName: resource.countryName ?? null, federalState: resource.federalState ?? null,