@nightkatana/kronosys-app 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,2943 @@
1
+ "use client";
2
+
3
+ import {
4
+ Fragment,
5
+ type ReactNode,
6
+ Suspense,
7
+ useCallback,
8
+ useEffect,
9
+ useMemo,
10
+ useState,
11
+ } from "react";
12
+ import Link from "next/link";
13
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
14
+ import { ChevronLeft, ChevronRight } from "lucide-react";
15
+ import {
16
+ postKronosysAction,
17
+ type WorkspaceCodeSnapshotPayload,
18
+ } from "@/lib/kronosysApi";
19
+ import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
20
+ import {
21
+ formatDuration,
22
+ formatTagDisplay,
23
+ isFallbackTaskTagKey,
24
+ normalizeProjectKey,
25
+ normalizeTagKey,
26
+ readTaskDefaultTagBucketEnabled,
27
+ } from "@/lib/taskParsing";
28
+ import {
29
+ UNDATED_KEY,
30
+ aggregateArchivedExcludedTaskMinutes,
31
+ aggregateReporting,
32
+ aggregateProjectTaskMinutesByDay,
33
+ aggregateTagTaskMinutesByDayAndWeek,
34
+ buildTagFilterSet,
35
+ mergeSessionsFromPayload,
36
+ sortedDayKeys,
37
+ type ReportingTagTimeDayRow,
38
+ type ReportingTagTimeWeekRow,
39
+ } from "@/lib/reportingAggregate";
40
+ import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
41
+ import {
42
+ appShellHeaderClassName,
43
+ appShellHeaderToolRowClassName,
44
+ } from "@/lib/appShellHeaderClasses";
45
+ import { dashboardStrings, type Lang } from "@/lib/dashboardCopy";
46
+ import {
47
+ reportingPresetDay,
48
+ reportingPresetMonth,
49
+ reportingPresetWeek,
50
+ reportingPresetYear,
51
+ } from "@/lib/reportingDatePresets";
52
+ import { InlineMetricHelpTrigger } from "@/components/dashboard/InlineMetricHelpTrigger";
53
+ import {
54
+ ReportingPageTocDesktop,
55
+ ReportingPageTocMobile,
56
+ } from "@/components/dashboard/ReportingPageToc";
57
+ import { reportingStrings } from "@/lib/reportingStrings";
58
+ import { trackCodeMetricsFromCfg } from "@/lib/usageProfile";
59
+ import {
60
+ addDaysYmd,
61
+ buildProjectWeekCalendarRows,
62
+ buildTagWeekCalendarRows,
63
+ formatWeekRangeLabel,
64
+ localWeekStartKeyFromDayKey,
65
+ readWeekStartsOnFromStorage,
66
+ type ReportingWeekStartsOn,
67
+ weekdayDateColumnHeaders,
68
+ writeWeekStartsOnToStorage,
69
+ type ProjectWeekCalendarRow,
70
+ type TagWeekCalendarRow,
71
+ } from "@/lib/reportingWeekLayout";
72
+ import { computeReportingNonFinalFlags } from "@/lib/reportingNonFinalIndicators";
73
+ import {
74
+ buildTagWeekDisplayBlocks,
75
+ groupTagDayRowsForDisplay,
76
+ } from "@/lib/reportingTagWeekBreakdown";
77
+ import { ReportingTour } from "@/components/dashboard/ReportingTour";
78
+ import { ScrollToTopFab } from "@/components/dashboard/ScrollToTopFab";
79
+ import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
80
+ import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
81
+ import { AppShellRouteNav } from "@/components/dashboard/AppShellRouteNav";
82
+ import { readDashboardTimeZoneFromCfg } from "@/lib/dashboardTimeZone";
83
+ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
84
+ import { LanguageMenu } from "@/components/dashboard/LanguageMenu";
85
+ import {
86
+ isReportingTourCompleted,
87
+ resetReportingTour,
88
+ } from "@/lib/dashboardTourStorage";
89
+
90
+ type LiveShape = { language?: string };
91
+
92
+ function dayLabel(key: string, undated: string): string {
93
+ return key === UNDATED_KEY ? undated : key;
94
+ }
95
+
96
+ function formatMinutesCell(minutes: number | undefined): string {
97
+ const m = minutes ?? 0;
98
+ return m > 0 ? formatDuration(m) : "—";
99
+ }
100
+
101
+ function reportingArchivedExcludedRichText(
102
+ template: string,
103
+ durationMinutes: number,
104
+ ): ReactNode {
105
+ const marker = "{duration}";
106
+ const i = template.indexOf(marker);
107
+ if (i < 0) {
108
+ return template;
109
+ }
110
+ const label = formatDuration(durationMinutes);
111
+ return (
112
+ <>
113
+ {template.slice(0, i)}
114
+ <strong className="font-semibold text-amber-50 tabular-nums">
115
+ {label}
116
+ </strong>
117
+ {template.slice(i + marker.length)}
118
+ </>
119
+ );
120
+ }
121
+
122
+ const BAR_TRACK_PX = 100;
123
+
124
+ function MiniBars({
125
+ days,
126
+ values,
127
+ max,
128
+ className,
129
+ undatedLabel: undated,
130
+ valueTitle,
131
+ }: {
132
+ days: string[];
133
+ values: Record<string, number>;
134
+ max: number;
135
+ className: string;
136
+ undatedLabel: string;
137
+ /** If set, used for the bar tooltip instead of the raw numeric value. */
138
+ valueTitle?: (value: number) => string;
139
+ }) {
140
+ return (
141
+ <div className="flex items-end gap-1 overflow-x-auto pb-2">
142
+ {days.map((d) => {
143
+ const v = values[d] ?? 0;
144
+ const px = max > 0 ? Math.round((v / max) * BAR_TRACK_PX) : 0;
145
+ const label = dayLabel(d, undated);
146
+ const tip = valueTitle
147
+ ? `${label}: ${valueTitle(v)}`
148
+ : `${label}: ${v}`;
149
+ return (
150
+ <div
151
+ key={d}
152
+ className="flex w-8 shrink-0 flex-col items-center gap-1"
153
+ >
154
+ <div
155
+ className="flex h-[100px] w-full items-end justify-center"
156
+ title={tip}
157
+ >
158
+ <div
159
+ className={`w-full rounded-t ${className}`}
160
+ style={{ height: `${Math.max(px, v > 0 ? 3 : 0)}px` }}
161
+ />
162
+ </div>
163
+ <span
164
+ className="max-w-[2.5rem] truncate text-[0.6rem] text-zinc-500"
165
+ title={label}
166
+ >
167
+ {d === UNDATED_KEY ? "—" : d.slice(5).replace(/^-/, "")}
168
+ </span>
169
+ </div>
170
+ );
171
+ })}
172
+ </div>
173
+ );
174
+ }
175
+
176
+ function StackedTaskBars({
177
+ days,
178
+ done,
179
+ active,
180
+ max,
181
+ undatedLabel: undated,
182
+ }: {
183
+ days: string[];
184
+ done: Record<string, number>;
185
+ active: Record<string, number>;
186
+ max: number;
187
+ undatedLabel: string;
188
+ }) {
189
+ return (
190
+ <div className="flex items-end gap-1 overflow-x-auto pb-2">
191
+ {days.map((d) => {
192
+ const a = active[d] ?? 0;
193
+ const b = done[d] ?? 0;
194
+ const sum = a + b;
195
+ const totalPx = max > 0 ? Math.round((sum / max) * BAR_TRACK_PX) : 0;
196
+ const activePx = sum > 0 ? Math.round((a / sum) * totalPx) : 0;
197
+ const donePx = sum > 0 ? totalPx - activePx : 0;
198
+ const label = dayLabel(d, undated);
199
+ return (
200
+ <div
201
+ key={d}
202
+ className="flex w-8 shrink-0 flex-col items-center gap-1"
203
+ >
204
+ <div
205
+ className="flex h-[100px] w-full flex-col justify-end rounded-t bg-zinc-800/80"
206
+ title={`${label}: ${b} / ${a}`}
207
+ >
208
+ {donePx > 0 ? (
209
+ <div
210
+ className="w-full rounded-t bg-emerald-600/90"
211
+ style={{ height: `${donePx}px` }}
212
+ />
213
+ ) : null}
214
+ {activePx > 0 ? (
215
+ <div
216
+ className="w-full bg-amber-500/90"
217
+ style={{ height: `${activePx}px` }}
218
+ />
219
+ ) : null}
220
+ </div>
221
+ <span
222
+ className="max-w-[2.5rem] truncate text-[0.6rem] text-zinc-500"
223
+ title={label}
224
+ >
225
+ {d === UNDATED_KEY ? "—" : d.slice(5).replace(/^-/, "")}
226
+ </span>
227
+ </div>
228
+ );
229
+ })}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ function reportingTagDescriptionLine(
235
+ tagKey: string,
236
+ descriptions: Record<string, string>,
237
+ ): string | null {
238
+ if (!tagKey.trim()) {
239
+ return null;
240
+ }
241
+ const k = normalizeTagKey(tagKey).toLowerCase();
242
+ const line = (
243
+ descriptions[k] ??
244
+ descriptions[normalizeTagKey(tagKey)] ??
245
+ ""
246
+ ).trim();
247
+ return line || null;
248
+ }
249
+
250
+ function reportingProjectDescriptionLine(
251
+ projectKey: string,
252
+ descriptions: Record<string, string>,
253
+ ): string | null {
254
+ if (!projectKey.trim()) {
255
+ return null;
256
+ }
257
+ const canon = normalizeProjectKey(projectKey);
258
+ const k = canon.toLowerCase();
259
+ const candidates = [
260
+ descriptions[k],
261
+ descriptions[canon],
262
+ descriptions[projectKey],
263
+ descriptions[projectKey.trim().toLowerCase()],
264
+ ];
265
+ for (const c of candidates) {
266
+ const line = (c ?? "").trim();
267
+ if (line) {
268
+ return line;
269
+ }
270
+ }
271
+ for (const [dk, val] of Object.entries(descriptions)) {
272
+ const line = (val ?? "").trim();
273
+ if (!line) {
274
+ continue;
275
+ }
276
+ if (normalizeProjectKey(dk).toLowerCase() === k) {
277
+ return line;
278
+ }
279
+ }
280
+ return null;
281
+ }
282
+
283
+ function ReportingTagNameCell({
284
+ tagKey,
285
+ displayLabel,
286
+ untaggedLabel,
287
+ descriptions,
288
+ className,
289
+ }: {
290
+ tagKey: string;
291
+ displayLabel: string;
292
+ untaggedLabel: string;
293
+ descriptions: Record<string, string>;
294
+ /** Classes de la pastille / libellé principal. */
295
+ className: string;
296
+ }) {
297
+ const desc = reportingTagDescriptionLine(tagKey, descriptions);
298
+ const label = isFallbackTaskTagKey(tagKey) ? untaggedLabel : displayLabel;
299
+ return (
300
+ <td className={`align-top ${className}`}>
301
+ <div>{label}</div>
302
+ {desc ? (
303
+ <p className="mt-1 max-w-[min(22rem,55vw)] whitespace-pre-line text-[0.65rem] font-normal leading-snug text-zinc-500">
304
+ {desc}
305
+ </p>
306
+ ) : null}
307
+ </td>
308
+ );
309
+ }
310
+
311
+ function ReportingProjectNameCell({
312
+ projectKey,
313
+ displayLabel,
314
+ unassignedLabel,
315
+ descriptions,
316
+ className,
317
+ }: {
318
+ projectKey: string;
319
+ displayLabel: string;
320
+ unassignedLabel: string;
321
+ descriptions: Record<string, string>;
322
+ className: string;
323
+ }) {
324
+ const desc = reportingProjectDescriptionLine(projectKey, descriptions);
325
+ const label = projectKey === "" ? unassignedLabel : displayLabel;
326
+ return (
327
+ <td className={`align-top ${className}`}>
328
+ <div>{label}</div>
329
+ {desc ? (
330
+ <p className="mt-1 max-w-[min(22rem,55vw)] whitespace-pre-line text-[0.65rem] font-normal leading-snug text-zinc-500">
331
+ {desc}
332
+ </p>
333
+ ) : null}
334
+ </td>
335
+ );
336
+ }
337
+
338
+ function ReportingFilteredBadge({
339
+ active,
340
+ label,
341
+ titleText,
342
+ }: Readonly<{
343
+ active: boolean;
344
+ label: string;
345
+ titleText: string;
346
+ }>) {
347
+ if (!active) {
348
+ return null;
349
+ }
350
+ return (
351
+ <span
352
+ className="inline-flex shrink-0 items-center rounded border border-amber-500/35 bg-amber-950/40 px-1.5 py-px text-[0.65rem] font-semibold uppercase tracking-wide text-amber-200/90"
353
+ title={titleText}
354
+ >
355
+ {label}
356
+ </span>
357
+ );
358
+ }
359
+
360
+ function ReportingContent() {
361
+ const router = useRouter();
362
+ const pathname = usePathname();
363
+ const searchParams = useSearchParams();
364
+ const dashboardSessionNavId = searchParams.get("session");
365
+ const { payload, error, refresh } = useKronosysPayload();
366
+ const [dateFrom, setDateFrom] = useState("");
367
+ const [dateTo, setDateTo] = useState("");
368
+ const [selectedTags, setSelectedTags] = useState<string[]>([]);
369
+ const [workspaceSnapBusy, setWorkspaceSnapBusy] = useState(false);
370
+ const [weekStartsOn, setWeekStartsOn] =
371
+ useState<ReportingWeekStartsOn>("monday");
372
+ /** `-1` = afficher la dernière semaine avec données (pas d’éclair incorrect au chargement). */
373
+ const [chartWeekNavIndex, setChartWeekNavIndex] = useState(-1);
374
+ /** Clés `semaine:::projet` pour les lignes @projet regroupant plusieurs `projet#code`. */
375
+ const [tagWeekRollupOpenKeys, setTagWeekRollupOpenKeys] = useState<
376
+ Set<string>
377
+ >(() => new Set<string>());
378
+ const [tagDayRollupOpenKeys, setTagDayRollupOpenKeys] = useState<Set<string>>(
379
+ () => new Set<string>(),
380
+ );
381
+ const [reportingTourOpen, setReportingTourOpen] = useState(false);
382
+
383
+ const toggleTagWeekRollup = useCallback((key: string) => {
384
+ setTagWeekRollupOpenKeys((prev) => {
385
+ const next = new Set(prev);
386
+ if (next.has(key)) {
387
+ next.delete(key);
388
+ } else {
389
+ next.add(key);
390
+ }
391
+ return next;
392
+ });
393
+ }, []);
394
+
395
+ const toggleTagDayRollup = useCallback((key: string) => {
396
+ setTagDayRollupOpenKeys((prev) => {
397
+ const next = new Set(prev);
398
+ if (next.has(key)) {
399
+ next.delete(key);
400
+ } else {
401
+ next.add(key);
402
+ }
403
+ return next;
404
+ });
405
+ }, []);
406
+
407
+ useEffect(() => {
408
+ setWeekStartsOn(readWeekStartsOnFromStorage());
409
+ }, []);
410
+
411
+ const persistWeekStartsOn = useCallback((v: ReportingWeekStartsOn) => {
412
+ setWeekStartsOn(v);
413
+ writeWeekStartsOnToStorage(v);
414
+ }, []);
415
+
416
+ const refreshWorkspaceSnapshot = useCallback(async () => {
417
+ setWorkspaceSnapBusy(true);
418
+ try {
419
+ await postKronosysAction({ type: "refreshWorkspaceCodeSnapshot" });
420
+ await refresh();
421
+ } catch {
422
+ await refresh();
423
+ } finally {
424
+ setWorkspaceSnapBusy(false);
425
+ }
426
+ }, [refresh]);
427
+
428
+ const postLang = useCallback(
429
+ async (next: Lang) => {
430
+ await postKronosysAction({ type: "setLanguage", lang: next });
431
+ await refresh();
432
+ },
433
+ [refresh],
434
+ );
435
+
436
+ const stripReportingTourReplayParam = useCallback(() => {
437
+ if (searchParams.get("tour") !== "replay") {
438
+ return;
439
+ }
440
+ const next = new URLSearchParams(searchParams.toString());
441
+ next.delete("tour");
442
+ const q = next.toString();
443
+ router.replace(q ? `${pathname}?${q}` : pathname);
444
+ }, [pathname, router, searchParams]);
445
+
446
+ const live = payload?.current as LiveShape | undefined;
447
+ const lang: Lang = live?.language === "fr" ? "fr" : "en";
448
+ const t = reportingStrings(lang);
449
+ const dt = dashboardStrings(lang);
450
+ const handleManualRefresh = useCallback(async () => {
451
+ return await refresh({ routerInvalidate: true });
452
+ }, [refresh]);
453
+
454
+ const reportLocale = lang === "fr" ? "fr-CA" : "en-CA";
455
+ const tagDescriptions = (payload?.tagDescriptions ?? {}) as Record<
456
+ string,
457
+ string
458
+ >;
459
+ const projectDescriptions = (payload?.projectDescriptions ?? {}) as Record<
460
+ string,
461
+ string
462
+ >;
463
+ const tagKeys = useMemo(() => {
464
+ const raw = payload?.knownTags;
465
+ if (!Array.isArray(raw)) {
466
+ return [] as string[];
467
+ }
468
+ return [...new Set(raw.map((x) => String(x)).filter(Boolean))].sort(
469
+ (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }),
470
+ );
471
+ }, [payload?.knownTags]);
472
+
473
+ const taskDefaultTagBucketEnabled = useMemo(
474
+ () => readTaskDefaultTagBucketEnabled(payload?.cfg),
475
+ [payload?.cfg],
476
+ );
477
+
478
+ const reportTimeZone = useMemo(
479
+ () => readDashboardTimeZoneFromCfg(payload?.cfg as Record<string, unknown> | undefined),
480
+ [payload?.cfg]
481
+ );
482
+
483
+ const tagSet = useMemo(() => buildTagFilterSet(selectedTags), [selectedTags]);
484
+
485
+ const reportingFiltersActive = useMemo(
486
+ () => Boolean(dateFrom.trim() || dateTo.trim() || selectedTags.length > 0),
487
+ [dateFrom, dateTo, selectedTags],
488
+ );
489
+
490
+ const agg = useMemo(() => {
491
+ if (!payload) {
492
+ return null;
493
+ }
494
+ const sessions = mergeSessionsFromPayload(payload);
495
+ return aggregateReporting(
496
+ sessions,
497
+ tagSet,
498
+ dateFrom.trim() || null,
499
+ dateTo.trim() || null,
500
+ reportTimeZone,
501
+ taskDefaultTagBucketEnabled,
502
+ );
503
+ }, [payload, tagSet, dateFrom, dateTo, reportTimeZone, taskDefaultTagBucketEnabled]);
504
+
505
+ const archivedExcludedTaskMinutes = useMemo(() => {
506
+ if (!payload) {
507
+ return 0;
508
+ }
509
+ const sessions = mergeSessionsFromPayload(payload);
510
+ return aggregateArchivedExcludedTaskMinutes(
511
+ sessions,
512
+ tagSet,
513
+ dateFrom.trim() || null,
514
+ dateTo.trim() || null,
515
+ reportTimeZone,
516
+ taskDefaultTagBucketEnabled,
517
+ );
518
+ }, [payload, tagSet, dateFrom, dateTo, reportTimeZone, taskDefaultTagBucketEnabled]);
519
+
520
+ const reportingNonFinal = useMemo(
521
+ () =>
522
+ computeReportingNonFinalFlags(
523
+ payload,
524
+ tagSet,
525
+ dateFrom,
526
+ dateTo,
527
+ agg?.tasksByDayActive,
528
+ reportTimeZone,
529
+ taskDefaultTagBucketEnabled,
530
+ ),
531
+ [
532
+ payload,
533
+ tagSet,
534
+ dateFrom,
535
+ dateTo,
536
+ agg?.tasksByDayActive,
537
+ reportTimeZone,
538
+ taskDefaultTagBucketEnabled,
539
+ ],
540
+ );
541
+
542
+ const dayKeys = useMemo(() => {
543
+ if (!agg) {
544
+ return [] as string[];
545
+ }
546
+ return sortedDayKeys(
547
+ agg.sessionsByDay,
548
+ agg.tasksByDayDone,
549
+ agg.tasksByDayActive,
550
+ agg.taskMinutesByDay,
551
+ agg.sessionCodingMinutesByDay,
552
+ agg.sessionWallClockMinutesByDay,
553
+ );
554
+ }, [agg]);
555
+
556
+ const chartStrictDayKeys = useMemo(
557
+ () => dayKeys.filter((d) => d !== UNDATED_KEY),
558
+ [dayKeys],
559
+ );
560
+
561
+ const tagTimeSplit = useMemo((): {
562
+ byDay: ReportingTagTimeDayRow[];
563
+ byWeek: ReportingTagTimeWeekRow[];
564
+ } => {
565
+ if (!payload) {
566
+ return { byDay: [], byWeek: [] };
567
+ }
568
+ const sessions = mergeSessionsFromPayload(payload);
569
+ return aggregateTagTaskMinutesByDayAndWeek(
570
+ sessions,
571
+ tagSet,
572
+ dateFrom.trim() || null,
573
+ dateTo.trim() || null,
574
+ reportTimeZone,
575
+ weekStartsOn,
576
+ t.tagTimeUntagged,
577
+ taskDefaultTagBucketEnabled,
578
+ );
579
+ }, [
580
+ payload,
581
+ tagSet,
582
+ dateFrom,
583
+ dateTo,
584
+ reportTimeZone,
585
+ weekStartsOn,
586
+ t.tagTimeUntagged,
587
+ taskDefaultTagBucketEnabled,
588
+ ]);
589
+
590
+ const tagWeekCalendarRows = useMemo(
591
+ () => buildTagWeekCalendarRows(tagTimeSplit.byDay, weekStartsOn),
592
+ [tagTimeSplit.byDay, weekStartsOn],
593
+ );
594
+
595
+ const projectTaskMinutesByDay = useMemo(() => {
596
+ if (!payload) {
597
+ return [];
598
+ }
599
+ const sessions = mergeSessionsFromPayload(payload);
600
+ return aggregateProjectTaskMinutesByDay(
601
+ sessions,
602
+ tagSet,
603
+ dateFrom.trim() || null,
604
+ dateTo.trim() || null,
605
+ reportTimeZone,
606
+ taskDefaultTagBucketEnabled,
607
+ );
608
+ }, [payload, tagSet, dateFrom, dateTo, reportTimeZone, taskDefaultTagBucketEnabled]);
609
+
610
+ const projectWeekCalendarRows = useMemo(
611
+ () => buildProjectWeekCalendarRows(projectTaskMinutesByDay, weekStartsOn),
612
+ [projectTaskMinutesByDay, weekStartsOn],
613
+ );
614
+
615
+ const navigableWeekStarts = useMemo(() => {
616
+ if (!agg) {
617
+ return [] as string[];
618
+ }
619
+ const set = new Set<string>();
620
+ for (const d of chartStrictDayKeys) {
621
+ const sw = agg.sessionWallClockMinutesByDay[d] ?? 0;
622
+ const sc = agg.sessionsByDay[d] ?? 0;
623
+ if (sw > 0 || sc > 0) {
624
+ const ws = localWeekStartKeyFromDayKey(d, weekStartsOn);
625
+ if (ws) {
626
+ set.add(ws);
627
+ }
628
+ }
629
+ }
630
+ if (set.size === 0) {
631
+ for (const r of tagWeekCalendarRows) {
632
+ set.add(r.weekStart);
633
+ }
634
+ }
635
+ if (set.size === 0) {
636
+ for (const r of projectWeekCalendarRows) {
637
+ set.add(r.weekStart);
638
+ }
639
+ }
640
+ return [...set].sort();
641
+ }, [
642
+ agg,
643
+ chartStrictDayKeys,
644
+ weekStartsOn,
645
+ tagWeekCalendarRows,
646
+ projectWeekCalendarRows,
647
+ ]);
648
+
649
+ const navigableWeeksKey = navigableWeekStarts.join("|");
650
+
651
+ useEffect(() => {
652
+ setChartWeekNavIndex(-1);
653
+ }, [navigableWeeksKey]);
654
+
655
+ const chartWeekNavIndexSafe =
656
+ navigableWeekStarts.length <= 1
657
+ ? 0
658
+ : chartWeekNavIndex < 0
659
+ ? navigableWeekStarts.length - 1
660
+ : Math.min(chartWeekNavIndex, navigableWeekStarts.length - 1);
661
+
662
+ const reportingDayKeys = useMemo(() => {
663
+ if (navigableWeekStarts.length > 1) {
664
+ const ws = navigableWeekStarts[chartWeekNavIndexSafe];
665
+ if (!ws) {
666
+ return dayKeys;
667
+ }
668
+ return [0, 1, 2, 3, 4, 5, 6].map((i) => addDaysYmd(ws, i));
669
+ }
670
+ const strict = dayKeys.filter((d) => d !== UNDATED_KEY);
671
+ if (strict.length > 0) {
672
+ return dayKeys;
673
+ }
674
+ if (navigableWeekStarts.length === 1) {
675
+ const ws = navigableWeekStarts[0];
676
+ if (ws) {
677
+ return [0, 1, 2, 3, 4, 5, 6].map((i) => addDaysYmd(ws, i));
678
+ }
679
+ }
680
+ return dayKeys;
681
+ }, [navigableWeekStarts, chartWeekNavIndexSafe, dayKeys]);
682
+
683
+ const peak = useMemo(() => {
684
+ if (!agg) {
685
+ return 1;
686
+ }
687
+ let m = 0;
688
+ for (const d of reportingDayKeys) {
689
+ if (d === UNDATED_KEY) {
690
+ continue;
691
+ }
692
+ const s = agg.sessionsByDay[d] ?? 0;
693
+ if (s > m) {
694
+ m = s;
695
+ }
696
+ const td = (agg.tasksByDayDone[d] ?? 0) + (agg.tasksByDayActive[d] ?? 0);
697
+ if (td > m) {
698
+ m = td;
699
+ }
700
+ }
701
+ return m || 1;
702
+ }, [agg, reportingDayKeys]);
703
+
704
+ const peakTasks = useMemo(() => {
705
+ if (!agg) {
706
+ return 1;
707
+ }
708
+ let m = 0;
709
+ for (const d of reportingDayKeys) {
710
+ if (d === UNDATED_KEY) {
711
+ continue;
712
+ }
713
+ const sum = (agg.tasksByDayDone[d] ?? 0) + (agg.tasksByDayActive[d] ?? 0);
714
+ if (sum > m) {
715
+ m = sum;
716
+ }
717
+ }
718
+ return m || 1;
719
+ }, [agg, reportingDayKeys]);
720
+
721
+ const peakTaskMinutes = useMemo(() => {
722
+ if (!agg) {
723
+ return 1;
724
+ }
725
+ let m = 0;
726
+ for (const d of reportingDayKeys) {
727
+ if (d === UNDATED_KEY) {
728
+ continue;
729
+ }
730
+ const v = agg.taskMinutesByDay[d] ?? 0;
731
+ if (v > m) {
732
+ m = v;
733
+ }
734
+ }
735
+ return m || 1;
736
+ }, [agg, reportingDayKeys]);
737
+
738
+ const peakSessionWallMinutes = useMemo(() => {
739
+ if (!agg) {
740
+ return 1;
741
+ }
742
+ let m = 0;
743
+ for (const d of reportingDayKeys) {
744
+ if (d === UNDATED_KEY) {
745
+ continue;
746
+ }
747
+ const v = agg.sessionWallClockMinutesByDay[d] ?? 0;
748
+ if (v > m) {
749
+ m = v;
750
+ }
751
+ }
752
+ return m || 1;
753
+ }, [agg, reportingDayKeys]);
754
+
755
+ const tagWeekCalendarRowsVisible = useMemo(() => {
756
+ if (navigableWeekStarts.length <= 1) {
757
+ return tagWeekCalendarRows;
758
+ }
759
+ const ws = navigableWeekStarts[chartWeekNavIndexSafe];
760
+ return tagWeekCalendarRows.filter((r) => r.weekStart === ws);
761
+ }, [tagWeekCalendarRows, navigableWeekStarts, chartWeekNavIndexSafe]);
762
+
763
+ const tagCalendarWeekGroups = useMemo(() => {
764
+ const byWeek = new Map<string, TagWeekCalendarRow[]>();
765
+ for (const r of tagWeekCalendarRowsVisible) {
766
+ const list = byWeek.get(r.weekStart) ?? [];
767
+ list.push(r);
768
+ byWeek.set(r.weekStart, list);
769
+ }
770
+ const weekOrder = [...byWeek.keys()].sort();
771
+ return weekOrder.map((weekStart) => {
772
+ const rows = [...(byWeek.get(weekStart) ?? [])].sort((a, b) => {
773
+ if (isFallbackTaskTagKey(a.tagKey) && !isFallbackTaskTagKey(b.tagKey)) {
774
+ return 1;
775
+ }
776
+ if (!isFallbackTaskTagKey(a.tagKey) && isFallbackTaskTagKey(b.tagKey)) {
777
+ return -1;
778
+ }
779
+ return a.tagKey.localeCompare(b.tagKey);
780
+ });
781
+ return { weekStart, rows };
782
+ });
783
+ }, [tagWeekCalendarRowsVisible]);
784
+
785
+ const projectWeekCalendarRowsVisible = useMemo(() => {
786
+ if (navigableWeekStarts.length <= 1) {
787
+ return projectWeekCalendarRows;
788
+ }
789
+ const ws = navigableWeekStarts[chartWeekNavIndexSafe];
790
+ return projectWeekCalendarRows.filter((r) => r.weekStart === ws);
791
+ }, [projectWeekCalendarRows, navigableWeekStarts, chartWeekNavIndexSafe]);
792
+
793
+ const projectCalendarWeekGroups = useMemo(() => {
794
+ const byWeek = new Map<string, ProjectWeekCalendarRow[]>();
795
+ for (const r of projectWeekCalendarRowsVisible) {
796
+ const list = byWeek.get(r.weekStart) ?? [];
797
+ list.push(r);
798
+ byWeek.set(r.weekStart, list);
799
+ }
800
+ const weekOrder = [...byWeek.keys()].sort();
801
+ return weekOrder.map((weekStart) => {
802
+ const rows = [...(byWeek.get(weekStart) ?? [])].sort((a, b) => {
803
+ if (a.projectKey === "" && b.projectKey !== "") {
804
+ return 1;
805
+ }
806
+ if (b.projectKey === "" && a.projectKey !== "") {
807
+ return -1;
808
+ }
809
+ return a.projectKey.localeCompare(b.projectKey);
810
+ });
811
+ return { weekStart, rows };
812
+ });
813
+ }, [projectWeekCalendarRowsVisible]);
814
+
815
+ const toggleTag = (tag: string) => {
816
+ const key = normalizeTagKey(tag).toLowerCase();
817
+ setSelectedTags((prev) => {
818
+ const has = prev.some((p) => normalizeTagKey(p).toLowerCase() === key);
819
+ if (has) {
820
+ return prev.filter((p) => normalizeTagKey(p).toLowerCase() !== key);
821
+ }
822
+ return [...prev, tag];
823
+ });
824
+ };
825
+
826
+ const headerApiError =
827
+ lang === "fr"
828
+ ? "Impossible de joindre l’API locale Kronosys (127.0.0.1:5566 par défaut). Vérifiez que le serveur tourne."
829
+ : "Cannot reach the local Kronosys API (default 127.0.0.1:5566). Make sure the server is running.";
830
+
831
+ const trackCodeMetrics = trackCodeMetricsFromCfg(
832
+ payload ? (payload.cfg as Record<string, unknown> | undefined) : undefined,
833
+ );
834
+
835
+ const hasReportingChartData =
836
+ !!agg &&
837
+ (dayKeys.length > 0 ||
838
+ navigableWeekStarts.length > 1 ||
839
+ tagWeekCalendarRows.length > 0 ||
840
+ projectWeekCalendarRows.length > 0);
841
+
842
+ useEffect(() => {
843
+ if (!payload || !agg) {
844
+ return;
845
+ }
846
+ if (searchParams.get("tour") === "replay") {
847
+ setReportingTourOpen(true);
848
+ stripReportingTourReplayParam();
849
+ return;
850
+ }
851
+ if (!isReportingTourCompleted()) {
852
+ setReportingTourOpen(true);
853
+ }
854
+ }, [payload, agg, searchParams, stripReportingTourReplayParam]);
855
+
856
+ const reportingTocEntries = (() => {
857
+ if (!payload) {
858
+ return [] as { id: string; label: string }[];
859
+ }
860
+ const rows: { id: string; label: string }[] = [
861
+ { id: "report-filters", label: t.filtersTitle },
862
+ ];
863
+ if (trackCodeMetrics) {
864
+ rows.push({
865
+ id: "report-workspace-snapshot",
866
+ label: t.workspaceSnapshotTitle,
867
+ });
868
+ }
869
+ if (!agg) {
870
+ return rows;
871
+ }
872
+ rows.push({ id: "report-summary-kpis", label: t.tocSummaryKpis });
873
+ if (trackCodeMetrics) {
874
+ rows.push({ id: "report-loc-metrics", label: t.tocLocSection });
875
+ }
876
+ if (!hasReportingChartData) {
877
+ return rows;
878
+ }
879
+ if (navigableWeekStarts.length > 1) {
880
+ rows.push({ id: "report-week-nav", label: t.tocWeekNav });
881
+ }
882
+ rows.push(
883
+ { id: "report-chart-sessions", label: t.chartSessionsPerDay },
884
+ { id: "report-chart-tasks", label: t.chartTasksByStatusPerDay },
885
+ { id: "report-chart-task-time", label: t.chartTaskTimePerDay },
886
+ { id: "report-chart-session-wall", label: t.chartSessionWallPerDay },
887
+ { id: "report-daily-table", label: t.tocDailyTable },
888
+ { id: "report-tag-time", label: t.tocTagTimeSection },
889
+ { id: "report-projects", label: t.projectSectionTitle },
890
+ );
891
+ return rows;
892
+ })();
893
+
894
+ return (
895
+ <div className="min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
896
+ <header className={appShellHeaderClassName}>
897
+ <div className={appShellHeaderToolRowClassName}>
898
+ <div className="flex min-w-0 flex-col gap-1">
899
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
900
+ <Link
901
+ href={withDashboardSessionParam("/", dashboardSessionNavId)}
902
+ className="text-xl font-semibold tracking-tight text-zinc-900 hover:text-violet-700 dark:text-zinc-100 dark:hover:text-violet-300"
903
+ >
904
+ Kronosys
905
+ </Link>
906
+ <span className="text-zinc-400 dark:text-zinc-600">/</span>
907
+ <span className="text-lg font-medium text-zinc-700 dark:text-zinc-300">
908
+ {t.title}
909
+ </span>
910
+ </div>
911
+ <p className="flex flex-wrap items-center gap-x-2 text-xs font-medium leading-snug text-zinc-500 dark:text-zinc-400">
912
+ <span>{dt.brandTagline}</span>
913
+ <span className="text-zinc-400/70 dark:text-zinc-600" aria-hidden>
914
+ ·
915
+ </span>
916
+ <AppVersionStamp ariaLabelTemplate={dt.appVersionAriaLabel} />
917
+ </p>
918
+ </div>
919
+ <div className="flex flex-wrap items-center gap-1.5">
920
+ <AppShellRouteNav
921
+ current="reporting"
922
+ labels={{
923
+ dashboard: t.dashboard,
924
+ reporting: t.reporting,
925
+ settings: t.settings,
926
+ guide: t.guide,
927
+ }}
928
+ navAriaLabel={dt.appShellRouteNavAria}
929
+ dashboardSessionId={dashboardSessionNavId}
930
+ />
931
+ <ThemeToggle lang={lang} />
932
+ <PageRefreshButton
933
+ title={dt.pageRefreshTitle}
934
+ ariaLabel={dt.pageRefreshAriaLabel}
935
+ inlineMessages={{
936
+ loading: dt.pageRefreshProgressLabel,
937
+ success: dt.pageRefreshDoneToast,
938
+ error: dt.pageRefreshFailedToast,
939
+ }}
940
+ onRefresh={handleManualRefresh}
941
+ />
942
+ <LanguageMenu
943
+ lang={lang}
944
+ labelEn="English"
945
+ labelFr="Français"
946
+ menuHeading={lang === "fr" ? "Langue" : "Language"}
947
+ triggerAriaLabel={
948
+ lang === "fr" ? "Langue de l’interface" : "Interface language"
949
+ }
950
+ onSelect={(next) => void postLang(next)}
951
+ />
952
+ </div>
953
+ </div>
954
+ </header>
955
+
956
+ <div className="w-full px-5 py-8 sm:px-8 lg:px-10 xl:px-12">
957
+ <div
958
+ id="reporting-tour-anchor-intro"
959
+ className="mb-8 scroll-mt-28 space-y-3 text-sm text-zinc-600 dark:text-zinc-500"
960
+ >
961
+ <p>{t.subtitle}</p>
962
+ <p className="text-zinc-500 dark:text-zinc-400">
963
+ {t.archivedSessionsReportingNote}
964
+ </p>
965
+ {payload ? (
966
+ <div className="pt-1">
967
+ <button
968
+ type="button"
969
+ className="rounded-lg border border-zinc-500/50 bg-zinc-500/10 px-3 py-2 text-sm font-medium text-zinc-800 transition hover:border-zinc-500/75 hover:bg-zinc-500/18 dark:border-zinc-400/45 dark:bg-zinc-600/20 dark:text-zinc-100 dark:hover:border-zinc-400/60 dark:hover:bg-zinc-600/30"
970
+ onClick={() => {
971
+ resetReportingTour();
972
+ setReportingTourOpen(true);
973
+ }}
974
+ >
975
+ {dt.tourUndismissBtn}
976
+ </button>
977
+ </div>
978
+ ) : null}
979
+ </div>
980
+
981
+ {error && (
982
+ <div
983
+ className="mb-8 rounded-lg border border-red-900/60 bg-red-950/40 px-4 py-3 text-sm text-red-100"
984
+ role="alert"
985
+ >
986
+ <strong className="block text-red-200">API</strong>
987
+ {headerApiError}
988
+ <pre className="mt-2 overflow-x-auto text-xs text-red-200/80">
989
+ {error}
990
+ </pre>
991
+ </div>
992
+ )}
993
+
994
+ {!payload && !error && (
995
+ <p className="text-sm text-zinc-500">{t.loading}</p>
996
+ )}
997
+
998
+ {payload && (
999
+ <div
1000
+ id="reporting-tour-anchor-toc-layout"
1001
+ className="lg:flex lg:items-start lg:gap-8 xl:gap-10"
1002
+ >
1003
+ <div className="min-w-0 flex-1">
1004
+ <ReportingPageTocMobile
1005
+ title={t.tocTitle}
1006
+ ariaLabel={t.tocNavAria}
1007
+ entries={reportingTocEntries}
1008
+ />
1009
+ <section
1010
+ id="report-filters"
1011
+ className="mb-10 scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
1012
+ >
1013
+ <div className="flex flex-wrap items-center gap-2 gap-y-2">
1014
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-400">
1015
+ {t.filtersTitle}
1016
+ </h2>
1017
+ <InlineMetricHelpTrigger
1018
+ ariaLabel={t.filtersHelpAriaLabel}
1019
+ body={t.filtersHelpBody}
1020
+ preserveLineBreaks
1021
+ panelClassName="w-[min(calc(100vw-2rem),24rem)]"
1022
+ />
1023
+ {(reportingNonFinal.sessionsNonFinal ||
1024
+ reportingNonFinal.projectsNonFinal ||
1025
+ reportingNonFinal.tagsNonFinal) && (
1026
+ <div className="flex min-w-0 max-w-full flex-wrap items-center gap-x-1.5 gap-y-1 border-l border-zinc-600/70 pl-2.5 dark:border-zinc-500/50">
1027
+ <span className="shrink-0 text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
1028
+ {t.reportingNonFinalLegend}
1029
+ </span>
1030
+ {reportingNonFinal.sessionsNonFinal ? (
1031
+ <span className="rounded border border-emerald-600/50 bg-emerald-950/40 px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-emerald-700 dark:border-emerald-500/45 dark:bg-emerald-950/50 dark:text-emerald-400">
1032
+ {t.reportingNonFinalSessionsBadge}
1033
+ </span>
1034
+ ) : null}
1035
+ {reportingNonFinal.projectsNonFinal ? (
1036
+ <span className="rounded border border-emerald-600/50 bg-emerald-950/40 px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-emerald-700 dark:border-emerald-500/45 dark:bg-emerald-950/50 dark:text-emerald-400">
1037
+ {t.reportingNonFinalProjectsBadge}
1038
+ </span>
1039
+ ) : null}
1040
+ {reportingNonFinal.tagsNonFinal ? (
1041
+ <span className="rounded border border-emerald-600/50 bg-emerald-950/40 px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-emerald-700 dark:border-emerald-500/45 dark:bg-emerald-950/50 dark:text-emerald-400">
1042
+ {t.reportingNonFinalTagsBadge}
1043
+ </span>
1044
+ ) : null}
1045
+ </div>
1046
+ )}
1047
+ </div>
1048
+ <div className="mt-4 flex flex-wrap items-end gap-4">
1049
+ <label className="flex flex-col gap-1 text-xs text-zinc-500">
1050
+ {t.dateFrom}
1051
+ <input
1052
+ type="date"
1053
+ value={dateFrom}
1054
+ onChange={(e) => setDateFrom(e.target.value)}
1055
+ className="rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
1056
+ />
1057
+ </label>
1058
+ <label className="flex flex-col gap-1 text-xs text-zinc-500">
1059
+ {t.dateTo}
1060
+ <input
1061
+ type="date"
1062
+ value={dateTo}
1063
+ onChange={(e) => setDateTo(e.target.value)}
1064
+ className="rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
1065
+ />
1066
+ </label>
1067
+ <button
1068
+ type="button"
1069
+ className="rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
1070
+ onClick={() => {
1071
+ const p = reportingPresetDay();
1072
+ setDateFrom(p.from);
1073
+ setDateTo(p.to);
1074
+ }}
1075
+ >
1076
+ {t.today}
1077
+ </button>
1078
+ <button
1079
+ type="button"
1080
+ className="rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
1081
+ onClick={() => {
1082
+ setDateFrom("");
1083
+ setDateTo("");
1084
+ }}
1085
+ >
1086
+ {t.clearDates}
1087
+ </button>
1088
+ </div>
1089
+ <div
1090
+ className="mt-4 flex flex-col gap-2"
1091
+ role="group"
1092
+ aria-label={t.presetPeriodLabel}
1093
+ >
1094
+ <span className="text-xs font-medium text-zinc-500">
1095
+ {t.presetPeriodLabel}
1096
+ </span>
1097
+ <div className="flex flex-wrap gap-2">
1098
+ <button
1099
+ type="button"
1100
+ className="rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:border-zinc-500 hover:bg-zinc-800"
1101
+ onClick={() => {
1102
+ const p = reportingPresetDay();
1103
+ setDateFrom(p.from);
1104
+ setDateTo(p.to);
1105
+ }}
1106
+ >
1107
+ {t.presetDay}
1108
+ </button>
1109
+ <button
1110
+ type="button"
1111
+ className="rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:border-zinc-500 hover:bg-zinc-800"
1112
+ onClick={() => {
1113
+ const p = reportingPresetWeek();
1114
+ setDateFrom(p.from);
1115
+ setDateTo(p.to);
1116
+ }}
1117
+ >
1118
+ {t.presetWeek}
1119
+ </button>
1120
+ <button
1121
+ type="button"
1122
+ className="rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:border-zinc-500 hover:bg-zinc-800"
1123
+ onClick={() => {
1124
+ const p = reportingPresetMonth();
1125
+ setDateFrom(p.from);
1126
+ setDateTo(p.to);
1127
+ }}
1128
+ >
1129
+ {t.presetMonth}
1130
+ </button>
1131
+ <button
1132
+ type="button"
1133
+ className="rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:border-zinc-500 hover:bg-zinc-800"
1134
+ onClick={() => {
1135
+ const p = reportingPresetYear();
1136
+ setDateFrom(p.from);
1137
+ setDateTo(p.to);
1138
+ }}
1139
+ >
1140
+ {t.presetYear}
1141
+ </button>
1142
+ </div>
1143
+ <p className="text-[0.65rem] leading-snug text-zinc-600">
1144
+ {t.presetPeriodHint}
1145
+ </p>
1146
+ </div>
1147
+ <p className="mt-3 text-xs text-zinc-500">{t.tagsHint}</p>
1148
+ <div className="mt-2">
1149
+ <span className="text-xs font-medium uppercase text-zinc-500">
1150
+ {t.tagsLabel}
1151
+ </span>
1152
+ <div className="mt-2 flex flex-wrap gap-2">
1153
+ {tagKeys.length === 0 ? (
1154
+ <span className="text-sm text-zinc-500">—</span>
1155
+ ) : (
1156
+ tagKeys.map((tag) => {
1157
+ const on = selectedTags.some(
1158
+ (p) =>
1159
+ normalizeTagKey(p).toLowerCase() ===
1160
+ normalizeTagKey(tag).toLowerCase(),
1161
+ );
1162
+ return (
1163
+ <button
1164
+ key={tag}
1165
+ type="button"
1166
+ onClick={() => toggleTag(tag)}
1167
+ className={`rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
1168
+ on
1169
+ ? "border-violet-500 bg-violet-600/30 text-violet-100"
1170
+ : "border-zinc-600 bg-zinc-950 text-zinc-400 hover:border-zinc-500"
1171
+ }`}
1172
+ >
1173
+ {formatTagDisplay(tag)}
1174
+ </button>
1175
+ );
1176
+ })
1177
+ )}
1178
+ </div>
1179
+ </div>
1180
+ <div className="mt-4 flex flex-wrap items-center gap-3">
1181
+ <button
1182
+ type="button"
1183
+ disabled={!reportingFiltersActive}
1184
+ className="rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:border-zinc-500 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"
1185
+ onClick={() => {
1186
+ setDateFrom("");
1187
+ setDateTo("");
1188
+ setSelectedTags([]);
1189
+ }}
1190
+ >
1191
+ {t.resetAllFilters}
1192
+ </button>
1193
+ </div>
1194
+ </section>
1195
+
1196
+ {trackCodeMetrics ? (
1197
+ <section
1198
+ id="report-workspace-snapshot"
1199
+ className="mb-10 scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
1200
+ >
1201
+ <div className="flex flex-wrap items-start justify-between gap-3">
1202
+ <div className="flex min-w-0 max-w-full items-center gap-1">
1203
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-400">
1204
+ {t.workspaceSnapshotTitle}
1205
+ </h2>
1206
+ <InlineMetricHelpTrigger
1207
+ ariaLabel={t.metricHelpWorkspaceTitleAria}
1208
+ body={t.metricHelpWorkspaceTitleBody}
1209
+ />
1210
+ </div>
1211
+ <button
1212
+ type="button"
1213
+ disabled={workspaceSnapBusy}
1214
+ className="shrink-0 rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
1215
+ onClick={() => void refreshWorkspaceSnapshot()}
1216
+ >
1217
+ {workspaceSnapBusy
1218
+ ? t.workspaceSnapshotRefreshing
1219
+ : t.workspaceSnapshotRefresh}
1220
+ </button>
1221
+ </div>
1222
+ <p className="mt-2 text-xs text-zinc-500">
1223
+ {t.workspaceSnapshotIntro}
1224
+ </p>
1225
+ {(() => {
1226
+ const ws = payload.workspaceCodeSnapshot as
1227
+ | WorkspaceCodeSnapshotPayload
1228
+ | undefined;
1229
+ if (!ws) {
1230
+ return (
1231
+ <p className="mt-4 text-sm text-zinc-500">
1232
+ {t.workspaceSnapshotRefreshHint}
1233
+ </p>
1234
+ );
1235
+ }
1236
+ if (ws.ok) {
1237
+ return (
1238
+ <>
1239
+ <p className="mt-2 text-xs text-zinc-500">
1240
+ {ws.source === "git"
1241
+ ? t.workspaceSnapshotSourceGit
1242
+ : t.workspaceSnapshotSourceWalk}
1243
+ </p>
1244
+ <div className="mt-3 flex flex-wrap gap-6 text-sm">
1245
+ <div>
1246
+ <div className="flex min-h-5 items-center gap-0.5">
1247
+ <span className="text-xs uppercase text-zinc-500">
1248
+ {t.workspaceSnapshotTotalLines}
1249
+ </span>
1250
+ <InlineMetricHelpTrigger
1251
+ ariaLabel={t.metricHelpWsTotalLinesAria}
1252
+ body={t.metricHelpWsTotalLinesBody}
1253
+ />
1254
+ </div>
1255
+ <div className="tabular-nums text-lg font-semibold text-zinc-100">
1256
+ {ws.totalLines}
1257
+ </div>
1258
+ </div>
1259
+ <div>
1260
+ <div className="flex min-h-5 items-center gap-0.5">
1261
+ <span className="text-xs uppercase text-zinc-500">
1262
+ {t.workspaceSnapshotFileCount}
1263
+ </span>
1264
+ <InlineMetricHelpTrigger
1265
+ ariaLabel={t.metricHelpWsFileCountAria}
1266
+ body={t.metricHelpWsFileCountBody}
1267
+ />
1268
+ </div>
1269
+ <div className="tabular-nums text-lg font-semibold text-zinc-100">
1270
+ {ws.fileCount}
1271
+ </div>
1272
+ </div>
1273
+ </div>
1274
+ {ws.byLanguage.length === 0 ? (
1275
+ <p className="mt-4 text-sm text-zinc-500">—</p>
1276
+ ) : (
1277
+ <div className="mt-4 overflow-x-auto">
1278
+ <table className="w-full min-w-[22rem] text-left text-sm">
1279
+ <thead className="border-b border-zinc-800 text-xs uppercase text-zinc-500">
1280
+ <tr>
1281
+ <th className="py-2 pr-3 font-medium">
1282
+ <div className="flex min-h-5 items-center gap-0.5">
1283
+ <span>
1284
+ {t.workspaceSnapshotColLang}
1285
+ </span>
1286
+ <InlineMetricHelpTrigger
1287
+ ariaLabel={t.metricHelpWsColLangAria}
1288
+ body={t.metricHelpWsColLangBody}
1289
+ />
1290
+ </div>
1291
+ </th>
1292
+ <th className="py-2 pr-3 font-medium tabular-nums">
1293
+ <div className="flex min-h-5 items-center gap-0.5">
1294
+ <span>
1295
+ {t.workspaceSnapshotColLines}
1296
+ </span>
1297
+ <InlineMetricHelpTrigger
1298
+ ariaLabel={t.metricHelpWsColLinesAria}
1299
+ body={t.metricHelpWsColLinesBody}
1300
+ />
1301
+ </div>
1302
+ </th>
1303
+ <th className="py-2 font-medium">
1304
+ <div className="flex min-h-5 items-center gap-0.5">
1305
+ <span>
1306
+ {t.workspaceSnapshotColPercent}
1307
+ </span>
1308
+ <InlineMetricHelpTrigger
1309
+ align="end"
1310
+ ariaLabel={
1311
+ t.metricHelpWsColPercentAria
1312
+ }
1313
+ body={t.metricHelpWsColPercentBody}
1314
+ />
1315
+ </div>
1316
+ </th>
1317
+ </tr>
1318
+ </thead>
1319
+ <tbody>
1320
+ {ws.byLanguage.map((row) => (
1321
+ <tr
1322
+ key={row.languageId}
1323
+ className="border-b border-zinc-800/80 last:border-0"
1324
+ >
1325
+ <td className="py-2 pr-3 font-mono text-xs text-zinc-400">
1326
+ {row.languageId}
1327
+ </td>
1328
+ <td className="py-2 pr-3 tabular-nums text-zinc-200">
1329
+ {row.lines}
1330
+ </td>
1331
+ <td className="py-2">
1332
+ <div className="flex items-center gap-2">
1333
+ <div className="h-2 min-w-[4rem] flex-1 overflow-hidden rounded bg-zinc-800">
1334
+ <div
1335
+ className="h-full rounded bg-sky-600/85"
1336
+ style={{
1337
+ width: `${Math.min(
1338
+ 100,
1339
+ row.percent,
1340
+ )}%`,
1341
+ }}
1342
+ />
1343
+ </div>
1344
+ <span className="w-14 shrink-0 tabular-nums text-zinc-400">
1345
+ {row.percent.toFixed(1)}%
1346
+ </span>
1347
+ </div>
1348
+ </td>
1349
+ </tr>
1350
+ ))}
1351
+ </tbody>
1352
+ </table>
1353
+ </div>
1354
+ )}
1355
+ </>
1356
+ );
1357
+ }
1358
+ return (
1359
+ <p className="mt-4 text-sm text-amber-200/90">
1360
+ {ws.reason === "no_workspace"
1361
+ ? t.workspaceSnapshotNoWorkspace
1362
+ : ws.reason === "empty"
1363
+ ? ws.message || t.workspaceSnapshotEmpty
1364
+ : ws.message || t.workspaceSnapshotError}
1365
+ </p>
1366
+ );
1367
+ })()}
1368
+ {archivedExcludedTaskMinutes > 1e-9 ? (
1369
+ <div
1370
+ role="status"
1371
+ className="rounded-lg border border-amber-600/45 bg-amber-950/30 px-3 py-2.5 text-sm leading-relaxed text-amber-100/95 dark:border-amber-500/40"
1372
+ >
1373
+ {reportingArchivedExcludedRichText(
1374
+ t.reportingArchivedExcludedAside,
1375
+ archivedExcludedTaskMinutes
1376
+ )}
1377
+ </div>
1378
+ ) : null}
1379
+ </section>
1380
+ ) : null}
1381
+
1382
+ {agg && (
1383
+ <>
1384
+ <section
1385
+ id="report-summary-kpis"
1386
+ className="mb-10 scroll-mt-28 flex flex-col gap-3"
1387
+ >
1388
+ <div className="flex flex-wrap items-center gap-2">
1389
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-400">
1390
+ {t.tocSummaryKpis}
1391
+ </h2>
1392
+ <ReportingFilteredBadge
1393
+ active={reportingFiltersActive}
1394
+ label={t.sectionFilteredBadge}
1395
+ titleText={t.sectionFilteredBadgeTitle}
1396
+ />
1397
+ </div>
1398
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
1399
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1400
+ <div className="flex min-h-5 items-center gap-0.5">
1401
+ <span className="text-xs uppercase text-zinc-500">
1402
+ {t.summarySessionsInRange}
1403
+ </span>
1404
+ <InlineMetricHelpTrigger
1405
+ ariaLabel={t.metricHelpSessionsAria}
1406
+ body={t.metricHelpSessionsBody}
1407
+ />
1408
+ </div>
1409
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1410
+ {agg.sessionCountContributing}
1411
+ </div>
1412
+ </div>
1413
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1414
+ <div className="flex min-h-5 items-center gap-0.5">
1415
+ <span className="text-xs uppercase text-zinc-500">
1416
+ {t.summaryTaskEvents}
1417
+ </span>
1418
+ <InlineMetricHelpTrigger
1419
+ ariaLabel={t.metricHelpTaskRowsAria}
1420
+ body={t.metricHelpTaskRowsBody}
1421
+ />
1422
+ </div>
1423
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1424
+ {agg.taskCountContributing}
1425
+ </div>
1426
+ </div>
1427
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1428
+ <div className="flex min-h-5 items-center gap-0.5">
1429
+ <span className="text-xs uppercase text-zinc-500">
1430
+ {t.summaryKronoFocusCompleted}
1431
+ </span>
1432
+ <InlineMetricHelpTrigger
1433
+ align="end"
1434
+ ariaLabel={t.metricHelpKronoFocusCompletedAria}
1435
+ body={t.metricHelpKronoFocusCompletedBody}
1436
+ />
1437
+ </div>
1438
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1439
+ {agg.kronoFocusSessionsCompleted}
1440
+ </div>
1441
+ </div>
1442
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1443
+ <div className="flex min-h-5 items-center gap-0.5">
1444
+ <span className="text-xs uppercase text-zinc-500">
1445
+ {t.summaryKronoFocusTasksUsed}
1446
+ </span>
1447
+ <InlineMetricHelpTrigger
1448
+ ariaLabel={t.metricHelpKronoFocusTasksUsedAria}
1449
+ body={t.metricHelpKronoFocusTasksUsedBody}
1450
+ />
1451
+ </div>
1452
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1453
+ {agg.kronoFocusTasksUsedCount}
1454
+ </div>
1455
+ </div>
1456
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1457
+ <div className="flex min-h-5 items-center gap-0.5">
1458
+ <span className="text-xs uppercase text-zinc-500">
1459
+ {t.summaryKronoFocusCycles}
1460
+ </span>
1461
+ <InlineMetricHelpTrigger
1462
+ ariaLabel={t.metricHelpKronoFocusCyclesAria}
1463
+ body={t.metricHelpKronoFocusCyclesBody}
1464
+ />
1465
+ </div>
1466
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1467
+ {agg.kronoFocusTaskCyclesSum}
1468
+ </div>
1469
+ </div>
1470
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1471
+ <div className="flex min-h-5 items-center gap-0.5">
1472
+ <span className="text-xs uppercase text-zinc-500">
1473
+ {t.summaryTaskTimeRecorded}
1474
+ </span>
1475
+ <InlineMetricHelpTrigger
1476
+ align="end"
1477
+ ariaLabel={t.metricHelpTaskTimeAria}
1478
+ body={t.metricHelpTaskTimeBody}
1479
+ />
1480
+ </div>
1481
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1482
+ {formatDuration(agg.taskMinutesTotal)}
1483
+ </div>
1484
+ </div>
1485
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1486
+ <div className="flex min-h-5 items-center gap-0.5">
1487
+ <span className="text-xs uppercase text-zinc-500">
1488
+ {t.summarySessionCoding}
1489
+ </span>
1490
+ <InlineMetricHelpTrigger
1491
+ ariaLabel={t.metricHelpSessionCodingAria}
1492
+ body={t.metricHelpSessionCodingBody}
1493
+ />
1494
+ </div>
1495
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1496
+ {formatDuration(agg.sessionCodingMinutesTotal)}
1497
+ </div>
1498
+ </div>
1499
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1500
+ <div className="flex min-h-5 items-center gap-0.5">
1501
+ <span className="text-xs uppercase text-zinc-500">
1502
+ {t.summarySessionActive}
1503
+ </span>
1504
+ <InlineMetricHelpTrigger
1505
+ ariaLabel={t.metricHelpSessionActiveAria}
1506
+ body={t.metricHelpSessionActiveBody}
1507
+ />
1508
+ </div>
1509
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1510
+ {formatDuration(agg.sessionActiveMinutesTotal)}
1511
+ </div>
1512
+ </div>
1513
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1514
+ <div className="flex min-h-5 items-center gap-0.5">
1515
+ <span className="text-xs uppercase text-zinc-500">
1516
+ {t.summarySessionWallClock}
1517
+ </span>
1518
+ <InlineMetricHelpTrigger
1519
+ align="end"
1520
+ ariaLabel={t.metricHelpSessionWallSummaryAria}
1521
+ body={t.metricHelpSessionWallSummaryBody}
1522
+ />
1523
+ </div>
1524
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1525
+ {formatDuration(agg.sessionWallClockMinutesTotal)}
1526
+ </div>
1527
+ </div>
1528
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1529
+ <div className="flex min-h-5 items-center gap-0.5">
1530
+ <span className="text-xs uppercase text-zinc-500">
1531
+ {t.summaryAssiduityWithReference}
1532
+ </span>
1533
+ <InlineMetricHelpTrigger
1534
+ align="end"
1535
+ ariaLabel={t.metricHelpAssiduityRefAria}
1536
+ body={t.metricHelpAssiduityRefBody}
1537
+ />
1538
+ </div>
1539
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1540
+ {agg.assiduityReferenceSessionCount}
1541
+ </div>
1542
+ </div>
1543
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1544
+ <div className="flex min-h-5 items-center gap-0.5">
1545
+ <span className="text-xs uppercase text-zinc-500">
1546
+ {t.summaryAssiduityLateSessions}
1547
+ </span>
1548
+ <InlineMetricHelpTrigger
1549
+ align="end"
1550
+ ariaLabel={t.metricHelpAssiduityLateCountAria}
1551
+ body={t.metricHelpAssiduityLateCountBody}
1552
+ />
1553
+ </div>
1554
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1555
+ {agg.assiduityLateSessionCount}
1556
+ </div>
1557
+ </div>
1558
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1559
+ <div className="flex min-h-5 items-center gap-0.5">
1560
+ <span className="text-xs uppercase text-zinc-500">
1561
+ {t.summaryAssiduityLateTotal}
1562
+ </span>
1563
+ <InlineMetricHelpTrigger
1564
+ align="end"
1565
+ ariaLabel={t.metricHelpAssiduityLateTotalAria}
1566
+ body={t.metricHelpAssiduityLateTotalBody}
1567
+ />
1568
+ </div>
1569
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1570
+ {formatDuration(agg.assiduityLateMinutesTotal)}
1571
+ </div>
1572
+ </div>
1573
+ <div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
1574
+ <div className="flex min-h-5 items-center gap-0.5">
1575
+ <span className="text-xs uppercase text-zinc-500">
1576
+ {t.summaryAssiduityAvgLateWhenLate}
1577
+ </span>
1578
+ <InlineMetricHelpTrigger
1579
+ align="end"
1580
+ ariaLabel={t.metricHelpAssiduityAvgLateAria}
1581
+ body={t.metricHelpAssiduityAvgLateBody}
1582
+ />
1583
+ </div>
1584
+ <div className="mt-1 text-2xl font-semibold tabular-nums">
1585
+ {agg.assiduityAverageLateMinutesWhenLate == null
1586
+ ? "—"
1587
+ : formatDuration(agg.assiduityAverageLateMinutesWhenLate)}
1588
+ </div>
1589
+ </div>
1590
+ </div>
1591
+ {archivedExcludedTaskMinutes > 1e-9 ? (
1592
+ <div
1593
+ role="status"
1594
+ className="rounded-lg border border-amber-600/45 bg-amber-950/30 px-3 py-2.5 text-sm leading-relaxed text-amber-100/95 dark:border-amber-500/40"
1595
+ >
1596
+ {reportingArchivedExcludedRichText(
1597
+ t.reportingArchivedExcludedAside,
1598
+ archivedExcludedTaskMinutes,
1599
+ )}
1600
+ </div>
1601
+ ) : null}
1602
+ </section>
1603
+
1604
+ {trackCodeMetrics ? (
1605
+ <section
1606
+ id="report-loc-metrics"
1607
+ className="mb-10 scroll-mt-28 space-y-4 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
1608
+ >
1609
+ <div className="flex flex-wrap items-center gap-2">
1610
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-400">
1611
+ {t.tocLocSection}
1612
+ </h2>
1613
+ <ReportingFilteredBadge
1614
+ active={reportingFiltersActive}
1615
+ label={t.sectionFilteredBadge}
1616
+ titleText={t.sectionFilteredBadgeTitle}
1617
+ />
1618
+ </div>
1619
+ <p className="text-xs text-zinc-500">
1620
+ {t.locMetricsHint}
1621
+ </p>
1622
+ <div className="grid gap-3 sm:grid-cols-3">
1623
+ <div className="rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3">
1624
+ <div className="flex min-h-5 items-center gap-0.5">
1625
+ <span className="text-xs uppercase text-zinc-500">
1626
+ {t.summaryLinesWrittenTotal}
1627
+ </span>
1628
+ <InlineMetricHelpTrigger
1629
+ ariaLabel={t.metricHelpLinesTotalAria}
1630
+ body={t.metricHelpLinesTotalBody}
1631
+ />
1632
+ </div>
1633
+ <div className="mt-1 text-xl font-semibold tabular-nums text-zinc-100">
1634
+ {agg.linesWrittenTotalSum}
1635
+ </div>
1636
+ </div>
1637
+ <div className="rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3">
1638
+ <div className="flex min-h-5 items-center gap-0.5">
1639
+ <span className="text-xs uppercase text-zinc-500">
1640
+ {t.summaryLinesWrittenHuman}
1641
+ </span>
1642
+ <InlineMetricHelpTrigger
1643
+ ariaLabel={t.metricHelpLinesHumanAria}
1644
+ body={t.metricHelpLinesHumanBody}
1645
+ />
1646
+ </div>
1647
+ <div className="mt-1 text-xl font-semibold tabular-nums text-emerald-400/90">
1648
+ {agg.linesWrittenHumanSum}
1649
+ </div>
1650
+ </div>
1651
+ <div className="rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3">
1652
+ <div className="flex min-h-5 items-center gap-0.5">
1653
+ <span className="text-xs uppercase text-zinc-500">
1654
+ {t.summaryLinesWrittenAi}
1655
+ </span>
1656
+ <InlineMetricHelpTrigger
1657
+ align="end"
1658
+ ariaLabel={t.metricHelpLinesAiAria}
1659
+ body={t.metricHelpLinesAiBody}
1660
+ />
1661
+ </div>
1662
+ <div className="mt-1 text-xl font-semibold tabular-nums text-violet-400/90">
1663
+ {agg.linesWrittenAiSum}
1664
+ </div>
1665
+ </div>
1666
+ </div>
1667
+ <div className="grid gap-6 lg:grid-cols-2">
1668
+ <div>
1669
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
1670
+ <h3 className="text-sm font-semibold text-zinc-200">
1671
+ {t.locByLanguageSectionTitle}
1672
+ </h3>
1673
+ <ReportingFilteredBadge
1674
+ active={reportingFiltersActive}
1675
+ label={t.sectionFilteredBadge}
1676
+ titleText={t.sectionFilteredBadgeTitle}
1677
+ />
1678
+ <InlineMetricHelpTrigger
1679
+ ariaLabel={t.metricHelpLocByLangTitleAria}
1680
+ body={t.metricHelpLocByLangTitleBody}
1681
+ />
1682
+ </div>
1683
+ {agg.locByLanguageMerged.length === 0 ? (
1684
+ <p className="mt-3 text-sm text-zinc-500">—</p>
1685
+ ) : (
1686
+ <table className="mt-3 w-full min-w-[12rem] text-left text-sm">
1687
+ <thead className="border-b border-zinc-800 text-xs uppercase text-zinc-500">
1688
+ <tr>
1689
+ <th className="py-2 pr-3 font-medium">
1690
+ <div className="flex min-h-5 items-center gap-0.5">
1691
+ <span>{t.locByLanguageColLang}</span>
1692
+ <InlineMetricHelpTrigger
1693
+ ariaLabel={
1694
+ t.metricHelpAggLocColLangAria
1695
+ }
1696
+ body={t.metricHelpAggLocColLangBody}
1697
+ />
1698
+ </div>
1699
+ </th>
1700
+ <th className="py-2 font-medium tabular-nums">
1701
+ <div className="flex min-h-5 items-center gap-0.5">
1702
+ <span>{t.locByLanguageColLines}</span>
1703
+ <InlineMetricHelpTrigger
1704
+ align="end"
1705
+ ariaLabel={
1706
+ t.metricHelpAggLocColLinesAria
1707
+ }
1708
+ body={t.metricHelpAggLocColLinesBody}
1709
+ />
1710
+ </div>
1711
+ </th>
1712
+ </tr>
1713
+ </thead>
1714
+ <tbody>
1715
+ {agg.locByLanguageMerged.map(([lang, n]) => (
1716
+ <tr
1717
+ key={lang}
1718
+ className="border-b border-zinc-800/80 last:border-0"
1719
+ >
1720
+ <td className="py-2 pr-3 font-mono text-xs text-zinc-400">
1721
+ {lang}
1722
+ </td>
1723
+ <td className="py-2 tabular-nums text-zinc-200">
1724
+ {n}
1725
+ </td>
1726
+ </tr>
1727
+ ))}
1728
+ </tbody>
1729
+ </table>
1730
+ )}
1731
+ </div>
1732
+ <div>
1733
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
1734
+ <h3 className="text-sm font-semibold text-zinc-200">
1735
+ {t.codingSignalsSectionTitle}
1736
+ </h3>
1737
+ <ReportingFilteredBadge
1738
+ active={reportingFiltersActive}
1739
+ label={t.sectionFilteredBadge}
1740
+ titleText={t.sectionFilteredBadgeTitle}
1741
+ />
1742
+ <InlineMetricHelpTrigger
1743
+ ariaLabel={t.metricHelpCodingSignalsTitleAria}
1744
+ body={t.metricHelpCodingSignalsTitleBody}
1745
+ />
1746
+ </div>
1747
+ {agg.codingSignalsByLanguageMerged.length === 0 ? (
1748
+ <p className="mt-3 text-sm text-zinc-500">—</p>
1749
+ ) : (
1750
+ <table className="mt-3 w-full min-w-[12rem] text-left text-sm">
1751
+ <thead className="border-b border-zinc-800 text-xs uppercase text-zinc-500">
1752
+ <tr>
1753
+ <th className="py-2 pr-3 font-medium">
1754
+ <div className="flex min-h-5 items-center gap-0.5">
1755
+ <span>{t.codingSignalsColLang}</span>
1756
+ <InlineMetricHelpTrigger
1757
+ ariaLabel={
1758
+ t.metricHelpAggSigColLangAria
1759
+ }
1760
+ body={t.metricHelpAggSigColLangBody}
1761
+ />
1762
+ </div>
1763
+ </th>
1764
+ <th className="py-2 font-medium tabular-nums">
1765
+ <div className="flex min-h-5 items-center gap-0.5">
1766
+ <span>{t.codingSignalsColCount}</span>
1767
+ <InlineMetricHelpTrigger
1768
+ align="end"
1769
+ ariaLabel={
1770
+ t.metricHelpAggSigColCountAria
1771
+ }
1772
+ body={t.metricHelpAggSigColCountBody}
1773
+ />
1774
+ </div>
1775
+ </th>
1776
+ </tr>
1777
+ </thead>
1778
+ <tbody>
1779
+ {agg.codingSignalsByLanguageMerged.map(
1780
+ ([lang, n]) => (
1781
+ <tr
1782
+ key={lang}
1783
+ className="border-b border-zinc-800/80 last:border-0"
1784
+ >
1785
+ <td className="py-2 pr-3 font-mono text-xs text-zinc-400">
1786
+ {lang}
1787
+ </td>
1788
+ <td className="py-2 tabular-nums text-zinc-200">
1789
+ {n}
1790
+ </td>
1791
+ </tr>
1792
+ ),
1793
+ )}
1794
+ </tbody>
1795
+ </table>
1796
+ )}
1797
+ </div>
1798
+ </div>
1799
+ </section>
1800
+ ) : null}
1801
+
1802
+ {!hasReportingChartData ? (
1803
+ <p className="text-sm text-zinc-500">{t.noRowsInRange}</p>
1804
+ ) : (
1805
+ <>
1806
+ {navigableWeekStarts.length > 1 ? (
1807
+ <section
1808
+ id="report-week-nav"
1809
+ className="mb-8 scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-5"
1810
+ aria-label={t.weekNavAriaLabel}
1811
+ >
1812
+ <div className="mb-2 flex flex-wrap items-center gap-2">
1813
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-400">
1814
+ {t.tocWeekNav}
1815
+ </h2>
1816
+ <ReportingFilteredBadge
1817
+ active={reportingFiltersActive}
1818
+ label={t.sectionFilteredBadge}
1819
+ titleText={t.sectionFilteredBadgeTitle}
1820
+ />
1821
+ </div>
1822
+ <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
1823
+ <p className="max-w-xl text-xs text-zinc-500">
1824
+ {t.weekNavHelpHint}
1825
+ </p>
1826
+ <div className="flex flex-wrap items-center justify-center gap-2 sm:justify-end">
1827
+ <InlineMetricHelpTrigger
1828
+ ariaLabel={t.metricHelpWeekNavAria}
1829
+ body={t.metricHelpWeekNavBody}
1830
+ />
1831
+ <button
1832
+ type="button"
1833
+ className="inline-flex items-center gap-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"
1834
+ disabled={chartWeekNavIndexSafe <= 0}
1835
+ aria-label={t.weekNavPrev}
1836
+ onClick={() =>
1837
+ setChartWeekNavIndex((i) => {
1838
+ const cur =
1839
+ i < 0
1840
+ ? navigableWeekStarts.length - 1
1841
+ : i;
1842
+ return Math.max(0, cur - 1);
1843
+ })
1844
+ }
1845
+ >
1846
+ <ChevronLeft
1847
+ className="h-4 w-4 shrink-0"
1848
+ aria-hidden
1849
+ />
1850
+ {t.weekNavPrev}
1851
+ </button>
1852
+ <span className="min-w-[10rem] px-2 text-center text-sm font-medium tabular-nums text-zinc-100">
1853
+ {formatWeekRangeLabel(
1854
+ navigableWeekStarts[chartWeekNavIndexSafe],
1855
+ reportLocale,
1856
+ )}
1857
+ </span>
1858
+ <button
1859
+ type="button"
1860
+ className="inline-flex items-center gap-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"
1861
+ disabled={
1862
+ chartWeekNavIndexSafe >=
1863
+ navigableWeekStarts.length - 1
1864
+ }
1865
+ aria-label={t.weekNavNext}
1866
+ onClick={() =>
1867
+ setChartWeekNavIndex((i) => {
1868
+ const cur =
1869
+ i < 0
1870
+ ? navigableWeekStarts.length - 1
1871
+ : i;
1872
+ return Math.min(
1873
+ navigableWeekStarts.length - 1,
1874
+ cur + 1,
1875
+ );
1876
+ })
1877
+ }
1878
+ >
1879
+ {t.weekNavNext}
1880
+ <ChevronRight
1881
+ className="h-4 w-4 shrink-0"
1882
+ aria-hidden
1883
+ />
1884
+ </button>
1885
+ </div>
1886
+ </div>
1887
+ </section>
1888
+ ) : null}
1889
+ <section
1890
+ id="report-chart-sessions"
1891
+ className="mb-10 scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
1892
+ >
1893
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
1894
+ <h3 className="text-sm font-semibold text-zinc-200">
1895
+ {t.chartSessionsPerDay}
1896
+ </h3>
1897
+ <ReportingFilteredBadge
1898
+ active={reportingFiltersActive}
1899
+ label={t.sectionFilteredBadge}
1900
+ titleText={t.sectionFilteredBadgeTitle}
1901
+ />
1902
+ <InlineMetricHelpTrigger
1903
+ ariaLabel={t.metricHelpChartSessionsAria}
1904
+ body={t.metricHelpChartSessionsBody}
1905
+ />
1906
+ </div>
1907
+ <div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-zinc-500">
1908
+ <span>{t.legendSessions}</span>
1909
+ <InlineMetricHelpTrigger
1910
+ ariaLabel={t.metricHelpLegendSessionsAria}
1911
+ body={t.metricHelpLegendSessionsBody}
1912
+ />
1913
+ </div>
1914
+ <div className="mt-4">
1915
+ <MiniBars
1916
+ days={reportingDayKeys}
1917
+ values={agg.sessionsByDay}
1918
+ max={peak}
1919
+ className="bg-violet-500/90"
1920
+ undatedLabel={t.undatedLabel}
1921
+ />
1922
+ </div>
1923
+ </section>
1924
+
1925
+ <section
1926
+ id="report-chart-tasks"
1927
+ className="mb-10 scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
1928
+ >
1929
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
1930
+ <h3 className="text-sm font-semibold text-zinc-200">
1931
+ {t.chartTasksByStatusPerDay}
1932
+ </h3>
1933
+ <ReportingFilteredBadge
1934
+ active={reportingFiltersActive}
1935
+ label={t.sectionFilteredBadge}
1936
+ titleText={t.sectionFilteredBadgeTitle}
1937
+ />
1938
+ <InlineMetricHelpTrigger
1939
+ ariaLabel={t.metricHelpChartTasksAria}
1940
+ body={t.metricHelpChartTasksBody}
1941
+ />
1942
+ </div>
1943
+ <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-zinc-500">
1944
+ <div className="inline-flex items-center gap-1">
1945
+ <span className="mr-0.5 inline-block h-2 w-2 shrink-0 rounded-sm bg-emerald-600" />
1946
+ {t.legendDone}
1947
+ <InlineMetricHelpTrigger
1948
+ ariaLabel={t.metricHelpLegendDoneAria}
1949
+ body={t.metricHelpLegendDoneBody}
1950
+ />
1951
+ </div>
1952
+ <div className="inline-flex items-center gap-1">
1953
+ <span className="mr-0.5 inline-block h-2 w-2 shrink-0 rounded-sm bg-amber-500" />
1954
+ {t.legendActive}
1955
+ <InlineMetricHelpTrigger
1956
+ ariaLabel={t.metricHelpLegendActiveAria}
1957
+ body={t.metricHelpLegendActiveBody}
1958
+ />
1959
+ </div>
1960
+ </div>
1961
+ <div className="mt-4">
1962
+ <StackedTaskBars
1963
+ days={reportingDayKeys}
1964
+ done={agg.tasksByDayDone}
1965
+ active={agg.tasksByDayActive}
1966
+ max={peakTasks}
1967
+ undatedLabel={t.undatedLabel}
1968
+ />
1969
+ </div>
1970
+ </section>
1971
+
1972
+ <section
1973
+ id="report-chart-task-time"
1974
+ className="mb-10 scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
1975
+ >
1976
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
1977
+ <h3 className="text-sm font-semibold text-zinc-200">
1978
+ {t.chartTaskTimePerDay}
1979
+ </h3>
1980
+ <ReportingFilteredBadge
1981
+ active={reportingFiltersActive}
1982
+ label={t.sectionFilteredBadge}
1983
+ titleText={t.sectionFilteredBadgeTitle}
1984
+ />
1985
+ <InlineMetricHelpTrigger
1986
+ ariaLabel={t.metricHelpChartTaskTimeAria}
1987
+ body={t.metricHelpChartTaskTimeBody}
1988
+ />
1989
+ </div>
1990
+ <div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-zinc-500">
1991
+ <span>{t.summaryTaskTimeRecorded}</span>
1992
+ <InlineMetricHelpTrigger
1993
+ ariaLabel={t.metricHelpTaskTimeAria}
1994
+ body={t.metricHelpTaskTimeBody}
1995
+ />
1996
+ </div>
1997
+ <div className="mt-4">
1998
+ <MiniBars
1999
+ days={reportingDayKeys}
2000
+ values={agg.taskMinutesByDay}
2001
+ max={peakTaskMinutes}
2002
+ className="bg-sky-500/90"
2003
+ undatedLabel={t.undatedLabel}
2004
+ valueTitle={(m) => formatDuration(m)}
2005
+ />
2006
+ </div>
2007
+ </section>
2008
+
2009
+ <section
2010
+ id="report-chart-session-wall"
2011
+ className="mb-10 scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
2012
+ >
2013
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2014
+ <h3 className="text-sm font-semibold text-zinc-200">
2015
+ {t.chartSessionWallPerDay}
2016
+ </h3>
2017
+ <ReportingFilteredBadge
2018
+ active={reportingFiltersActive}
2019
+ label={t.sectionFilteredBadge}
2020
+ titleText={t.sectionFilteredBadgeTitle}
2021
+ />
2022
+ <InlineMetricHelpTrigger
2023
+ ariaLabel={t.metricHelpChartSessionWallAria}
2024
+ body={t.metricHelpChartSessionWallBody}
2025
+ />
2026
+ </div>
2027
+ <div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-zinc-500">
2028
+ <span>{t.summarySessionWallClock}</span>
2029
+ <InlineMetricHelpTrigger
2030
+ ariaLabel={t.metricHelpSessionWallSummaryAria}
2031
+ body={t.metricHelpSessionWallSummaryBody}
2032
+ />
2033
+ </div>
2034
+ <div className="mt-4">
2035
+ <MiniBars
2036
+ days={reportingDayKeys}
2037
+ values={agg.sessionWallClockMinutesByDay}
2038
+ max={peakSessionWallMinutes}
2039
+ className="bg-fuchsia-500/85"
2040
+ undatedLabel={t.undatedLabel}
2041
+ valueTitle={(m) => formatDuration(m)}
2042
+ />
2043
+ </div>
2044
+ </section>
2045
+
2046
+ <section
2047
+ id="report-daily-table"
2048
+ className="scroll-mt-28 overflow-x-auto rounded-xl border border-zinc-800 bg-zinc-900/50"
2049
+ >
2050
+ <div className="flex flex-wrap items-center gap-2 border-b border-zinc-800 px-4 py-3">
2051
+ <h3 className="text-sm font-semibold text-zinc-200">
2052
+ {t.tocDailyTable}
2053
+ </h3>
2054
+ <ReportingFilteredBadge
2055
+ active={reportingFiltersActive}
2056
+ label={t.sectionFilteredBadge}
2057
+ titleText={t.sectionFilteredBadgeTitle}
2058
+ />
2059
+ </div>
2060
+ <table className="w-full min-w-[48rem] text-left text-sm">
2061
+ <thead className="border-b border-zinc-800 text-xs uppercase text-zinc-500">
2062
+ <tr>
2063
+ <th className="px-4 py-3 font-medium">
2064
+ <div className="flex min-h-5 items-center gap-0.5">
2065
+ <span>{t.tableDay}</span>
2066
+ <InlineMetricHelpTrigger
2067
+ ariaLabel={t.metricHelpTblDayAria}
2068
+ body={t.metricHelpTblDayBody}
2069
+ />
2070
+ </div>
2071
+ </th>
2072
+ <th className="px-4 py-3 font-medium tabular-nums">
2073
+ <div className="flex min-h-5 items-center gap-0.5">
2074
+ <span>{t.tableSessions}</span>
2075
+ <InlineMetricHelpTrigger
2076
+ ariaLabel={t.metricHelpTblSessionsAria}
2077
+ body={t.metricHelpTblSessionsBody}
2078
+ />
2079
+ </div>
2080
+ </th>
2081
+ <th className="px-4 py-3 font-medium tabular-nums">
2082
+ <div className="flex min-h-5 items-center gap-0.5">
2083
+ <span>{t.tableSessionWall}</span>
2084
+ <InlineMetricHelpTrigger
2085
+ ariaLabel={t.metricHelpTblSessionWallAria}
2086
+ body={t.metricHelpTblSessionWallBody}
2087
+ />
2088
+ </div>
2089
+ </th>
2090
+ <th className="px-4 py-3 font-medium tabular-nums">
2091
+ <div className="flex min-h-5 items-center gap-0.5">
2092
+ <span>{t.tableDone}</span>
2093
+ <InlineMetricHelpTrigger
2094
+ ariaLabel={t.metricHelpTblDoneAria}
2095
+ body={t.metricHelpTblDoneBody}
2096
+ />
2097
+ </div>
2098
+ </th>
2099
+ <th className="px-4 py-3 font-medium tabular-nums">
2100
+ <div className="flex min-h-5 items-center gap-0.5">
2101
+ <span>{t.tableActive}</span>
2102
+ <InlineMetricHelpTrigger
2103
+ align="end"
2104
+ ariaLabel={t.metricHelpTblActiveAria}
2105
+ body={t.metricHelpTblActiveBody}
2106
+ />
2107
+ </div>
2108
+ </th>
2109
+ <th className="px-4 py-3 font-medium tabular-nums">
2110
+ <div className="flex min-h-5 items-center gap-0.5">
2111
+ <span>{t.tableTaskTime}</span>
2112
+ <InlineMetricHelpTrigger
2113
+ ariaLabel={t.metricHelpTblTaskTimeAria}
2114
+ body={t.metricHelpTblTaskTimeBody}
2115
+ />
2116
+ </div>
2117
+ </th>
2118
+ <th className="px-4 py-3 font-medium tabular-nums">
2119
+ <div className="flex min-h-5 items-center gap-0.5">
2120
+ <span>{t.tableSessionCoding}</span>
2121
+ <InlineMetricHelpTrigger
2122
+ align="end"
2123
+ ariaLabel={t.metricHelpTblSessionCodingAria}
2124
+ body={t.metricHelpTblSessionCodingBody}
2125
+ />
2126
+ </div>
2127
+ </th>
2128
+ </tr>
2129
+ </thead>
2130
+ <tbody>
2131
+ {reportingDayKeys.map((d) => (
2132
+ <tr
2133
+ key={d}
2134
+ className="border-b border-zinc-800/80 last:border-0"
2135
+ >
2136
+ <td className="px-4 py-2.5 text-zinc-200">
2137
+ {dayLabel(d, t.undatedLabel)}
2138
+ </td>
2139
+ <td className="px-4 py-2.5 tabular-nums text-zinc-300">
2140
+ {agg.sessionsByDay[d] ?? 0}
2141
+ </td>
2142
+ <td className="px-4 py-2.5 tabular-nums text-fuchsia-300/90">
2143
+ {formatMinutesCell(
2144
+ agg.sessionWallClockMinutesByDay[d],
2145
+ )}
2146
+ </td>
2147
+ <td className="px-4 py-2.5 tabular-nums text-emerald-400/90">
2148
+ {agg.tasksByDayDone[d] ?? 0}
2149
+ </td>
2150
+ <td className="px-4 py-2.5 tabular-nums text-amber-400/90">
2151
+ {agg.tasksByDayActive[d] ?? 0}
2152
+ </td>
2153
+ <td className="px-4 py-2.5 tabular-nums text-sky-300/90">
2154
+ {formatMinutesCell(agg.taskMinutesByDay[d])}
2155
+ </td>
2156
+ <td className="px-4 py-2.5 tabular-nums text-zinc-300">
2157
+ {formatMinutesCell(
2158
+ agg.sessionCodingMinutesByDay[d],
2159
+ )}
2160
+ </td>
2161
+ </tr>
2162
+ ))}
2163
+ </tbody>
2164
+ </table>
2165
+ </section>
2166
+
2167
+ <section
2168
+ id="report-tag-time"
2169
+ className="mt-10 scroll-mt-28 space-y-8 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
2170
+ >
2171
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2172
+ <h3 className="text-sm font-semibold text-zinc-200">
2173
+ {t.tagTimeSectionTitle}
2174
+ </h3>
2175
+ <ReportingFilteredBadge
2176
+ active={reportingFiltersActive}
2177
+ label={t.sectionFilteredBadge}
2178
+ titleText={t.sectionFilteredBadgeTitle}
2179
+ />
2180
+ <InlineMetricHelpTrigger
2181
+ ariaLabel={t.metricHelpTagTimeSectionAria}
2182
+ body={t.metricHelpTagTimeSectionBody}
2183
+ />
2184
+ </div>
2185
+ <p className="text-xs text-zinc-500">
2186
+ {t.tagTimeSectionHint}
2187
+ </p>
2188
+
2189
+ <div id="report-tag-time-day">
2190
+ <div className="flex min-h-6 items-center gap-0.5">
2191
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-400">
2192
+ {t.tagTimeByDayTitle}
2193
+ </h4>
2194
+ <InlineMetricHelpTrigger
2195
+ ariaLabel={t.metricHelpTagTimeDayTableAria}
2196
+ body={t.metricHelpTagTimeDayTableBody}
2197
+ />
2198
+ </div>
2199
+ {tagTimeSplit.byDay.length === 0 ? (
2200
+ <p className="mt-3 text-sm text-zinc-500">—</p>
2201
+ ) : (
2202
+ <div className="mt-3 overflow-x-auto">
2203
+ <table className="w-full min-w-[22rem] text-left text-sm">
2204
+ <thead className="border-b border-zinc-800 text-xs uppercase text-zinc-500">
2205
+ <tr>
2206
+ <th className="py-2 pr-3 font-medium">
2207
+ <div className="flex min-h-5 items-center gap-0.5">
2208
+ <span>{t.tagTimeColTag}</span>
2209
+ <InlineMetricHelpTrigger
2210
+ ariaLabel={
2211
+ t.metricHelpTagTimeColTagAria
2212
+ }
2213
+ body={t.metricHelpTagTimeColTagBody}
2214
+ />
2215
+ </div>
2216
+ </th>
2217
+ <th className="py-2 pr-3 font-medium tabular-nums">
2218
+ <div className="flex min-h-5 items-center gap-0.5">
2219
+ <span>{t.tagTimeColDay}</span>
2220
+ <InlineMetricHelpTrigger
2221
+ ariaLabel={
2222
+ t.metricHelpTagTimeColDayAria
2223
+ }
2224
+ body={t.metricHelpTagTimeColDayBody}
2225
+ />
2226
+ </div>
2227
+ </th>
2228
+ <th className="py-2 font-medium tabular-nums">
2229
+ <div className="flex min-h-5 items-center gap-0.5">
2230
+ <span>{t.tagTimeColMinutes}</span>
2231
+ <InlineMetricHelpTrigger
2232
+ align="end"
2233
+ ariaLabel={
2234
+ t.metricHelpTagTimeColMinutesAria
2235
+ }
2236
+ body={
2237
+ t.metricHelpTagTimeColMinutesBody
2238
+ }
2239
+ />
2240
+ </div>
2241
+ </th>
2242
+ </tr>
2243
+ </thead>
2244
+ <tbody>
2245
+ {groupTagDayRowsForDisplay(
2246
+ tagTimeSplit.byDay,
2247
+ ).flatMap(({ day, blocks }) =>
2248
+ blocks.flatMap((block) => {
2249
+ if (block.kind === "leaf") {
2250
+ const row = block.row;
2251
+ return [
2252
+ <tr
2253
+ key={`${row.tagKey}\0${row.day}`}
2254
+ className="border-b border-zinc-800/80 last:border-0"
2255
+ >
2256
+ <ReportingTagNameCell
2257
+ tagKey={row.tagKey}
2258
+ displayLabel={
2259
+ row.displayTag || row.tagKey
2260
+ }
2261
+ untaggedLabel={t.tagTimeUntagged}
2262
+ descriptions={tagDescriptions}
2263
+ className="py-2 pr-3 text-violet-200/90"
2264
+ />
2265
+ <td className="py-2 pr-3 tabular-nums text-zinc-300">
2266
+ {dayLabel(
2267
+ row.day,
2268
+ t.undatedLabel,
2269
+ )}
2270
+ </td>
2271
+ <td className="py-2 tabular-nums text-zinc-200">
2272
+ {formatDuration(row.minutes)}
2273
+ </td>
2274
+ </tr>,
2275
+ ];
2276
+ }
2277
+ const rollupKey = `${day}:::${block.projectKeyLower}`;
2278
+ const open =
2279
+ tagDayRollupOpenKeys.has(rollupKey);
2280
+ const rollupDesc =
2281
+ reportingProjectDescriptionLine(
2282
+ block.projectKeyLower,
2283
+ projectDescriptions,
2284
+ ) ?? "";
2285
+ const rollupHead = (
2286
+ <tr
2287
+ key={`${rollupKey}-rollup`}
2288
+ className="border-b border-zinc-800/80 bg-zinc-900/40"
2289
+ >
2290
+ <td className="py-2 pr-3 align-top text-violet-200/90">
2291
+ <div className="flex items-start gap-1.5">
2292
+ <button
2293
+ type="button"
2294
+ aria-expanded={
2295
+ open ? "true" : "false"
2296
+ }
2297
+ aria-label={
2298
+ t.tagWeekScopedRollupToggleAria
2299
+ }
2300
+ className="mt-0.5 shrink-0 rounded p-0.5 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
2301
+ onClick={() =>
2302
+ toggleTagDayRollup(rollupKey)
2303
+ }
2304
+ >
2305
+ <ChevronRight
2306
+ className={`size-4 transition-transform duration-200 ${
2307
+ open ? "rotate-90" : ""
2308
+ }`}
2309
+ strokeWidth={2}
2310
+ aria-hidden
2311
+ />
2312
+ </button>
2313
+ <div className="min-w-0">
2314
+ <div className="flex flex-wrap items-baseline gap-x-1.5 font-medium">
2315
+ <span>
2316
+ @{block.displayProject}
2317
+ </span>
2318
+ <span className="text-[0.65rem] font-normal tabular-nums text-zinc-500">
2319
+ ({block.children.length})
2320
+ </span>
2321
+ </div>
2322
+ {rollupDesc ? (
2323
+ <p className="mt-1 max-w-[min(22rem,55vw)] whitespace-pre-line text-[0.65rem] font-normal leading-snug text-zinc-500">
2324
+ {rollupDesc}
2325
+ </p>
2326
+ ) : null}
2327
+ </div>
2328
+ </div>
2329
+ </td>
2330
+ <td className="py-2 pr-3 tabular-nums text-zinc-300">
2331
+ {dayLabel(day, t.undatedLabel)}
2332
+ </td>
2333
+ <td className="py-2 tabular-nums text-zinc-200">
2334
+ {formatDuration(
2335
+ block.parentMinutes,
2336
+ )}
2337
+ </td>
2338
+ </tr>
2339
+ );
2340
+ if (!open) {
2341
+ return [rollupHead];
2342
+ }
2343
+ return [
2344
+ rollupHead,
2345
+ ...block.children.map((child) => (
2346
+ <tr
2347
+ key={`${child.tagKey}\0${child.day}`}
2348
+ className="border-b border-zinc-800/55 bg-zinc-950/30"
2349
+ >
2350
+ <ReportingTagNameCell
2351
+ tagKey={child.tagKey}
2352
+ displayLabel={
2353
+ child.displayTag || child.tagKey
2354
+ }
2355
+ untaggedLabel={t.tagTimeUntagged}
2356
+ descriptions={tagDescriptions}
2357
+ className="py-2 pr-3 pl-8 text-violet-200/85"
2358
+ />
2359
+ <td className="py-2 pr-3 tabular-nums text-zinc-300/95">
2360
+ {dayLabel(
2361
+ child.day,
2362
+ t.undatedLabel,
2363
+ )}
2364
+ </td>
2365
+ <td className="py-2 tabular-nums text-zinc-300/95">
2366
+ {formatDuration(child.minutes)}
2367
+ </td>
2368
+ </tr>
2369
+ )),
2370
+ ];
2371
+ }),
2372
+ )}
2373
+ </tbody>
2374
+ </table>
2375
+ </div>
2376
+ )}
2377
+ </div>
2378
+
2379
+ <div id="report-tag-time-week" className="space-y-4">
2380
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2381
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-400">
2382
+ {t.tagTimeByWeekTitle}
2383
+ </h4>
2384
+ <InlineMetricHelpTrigger
2385
+ ariaLabel={t.metricHelpTagTimeWeekTableAria}
2386
+ body={t.metricHelpTagTimeWeekTableBody}
2387
+ />
2388
+ <InlineMetricHelpTrigger
2389
+ ariaLabel={t.metricHelpTagWeekCalendarAria}
2390
+ body={t.metricHelpTagWeekCalendarBody}
2391
+ />
2392
+ </div>
2393
+
2394
+ <fieldset className="rounded-lg border border-zinc-800/90 bg-zinc-950/30 p-3">
2395
+ <legend className="px-1 text-xs text-zinc-500">
2396
+ <span className="inline-flex flex-wrap items-center gap-1">
2397
+ {t.tagWeekStartsOnLegend}
2398
+ <InlineMetricHelpTrigger
2399
+ ariaLabel={t.metricHelpTagWeekStartsOnAria}
2400
+ body={t.metricHelpTagWeekStartsOnBody}
2401
+ />
2402
+ </span>
2403
+ </legend>
2404
+ <div
2405
+ className="mt-2 flex flex-wrap gap-2"
2406
+ role="radiogroup"
2407
+ aria-label={t.tagWeekStartsOnLegend}
2408
+ >
2409
+ {(
2410
+ [
2411
+ ["monday", t.weekStartsMonday],
2412
+ ["sunday", t.weekStartsSunday],
2413
+ ["saturday", t.weekStartsSaturday],
2414
+ ] as const
2415
+ ).map(([value, label]) => (
2416
+ <button
2417
+ key={value}
2418
+ type="button"
2419
+ role="radio"
2420
+ aria-checked={weekStartsOn === value}
2421
+ onClick={() => persistWeekStartsOn(value)}
2422
+ className={`rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
2423
+ weekStartsOn === value
2424
+ ? "border-violet-500/80 bg-violet-950/50 text-violet-200"
2425
+ : "border-zinc-700 bg-zinc-900/60 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200"
2426
+ }`}
2427
+ >
2428
+ {label}
2429
+ </button>
2430
+ ))}
2431
+ </div>
2432
+ </fieldset>
2433
+
2434
+ {tagWeekCalendarRows.length === 0 ? (
2435
+ <p className="text-sm text-zinc-500">—</p>
2436
+ ) : tagWeekCalendarRowsVisible.length === 0 ? (
2437
+ <div className="space-y-2 text-sm text-zinc-500">
2438
+ <p>{t.weekNavNoTagDataThisWeek}</p>
2439
+ {archivedExcludedTaskMinutes > 1e-9 ? (
2440
+ <p className="text-amber-200/90">
2441
+ {reportingArchivedExcludedRichText(
2442
+ t.reportingArchivedExcludedAside,
2443
+ archivedExcludedTaskMinutes,
2444
+ )}
2445
+ </p>
2446
+ ) : null}
2447
+ </div>
2448
+ ) : (
2449
+ <div className="space-y-8">
2450
+ {tagCalendarWeekGroups.map(
2451
+ ({ weekStart, rows }) => (
2452
+ <div key={weekStart}>
2453
+ <div className="mb-2 flex flex-wrap items-center gap-2">
2454
+ <p className="text-sm font-medium text-zinc-300">
2455
+ {formatWeekRangeLabel(
2456
+ weekStart,
2457
+ reportLocale,
2458
+ )}
2459
+ </p>
2460
+ <InlineMetricHelpTrigger
2461
+ ariaLabel={
2462
+ t.metricHelpTagTimeColWeekAria
2463
+ }
2464
+ body={t.metricHelpTagTimeColWeekBody}
2465
+ />
2466
+ </div>
2467
+ <div className="overflow-x-auto rounded-lg border border-zinc-800/80">
2468
+ <table className="w-full min-w-[28rem] text-left text-sm">
2469
+ <thead className="border-b border-zinc-800 bg-zinc-950/40 text-xs uppercase text-zinc-500">
2470
+ <tr>
2471
+ <th className="px-2 py-2 pl-3 font-medium">
2472
+ <div className="flex min-h-5 items-center gap-0.5">
2473
+ <span>{t.tagTimeColTag}</span>
2474
+ <InlineMetricHelpTrigger
2475
+ ariaLabel={
2476
+ t.metricHelpTagTimeColTagAria
2477
+ }
2478
+ body={
2479
+ t.metricHelpTagTimeColTagBody
2480
+ }
2481
+ />
2482
+ </div>
2483
+ </th>
2484
+ {weekdayDateColumnHeaders(
2485
+ weekStart,
2486
+ reportLocale,
2487
+ ).map((col) => (
2488
+ <th
2489
+ key={col.dateKey}
2490
+ className="px-1 py-2 text-center font-medium normal-case"
2491
+ title={col.dateKey}
2492
+ >
2493
+ <div className="flex flex-col items-center gap-0.5 leading-tight">
2494
+ <span className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500">
2495
+ {col.weekdayShort}
2496
+ </span>
2497
+ <span className="text-[0.7rem] font-medium tabular-nums text-zinc-300">
2498
+ {col.calendarDateShort}
2499
+ </span>
2500
+ </div>
2501
+ </th>
2502
+ ))}
2503
+ <th className="px-2 py-2 pr-3 text-center font-medium">
2504
+ <div className="flex min-h-5 items-center justify-center gap-0.5">
2505
+ <span>
2506
+ {t.tagWeekSumColumn}
2507
+ </span>
2508
+ <InlineMetricHelpTrigger
2509
+ align="end"
2510
+ ariaLabel={
2511
+ t.metricHelpTagTimeColMinutesAria
2512
+ }
2513
+ body={
2514
+ t.metricHelpTagTimeColMinutesBody
2515
+ }
2516
+ />
2517
+ </div>
2518
+ </th>
2519
+ </tr>
2520
+ </thead>
2521
+ <tbody>
2522
+ {buildTagWeekDisplayBlocks(rows).map(
2523
+ (block) => {
2524
+ if (block.kind === "leaf") {
2525
+ const row = block.row;
2526
+ return (
2527
+ <tr
2528
+ key={`${weekStart}\0${row.tagKey}`}
2529
+ className="border-b border-zinc-800/70 last:border-0"
2530
+ >
2531
+ <ReportingTagNameCell
2532
+ tagKey={row.tagKey}
2533
+ displayLabel={
2534
+ row.displayTag ||
2535
+ row.tagKey
2536
+ }
2537
+ untaggedLabel={
2538
+ t.tagTimeUntagged
2539
+ }
2540
+ descriptions={
2541
+ tagDescriptions
2542
+ }
2543
+ className="px-2 py-2 pl-3 text-violet-200/90"
2544
+ />
2545
+ {row.slots.map(
2546
+ (mins, i) => {
2547
+ const dateKey =
2548
+ addDaysYmd(
2549
+ row.weekStart,
2550
+ i,
2551
+ );
2552
+ return (
2553
+ <td
2554
+ key={dateKey}
2555
+ className="px-1 py-2 text-center tabular-nums text-zinc-200"
2556
+ title={dateKey}
2557
+ >
2558
+ {mins > 0
2559
+ ? formatDuration(
2560
+ mins,
2561
+ )
2562
+ : "—"}
2563
+ </td>
2564
+ );
2565
+ },
2566
+ )}
2567
+ <td className="px-2 py-2 pr-3 text-center tabular-nums font-semibold text-zinc-100">
2568
+ {formatDuration(
2569
+ row.total,
2570
+ )}
2571
+ </td>
2572
+ </tr>
2573
+ );
2574
+ }
2575
+ const rollupKey = `${weekStart}:::${block.projectKeyLower}`;
2576
+ const open =
2577
+ tagWeekRollupOpenKeys.has(
2578
+ rollupKey,
2579
+ );
2580
+ const rollupDesc =
2581
+ reportingProjectDescriptionLine(
2582
+ block.projectKeyLower,
2583
+ projectDescriptions,
2584
+ ) ?? "";
2585
+ return (
2586
+ <Fragment key={rollupKey}>
2587
+ <tr className="border-b border-zinc-800/70 bg-zinc-900/40">
2588
+ <td className="px-2 py-2 pl-3 align-top text-violet-200/90">
2589
+ <div className="flex items-start gap-1.5">
2590
+ <button
2591
+ type="button"
2592
+ aria-expanded={
2593
+ open
2594
+ ? "true"
2595
+ : "false"
2596
+ }
2597
+ aria-label={
2598
+ t.tagWeekScopedRollupToggleAria
2599
+ }
2600
+ className="mt-0.5 shrink-0 rounded p-0.5 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
2601
+ onClick={() =>
2602
+ toggleTagWeekRollup(
2603
+ rollupKey,
2604
+ )
2605
+ }
2606
+ >
2607
+ <ChevronRight
2608
+ className={`size-4 transition-transform duration-200 ${
2609
+ open
2610
+ ? "rotate-90"
2611
+ : ""
2612
+ }`}
2613
+ strokeWidth={2}
2614
+ aria-hidden
2615
+ />
2616
+ </button>
2617
+ <div className="min-w-0">
2618
+ <div className="flex flex-wrap items-baseline gap-x-1.5 font-medium">
2619
+ <span>
2620
+ @
2621
+ {
2622
+ block.displayProject
2623
+ }
2624
+ </span>
2625
+ <span className="text-[0.65rem] font-normal tabular-nums text-zinc-500">
2626
+ (
2627
+ {
2628
+ block.children
2629
+ .length
2630
+ }
2631
+ )
2632
+ </span>
2633
+ </div>
2634
+ {rollupDesc ? (
2635
+ <p className="mt-1 max-w-[min(22rem,55vw)] whitespace-pre-line text-[0.65rem] font-normal leading-snug text-zinc-500">
2636
+ {rollupDesc}
2637
+ </p>
2638
+ ) : null}
2639
+ </div>
2640
+ </div>
2641
+ </td>
2642
+ {block.parentSlots.map(
2643
+ (mins, i) => {
2644
+ const dateKey =
2645
+ addDaysYmd(
2646
+ block.weekStart,
2647
+ i,
2648
+ );
2649
+ return (
2650
+ <td
2651
+ key={dateKey}
2652
+ className="px-1 py-2 text-center tabular-nums text-zinc-200"
2653
+ title={dateKey}
2654
+ >
2655
+ {mins > 0
2656
+ ? formatDuration(
2657
+ mins,
2658
+ )
2659
+ : "—"}
2660
+ </td>
2661
+ );
2662
+ },
2663
+ )}
2664
+ <td className="px-2 py-2 pr-3 text-center tabular-nums font-semibold text-zinc-100">
2665
+ {formatDuration(
2666
+ block.parentTotal,
2667
+ )}
2668
+ </td>
2669
+ </tr>
2670
+ {open
2671
+ ? block.children.map(
2672
+ (child) => (
2673
+ <tr
2674
+ key={`${weekStart}\0${child.tagKey}`}
2675
+ className="border-b border-zinc-800/55 bg-zinc-950/30"
2676
+ >
2677
+ <ReportingTagNameCell
2678
+ tagKey={
2679
+ child.tagKey
2680
+ }
2681
+ displayLabel={
2682
+ child.displayTag ||
2683
+ child.tagKey
2684
+ }
2685
+ untaggedLabel={
2686
+ t.tagTimeUntagged
2687
+ }
2688
+ descriptions={
2689
+ tagDescriptions
2690
+ }
2691
+ className="px-2 py-2 pl-10 text-violet-200/85"
2692
+ />
2693
+ {child.slots.map(
2694
+ (mins, i) => {
2695
+ const dateKey =
2696
+ addDaysYmd(
2697
+ child.weekStart,
2698
+ i,
2699
+ );
2700
+ return (
2701
+ <td
2702
+ key={
2703
+ dateKey
2704
+ }
2705
+ className="px-1 py-2 text-center tabular-nums text-zinc-300/95"
2706
+ title={
2707
+ dateKey
2708
+ }
2709
+ >
2710
+ {mins > 0
2711
+ ? formatDuration(
2712
+ mins,
2713
+ )
2714
+ : "—"}
2715
+ </td>
2716
+ );
2717
+ },
2718
+ )}
2719
+ <td className="px-2 py-2 pr-3 text-center tabular-nums font-medium text-zinc-200">
2720
+ {formatDuration(
2721
+ child.total,
2722
+ )}
2723
+ </td>
2724
+ </tr>
2725
+ ),
2726
+ )
2727
+ : null}
2728
+ </Fragment>
2729
+ );
2730
+ },
2731
+ )}
2732
+ </tbody>
2733
+ </table>
2734
+ </div>
2735
+ </div>
2736
+ ),
2737
+ )}
2738
+ </div>
2739
+ )}
2740
+ </div>
2741
+ </section>
2742
+
2743
+ <section
2744
+ id="report-projects"
2745
+ className="mt-10 scroll-mt-28 space-y-4 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
2746
+ >
2747
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2748
+ <h3 className="text-sm font-semibold text-zinc-200">
2749
+ {t.projectSectionTitle}
2750
+ </h3>
2751
+ <ReportingFilteredBadge
2752
+ active={reportingFiltersActive}
2753
+ label={t.sectionFilteredBadge}
2754
+ titleText={t.sectionFilteredBadgeTitle}
2755
+ />
2756
+ <InlineMetricHelpTrigger
2757
+ ariaLabel={t.metricHelpProjectTitleAria}
2758
+ body={t.metricHelpProjectTitleBody}
2759
+ />
2760
+ </div>
2761
+ <p className="text-xs text-zinc-500">
2762
+ {t.projectTableHint}
2763
+ </p>
2764
+
2765
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2766
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-400">
2767
+ {t.projectWeeklyCalendarTitle}
2768
+ </h4>
2769
+ <InlineMetricHelpTrigger
2770
+ ariaLabel={t.metricHelpProjectCalendarAria}
2771
+ body={t.metricHelpProjectCalendarBody}
2772
+ />
2773
+ </div>
2774
+
2775
+ {projectWeekCalendarRows.length === 0 ? (
2776
+ <p className="text-sm text-zinc-500">—</p>
2777
+ ) : projectWeekCalendarRowsVisible.length === 0 ? (
2778
+ <div className="space-y-2 text-sm text-zinc-500">
2779
+ <p>{t.weekNavNoProjectDataThisWeek}</p>
2780
+ {archivedExcludedTaskMinutes > 1e-9 ? (
2781
+ <p className="text-amber-200/90">
2782
+ {reportingArchivedExcludedRichText(
2783
+ t.reportingArchivedExcludedAside,
2784
+ archivedExcludedTaskMinutes,
2785
+ )}
2786
+ </p>
2787
+ ) : null}
2788
+ </div>
2789
+ ) : (
2790
+ <div className="space-y-8">
2791
+ {projectCalendarWeekGroups.map(
2792
+ ({ weekStart, rows }) => (
2793
+ <div key={weekStart}>
2794
+ <div className="mb-2 flex flex-wrap items-center gap-2">
2795
+ <p className="text-sm font-medium text-zinc-300">
2796
+ {formatWeekRangeLabel(
2797
+ weekStart,
2798
+ reportLocale,
2799
+ )}
2800
+ </p>
2801
+ <InlineMetricHelpTrigger
2802
+ ariaLabel={t.metricHelpTagTimeColWeekAria}
2803
+ body={t.metricHelpTagTimeColWeekBody}
2804
+ />
2805
+ </div>
2806
+ <div className="overflow-x-auto rounded-lg border border-zinc-800/80">
2807
+ <table className="w-full min-w-[28rem] text-left text-sm">
2808
+ <thead className="border-b border-zinc-800 bg-zinc-950/40 text-xs uppercase text-zinc-500">
2809
+ <tr>
2810
+ <th className="px-2 py-2 pl-3 font-medium">
2811
+ <div className="flex min-h-5 items-center gap-0.5">
2812
+ <span>{t.projectColProject}</span>
2813
+ <InlineMetricHelpTrigger
2814
+ ariaLabel={
2815
+ t.metricHelpProjectColProjAria
2816
+ }
2817
+ body={
2818
+ t.metricHelpProjectColProjBody
2819
+ }
2820
+ />
2821
+ </div>
2822
+ </th>
2823
+ {weekdayDateColumnHeaders(
2824
+ weekStart,
2825
+ reportLocale,
2826
+ ).map((col) => (
2827
+ <th
2828
+ key={`proj-${col.dateKey}`}
2829
+ className="px-1 py-2 text-center font-medium normal-case"
2830
+ title={col.dateKey}
2831
+ >
2832
+ <div className="flex flex-col items-center gap-0.5 leading-tight">
2833
+ <span className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500">
2834
+ {col.weekdayShort}
2835
+ </span>
2836
+ <span className="text-[0.7rem] font-medium tabular-nums text-zinc-300">
2837
+ {col.calendarDateShort}
2838
+ </span>
2839
+ </div>
2840
+ </th>
2841
+ ))}
2842
+ <th className="px-2 py-2 pr-3 text-center font-medium">
2843
+ <div className="flex min-h-5 items-center justify-center gap-0.5">
2844
+ <span>{t.tagWeekSumColumn}</span>
2845
+ <InlineMetricHelpTrigger
2846
+ align="end"
2847
+ ariaLabel={
2848
+ t.metricHelpProjectColTimeAria
2849
+ }
2850
+ body={
2851
+ t.metricHelpProjectColTimeBody
2852
+ }
2853
+ />
2854
+ </div>
2855
+ </th>
2856
+ </tr>
2857
+ </thead>
2858
+ <tbody>
2859
+ {rows.map((row) => (
2860
+ <tr
2861
+ key={`${weekStart}\0${row.projectKey}`}
2862
+ className="border-b border-zinc-800/70 last:border-0"
2863
+ >
2864
+ <ReportingProjectNameCell
2865
+ projectKey={row.projectKey}
2866
+ displayLabel={
2867
+ row.displayProject ||
2868
+ row.projectKey
2869
+ }
2870
+ unassignedLabel={
2871
+ t.projectUnassigned
2872
+ }
2873
+ descriptions={projectDescriptions}
2874
+ className="px-2 py-2 pl-3 text-sky-200/90"
2875
+ />
2876
+ {row.slots.map((mins, i) => {
2877
+ const dateKey = addDaysYmd(
2878
+ row.weekStart,
2879
+ i,
2880
+ );
2881
+ return (
2882
+ <td
2883
+ key={dateKey}
2884
+ className="px-1 py-2 text-center tabular-nums text-zinc-200"
2885
+ title={dateKey}
2886
+ >
2887
+ {mins > 0
2888
+ ? formatDuration(mins)
2889
+ : "—"}
2890
+ </td>
2891
+ );
2892
+ })}
2893
+ <td className="px-2 py-2 pr-3 text-center tabular-nums font-semibold text-zinc-100">
2894
+ {formatDuration(row.total)}
2895
+ </td>
2896
+ </tr>
2897
+ ))}
2898
+ </tbody>
2899
+ </table>
2900
+ </div>
2901
+ </div>
2902
+ ),
2903
+ )}
2904
+ </div>
2905
+ )}
2906
+ </section>
2907
+ </>
2908
+ )}
2909
+ </>
2910
+ )}
2911
+ </div>
2912
+ <ReportingPageTocDesktop
2913
+ title={t.tocTitle}
2914
+ ariaLabel={t.tocNavAria}
2915
+ entries={reportingTocEntries}
2916
+ />
2917
+ </div>
2918
+ )}
2919
+ </div>
2920
+ <ScrollToTopFab ariaLabel={t.scrollToTopAria} />
2921
+ <ReportingTour
2922
+ open={reportingTourOpen}
2923
+ onOpenChange={setReportingTourOpen}
2924
+ dt={dt}
2925
+ hasReportingChartData={hasReportingChartData}
2926
+ />
2927
+ </div>
2928
+ );
2929
+ }
2930
+
2931
+ export default function ReportingPage() {
2932
+ return (
2933
+ <Suspense
2934
+ fallback={
2935
+ <div className="min-h-screen bg-zinc-100 px-6 py-10 text-sm text-zinc-500 dark:bg-zinc-900">
2936
+ Kronosys…
2937
+ </div>
2938
+ }
2939
+ >
2940
+ <ReportingContent />
2941
+ </Suspense>
2942
+ );
2943
+ }