@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
@@ -0,0 +1,601 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useMemo, useState } from "react";
4
+ import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
5
+ import { formatIsoInstantShort } from "@/lib/formatIsoShort";
6
+ import {
7
+ formatDuration,
8
+ formatProjectDisplay,
9
+ formatWallDurationMs,
10
+ taskTitleForDisplay,
11
+ } from "@/lib/taskParsing";
12
+ import type { TaskTimelineGanttRow } from "@/lib/taskTimelineGantt";
13
+ import {
14
+ DEFAULT_DASHBOARD_TIME_ZONE,
15
+ isValidIanaTimeZone,
16
+ } from "@/lib/dashboardTimeZone";
17
+
18
+ export type { TaskTimelineGanttRow } from "@/lib/taskTimelineGantt";
19
+
20
+ const FILL_BG: readonly string[] = [
21
+ "bg-violet-500/85 dark:bg-violet-500/75",
22
+ "bg-emerald-500/85 dark:bg-emerald-500/75",
23
+ "bg-amber-500/85 dark:bg-amber-500/75",
24
+ "bg-sky-500/85 dark:bg-sky-500/75",
25
+ "bg-rose-500/85 dark:bg-rose-500/75",
26
+ "bg-fuchsia-500/80 dark:bg-fuchsia-500/70",
27
+ ];
28
+
29
+ function fillClassForId(id: string): string {
30
+ let h = 0;
31
+ for (let i = 0; i < id.length; i++) {
32
+ h = (h * 31 + id.charCodeAt(i)) >>> 0;
33
+ }
34
+ return FILL_BG[h % FILL_BG.length] ?? FILL_BG[0];
35
+ }
36
+
37
+ const GAP_STRIPES =
38
+ "bg-[repeating-linear-gradient(-45deg,theme(colors.zinc.200),theme(colors.zinc.200)_5px,theme(colors.zinc.100)_5px,theme(colors.zinc.100)_10px)] dark:bg-[repeating-linear-gradient(-45deg,theme(colors.zinc.700),theme(colors.zinc.700)_5px,theme(colors.zinc.800)_5px,theme(colors.zinc.800)_10px)]";
39
+
40
+ const HOUR_MS = 60 * 60 * 1000;
41
+ const THIRTY_MS = 30 * 60 * 1000;
42
+ const PX_PER_MINUTE = 3;
43
+ const MIN_TRACK_PX = 520;
44
+
45
+ function useEscapeClose(open: boolean, onClose: () => void) {
46
+ useEffect(() => {
47
+ if (!open) {
48
+ return;
49
+ }
50
+ const onKey = (e: KeyboardEvent) => {
51
+ if (e.key === "Escape") {
52
+ onClose();
53
+ }
54
+ };
55
+ document.addEventListener("keydown", onKey);
56
+ return () => document.removeEventListener("keydown", onKey);
57
+ }, [open, onClose]);
58
+ }
59
+
60
+ function clampPct(n: number): number {
61
+ if (!Number.isFinite(n)) {
62
+ return 0;
63
+ }
64
+ return Math.min(100, Math.max(0, n));
65
+ }
66
+
67
+ function formatAxisTickLabel(
68
+ ms: number,
69
+ tier: "major" | "half" | "minor",
70
+ lang: Lang,
71
+ displayTimeZone: string,
72
+ use24HourClock: boolean,
73
+ ): string {
74
+ const iso = new Date(ms).toISOString();
75
+ if (tier === "major") {
76
+ return (
77
+ formatIsoInstantShort(iso, lang, displayTimeZone, use24HourClock) ?? ""
78
+ );
79
+ }
80
+ const tz =
81
+ displayTimeZone.trim() && isValidIanaTimeZone(displayTimeZone.trim())
82
+ ? displayTimeZone.trim()
83
+ : DEFAULT_DASHBOARD_TIME_ZONE;
84
+ const opts: Intl.DateTimeFormatOptions = {
85
+ hour: "numeric",
86
+ minute: "2-digit",
87
+ hour12: !use24HourClock,
88
+ };
89
+ if (tz) {
90
+ opts.timeZone = tz;
91
+ }
92
+ return new Intl.DateTimeFormat(
93
+ lang === "fr" ? "fr-CA" : "en-CA",
94
+ opts,
95
+ ).format(new Date(ms));
96
+ }
97
+
98
+ type AxisTick = { ms: number; tier: "major" | "half" | "minor"; label: string };
99
+
100
+ function buildAxisTicks(
101
+ windowStartMs: number,
102
+ windowEndMs: number,
103
+ spanMs: number,
104
+ lang: Lang,
105
+ displayTimeZone: string,
106
+ use24HourClock: boolean,
107
+ ): AxisTick[] {
108
+ if (!Number.isFinite(spanMs) || spanMs <= 0) {
109
+ return [];
110
+ }
111
+ const out: AxisTick[] = [];
112
+ const firstTickMs = Math.floor(windowStartMs / THIRTY_MS) * THIRTY_MS;
113
+ for (let t = firstTickMs; t <= windowEndMs + 1; t += THIRTY_MS) {
114
+ const tier: AxisTick["tier"] = t % HOUR_MS === 0 ? "major" : "half";
115
+ if (t > windowEndMs + 1) {
116
+ break;
117
+ }
118
+ const label = formatAxisTickLabel(
119
+ t,
120
+ tier,
121
+ lang,
122
+ displayTimeZone,
123
+ use24HourClock,
124
+ );
125
+ out.push({ ms: t, tier, label });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ function barTooltipText(
131
+ r: TaskTimelineGanttRow,
132
+ effEndMs: number,
133
+ t: DashboardStrings,
134
+ lang: Lang,
135
+ displayTimeZone: string,
136
+ use24HourClock: boolean,
137
+ ): string {
138
+ const wallMs = Math.max(0, effEndMs - r.startMs);
139
+ const endIso = formatIsoInstantShort(
140
+ new Date(effEndMs).toISOString(),
141
+ lang,
142
+ displayTimeZone,
143
+ use24HourClock,
144
+ );
145
+ const timerMs =
146
+ r.timerDurationMs !== null && Number.isFinite(r.timerDurationMs)
147
+ ? r.timerDurationMs
148
+ : null;
149
+ const timerStr =
150
+ timerMs !== null
151
+ ? formatWallDurationMs(timerMs)
152
+ : t.tasksTimelineGanttTooltipTimerUnknown;
153
+ const wallStr = formatWallDurationMs(wallMs);
154
+ return [
155
+ `${t.tasksTimelineGanttTooltipStart}: ${r.startLabel}`,
156
+ `${t.tasksTimelineGanttTooltipEnd}: ${endIso ?? r.endLabel}`,
157
+ `${t.tasksTimelineGanttTooltipTimer}: ${timerStr}`,
158
+ `${t.tasksTimelineGanttTooltipWall}: ${wallStr}`,
159
+ ].join("\n");
160
+ }
161
+
162
+ export function TaskTimelineGanttModal({
163
+ open,
164
+ onClose,
165
+ lang,
166
+ displayTimeZone,
167
+ use24HourClock,
168
+ t,
169
+ title,
170
+ description,
171
+ rows,
172
+ sessionStartMs,
173
+ sessionEndMs,
174
+ }: {
175
+ open: boolean;
176
+ onClose: () => void;
177
+ lang: Lang;
178
+ displayTimeZone: string;
179
+ use24HourClock: boolean;
180
+ t: DashboardStrings;
181
+ title?: string;
182
+ description?: string;
183
+ rows: TaskTimelineGanttRow[];
184
+ sessionStartMs: number | null;
185
+ sessionEndMs: number | null;
186
+ }) {
187
+ const titleId = useId();
188
+ const [nowMs, setNowMs] = useState(() => Date.now());
189
+ useEscapeClose(open, onClose);
190
+
191
+ useEffect(() => {
192
+ if (!open) {
193
+ return;
194
+ }
195
+ setNowMs(Date.now());
196
+ const id = globalThis.setInterval(() => {
197
+ setNowMs(Date.now());
198
+ }, 1000);
199
+ return () => globalThis.clearInterval(id);
200
+ }, [open]);
201
+
202
+ const durationTotals = useMemo(() => {
203
+ let productive = 0;
204
+ let personal = 0;
205
+ for (const r of rows) {
206
+ const ms =
207
+ r.timerDurationMs !== null && Number.isFinite(r.timerDurationMs)
208
+ ? Math.max(0, r.timerDurationMs)
209
+ : 0;
210
+ if (ms <= 0) {
211
+ continue;
212
+ }
213
+ if (r.personalProject) {
214
+ personal += ms;
215
+ } else {
216
+ productive += ms;
217
+ }
218
+ }
219
+ return { productive, personal };
220
+ }, [rows]);
221
+
222
+ const { windowStartMs, windowEndMs, spanMs, trackWidthPx } = useMemo(() => {
223
+ if (rows.length === 0) {
224
+ return {
225
+ windowStartMs: 0,
226
+ windowEndMs: 0,
227
+ spanMs: 1,
228
+ trackWidthPx: MIN_TRACK_PX,
229
+ };
230
+ }
231
+
232
+ const taskStarts = rows.map((r) => r.startMs);
233
+ const minTaskStart = Math.min(...taskStarts);
234
+
235
+ let ws: number;
236
+ if (sessionStartMs !== null) {
237
+ ws = sessionStartMs;
238
+ } else {
239
+ ws = minTaskStart;
240
+ }
241
+
242
+ let we = nowMs;
243
+ if (sessionEndMs !== null) {
244
+ we = Math.max(we, sessionEndMs);
245
+ }
246
+ for (const r of rows) {
247
+ const effEnd = typeof r.endMs === "number" ? r.endMs : nowMs;
248
+ we = Math.max(we, effEnd);
249
+ }
250
+ we = Math.max(we, ws + 60_000);
251
+ const alignedWindowStart = Math.floor(ws / THIRTY_MS) * THIRTY_MS;
252
+ let alignedWindowEnd = Math.ceil(we / THIRTY_MS) * THIRTY_MS;
253
+ if (alignedWindowEnd <= alignedWindowStart) {
254
+ alignedWindowEnd = alignedWindowStart + THIRTY_MS;
255
+ }
256
+ const span = Math.max(alignedWindowEnd - alignedWindowStart, THIRTY_MS);
257
+ const spanMin = span / 60000;
258
+ const trackWidthPx = Math.max(MIN_TRACK_PX, spanMin * PX_PER_MINUTE);
259
+
260
+ return {
261
+ windowStartMs: alignedWindowStart,
262
+ windowEndMs: alignedWindowEnd,
263
+ spanMs: span,
264
+ trackWidthPx,
265
+ };
266
+ }, [rows, sessionStartMs, sessionEndMs, nowMs]);
267
+
268
+ const axisTicks = useMemo(
269
+ () =>
270
+ rows.length === 0
271
+ ? ([] as AxisTick[])
272
+ : buildAxisTicks(
273
+ windowStartMs,
274
+ windowEndMs,
275
+ spanMs,
276
+ lang,
277
+ displayTimeZone,
278
+ use24HourClock,
279
+ ),
280
+ [
281
+ rows.length,
282
+ windowStartMs,
283
+ windowEndMs,
284
+ spanMs,
285
+ lang,
286
+ displayTimeZone,
287
+ use24HourClock,
288
+ ],
289
+ );
290
+
291
+ const sessionOriginLabel = useMemo(
292
+ () =>
293
+ formatIsoInstantShort(
294
+ new Date(windowStartMs).toISOString(),
295
+ lang,
296
+ displayTimeZone,
297
+ use24HourClock,
298
+ ),
299
+ [windowStartMs, lang, displayTimeZone, use24HourClock],
300
+ );
301
+
302
+ const sessionEndLabel = useMemo(
303
+ () =>
304
+ formatIsoInstantShort(
305
+ new Date(windowEndMs).toISOString(),
306
+ lang,
307
+ displayTimeZone,
308
+ use24HourClock,
309
+ ),
310
+ [windowEndMs, lang, displayTimeZone, use24HourClock],
311
+ );
312
+
313
+ const axisBorderClass = (tier: AxisTick["tier"]) => {
314
+ if (tier === "major") {
315
+ return "border-l-[1.5px] border-zinc-400 dark:border-zinc-500";
316
+ }
317
+ if (tier === "half") {
318
+ return "border-l border-zinc-400/85 dark:border-zinc-500/70";
319
+ }
320
+ return "border-l border-zinc-300/75 dark:border-zinc-600/55";
321
+ };
322
+
323
+ const axisLabelClass = (tier: AxisTick["tier"]) => {
324
+ if (tier === "major") {
325
+ return "max-w-[10rem] truncate text-[0.62rem] font-medium leading-none text-zinc-600 dark:text-zinc-300";
326
+ }
327
+ if (tier === "half") {
328
+ return "max-w-[6rem] truncate text-[0.58rem] leading-none text-zinc-500 dark:text-zinc-400";
329
+ }
330
+ return "max-w-[5rem] truncate text-[0.54rem] leading-none text-zinc-400/95 dark:text-zinc-500";
331
+ };
332
+
333
+ if (!open) {
334
+ return null;
335
+ }
336
+
337
+ return (
338
+ <div
339
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
340
+ role="dialog"
341
+ aria-modal="true"
342
+ aria-labelledby={titleId}
343
+ onClick={onClose}
344
+ >
345
+ <div
346
+ className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
347
+ onClick={(e) => e.stopPropagation()}
348
+ >
349
+ <div className="border-b border-zinc-200 px-5 py-4 dark:border-zinc-700/80">
350
+ <h2
351
+ id={titleId}
352
+ className="text-lg font-semibold text-zinc-900 dark:text-zinc-100"
353
+ >
354
+ {title ?? t.tasksTimelineGanttTitle}
355
+ </h2>
356
+ <p className="mt-1.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
357
+ {description ?? t.tasksTimelineGanttDescription}
358
+ </p>
359
+ </div>
360
+
361
+ <div className="min-h-0 flex-1 overflow-hidden px-5 py-4">
362
+ {rows.length === 0 ? (
363
+ <p className="text-sm text-zinc-500">{t.tasksTimelineEmpty}</p>
364
+ ) : (
365
+ <div className="flex max-h-[min(70vh,540px)] min-h-[240px] flex-col gap-0 overflow-hidden">
366
+ <div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
367
+ <div className="flex min-w-0">
368
+ <div className="flex w-[min(13rem,32vw)] shrink-0 flex-col border-r border-zinc-200 bg-white pr-2 dark:border-zinc-700/80 dark:bg-zinc-900">
369
+ <div className="h-10 shrink-0" aria-hidden />
370
+ {rows.map((r) => (
371
+ <div
372
+ key={`label-${r.id}`}
373
+ className="flex min-h-11 items-start gap-2 border-b border-zinc-100 py-1.5 dark:border-zinc-800/90"
374
+ >
375
+ <div className="min-w-0 flex-1">
376
+ <p className="truncate text-left text-sm font-medium text-zinc-900 dark:text-zinc-100">
377
+ {taskTitleForDisplay(r.name)}
378
+ </p>
379
+ {typeof r.project === "string" && r.project.trim() ? (
380
+ <p className="mt-0.5 truncate text-left text-[0.65rem] text-zinc-500 dark:text-zinc-400">
381
+ {formatProjectDisplay(r.project, {
382
+ personal: r.personalProject === true,
383
+ })}
384
+ </p>
385
+ ) : null}
386
+ </div>
387
+ {r.durationLabel ? (
388
+ <span className="shrink-0 rounded-full border border-zinc-500/40 px-1.5 py-0.5 text-[0.6rem] tabular-nums text-zinc-500 dark:text-zinc-400">
389
+ {r.durationLabel}
390
+ </span>
391
+ ) : null}
392
+ </div>
393
+ ))}
394
+ <div className="h-10 shrink-0" aria-hidden />
395
+ </div>
396
+
397
+ <div className="min-w-0 flex-1 overflow-x-auto">
398
+ <div
399
+ className="flex flex-col"
400
+ style={{ width: `${trackWidthPx}px`, minWidth: "100%" }}
401
+ >
402
+ <div className="sticky top-0 z-[1] h-10 shrink-0 border-b border-zinc-200 bg-white dark:border-zinc-700/80 dark:bg-zinc-900">
403
+ <div className="relative h-full">
404
+ {axisTicks.map((tick) => {
405
+ const leftPct = clampPct(
406
+ ((tick.ms - windowStartMs) / spanMs) * 100,
407
+ );
408
+ return (
409
+ <div
410
+ key={`top-${tick.ms}`}
411
+ className={`absolute top-0 bottom-0 ${axisBorderClass(
412
+ tick.tier,
413
+ )}`}
414
+ style={{ left: `${leftPct}%` }}
415
+ >
416
+ <span
417
+ className={`absolute left-0.5 top-1 whitespace-nowrap ${axisLabelClass(
418
+ tick.tier,
419
+ )}`}
420
+ >
421
+ {tick.label}
422
+ </span>
423
+ </div>
424
+ );
425
+ })}
426
+ </div>
427
+ </div>
428
+
429
+ {rows.map((r) => {
430
+ const effEnd =
431
+ typeof r.endMs === "number" ? r.endMs : nowMs;
432
+ const clipStart = Math.max(r.startMs, windowStartMs);
433
+ const clipEnd = Math.min(effEnd, windowEndMs);
434
+ const fillClass = r.personalProject
435
+ ? "bg-rose-500/85 dark:bg-rose-500/75"
436
+ : fillClassForId(r.id);
437
+ const left = clampPct(
438
+ ((clipStart - windowStartMs) / spanMs) * 100,
439
+ );
440
+ const width = clampPct(
441
+ ((clipEnd - clipStart) / spanMs) * 100,
442
+ );
443
+ const barW = Math.max(
444
+ width,
445
+ clipEnd > clipStart ? 0.2 : 0,
446
+ );
447
+ const right = left + barW;
448
+ const tip =
449
+ clipEnd > clipStart
450
+ ? barTooltipText(
451
+ r,
452
+ effEnd,
453
+ t,
454
+ lang,
455
+ displayTimeZone,
456
+ use24HourClock,
457
+ )
458
+ : "";
459
+
460
+ return (
461
+ <div
462
+ key={`row-${r.id}`}
463
+ className="relative min-h-11 shrink-0 border-b border-zinc-100 dark:border-zinc-800/90"
464
+ >
465
+ <div
466
+ className={`absolute inset-0 ${GAP_STRIPES}`}
467
+ aria-hidden
468
+ />
469
+ <div
470
+ className={`absolute top-0 bottom-0 ${fillClass} z-[4] cursor-default rounded-sm shadow-sm ring-1 ring-black/5 dark:ring-white/10`}
471
+ style={{
472
+ left: `${left}%`,
473
+ width: `${barW}%`,
474
+ minWidth:
475
+ clipEnd > clipStart && barW > 0 ? "4px" : 0,
476
+ opacity: clipEnd > clipStart ? 0.92 : 0,
477
+ pointerEvents:
478
+ clipEnd > clipStart ? "auto" : "none",
479
+ }}
480
+ title={tip || undefined}
481
+ aria-hidden={clipEnd <= clipStart}
482
+ />
483
+ <div
484
+ className={`pointer-events-none absolute inset-0 z-[2] ${GAP_STRIPES}`}
485
+ style={{
486
+ clipPath: `polygon(0% 0%, ${left}% 0%, ${left}% 100%, 0% 100%)`,
487
+ }}
488
+ aria-hidden
489
+ />
490
+ <div
491
+ className={`pointer-events-none absolute inset-0 z-[2] ${GAP_STRIPES}`}
492
+ style={{
493
+ clipPath: `polygon(${right}% 0%, 100% 0%, 100% 100%, ${right}% 100%)`,
494
+ }}
495
+ aria-hidden
496
+ />
497
+ </div>
498
+ );
499
+ })}
500
+
501
+ <div className="h-10 shrink-0 border-t border-zinc-200 bg-white dark:border-zinc-700/80 dark:bg-zinc-900">
502
+ <div className="relative h-full">
503
+ {axisTicks.map((tick) => {
504
+ const leftPct = clampPct(
505
+ ((tick.ms - windowStartMs) / spanMs) * 100,
506
+ );
507
+ return (
508
+ <div
509
+ key={`bottom-${tick.ms}`}
510
+ className={`absolute top-0 bottom-0 ${axisBorderClass(
511
+ tick.tier,
512
+ )}`}
513
+ style={{ left: `${leftPct}%` }}
514
+ >
515
+ <span
516
+ className={`absolute bottom-1 left-0.5 whitespace-nowrap ${axisLabelClass(
517
+ tick.tier,
518
+ )}`}
519
+ >
520
+ {tick.label}
521
+ </span>
522
+ </div>
523
+ );
524
+ })}
525
+ </div>
526
+ </div>
527
+ </div>
528
+ </div>
529
+ </div>
530
+ </div>
531
+
532
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 border-t border-zinc-200 pt-2 text-xs text-zinc-600 dark:border-zinc-700/80 dark:text-zinc-400">
533
+ <span>
534
+ {t.tasksTimelineGanttSummaryProductive.replace(
535
+ "{duration}",
536
+ formatDuration(durationTotals.productive / 60000),
537
+ )}
538
+ </span>
539
+ <span>
540
+ {t.tasksTimelineGanttSummaryPersonal.replace(
541
+ "{duration}",
542
+ formatDuration(durationTotals.personal / 60000),
543
+ )}
544
+ </span>
545
+ </div>
546
+
547
+ <div className="mt-2 flex flex-wrap justify-between gap-2 border-t border-zinc-200 pt-2 text-[0.65rem] text-zinc-500 dark:border-zinc-700/80 dark:text-zinc-500">
548
+ <span>
549
+ <span className="font-semibold uppercase tracking-wide">
550
+ {t.tasksTimelineGanttWindowStartLabel}
551
+ </span>{" "}
552
+ <span className="font-mono tabular-nums text-zinc-700 dark:text-zinc-300">
553
+ {sessionOriginLabel}
554
+ </span>
555
+ </span>
556
+ <span>
557
+ <span className="font-semibold uppercase tracking-wide">
558
+ {t.tasksTimelineGanttWindowEndLabel}
559
+ </span>{" "}
560
+ <span className="font-mono tabular-nums text-zinc-700 dark:text-zinc-300">
561
+ {sessionEndLabel}
562
+ </span>
563
+ </span>
564
+ </div>
565
+ </div>
566
+ )}
567
+ </div>
568
+
569
+ <div className="border-t border-zinc-200 px-5 py-4 dark:border-zinc-700/80">
570
+ <div className="flex flex-wrap items-center gap-3">
571
+ <span
572
+ className={`inline-block size-3 shrink-0 rounded-sm ${FILL_BG[0]}`}
573
+ />
574
+ <span className="text-xs text-zinc-600 dark:text-zinc-400">
575
+ {t.tasksTimelineGanttLegendProductive}
576
+ </span>
577
+ <span className="inline-block size-3 shrink-0 rounded-sm bg-rose-500/85 dark:bg-rose-500/75" />
578
+ <span className="text-xs text-zinc-600 dark:text-zinc-400">
579
+ {t.tasksTimelineGanttLegendPersonal}
580
+ </span>
581
+ <span
582
+ className={`inline-block h-3 w-8 shrink-0 rounded-sm ${GAP_STRIPES} ring-1 ring-zinc-300/80 dark:ring-zinc-600/80`}
583
+ />
584
+ <span className="text-xs text-zinc-600 dark:text-zinc-400">
585
+ {t.tasksTimelineGanttLegendGap}
586
+ </span>
587
+ </div>
588
+ <div className="mt-4 flex justify-end">
589
+ <button
590
+ type="button"
591
+ className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
592
+ onClick={onClose}
593
+ >
594
+ {t.tasksTimelineGanttCloseBtn}
595
+ </button>
596
+ </div>
597
+ </div>
598
+ </div>
599
+ </div>
600
+ );
601
+ }
@@ -14,10 +14,11 @@ export const TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS =
14
14
  "h-10 min-h-10 min-w-0 flex-1 border-0 border-b border-zinc-400 bg-transparent px-0 text-xl leading-snug text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-600 focus:ring-0 rounded-none dark:border-zinc-600 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-zinc-200";
15
15
 
16
16
  /**
17
- * Déclencheur date-heure (tâche passée, popover thémée) : violet / zinc comme le reste du tableau de bord.
17
+ * Déclencheur date-heure : même ton que les libellés « début » / « fin » (zinc-500), sans encadré ;
18
+ * l’icône calendrier ouvre le popover (thème du calendrier inchangé).
18
19
  */
19
20
  export const TASK_PAST_DATETIME_TRIGGER_CLASS =
20
- "h-9 w-auto min-w-[8.5rem] max-w-[13rem] shrink-0 rounded-lg border border-violet-500/45 bg-violet-500/10 px-2 py-1 font-mono text-xs tabular-nums text-zinc-800 shadow-none outline-none transition-[border-color,background-color,box-shadow] [color-scheme:light] focus-visible:border-violet-500/75 focus-visible:bg-violet-500/15 focus-visible:ring-2 focus-visible:ring-violet-500/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] dark:border-violet-400/40 dark:bg-violet-600/18 dark:text-zinc-100 dark:[color-scheme:dark] dark:focus-visible:border-violet-400/60 dark:focus-visible:bg-violet-600/26 dark:focus-visible:ring-violet-400/25 dark:focus-visible:ring-offset-[var(--background)]";
21
+ "group/datetime-trigger inline-flex min-h-0 w-auto min-w-0 max-w-[13rem] shrink-0 cursor-pointer items-center gap-1 border-0 bg-transparent p-0 font-mono text-xs tabular-nums shadow-none outline-none transition-[opacity,color] [color-scheme:light] hover:opacity-90 focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-zinc-400/45 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] dark:[color-scheme:dark] dark:focus-visible:ring-zinc-500/40 dark:focus-visible:ring-offset-[var(--background)]";
21
22
 
22
23
  /** Titre tâche en session (lecture) : volontairement plus petit que le champ « nouvelle tâche ». */
23
24
  export const TASK_ACTIVE_TITLE_READ_CLASS =
@@ -77,8 +78,23 @@ export const TAG_SUGGEST_CHIP_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_
77
78
  /** Suggestion de projet enregistré (`@nom`) : même rendu « éteint » que les tags tant que le projet n’est pas appliqué. */
78
79
  export const PROJECT_SUGGEST_CHIP_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-pointer select-none rounded border border-zinc-300 bg-zinc-100 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-zinc-700 hover:border-sky-500/50 hover:bg-sky-50 hover:text-sky-900 dark:border-zinc-600/50 dark:bg-zinc-800/40 dark:text-zinc-400 dark:hover:border-sky-700/50 dark:hover:bg-sky-950/35 dark:hover:text-sky-200/90`;
79
80
 
80
- /** Pastille projet appliquée (ligne sous le titre). */
81
- export const PROJECT_CHIP_APPLIED_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-sky-500/50 bg-sky-50 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-sky-900 dark:border-sky-500/55 dark:bg-sky-950/40 dark:text-sky-100`;
81
+ /** Pastille projet travail appliquée (`@`) teinte ciel. */
82
+ export const PROJECT_CHIP_APPLIED_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-sky-500/50 bg-sky-50 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-sky-950 dark:border-sky-500/55 dark:bg-sky-950/40 dark:text-sky-100`;
83
+
84
+ /** Pastille projet personnel appliquée (`!`) — teinte rose, distincte des projets `@`. */
85
+ export const PROJECT_CHIP_APPLIED_PERSONAL_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-rose-500/50 bg-rose-50 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-rose-950 dark:border-rose-500/55 dark:bg-rose-950/40 dark:text-rose-100`;
86
+
87
+ /** Raccourci projet personnel inactif (suggestions). */
88
+ export const PROJECT_INLINE_SUGGEST_PERSONAL =
89
+ "shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-medium text-zinc-600 transition-colors hover:text-rose-800 dark:text-zinc-400 dark:hover:text-rose-200";
90
+
91
+ /** Raccourci projet personnel actif. */
92
+ export const PROJECT_INLINE_SUGGEST_PERSONAL_SELECTED =
93
+ "shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-semibold text-rose-900 underline decoration-rose-500/80 decoration-2 underline-offset-4 dark:text-rose-100";
94
+
95
+ /** Projet personnel appliqué en style texte. */
96
+ export const PROJECT_INLINE_APPLIED_PERSONAL =
97
+ "text-[0.9375rem] font-semibold text-rose-900 dark:text-rose-100";
82
98
 
83
99
  // --- Rendu « texte » (tâches : sans boîtes type pastille) ----------------------------------------
84
100