@nightkatana/kronosys-app 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,211 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
4
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
5
+ import type { SettingsCopy } from "@/lib/settingsCopy";
6
+ import type { GitIdentityPayload } from "@/lib/kronosysApi";
7
+ import { tbModalPrimary } from "@/lib/translucentButtonClasses";
8
+
9
+ function useEscapeClose(open: boolean, onClose: () => void, blocked: boolean) {
10
+ useEffect(() => {
11
+ if (!open || blocked) {
12
+ return;
13
+ }
14
+ const onKey = (e: KeyboardEvent) => {
15
+ if (e.key === "Escape") {
16
+ e.preventDefault();
17
+ onClose();
18
+ }
19
+ };
20
+ document.addEventListener("keydown", onKey);
21
+ return () => document.removeEventListener("keydown", onKey);
22
+ }, [open, onClose, blocked]);
23
+ }
24
+
25
+ function inputClass(disabled: boolean) {
26
+ return `w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-violet-500/30 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 ${
27
+ disabled ? "opacity-50" : ""
28
+ }`;
29
+ }
30
+
31
+ function seedFromIdentity(g: GitIdentityPayload | undefined): { name: string; email: string; login: string } {
32
+ return {
33
+ name: typeof g?.gitUserName === "string" ? g.gitUserName : "",
34
+ email: typeof g?.gitUserEmail === "string" ? g.gitUserEmail : "",
35
+ login: typeof g?.gitAccountLogin === "string" ? g.gitAccountLogin : "",
36
+ };
37
+ }
38
+
39
+ export function GitIdentityQuickSetupModal({
40
+ open,
41
+ onClose,
42
+ dt,
43
+ s,
44
+ initialGitIdentity,
45
+ onSave,
46
+ }: {
47
+ open: boolean;
48
+ onClose: () => void;
49
+ dt: DashboardStrings;
50
+ s: SettingsCopy;
51
+ initialGitIdentity: GitIdentityPayload | undefined;
52
+ onSave: (fields: { gitUserName: string; gitUserEmail: string; gitAccountLogin: string }) => Promise<void>;
53
+ }) {
54
+ const titleId = useId();
55
+ const introId = useId();
56
+ const [name, setName] = useState("");
57
+ const [email, setEmail] = useState("");
58
+ const [login, setLogin] = useState("");
59
+ const [saving, setSaving] = useState(false);
60
+ const [error, setError] = useState<string | null>(null);
61
+ const seededForOpenCycle = useRef(false);
62
+
63
+ useEffect(() => {
64
+ if (!open) {
65
+ seededForOpenCycle.current = false;
66
+ return;
67
+ }
68
+ if (seededForOpenCycle.current) {
69
+ return;
70
+ }
71
+ seededForOpenCycle.current = true;
72
+ const seed = seedFromIdentity(initialGitIdentity);
73
+ setName(seed.name);
74
+ setEmail(seed.email);
75
+ setLogin(seed.login);
76
+ setError(null);
77
+ }, [open, initialGitIdentity]);
78
+
79
+ useEscapeClose(open, onClose, saving);
80
+
81
+ const canSubmit =
82
+ name.trim().length > 0 || email.trim().length > 0 || login.trim().length > 0;
83
+
84
+ const handleSubmit = useCallback(async () => {
85
+ if (!canSubmit || saving) {
86
+ return;
87
+ }
88
+ setSaving(true);
89
+ setError(null);
90
+ try {
91
+ await onSave({
92
+ gitUserName: name.trim(),
93
+ gitUserEmail: email.trim(),
94
+ gitAccountLogin: login.trim(),
95
+ });
96
+ onClose();
97
+ } catch (e: unknown) {
98
+ setError(e instanceof Error ? e.message : dt.gitIdentityModalSaveError);
99
+ } finally {
100
+ setSaving(false);
101
+ }
102
+ }, [canSubmit, saving, onSave, onClose, name, email, login, dt.gitIdentityModalSaveError]);
103
+
104
+ if (!open) {
105
+ return null;
106
+ }
107
+
108
+ return (
109
+ <div
110
+ className="fixed inset-0 z-[220] flex items-center justify-center bg-black/60 p-4"
111
+ role="dialog"
112
+ aria-modal="true"
113
+ aria-labelledby={titleId}
114
+ aria-describedby={introId}
115
+ >
116
+ <div className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900">
117
+ <h2 id={titleId} className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
118
+ {dt.gitIdentityModalTitle}
119
+ </h2>
120
+ <p
121
+ id={introId}
122
+ className="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
123
+ >
124
+ {dt.gitIdentityModalIntro}
125
+ </p>
126
+
127
+ <div className="mt-5 space-y-4">
128
+ <div className="space-y-1.5">
129
+ <label className="block text-sm font-medium text-zinc-800 dark:text-zinc-200" htmlFor="git-quick-name">
130
+ {s.gitIdentityNameLabel}
131
+ </label>
132
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">{s.gitIdentityNameDesc}</p>
133
+ <input
134
+ id="git-quick-name"
135
+ type="text"
136
+ className={inputClass(saving)}
137
+ value={name}
138
+ onChange={(e) => setName(e.target.value)}
139
+ disabled={saving}
140
+ autoComplete="name"
141
+ spellCheck={false}
142
+ />
143
+ </div>
144
+ <div className="space-y-1.5">
145
+ <label className="block text-sm font-medium text-zinc-800 dark:text-zinc-200" htmlFor="git-quick-email">
146
+ {s.gitIdentityEmailLabel}
147
+ </label>
148
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">{s.gitIdentityEmailDesc}</p>
149
+ <input
150
+ id="git-quick-email"
151
+ type="email"
152
+ className={inputClass(saving)}
153
+ value={email}
154
+ onChange={(e) => setEmail(e.target.value)}
155
+ disabled={saving}
156
+ autoComplete="email"
157
+ spellCheck={false}
158
+ />
159
+ </div>
160
+ <div className="space-y-1.5">
161
+ <label className="block text-sm font-medium text-zinc-800 dark:text-zinc-200" htmlFor="git-quick-login">
162
+ {s.gitIdentityLoginLabel}
163
+ </label>
164
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">{s.gitIdentityLoginDesc}</p>
165
+ <input
166
+ id="git-quick-login"
167
+ type="text"
168
+ className={inputClass(saving)}
169
+ value={login}
170
+ onChange={(e) => setLogin(e.target.value)}
171
+ disabled={saving}
172
+ autoComplete="username"
173
+ spellCheck={false}
174
+ />
175
+ </div>
176
+ </div>
177
+
178
+ {canSubmit ? null : (
179
+ <p className="mt-3 text-xs text-amber-700 dark:text-amber-300">{dt.gitIdentityModalNeedOneField}</p>
180
+ )}
181
+
182
+ {error ? (
183
+ <p className="mt-3 whitespace-pre-wrap text-sm text-red-600 dark:text-red-400" role="alert">
184
+ {error}
185
+ </p>
186
+ ) : null}
187
+
188
+ <p className="mt-4 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">{dt.gitIdentityModalFooter}</p>
189
+
190
+ <div className="mt-6 flex flex-wrap justify-end gap-2">
191
+ <button
192
+ type="button"
193
+ className="rounded-lg border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 disabled:opacity-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
194
+ disabled={saving}
195
+ onClick={onClose}
196
+ >
197
+ {dt.dialogCancelBtn}
198
+ </button>
199
+ <button
200
+ type="button"
201
+ className={`${tbModalPrimary} disabled:cursor-not-allowed disabled:opacity-40`}
202
+ disabled={!canSubmit || saving}
203
+ onClick={() => void handleSubmit()}
204
+ >
205
+ {saving ? s.gitIdentitySaving : s.gitIdentitySave}
206
+ </button>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ );
211
+ }
@@ -0,0 +1,69 @@
1
+ import { Database, FileJson } from "lucide-react";
2
+ import type { LucideIcon } from "lucide-react";
3
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
4
+
5
+ function StorageBadge({
6
+ label,
7
+ description,
8
+ icon: Icon,
9
+ }: Readonly<{
10
+ label: string;
11
+ /** Texte d’infobulle (title) et nom accessible (aria-label) */
12
+ description: string;
13
+ icon: LucideIcon;
14
+ }>) {
15
+ return (
16
+ <span
17
+ aria-label={description}
18
+ title={description}
19
+ className="inline-flex cursor-default items-center gap-1 rounded-md border border-emerald-500/50 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium leading-tight text-emerald-900 ring-1 ring-emerald-600/15 dark:border-emerald-600/55 dark:bg-emerald-950/45 dark:text-emerald-300 dark:ring-0"
20
+ >
21
+ <Icon className="size-3.5 shrink-0 text-emerald-700 dark:text-emerald-400" aria-hidden />
22
+ {label}
23
+ </span>
24
+ );
25
+ }
26
+
27
+ export type HeaderLocalPersistenceDriver = "sqlite" | "json";
28
+
29
+ export function HeaderIntegrationBadges({
30
+ t,
31
+ localPersistenceDriver,
32
+ mongoEnabled,
33
+ mongoConnected,
34
+ }: Readonly<{
35
+ t: DashboardStrings;
36
+ localPersistenceDriver: HeaderLocalPersistenceDriver;
37
+ mongoEnabled: boolean;
38
+ mongoConnected: boolean;
39
+ }>) {
40
+ const showMongo = mongoEnabled && mongoConnected;
41
+ let kind: "mongo" | "json" | "sqlite";
42
+ if (showMongo) {
43
+ kind = "mongo";
44
+ } else if (localPersistenceDriver === "json") {
45
+ kind = "json";
46
+ } else {
47
+ kind = "sqlite";
48
+ }
49
+
50
+ if (kind === "mongo") {
51
+ return (
52
+ <span className="inline-flex shrink-0 flex-wrap items-center gap-1.5">
53
+ <StorageBadge label={t.storageBadgeLabelMongo} description={t.storageBadgeTooltipMongo} icon={Database} />
54
+ </span>
55
+ );
56
+ }
57
+ if (kind === "json") {
58
+ return (
59
+ <span className="inline-flex shrink-0 flex-wrap items-center gap-1.5">
60
+ <StorageBadge label={t.storageBadgeLabelJson} description={t.storageBadgeTooltipJson} icon={FileJson} />
61
+ </span>
62
+ );
63
+ }
64
+ return (
65
+ <span className="inline-flex shrink-0 flex-wrap items-center gap-1.5">
66
+ <StorageBadge label={t.storageBadgeLabelSqlite} description={t.storageBadgeTooltipSqlite} icon={Database} />
67
+ </span>
68
+ );
69
+ }
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { CircleHelp } from "lucide-react";
6
+ import { useAnchoredFloatingPortalStyle } from "./useAnchoredFloatingPortalStyle";
7
+
8
+ const PANEL_PLACEHOLDER_STYLE: CSSProperties = {
9
+ position: "fixed",
10
+ top: 0,
11
+ left: 0,
12
+ width: 288,
13
+ zIndex: 80,
14
+ visibility: "hidden",
15
+ pointerEvents: "none",
16
+ };
17
+
18
+ type Align = "start" | "end";
19
+
20
+ /**
21
+ * Icône (?) avec panneau d’aide : rendu en portail fixe pour éviter le clip `overflow-*`.
22
+ * `align="end"` pour la dernière colonne d’une grille.
23
+ */
24
+ export function InlineMetricHelpTrigger({
25
+ ariaLabel,
26
+ body,
27
+ align = "start",
28
+ preserveLineBreaks = false,
29
+ panelClassName,
30
+ }: Readonly<{
31
+ ariaLabel: string;
32
+ body: string;
33
+ align?: Align;
34
+ /** Affiche les sauts de ligne du texte (ex. paragraphes séparés par \\n\\n). */
35
+ preserveLineBreaks?: boolean;
36
+ /** Remplace la largeur maximale du panneau (textes d’aide longs). */
37
+ panelClassName?: string;
38
+ }>) {
39
+ const [open, setOpen] = useState(false);
40
+ const triggerRef = useRef<HTMLDivElement>(null);
41
+ const panelRef = useRef<HTMLDivElement>(null);
42
+ const id = useId();
43
+
44
+ const maxWidthRem = panelClassName ? 24 : 18;
45
+ const panelStyle = useAnchoredFloatingPortalStyle(open, triggerRef, panelRef, {
46
+ align,
47
+ maxWidthRem,
48
+ });
49
+
50
+ useEffect(() => {
51
+ if (!open) {
52
+ return;
53
+ }
54
+ const onDoc = (e: MouseEvent) => {
55
+ const n = e.target as Node;
56
+ if (!triggerRef.current?.contains(n) && !panelRef.current?.contains(n)) {
57
+ setOpen(false);
58
+ }
59
+ };
60
+ document.addEventListener("mousedown", onDoc);
61
+ return () => document.removeEventListener("mousedown", onDoc);
62
+ }, [open]);
63
+
64
+ const mergedStyle = panelStyle ?? (open ? PANEL_PLACEHOLDER_STYLE : undefined);
65
+
66
+ const panel =
67
+ open && typeof document !== "undefined" && mergedStyle
68
+ ? createPortal(
69
+ <div
70
+ ref={panelRef}
71
+ id={`${id}-metric-help`}
72
+ style={mergedStyle}
73
+ className={`rounded-lg border border-zinc-200 bg-white p-2.5 text-left shadow-lg normal-case dark:border-zinc-600 dark:bg-zinc-900${panelClassName ? ` ${panelClassName}` : ""}`}
74
+ role="region"
75
+ aria-label={ariaLabel}
76
+ >
77
+ <p
78
+ className={`text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300${preserveLineBreaks ? " whitespace-pre-line" : ""} ${!panelClassName ? "max-w-full" : ""}`}
79
+ >
80
+ {body}
81
+ </p>
82
+ </div>,
83
+ document.body
84
+ )
85
+ : null;
86
+
87
+ return (
88
+ <div className="relative inline-flex shrink-0" ref={triggerRef}>
89
+ <button
90
+ type="button"
91
+ className="rounded p-0.5 text-zinc-500 hover:bg-zinc-200/90 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
92
+ aria-label={ariaLabel}
93
+ aria-expanded={open ? "true" : "false"}
94
+ aria-controls={`${id}-metric-help`}
95
+ onClick={() => setOpen((o) => !o)}
96
+ >
97
+ <CircleHelp size={13} strokeWidth={1.75} aria-hidden />
98
+ </button>
99
+ {panel}
100
+ </div>
101
+ );
102
+ }
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { Loader2, X } from "lucide-react";
5
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
6
+
7
+ export type RemoteIssue = { title?: string; number: number | string; source?: string };
8
+
9
+ const SEARCH_DEBOUNCE_MS = 350;
10
+ const SEARCH_MIN_CHARS = 2;
11
+
12
+ /** IID seul : le serveur utilise `iids[]` ; un seul chiffre suffit (ex. « 5 » ou « #12 »). */
13
+ function issueSearchMinChars(trimmed: string): number {
14
+ const compact = trimmed.replace(/\s+/g, "");
15
+ return /^#?\d{1,10}$/.test(compact) ? 1 : SEARCH_MIN_CHARS;
16
+ }
17
+
18
+ export function IssuePickerModal({
19
+ t,
20
+ onClose,
21
+ onSelect,
22
+ fetchIssues,
23
+ }: {
24
+ t: DashboardStrings;
25
+ onClose: () => void;
26
+ onSelect: (issue: RemoteIssue) => void;
27
+ fetchIssues: (query: string) => Promise<{ issues: RemoteIssue[]; error?: string }>;
28
+ }) {
29
+ const [search, setSearch] = useState("");
30
+ const [results, setResults] = useState<RemoteIssue[]>([]);
31
+ const [loading, setLoading] = useState(false);
32
+ const [error, setError] = useState<string | undefined>();
33
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+ const requestSeq = useRef(0);
35
+
36
+ const runFetch = useCallback(
37
+ async (q: string) => {
38
+ const seq = ++requestSeq.current;
39
+ setError(undefined);
40
+ try {
41
+ const out = await fetchIssues(q);
42
+ if (seq !== requestSeq.current) {
43
+ return;
44
+ }
45
+ setResults(out.issues);
46
+ setError(out.error);
47
+ } catch (e) {
48
+ if (seq !== requestSeq.current) {
49
+ return;
50
+ }
51
+ setResults([]);
52
+ setError(e instanceof Error ? e.message : String(e));
53
+ } finally {
54
+ if (seq === requestSeq.current) {
55
+ setLoading(false);
56
+ }
57
+ }
58
+ },
59
+ [fetchIssues],
60
+ );
61
+
62
+ useEffect(() => {
63
+ if (debounceRef.current) {
64
+ clearTimeout(debounceRef.current);
65
+ debounceRef.current = null;
66
+ }
67
+ const q = search.trim();
68
+ if (q.length < issueSearchMinChars(q)) {
69
+ requestSeq.current += 1;
70
+ setResults([]);
71
+ setLoading(false);
72
+ setError(undefined);
73
+ return;
74
+ }
75
+ setLoading(true);
76
+ setError(undefined);
77
+ debounceRef.current = setTimeout(() => {
78
+ debounceRef.current = null;
79
+ void runFetch(q);
80
+ }, SEARCH_DEBOUNCE_MS);
81
+ return () => {
82
+ if (debounceRef.current) {
83
+ clearTimeout(debounceRef.current);
84
+ debounceRef.current = null;
85
+ }
86
+ };
87
+ }, [search, runFetch]);
88
+
89
+ const q = search.trim();
90
+ const ready = q.length >= issueSearchMinChars(q);
91
+
92
+ return (
93
+ <div
94
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
95
+ role="dialog"
96
+ aria-modal="true"
97
+ aria-labelledby="issue-picker-title"
98
+ onClick={onClose}
99
+ >
100
+ <div
101
+ className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
102
+ onClick={(e) => e.stopPropagation()}
103
+ >
104
+ <div className="flex shrink-0 items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
105
+ <h3 id="issue-picker-title" className="font-semibold text-zinc-900 dark:text-zinc-100">
106
+ {t.selectIssue}
107
+ </h3>
108
+ <button
109
+ type="button"
110
+ className="rounded p-1 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
111
+ aria-label={t.issuePickerCloseAria}
112
+ onClick={onClose}
113
+ >
114
+ <X size={20} aria-hidden />
115
+ </button>
116
+ </div>
117
+ <div className="shrink-0 border-b border-zinc-200 dark:border-zinc-800">
118
+ <input
119
+ className="w-full bg-white px-4 py-2.5 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-2 focus:ring-violet-500/40 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500"
120
+ placeholder={t.issuePickerSearchPlaceholder}
121
+ value={search}
122
+ onChange={(e) => setSearch(e.target.value)}
123
+ autoFocus
124
+ aria-label={t.issuePickerSearchPlaceholder}
125
+ />
126
+ </div>
127
+ <div className="min-h-0 flex-1 overflow-y-auto">
128
+ {loading ? (
129
+ <div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-zinc-600 dark:text-zinc-400">
130
+ <Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
131
+ <span>{t.issuePickerLoading}</span>
132
+ </div>
133
+ ) : error ? (
134
+ <div className="px-4 py-6 text-center text-sm text-red-600 dark:text-red-400">{error}</div>
135
+ ) : !ready ? (
136
+ <div className="px-4 py-6 text-center text-sm text-zinc-600 dark:text-zinc-500">
137
+ {t.issuePickerSearchMinHint}
138
+ </div>
139
+ ) : results.length === 0 ? (
140
+ <div className="px-4 py-6 text-center text-sm text-zinc-600 dark:text-zinc-500">
141
+ {t.issuePickerNoResults}
142
+ </div>
143
+ ) : (
144
+ <ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
145
+ {results.map((issue, idx) => (
146
+ <li key={`${String(issue.source)}-${String(issue.number)}-${idx}`}>
147
+ <button
148
+ type="button"
149
+ className="w-full px-4 py-3 text-left text-sm hover:bg-zinc-100/90 dark:hover:bg-zinc-800/60"
150
+ onClick={() => onSelect(issue)}
151
+ >
152
+ <div className="font-medium text-zinc-900 dark:text-zinc-200">{issue.title}</div>
153
+ <div className="mt-1 flex flex-wrap gap-2 text-xs text-zinc-600 dark:text-zinc-500">
154
+ {issue.source ? (
155
+ <span className="rounded bg-zinc-200 px-1.5 py-0.5 dark:bg-zinc-800">{issue.source}</span>
156
+ ) : null}
157
+ <span>#{issue.number}</span>
158
+ </div>
159
+ </button>
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ )}
164
+ </div>
165
+ </div>
166
+ </div>
167
+ );
168
+ }