@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,700 @@
1
+ "use client";
2
+
3
+ import {
4
+ Suspense,
5
+ useCallback,
6
+ useMemo,
7
+ useState,
8
+ type Dispatch,
9
+ type SetStateAction,
10
+ } from "react";
11
+ import Link from "next/link";
12
+ import { useSearchParams } from "next/navigation";
13
+ import {
14
+ Ban,
15
+ Check,
16
+ ChevronDown,
17
+ Copy,
18
+ FileCode2,
19
+ ScrollText,
20
+ X,
21
+ } from "lucide-react";
22
+ import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
23
+ import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
24
+ import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
25
+ import { ScrollToTopFab } from "@/components/dashboard/ScrollToTopFab";
26
+ import { AppShellRouteNav } from "@/components/dashboard/AppShellRouteNav";
27
+ import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
28
+ import {
29
+ appShellHeaderClassName,
30
+ appShellHeaderTitleMetaRowClassName,
31
+ appShellHeaderToolbarClassName,
32
+ } from "@/lib/appShellHeaderClasses";
33
+ import { AppShellCommandCenterPlaceholder } from "@/components/dashboard/AppShellCommandCenterPlaceholder";
34
+ import { AppShellHeaderSessionMeta } from "@/components/dashboard/AppShellHeaderSessionMeta";
35
+ import { AppShellHeaderWallClock } from "@/components/dashboard/AppShellHeaderWallClock";
36
+ import { dashboardStrings, type Lang } from "@/lib/dashboardCopy";
37
+ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
38
+ import { implementationNotesBundle } from "@/lib/implementationNotes";
39
+ import type {
40
+ ImplementationNotesBundle,
41
+ ImplementationUserStory,
42
+ } from "@/lib/implementationNotes";
43
+ import { copyTextToClipboard } from "@/lib/copyToClipboard";
44
+ import { businessRulesMatrixBundle } from "@/lib/businessRulesMatrix";
45
+ import { reportingNav } from "@/lib/reportingStrings";
46
+ import { postKronosysAction } from "@/lib/kronosysApi";
47
+ import { LanguageMenu } from "@/components/dashboard/LanguageMenu";
48
+
49
+ type LiveShape = { language?: string };
50
+
51
+ function userStoryScreenReaderLabel(
52
+ lang: Lang,
53
+ implemented: boolean,
54
+ discarded?: boolean,
55
+ ): string {
56
+ if (discarded) {
57
+ return lang === "fr"
58
+ ? "Intention écartée volontairement ; documentée pour éviter les rediscussions."
59
+ : "Intent voluntarily discarded; kept on record to avoid reopening the same debate.";
60
+ }
61
+ if (lang === "fr") {
62
+ return implemented ? "Capacité livrée." : "Absent ou hors périmètre.";
63
+ }
64
+ return implemented ? "Capability shipped." : "Missing or out of scope.";
65
+ }
66
+
67
+ function coverageScreenReaderLabel(
68
+ lang: Lang,
69
+ columnLabel: string,
70
+ checked: boolean | undefined,
71
+ discarded?: boolean,
72
+ ): string {
73
+ if (discarded) {
74
+ return lang === "fr"
75
+ ? `${columnLabel} : sans objet pour une ligne écartée.`
76
+ : `${columnLabel}: not applicable for a discarded intent.`;
77
+ }
78
+ const yn =
79
+ lang === "fr"
80
+ ? checked ? "oui" : "non"
81
+ : checked
82
+ ? "yes"
83
+ : "no";
84
+ return `${columnLabel}: ${yn}`;
85
+ }
86
+
87
+ function implementationCopyFeedbackMessage(
88
+ lang: Lang,
89
+ status: "copied" | "failed",
90
+ ): string {
91
+ if (status === "copied") {
92
+ return lang === "fr" ? "Copié" : "Copied";
93
+ }
94
+ return lang === "fr"
95
+ ? "Copie impossible (navigateur ou contexte HTTP)."
96
+ : "Could not copy (browser or non-HTTPS context).";
97
+ }
98
+
99
+ type StoryCopyFeedback = {
100
+ storyId: string;
101
+ status: "copied" | "failed";
102
+ };
103
+
104
+ function applyStoryCopyResult(
105
+ storyId: string,
106
+ ok: boolean,
107
+ setCopyFeedback: Dispatch<SetStateAction<StoryCopyFeedback | null>>,
108
+ ): void {
109
+ setCopyFeedback({ storyId, status: ok ? "copied" : "failed" });
110
+ globalThis.setTimeout(() => {
111
+ setCopyFeedback((prev) => (prev?.storyId === storyId ? null : prev));
112
+ }, 2500);
113
+ }
114
+
115
+ function stableStoryUuid(groupId: string, index: number): string {
116
+ const seed = `${groupId}:${index}`;
117
+ let hash = 2166136261;
118
+ for (let i = 0; i < seed.length; i += 1) {
119
+ hash ^= seed.codePointAt(i) ?? 0;
120
+ hash = Math.imul(hash, 16777619);
121
+ }
122
+ const toHex = (value: number) => (value >>> 0).toString(16).padStart(8, "0");
123
+ const part1 = toHex(hash);
124
+ const part2 = toHex(hash ^ 0x9e3779b9);
125
+ const part3Raw = toHex(hash ^ 0x85ebca6b).slice(0, 4);
126
+ const part4Raw = toHex(hash ^ 0xc2b2ae35).slice(0, 4);
127
+ const part5 = `${toHex(hash ^ 0x27d4eb2f)}${toHex(hash ^ 0x165667b1).slice(
128
+ 0,
129
+ 4,
130
+ )}`;
131
+ const part3 = `${(
132
+ (Number.parseInt(part3Raw[0] ?? "0", 16) & 0x0f) |
133
+ 0x40
134
+ ).toString(16)}${part3Raw.slice(1)}`;
135
+ const part4 = `${(
136
+ (Number.parseInt(part4Raw[0] ?? "0", 16) & 0x3) |
137
+ 0x8
138
+ ).toString(16)}${part4Raw.slice(1)}`;
139
+ return `${part1}-${part2.slice(0, 4)}-${part3}-${part4}-${part5}`;
140
+ }
141
+
142
+ function ImplementationGlyph(
143
+ props: Readonly<{ implemented: boolean; discarded?: boolean }>,
144
+ ) {
145
+ const { implemented, discarded } = props;
146
+ if (discarded) {
147
+ return (
148
+ <span className="inline-flex text-zinc-400 dark:text-zinc-500" aria-hidden>
149
+ <Ban className="size-4 shrink-0" strokeWidth={2.5} />
150
+ </span>
151
+ );
152
+ }
153
+ if (implemented) {
154
+ return (
155
+ <span
156
+ className="inline-flex text-emerald-600 dark:text-emerald-500"
157
+ aria-hidden
158
+ >
159
+ <Check className="size-4 shrink-0" strokeWidth={2.5} />
160
+ </span>
161
+ );
162
+ }
163
+ return (
164
+ <span className="inline-flex text-red-600 dark:text-red-500" aria-hidden>
165
+ <X className="size-4 shrink-0" strokeWidth={2.5} />
166
+ </span>
167
+ );
168
+ }
169
+
170
+ function CoverageGlyph(props: Readonly<{ checked: boolean }>) {
171
+ const { checked } = props;
172
+ if (checked) {
173
+ return (
174
+ <span
175
+ className="inline-flex text-emerald-600 dark:text-emerald-500"
176
+ aria-hidden
177
+ >
178
+ <Check className="size-4 shrink-0" strokeWidth={2.5} />
179
+ </span>
180
+ );
181
+ }
182
+ return (
183
+ <span className="inline-flex text-red-600 dark:text-red-500" aria-hidden>
184
+ <X className="size-4 shrink-0" strokeWidth={2.5} />
185
+ </span>
186
+ );
187
+ }
188
+
189
+ function ImplementationStoryExpandedBlock(
190
+ props: Readonly<{
191
+ storyKey: string;
192
+ story: ImplementationUserStory;
193
+ notes: ImplementationNotesBundle;
194
+ }>,
195
+ ) {
196
+ const { storyKey, story, notes } = props;
197
+ return (
198
+ <section
199
+ id={`implementation-story-extra-${storyKey}`}
200
+ aria-labelledby={`implementation-story-toggle-${storyKey}`}
201
+ className="border-l-2 border-violet-200 pl-3 text-[11px] leading-snug text-zinc-600 dark:border-violet-600/60 dark:text-zinc-400"
202
+ >
203
+ {story.detail ? <p>{story.detail}</p> : null}
204
+ {story.example ? (
205
+ <p className={story.detail ? "mt-2" : ""}>
206
+ <span className="font-medium text-zinc-700 dark:text-zinc-300">
207
+ {notes.storyDetailExampleLabel}
208
+ </span>{" "}
209
+ {story.example}
210
+ </p>
211
+ ) : null}
212
+ </section>
213
+ );
214
+ }
215
+
216
+ function ImplementationStoryPrimaryCell(
217
+ props: Readonly<{
218
+ story: ImplementationUserStory;
219
+ storyKey: string;
220
+ expanded: boolean;
221
+ onToggle: () => void;
222
+ notes: ImplementationNotesBundle;
223
+ }>,
224
+ ) {
225
+ const { story, storyKey, expanded, onToggle, notes } = props;
226
+ const hasExtra = Boolean(story.detail || story.example);
227
+
228
+ return (
229
+ <td className="py-2.5 pr-2 align-top leading-snug">
230
+ <div className="flex gap-1.5">
231
+ <span className="inline-flex size-7 shrink-0 items-start justify-center pt-0.5">
232
+ {hasExtra ? (
233
+ <button
234
+ type="button"
235
+ className="rounded-md p-0.5 text-zinc-500 transition hover:bg-zinc-200/90 hover:text-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100"
236
+ aria-expanded={expanded}
237
+ aria-controls={`implementation-story-extra-${storyKey}`}
238
+ id={`implementation-story-toggle-${storyKey}`}
239
+ onClick={onToggle}
240
+ aria-label={
241
+ expanded
242
+ ? notes.storyDetailsToggleCollapse
243
+ : notes.storyDetailsToggleExpand
244
+ }
245
+ >
246
+ <ChevronDown
247
+ className={`size-4 shrink-0 transition-transform duration-150 ease-out motion-reduce:transition-none ${
248
+ expanded ? "rotate-180" : ""
249
+ }`}
250
+ aria-hidden
251
+ />
252
+ </button>
253
+ ) : null}
254
+ </span>
255
+ <div className="min-w-0 flex-1 space-y-2">
256
+ <div className="flex flex-wrap items-start gap-x-2 gap-y-1.5">
257
+ {story.discarded ? (
258
+ <span className="inline-flex shrink-0 rounded-full border border-zinc-300 bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
259
+ {notes.storyDiscardedBadge}
260
+ </span>
261
+ ) : null}
262
+ <p className="min-w-0 flex-1 text-sm text-zinc-800 dark:text-zinc-200">
263
+ {story.text}
264
+ </p>
265
+ </div>
266
+ {expanded && hasExtra ? (
267
+ <ImplementationStoryExpandedBlock
268
+ storyKey={storyKey}
269
+ story={story}
270
+ notes={notes}
271
+ />
272
+ ) : null}
273
+ </div>
274
+ </div>
275
+ </td>
276
+ );
277
+ }
278
+
279
+ function ImplementationBody() {
280
+ const searchParams = useSearchParams();
281
+ const dashboardSessionNavId = searchParams.get("session");
282
+ const { payload, refresh } = useKronosysPayload();
283
+ const live = payload?.current as LiveShape | undefined;
284
+ const lang: Lang = live?.language === "fr" ? "fr" : "en";
285
+ const dt = dashboardStrings(lang);
286
+ const nav = useMemo(() => reportingNav(lang), [lang]);
287
+ const notes = useMemo(() => implementationNotesBundle(lang), [lang]);
288
+ const businessRules = useMemo(() => businessRulesMatrixBundle(lang), [lang]);
289
+ const [copyFeedback, setCopyFeedback] = useState<StoryCopyFeedback | null>(
290
+ null,
291
+ );
292
+ const [expandedStoryIds, setExpandedStoryIds] = useState<Set<string>>(
293
+ () => new Set(),
294
+ );
295
+
296
+ const toggleStoryExpanded = useCallback((storyKey: string) => {
297
+ setExpandedStoryIds((prev) => {
298
+ const next = new Set(prev);
299
+ if (next.has(storyKey)) {
300
+ next.delete(storyKey);
301
+ } else {
302
+ next.add(storyKey);
303
+ }
304
+ return next;
305
+ });
306
+ }, []);
307
+
308
+ const postLang = async (next: Lang) => {
309
+ await postKronosysAction({ type: "setLanguage", lang: next });
310
+ await refresh();
311
+ };
312
+
313
+ const copyStoryId = useCallback((storyId: string) => {
314
+ copyTextToClipboard(storyId).then((ok) =>
315
+ applyStoryCopyResult(storyId, ok, setCopyFeedback),
316
+ );
317
+ }, []);
318
+
319
+ return (
320
+ <div className="min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
321
+ <header className={appShellHeaderClassName}>
322
+ <div className={appShellHeaderTitleMetaRowClassName}>
323
+ <div className="flex min-w-0 flex-col gap-1">
324
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
325
+ <Link
326
+ href={withDashboardSessionParam("/", dashboardSessionNavId)}
327
+ className="text-xl font-semibold tracking-tight text-zinc-900 hover:text-violet-700 dark:text-zinc-100 dark:hover:text-violet-300"
328
+ >
329
+ Kronosys
330
+ </Link>
331
+ <span className="text-zinc-400 dark:text-zinc-600" aria-hidden>
332
+ /
333
+ </span>
334
+ <span className="text-lg font-medium text-zinc-700 dark:text-zinc-300">
335
+ {notes.pageTitle}
336
+ </span>
337
+ <span
338
+ className="inline-flex items-center text-violet-500 dark:text-violet-400"
339
+ aria-hidden
340
+ >
341
+ <FileCode2 className="size-5" strokeWidth={2} />
342
+ </span>
343
+ </div>
344
+ <p className="flex flex-wrap items-center gap-x-2 text-xs font-medium leading-snug text-zinc-500 dark:text-zinc-400">
345
+ <span>{dt.brandTagline}</span>
346
+ <span className="text-zinc-400/70 dark:text-zinc-600" aria-hidden>
347
+ ·
348
+ </span>
349
+ <AppVersionStamp ariaLabelTemplate={dt.appVersionAriaLabel} />
350
+ </p>
351
+ </div>
352
+ <AppShellHeaderSessionMeta payload={payload} dt={dt} />
353
+ </div>
354
+ <div className="flex w-full justify-end">
355
+ <div className={appShellHeaderToolbarClassName}>
356
+ <AppShellHeaderWallClock lang={lang} dt={dt} />
357
+ <AppShellCommandCenterPlaceholder />
358
+ <Link
359
+ href={withDashboardSessionParam(
360
+ "/changelog",
361
+ dashboardSessionNavId,
362
+ )}
363
+ className="inline-flex size-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 shadow-sm transition hover:border-zinc-400 hover:bg-zinc-50 hover:text-violet-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 dark:border-zinc-600 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-violet-300"
364
+ aria-label={notes.openChangelogAria}
365
+ title={notes.openChangelogTooltip}
366
+ >
367
+ <ScrollText size={18} />
368
+ </Link>
369
+ <AppShellRouteNav
370
+ current="implementation"
371
+ labels={nav}
372
+ navAriaLabel={dt.appShellRouteNavAria}
373
+ dashboardSessionId={dashboardSessionNavId}
374
+ reserveGlobalPauseSlot
375
+ />
376
+ <ThemeToggle lang={lang} />
377
+ <PageRefreshButton
378
+ title={dt.pageRefreshTitle}
379
+ ariaLabel={dt.pageRefreshAriaLabel}
380
+ inlineMessages={{
381
+ loading: dt.pageRefreshProgressLabel,
382
+ success: dt.pageRefreshDoneToast,
383
+ error: dt.pageRefreshFailedToast,
384
+ }}
385
+ onRefresh={async () => {
386
+ return await refresh({ routerInvalidate: true });
387
+ }}
388
+ />
389
+ <LanguageMenu
390
+ lang={lang}
391
+ labelEn="English"
392
+ labelFr="Français"
393
+ menuHeading={lang === "fr" ? "Langue" : "Language"}
394
+ triggerAriaLabel={
395
+ lang === "fr" ? "Langue de l’interface" : "Interface language"
396
+ }
397
+ onSelect={(next) => void postLang(next)}
398
+ />
399
+ </div>
400
+ </div>
401
+ </header>
402
+
403
+ <main className="mx-auto w-full max-w-4xl px-5 pb-16 pt-6 sm:px-8 lg:px-10">
404
+ <p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
405
+ {notes.pageSubtitle}
406
+ </p>
407
+ <p className="mt-2 text-xs font-medium text-zinc-700 dark:text-zinc-300">
408
+ {notes.statusKeyLine}
409
+ </p>
410
+ <p className="mt-1 text-xs leading-snug text-zinc-500 dark:text-zinc-500">
411
+ {notes.storyDiscardedLegendLine}
412
+ </p>
413
+ <p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
414
+ {notes.lastUpdated}
415
+ </p>
416
+ <p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
417
+ {notes.repositoryDocLabel}
418
+ </p>
419
+
420
+ <section
421
+ className="mt-8 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-800/60"
422
+ aria-labelledby="implementation-user-stories-heading"
423
+ >
424
+ <h2
425
+ id="implementation-user-stories-heading"
426
+ className="text-base font-semibold text-zinc-900 dark:text-zinc-100"
427
+ >
428
+ {notes.storyGroupsHeading}
429
+ </h2>
430
+ <p className="mt-2 text-xs leading-snug text-zinc-500 dark:text-zinc-500">
431
+ {notes.storyExpandHintLine}
432
+ </p>
433
+ <div className="mt-6 space-y-8">
434
+ {notes.storyGroups.map((group) => (
435
+ <section
436
+ key={group.id}
437
+ id={group.id}
438
+ className="rounded-lg border border-zinc-100 bg-zinc-50/80 p-3 dark:border-zinc-700/80 dark:bg-zinc-900/40"
439
+ aria-labelledby={`${group.id}-heading`}
440
+ >
441
+ <h3
442
+ id={`${group.id}-heading`}
443
+ className="text-sm font-semibold text-zinc-900 dark:text-zinc-100"
444
+ >
445
+ {group.title}
446
+ </h3>
447
+ {group.lead ? (
448
+ <p className="mt-1.5 text-xs leading-snug text-zinc-600 dark:text-zinc-400">
449
+ {group.lead}
450
+ </p>
451
+ ) : null}
452
+ <div className="mt-3 overflow-x-auto">
453
+ <table className="w-full border-collapse text-left text-sm text-zinc-700 dark:text-zinc-300">
454
+ <caption className="sr-only">{group.title}</caption>
455
+ <thead>
456
+ <tr className="border-b border-zinc-200 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-700/80 dark:text-zinc-400">
457
+ <th className="px-2 py-2 text-left font-semibold">
458
+ {notes.storyColumnLabel}
459
+ </th>
460
+ <th className="w-28 px-1 py-2 text-left font-semibold">
461
+ UUID
462
+ </th>
463
+ <th className="w-10 px-2 py-2 text-left font-semibold">
464
+ {notes.implementationColumnLabel}
465
+ </th>
466
+ <th className="w-16 px-1 py-2 text-center font-semibold">
467
+ {notes.integrationColumnLabel}
468
+ </th>
469
+ <th className="w-14 px-1 py-2 text-center font-semibold">
470
+ {notes.e2eColumnLabel}
471
+ </th>
472
+ </tr>
473
+ </thead>
474
+ <tbody>
475
+ {group.stories.map((story, idx) => {
476
+ const storyUuid = stableStoryUuid(group.id, idx);
477
+ const storyKey = `${group.id}-${idx}`;
478
+ const srMark = userStoryScreenReaderLabel(
479
+ lang,
480
+ story.implemented,
481
+ story.discarded,
482
+ );
483
+ return (
484
+ <tr
485
+ key={`${group.id}-${idx}`}
486
+ className={
487
+ idx === 0
488
+ ? ""
489
+ : "border-t border-zinc-200 dark:border-zinc-700/80"
490
+ }
491
+ >
492
+ <ImplementationStoryPrimaryCell
493
+ story={story}
494
+ storyKey={storyKey}
495
+ expanded={expandedStoryIds.has(storyKey)}
496
+ onToggle={() => toggleStoryExpanded(storyKey)}
497
+ notes={notes}
498
+ />
499
+ <td className="w-28 align-top px-1 py-2.5">
500
+ <button
501
+ type="button"
502
+ onClick={() => {
503
+ copyStoryId(storyUuid);
504
+ }}
505
+ className="inline-flex shrink-0 items-center gap-1 rounded-md border border-zinc-300 bg-white px-1.5 py-1 text-[10px] font-medium text-zinc-700 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700/80"
506
+ aria-label={
507
+ lang === "fr"
508
+ ? `Copier l’identifiant ${storyUuid}`
509
+ : `Copy identifier ${storyUuid}`
510
+ }
511
+ title={
512
+ lang === "fr"
513
+ ? "Copier l’identifiant du point"
514
+ : "Copy story identifier"
515
+ }
516
+ >
517
+ <Copy className="size-3.5" />
518
+ <span className="font-mono leading-none">
519
+ {storyUuid.slice(0, 8)}
520
+ </span>
521
+ </button>
522
+ {copyFeedback?.storyId === storyUuid ? (
523
+ <p
524
+ className={
525
+ copyFeedback.status === "copied"
526
+ ? "mt-1 text-[10px] text-emerald-600 dark:text-emerald-500"
527
+ : "mt-1 text-[10px] text-red-600 dark:text-red-500"
528
+ }
529
+ >
530
+ {implementationCopyFeedbackMessage(
531
+ lang,
532
+ copyFeedback.status,
533
+ )}
534
+ </p>
535
+ ) : null}
536
+ </td>
537
+ <td className="w-10 align-top px-2 py-2.5">
538
+ <span className="sr-only">{srMark} </span>
539
+ <ImplementationGlyph
540
+ implemented={story.implemented}
541
+ discarded={story.discarded}
542
+ />
543
+ </td>
544
+ <td className="w-16 shrink-0 align-top px-1 py-2.5 text-center">
545
+ <span className="sr-only">
546
+ {coverageScreenReaderLabel(
547
+ lang,
548
+ notes.integrationColumnLabel,
549
+ story.integrationChecked,
550
+ story.discarded,
551
+ )}
552
+ </span>
553
+ {story.discarded ? (
554
+ <span
555
+ className="text-zinc-400 dark:text-zinc-500"
556
+ aria-hidden
557
+ >
558
+
559
+ </span>
560
+ ) : (
561
+ <CoverageGlyph
562
+ checked={story.integrationChecked === true}
563
+ />
564
+ )}
565
+ </td>
566
+ <td className="w-14 shrink-0 align-top px-1 py-2.5 text-center">
567
+ <span className="sr-only">
568
+ {coverageScreenReaderLabel(
569
+ lang,
570
+ notes.e2eColumnLabel,
571
+ story.e2eChecked,
572
+ story.discarded,
573
+ )}
574
+ </span>
575
+ {story.discarded ? (
576
+ <span
577
+ className="text-zinc-400 dark:text-zinc-500"
578
+ aria-hidden
579
+ >
580
+
581
+ </span>
582
+ ) : (
583
+ <CoverageGlyph
584
+ checked={story.e2eChecked === true}
585
+ />
586
+ )}
587
+ </td>
588
+ </tr>
589
+ );
590
+ })}
591
+ </tbody>
592
+ </table>
593
+ </div>
594
+ </section>
595
+ ))}
596
+ </div>
597
+ </section>
598
+
599
+ <section
600
+ className="mt-8 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-800/60"
601
+ aria-labelledby="implementation-business-rules-heading"
602
+ >
603
+ <h2
604
+ id="implementation-business-rules-heading"
605
+ className="text-base font-semibold text-zinc-900 dark:text-zinc-100"
606
+ >
607
+ {businessRules.heading}
608
+ </h2>
609
+ <p className="mt-2 text-xs leading-snug text-zinc-600 dark:text-zinc-400">
610
+ {businessRules.subtitle}
611
+ </p>
612
+ <p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">
613
+ {businessRules.statusLegend}
614
+ </p>
615
+ <div className="mt-4 overflow-x-auto">
616
+ <table className="w-full border-collapse text-left text-xs text-zinc-700 dark:text-zinc-300">
617
+ <caption className="sr-only">{businessRules.heading}</caption>
618
+ <thead>
619
+ <tr className="border-b border-zinc-200 text-[11px] uppercase tracking-wide text-zinc-500 dark:border-zinc-700/80 dark:text-zinc-400">
620
+ <th className="px-2 py-2 text-left font-semibold">
621
+ {businessRules.columns.id}
622
+ </th>
623
+ <th className="px-2 py-2 text-left font-semibold">
624
+ {businessRules.columns.domain}
625
+ </th>
626
+ <th className="px-2 py-2 text-left font-semibold">
627
+ {businessRules.columns.rule}
628
+ </th>
629
+ <th className="px-2 py-2 text-left font-semibold">
630
+ {businessRules.columns.status}
631
+ </th>
632
+ <th className="px-2 py-2 text-left font-semibold">
633
+ {businessRules.columns.code}
634
+ </th>
635
+ <th className="px-2 py-2 text-left font-semibold">
636
+ {businessRules.columns.tests}
637
+ </th>
638
+ <th className="px-2 py-2 text-left font-semibold">
639
+ {businessRules.columns.note}
640
+ </th>
641
+ </tr>
642
+ </thead>
643
+ <tbody>
644
+ {businessRules.rows.map((row, idx) => (
645
+ <tr
646
+ key={row.id}
647
+ className={
648
+ idx === 0
649
+ ? ""
650
+ : "border-t border-zinc-200 dark:border-zinc-700/80"
651
+ }
652
+ >
653
+ <td className="px-2 py-2 align-top font-mono text-[11px]">
654
+ {row.id}
655
+ </td>
656
+ <td className="px-2 py-2 align-top">{row.domain}</td>
657
+ <td className="px-2 py-2 align-top leading-snug">
658
+ {row.title}
659
+ </td>
660
+ <td className="px-2 py-2 align-top">
661
+ <span className="inline-flex rounded-full border border-zinc-300 bg-zinc-100 px-2 py-0.5 text-[11px] dark:border-zinc-600 dark:bg-zinc-700/60">
662
+ {businessRules.statuses[row.status]}
663
+ </span>
664
+ </td>
665
+ <td className="px-2 py-2 align-top leading-snug">
666
+ {row.codeRefs.join(", ")}
667
+ </td>
668
+ <td className="px-2 py-2 align-top leading-snug">
669
+ {row.testRefs.join(", ")}
670
+ </td>
671
+ <td className="px-2 py-2 align-top leading-snug">
672
+ {row.note ?? "—"}
673
+ </td>
674
+ </tr>
675
+ ))}
676
+ </tbody>
677
+ </table>
678
+ </div>
679
+ </section>
680
+ </main>
681
+ <ScrollToTopFab
682
+ ariaLabel={lang === "fr" ? "Retour en haut de la page" : "Back to top"}
683
+ />
684
+ </div>
685
+ );
686
+ }
687
+
688
+ export default function ImplementationPage() {
689
+ return (
690
+ <Suspense
691
+ fallback={
692
+ <div className="min-h-screen bg-zinc-100 px-6 py-10 text-sm text-zinc-500 dark:bg-zinc-900">
693
+ Kronosys…
694
+ </div>
695
+ }
696
+ >
697
+ <ImplementationBody />
698
+ </Suspense>
699
+ );
700
+ }