@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21

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 (112) hide show
  1. package/README.md +28 -1
  2. package/app/api/action/route.ts +39 -3
  3. package/app/api/action-logs/route.ts +24 -0
  4. package/app/api/backup/route.ts +1 -1
  5. package/app/api/restore/route.ts +145 -0
  6. package/app/changelog/page.tsx +71 -4
  7. package/app/globals.css +127 -0
  8. package/app/guide/page.tsx +61 -15
  9. package/app/implementation/page.tsx +700 -0
  10. package/app/layout.tsx +14 -3
  11. package/app/licenses/page.tsx +99 -37
  12. package/app/logs/page.tsx +258 -0
  13. package/app/manifest.ts +5 -5
  14. package/app/page.tsx +784 -229
  15. package/app/reporting/page.tsx +1266 -474
  16. package/app/settings/page.tsx +252 -18
  17. package/bin/kronosys.mjs +140 -15
  18. package/components/KronosysPayloadProvider.tsx +2 -0
  19. package/components/RouteTransition.tsx +18 -0
  20. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
  21. package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
  22. package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
  23. package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
  24. package/components/dashboard/AppShellRouteNav.tsx +323 -48
  25. package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
  26. package/components/dashboard/DashboardSimpleModal.tsx +168 -25
  27. package/components/dashboard/DashboardTour.tsx +115 -29
  28. package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
  29. package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
  30. package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
  31. package/components/dashboard/NewSessionScopeModal.tsx +211 -20
  32. package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
  33. package/components/dashboard/ReportingTour.tsx +87 -21
  34. package/components/dashboard/SavedProjectPicker.tsx +16 -3
  35. package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
  36. package/components/dashboard/SessionListPanel.tsx +327 -44
  37. package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
  38. package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
  39. package/components/dashboard/SettingsTour.tsx +86 -21
  40. package/components/dashboard/TagPills.tsx +14 -1
  41. package/components/dashboard/TaskFocusPanel.tsx +1081 -478
  42. package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
  43. package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
  44. package/components/dashboard/taskFieldStyles.ts +20 -4
  45. package/components/dashboard/useReportingInteractionState.ts +80 -0
  46. package/lib/appShellHeaderClasses.ts +13 -0
  47. package/lib/businessRulesMatrix.ts +210 -0
  48. package/lib/copyToClipboard.ts +43 -0
  49. package/lib/dashboardCopy.ts +494 -84
  50. package/lib/dashboardQuickSearch.ts +54 -2
  51. package/lib/dashboardTimeZone.ts +109 -0
  52. package/lib/formatAppShellWallClock.ts +66 -0
  53. package/lib/formatSessionNameTemplate.ts +141 -0
  54. package/lib/generatedUserChangelog.ts +177 -6
  55. package/lib/globalPausePreview.ts +292 -0
  56. package/lib/implementationNotes.ts +1188 -0
  57. package/lib/kronosysApi.ts +6 -0
  58. package/lib/kronosysDashboardModalGates.ts +24 -0
  59. package/lib/plannedBoundaryAttention.ts +9 -0
  60. package/lib/plannedBoundaryConflict.ts +23 -0
  61. package/lib/reportingAggregate.ts +517 -75
  62. package/lib/reportingMetricHelp.ts +8 -0
  63. package/lib/reportingStrings.ts +37 -3
  64. package/lib/sessionListMerge.ts +4 -0
  65. package/lib/sessionTaskSidebarStats.ts +182 -21
  66. package/lib/settingsCopy.ts +178 -4
  67. package/lib/taskParsing.ts +360 -103
  68. package/lib/taskTemplateDraft.ts +135 -0
  69. package/lib/taskTimelineGantt.ts +265 -0
  70. package/lib/temporalDisplayPlanned.ts +71 -0
  71. package/lib/userGuideCopy.ts +121 -47
  72. package/next.config.ts +7 -0
  73. package/package.json +12 -24
  74. package/server/actionDispatch.ts +1000 -77
  75. package/server/actionTaskSession.ts +337 -24
  76. package/server/db.ts +7 -15
  77. package/server/dbSchema.ts +24 -0
  78. package/server/defaultCfg.ts +5 -0
  79. package/server/gitlabTokenStore.ts +0 -12
  80. package/server/liveHistorySync.ts +53 -0
  81. package/server/mainTimerHydrate.ts +38 -2
  82. package/server/payloadStore.ts +33 -11
  83. package/server/sessionWallHydrate.ts +66 -3
  84. package/server/userActionLog.ts +126 -0
  85. package/sonar-project.properties +11 -0
  86. package/tsconfig.json +2 -1
  87. package/components/dashboard/IssuePickerModal.tsx +0 -168
  88. package/components/dashboard/ThemeToggle.test.tsx +0 -26
  89. package/lib/backupCsvExport.test.ts +0 -149
  90. package/lib/dashboardQuickSearchQuery.test.ts +0 -63
  91. package/lib/dataDir.test.ts +0 -87
  92. package/lib/formatIsoShort.test.ts +0 -46
  93. package/lib/kronoFocusRhythm.test.ts +0 -130
  94. package/lib/kronoFocusTimerUrgency.test.ts +0 -74
  95. package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
  96. package/lib/reportingAggregate.test.ts +0 -325
  97. package/lib/reportingNonFinalIndicators.test.ts +0 -157
  98. package/lib/reportingTagWeekBreakdown.test.ts +0 -141
  99. package/lib/reportingWeekLayout.test.ts +0 -239
  100. package/lib/sessionAssiduity.test.ts +0 -25
  101. package/lib/sessionEndWarnings.test.ts +0 -200
  102. package/lib/sessionListMerge.test.ts +0 -101
  103. package/lib/sessionTaskSidebarStats.test.ts +0 -24
  104. package/lib/taskParsing.test.ts +0 -153
  105. package/lib/usageProfile.test.ts +0 -84
  106. package/server/actionDispatch.test.ts +0 -723
  107. package/server/actionTaskSession.test.ts +0 -713
  108. package/server/kronoFocusHydrate.test.ts +0 -142
  109. package/server/kronoFocusMigrate.test.ts +0 -53
  110. package/server/mainTimerHydrate.test.ts +0 -65
  111. package/server/payloadStore.test.ts +0 -78
  112. package/server/sessionWallHydrate.test.ts +0 -46
@@ -9,6 +9,7 @@ import {
9
9
  useMemo,
10
10
  useState,
11
11
  } from "react";
12
+ import { createPortal } from "react-dom";
12
13
  import Link from "next/link";
13
14
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
14
15
  import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -18,9 +19,11 @@ import {
18
19
  } from "@/lib/kronosysApi";
19
20
  import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
20
21
  import {
22
+ DEFAULT_FALLBACK_TASK_TAG,
21
23
  formatDuration,
22
24
  formatTagDisplay,
23
25
  isFallbackTaskTagKey,
26
+ parseProjectScopedTag,
24
27
  normalizeProjectKey,
25
28
  normalizeTagKey,
26
29
  readTaskDefaultTagBucketEnabled,
@@ -33,15 +36,23 @@ import {
33
36
  aggregateTagTaskMinutesByDayAndWeek,
34
37
  buildTagFilterSet,
35
38
  mergeSessionsFromPayload,
39
+ collectTasksDeduped,
40
+ dayInRange,
41
+ taskCountsTowardArchivedSessionReporting,
42
+ taskMatchesTags,
36
43
  sortedDayKeys,
44
+ REPORTING_SESSION_CLOSURE_DISPLAY_ORDER,
37
45
  type ReportingTagTimeDayRow,
38
- type ReportingTagTimeWeekRow,
39
46
  } from "@/lib/reportingAggregate";
40
47
  import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
41
48
  import {
42
49
  appShellHeaderClassName,
43
- appShellHeaderToolRowClassName,
50
+ appShellHeaderTitleMetaRowClassName,
51
+ appShellHeaderToolbarClassName,
44
52
  } from "@/lib/appShellHeaderClasses";
53
+ import { AppShellCommandCenterPlaceholder } from "@/components/dashboard/AppShellCommandCenterPlaceholder";
54
+ import { AppShellHeaderSessionMeta } from "@/components/dashboard/AppShellHeaderSessionMeta";
55
+ import { AppShellHeaderWallClock } from "@/components/dashboard/AppShellHeaderWallClock";
45
56
  import { dashboardStrings, type Lang } from "@/lib/dashboardCopy";
46
57
  import {
47
58
  reportingPresetDay,
@@ -62,7 +73,6 @@ import {
62
73
  buildTagWeekCalendarRows,
63
74
  formatWeekRangeLabel,
64
75
  localWeekStartKeyFromDayKey,
65
- readWeekStartsOnFromStorage,
66
76
  type ReportingWeekStartsOn,
67
77
  weekdayDateColumnHeaders,
68
78
  writeWeekStartsOnToStorage,
@@ -70,29 +80,85 @@ import {
70
80
  type TagWeekCalendarRow,
71
81
  } from "@/lib/reportingWeekLayout";
72
82
  import { computeReportingNonFinalFlags } from "@/lib/reportingNonFinalIndicators";
73
- import {
74
- buildTagWeekDisplayBlocks,
75
- groupTagDayRowsForDisplay,
76
- } from "@/lib/reportingTagWeekBreakdown";
83
+ import { buildTagWeekDisplayBlocks } from "@/lib/reportingTagWeekBreakdown";
77
84
  import { ReportingTour } from "@/components/dashboard/ReportingTour";
78
85
  import { ScrollToTopFab } from "@/components/dashboard/ScrollToTopFab";
79
86
  import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
80
87
  import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
81
88
  import { AppShellRouteNav } from "@/components/dashboard/AppShellRouteNav";
82
- import { readDashboardTimeZoneFromCfg } from "@/lib/dashboardTimeZone";
89
+ import {
90
+ calendarDateKeyInTimeZone,
91
+ readDashboardTimeZoneFromCfg,
92
+ } from "@/lib/dashboardTimeZone";
83
93
  import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
84
94
  import { LanguageMenu } from "@/components/dashboard/LanguageMenu";
85
95
  import {
86
96
  isReportingTourCompleted,
87
97
  resetReportingTour,
88
98
  } from "@/lib/dashboardTourStorage";
99
+ import {
100
+ TaskSessionLiveCard,
101
+ type TaskSessionLiveCardTask,
102
+ } from "@/components/dashboard/TaskSessionLiveCard";
103
+ import { useReportingInteractionState } from "@/components/dashboard/useReportingInteractionState";
89
104
 
90
105
  type LiveShape = { language?: string };
106
+ type ReportingTaskInspectScope = {
107
+ kind: "tag" | "project";
108
+ title: string;
109
+ dayKey?: string;
110
+ weekStart?: string;
111
+ tagKey?: string;
112
+ projectKey?: string;
113
+ sourceLabel?: string;
114
+ };
115
+
116
+ type ReportingTaskDetail = {
117
+ sessionId: string;
118
+ task: TaskSessionLiveCardTask;
119
+ pausePlayMode: "pause" | "resume" | null;
120
+ dayKey: string;
121
+ weekStart: string | null;
122
+ tagKeys: string[];
123
+ projectKey: string;
124
+ };
125
+
126
+ function taskTagKeysForReporting(
127
+ tags: string[] | undefined,
128
+ defaultTagBucketEnabled: boolean,
129
+ ): string[] {
130
+ const keys = (tags ?? [])
131
+ .map((raw) => normalizeTagKey(String(raw)).toLowerCase())
132
+ .filter(Boolean);
133
+ if (keys.length > 0) {
134
+ return [...new Set(keys)];
135
+ }
136
+ return defaultTagBucketEnabled ? [DEFAULT_FALLBACK_TASK_TAG] : [""];
137
+ }
91
138
 
92
139
  function dayLabel(key: string, undated: string): string {
93
140
  return key === UNDATED_KEY ? undated : key;
94
141
  }
95
142
 
143
+ function weekRangeLabelForSource(
144
+ weekStart: string,
145
+ lang: Lang,
146
+ locale: string,
147
+ ): string {
148
+ const start = new Date(`${weekStart}T00:00:00`);
149
+ if (Number.isNaN(start.getTime())) {
150
+ return weekStart;
151
+ }
152
+ const end = new Date(start);
153
+ end.setDate(end.getDate() + 6);
154
+ const fmt = new Intl.DateTimeFormat(locale, {
155
+ month: "short",
156
+ day: "numeric",
157
+ });
158
+ const prefix = lang === "fr" ? "Semaine" : "Week";
159
+ return `${prefix} ${fmt.format(start)} - ${fmt.format(end)}`;
160
+ }
161
+
96
162
  function formatMinutesCell(minutes: number | undefined): string {
97
163
  const m = minutes ?? 0;
98
164
  return m > 0 ? formatDuration(m) : "—";
@@ -367,46 +433,64 @@ function ReportingContent() {
367
433
  const [dateTo, setDateTo] = useState("");
368
434
  const [selectedTags, setSelectedTags] = useState<string[]>([]);
369
435
  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
- }, []);
436
+ const {
437
+ weekStartsOn,
438
+ setWeekStartsOn,
439
+ chartWeekNavIndex,
440
+ setChartWeekNavIndex,
441
+ tagWeekRollupOpenKeys,
442
+ toggleTagWeekRollup,
443
+ projectWeekTagBreakdownOpenKeys,
444
+ toggleProjectWeekTagBreakdown,
445
+ reportingTourOpen,
446
+ setReportingTourOpen,
447
+ taskInspectScope,
448
+ setTaskInspectScope,
449
+ taskInspectAnchor,
450
+ setTaskInspectAnchor,
451
+ taskInspectModalRect,
452
+ setTaskInspectModalRect,
453
+ portalReady,
454
+ taskInspectModalRef,
455
+ } = useReportingInteractionState<ReportingTaskInspectScope>();
394
456
 
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);
457
+ useEffect(() => {
458
+ if (!taskInspectScope || !portalReady) {
459
+ setTaskInspectModalRect(null);
460
+ return;
461
+ }
462
+ const updateRect = () => {
463
+ const node = taskInspectModalRef.current;
464
+ if (!node) {
465
+ return;
402
466
  }
403
- return next;
404
- });
405
- }, []);
467
+ const rect = node.getBoundingClientRect();
468
+ setTaskInspectModalRect({
469
+ left: rect.left,
470
+ top: rect.top,
471
+ width: rect.width,
472
+ height: rect.height,
473
+ });
474
+ };
475
+ const raf = globalThis.requestAnimationFrame(updateRect);
476
+ const onResize = () => updateRect();
477
+ globalThis.addEventListener("resize", onResize);
478
+ return () => {
479
+ globalThis.cancelAnimationFrame(raf);
480
+ globalThis.removeEventListener("resize", onResize);
481
+ };
482
+ }, [portalReady, taskInspectScope]);
406
483
 
407
484
  useEffect(() => {
408
- setWeekStartsOn(readWeekStartsOnFromStorage());
409
- }, []);
485
+ if (!taskInspectScope) {
486
+ return;
487
+ }
488
+ const prevOverflow = document.body.style.overflow;
489
+ document.body.style.overflow = "hidden";
490
+ return () => {
491
+ document.body.style.overflow = prevOverflow;
492
+ };
493
+ }, [taskInspectScope]);
410
494
 
411
495
  const persistWeekStartsOn = useCallback((v: ReportingWeekStartsOn) => {
412
496
  setWeekStartsOn(v);
@@ -433,6 +517,14 @@ function ReportingContent() {
433
517
  [refresh],
434
518
  );
435
519
 
520
+ const postReportingTaskAction = useCallback(
521
+ async (body: Record<string, unknown>) => {
522
+ await postKronosysAction(body);
523
+ await refresh();
524
+ },
525
+ [refresh],
526
+ );
527
+
436
528
  const stripReportingTourReplayParam = useCallback(() => {
437
529
  if (searchParams.get("tour") !== "replay") {
438
530
  return;
@@ -447,6 +539,22 @@ function ReportingContent() {
447
539
  const lang: Lang = live?.language === "fr" ? "fr" : "en";
448
540
  const t = reportingStrings(lang);
449
541
  const dt = dashboardStrings(lang);
542
+ const reportingClosureKindLabels = useMemo(
543
+ () => ({
544
+ planned: dt.sessionEndReasonPlanned,
545
+ early: dt.sessionEndReasonEarly,
546
+ overrun: dt.sessionEndReasonOverrun,
547
+ other: dt.sessionEndReasonOther,
548
+ unspecified: t.closureBreakdownUnspecified,
549
+ }),
550
+ [
551
+ dt.sessionEndReasonPlanned,
552
+ dt.sessionEndReasonEarly,
553
+ dt.sessionEndReasonOverrun,
554
+ dt.sessionEndReasonOther,
555
+ t.closureBreakdownUnspecified,
556
+ ],
557
+ );
450
558
  const handleManualRefresh = useCallback(async () => {
451
559
  return await refresh({ routerInvalidate: true });
452
560
  }, [refresh]);
@@ -469,6 +577,15 @@ function ReportingContent() {
469
577
  (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }),
470
578
  );
471
579
  }, [payload?.knownTags]);
580
+ const knownProjects = useMemo(() => {
581
+ const raw = payload?.knownProjects;
582
+ if (!Array.isArray(raw)) {
583
+ return [] as string[];
584
+ }
585
+ return [...new Set(raw.map((x) => String(x)).filter(Boolean))].sort(
586
+ (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }),
587
+ );
588
+ }, [payload?.knownProjects]);
472
589
 
473
590
  const taskDefaultTagBucketEnabled = useMemo(
474
591
  () => readTaskDefaultTagBucketEnabled(payload?.cfg),
@@ -476,46 +593,67 @@ function ReportingContent() {
476
593
  );
477
594
 
478
595
  const reportTimeZone = useMemo(
479
- () => readDashboardTimeZoneFromCfg(payload?.cfg as Record<string, unknown> | undefined),
480
- [payload?.cfg]
596
+ () =>
597
+ readDashboardTimeZoneFromCfg(
598
+ payload?.cfg as Record<string, unknown> | undefined,
599
+ ),
600
+ [payload?.cfg],
481
601
  );
482
602
 
483
603
  const tagSet = useMemo(() => buildTagFilterSet(selectedTags), [selectedTags]);
604
+ const mergedSessions = useMemo(
605
+ () => (payload ? mergeSessionsFromPayload(payload) : []),
606
+ [payload],
607
+ );
484
608
 
485
609
  const reportingFiltersActive = useMemo(
486
610
  () => Boolean(dateFrom.trim() || dateTo.trim() || selectedTags.length > 0),
487
611
  [dateFrom, dateTo, selectedTags],
488
612
  );
613
+ const dateFromFilter = dateFrom.trim() || null;
614
+ const dateToFilter = dateTo.trim() || null;
489
615
 
490
616
  const agg = useMemo(() => {
491
- if (!payload) {
617
+ if (mergedSessions.length === 0) {
492
618
  return null;
493
619
  }
494
- const sessions = mergeSessionsFromPayload(payload);
495
620
  return aggregateReporting(
496
- sessions,
621
+ mergedSessions,
497
622
  tagSet,
498
- dateFrom.trim() || null,
499
- dateTo.trim() || null,
623
+ dateFromFilter,
624
+ dateToFilter,
500
625
  reportTimeZone,
501
626
  taskDefaultTagBucketEnabled,
502
627
  );
503
- }, [payload, tagSet, dateFrom, dateTo, reportTimeZone, taskDefaultTagBucketEnabled]);
628
+ }, [
629
+ mergedSessions,
630
+ tagSet,
631
+ dateFromFilter,
632
+ dateToFilter,
633
+ reportTimeZone,
634
+ taskDefaultTagBucketEnabled,
635
+ ]);
504
636
 
505
637
  const archivedExcludedTaskMinutes = useMemo(() => {
506
- if (!payload) {
638
+ if (mergedSessions.length === 0) {
507
639
  return 0;
508
640
  }
509
- const sessions = mergeSessionsFromPayload(payload);
510
641
  return aggregateArchivedExcludedTaskMinutes(
511
- sessions,
642
+ mergedSessions,
512
643
  tagSet,
513
- dateFrom.trim() || null,
514
- dateTo.trim() || null,
644
+ dateFromFilter,
645
+ dateToFilter,
515
646
  reportTimeZone,
516
647
  taskDefaultTagBucketEnabled,
517
648
  );
518
- }, [payload, tagSet, dateFrom, dateTo, reportTimeZone, taskDefaultTagBucketEnabled]);
649
+ }, [
650
+ mergedSessions,
651
+ tagSet,
652
+ dateFromFilter,
653
+ dateToFilter,
654
+ reportTimeZone,
655
+ taskDefaultTagBucketEnabled,
656
+ ]);
519
657
 
520
658
  const reportingNonFinal = useMemo(
521
659
  () =>
@@ -558,29 +696,26 @@ function ReportingContent() {
558
696
  [dayKeys],
559
697
  );
560
698
 
561
- const tagTimeSplit = useMemo((): {
562
- byDay: ReportingTagTimeDayRow[];
563
- byWeek: ReportingTagTimeWeekRow[];
564
- } => {
565
- if (!payload) {
566
- return { byDay: [], byWeek: [] };
699
+ const tagTimeSplit = useMemo((): { byDay: ReportingTagTimeDayRow[] } => {
700
+ if (mergedSessions.length === 0) {
701
+ return { byDay: [] };
567
702
  }
568
- const sessions = mergeSessionsFromPayload(payload);
569
- return aggregateTagTaskMinutesByDayAndWeek(
570
- sessions,
703
+ const { byDay } = aggregateTagTaskMinutesByDayAndWeek(
704
+ mergedSessions,
571
705
  tagSet,
572
- dateFrom.trim() || null,
573
- dateTo.trim() || null,
706
+ dateFromFilter,
707
+ dateToFilter,
574
708
  reportTimeZone,
575
709
  weekStartsOn,
576
710
  t.tagTimeUntagged,
577
711
  taskDefaultTagBucketEnabled,
578
712
  );
713
+ return { byDay };
579
714
  }, [
580
- payload,
715
+ mergedSessions,
581
716
  tagSet,
582
- dateFrom,
583
- dateTo,
717
+ dateFromFilter,
718
+ dateToFilter,
584
719
  reportTimeZone,
585
720
  weekStartsOn,
586
721
  t.tagTimeUntagged,
@@ -593,19 +728,25 @@ function ReportingContent() {
593
728
  );
594
729
 
595
730
  const projectTaskMinutesByDay = useMemo(() => {
596
- if (!payload) {
731
+ if (mergedSessions.length === 0) {
597
732
  return [];
598
733
  }
599
- const sessions = mergeSessionsFromPayload(payload);
600
734
  return aggregateProjectTaskMinutesByDay(
601
- sessions,
735
+ mergedSessions,
602
736
  tagSet,
603
- dateFrom.trim() || null,
604
- dateTo.trim() || null,
737
+ dateFromFilter,
738
+ dateToFilter,
605
739
  reportTimeZone,
606
740
  taskDefaultTagBucketEnabled,
607
741
  );
608
- }, [payload, tagSet, dateFrom, dateTo, reportTimeZone, taskDefaultTagBucketEnabled]);
742
+ }, [
743
+ mergedSessions,
744
+ tagSet,
745
+ dateFromFilter,
746
+ dateToFilter,
747
+ reportTimeZone,
748
+ taskDefaultTagBucketEnabled,
749
+ ]);
609
750
 
610
751
  const projectWeekCalendarRows = useMemo(
611
752
  () => buildProjectWeekCalendarRows(projectTaskMinutesByDay, weekStartsOn),
@@ -790,6 +931,38 @@ function ReportingContent() {
790
931
  return projectWeekCalendarRows.filter((r) => r.weekStart === ws);
791
932
  }, [projectWeekCalendarRows, navigableWeekStarts, chartWeekNavIndexSafe]);
792
933
 
934
+ const projectScopedTagRowsByWeekProject = useMemo(() => {
935
+ const byWeekProject = new Map<string, TagWeekCalendarRow[]>();
936
+ for (const row of tagWeekCalendarRowsVisible) {
937
+ if (isFallbackTaskTagKey(row.tagKey)) {
938
+ continue;
939
+ }
940
+ const scoped = parseProjectScopedTag(row.tagKey);
941
+ if (!scoped) {
942
+ continue;
943
+ }
944
+ const projectKeyLower = normalizeProjectKey(
945
+ scoped.projectKey,
946
+ ).toLowerCase();
947
+ if (!projectKeyLower) {
948
+ continue;
949
+ }
950
+ const bucketKey = `${row.weekStart}:::${projectKeyLower}`;
951
+ const list = byWeekProject.get(bucketKey) ?? [];
952
+ list.push(row);
953
+ byWeekProject.set(bucketKey, list);
954
+ }
955
+ for (const [bucketKey, rows] of byWeekProject) {
956
+ byWeekProject.set(
957
+ bucketKey,
958
+ [...rows].sort((a, b) =>
959
+ normalizeTagKey(a.tagKey).localeCompare(normalizeTagKey(b.tagKey)),
960
+ ),
961
+ );
962
+ }
963
+ return byWeekProject;
964
+ }, [tagWeekCalendarRowsVisible]);
965
+
793
966
  const projectCalendarWeekGroups = useMemo(() => {
794
967
  const byWeek = new Map<string, ProjectWeekCalendarRow[]>();
795
968
  for (const r of projectWeekCalendarRowsVisible) {
@@ -812,6 +985,133 @@ function ReportingContent() {
812
985
  });
813
986
  }, [projectWeekCalendarRowsVisible]);
814
987
 
988
+ const reportingTaskDetails = useMemo(() => {
989
+ const list: ReportingTaskDetail[] = [];
990
+ const unbounded = !dateFromFilter && !dateToFilter;
991
+ for (const s of mergedSessions) {
992
+ const sessionId =
993
+ typeof s.sessionId === "string" ? s.sessionId.trim() : "";
994
+ if (!sessionId) {
995
+ continue;
996
+ }
997
+ const allTasks = collectTasksDeduped(s);
998
+ const matching = allTasks.filter((task) =>
999
+ taskMatchesTags(task, tagSet, taskDefaultTagBucketEnabled),
1000
+ );
1001
+ if (tagSet.size > 0 && matching.length === 0) {
1002
+ continue;
1003
+ }
1004
+ for (const task of matching) {
1005
+ if (
1006
+ !taskCountsTowardArchivedSessionReporting(task) &&
1007
+ s.archived === true
1008
+ ) {
1009
+ continue;
1010
+ }
1011
+ const taskDayRaw = calendarDateKeyInTimeZone(
1012
+ task.endTime ?? task.startTime,
1013
+ reportTimeZone,
1014
+ );
1015
+ if (
1016
+ !unbounded &&
1017
+ !dayInRange(taskDayRaw, dateFromFilter, dateToFilter)
1018
+ ) {
1019
+ continue;
1020
+ }
1021
+ const dayKey = taskDayRaw ?? (unbounded ? UNDATED_KEY : null);
1022
+ if (!dayKey) {
1023
+ continue;
1024
+ }
1025
+ const minutes = (task.durationMs ?? 0) / 60000;
1026
+ if (minutes <= 0) {
1027
+ continue;
1028
+ }
1029
+ const projectRaw =
1030
+ typeof task.project === "string" ? task.project.trim() : "";
1031
+ const projectKey = projectRaw
1032
+ ? normalizeProjectKey(projectRaw).toLowerCase()
1033
+ : "";
1034
+ const weekStart =
1035
+ dayKey !== UNDATED_KEY
1036
+ ? localWeekStartKeyFromDayKey(dayKey, weekStartsOn)
1037
+ : null;
1038
+ const taskRecord = task as Record<string, unknown>;
1039
+ const rawTaskName =
1040
+ typeof taskRecord.name === "string" ? taskRecord.name.trim() : "";
1041
+ const taskName =
1042
+ rawTaskName || (lang === "fr" ? "Tâche sans titre" : "Untitled task");
1043
+ list.push({
1044
+ sessionId,
1045
+ pausePlayMode:
1046
+ task.isDone === true
1047
+ ? null
1048
+ : task.manualTaskTimerPaused === true
1049
+ ? "resume"
1050
+ : "pause",
1051
+ dayKey,
1052
+ weekStart,
1053
+ projectKey,
1054
+ tagKeys: taskTagKeysForReporting(
1055
+ task.tags,
1056
+ taskDefaultTagBucketEnabled,
1057
+ ),
1058
+ task: {
1059
+ id: String(task.id ?? `${sessionId}-${dayKey}-${list.length}`),
1060
+ name: taskName,
1061
+ durationMs: Math.max(0, Number(task.durationMs ?? 0)),
1062
+ isDone: task.isDone === true,
1063
+ startTime: task.startTime,
1064
+ endTime: task.endTime,
1065
+ tags: task.tags ?? [],
1066
+ project: task.project ?? null,
1067
+ },
1068
+ });
1069
+ }
1070
+ }
1071
+ return list;
1072
+ }, [
1073
+ dateFromFilter,
1074
+ dateToFilter,
1075
+ lang,
1076
+ mergedSessions,
1077
+ reportTimeZone,
1078
+ tagSet,
1079
+ taskDefaultTagBucketEnabled,
1080
+ weekStartsOn,
1081
+ ]);
1082
+
1083
+ const taskInspectList = useMemo(() => {
1084
+ if (!taskInspectScope) {
1085
+ return [];
1086
+ }
1087
+ return reportingTaskDetails.filter((entry) => {
1088
+ if (taskInspectScope.dayKey && entry.dayKey !== taskInspectScope.dayKey) {
1089
+ return false;
1090
+ }
1091
+ if (
1092
+ taskInspectScope.weekStart &&
1093
+ entry.weekStart !== taskInspectScope.weekStart
1094
+ ) {
1095
+ return false;
1096
+ }
1097
+ if (taskInspectScope.kind === "project") {
1098
+ return entry.projectKey === (taskInspectScope.projectKey ?? "");
1099
+ }
1100
+ return entry.tagKeys.includes(taskInspectScope.tagKey ?? "");
1101
+ });
1102
+ }, [reportingTaskDetails, taskInspectScope]);
1103
+
1104
+ const openTaskInspectScope = useCallback(
1105
+ (
1106
+ scope: ReportingTaskInspectScope,
1107
+ anchor: { x: number; y: number } | null,
1108
+ ) => {
1109
+ setTaskInspectScope(scope);
1110
+ setTaskInspectAnchor(anchor);
1111
+ },
1112
+ [],
1113
+ );
1114
+
815
1115
  const toggleTag = (tag: string) => {
816
1116
  const key = normalizeTagKey(tag).toLowerCase();
817
1117
  setSelectedTags((prev) => {
@@ -823,6 +1123,201 @@ function ReportingContent() {
823
1123
  });
824
1124
  };
825
1125
 
1126
+ const renderReportingDurationButton = (
1127
+ minutes: number,
1128
+ scope: ReportingTaskInspectScope,
1129
+ className: string,
1130
+ ) => {
1131
+ const label = formatDuration(minutes);
1132
+ return (
1133
+ <button
1134
+ type="button"
1135
+ className={`${className} underline decoration-dotted underline-offset-2 hover:text-violet-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400/60`}
1136
+ onClick={(event) => {
1137
+ const rect = event.currentTarget.getBoundingClientRect();
1138
+ openTaskInspectScope(scope, {
1139
+ x: rect.left + rect.width / 2,
1140
+ y: rect.bottom,
1141
+ });
1142
+ }}
1143
+ title={
1144
+ lang === "fr" ? `Voir les tâches (${label})` : `View tasks (${label})`
1145
+ }
1146
+ >
1147
+ {label}
1148
+ </button>
1149
+ );
1150
+ };
1151
+
1152
+ const taskInspectPlacement = useMemo(() => {
1153
+ if (!taskInspectAnchor) {
1154
+ return null;
1155
+ }
1156
+ const viewportWidth = globalThis.innerWidth;
1157
+ const viewportHeight = globalThis.innerHeight;
1158
+ const preferredPanelWidth = Math.min(
1159
+ 760,
1160
+ Math.max(320, viewportWidth - 32),
1161
+ );
1162
+ const estimatedPanelHeight = Math.min(
1163
+ Math.max(300, viewportHeight * 0.72),
1164
+ viewportHeight - 32,
1165
+ );
1166
+ const margin = 16;
1167
+ const offset = 72;
1168
+ const sourceSafeZoneX = 72;
1169
+ const sourceSafeZoneY = 36;
1170
+ const minPanelWidth = 220;
1171
+ const spaceRight = viewportWidth - margin - (taskInspectAnchor.x + offset);
1172
+ const spaceLeft = taskInspectAnchor.x - margin - offset;
1173
+ const placeRight = spaceRight >= spaceLeft;
1174
+ const availableSideSpace = placeRight
1175
+ ? Math.max(0, spaceRight)
1176
+ : Math.max(0, spaceLeft);
1177
+ let panelWidth = Math.min(preferredPanelWidth, availableSideSpace);
1178
+ if (panelWidth < minPanelWidth) {
1179
+ const otherSideSpace = placeRight
1180
+ ? Math.max(0, spaceLeft)
1181
+ : Math.max(0, spaceRight);
1182
+ panelWidth = Math.min(
1183
+ preferredPanelWidth,
1184
+ Math.max(minPanelWidth, otherSideSpace),
1185
+ );
1186
+ }
1187
+ const clampLeft = (value: number) =>
1188
+ Math.max(margin, Math.min(value, viewportWidth - panelWidth - margin));
1189
+ const clampTop = (value: number) =>
1190
+ Math.max(
1191
+ margin,
1192
+ Math.min(value, viewportHeight - estimatedPanelHeight - margin),
1193
+ );
1194
+ const rawLeft = placeRight
1195
+ ? taskInspectAnchor.x + offset
1196
+ : taskInspectAnchor.x - panelWidth - offset;
1197
+ let adjustedLeft = clampLeft(rawLeft);
1198
+ const overlapsSourceX =
1199
+ adjustedLeft <= taskInspectAnchor.x + sourceSafeZoneX &&
1200
+ taskInspectAnchor.x - sourceSafeZoneX <= adjustedLeft + panelWidth;
1201
+ if (overlapsSourceX) {
1202
+ const fallbackLeft = placeRight
1203
+ ? taskInspectAnchor.x - panelWidth - offset
1204
+ : taskInspectAnchor.x + offset;
1205
+ adjustedLeft = clampLeft(fallbackLeft);
1206
+ }
1207
+ const centeredTop = clampTop(
1208
+ taskInspectAnchor.y - estimatedPanelHeight / 2,
1209
+ );
1210
+ const sourceBandTop = taskInspectAnchor.y - sourceSafeZoneY;
1211
+ const sourceBandBottom = taskInspectAnchor.y + sourceSafeZoneY;
1212
+ const candidatesTop = [
1213
+ centeredTop,
1214
+ clampTop(sourceBandBottom + offset),
1215
+ clampTop(sourceBandTop - estimatedPanelHeight - offset),
1216
+ ];
1217
+ const nonOverlapTop = candidatesTop.filter((top) => {
1218
+ const modalTop = top;
1219
+ const modalBottom = top + estimatedPanelHeight;
1220
+ return !(modalTop <= sourceBandBottom && sourceBandTop <= modalBottom);
1221
+ });
1222
+ const pool = nonOverlapTop.length > 0 ? nonOverlapTop : candidatesTop;
1223
+ let adjustedTop = pool[0];
1224
+ let bestTopScore = Number.POSITIVE_INFINITY;
1225
+ for (const top of pool) {
1226
+ const score = Math.abs(top - centeredTop);
1227
+ if (score < bestTopScore) {
1228
+ bestTopScore = score;
1229
+ adjustedTop = top;
1230
+ }
1231
+ }
1232
+ return {
1233
+ left: adjustedLeft,
1234
+ top: adjustedTop,
1235
+ width: panelWidth,
1236
+ height: estimatedPanelHeight,
1237
+ };
1238
+ }, [taskInspectAnchor]);
1239
+ const taskInspectPlacementTop = useMemo(() => {
1240
+ if (!taskInspectPlacement) {
1241
+ return null;
1242
+ }
1243
+ if (!taskInspectModalRect) {
1244
+ return taskInspectPlacement.top;
1245
+ }
1246
+ const viewportHeight = globalThis.innerHeight;
1247
+ const margin = 16;
1248
+ const overflowBottom =
1249
+ taskInspectModalRect.top +
1250
+ taskInspectModalRect.height -
1251
+ (viewportHeight - margin);
1252
+ const overflowTop = margin - taskInspectModalRect.top;
1253
+ let nextTop =
1254
+ taskInspectPlacement.top -
1255
+ Math.max(0, overflowBottom) +
1256
+ Math.max(0, overflowTop);
1257
+ const maxTop = Math.max(
1258
+ margin,
1259
+ viewportHeight - taskInspectModalRect.height - margin,
1260
+ );
1261
+ nextTop = Math.max(margin, Math.min(nextTop, maxTop));
1262
+ return nextTop;
1263
+ }, [taskInspectModalRect, taskInspectPlacement]);
1264
+ const taskInspectConnector = useMemo(() => {
1265
+ if (!taskInspectAnchor || !taskInspectPlacement) {
1266
+ return null;
1267
+ }
1268
+ const modalLeft = taskInspectModalRect?.left ?? taskInspectPlacement.left;
1269
+ const modalTop = taskInspectModalRect?.top ?? taskInspectPlacement.top;
1270
+ const modalWidth =
1271
+ taskInspectModalRect?.width ?? taskInspectPlacement.width;
1272
+ const modalHeight =
1273
+ taskInspectModalRect?.height ?? taskInspectPlacement.height;
1274
+ const modalRight = modalLeft + modalWidth;
1275
+ const modalBottom = modalTop + modalHeight;
1276
+ const inset = 14;
1277
+ const anchors = [
1278
+ // Coins
1279
+ { x: modalLeft + inset, y: modalTop + inset },
1280
+ { x: modalRight - inset, y: modalTop + inset },
1281
+ { x: modalRight - inset, y: modalBottom - inset },
1282
+ { x: modalLeft + inset, y: modalBottom - inset },
1283
+ // Milieux des côtés
1284
+ { x: modalLeft + modalWidth / 2, y: modalTop + inset },
1285
+ { x: modalRight - inset, y: modalTop + modalHeight / 2 },
1286
+ { x: modalLeft + modalWidth / 2, y: modalBottom - inset },
1287
+ { x: modalLeft + inset, y: modalTop + modalHeight / 2 },
1288
+ ];
1289
+ let startX = anchors[0].x;
1290
+ let startY = anchors[0].y;
1291
+ let bestDistance = Number.POSITIVE_INFINITY;
1292
+ for (const anchor of anchors) {
1293
+ const d = Math.hypot(
1294
+ anchor.x - taskInspectAnchor.x,
1295
+ anchor.y - taskInspectAnchor.y,
1296
+ );
1297
+ if (d < bestDistance) {
1298
+ bestDistance = d;
1299
+ startX = anchor.x;
1300
+ startY = anchor.y;
1301
+ }
1302
+ }
1303
+ const dx = startX - taskInspectAnchor.x;
1304
+ const dy = startY - taskInspectAnchor.y;
1305
+ const length = Math.hypot(dx, dy);
1306
+ const sourceGap = 14;
1307
+ const ux = length > 0 ? dx / length : 0;
1308
+ const uy = length > 0 ? dy / length : 0;
1309
+ const lineStartX = taskInspectAnchor.x + ux * sourceGap;
1310
+ const lineStartY = taskInspectAnchor.y + uy * sourceGap;
1311
+ return {
1312
+ startX,
1313
+ startY,
1314
+ lineStartX,
1315
+ lineStartY,
1316
+ length: Math.max(0, length - sourceGap),
1317
+ angleRad: Math.atan2(dy, dx),
1318
+ };
1319
+ }, [taskInspectAnchor, taskInspectModalRect, taskInspectPlacement]);
1320
+
826
1321
  const headerApiError =
827
1322
  lang === "fr"
828
1323
  ? "Impossible de joindre l’API locale Kronosys (127.0.0.1:5566 par défaut). Vérifiez que le serveur tourne."
@@ -870,6 +1365,7 @@ function ReportingContent() {
870
1365
  return rows;
871
1366
  }
872
1367
  rows.push({ id: "report-summary-kpis", label: t.tocSummaryKpis });
1368
+ rows.push({ id: "report-closure-by-kind", label: t.tocClosureBreakdown });
873
1369
  if (trackCodeMetrics) {
874
1370
  rows.push({ id: "report-loc-metrics", label: t.tocLocSection });
875
1371
  }
@@ -894,7 +1390,7 @@ function ReportingContent() {
894
1390
  return (
895
1391
  <div className="min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
896
1392
  <header className={appShellHeaderClassName}>
897
- <div className={appShellHeaderToolRowClassName}>
1393
+ <div className={appShellHeaderTitleMetaRowClassName}>
898
1394
  <div className="flex min-w-0 flex-col gap-1">
899
1395
  <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
900
1396
  <Link
@@ -916,17 +1412,24 @@ function ReportingContent() {
916
1412
  <AppVersionStamp ariaLabelTemplate={dt.appVersionAriaLabel} />
917
1413
  </p>
918
1414
  </div>
919
- <div className="flex flex-wrap items-center gap-1.5">
1415
+ <AppShellHeaderSessionMeta payload={payload} dt={dt} />
1416
+ </div>
1417
+ <div className="flex w-full justify-end">
1418
+ <div className={appShellHeaderToolbarClassName}>
1419
+ <AppShellHeaderWallClock lang={lang} dt={dt} />
1420
+ <AppShellCommandCenterPlaceholder />
920
1421
  <AppShellRouteNav
921
1422
  current="reporting"
922
1423
  labels={{
923
1424
  dashboard: t.dashboard,
924
1425
  reporting: t.reporting,
925
1426
  settings: t.settings,
1427
+ logs: t.logs,
926
1428
  guide: t.guide,
927
1429
  }}
928
1430
  navAriaLabel={dt.appShellRouteNavAria}
929
1431
  dashboardSessionId={dashboardSessionNavId}
1432
+ reserveGlobalPauseSlot
930
1433
  />
931
1434
  <ThemeToggle lang={lang} />
932
1435
  <PageRefreshButton
@@ -1365,17 +1868,17 @@ function ReportingContent() {
1365
1868
  </p>
1366
1869
  );
1367
1870
  })()}
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}
1871
+ {archivedExcludedTaskMinutes > 1e-9 ? (
1872
+ <div
1873
+ role="status"
1874
+ 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"
1875
+ >
1876
+ {reportingArchivedExcludedRichText(
1877
+ t.reportingArchivedExcludedAside,
1878
+ archivedExcludedTaskMinutes,
1879
+ )}
1880
+ </div>
1881
+ ) : null}
1379
1882
  </section>
1380
1883
  ) : null}
1381
1884
 
@@ -1584,10 +2087,52 @@ function ReportingContent() {
1584
2087
  <div className="mt-1 text-2xl font-semibold tabular-nums">
1585
2088
  {agg.assiduityAverageLateMinutesWhenLate == null
1586
2089
  ? "—"
1587
- : formatDuration(agg.assiduityAverageLateMinutesWhenLate)}
2090
+ : formatDuration(
2091
+ agg.assiduityAverageLateMinutesWhenLate,
2092
+ )}
1588
2093
  </div>
1589
2094
  </div>
1590
2095
  </div>
2096
+ <div
2097
+ id="report-closure-by-kind"
2098
+ className="mt-4 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4"
2099
+ >
2100
+ <div className="mb-3 flex flex-wrap items-center gap-2">
2101
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2102
+ {t.closureBreakdownTitle}
2103
+ </h3>
2104
+ <ReportingFilteredBadge
2105
+ active={reportingFiltersActive}
2106
+ label={t.sectionFilteredBadge}
2107
+ titleText={t.sectionFilteredBadgeTitle}
2108
+ />
2109
+ <InlineMetricHelpTrigger
2110
+ ariaLabel={t.metricHelpClosureBreakdownAria}
2111
+ body={t.metricHelpClosureBreakdownBody}
2112
+ />
2113
+ </div>
2114
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
2115
+ {REPORTING_SESSION_CLOSURE_DISPLAY_ORDER.map(
2116
+ (closureKey) => {
2117
+ const count =
2118
+ agg.sessionCountByClosureKind[closureKey] ?? 0;
2119
+ return (
2120
+ <div
2121
+ key={closureKey}
2122
+ className="rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3"
2123
+ >
2124
+ <div className="text-xs uppercase text-zinc-500">
2125
+ {reportingClosureKindLabels[closureKey]}
2126
+ </div>
2127
+ <div className="mt-1 text-xl font-semibold tabular-nums text-zinc-100">
2128
+ {count}
2129
+ </div>
2130
+ </div>
2131
+ );
2132
+ },
2133
+ )}
2134
+ </div>
2135
+ </div>
1591
2136
  {archivedExcludedTaskMinutes > 1e-9 ? (
1592
2137
  <div
1593
2138
  role="status"
@@ -1886,162 +2431,164 @@ function ReportingContent() {
1886
2431
  </div>
1887
2432
  </section>
1888
2433
  ) : 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}
2434
+ <div className="mb-10 grid grid-cols-1 gap-4 md:grid-cols-2">
2435
+ <section
2436
+ id="report-chart-sessions"
2437
+ className="scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
2438
+ >
2439
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2440
+ <h3 className="text-sm font-semibold text-zinc-200">
2441
+ {t.chartSessionsPerDay}
2442
+ </h3>
2443
+ <ReportingFilteredBadge
2444
+ active={reportingFiltersActive}
2445
+ label={t.sectionFilteredBadge}
2446
+ titleText={t.sectionFilteredBadgeTitle}
2447
+ />
1947
2448
  <InlineMetricHelpTrigger
1948
- ariaLabel={t.metricHelpLegendDoneAria}
1949
- body={t.metricHelpLegendDoneBody}
2449
+ ariaLabel={t.metricHelpChartSessionsAria}
2450
+ body={t.metricHelpChartSessionsBody}
1950
2451
  />
1951
2452
  </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}
2453
+ <div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-zinc-500">
2454
+ <span>{t.legendSessions}</span>
1955
2455
  <InlineMetricHelpTrigger
1956
- ariaLabel={t.metricHelpLegendActiveAria}
1957
- body={t.metricHelpLegendActiveBody}
2456
+ ariaLabel={t.metricHelpLegendSessionsAria}
2457
+ body={t.metricHelpLegendSessionsBody}
1958
2458
  />
1959
2459
  </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>
2460
+ <div className="mt-4">
2461
+ <MiniBars
2462
+ days={reportingDayKeys}
2463
+ values={agg.sessionsByDay}
2464
+ max={peak}
2465
+ className="bg-violet-500/90"
2466
+ undatedLabel={t.undatedLabel}
2467
+ />
2468
+ </div>
2469
+ </section>
1971
2470
 
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>
2471
+ <section
2472
+ id="report-chart-tasks"
2473
+ className="scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
2474
+ >
2475
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2476
+ <h3 className="text-sm font-semibold text-zinc-200">
2477
+ {t.chartTasksByStatusPerDay}
2478
+ </h3>
2479
+ <ReportingFilteredBadge
2480
+ active={reportingFiltersActive}
2481
+ label={t.sectionFilteredBadge}
2482
+ titleText={t.sectionFilteredBadgeTitle}
2483
+ />
2484
+ <InlineMetricHelpTrigger
2485
+ ariaLabel={t.metricHelpChartTasksAria}
2486
+ body={t.metricHelpChartTasksBody}
2487
+ />
2488
+ </div>
2489
+ <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-zinc-500">
2490
+ <div className="inline-flex items-center gap-1">
2491
+ <span className="mr-0.5 inline-block h-2 w-2 shrink-0 rounded-sm bg-emerald-600" />
2492
+ {t.legendDone}
2493
+ <InlineMetricHelpTrigger
2494
+ ariaLabel={t.metricHelpLegendDoneAria}
2495
+ body={t.metricHelpLegendDoneBody}
2496
+ />
2497
+ </div>
2498
+ <div className="inline-flex items-center gap-1">
2499
+ <span className="mr-0.5 inline-block h-2 w-2 shrink-0 rounded-sm bg-amber-500" />
2500
+ {t.legendActive}
2501
+ <InlineMetricHelpTrigger
2502
+ ariaLabel={t.metricHelpLegendActiveAria}
2503
+ body={t.metricHelpLegendActiveBody}
2504
+ />
2505
+ </div>
2506
+ </div>
2507
+ <div className="mt-4">
2508
+ <StackedTaskBars
2509
+ days={reportingDayKeys}
2510
+ done={agg.tasksByDayDone}
2511
+ active={agg.tasksByDayActive}
2512
+ max={peakTasks}
2513
+ undatedLabel={t.undatedLabel}
2514
+ />
2515
+ </div>
2516
+ </section>
2008
2517
 
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>
2518
+ <section
2519
+ id="report-chart-task-time"
2520
+ className="scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
2521
+ >
2522
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2523
+ <h3 className="text-sm font-semibold text-zinc-200">
2524
+ {t.chartTaskTimePerDay}
2525
+ </h3>
2526
+ <ReportingFilteredBadge
2527
+ active={reportingFiltersActive}
2528
+ label={t.sectionFilteredBadge}
2529
+ titleText={t.sectionFilteredBadgeTitle}
2530
+ />
2531
+ <InlineMetricHelpTrigger
2532
+ ariaLabel={t.metricHelpChartTaskTimeAria}
2533
+ body={t.metricHelpChartTaskTimeBody}
2534
+ />
2535
+ </div>
2536
+ <div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-zinc-500">
2537
+ <span>{t.summaryTaskTimeRecorded}</span>
2538
+ <InlineMetricHelpTrigger
2539
+ ariaLabel={t.metricHelpTaskTimeAria}
2540
+ body={t.metricHelpTaskTimeBody}
2541
+ />
2542
+ </div>
2543
+ <div className="mt-4">
2544
+ <MiniBars
2545
+ days={reportingDayKeys}
2546
+ values={agg.taskMinutesByDay}
2547
+ max={peakTaskMinutes}
2548
+ className="bg-sky-500/90"
2549
+ undatedLabel={t.undatedLabel}
2550
+ valueTitle={(m) => formatDuration(m)}
2551
+ />
2552
+ </div>
2553
+ </section>
2554
+
2555
+ <section
2556
+ id="report-chart-session-wall"
2557
+ className="scroll-mt-28 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 sm:p-6"
2558
+ >
2559
+ <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2560
+ <h3 className="text-sm font-semibold text-zinc-200">
2561
+ {t.chartSessionWallPerDay}
2562
+ </h3>
2563
+ <ReportingFilteredBadge
2564
+ active={reportingFiltersActive}
2565
+ label={t.sectionFilteredBadge}
2566
+ titleText={t.sectionFilteredBadgeTitle}
2567
+ />
2568
+ <InlineMetricHelpTrigger
2569
+ ariaLabel={t.metricHelpChartSessionWallAria}
2570
+ body={t.metricHelpChartSessionWallBody}
2571
+ />
2572
+ </div>
2573
+ <div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-zinc-500">
2574
+ <span>{t.summarySessionWallClock}</span>
2575
+ <InlineMetricHelpTrigger
2576
+ ariaLabel={t.metricHelpSessionWallSummaryAria}
2577
+ body={t.metricHelpSessionWallSummaryBody}
2578
+ />
2579
+ </div>
2580
+ <div className="mt-4">
2581
+ <MiniBars
2582
+ days={reportingDayKeys}
2583
+ values={agg.sessionWallClockMinutesByDay}
2584
+ max={peakSessionWallMinutes}
2585
+ className="bg-fuchsia-500/85"
2586
+ undatedLabel={t.undatedLabel}
2587
+ valueTitle={(m) => formatDuration(m)}
2588
+ />
2589
+ </div>
2590
+ </section>
2591
+ </div>
2045
2592
 
2046
2593
  <section
2047
2594
  id="report-daily-table"
@@ -2115,6 +2662,11 @@ function ReportingContent() {
2115
2662
  />
2116
2663
  </div>
2117
2664
  </th>
2665
+ <th className="px-4 py-3 font-medium tabular-nums">
2666
+ <div className="flex min-h-5 items-center gap-0.5">
2667
+ <span>{t.tableTaskTimeNonConcurrent}</span>
2668
+ </div>
2669
+ </th>
2118
2670
  <th className="px-4 py-3 font-medium tabular-nums">
2119
2671
  <div className="flex min-h-5 items-center gap-0.5">
2120
2672
  <span>{t.tableSessionCoding}</span>
@@ -2153,6 +2705,11 @@ function ReportingContent() {
2153
2705
  <td className="px-4 py-2.5 tabular-nums text-sky-300/90">
2154
2706
  {formatMinutesCell(agg.taskMinutesByDay[d])}
2155
2707
  </td>
2708
+ <td className="px-4 py-2.5 tabular-nums text-cyan-300/90">
2709
+ {formatMinutesCell(
2710
+ agg.nonConcurrentTaskMinutesByDay[d],
2711
+ )}
2712
+ </td>
2156
2713
  <td className="px-4 py-2.5 tabular-nums text-zinc-300">
2157
2714
  {formatMinutesCell(
2158
2715
  agg.sessionCodingMinutesByDay[d],
@@ -2186,196 +2743,6 @@ function ReportingContent() {
2186
2743
  {t.tagTimeSectionHint}
2187
2744
  </p>
2188
2745
 
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
2746
  <div id="report-tag-time-week" className="space-y-4">
2380
2747
  <div className="flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1">
2381
2748
  <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-400">
@@ -2556,8 +2923,24 @@ function ReportingContent() {
2556
2923
  title={dateKey}
2557
2924
  >
2558
2925
  {mins > 0
2559
- ? formatDuration(
2926
+ ? renderReportingDurationButton(
2560
2927
  mins,
2928
+ {
2929
+ kind: "tag",
2930
+ title:
2931
+ row.displayTag ||
2932
+ row.tagKey,
2933
+ dayKey:
2934
+ dateKey,
2935
+ tagKey:
2936
+ row.tagKey,
2937
+ sourceLabel:
2938
+ dayLabel(
2939
+ dateKey,
2940
+ t.undatedLabel,
2941
+ ),
2942
+ },
2943
+ "text-zinc-200",
2561
2944
  )
2562
2945
  : "—"}
2563
2946
  </td>
@@ -2565,8 +2948,24 @@ function ReportingContent() {
2565
2948
  },
2566
2949
  )}
2567
2950
  <td className="px-2 py-2 pr-3 text-center tabular-nums font-semibold text-zinc-100">
2568
- {formatDuration(
2951
+ {renderReportingDurationButton(
2569
2952
  row.total,
2953
+ {
2954
+ kind: "tag",
2955
+ title:
2956
+ row.displayTag ||
2957
+ row.tagKey,
2958
+ weekStart:
2959
+ row.weekStart,
2960
+ tagKey: row.tagKey,
2961
+ sourceLabel:
2962
+ weekRangeLabelForSource(
2963
+ row.weekStart,
2964
+ lang,
2965
+ reportLocale,
2966
+ ),
2967
+ },
2968
+ "text-zinc-100",
2570
2969
  )}
2571
2970
  </td>
2572
2971
  </tr>
@@ -2653,8 +3052,22 @@ function ReportingContent() {
2653
3052
  title={dateKey}
2654
3053
  >
2655
3054
  {mins > 0
2656
- ? formatDuration(
3055
+ ? renderReportingDurationButton(
2657
3056
  mins,
3057
+ {
3058
+ kind: "project",
3059
+ title: `@${block.displayProject}`,
3060
+ dayKey:
3061
+ dateKey,
3062
+ projectKey:
3063
+ block.projectKeyLower,
3064
+ sourceLabel:
3065
+ dayLabel(
3066
+ dateKey,
3067
+ t.undatedLabel,
3068
+ ),
3069
+ },
3070
+ "text-zinc-200",
2658
3071
  )
2659
3072
  : "—"}
2660
3073
  </td>
@@ -2662,8 +3075,23 @@ function ReportingContent() {
2662
3075
  },
2663
3076
  )}
2664
3077
  <td className="px-2 py-2 pr-3 text-center tabular-nums font-semibold text-zinc-100">
2665
- {formatDuration(
3078
+ {renderReportingDurationButton(
2666
3079
  block.parentTotal,
3080
+ {
3081
+ kind: "project",
3082
+ title: `@${block.displayProject}`,
3083
+ weekStart:
3084
+ block.weekStart,
3085
+ projectKey:
3086
+ block.projectKeyLower,
3087
+ sourceLabel:
3088
+ weekRangeLabelForSource(
3089
+ block.weekStart,
3090
+ lang,
3091
+ reportLocale,
3092
+ ),
3093
+ },
3094
+ "text-zinc-100",
2667
3095
  )}
2668
3096
  </td>
2669
3097
  </tr>
@@ -2708,8 +3136,24 @@ function ReportingContent() {
2708
3136
  }
2709
3137
  >
2710
3138
  {mins > 0
2711
- ? formatDuration(
3139
+ ? renderReportingDurationButton(
2712
3140
  mins,
3141
+ {
3142
+ kind: "tag",
3143
+ title:
3144
+ child.displayTag ||
3145
+ child.tagKey,
3146
+ dayKey:
3147
+ dateKey,
3148
+ tagKey:
3149
+ child.tagKey,
3150
+ sourceLabel:
3151
+ dayLabel(
3152
+ dateKey,
3153
+ t.undatedLabel,
3154
+ ),
3155
+ },
3156
+ "text-zinc-300/95",
2713
3157
  )
2714
3158
  : "—"}
2715
3159
  </td>
@@ -2717,8 +3161,25 @@ function ReportingContent() {
2717
3161
  },
2718
3162
  )}
2719
3163
  <td className="px-2 py-2 pr-3 text-center tabular-nums font-medium text-zinc-200">
2720
- {formatDuration(
3164
+ {renderReportingDurationButton(
2721
3165
  child.total,
3166
+ {
3167
+ kind: "tag",
3168
+ title:
3169
+ child.displayTag ||
3170
+ child.tagKey,
3171
+ weekStart:
3172
+ child.weekStart,
3173
+ tagKey:
3174
+ child.tagKey,
3175
+ sourceLabel:
3176
+ weekRangeLabelForSource(
3177
+ child.weekStart,
3178
+ lang,
3179
+ reportLocale,
3180
+ ),
3181
+ },
3182
+ "text-zinc-200",
2722
3183
  )}
2723
3184
  </td>
2724
3185
  </tr>
@@ -2856,45 +3317,239 @@ function ReportingContent() {
2856
3317
  </tr>
2857
3318
  </thead>
2858
3319
  <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
- : "—"}
3320
+ {rows.map((row) => {
3321
+ const projectKeyLower =
3322
+ normalizeProjectKey(
3323
+ row.projectKey,
3324
+ ).toLowerCase();
3325
+ const breakdownKey = `${weekStart}:::${projectKeyLower}`;
3326
+ const childTagRows =
3327
+ projectScopedTagRowsByWeekProject.get(
3328
+ breakdownKey,
3329
+ ) ?? [];
3330
+ const canExpand =
3331
+ childTagRows.length > 0;
3332
+ const isOpen =
3333
+ canExpand &&
3334
+ projectWeekTagBreakdownOpenKeys.has(
3335
+ breakdownKey,
3336
+ );
3337
+ const rowTitle =
3338
+ row.displayProject ||
3339
+ row.projectKey;
3340
+ const rowProjectDescription =
3341
+ reportingProjectDescriptionLine(
3342
+ row.projectKey,
3343
+ projectDescriptions,
3344
+ ) ?? "";
3345
+ return (
3346
+ <Fragment
3347
+ key={`${weekStart}\0${row.projectKey}`}
3348
+ >
3349
+ <tr className="border-b border-zinc-800/70 last:border-0">
3350
+ {canExpand ? (
3351
+ <td className="align-top px-2 py-2 pl-3 text-sky-200/90">
3352
+ <div className="flex items-start gap-1.5">
3353
+ <button
3354
+ type="button"
3355
+ aria-expanded={isOpen}
3356
+ aria-label={
3357
+ t.tagWeekScopedRollupToggleAria
3358
+ }
3359
+ className="mt-0.5 shrink-0 rounded p-0.5 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
3360
+ onClick={() =>
3361
+ toggleProjectWeekTagBreakdown(
3362
+ breakdownKey,
3363
+ )
3364
+ }
3365
+ >
3366
+ <ChevronRight
3367
+ className={`size-4 transition-transform duration-200 ${
3368
+ isOpen
3369
+ ? "rotate-90"
3370
+ : ""
3371
+ }`}
3372
+ strokeWidth={2}
3373
+ aria-hidden
3374
+ />
3375
+ </button>
3376
+ <div className="min-w-0">
3377
+ <div>
3378
+ {row.projectKey === ""
3379
+ ? t.projectUnassigned
3380
+ : row.displayProject ||
3381
+ row.projectKey}
3382
+ </div>
3383
+ {rowProjectDescription ? (
3384
+ <p className="mt-1 max-w-[min(22rem,55vw)] whitespace-pre-line text-[0.65rem] font-normal leading-snug text-zinc-500">
3385
+ {
3386
+ rowProjectDescription
3387
+ }
3388
+ </p>
3389
+ ) : null}
3390
+ <p className="mt-1 text-[0.65rem] font-normal tabular-nums text-zinc-500">
3391
+ {childTagRows.length}{" "}
3392
+ {lang === "fr"
3393
+ ? "étiquette(s) liée(s)"
3394
+ : "linked tag(s)"}
3395
+ </p>
3396
+ </div>
3397
+ </div>
3398
+ </td>
3399
+ ) : (
3400
+ <ReportingProjectNameCell
3401
+ projectKey={row.projectKey}
3402
+ displayLabel={
3403
+ row.displayProject ||
3404
+ row.projectKey
3405
+ }
3406
+ unassignedLabel={
3407
+ t.projectUnassigned
3408
+ }
3409
+ descriptions={
3410
+ projectDescriptions
3411
+ }
3412
+ className="px-2 py-2 pl-3 text-sky-200/90"
3413
+ />
3414
+ )}
3415
+ {row.slots.map((mins, i) => {
3416
+ const dateKey = addDaysYmd(
3417
+ row.weekStart,
3418
+ i,
3419
+ );
3420
+ return (
3421
+ <td
3422
+ key={dateKey}
3423
+ className="px-1 py-2 text-center tabular-nums text-zinc-200"
3424
+ title={dateKey}
3425
+ >
3426
+ {mins > 0
3427
+ ? renderReportingDurationButton(
3428
+ mins,
3429
+ {
3430
+ kind: "project",
3431
+ title: rowTitle,
3432
+ dayKey: dateKey,
3433
+ projectKey:
3434
+ row.projectKey,
3435
+ sourceLabel:
3436
+ dayLabel(
3437
+ dateKey,
3438
+ t.undatedLabel,
3439
+ ),
3440
+ },
3441
+ "text-zinc-200",
3442
+ )
3443
+ : "—"}
3444
+ </td>
3445
+ );
3446
+ })}
3447
+ <td className="px-2 py-2 pr-3 text-center tabular-nums font-semibold text-zinc-100">
3448
+ {renderReportingDurationButton(
3449
+ row.total,
3450
+ {
3451
+ kind: "project",
3452
+ title: rowTitle,
3453
+ weekStart: row.weekStart,
3454
+ projectKey:
3455
+ row.projectKey,
3456
+ sourceLabel:
3457
+ weekRangeLabelForSource(
3458
+ row.weekStart,
3459
+ lang,
3460
+ reportLocale,
3461
+ ),
3462
+ },
3463
+ "text-zinc-100",
3464
+ )}
2890
3465
  </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
- ))}
3466
+ </tr>
3467
+ {isOpen
3468
+ ? childTagRows.map((child) => (
3469
+ <tr
3470
+ key={`${weekStart}\0${row.projectKey}\0${child.tagKey}`}
3471
+ className="border-b border-zinc-800/55 bg-zinc-950/30"
3472
+ >
3473
+ <ReportingTagNameCell
3474
+ tagKey={child.tagKey}
3475
+ displayLabel={
3476
+ child.displayTag ||
3477
+ child.tagKey
3478
+ }
3479
+ untaggedLabel={
3480
+ t.tagTimeUntagged
3481
+ }
3482
+ descriptions={
3483
+ tagDescriptions
3484
+ }
3485
+ className="px-2 py-2 pl-10 text-violet-200/85"
3486
+ />
3487
+ {child.slots.map(
3488
+ (mins, i) => {
3489
+ const dateKey =
3490
+ addDaysYmd(
3491
+ child.weekStart,
3492
+ i,
3493
+ );
3494
+ return (
3495
+ <td
3496
+ key={dateKey}
3497
+ className="px-1 py-2 text-center tabular-nums text-zinc-300/95"
3498
+ title={dateKey}
3499
+ >
3500
+ {mins > 0
3501
+ ? renderReportingDurationButton(
3502
+ mins,
3503
+ {
3504
+ kind: "tag",
3505
+ title:
3506
+ child.displayTag ||
3507
+ child.tagKey,
3508
+ dayKey:
3509
+ dateKey,
3510
+ tagKey:
3511
+ child.tagKey,
3512
+ sourceLabel:
3513
+ dayLabel(
3514
+ dateKey,
3515
+ t.undatedLabel,
3516
+ ),
3517
+ },
3518
+ "text-zinc-300/95",
3519
+ )
3520
+ : "—"}
3521
+ </td>
3522
+ );
3523
+ },
3524
+ )}
3525
+ <td className="px-2 py-2 pr-3 text-center tabular-nums font-medium text-zinc-200">
3526
+ {renderReportingDurationButton(
3527
+ child.total,
3528
+ {
3529
+ kind: "tag",
3530
+ title:
3531
+ child.displayTag ||
3532
+ child.tagKey,
3533
+ weekStart:
3534
+ child.weekStart,
3535
+ tagKey:
3536
+ child.tagKey,
3537
+ sourceLabel:
3538
+ weekRangeLabelForSource(
3539
+ child.weekStart,
3540
+ lang,
3541
+ reportLocale,
3542
+ ),
3543
+ },
3544
+ "text-zinc-200",
3545
+ )}
3546
+ </td>
3547
+ </tr>
3548
+ ))
3549
+ : null}
3550
+ </Fragment>
3551
+ );
3552
+ })}
2898
3553
  </tbody>
2899
3554
  </table>
2900
3555
  </div>
@@ -2917,7 +3572,144 @@ function ReportingContent() {
2917
3572
  </div>
2918
3573
  )}
2919
3574
  </div>
3575
+ <div
3576
+ aria-hidden
3577
+ className="pointer-events-none h-[45vh] min-h-44 max-h-80"
3578
+ />
2920
3579
  <ScrollToTopFab ariaLabel={t.scrollToTopAria} />
3580
+ {portalReady && taskInspectScope
3581
+ ? createPortal(
3582
+ <div
3583
+ className="fixed inset-0 z-[140] bg-black/65 p-4"
3584
+ role="dialog"
3585
+ aria-modal="true"
3586
+ aria-label={lang === "fr" ? "Détails des tâches" : "Task details"}
3587
+ onClick={(e) => {
3588
+ if (e.target === e.currentTarget) {
3589
+ setTaskInspectScope(null);
3590
+ setTaskInspectAnchor(null);
3591
+ setTaskInspectModalRect(null);
3592
+ }
3593
+ }}
3594
+ >
3595
+ {taskInspectAnchor && taskInspectConnector ? (
3596
+ <>
3597
+ <div
3598
+ className="pointer-events-none fixed z-[141] size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-violet-200/70 shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
3599
+ style={{
3600
+ left: `${taskInspectAnchor.x}px`,
3601
+ top: `${taskInspectAnchor.y}px`,
3602
+ }}
3603
+ />
3604
+ <div
3605
+ className="pointer-events-none fixed z-[141] h-px origin-left border-t border-dashed border-violet-200/45"
3606
+ style={{
3607
+ left: `${taskInspectConnector.lineStartX}px`,
3608
+ top: `${taskInspectConnector.lineStartY}px`,
3609
+ width: `${taskInspectConnector.length}px`,
3610
+ transform: `rotate(${taskInspectConnector.angleRad}rad)`,
3611
+ }}
3612
+ />
3613
+ </>
3614
+ ) : null}
3615
+ <div
3616
+ ref={taskInspectModalRef}
3617
+ className="max-h-[72vh] overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl"
3618
+ style={
3619
+ taskInspectPlacement
3620
+ ? {
3621
+ position: "fixed" as const,
3622
+ left: `${taskInspectPlacement.left}px`,
3623
+ top: `${
3624
+ taskInspectPlacementTop ?? taskInspectPlacement.top
3625
+ }px`,
3626
+ width: `${taskInspectPlacement.width}px`,
3627
+ }
3628
+ : {
3629
+ position: "fixed" as const,
3630
+ left: "50%",
3631
+ top: "50%",
3632
+ width: "min(56rem, calc(100vw - 2rem))",
3633
+ transform: "translate(-50%, -50%)",
3634
+ }
3635
+ }
3636
+ >
3637
+ <div className="flex items-center justify-between gap-3 border-b border-zinc-700 px-4 py-3">
3638
+ <div className="min-w-0">
3639
+ <h2 className="min-w-0 truncate text-sm font-semibold text-zinc-100 sm:text-base">
3640
+ {lang === "fr"
3641
+ ? `Détails des tâches - ${taskInspectScope.title}`
3642
+ : `Task details - ${taskInspectScope.title}`}
3643
+ </h2>
3644
+ {taskInspectScope.sourceLabel ? (
3645
+ <p className="mt-0.5 truncate text-[0.7rem] text-zinc-400">
3646
+ {lang === "fr"
3647
+ ? `Source : ${
3648
+ taskInspectScope.kind === "tag"
3649
+ ? "Étiquette"
3650
+ : "Projet"
3651
+ } - ${taskInspectScope.sourceLabel}`
3652
+ : `Source: ${
3653
+ taskInspectScope.kind === "tag"
3654
+ ? "Tag"
3655
+ : "Project"
3656
+ } - ${taskInspectScope.sourceLabel}`}
3657
+ </p>
3658
+ ) : null}
3659
+ </div>
3660
+ <button
3661
+ type="button"
3662
+ className="rounded border border-zinc-600 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-800"
3663
+ onClick={() => {
3664
+ setTaskInspectScope(null);
3665
+ setTaskInspectAnchor(null);
3666
+ setTaskInspectModalRect(null);
3667
+ }}
3668
+ >
3669
+ {lang === "fr" ? "Fermer" : "Close"}
3670
+ </button>
3671
+ </div>
3672
+ <div className="max-h-[calc(72vh-3.25rem)] overflow-y-auto p-4">
3673
+ {taskInspectList.length > 0 ? (
3674
+ <div className="space-y-3">
3675
+ {taskInspectList.map((entry) => (
3676
+ <TaskSessionLiveCard
3677
+ key={`${entry.sessionId}\0${entry.task.id}`}
3678
+ task={entry.task}
3679
+ lang={lang}
3680
+ isInspecting
3681
+ inspectingId={entry.sessionId}
3682
+ knownTags={tagKeys}
3683
+ knownProjects={knownProjects}
3684
+ post={postReportingTaskAction}
3685
+ t={dt}
3686
+ confirmDeleteTask={() => {}}
3687
+ kronoFocusIsRunningOrPaused={false}
3688
+ showKronoFocusTaskActions={false}
3689
+ startKronoFocusFromTask={() => {}}
3690
+ onDuplicateTask={() => {}}
3691
+ pausePlayMode={entry.pausePlayMode}
3692
+ onPausePlay={() => {}}
3693
+ showTaskActionButtons={false}
3694
+ allowAddSubtasks={false}
3695
+ showLiveTimerWhenInspecting
3696
+ deriveLiveTimerFromStartTime={false}
3697
+ />
3698
+ ))}
3699
+ </div>
3700
+ ) : (
3701
+ <p className="text-sm text-zinc-400">
3702
+ {lang === "fr"
3703
+ ? "Aucune tâche correspondante pour ce segment de temps."
3704
+ : "No matching tasks for this time segment."}
3705
+ </p>
3706
+ )}
3707
+ </div>
3708
+ </div>
3709
+ </div>,
3710
+ document.body,
3711
+ )
3712
+ : null}
2921
3713
  <ReportingTour
2922
3714
  open={reportingTourOpen}
2923
3715
  onOpenChange={setReportingTourOpen}