From 2376a1d63ab05181224ddf73ee17c8edc71d2859 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 14 Jul 2026 01:06:29 +0000 Subject: [PATCH 1/2] feat(task T09): implement via codex --- dashboard/src/v2/DashboardV2.tsx | 29 +- dashboard/src/v2/components/HeaderStats.tsx | 90 +++--- .../src/v2/components/OverviewTelemetry.tsx | 90 +++--- dashboard/src/v2/components/SourcesGrid.tsx | 23 +- dashboard/src/v2/components/TasksList.tsx | 52 +-- .../components/__tests__/SourcesGrid.test.tsx | 33 +- .../v2/components/sprints/SprintControls.tsx | 78 +++-- .../src/v2/components/ui/CellActions.tsx | 8 +- .../src/v2/components/ui/FilterStrip.tsx | 8 +- .../src/v2/components/ui/SectionHeader.tsx | 10 +- dashboard/src/v2/components/ui/SourceCell.tsx | 28 +- .../src/v2/components/ui/SprintStreamRow.tsx | 36 ++- dashboard/src/v2/components/ui/TaskRow.tsx | 57 +++- .../src/v2/hooks/use-overview-page-data.ts | 27 +- dashboard/src/v2/i18n/messages/overview.ts | 306 ++++++++++++++++++ dashboard/src/v2/lib/overview-stats.ts | 36 +++ dashboard/src/v2/lib/overview-streams.ts | 14 + .../v2/lib/overview-telemetry-view-models.ts | 37 ++- dashboard/src/v2/lib/view-models.ts | 39 ++- .../dashboard-internationalization.md | 6 + ...tecture-dashboard-internationalization.mdx | 6 + docs/dashboard/design-system-overview.md | 1 + docs/dashboard/internationalization.md | 6 + tests/dashboard/lib/overview-stats.test.ts | 19 +- tests/dashboard/lib/overview-streams.test.ts | 12 +- .../overview-telemetry-view-models.test.ts | 20 ++ .../lib/project-resource-utils.test.ts | 21 +- .../v2/components/ui/source-cell.test.tsx | 3 +- tests/dashboard/v2/header-stats.test.tsx | 39 ++- tests/dashboard/v2/overview-page.test.tsx | 69 ++++ .../dashboard/v2/overview-telemetry.test.tsx | 89 ++++- tests/dashboard/v2/tasks-list.test.tsx | 38 ++- 32 files changed, 1118 insertions(+), 212 deletions(-) create mode 100644 dashboard/src/v2/i18n/messages/overview.ts create mode 100644 tests/dashboard/v2/overview-page.test.tsx diff --git a/dashboard/src/v2/DashboardV2.tsx b/dashboard/src/v2/DashboardV2.tsx index 15946ee7eb..369dc68a00 100644 --- a/dashboard/src/v2/DashboardV2.tsx +++ b/dashboard/src/v2/DashboardV2.tsx @@ -13,6 +13,8 @@ import { PageHeader } from "./components/layout/PageHeader.js"; import { Hexagon } from "lucide-preact"; import { SectionDivider } from "./components/ui/SectionDivider.js"; +import { useDashboardI18n } from "./i18n/index.js"; +import { overviewMessages } from "./i18n/messages/overview.js"; const OverviewTelemetry = lazy(() => import("./components/OverviewTelemetry.js").then(m => ({ default: m.OverviewTelemetry }))); @@ -20,6 +22,7 @@ export const DashboardV2: FunctionComponent = () => { const mainContentRef = useRef(null); const pageData = useOverviewPageData(); const prefersReducedMotion = useReducedMotion(); + const { translate } = useDashboardI18n(); useLayoutEffect(() => { const ctx = gsap.context(() => { @@ -39,48 +42,48 @@ export const DashboardV2: FunctionComponent = () => { }, [prefersReducedMotion]); return ( - -
Overview route loaded
+ +
{translate(overviewMessages, "routeLoaded")}
{/* Page Header */} +
} /> {/* Metrics Section */} -
+
{/* Section Divider */} - + {/* Main Grid */}
{/* Sources and Tasks */}
-
+
-
+
{/* Live Telemetry */} -
}> diff --git a/dashboard/src/v2/components/HeaderStats.tsx b/dashboard/src/v2/components/HeaderStats.tsx index 3e7113ace1..716944cc84 100644 --- a/dashboard/src/v2/components/HeaderStats.tsx +++ b/dashboard/src/v2/components/HeaderStats.tsx @@ -3,8 +3,9 @@ import { useMemo } from "preact/hooks"; import { Sparkline } from "./ui/Sparkline.js"; import { StatsCard } from "../pages/stats/components/StatsCard.js"; import { SkeletonCard } from "./layout/SkeletonLoader.js"; -import { computeOverviewStats } from "../lib/overview-stats.js"; -import { formatCost, formatStatsDuration, formatTokens } from "../pages/stats/stats-utils.js"; +import { computeOverviewStats, formatOverviewCost, formatOverviewDuration, formatOverviewTokens } from "../lib/overview-stats.js"; +import { useDashboardI18n } from "../i18n/index.js"; +import { overviewMessages } from "../i18n/messages/overview.js"; const OverviewCardSparkline: FunctionComponent<{ points: number[]; color: string }> = ({ points, color }) => (
}> = ({ pageData }) => { const { projects, selectedProject, sprints, tasks, stats: statsSnapshot, isLoading } = pageData; + const { formatNumber, translate, translatePlural } = useDashboardI18n(); const stats = useMemo(() => computeOverviewStats(projects, sprints, tasks, statsSnapshot), [projects, sprints, tasks, statsSnapshot]); const usage = statsSnapshot?.usage; const totalTasks = stats.completedTasks + stats.openTasks; const completionRate = totalTasks > 0 ? Math.round((stats.completedTasks / totalTasks) * 100) : 0; const completedSprints = Math.max(stats.totalSprints - stats.activeSprints, 0); - const activeTime = formatStatsDuration(usage?.activeTimeMs ?? 0); + const activeTime = formatOverviewDuration(usage?.activeTimeMs ?? 0, formatNumber); const invocationCount = usage?.invocationCount ?? 0; const activeSprintLabel = statsSnapshot?.activeSprint - ? `#${statsSnapshot.activeSprint.sprintNumber ?? "-"}` - : "None"; + ? `#${statsSnapshot.activeSprint.sprintNumber == null ? "-" : formatNumber(statsSnapshot.activeSprint.sprintNumber)}` + : translate(overviewMessages, "none"); if (isLoading) { return ( -
- Loading overview stats. +
+ {translate(overviewMessages, "loadingOverviewStatsAnnouncement")} @@ -42,110 +44,110 @@ export const HeaderStats: FunctionComponent<{ pageData: ReturnType +
Token telemetry active
} + trend={
{translate(overviewMessages, "tokenTelemetryActive")}
} >
- PROJECT - {selectedProject?.name || "None"} + {translate(overviewMessages, "project")} + {selectedProject?.name || translate(overviewMessages, "none")}
- COST - {formatCost(usage?.totalCostUsd ?? 0)} + {translate(overviewMessages, "cost")} + {formatOverviewCost(usage?.totalCostUsd ?? 0, formatNumber)}
- RUNS - {invocationCount.toLocaleString()} + {translate(overviewMessages, "runs")} + {formatNumber(invocationCount)}
Sprint telemetry available
} + trend={
{translate(overviewMessages, "sprintTelemetryAvailable")}
} >
- ACTIVE - {stats.activeSprints} + {translate(overviewMessages, "active")} + {formatNumber(stats.activeSprints)}
- COMPLETE - {completedSprints} + {translate(overviewMessages, "complete")} + {formatNumber(completedSprints)}
- CURRENT + {translate(overviewMessages, "current")} {activeSprintLabel}
{stats.runningTasks} live tasks
} - description={{stats.runningTasks} live} + trend={
{translatePlural(overviewMessages, "liveTaskCount", stats.runningTasks, { formattedCount: formatNumber(stats.runningTasks) })}
} + description={{translate(overviewMessages, "liveCount", { count: formatNumber(stats.runningTasks) })}} >
- RUNNING - {stats.runningTasks} + {translate(overviewMessages, "running")} + {formatNumber(stats.runningTasks)}
- CRITICAL - 0 ? "text-status-red" : "text-slate-700 dark:text-slate-300"}>{stats.criticalTasks} + {translate(overviewMessages, "critical")} + 0 ? "text-status-red" : "text-slate-700 dark:text-slate-300"}>{formatNumber(stats.criticalTasks)}
- HEALTH - 0 ? "text-status-red" : "text-signal-700 dark:text-signal-400"}>{stats.criticalTasks > 0 ? "Review" : "Clear"} + {translate(overviewMessages, "health")} + 0 ? "text-status-red" : "text-signal-700 dark:text-signal-400"}>{translate(overviewMessages, stats.criticalTasks > 0 ? "review" : "clear")}
- Completion telemetry updated + {translate(overviewMessages, "completionTelemetryUpdated")}
} description={ - {completionRate}% + {formatNumber(completionRate / 100, { style: "percent", maximumFractionDigits: 0 })} } >
- OPEN - {stats.openTasks} + {translate(overviewMessages, "open")} + {formatNumber(stats.openTasks)}
- TOTAL - {totalTasks} + {translate(overviewMessages, "total")} + {formatNumber(totalTasks)}
- ACTIVE TIME + {translate(overviewMessages, "activeTime")} {activeTime}
diff --git a/dashboard/src/v2/components/OverviewTelemetry.tsx b/dashboard/src/v2/components/OverviewTelemetry.tsx index 843640fbf6..16bd66b5d6 100644 --- a/dashboard/src/v2/components/OverviewTelemetry.tsx +++ b/dashboard/src/v2/components/OverviewTelemetry.tsx @@ -5,10 +5,11 @@ import { SkeletonPanel } from "./layout/SkeletonLoader.js"; import { useDashboardRuntimeData } from "../../hooks/use-dashboard-runtime-data.js"; import { useOverviewTelemetry } from "../../hooks/use-overview-telemetry.js"; import { useSprints } from "../../hooks/useSprints.js"; -import { formatTime } from "../../lib/time.js"; import { buildProjectLookup, getEventStyle, getInterventionContent } from "../lib/overview-telemetry-view-models.js"; import { useProjectData } from "../context/project-data.js"; import { AttentionQueueItemsList } from "./AttentionLedger.js"; +import { useDashboardI18n } from "../i18n/index.js"; +import { overviewMessages } from "../i18n/messages/overview.js"; export const OverviewTelemetry: FunctionComponent = () => { @@ -21,6 +22,7 @@ export const OverviewTelemetry: FunctionComponent = () => { { selectedSprintId }, ); const isLoading = telemetryLoading || projectsLoading; + const { formatNumber, formatTime, translate } = useDashboardI18n(); const hasActiveProjects = telemetry?.activeProjects?.length > 0; const hasAttentionProjects = telemetry?.attentionProjects?.length > 0; @@ -35,6 +37,22 @@ export const OverviewTelemetry: FunctionComponent = () => { () => (telemetry?.activeProjects ?? []).reduce((sum, project) => sum + (project.runningDispatchCount ?? 0), 0), [telemetry], ); + const eventLabels = useMemo(() => ({ + taskState: (state: string) => translate(overviewMessages, "eventTaskState", { state }), + sprintState: (state: string) => translate(overviewMessages, "eventSprintState", { state }), + sprintPaused: translate(overviewMessages, "eventSprintPaused"), + states: { + failed: translate(overviewMessages, "eventStateFailed"), + completed: translate(overviewMessages, "eventStateCompleted"), + blocked: translate(overviewMessages, "eventStateBlocked"), + paused: translate(overviewMessages, "eventStatePaused"), + started: translate(overviewMessages, "eventStateStarted"), + running: translate(overviewMessages, "eventStateRunning"), + queued: translate(overviewMessages, "eventStateQueued"), + pending: translate(overviewMessages, "eventStatePending"), + in_progress: translate(overviewMessages, "taskStatusInProgress"), + }, + }), [translate]); if (error) { return ( @@ -42,7 +60,7 @@ export const OverviewTelemetry: FunctionComponent = () => {
@@ -62,17 +80,17 @@ export const OverviewTelemetry: FunctionComponent = () => { : "bg-slate-400 dark:bg-slate-500" }`} /> - {hasActiveProjects ? "Telemetry status: active projects running" : hasAnyAttentionSignal ? "Telemetry status: attention needed" : "Telemetry status: idle"} + {translate(overviewMessages, hasActiveProjects ? "telemetryStatusActive" : hasAnyAttentionSignal ? "telemetryStatusAttention" : "telemetryStatusIdle")}
- Telemetry. + {translate(overviewMessages, "telemetryTitle")}
{isLoading ? ( -
- Loading overview telemetry. +
+ {translate(overviewMessages, "loadingOverviewTelemetryAnnouncement")}
@@ -87,8 +105,8 @@ export const OverviewTelemetry: FunctionComponent = () => {
- Awaiting Runtime - No active project telemetry yet + {translate(overviewMessages, "awaitingRuntime")} + {translate(overviewMessages, "noActiveProjectTelemetry")}
) : ( @@ -96,20 +114,20 @@ export const OverviewTelemetry: FunctionComponent = () => { {/* Stat cards */}
-
Active
-
{telemetry?.activeProjects?.length ?? 0}
+
{translate(overviewMessages, "active")}
+
{formatNumber(telemetry?.activeProjects?.length ?? 0)}
-
-
{totalRunningDispatches}
+
+
{formatNumber(totalRunningDispatches)}
-
Attention
-
{telemetry?.attentionProjects?.length ?? 0}
+
{translate(overviewMessages, "attention")}
+
{formatNumber(telemetry?.attentionProjects?.length ?? 0)}
-
Events
-
{telemetry?.recentEvents?.length ?? 0}
+
{translate(overviewMessages, "events")}
+
{formatNumber(telemetry?.recentEvents?.length ?? 0)}
@@ -118,21 +136,21 @@ export const OverviewTelemetry: FunctionComponent = () => {
- {scopedAttentionItems.length} + {formatNumber(scopedAttentionItems.length)}

- {selectedProject?.name || "Selected project"} + {selectedProject?.name || translate(overviewMessages, "selectedProject")}

@@ -144,7 +162,7 @@ export const OverviewTelemetry: FunctionComponent = () => {
{telemetry.attentionProjects.map((project) => ( @@ -153,17 +171,17 @@ export const OverviewTelemetry: FunctionComponent = () => {
{project.projectName}
- {project.sprintName}{project.sprintNumber != null ? ` · Sprint ${project.sprintNumber}` : ""} + {project.sprintName}{project.sprintNumber != null ? ` · ${translate(overviewMessages, "sprintNumber", { number: formatNumber(project.sprintNumber) })}` : ""}
- {getInterventionContent(project) && ( + {getInterventionContent(project, translate(overviewMessages, "humanInterventionRequired")) && (
- {getInterventionContent(project)!.title} + {getInterventionContent(project, translate(overviewMessages, "humanInterventionRequired"))!.title}
)}
@@ -178,8 +196,8 @@ export const OverviewTelemetry: FunctionComponent = () => { {telemetry?.activeProjects?.length > 0 && (
- Active Sprints - {telemetry.activeProjects.length} + {translate(overviewMessages, "activeSprints")} + {formatNumber(telemetry.activeProjects.length)}
{telemetry.activeProjects.map((project) => { @@ -196,14 +214,14 @@ export const OverviewTelemetry: FunctionComponent = () => {
{project.projectName}
- {project.sprintName}{project.sprintNumber != null ? ` · Sprint ${project.sprintNumber}` : ""} + {project.sprintName}{project.sprintNumber != null ? ` · ${translate(overviewMessages, "sprintNumber", { number: formatNumber(project.sprintNumber) })}` : ""}
{active > 0 && ( @@ -219,22 +237,22 @@ export const OverviewTelemetry: FunctionComponent = () => { )}
-
Runtime Timeline
-
+
{translate(overviewMessages, "runtimeTimeline")}
+
{telemetry?.recentEvents?.map((event) => { - const style = getEventStyle(event); + const style = getEventStyle(event, eventLabels); return (
{style.label}
-
{formatTime(event.createdAt)}
+
{formatTime(new Date(event.createdAt), { hour: "2-digit", minute: "2-digit" })}
- {projectLookup.get(event.projectId) || "Project"} + {projectLookup.get(event.projectId) || translate(overviewMessages, "fallbackProject")}
- {event.sprintName}{event.sprintNumber != null ? ` · Sprint ${event.sprintNumber}` : ""} + {event.sprintName}{event.sprintNumber != null ? ` · ${translate(overviewMessages, "sprintNumber", { number: formatNumber(event.sprintNumber) })}` : ""}
); diff --git a/dashboard/src/v2/components/SourcesGrid.tsx b/dashboard/src/v2/components/SourcesGrid.tsx index 804f6716df..67344f7697 100644 --- a/dashboard/src/v2/components/SourcesGrid.tsx +++ b/dashboard/src/v2/components/SourcesGrid.tsx @@ -7,6 +7,9 @@ import { SourceCell } from "./ui/SourceCell.js"; import { SkeletonCard } from "./layout/SkeletonLoader.js"; import { useProjectData } from "../context/project-data.js"; import { useReducedMotion } from "../hooks/use-reduced-motion.js"; +import { EmptyState } from "./ui/EmptyState.js"; +import { useDashboardI18n } from "../i18n/index.js"; +import { overviewMessages } from "../i18n/messages/overview.js"; const DEFAULT_VISIBLE_SOURCE_CELLS = 5; const COMPACT_VISIBLE_SOURCE_CELLS = 4; @@ -54,6 +57,7 @@ export const SourcesGrid: FunctionComponent = () => { const [availableColumns, setAvailableColumns] = useState(DEFAULT_VISIBLE_SOURCE_CELLS); const { projects, loading: projectsLoading } = useProjectData(); const prefersReducedMotion = useReducedMotion(); + const { translate } = useDashboardI18n(); useLayoutEffect(() => { if (containerRef.current) { @@ -105,11 +109,11 @@ export const SourcesGrid: FunctionComponent = () => { }, [layoutPlan.visibleCount, projects]); return ( -
+
} - title="Projects & Sources" + title={translate(overviewMessages, "projectsAndSources")} />
{ data-source-columns={projectsLoading ? undefined : layoutPlan.columns} > {projectsLoading ? ( - <> +
+ {translate(overviewMessages, "loadingSourcesAnnouncement")}
- +
+ ) : recentSources.length === 0 ? ( +
+
) : ( recentSources.map((source, index) => (
}> = ({ pageData }) => { const listRef = useRef(null); - const [activeFilter, setActiveFilter] = useState("All Tasks"); + const [activeFilter, setActiveFilter] = useState("all"); + const { formatNumber, locale, translate, translatePlural } = useDashboardI18n(); + + const filterOptions = useMemo(() => [ + { value: "all", label: translate(overviewMessages, "filterAllTasks") }, + { value: "running", label: translate(overviewMessages, "filterRunning") }, + { value: "queued", label: translate(overviewMessages, "filterQueued") }, + { value: "completed", label: translate(overviewMessages, "filterCompleted") }, + ] as const, [translate]); - const handleFilterChange = (newFilter: TaskFilter) => { + const handleFilterChange = (newFilter: OverviewTaskFilter) => { setActiveFilter(newFilter); }; const handleClearFilter = () => { - setActiveFilter("All Tasks"); + setActiveFilter("all"); }; const { sprints, tasks, execution, selectedProject, isLoading } = pageData; @@ -36,13 +42,7 @@ export const TasksList: FunctionComponent<{ pageData: ReturnType deriveActiveSprintIds(sprints), [sprints]); const activeTasks = useMemo(() => filterTasksToActiveSprints(tasks, activeSprintIds), [tasks, activeSprintIds]); - const filteredTasks = useMemo(() => activeTasks.filter(task => { - if (activeFilter === "All Tasks") return true; - if (activeFilter === "Running") return task.status === "in_progress"; - if (activeFilter === "Queued") return task.status === "pending"; - if (activeFilter === "Completed") return task.status === "completed"; - return true; - }), [activeTasks, activeFilter]); + const filteredTasks = useMemo(() => filterOverviewTasks(activeTasks, activeFilter), [activeTasks, activeFilter]); const visibleTasks = useProgressiveList(filteredTasks, { initialCount: DEFAULT_LIST_WINDOW, incrementCount: DEFAULT_LIST_WINDOW, @@ -87,26 +87,29 @@ export const TasksList: FunctionComponent<{ pageData: ReturnType
-

Active Streams

+

{translate(overviewMessages, "activeStreams")}

- {filteredTasks.length} active + {translatePlural(overviewMessages, "activeCount", filteredTasks.length, { formattedCount: formatNumber(filteredTasks.length) })}
{/* Task rows */} -
+
{isLoading ? ( -
+
@@ -130,6 +133,7 @@ export const TasksList: FunctionComponent<{ pageData: ReturnType streamActions.playStopTask(task)} /> @@ -137,10 +141,10 @@ export const TasksList: FunctionComponent<{ pageData: ReturnType +
diff --git a/dashboard/src/v2/components/__tests__/SourcesGrid.test.tsx b/dashboard/src/v2/components/__tests__/SourcesGrid.test.tsx index ac519e23a2..f24e9b776a 100644 --- a/dashboard/src/v2/components/__tests__/SourcesGrid.test.tsx +++ b/dashboard/src/v2/components/__tests__/SourcesGrid.test.tsx @@ -6,6 +6,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { Source } from "../../types.js"; import { planSourcesGridLayout, SourcesGrid } from "../SourcesGrid.js"; import { ProjectDataContext } from "../../context/project-data.js"; +import { DashboardI18nProvider } from "../../i18n/index.js"; +import type { DashboardLocale } from "../../i18n/locales.js"; expect.extend(matchers); @@ -53,13 +55,14 @@ const createSource = (id: number, updatedAt: string): Source => ({ updatedAt, }); -const renderSourcesGrid = (projects: Source[]) => render( - render( + + render( deleteProject: vi.fn(), }} > - - , + + + , ); describe("SourcesGrid", () => { @@ -117,4 +121,23 @@ describe("SourcesGrid", () => { expect(grid.style.justifyContent).toBe("space-between"); expect(grid.style.gridTemplateColumns).toContain("calc((100% - 48px) / 3)"); }); + + it("localizes German source state and counts without changing project names", () => { + const source = { ...createSource(1234, "2026-01-06T00:00:00.000Z"), name: "Runtime Project Name", status: "running" } as Source; + renderSourcesGrid([source], "de"); + + expect(screen.getByRole("heading", { name: "Projekte & Quellen" })).toBeInTheDocument(); + expect(screen.getByText("Runtime Project Name")).toBeInTheDocument(); + expect(screen.getByText("Laufend")).toBeInTheDocument(); + expect(screen.getByText(`${new Intl.NumberFormat("de").format(1234)} offen`)).toBeInTheDocument(); + }); + + it("announces German loading and empty states", () => { + const loadingView = renderSourcesGrid([], "de", true); + expect(screen.getByRole("status", { name: "Projekte und Quellen werden geladen" })).toHaveAttribute("aria-busy", "true"); + loadingView.unmount(); + + renderSourcesGrid([], "de"); + expect(screen.getByRole("status")).toHaveTextContent("Keine Projekte oder Quellen"); + }); }); diff --git a/dashboard/src/v2/components/sprints/SprintControls.tsx b/dashboard/src/v2/components/sprints/SprintControls.tsx index 66756ea7f4..fa8ab81ff2 100644 --- a/dashboard/src/v2/components/sprints/SprintControls.tsx +++ b/dashboard/src/v2/components/sprints/SprintControls.tsx @@ -10,8 +10,45 @@ export interface SprintControlsProps { isPauseResumePending: boolean; onStartStop: () => void; onPauseResume: () => void; + labels?: SprintControlsLabels; } +export interface SprintControlsLabels { + pause: string; + resume: string; + start: string; + stop: string; + pending: (action: string) => string; + pendingLabel: (action: string, sprintName: string) => string; + actionLabel: (action: string, sprintName: string) => string; + waitForAction: string; + waitForActionTitle: string; + pauseUnavailable: string; + mustRunToPause: string; + resumeExecution: string; + pauseExecution: string; + stopExecution: string; + startExecution: string; +} + +const DEFAULT_LABELS: SprintControlsLabels = { + pause: "Pause", + resume: "Resume", + start: "Start", + stop: "Stop", + pending: (action) => `${action} pending`, + pendingLabel: (action, sprintName) => `${action} ${sprintName} is pending`, + actionLabel: (action, sprintName) => `${action} ${sprintName}`, + waitForAction: "Wait for the current sprint action to finish.", + waitForActionTitle: "Wait for the current sprint action to finish", + pauseUnavailable: "Pause is available after the sprint starts.", + mustRunToPause: "Sprint must be running to pause", + resumeExecution: "Resume sprint execution", + pauseExecution: "Pause sprint execution", + stopExecution: "Stop sprint execution", + startExecution: "Start sprint execution", +}; + export const SprintControls: FunctionComponent = ({ isActive, isPaused, @@ -20,11 +57,12 @@ export const SprintControls: FunctionComponent = ({ onStartStop, onPauseResume, sprintName = "sprint", + labels = DEFAULT_LABELS, }) => { const interactionTokens = useInteractionTokens(); const canPauseResume = isActive || isPaused; - const pauseResumeLabel = isPaused ? "Resume" : "Pause"; - const startStopLabel = isActive ? "Stop" : "Start"; + const pauseResumeLabel = isPaused ? labels.resume : labels.pause; + const startStopLabel = isActive ? labels.stop : labels.start; const isAnyPending = isPauseResumePending || isStartStopPending; const controlFeedbackStyle = { transitionDuration: interactionTokens.controlFeedback.duration, @@ -35,14 +73,14 @@ export const SprintControls: FunctionComponent = ({ transitionTimingFunction: interactionTokens.asyncFeedback.ease, }; const busyLabel = isPauseResumePending - ? `${pauseResumeLabel} pending` + ? labels.pending(pauseResumeLabel) : isStartStopPending - ? `${startStopLabel} pending` + ? labels.pending(startStopLabel) : null; const disabledReason = isAnyPending - ? "Wait for the current sprint action to finish." + ? labels.waitForAction : !canPauseResume - ? "Pause is available after the sprint starts." + ? labels.pauseUnavailable : null; const reasonId = `sprint-controls-${sprintName.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-reason`; const handlePauseResume = () => { @@ -65,22 +103,22 @@ export const SprintControls: FunctionComponent = ({ onClick={handlePauseResume} aria-label={ isPauseResumePending - ? `${pauseResumeLabel} ${sprintName} is pending` + ? labels.pendingLabel(pauseResumeLabel, sprintName) : isPaused - ? `Resume ${sprintName}` - : `Pause ${sprintName}` + ? labels.actionLabel(labels.resume, sprintName) + : labels.actionLabel(labels.pause, sprintName) } aria-busy={isPauseResumePending ? "true" : undefined} aria-describedby={disabledReason ? reasonId : undefined} disabled={!canPauseResume || isAnyPending} title={ isPauseResumePending || isStartStopPending - ? "Wait for the current sprint action to finish" + ? labels.waitForActionTitle : !canPauseResume - ? "Sprint must be running to pause" + ? labels.mustRunToPause : isPaused - ? "Resume sprint execution" - : "Pause sprint execution" + ? labels.resumeExecution + : labels.pauseExecution } className={`inline-flex min-h-8 min-w-[6.75rem] flex-1 flex-nowrap items-center justify-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-bold leading-tight no-underline decoration-transparent transition-colors hover:no-underline focus:no-underline focus-visible:ring-2 focus-visible:ring-signal-500/30 sm:flex-none ${ isPaused @@ -106,20 +144,20 @@ export const SprintControls: FunctionComponent = ({ onClick={handleStartStop} aria-label={ isStartStopPending - ? `${startStopLabel} ${sprintName} is pending` + ? labels.pendingLabel(startStopLabel, sprintName) : isActive - ? `Stop ${sprintName}` - : `Start ${sprintName}` + ? labels.actionLabel(labels.stop, sprintName) + : labels.actionLabel(labels.start, sprintName) } aria-busy={isStartStopPending ? "true" : undefined} aria-describedby={disabledReason ? reasonId : undefined} disabled={isAnyPending} title={ isStartStopPending || isPauseResumePending - ? "Wait for the current sprint action to finish" + ? labels.waitForActionTitle : isActive - ? "Stop sprint execution" - : "Start sprint execution" + ? labels.stopExecution + : labels.startExecution } className={`inline-flex min-h-8 min-w-[6.75rem] flex-1 flex-nowrap items-center justify-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-bold leading-tight no-underline decoration-transparent transition-colors hover:no-underline focus:no-underline focus-visible:ring-2 focus-visible:ring-signal-500/30 sm:flex-none ${ isActive @@ -145,7 +183,7 @@ export const SprintControls: FunctionComponent = ({ aria-live="polite" className={busyLabel ? "basis-full text-left text-[11px] font-bold leading-4 text-signal-600 dark:text-signal-300" : "sr-only"} > - {busyLabel ? `${busyLabel}. Wait for the current sprint action to finish.` : disabledReason ?? ""} + {busyLabel ? `${busyLabel}. ${labels.waitForAction}` : disabledReason ?? ""} ); diff --git a/dashboard/src/v2/components/ui/CellActions.tsx b/dashboard/src/v2/components/ui/CellActions.tsx index f95546669d..552e21ca2b 100644 --- a/dashboard/src/v2/components/ui/CellActions.tsx +++ b/dashboard/src/v2/components/ui/CellActions.tsx @@ -15,6 +15,8 @@ interface CellActionsProps { onPrimaryAction?: () => void | Promise; onSprintsClick?: () => void | Promise; onSettingsClick?: () => void | Promise; + primaryLabel?: string; + settingsLabel?: string; } /** @@ -29,6 +31,8 @@ export const CellActions: FunctionComponent = ({ onPrimaryAction, onSprintsClick, onSettingsClick, + primaryLabel, + settingsLabel = "Settings", }) => { const { feedback: primaryFeedback, setPending: setPrimaryPending, setSuccess: setPrimarySuccess, setError: setPrimaryError } = useActionFeedback(1500); const tokens = useInteractionTokens(); @@ -60,7 +64,7 @@ export const CellActions: FunctionComponent = ({ )}
diff --git a/dashboard/src/v2/components/ui/SectionHeader.tsx b/dashboard/src/v2/components/ui/SectionHeader.tsx index a6affd9497..89e19110b4 100644 --- a/dashboard/src/v2/components/ui/SectionHeader.tsx +++ b/dashboard/src/v2/components/ui/SectionHeader.tsx @@ -19,13 +19,13 @@ export const SectionHeader: FunctionComponent = ({ }) => (
-

+

-

- {icon} + +

+ {title} -

+
); diff --git a/dashboard/src/v2/components/ui/SourceCell.tsx b/dashboard/src/v2/components/ui/SourceCell.tsx index adb1597510..9663104834 100644 --- a/dashboard/src/v2/components/ui/SourceCell.tsx +++ b/dashboard/src/v2/components/ui/SourceCell.tsx @@ -6,12 +6,14 @@ import type { Source } from "../../types.js"; import { useProjectData } from "../../context/project-data.js"; import { CellActions } from "./CellActions.js"; import { ORGANIC_CELL_SHADOW_CLASS } from "./organic-cell-styles.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { overviewMessages } from "../../i18n/messages/overview.js"; const statusMap = { - running: { ring: 'border-status-green/50 shadow-[0_0_28px_rgba(0,171,132,0.35)]', text: 'text-status-green', icon: Activity, label: "Running" }, - failed: { ring: 'border-status-red/60 shadow-[0_0_28px_rgba(227,0,15,0.35)]', text: 'text-status-red', icon: XCircle, label: "Failed" }, - intervention: { ring: 'border-status-amber/50 shadow-[0_0_28px_rgba(245,158,11,0.3)]', text: 'text-status-amber', icon: AlertTriangle, label: "Needs Review" }, - idle: { ring: '', text: 'text-slate-400 dark:text-slate-500', icon: FolderGit2, label: "Idle" }, + running: { ring: 'border-status-green/50 shadow-[0_0_28px_rgba(0,171,132,0.35)]', text: 'text-status-green', icon: Activity, labelKey: "sourceStatusRunning" }, + failed: { ring: 'border-status-red/60 shadow-[0_0_28px_rgba(227,0,15,0.35)]', text: 'text-status-red', icon: XCircle, labelKey: "sourceStatusFailed" }, + intervention: { ring: 'border-status-amber/50 shadow-[0_0_28px_rgba(245,158,11,0.3)]', text: 'text-status-amber', icon: AlertTriangle, labelKey: "sourceStatusIntervention" }, + idle: { ring: '', text: 'text-slate-400 dark:text-slate-500', icon: FolderGit2, labelKey: "sourceStatusIdle" }, } as const; interface SourceCellProps { @@ -23,9 +25,13 @@ interface SourceCellProps { export const SourceCell: FunctionComponent = ({ source, isEven, animDelay = 0 }) => { const cellRef = useRef(null); const { selectProject } = useProjectData(); + const { formatNumber, translate, translatePlural } = useDashboardI18n(); const anim = isEven ? 'animate-organic' : 'animate-organic-reverse'; const state = statusMap[source.status] ?? statusMap.idle; const StatusIcon = state.icon; + const statusLabel = translate(overviewMessages, state.labelKey); + const openCount = translatePlural(overviewMessages, "sourceOpenCount", source.openTasks, { formattedCount: formatNumber(source.openTasks) }); + const doneCount = translatePlural(overviewMessages, "sourceDoneCount", source.completedTasks, { formattedCount: formatNumber(source.completedTasks) }); const handleHoverEnter = useCallback(() => { if (!cellRef.current) return; @@ -57,6 +63,7 @@ export const SourceCell: FunctionComponent = ({ source, isEven, onFocus={handleHoverEnter} onBlur={handleHoverLeave} role="group" + aria-label={translate(overviewMessages, "sourceGroupLabel", { name: source.name, status: statusLabel, openCount, doneCount })} tabIndex={0} className="relative group cursor-pointer aspect-square w-[min(14rem,72vw)] max-w-full flex items-center justify-center shrink-0 perspective-1000 focus-visible:ring-2 focus-visible:ring-signal-500/50 focus-visible:rounded-[2rem] focus:outline-none" style={{ animationDelay: `${animDelay}s` }} @@ -82,13 +89,13 @@ export const SourceCell: FunctionComponent = ({ source, isEven,
{/* Status label on hover */}
- - {state.label} +
{/* Main icon */}
- +

@@ -96,14 +103,17 @@ export const SourceCell: FunctionComponent = ({ source, isEven,

- {source.openTasks} open + {openCount} · - {source.completedTasks} done + {doneCount}
{/* Actions */} selectProject(source.id)} onSettingsClick={() => selectProject(source.id)} diff --git a/dashboard/src/v2/components/ui/SprintStreamRow.tsx b/dashboard/src/v2/components/ui/SprintStreamRow.tsx index 396136e5be..846769523c 100644 --- a/dashboard/src/v2/components/ui/SprintStreamRow.tsx +++ b/dashboard/src/v2/components/ui/SprintStreamRow.tsx @@ -2,8 +2,11 @@ import type { FunctionComponent } from "preact"; import { Layers } from "lucide-preact"; import type { Sprint } from "../../types.js"; import { SprintControls } from "../sprints/SprintControls.js"; +import type { SprintControlsLabels } from "../sprints/SprintControls.js"; import type { SprintStreamState } from "../../hooks/use-overview-stream-actions.js"; -import { clampSprintCompletion, formatSprintCompletion } from "../../lib/sprint-progress-display.js"; +import { clampSprintCompletion } from "../../lib/sprint-progress-display.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { overviewMessages } from "../../i18n/messages/overview.js"; interface SprintStreamRowProps { sprint: Sprint; @@ -28,20 +31,38 @@ export const SprintStreamRow: FunctionComponent = ({ onStartStop, onPauseResume, }) => { + const { formatNumber, translate, translatePlural } = useDashboardI18n(); const completion = clampSprintCompletion(sprint.completion ?? 0); - const completionLabel = formatSprintCompletion(completion); + const completionLabel = formatNumber(completion / 100, { style: "percent", maximumFractionDigits: 1 }); const accent = state.isPaused ? "bg-status-amber" : state.isActive ? "bg-status-green" : "bg-slate-400 dark:bg-slate-500"; - const statusLabel = state.isPaused ? "Paused" : state.isActive ? "Running" : "Idle"; + const statusLabel = translate(overviewMessages, state.isPaused ? "sprintStatusPaused" : state.isActive ? "sprintStatusRunning" : "sprintStatusIdle"); + const controlsLabels: SprintControlsLabels = { + pause: translate(overviewMessages, "sprintPause"), + resume: translate(overviewMessages, "sprintResume"), + start: translate(overviewMessages, "sprintStart"), + stop: translate(overviewMessages, "sprintStop"), + pending: (action) => translate(overviewMessages, "sprintActionPending", { action }), + pendingLabel: (action, sprintName) => translate(overviewMessages, "sprintActionPendingLabel", { action, name: sprintName }), + actionLabel: (action, sprintName) => translate(overviewMessages, "sprintActionLabel", { action, name: sprintName }), + waitForAction: translate(overviewMessages, "waitForSprintAction"), + waitForActionTitle: translate(overviewMessages, "waitForSprintActionTitle"), + pauseUnavailable: translate(overviewMessages, "sprintPauseUnavailable"), + mustRunToPause: translate(overviewMessages, "sprintMustRunToPause"), + resumeExecution: translate(overviewMessages, "resumeSprintExecution"), + pauseExecution: translate(overviewMessages, "pauseSprintExecution"), + stopExecution: translate(overviewMessages, "stopSprintExecution"), + startExecution: translate(overviewMessages, "startSprintExecution"), + }; return (
@@ -60,7 +81,7 @@ export const SprintStreamRow: FunctionComponent = ({ · - {taskCount} task{taskCount === 1 ? "" : "s"} + {translatePlural(overviewMessages, "taskCount", taskCount, { formattedCount: formatNumber(taskCount) })}

@@ -72,13 +93,13 @@ export const SprintStreamRow: FunctionComponent = ({
- Progress + {translate(overviewMessages, "progress")} {completionLabel}
= ({ onStartStop={onStartStop} onPauseResume={onPauseResume} sprintName={sprint.name} + labels={controlsLabels} />
diff --git a/dashboard/src/v2/components/ui/TaskRow.tsx b/dashboard/src/v2/components/ui/TaskRow.tsx index 503d672a4c..38ca111658 100644 --- a/dashboard/src/v2/components/ui/TaskRow.tsx +++ b/dashboard/src/v2/components/ui/TaskRow.tsx @@ -5,22 +5,45 @@ import type { Task } from "../../types.js"; import type { TaskStreamState } from "../../hooks/use-overview-stream-actions.js"; import { SprintReviewBadge } from "../sprints/SprintReviewBadge.js"; import { useInteractionTokens } from "../../lib/motion/tokens.js"; +import { overviewMessages } from "../../i18n/messages/overview.js"; +import { translateDashboardMessage, type DashboardLocale, type DashboardMessageVariables, type DashboardTextMessageKey } from "../../i18n/locales.js"; interface TaskRowProps { task: Task; state?: TaskStreamState; onPlayStop?: () => void; + locale?: DashboardLocale; } -export const TaskRow: FunctionComponent = memo(({ task, state, onPlayStop }) => { +export const TaskRow: FunctionComponent = memo(({ task, state, onPlayStop, locale = "en" }) => { const isRunning = state?.isRunning ?? task.status === "in_progress"; const busy = state?.busy ?? false; const interactionTokens = useInteractionTokens(); - const playStopLabel = isRunning ? "Stop" : "Rerun"; + const translate = >( + key: Key, + variables?: DashboardMessageVariables, + ): string => translateDashboardMessage(overviewMessages, locale, key, variables); + const playStopLabel = translate(isRunning ? "stop" : "rerun"); + const taskStatus = translate(task.status === "completed" + ? "taskStatusCompleted" + : task.status === "coding_completed" + ? "taskStatusCodingCompleted" + : task.status === "in_progress" + ? "taskStatusInProgress" + : task.status === "QA_REVIEW_FAILED" + ? "taskStatusQaReviewFailed" + : "taskStatusPending"); + const taskTime = task.status === "completed" + ? translate("taskTimeDone") + : task.status === "coding_completed" + ? translate("taskTimeReview") + : task.status === "in_progress" + ? translate("taskTimeActive") + : task.time; const disabledReason = busy - ? `${playStopLabel} unavailable while task action is pending` + ? translate("taskActionPendingUnavailable", { action: playStopLabel }) : !onPlayStop - ? `${playStopLabel} unavailable for this task` + ? translate("taskActionUnavailable", { action: playStopLabel }) : null; return (
= memo(({ task, state, onP {/* Source */}
- Source: {task.source} + {translate("taskSource")} {task.source}
{/* Status */} @@ -77,14 +100,14 @@ export const TaskRow: FunctionComponent = memo(({ task, state, onP )} {task.status === 'pending' &&
@@ -92,8 +115,8 @@ export const TaskRow: FunctionComponent = memo(({ task, state, onP
{/* Quick actions */} @@ -101,8 +124,10 @@ export const TaskRow: FunctionComponent = memo(({ task, state, onP event.stopPropagation()} > @@ -129,8 +154,8 @@ export const TaskRow: FunctionComponent = memo(({ task, state, onP event.stopPropagation()} > diff --git a/dashboard/src/v2/hooks/use-overview-page-data.ts b/dashboard/src/v2/hooks/use-overview-page-data.ts index bccfefa053..5a3387bc5d 100644 --- a/dashboard/src/v2/hooks/use-overview-page-data.ts +++ b/dashboard/src/v2/hooks/use-overview-page-data.ts @@ -4,9 +4,13 @@ import { useSprints } from "../../hooks/useSprints.js"; import { useExecutions } from "../../hooks/useExecutions.js"; import { useProjectTasks } from "./use-project-tasks.js"; import { useProjectStats } from "./use-project-stats.js"; +import { useDashboardI18n } from "../i18n/index.js"; +import { overviewMessages } from "../i18n/messages/overview.js"; +import { formatSprintDateRange, localizeTaskViewModelFallbacks } from "../lib/view-models.js"; export function useOverviewPageData() { const { projects, selectedProject, loading: projectsLoading } = useProjectData(); + const { locale, translate } = useDashboardI18n(); const projectId = selectedProject?.id || null; const { data: sprints, loading: sprintsLoading } = useSprints(projectId); @@ -15,14 +19,31 @@ export function useOverviewPageData() { const { data: execution } = useExecutions(projectId); const isLoading = projectsLoading || sprintsLoading || tasksLoading || statsLoading; + const localizedSprints = useMemo(() => sprints.map((sprint) => ({ + ...sprint, + date: formatSprintDateRange(sprint.startDate, sprint.endDate, { + locale, + scheduleTbd: translate(overviewMessages, "scheduleTbd"), + }), + })), [locale, sprints, translate]); + const localizedTasks = useMemo(() => { + const knownSourceNames = new Set(projects.map((project) => project.name)); + const knownSprintNames = new Set(sprints.map((sprint) => sprint.name)); + return tasks.map((task) => localizeTaskViewModelFallbacks(task, { + knownSourceNames, + knownSprintNames, + unassigned: translate(overviewMessages, "unassigned"), + sprint: translate(overviewMessages, "sprintFallback"), + })); + }, [projects, sprints, tasks, translate]); return useMemo(() => ({ projects, selectedProject, - sprints, - tasks, + sprints: localizedSprints, + tasks: localizedTasks, stats, execution, isLoading - }), [projects, selectedProject, sprints, tasks, stats, execution, isLoading]); + }), [projects, selectedProject, localizedSprints, localizedTasks, stats, execution, isLoading]); } diff --git a/dashboard/src/v2/i18n/messages/overview.ts b/dashboard/src/v2/i18n/messages/overview.ts new file mode 100644 index 0000000000..4e778ae16a --- /dev/null +++ b/dashboard/src/v2/i18n/messages/overview.ts @@ -0,0 +1,306 @@ +import { defineDashboardMessages } from "../locales.js"; + +export const overviewMessages = defineDashboardMessages({ + en: { + routeLabel: "Dashboard Overview", + routeLoaded: "Overview route loaded", + eyebrow: "Mission Control", + title: "Overview", + subtitle: "Real-time metrics and operational intelligence across your cluster.", + clusterOptimal: "Cluster Optimal", + clusterOptimalStatus: "Status: Cluster Optimal", + metrics: "Metrics", + dataStreams: "Data Streams", + sources: "Sources", + tasks: "Tasks", + liveTelemetry: "Live Telemetry", + loadingTelemetryPanel: "Loading live telemetry.", + loadingOverviewStats: "Loading overview stats", + loadingOverviewStatsAnnouncement: "Loading overview stats.", + overviewMetricCards: "Overview metric cards", + totalTokens: "Total Tokens", + tokenTelemetryActive: "Token telemetry active", + project: "Project", + cost: "Cost", + runs: "Runs", + none: "None", + sprints: "Sprints", + sprintTelemetryAvailable: "Sprint telemetry available", + active: "Active", + complete: "Complete", + current: "Current", + openTasks: "Open Tasks", + running: "Running", + critical: "Critical", + health: "Health", + review: "Review", + clear: "Clear", + completedTasks: "Completed Tasks", + completionTelemetryUpdated: "Completion telemetry updated", + open: "Open", + total: "Total", + activeTime: "Active Time", + liveTaskCount: { one: "{formattedCount} live task", other: "{formattedCount} live tasks" }, + liveCount: "{count} live", + projectsAndSources: "Projects & Sources", + dataWatermark: "DATA", + loadingSources: "Loading projects and sources", + loadingSourcesAnnouncement: "Loading projects and sources.", + noSources: "No Projects or Sources", + noSourcesDescription: "Projects will appear here after you add them.", + sourceStatusRunning: "Running", + sourceStatusFailed: "Failed", + sourceStatusIntervention: "Needs Review", + sourceStatusIdle: "Idle", + sourceOpenCount: { one: "{formattedCount} open", other: "{formattedCount} open" }, + sourceDoneCount: { one: "{formattedCount} done", other: "{formattedCount} done" }, + sourceGroupLabel: "{name}. {status}. {openCount}; {doneCount}.", + sourceSprintsAction: "Sprints", + play: "Play", + stop: "Stop", + settings: "Settings", + activeStreams: "Active Streams", + filterAllTasks: "All Tasks", + filterRunning: "Running", + filterQueued: "Queued", + filterCompleted: "Completed", + clearFilters: "Clear filters", + activeStreamFilters: "Active stream filters", + activeCount: { one: "{formattedCount} active", other: "{formattedCount} active" }, + activeStreamTasks: "Active stream tasks", + loadingActiveStreamTasks: "Loading active stream tasks", + noActiveStreams: "No Active Streams", + noActiveStreamsDescription: "There are no tasks currently matching the selected filter in active sprints.", + taskCount: { one: "{formattedCount} task", other: "{formattedCount} tasks" }, + sprintStatusPaused: "Paused", + sprintStatusRunning: "Running", + sprintStatusIdle: "Idle", + sprintStreamDescription: "{name} active stream. {status}. {completion} complete.", + progress: "Progress", + sprintProgress: "{name} progress", + taskSource: "Source:", + taskDuration: "Duration:", + taskStatusAnnouncement: "Task {id} status is now {status}", + taskStatusCompleted: "completed", + taskStatusCodingCompleted: "coding completed", + taskStatusInProgress: "in progress", + taskStatusPending: "pending", + taskStatusQaReviewFailed: "QA review failed", + rerun: "Rerun", + configureTask: "Configure task", + openLiveSession: "Open live session", + loading: "Loading", + taskActionPendingUnavailable: "{action} unavailable while task action is pending", + taskActionUnavailable: "{action} unavailable for this task", + taskActionLabel: "{action} task {id}: {title}", + taskActionLabelWithReason: "{action} task {id}: {title}. {reason}", + configureTaskLabel: "Configure task {id}: {title}", + openLiveSessionLabel: "Open live session for task {id}: {title}", + telemetryError: "Telemetry Error", + telemetryStatusActive: "Telemetry status: active projects running", + telemetryStatusAttention: "Telemetry status: attention needed", + telemetryStatusIdle: "Telemetry status: idle", + telemetryTitle: "Telemetry.", + loadingOverviewTelemetry: "Loading overview telemetry", + loadingOverviewTelemetryAnnouncement: "Loading overview telemetry.", + awaitingRuntime: "Awaiting Runtime", + noActiveProjectTelemetry: "No active project telemetry yet", + attention: "Attention", + events: "Events", + selectedSprintAttentionQueue: "Selected Sprint Attention Queue", + selectedProject: "Selected project", + selectedSprintAttentionItems: "Selected sprint attention items", + humanInterventionNeeded: "Human Intervention Needed", + humanInterventionRequired: "Human intervention required", + paused: "Paused", + sprintNumber: "Sprint {number}", + activeSprints: "Active Sprints", + runningDispatches: "Running dispatches:", + runtimeTimeline: "Runtime Timeline", + overviewRuntimeTimeline: "Overview runtime timeline", + fallbackProject: "Project", + eventTaskState: "task {state}", + eventSprintState: "sprint {state}", + eventSprintPaused: "sprint paused", + eventStateFailed: "failed", + eventStateCompleted: "completed", + eventStateBlocked: "blocked", + eventStatePaused: "paused", + eventStateStarted: "started", + eventStateRunning: "running", + eventStateQueued: "queued", + eventStatePending: "pending", + sprintPause: "Pause", + sprintResume: "Resume", + sprintStart: "Start", + sprintStop: "Stop", + sprintActionPending: "{action} pending", + sprintActionPendingLabel: "{action} {name} is pending", + sprintActionLabel: "{action} {name}", + waitForSprintAction: "Wait for the current sprint action to finish.", + waitForSprintActionTitle: "Wait for the current sprint action to finish", + sprintPauseUnavailable: "Pause is available after the sprint starts.", + sprintMustRunToPause: "Sprint must be running to pause", + resumeSprintExecution: "Resume sprint execution", + pauseSprintExecution: "Pause sprint execution", + stopSprintExecution: "Stop sprint execution", + startSprintExecution: "Start sprint execution", + scheduleTbd: "Schedule TBD", + unassigned: "Unassigned", + sprintFallback: "Sprint", + taskTimeDone: "Done", + taskTimeReview: "Review", + taskTimeActive: "Active", + }, + de: { + routeLabel: "Dashboard-Übersicht", + routeLoaded: "Übersichtsseite geladen", + eyebrow: "Einsatzzentrale", + title: "Übersicht", + subtitle: "Echtzeit-Kennzahlen und operative Einblicke für deinen gesamten Cluster.", + clusterOptimal: "Cluster optimal", + clusterOptimalStatus: "Status: Cluster optimal", + metrics: "Kennzahlen", + dataStreams: "Datenströme", + sources: "Quellen", + tasks: "Aufgaben", + liveTelemetry: "Live-Telemetrie", + loadingTelemetryPanel: "Live-Telemetrie wird geladen.", + loadingOverviewStats: "Übersichtskennzahlen werden geladen", + loadingOverviewStatsAnnouncement: "Übersichtskennzahlen werden geladen.", + overviewMetricCards: "Kennzahlen der Übersicht", + totalTokens: "Tokens gesamt", + tokenTelemetryActive: "Token-Telemetrie aktiv", + project: "Projekt", + cost: "Kosten", + runs: "Läufe", + none: "Keine", + sprints: "Sprints", + sprintTelemetryAvailable: "Sprint-Telemetrie verfügbar", + active: "Aktiv", + complete: "Abgeschlossen", + current: "Aktuell", + openTasks: "Offene Aufgaben", + running: "Laufend", + critical: "Kritisch", + health: "Zustand", + review: "Prüfen", + clear: "In Ordnung", + completedTasks: "Abgeschlossene Aufgaben", + completionTelemetryUpdated: "Abschluss-Telemetrie aktualisiert", + open: "Offen", + total: "Gesamt", + activeTime: "Aktive Zeit", + liveTaskCount: { one: "{formattedCount} aktive Aufgabe", other: "{formattedCount} aktive Aufgaben" }, + liveCount: "{count} aktiv", + projectsAndSources: "Projekte & Quellen", + dataWatermark: "DATEN", + loadingSources: "Projekte und Quellen werden geladen", + loadingSourcesAnnouncement: "Projekte und Quellen werden geladen.", + noSources: "Keine Projekte oder Quellen", + noSourcesDescription: "Projekte erscheinen hier, nachdem du sie hinzugefügt hast.", + sourceStatusRunning: "Laufend", + sourceStatusFailed: "Fehlgeschlagen", + sourceStatusIntervention: "Prüfung erforderlich", + sourceStatusIdle: "Inaktiv", + sourceOpenCount: { one: "{formattedCount} offen", other: "{formattedCount} offen" }, + sourceDoneCount: { one: "{formattedCount} erledigt", other: "{formattedCount} erledigt" }, + sourceGroupLabel: "{name}. {status}. {openCount}; {doneCount}.", + sourceSprintsAction: "Sprints", + play: "Starten", + stop: "Stoppen", + settings: "Einstellungen", + activeStreams: "Aktive Datenströme", + filterAllTasks: "Alle Aufgaben", + filterRunning: "Laufend", + filterQueued: "Wartend", + filterCompleted: "Abgeschlossen", + clearFilters: "Filter löschen", + activeStreamFilters: "Filter für aktive Datenströme", + activeCount: { one: "{formattedCount} aktiv", other: "{formattedCount} aktiv" }, + activeStreamTasks: "Aufgaben in aktiven Datenströmen", + loadingActiveStreamTasks: "Aufgaben in aktiven Datenströmen werden geladen", + noActiveStreams: "Keine aktiven Datenströme", + noActiveStreamsDescription: "In aktiven Sprints entsprechen derzeit keine Aufgaben dem ausgewählten Filter.", + taskCount: { one: "{formattedCount} Aufgabe", other: "{formattedCount} Aufgaben" }, + sprintStatusPaused: "Pausiert", + sprintStatusRunning: "Laufend", + sprintStatusIdle: "Inaktiv", + sprintStreamDescription: "Aktiver Datenstrom {name}. {status}. Zu {completion} abgeschlossen.", + progress: "Fortschritt", + sprintProgress: "Fortschritt von {name}", + taskSource: "Quelle:", + taskDuration: "Dauer:", + taskStatusAnnouncement: "Status der Aufgabe {id} ist jetzt {status}", + taskStatusCompleted: "abgeschlossen", + taskStatusCodingCompleted: "Code abgeschlossen", + taskStatusInProgress: "in Bearbeitung", + taskStatusPending: "ausstehend", + taskStatusQaReviewFailed: "QA-Prüfung fehlgeschlagen", + rerun: "Erneut ausführen", + configureTask: "Aufgabe konfigurieren", + openLiveSession: "Live-Sitzung öffnen", + loading: "Wird geladen", + taskActionPendingUnavailable: "{action} ist nicht verfügbar, während die Aufgabenaktion läuft", + taskActionUnavailable: "{action} ist für diese Aufgabe nicht verfügbar", + taskActionLabel: "Aufgabe {id} {action}: {title}", + taskActionLabelWithReason: "Aufgabe {id} {action}: {title}. {reason}", + configureTaskLabel: "Aufgabe {id} konfigurieren: {title}", + openLiveSessionLabel: "Live-Sitzung für Aufgabe {id} öffnen: {title}", + telemetryError: "Telemetriefehler", + telemetryStatusActive: "Telemetriestatus: Aktive Projekte laufen", + telemetryStatusAttention: "Telemetriestatus: Eingriff erforderlich", + telemetryStatusIdle: "Telemetriestatus: inaktiv", + telemetryTitle: "Telemetrie.", + loadingOverviewTelemetry: "Übersichtstelemetrie wird geladen", + loadingOverviewTelemetryAnnouncement: "Übersichtstelemetrie wird geladen.", + awaitingRuntime: "Warten auf Laufzeitdaten", + noActiveProjectTelemetry: "Noch keine Telemetrie aktiver Projekte", + attention: "Eingriff", + events: "Ereignisse", + selectedSprintAttentionQueue: "Eingriffswarteschlange des ausgewählten Sprints", + selectedProject: "Ausgewähltes Projekt", + selectedSprintAttentionItems: "Eingriffselemente des ausgewählten Sprints", + humanInterventionNeeded: "Menschlicher Eingriff erforderlich", + humanInterventionRequired: "Menschlicher Eingriff erforderlich", + paused: "Pausiert", + sprintNumber: "Sprint {number}", + activeSprints: "Aktive Sprints", + runningDispatches: "Laufende Ausführungen:", + runtimeTimeline: "Laufzeit-Zeitleiste", + overviewRuntimeTimeline: "Laufzeit-Zeitleiste der Übersicht", + fallbackProject: "Projekt", + eventTaskState: "Aufgabe {state}", + eventSprintState: "Sprint {state}", + eventSprintPaused: "Sprint pausiert", + eventStateFailed: "fehlgeschlagen", + eventStateCompleted: "abgeschlossen", + eventStateBlocked: "blockiert", + eventStatePaused: "pausiert", + eventStateStarted: "gestartet", + eventStateRunning: "laufend", + eventStateQueued: "wartend", + eventStatePending: "ausstehend", + sprintPause: "Pausieren", + sprintResume: "Fortsetzen", + sprintStart: "Starten", + sprintStop: "Stoppen", + sprintActionPending: "{action} läuft", + sprintActionPendingLabel: "{action} für {name} läuft", + sprintActionLabel: "{name} {action}", + waitForSprintAction: "Warte, bis die aktuelle Sprint-Aktion abgeschlossen ist.", + waitForSprintActionTitle: "Warte, bis die aktuelle Sprint-Aktion abgeschlossen ist", + sprintPauseUnavailable: "Pausieren ist verfügbar, nachdem der Sprint gestartet wurde.", + sprintMustRunToPause: "Der Sprint muss laufen, um ihn zu pausieren", + resumeSprintExecution: "Sprint-Ausführung fortsetzen", + pauseSprintExecution: "Sprint-Ausführung pausieren", + stopSprintExecution: "Sprint-Ausführung stoppen", + startSprintExecution: "Sprint-Ausführung starten", + scheduleTbd: "Zeitplan offen", + unassigned: "Nicht zugewiesen", + sprintFallback: "Sprint", + taskTimeDone: "Erledigt", + taskTimeReview: "Prüfung", + taskTimeActive: "Aktiv", + }, +}); diff --git a/dashboard/src/v2/lib/overview-stats.ts b/dashboard/src/v2/lib/overview-stats.ts index 1351e6e219..dba99ab2c0 100644 --- a/dashboard/src/v2/lib/overview-stats.ts +++ b/dashboard/src/v2/lib/overview-stats.ts @@ -1,4 +1,5 @@ import type { Source, Sprint, Task, ProjectExecutionStatsSnapshot, ExecutionUsageBucketSummary } from "../types.js"; +import type { DashboardFormatters } from "../i18n/formatters.js"; export interface OverviewStats { totalProjects: number; @@ -16,6 +17,41 @@ export interface OverviewStats { completedTasksTrend: number[]; } +type OverviewNumberFormatter = DashboardFormatters["formatNumber"]; + +export function formatOverviewTokens(value: number, formatNumber: OverviewNumberFormatter): string { + if (value >= 1_000_000) { + return `${formatNumber(value / 1_000_000, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}M`; + } + if (value >= 1_000) { + return `${formatNumber(value / 1_000, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}k`; + } + return formatNumber(value); +} + +export function formatOverviewCost(value: number, formatNumber: OverviewNumberFormatter): string { + return formatNumber(value, { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +export function formatOverviewDuration(value: number, formatNumber: OverviewNumberFormatter): string { + const seconds = Math.max(0, Math.round(value / 1000)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + if (hours > 0) { + return `${formatNumber(hours)}h ${formatNumber(minutes)}m`; + } + if (minutes > 0) { + return `${formatNumber(minutes)}m ${formatNumber(remainingSeconds)}s`; + } + return `${formatNumber(remainingSeconds)}s`; +} + /** * Normalizes a date window for deterministic trend calculations. * Returns the start of the window (inclusive) and start of today for reference. diff --git a/dashboard/src/v2/lib/overview-streams.ts b/dashboard/src/v2/lib/overview-streams.ts index 88150f2b87..0c7cc2bb42 100644 --- a/dashboard/src/v2/lib/overview-streams.ts +++ b/dashboard/src/v2/lib/overview-streams.ts @@ -1,5 +1,7 @@ import type { Sprint, Task } from "../types.js"; +export type OverviewTaskFilter = "all" | "running" | "queued" | "completed"; + /** * Derives active sprint IDs from a list of sprints. * Sprints are considered active if their status is "active". @@ -17,3 +19,15 @@ export function filterTasksToActiveSprints(tasks: Task[], activeSprintIds: Set activeSprintIds.has(task.sprintId)); } + +export function filterOverviewTasks(tasks: Task[], filter: OverviewTaskFilter): Task[] { + if (filter === "all") { + return tasks; + } + const status = filter === "running" + ? "in_progress" + : filter === "queued" + ? "pending" + : "completed"; + return tasks.filter((task) => task.status === status); +} diff --git a/dashboard/src/v2/lib/overview-telemetry-view-models.ts b/dashboard/src/v2/lib/overview-telemetry-view-models.ts index f14adae8e4..68028c8678 100644 --- a/dashboard/src/v2/lib/overview-telemetry-view-models.ts +++ b/dashboard/src/v2/lib/overview-telemetry-view-models.ts @@ -5,6 +5,20 @@ export interface EventStyle { toneClass: string; } +export interface OverviewEventLabels { + taskState: (state: string) => string; + sprintState: (state: string) => string; + sprintPaused: string; + states: Readonly>; +} + +const DEFAULT_EVENT_LABELS: OverviewEventLabels = { + taskState: (state) => `task ${state}`, + sprintState: (state) => `sprint ${state}`, + sprintPaused: "sprint paused", + states: {}, +}; + export function buildProjectLookup(telemetry: OverviewTelemetrySnapshot): Map { const lookup = new Map(); for (const project of telemetry?.activeProjects || []) { @@ -16,7 +30,10 @@ export function buildProjectLookup(telemetry: OverviewTelemetrySnapshot): Map labels.states[term] ?? term) + .join(" "); } if (type.includes("failed") || type.includes("error")) { @@ -47,11 +69,14 @@ export function getEventStyle(event: ExecutionRuntimeEventSummary): EventStyle { return { label: baseLabel, toneClass: "text-slate-500" }; } -export function getInterventionContent(project: OverviewTelemetryProjectSummary): { title: string } | null { +export function getInterventionContent( + project: OverviewTelemetryProjectSummary, + fallbackTitle = "Human intervention required", +): { title: string } | null { if (!project.humanIntervention) { return null; } return { - title: project.humanIntervention.title || "Human intervention required", + title: project.humanIntervention.title || fallbackTitle, }; } diff --git a/dashboard/src/v2/lib/view-models.ts b/dashboard/src/v2/lib/view-models.ts index 0f83c41c64..03e458027d 100644 --- a/dashboard/src/v2/lib/view-models.ts +++ b/dashboard/src/v2/lib/view-models.ts @@ -1,6 +1,28 @@ import type { Source, Sprint, SprintRecord, Task, TaskRecord } from "../types.js"; +import { createDashboardFormatters } from "../i18n/formatters.js"; +import type { DashboardLocale } from "../i18n/locales.js"; -const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }); +export interface SprintViewModelPresentation { + locale?: DashboardLocale; + scheduleTbd?: string; +} + +export interface TaskViewModelFallbacks { + knownSourceNames: ReadonlySet; + knownSprintNames: ReadonlySet; + unassigned: string; + sprint: string; +} + +export function localizeTaskViewModelFallbacks(task: Task, fallbacks: TaskViewModelFallbacks): Task { + const source = task.source === "Unassigned" && !fallbacks.knownSourceNames.has(task.source) + ? fallbacks.unassigned + : task.source; + const sprint = task.sprint === "Sprint" && !fallbacks.knownSprintNames.has(task.sprint) + ? fallbacks.sprint + : task.sprint; + return source === task.source && sprint === task.sprint ? task : { ...task, source, sprint }; +} export function toSprintViewModel(sprint: SprintRecord): Sprint { return { @@ -141,15 +163,22 @@ function areSelfReflectionRatingsEqual(left: Task["selfReflectionRating"], right }); } -export function formatSprintDateRange(startDate: string | null, endDate: string | null): string { +export function formatSprintDateRange( + startDate: string | null, + endDate: string | null, + presentation: SprintViewModelPresentation = {}, +): string { + const { formatDate } = createDashboardFormatters(presentation.locale ?? "en"); + const format = (value: string): string => formatDate(new Date(value), { month: "short", day: "numeric" }); + const scheduleTbd = presentation.scheduleTbd ?? "Schedule TBD"; if (!startDate && !endDate) { - return "Schedule TBD"; + return scheduleTbd; } if (startDate && endDate) { - return `${DATE_FORMATTER.format(new Date(startDate))} - ${DATE_FORMATTER.format(new Date(endDate))}`; + return `${format(startDate)} - ${format(endDate)}`; } const resolvedDate = startDate || endDate; - return resolvedDate ? DATE_FORMATTER.format(new Date(resolvedDate)) : "Schedule TBD"; + return resolvedDate ? format(resolvedDate) : scheduleTbd; } function inferAssignee(task: TaskRecord): string { diff --git a/docs-web/architecture/dashboard-internationalization.md b/docs-web/architecture/dashboard-internationalization.md index 90b33a0c7a..7eaf7c7409 100644 --- a/docs-web/architecture/dashboard-internationalization.md +++ b/docs-web/architecture/dashboard-internationalization.md @@ -34,3 +34,9 @@ const { translate, translatePlural, formatNumber } = useDashboardI18n(); English and German must declare exactly the same top-level keys. Interpolation treats replacement values as literal text, plural messages require an `other` form, and locale-aware formatting delegates to the browser's native `Intl` implementation. Keep each catalog with its owning feature and import it only where the feature is loaded. Translate dashboard-authored interface copy only. Never translate provider output, API responses, stored instructions, project data, runtime diagnostics, or user-authored content. + +## Overview route + +The Overview route owns `messages/overview.ts`. Its headers, landmarks, metric and telemetry labels, source and task states, controls, plural counts, live-region announcements, and loading/empty/error fallbacks support English and German. Locale-bound formatters present counts, percentages, dates, costs, durations, and runtime times without changing timestamp interpretation, data ordering, polling, or realtime behavior. + +Live project, sprint, task, branch, repository, provider, and model values remain verbatim. Server errors, attention descriptions, and runtime-authored execution text are also outside the translation boundary. diff --git a/docs-web/content/docs/architecture-dashboard-internationalization.mdx b/docs-web/content/docs/architecture-dashboard-internationalization.mdx index 90b33a0c7a..7eaf7c7409 100644 --- a/docs-web/content/docs/architecture-dashboard-internationalization.mdx +++ b/docs-web/content/docs/architecture-dashboard-internationalization.mdx @@ -34,3 +34,9 @@ const { translate, translatePlural, formatNumber } = useDashboardI18n(); English and German must declare exactly the same top-level keys. Interpolation treats replacement values as literal text, plural messages require an `other` form, and locale-aware formatting delegates to the browser's native `Intl` implementation. Keep each catalog with its owning feature and import it only where the feature is loaded. Translate dashboard-authored interface copy only. Never translate provider output, API responses, stored instructions, project data, runtime diagnostics, or user-authored content. + +## Overview route + +The Overview route owns `messages/overview.ts`. Its headers, landmarks, metric and telemetry labels, source and task states, controls, plural counts, live-region announcements, and loading/empty/error fallbacks support English and German. Locale-bound formatters present counts, percentages, dates, costs, durations, and runtime times without changing timestamp interpretation, data ordering, polling, or realtime behavior. + +Live project, sprint, task, branch, repository, provider, and model values remain verbatim. Server errors, attention descriptions, and runtime-authored execution text are also outside the translation boundary. diff --git a/docs/dashboard/design-system-overview.md b/docs/dashboard/design-system-overview.md index ce6bc716e3..b31255f771 100644 --- a/docs/dashboard/design-system-overview.md +++ b/docs/dashboard/design-system-overview.md @@ -30,6 +30,7 @@ The overview page acts as a centralized "Polished Operational Command Surface." - Overview telemetry distinguishes urgency: loading, empty, pending, running, and timeline updates are polite, while project/transport failures that block trust in the telemetry rail are alerts. Timeline feeds use a named `role="log"` so updates are discoverable without replacing the whole page context. - The selected-sprint attention queue in Overview must use the shared attention row presentation from the Live runtime surface. It should inherit status/severity tones, markdown summary rendering, and list semantics from that shared component, while omitting claim/resolve/dismiss actions. - Dense runtime labels such as project names, sprint keys, provider/model labels, branch names, workflow names, and event snippets must wrap inside their cards or rails. Do not rely on hover-only truncation for operational values. +- Overview-authored copy and accessibility labels use the route-owned English/German catalog. Counts, percentages, dates, costs, and times use the active dashboard locale, while runtime-authored names, attention descriptions, execution text, and server errors remain verbatim. - The Warm Void visual language remains restrained: neutral glass surfaces for Overview structure, theme-specific signal utilities for primary active/focus/running states, and Ember/status tones only for intervention, warning, error, and destructive states. Stats uses a stricter solid-surface Warm Void variant for dense analytics and System administration; see [Stats & Analytics Design System](./design-system-stats.md). For repeatable page-level checks, use the [Dashboard Accessibility Quality Audit](./accessibility-quality-audit.md). diff --git a/docs/dashboard/internationalization.md b/docs/dashboard/internationalization.md index c0047eca70..17393b72f5 100644 --- a/docs/dashboard/internationalization.md +++ b/docs/dashboard/internationalization.md @@ -51,6 +51,12 @@ The initial application bundle translates only root-owned shell copy: the skip l Localization applies only to dashboard-authored interface copy. API responses, provider output, stored instructions, project and sprint data, runtime diagnostics, and all other user-authored content must remain unchanged. +## Overview route coverage + +The Overview route owns `messages/overview.ts`. Its page header, landmarks, metric deck, source grid, active-stream list, controls, telemetry rail, live-region announcements, and empty/loading/error fallbacks switch together with the active locale. Overview presentation helpers receive locale-bound formatters for token totals, USD cost, durations, counts, percentages, sprint dates, and runtime times; they do not change timestamp parsing, list ordering, status precedence, polling, or realtime subscriptions. + +Project, sprint, task, branch, repository, provider, and model values remain verbatim. The same boundary applies to server errors, attention titles and markdown, and runtime-authored execution text. Only dashboard-generated fallback labels and status summaries are translated. + ## Verification Foundation coverage is in `tests/dashboard/v2/i18n-foundation.test.tsx`. It exercises startup defaults, stored German restoration, live switching, invalid and unavailable storage, cross-tab events, interpolation, plural rules, all formatter families, and HTML `lang` synchronization. diff --git a/tests/dashboard/lib/overview-stats.test.ts b/tests/dashboard/lib/overview-stats.test.ts index afadc6027e..4a2b49ace5 100644 --- a/tests/dashboard/lib/overview-stats.test.ts +++ b/tests/dashboard/lib/overview-stats.test.ts @@ -7,8 +7,12 @@ import { extractOpenTasksTrend, extractCompletedTasksTrend, getDateWindow, - getTrendIndex + getTrendIndex, + formatOverviewCost, + formatOverviewDuration, + formatOverviewTokens, } from "../../../dashboard/src/v2/lib/overview-stats.js"; +import { createDashboardFormatters } from "../../../dashboard/src/v2/i18n/formatters.js"; describe("overview-stats", () => { const fakeNow = new Date("2024-03-10T12:00:00Z"); @@ -22,6 +26,19 @@ describe("overview-stats", () => { vi.useRealTimers(); }); + it("formats Overview metrics with German separators without changing precision", () => { + const { formatNumber } = createDashboardFormatters("de"); + expect(formatOverviewTokens(12_500, formatNumber)).toBe("12,5k"); + expect(formatOverviewTokens(1_250_000, formatNumber)).toBe("1,25M"); + expect(formatOverviewCost(1234.5, formatNumber)).toBe(new Intl.NumberFormat("de", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(1234.5)); + expect(formatOverviewDuration(65_000, formatNumber)).toBe("1m 5s"); + }); + it("computes project, sprint, and task summary counts", () => { const stats = computeOverviewStats( [ diff --git a/tests/dashboard/lib/overview-streams.test.ts b/tests/dashboard/lib/overview-streams.test.ts index c343dd92b3..2a8a52383b 100644 --- a/tests/dashboard/lib/overview-streams.test.ts +++ b/tests/dashboard/lib/overview-streams.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { deriveActiveSprintIds, filterTasksToActiveSprints } from "../../../dashboard/src/v2/lib/overview-streams.js"; +import { deriveActiveSprintIds, filterOverviewTasks, filterTasksToActiveSprints } from "../../../dashboard/src/v2/lib/overview-streams.js"; import type { Sprint, Task } from "../../../dashboard/src/v2/types.js"; describe("overview-streams", () => { @@ -53,4 +53,14 @@ describe("overview-streams", () => { expect(filtered).toHaveLength(0); }); }); + + it("filters Overview task states without changing their order", () => { + const tasks = [ + { id: "running-1", status: "in_progress" }, + { id: "pending-1", status: "pending" }, + { id: "running-2", status: "in_progress" }, + ] as Task[]; + expect(filterOverviewTasks(tasks, "running").map((task) => task.id)).toEqual(["running-1", "running-2"]); + expect(filterOverviewTasks(tasks, "all")).toBe(tasks); + }); }); diff --git a/tests/dashboard/lib/overview-telemetry-view-models.test.ts b/tests/dashboard/lib/overview-telemetry-view-models.test.ts index 5deada731e..e6283097c2 100644 --- a/tests/dashboard/lib/overview-telemetry-view-models.test.ts +++ b/tests/dashboard/lib/overview-telemetry-view-models.test.ts @@ -62,6 +62,19 @@ describe("overview-telemetry-view-models", () => { expect(getEventStyle({ eventType: "sprint_paused", sprintRunStatus: "paused" } as ExecutionRuntimeEventSummary).label).toBe("sprint paused"); expect(getEventStyle({ eventType: "sprint_completed", sprintRunStatus: "completed" } as ExecutionRuntimeEventSummary).label).toBe("sprint completed"); }); + + it("localizes presentation labels while preserving event classification", () => { + const style = getEventStyle( + { eventType: "run_running", taskRunState: "in_progress" } as ExecutionRuntimeEventSummary, + { + taskState: (state) => `Aufgabe ${state}`, + sprintState: (state) => `Sprint ${state}`, + sprintPaused: "Sprint pausiert", + states: { in_progress: "in Bearbeitung" }, + }, + ); + expect(style).toEqual({ label: "Aufgabe in Bearbeitung", toneClass: "text-status-blue" }); + }); }); describe("getInterventionContent", () => { @@ -83,5 +96,12 @@ describe("overview-telemetry-view-models", () => { expect(content).toEqual({ title: "Merge Required" }); expect((content as any).reason).toBeUndefined(); }); + + it("localizes only the dashboard fallback intervention title", () => { + const project = { humanIntervention: { title: "" } } as OverviewTelemetryProjectSummary; + expect(getInterventionContent(project, "Menschlicher Eingriff erforderlich")).toEqual({ + title: "Menschlicher Eingriff erforderlich", + }); + }); }); }); diff --git a/tests/dashboard/lib/project-resource-utils.test.ts b/tests/dashboard/lib/project-resource-utils.test.ts index 5cab9d00b1..e193891327 100644 --- a/tests/dashboard/lib/project-resource-utils.test.ts +++ b/tests/dashboard/lib/project-resource-utils.test.ts @@ -5,7 +5,7 @@ import { } from "../../../dashboard/src/v2/hooks/project-resource-utils.js"; import { stabilizeExecutionSnapshot, areExecutionSnapshotsEquivalent } from "../../../dashboard/src/lib/runtime-snapshot-stability.js"; import type { ExecutionDashboardSnapshot } from "../../../dashboard/src/types.js"; -import { toTaskViewModel } from "../../../dashboard/src/v2/lib/view-models.js"; +import { formatSprintDateRange, localizeTaskViewModelFallbacks, toTaskViewModel } from "../../../dashboard/src/v2/lib/view-models.js"; import type { Sprint, SprintReviewSummary, @@ -30,6 +30,25 @@ const qaSummary: SprintReviewSummary = { }], }; +describe("localized Overview view-model presentation", () => { + it("uses locale-aware sprint dates and translated dashboard fallbacks", () => { + expect(formatSprintDateRange("2026-07-13T00:00:00Z", "2026-07-14T00:00:00Z", { locale: "de" })).toBe( + `${new Intl.DateTimeFormat("de", { month: "short", day: "numeric" }).format(new Date("2026-07-13T00:00:00Z"))} - ${new Intl.DateTimeFormat("de", { month: "short", day: "numeric" }).format(new Date("2026-07-14T00:00:00Z"))}`, + ); + expect(formatSprintDateRange(null, null, { locale: "de", scheduleTbd: "Zeitplan offen" })).toBe("Zeitplan offen"); + }); + + it("does not translate a live project name that matches an English fallback word", () => { + const task = { source: "Unassigned", sprint: "Sprint" } as any; + expect(localizeTaskViewModelFallbacks(task, { + knownSourceNames: new Set(["Unassigned"]), + knownSprintNames: new Set(["Sprint"]), + unassigned: "Nicht zugewiesen", + sprint: "Sprint", + })).toBe(task); + }); +}); + function makeSprint(latestReview: SprintReviewSummary = qaSummary): Sprint { return { id: "sprint-1", diff --git a/tests/dashboard/v2/components/ui/source-cell.test.tsx b/tests/dashboard/v2/components/ui/source-cell.test.tsx index 0ea0c2813b..6a2f9a8e05 100644 --- a/tests/dashboard/v2/components/ui/source-cell.test.tsx +++ b/tests/dashboard/v2/components/ui/source-cell.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, cleanup } from "@testing-library/preact"; import type { Source } from "../../../../../dashboard/src/v2/types.js"; +import { DashboardI18nProvider } from "../../../../../dashboard/src/v2/i18n/index.js"; const { selectProjectMock } = vi.hoisted(() => ({ selectProjectMock: vi.fn(), @@ -59,7 +60,7 @@ describe("SourceCell", () => { updatedAt: "2024-01-01T00:00:00.000Z", }; - render(); + render(); expect(capturedCellActionsProps).not.toBeNull(); expect(capturedCellActionsProps).toMatchObject({ diff --git a/tests/dashboard/v2/header-stats.test.tsx b/tests/dashboard/v2/header-stats.test.tsx index e5d0f83c87..8f4cf1b891 100644 --- a/tests/dashboard/v2/header-stats.test.tsx +++ b/tests/dashboard/v2/header-stats.test.tsx @@ -2,12 +2,20 @@ /** @jsx h */ import { h } from "preact"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { render, cleanup } from "@testing-library/preact"; +import { render as testingRender, cleanup, screen } from "@testing-library/preact"; import * as matchers from '@testing-library/jest-dom/matchers'; import { HeaderStats } from "../../../dashboard/src/v2/components/HeaderStats.js"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/index.js"; +import type { DashboardLocale } from "../../../dashboard/src/v2/i18n/locales.js"; expect.extend(matchers); +const renderHeaderStats = (pageData: any, locale: DashboardLocale = "en") => testingRender( + + + , +); + // Mock the getTotalLength function for SVG paths in jsdom beforeEach(() => { // jsdom doesn't implement getTotalLength, so we mock it globally @@ -45,7 +53,7 @@ describe("HeaderStats", () => { isLoading: false }; - const { container } = render(); + const { container } = renderHeaderStats(mockPageData); // Assert Total Tokens rendering expect(container.textContent).toContain("Total Tokens"); @@ -75,4 +83,31 @@ describe("HeaderStats", () => { // Card 4: Completed Tasks (Green #00E0A0) expect(cards[3].innerHTML).toContain('stroke="#00E0A0"'); }); + + it("localizes German loading and active metrics while preserving project text", () => { + const projectName = "Ein sehr langes Project name that remains verbatim"; + const pageData = { + projects: [], + selectedProject: { id: "p1", name: projectName }, + sprints: [], + tasks: [], + stats: { usage: { totalTokens: 12500, totalCostUsd: 1234.5, invocationCount: 1234, activeTimeMs: 65000 } }, + isLoading: false, + }; + + renderHeaderStats(pageData, "de"); + + expect(screen.getByRole("region", { name: "Kennzahlen der Übersicht" })).toBeInTheDocument(); + expect(screen.getByText("Tokens gesamt")).toBeInTheDocument(); + expect(screen.getByText("12,5k")).toBeInTheDocument(); + expect(screen.getByText(projectName)).toBeInTheDocument(); + expect(screen.getByText(new Intl.NumberFormat("de").format(1234))).toBeInTheDocument(); + const expectedCost = new Intl.NumberFormat("de", { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1234.5); + expect(screen.getByText((_, element) => element?.textContent === expectedCost)).toBeInTheDocument(); + }); + + it("announces German loading state through a polite busy live region", () => { + renderHeaderStats({ projects: [], selectedProject: null, sprints: [], tasks: [], stats: null, isLoading: true }, "de"); + expect(screen.getByRole("status", { name: "Übersichtskennzahlen werden geladen" })).toHaveAttribute("aria-busy", "true"); + }); }); diff --git a/tests/dashboard/v2/overview-page.test.tsx b/tests/dashboard/v2/overview-page.test.tsx new file mode 100644 index 0000000000..de888e0bc6 --- /dev/null +++ b/tests/dashboard/v2/overview-page.test.tsx @@ -0,0 +1,69 @@ +/** @vitest-environment happy-dom */ +import { cleanup, render, screen } from "@testing-library/preact"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/index.js"; + +expect.extend(matchers); + +vi.mock("gsap", () => ({ + default: { + context: (callback: () => void) => { + callback(); + return { revert: vi.fn() }; + }, + set: vi.fn(), + fromTo: vi.fn(), + }, +})); + +vi.mock("../../../dashboard/src/v2/hooks/use-reduced-motion.js", async (importOriginal) => ({ + ...await importOriginal(), + useReducedMotion: () => true, +})); + +vi.mock("../../../dashboard/src/v2/hooks/use-overview-page-data.js", () => ({ + useOverviewPageData: () => ({ projects: [], selectedProject: null, sprints: [], tasks: [], stats: null, execution: undefined, isLoading: false }), +})); + +vi.mock("../../../dashboard/src/v2/components/HeaderStats.js", () => ({ + HeaderStats: () =>
localized stats
, +})); + +vi.mock("../../../dashboard/src/v2/components/SourcesGrid.js", () => ({ + SourcesGrid: () =>
localized sources
, +})); + +vi.mock("../../../dashboard/src/v2/components/TasksList.js", () => ({ + TasksList: () =>
localized tasks
, +})); + +vi.mock("../../../dashboard/src/v2/components/OverviewTelemetry.js", () => ({ + OverviewTelemetry: () =>
localized telemetry
, +})); + +import { DashboardV2 } from "../../../dashboard/src/v2/DashboardV2.js"; + +describe("Overview route localization", () => { + afterEach(() => cleanup()); + + it("renders German page copy, named landmarks, a polite route announcement, and responsive rail ordering", async () => { + const { container } = render( + + + , + ); + + expect(screen.getByRole("region", { name: "Dashboard-Übersicht" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1, name: "Übersicht" })).toBeInTheDocument(); + expect(screen.getByText("Echtzeit-Kennzahlen und operative Einblicke für deinen gesamten Cluster.")).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Status: Cluster optimal" })).toBeInTheDocument(); + expect(screen.getByText("Übersichtsseite geladen").closest('[role="status"]')).toHaveAttribute("aria-live", "polite"); + expect(screen.getByRole("region", { name: "Kennzahlen" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Quellen" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Aufgaben" })).toBeInTheDocument(); + expect(screen.getByRole("complementary", { name: "Live-Telemetrie" })).toHaveClass("order-last", "xl:order-none"); + expect(container.querySelector(".xl\\:grid-cols-12")).toBeInTheDocument(); + expect(await screen.findByText("localized telemetry")).toBeInTheDocument(); + }); +}); diff --git a/tests/dashboard/v2/overview-telemetry.test.tsx b/tests/dashboard/v2/overview-telemetry.test.tsx index 66bb45e1ff..bb6cdc122c 100644 --- a/tests/dashboard/v2/overview-telemetry.test.tsx +++ b/tests/dashboard/v2/overview-telemetry.test.tsx @@ -2,9 +2,9 @@ /** * @vitest-environment happy-dom */ -import { h } from "preact"; +import { h, type ComponentChildren } from "preact"; import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen, cleanup, within } from "@testing-library/preact"; +import { render as testingRender, screen, cleanup, within } from "@testing-library/preact"; import { renderHook, act } from "@testing-library/preact"; import * as matchers from "@testing-library/jest-dom/matchers"; @@ -16,6 +16,12 @@ import { useProjectData } from "../../../dashboard/src/v2/context/project-data.j import type { ExecutionAttentionItemSummary, OverviewTelemetrySnapshot } from "../../../dashboard/src/types.js"; import * as api from "../../../dashboard/src/lib/api/dashboard-api.js"; import * as realtime from "../../../dashboard/src/lib/realtime/dashboard-realtime-client.js"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/index.js"; +import type { DashboardLocale } from "../../../dashboard/src/v2/i18n/locales.js"; + +const render = (ui: ComponentChildren, locale: DashboardLocale = "en") => testingRender( + {ui}, +); expect.extend(matchers); @@ -354,6 +360,85 @@ describe("OverviewTelemetry Component", () => { expect(screen.getByText("dispatch failed")).toHaveClass("text-status-red"); expect(screen.getByRole("log", { name: "Overview runtime timeline" })).toHaveAttribute("aria-live", "polite"); }); + + it("localizes German loading, empty, and failure live regions while preserving server errors", () => { + vi.mocked(useOverviewTelemetry).mockReturnValue({ + telemetry: { activeProjects: [], attentionProjects: [], recentEvents: [], updatedAt: null }, + loading: true, + error: null, + refresh: vi.fn(), + }); + const loadingView = render(, "de"); + expect(screen.getByRole("status", { name: "Übersichtstelemetrie wird geladen" })).toHaveAttribute("aria-busy", "true"); + loadingView.unmount(); + + vi.mocked(useOverviewTelemetry).mockReturnValue({ + telemetry: { activeProjects: [], attentionProjects: [], recentEvents: [], updatedAt: "2000-01-01T00:00:00Z" }, + loading: false, + error: null, + refresh: vi.fn(), + }); + const emptyView = render(, "de"); + expect(screen.getByRole("status")).toHaveTextContent("Warten auf Laufzeitdaten"); + emptyView.unmount(); + + const serverError = "upstream telemetry timeout"; + vi.mocked(useOverviewTelemetry).mockReturnValue({ + telemetry: { activeProjects: [], attentionProjects: [], recentEvents: [], updatedAt: null }, + loading: false, + error: serverError, + refresh: vi.fn(), + }); + render(, "de"); + expect(screen.getByRole("alert")).toHaveTextContent("Telemetriefehler"); + expect(screen.getByRole("alert")).toHaveTextContent(serverError); + }); + + it("localizes active German telemetry but keeps long live names and attention copy verbatim", () => { + const projectName = "A very long runtime Project name with Repository/provider text"; + const attentionTitle = "Resolve provider-authored blocker exactly"; + const attentionSummary = "Runtime-authored description must remain unchanged."; + vi.mocked(useProjectData).mockReturnValue({ + selectedProjectId: "p1", + selectedProject: { id: "p1", name: projectName }, + loading: false, + } as any); + vi.mocked(useDashboardRuntimeData).mockReturnValue(makeRuntimeData([ + makeAttentionItem({ title: attentionTitle, summaryMarkdown: attentionSummary }), + ]) as any); + vi.mocked(useOverviewTelemetry).mockReturnValue({ + telemetry: { + activeProjects: [{ + projectId: "p1", + projectName, + sprintId: "s1", + sprintName: "Sprint name remains verbatim", + sprintNumber: 1234, + sprintRunId: "run1", + sprintRunStatus: "running", + activeDispatchCount: 2, + runningDispatchCount: 1, + updatedAt: "2000-01-01T00:00:00Z", + humanIntervention: null, + }], + attentionProjects: [], + recentEvents: [], + updatedAt: "2000-01-01T00:00:00Z", + }, + loading: false, + error: null, + refresh: vi.fn(), + }); + + render(, "de"); + + expect(screen.getByText("Telemetrie.")).toBeInTheDocument(); + expect(screen.getByText("Aktive Sprints")).toBeInTheDocument(); + expect(screen.getAllByText(projectName).length).toBeGreaterThan(0); + expect(screen.getByText(attentionTitle)).toBeInTheDocument(); + expect(screen.getByText(attentionSummary)).toBeInTheDocument(); + expect(screen.getByRole("list", { name: "Eingriffselemente des ausgewählten Sprints" })).toBeInTheDocument(); + }); }); describe("useOverviewTelemetry Hook", () => { diff --git a/tests/dashboard/v2/tasks-list.test.tsx b/tests/dashboard/v2/tasks-list.test.tsx index 6509ff8d77..a88e9752ed 100644 --- a/tests/dashboard/v2/tasks-list.test.tsx +++ b/tests/dashboard/v2/tasks-list.test.tsx @@ -1,10 +1,10 @@ import * as useReducedMotionModule from "../../../dashboard/src/v2/hooks/use-reduced-motion.js"; /** @vitest-environment happy-dom */ -import { h, Fragment } from "preact"; +import { h, Fragment, type ComponentChildren } from "preact"; /** @jsx h */ /** @jsxFrag Fragment */ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen, cleanup, fireEvent, act } from "@testing-library/preact"; +import { render as testingRender, screen, cleanup, fireEvent, act } from "@testing-library/preact"; import * as matchers from "@testing-library/jest-dom/matchers"; expect.extend(matchers); @@ -13,6 +13,19 @@ import { TaskBoardSprintSelector } from "../../../dashboard/src/v2/components/ta import { ProjectDataProvider } from "../../../dashboard/src/v2/context/project-data.js"; import gsap from "gsap"; import * as dashboardApi from "../../../dashboard/src/lib/api/dashboard-api.js"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/index.js"; +import type { DashboardLocale } from "../../../dashboard/src/v2/i18n/locales.js"; + +const render = (ui: ComponentChildren, locale: DashboardLocale = "en") => { + const wrap = (children: ComponentChildren) => ( + {children} + ); + const result = testingRender(wrap(ui)); + return { + ...result, + rerender: (nextUi: ComponentChildren) => result.rerender(wrap(nextUi)), + }; +}; vi.spyOn(useReducedMotionModule, 'useReducedMotion').mockReturnValue(false); @@ -327,6 +340,27 @@ const baseProps: any = { expect(progress.firstElementChild).toHaveStyle({ width: "7.5%" }); }); + it("localizes German filters, task state, actions, counts, and live regions", () => { + render(, "de"); + + expect(screen.getByRole("heading", { name: "Aktive Datenströme" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Alle Aufgaben" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("in Bearbeitung")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Aufgabe task-1 Stoppen: Test Task/i })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: /Aktiver Datenstrom Sprint One. Laufend. Zu 7,5\s?% abgeschlossen./i })).toBeInTheDocument(); + expect(screen.getByText("1 Aufgabe")).toBeInTheDocument(); + }); + + it("announces German loading and empty filtered states", async () => { + const loadingView = render(, "de"); + expect(screen.getByRole("status", { name: "Aufgaben in aktiven Datenströmen werden geladen" })).toHaveAttribute("aria-busy", "true"); + loadingView.unmount(); + + render(, "de"); + await act(async () => fireEvent.click(screen.getByRole("tab", { name: "Abgeschlossen" }))); + expect(screen.getByRole("status", { name: "Keine aktiven Datenströme" })).toHaveTextContent("In aktiven Sprints entsprechen derzeit keine Aufgaben dem ausgewählten Filter."); + }); + it("keeps the running sprint selector dot visible without raw pulse animation classes", () => { const sprint = { id: "sprint-running", From 70a0b482ff3e16b4e1eb9232b41dc48d0cb8f4bf Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 14 Jul 2026 02:11:07 +0000 Subject: [PATCH 2/2] fix(task T09): address qa review via codex --- dashboard/src/v2/components/ui/TaskRow.tsx | 9 +-- .../dashboard-internationalization.md | 2 + ...tecture-dashboard-internationalization.mdx | 2 + docs/dashboard/internationalization.md | 2 + tests/dashboard/v2/tasks-list.test.tsx | 59 +++++++++++++++++++ 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/dashboard/src/v2/components/ui/TaskRow.tsx b/dashboard/src/v2/components/ui/TaskRow.tsx index 38ca111658..41e20e0b32 100644 --- a/dashboard/src/v2/components/ui/TaskRow.tsx +++ b/dashboard/src/v2/components/ui/TaskRow.tsx @@ -33,13 +33,6 @@ export const TaskRow: FunctionComponent = memo(({ task, state, onP : task.status === "QA_REVIEW_FAILED" ? "taskStatusQaReviewFailed" : "taskStatusPending"); - const taskTime = task.status === "completed" - ? translate("taskTimeDone") - : task.status === "coding_completed" - ? translate("taskTimeReview") - : task.status === "in_progress" - ? translate("taskTimeActive") - : task.time; const disabledReason = busy ? translate("taskActionPendingUnavailable", { action: playStopLabel }) : !onPlayStop @@ -116,7 +109,7 @@ export const TaskRow: FunctionComponent = memo(({ task, state, onP
{/* Quick actions */} diff --git a/docs-web/architecture/dashboard-internationalization.md b/docs-web/architecture/dashboard-internationalization.md index 7eaf7c7409..4866e48d25 100644 --- a/docs-web/architecture/dashboard-internationalization.md +++ b/docs-web/architecture/dashboard-internationalization.md @@ -40,3 +40,5 @@ Keep each catalog with its owning feature and import it only where the feature i The Overview route owns `messages/overview.ts`. Its headers, landmarks, metric and telemetry labels, source and task states, controls, plural counts, live-region announcements, and loading/empty/error fallbacks support English and German. Locale-bound formatters present counts, percentages, dates, costs, durations, and runtime times without changing timestamp interpretation, data ordering, polling, or realtime behavior. Live project, sprint, task, branch, repository, provider, and model values remain verbatim. Server errors, attention descriptions, and runtime-authored execution text are also outside the translation boundary. + +Active-stream task rows localize their status labels and announcements, but the duration field always renders the runtime-provided task duration unchanged for pending, active, review, and completed tasks. diff --git a/docs-web/content/docs/architecture-dashboard-internationalization.mdx b/docs-web/content/docs/architecture-dashboard-internationalization.mdx index 7eaf7c7409..4866e48d25 100644 --- a/docs-web/content/docs/architecture-dashboard-internationalization.mdx +++ b/docs-web/content/docs/architecture-dashboard-internationalization.mdx @@ -40,3 +40,5 @@ Keep each catalog with its owning feature and import it only where the feature i The Overview route owns `messages/overview.ts`. Its headers, landmarks, metric and telemetry labels, source and task states, controls, plural counts, live-region announcements, and loading/empty/error fallbacks support English and German. Locale-bound formatters present counts, percentages, dates, costs, durations, and runtime times without changing timestamp interpretation, data ordering, polling, or realtime behavior. Live project, sprint, task, branch, repository, provider, and model values remain verbatim. Server errors, attention descriptions, and runtime-authored execution text are also outside the translation boundary. + +Active-stream task rows localize their status labels and announcements, but the duration field always renders the runtime-provided task duration unchanged for pending, active, review, and completed tasks. diff --git a/docs/dashboard/internationalization.md b/docs/dashboard/internationalization.md index 17393b72f5..c9fa69a5a5 100644 --- a/docs/dashboard/internationalization.md +++ b/docs/dashboard/internationalization.md @@ -57,6 +57,8 @@ The Overview route owns `messages/overview.ts`. Its page header, landmarks, metr Project, sprint, task, branch, repository, provider, and model values remain verbatim. The same boundary applies to server errors, attention titles and markdown, and runtime-authored execution text. Only dashboard-generated fallback labels and status summaries are translated. +Active-stream task rows localize their status labels and announcements, but the duration field always renders the runtime-provided task duration unchanged for pending, active, review, and completed tasks. + ## Verification Foundation coverage is in `tests/dashboard/v2/i18n-foundation.test.tsx`. It exercises startup defaults, stored German restoration, live switching, invalid and unavailable storage, cross-tab events, interpolation, plural rules, all formatter families, and HTML `lang` synchronization. diff --git a/tests/dashboard/v2/tasks-list.test.tsx b/tests/dashboard/v2/tasks-list.test.tsx index a88e9752ed..1eefbb96c2 100644 --- a/tests/dashboard/v2/tasks-list.test.tsx +++ b/tests/dashboard/v2/tasks-list.test.tsx @@ -351,6 +351,65 @@ const baseProps: any = { expect(screen.getByText("1 Aufgabe")).toBeInTheDocument(); }); + it("keeps runtime task durations for terminal, review, and active states in English and German", () => { + const statusTasks = [ + { + ...mockTask, + id: "completed-task", + recordId: "completed-task-record", + title: "Completed Task", + status: "completed", + time: "2m 14s", + }, + { + ...mockTask, + id: "review-task", + recordId: "review-task-record", + title: "Review Task", + status: "coding_completed", + time: "3m 27s", + }, + { + ...mockTask, + id: "active-task", + recordId: "active-task-record", + title: "Active Task", + status: "in_progress", + time: "4m 39s", + }, + ]; + const statusPageData = { + ...pageData, + tasks: statusTasks, + execution: { + ...pageData.execution, + taskDispatches: [{ id: "active-dispatch", taskId: "active-task-record", status: "running" }], + }, + }; + + const englishView = render( + , + ); + expect(screen.getByText("2m 14s")).toBeInTheDocument(); + expect(screen.getByText("3m 27s")).toBeInTheDocument(); + expect(screen.getByText("4m 39s")).toBeInTheDocument(); + expect(screen.getByText("Task completed-task status is now completed")).toBeInTheDocument(); + expect(screen.getByText("Task review-task status is now coding completed")).toBeInTheDocument(); + expect(screen.getByText("Task active-task status is now in progress")).toBeInTheDocument(); + englishView.unmount(); + + render( + , + "de", + ); + expect(screen.getByText("2m 14s")).toBeInTheDocument(); + expect(screen.getByText("3m 27s")).toBeInTheDocument(); + expect(screen.getByText("4m 39s")).toBeInTheDocument(); + expect(screen.getByText("Status der Aufgabe completed-task ist jetzt abgeschlossen")).toBeInTheDocument(); + expect(screen.getByText("Status der Aufgabe review-task ist jetzt Code abgeschlossen")).toBeInTheDocument(); + expect(screen.getByText("Status der Aufgabe active-task ist jetzt in Bearbeitung")).toBeInTheDocument(); + }); + it("announces German loading and empty filtered states", async () => { const loadingView = render(, "de"); expect(screen.getByRole("status", { name: "Aufgaben in aktiven Datenströmen werden geladen" })).toHaveAttribute("aria-busy", "true");