@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,357 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { createPortal } from "react-dom";
11
+ import { Calendar } from "lucide-react";
12
+ import { DayPicker } from "react-day-picker";
13
+ import { enUS, fr } from "date-fns/locale";
14
+ import "react-day-picker/style.css";
15
+
16
+ import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
17
+ import { DEFAULT_DASHBOARD_TIME_ZONE, isValidIanaTimeZone } from "@/lib/dashboardTimeZone";
18
+ import { TASK_PAST_DATETIME_TRIGGER_CLASS } from "./taskFieldStyles";
19
+
20
+ function pad2(n: number): string {
21
+ return String(n).padStart(2, "0");
22
+ }
23
+
24
+ export function formatDatetimeLocalValue(d: Date): string {
25
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(
26
+ d.getDate(),
27
+ )}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
28
+ }
29
+
30
+ export function parseDatetimeLocalValue(s: string): Date | undefined {
31
+ const t = s.trim();
32
+ if (!t) {
33
+ return undefined;
34
+ }
35
+ const d = new Date(t);
36
+ return Number.isNaN(d.getTime()) ? undefined : d;
37
+ }
38
+
39
+ const POPOVER_MAX_H = 400;
40
+ const GAP = 8;
41
+
42
+ type KronosysDatetimePopoverFieldProps = {
43
+ value: string;
44
+ onChange: (next: string) => void;
45
+ onBlur?: () => void;
46
+ "aria-label": string;
47
+ lang: Lang;
48
+ /** Fuseau IANA pour l’aperçu textuel (le champ reste en heure locale du navigateur). */
49
+ displayTimeZone?: string;
50
+ /** Aperçu textuel : `true` = 24 h, `false` = 12 h (AM/PM). */
51
+ use24HourClock?: boolean;
52
+ t: Pick<
53
+ DashboardStrings,
54
+ | "taskPastDatetimePlaceholder"
55
+ | "taskPastDatetimeTimeLabel"
56
+ | "taskPastDatetimeHourAria"
57
+ | "taskPastDatetimeMinuteAria"
58
+ | "taskPastDatetimeTodayBtn"
59
+ | "taskPastDatetimeNowBtn"
60
+ >;
61
+ };
62
+
63
+ export function KronosysDatetimePopoverField({
64
+ value,
65
+ onChange,
66
+ onBlur,
67
+ "aria-label": ariaLabel,
68
+ lang,
69
+ displayTimeZone = DEFAULT_DASHBOARD_TIME_ZONE,
70
+ use24HourClock = true,
71
+ t,
72
+ }: KronosysDatetimePopoverFieldProps) {
73
+ const [open, setOpen] = useState(false);
74
+ const wasOpenRef = useRef(false);
75
+ const [pos, setPos] = useState({ top: 0, left: 0, maxW: 300 });
76
+ const triggerRef = useRef<HTMLButtonElement>(null);
77
+ const popoverRef = useRef<HTMLDivElement>(null);
78
+
79
+ const parsed = parseDatetimeLocalValue(value);
80
+ const [selectedDay, setSelectedDay] = useState<Date | undefined>(parsed);
81
+ const [hour, setHour] = useState(parsed?.getHours() ?? 12);
82
+ const [minute, setMinute] = useState(parsed?.getMinutes() ?? 0);
83
+
84
+ useEffect(() => {
85
+ const p = parseDatetimeLocalValue(value);
86
+ setSelectedDay(p);
87
+ if (p) {
88
+ setHour(p.getHours());
89
+ setMinute(p.getMinutes());
90
+ }
91
+ }, [value]);
92
+
93
+ const commitFrom = useCallback(
94
+ (day: Date | undefined, h: number, m: number) => {
95
+ const base = day ?? selectedDay ?? new Date();
96
+ const next = new Date(base);
97
+ next.setHours(h, m, 0, 0);
98
+ onChange(formatDatetimeLocalValue(next));
99
+ },
100
+ [onChange, selectedDay],
101
+ );
102
+
103
+ const updatePosition = useCallback(() => {
104
+ const el = triggerRef.current;
105
+ if (!el) {
106
+ return;
107
+ }
108
+ const r = el.getBoundingClientRect();
109
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
110
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
111
+ const maxW = Math.min(300, vw - 16);
112
+ let top = r.bottom + GAP;
113
+ let left = r.left + r.width / 2 - maxW / 2;
114
+ left = Math.max(8, Math.min(left, vw - maxW - 8));
115
+ if (top + POPOVER_MAX_H > vh - 8) {
116
+ top = Math.max(8, r.top - GAP - POPOVER_MAX_H);
117
+ }
118
+ setPos({ top, left, maxW });
119
+ }, []);
120
+
121
+ useLayoutEffect(() => {
122
+ if (!open) {
123
+ return;
124
+ }
125
+ updatePosition();
126
+ const ro = () => updatePosition();
127
+ window.addEventListener("resize", ro);
128
+ window.addEventListener("scroll", ro, true);
129
+ return () => {
130
+ window.removeEventListener("resize", ro);
131
+ window.removeEventListener("scroll", ro, true);
132
+ };
133
+ }, [open, updatePosition]);
134
+
135
+ useEffect(() => {
136
+ if (!open) {
137
+ return;
138
+ }
139
+ const onDoc = (e: MouseEvent) => {
140
+ const tNode = e.target as Node;
141
+ if (
142
+ popoverRef.current?.contains(tNode) ||
143
+ triggerRef.current?.contains(tNode)
144
+ ) {
145
+ return;
146
+ }
147
+ setOpen(false);
148
+ };
149
+ const onKey = (e: KeyboardEvent) => {
150
+ if (e.key === "Escape") {
151
+ setOpen(false);
152
+ }
153
+ };
154
+ document.addEventListener("mousedown", onDoc);
155
+ document.addEventListener("keydown", onKey);
156
+ return () => {
157
+ document.removeEventListener("mousedown", onDoc);
158
+ document.removeEventListener("keydown", onKey);
159
+ };
160
+ }, [open]);
161
+
162
+ useEffect(() => {
163
+ if (open) {
164
+ wasOpenRef.current = true;
165
+ return;
166
+ }
167
+ if (wasOpenRef.current) {
168
+ wasOpenRef.current = false;
169
+ onBlur?.();
170
+ }
171
+ }, [open, onBlur]);
172
+
173
+ const locale = lang === "fr" ? fr : enUS;
174
+
175
+ const display =
176
+ parsed !== undefined
177
+ ? new Intl.DateTimeFormat(lang === "fr" ? "fr-CA" : "en-CA", {
178
+ dateStyle: "short",
179
+ timeStyle: "short",
180
+ hour12: !use24HourClock,
181
+ ...(displayTimeZone.trim() && isValidIanaTimeZone(displayTimeZone.trim())
182
+ ? { timeZone: displayTimeZone.trim() }
183
+ : {}),
184
+ }).format(parsed)
185
+ : null;
186
+
187
+ const hours = Array.from({ length: 24 }, (_, i) => i);
188
+ const minutes = Array.from({ length: 60 }, (_, i) => i);
189
+
190
+ const popover =
191
+ open && typeof document !== "undefined"
192
+ ? createPortal(
193
+ <div
194
+ ref={popoverRef}
195
+ role="dialog"
196
+ aria-modal="true"
197
+ aria-label={ariaLabel}
198
+ className="kronosys-datetime-popover fixed z-[200] rounded-xl border border-violet-500/45 bg-zinc-50 p-3 shadow-2xl dark:border-violet-400/45 dark:bg-zinc-900"
199
+ style={{
200
+ top: pos.top,
201
+ left: pos.left,
202
+ width: pos.maxW,
203
+ maxHeight: POPOVER_MAX_H,
204
+ }}
205
+ >
206
+ <DayPicker
207
+ mode="single"
208
+ required={false}
209
+ selected={selectedDay}
210
+ defaultMonth={selectedDay ?? new Date()}
211
+ onSelect={(d) => {
212
+ setSelectedDay(d);
213
+ if (d) {
214
+ commitFrom(d, hour, minute);
215
+ }
216
+ }}
217
+ locale={locale}
218
+ classNames={{
219
+ button_previous:
220
+ "rdp-button_previous rounded-lg border border-zinc-300 bg-white text-zinc-800 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700",
221
+ button_next:
222
+ "rdp-button_next rounded-lg border border-zinc-300 bg-white text-zinc-800 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700",
223
+ caption_label:
224
+ "rdp-caption_label text-sm font-semibold text-zinc-800 dark:text-zinc-100",
225
+ weekday: "rdp-weekday text-zinc-500 dark:text-zinc-400",
226
+ }}
227
+ components={{
228
+ Chevron: (props) =>
229
+ props.orientation === "left" ? (
230
+ <span
231
+ className="text-base leading-none font-semibold"
232
+ aria-hidden
233
+ >
234
+
235
+ </span>
236
+ ) : (
237
+ <span
238
+ className="text-base leading-none font-semibold"
239
+ aria-hidden
240
+ >
241
+
242
+ </span>
243
+ ),
244
+ }}
245
+ />
246
+ <div className="mt-3 flex flex-wrap items-center justify-center gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-700/80">
247
+ <button
248
+ type="button"
249
+ className="rounded-lg border border-zinc-300 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
250
+ onClick={() => {
251
+ const now = new Date();
252
+ const today = new Date(
253
+ now.getFullYear(),
254
+ now.getMonth(),
255
+ now.getDate(),
256
+ hour,
257
+ minute,
258
+ 0,
259
+ 0,
260
+ );
261
+ setSelectedDay(today);
262
+ commitFrom(today, hour, minute);
263
+ }}
264
+ >
265
+ {t.taskPastDatetimeTodayBtn}
266
+ </button>
267
+ <button
268
+ type="button"
269
+ className="rounded-lg border border-violet-500/45 bg-violet-50 px-2.5 py-1 text-xs font-medium text-violet-700 hover:bg-violet-100 dark:border-violet-400/55 dark:bg-violet-950/35 dark:text-violet-200 dark:hover:bg-violet-900/40"
270
+ onClick={() => {
271
+ const now = new Date();
272
+ const h = now.getHours();
273
+ const m = now.getMinutes();
274
+ setSelectedDay(now);
275
+ setHour(h);
276
+ setMinute(m);
277
+ commitFrom(now, h, m);
278
+ }}
279
+ >
280
+ {t.taskPastDatetimeNowBtn}
281
+ </button>
282
+ </div>
283
+ <div className="mt-3 flex flex-wrap items-center justify-center gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-700/80">
284
+ <label className="flex items-center gap-1.5 text-xs font-medium text-zinc-600 dark:text-zinc-400">
285
+ <span className="whitespace-nowrap">
286
+ {t.taskPastDatetimeTimeLabel}
287
+ </span>
288
+ <select
289
+ className="h-8 rounded-lg border border-violet-500/45 bg-white px-2 text-xs font-mono text-zinc-900 outline-none dark:border-violet-400/50 dark:bg-zinc-800 dark:text-zinc-100"
290
+ value={hour}
291
+ onChange={(e) => {
292
+ const h = Number(e.target.value);
293
+ setHour(h);
294
+ commitFrom(selectedDay, h, minute);
295
+ }}
296
+ aria-label={t.taskPastDatetimeHourAria}
297
+ >
298
+ {hours.map((h) => (
299
+ <option key={h} value={h}>
300
+ {pad2(h)}
301
+ </option>
302
+ ))}
303
+ </select>
304
+ <span className="font-mono text-zinc-500">:</span>
305
+ <select
306
+ className="h-8 rounded-lg border border-violet-500/45 bg-white px-2 text-xs font-mono text-zinc-900 outline-none dark:border-violet-400/50 dark:bg-zinc-800 dark:text-zinc-100"
307
+ value={minute}
308
+ onChange={(e) => {
309
+ const m = Number(e.target.value);
310
+ setMinute(m);
311
+ commitFrom(selectedDay, hour, m);
312
+ }}
313
+ aria-label={t.taskPastDatetimeMinuteAria}
314
+ >
315
+ {minutes.map((m) => (
316
+ <option key={m} value={m}>
317
+ {pad2(m)}
318
+ </option>
319
+ ))}
320
+ </select>
321
+ </label>
322
+ </div>
323
+ </div>,
324
+ document.body,
325
+ )
326
+ : null;
327
+
328
+ return (
329
+ <div className="inline-flex min-w-0 max-w-full shrink-0">
330
+ <button
331
+ ref={triggerRef}
332
+ type="button"
333
+ className={`${TASK_PAST_DATETIME_TRIGGER_CLASS} inline-flex max-w-full cursor-pointer items-center gap-2 text-left`}
334
+ aria-label={ariaLabel}
335
+ aria-expanded={open}
336
+ aria-haspopup="dialog"
337
+ onClick={() => setOpen((o) => !o)}
338
+ >
339
+ <span
340
+ className={`inline-block min-w-0 max-w-full flex-1 truncate text-left font-mono text-xs tabular-nums ${
341
+ display
342
+ ? "text-zinc-800 dark:text-zinc-100"
343
+ : "text-zinc-500 dark:text-zinc-500"
344
+ }`}
345
+ >
346
+ {display ?? t.taskPastDatetimePlaceholder}
347
+ </span>
348
+ <Calendar
349
+ className="h-3.5 w-3.5 shrink-0 opacity-70"
350
+ strokeWidth={2}
351
+ aria-hidden
352
+ />
353
+ </button>
354
+ {popover}
355
+ </div>
356
+ );
357
+ }
@@ -0,0 +1,233 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { Clock } from "lucide-react";
6
+
7
+ import type { Lang } from "@/lib/dashboardCopy";
8
+ import type { SettingsCopy } from "@/lib/settingsCopy";
9
+
10
+ const TIME_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;
11
+
12
+ function pad2(n: number): string {
13
+ return String(n).padStart(2, "0");
14
+ }
15
+
16
+ export function parseTimeHHmm(s: string): { h: number; m: number } {
17
+ const t = s.trim();
18
+ const m = TIME_RE.exec(t);
19
+ if (m) {
20
+ return { h: Number(m[1]), m: Number(m[2]) };
21
+ }
22
+ return { h: 9, m: 0 };
23
+ }
24
+
25
+ export function formatTimeHHmm(h: number, m: number): string {
26
+ const hh = ((h % 24) + 24) % 24;
27
+ const mm = ((m % 60) + 60) % 60;
28
+ return `${pad2(hh)}:${pad2(mm)}`;
29
+ }
30
+
31
+ const POPOVER_MAX_H = 200;
32
+ const GAP = 8;
33
+
34
+ type KronosysTimePopoverFieldProps = {
35
+ value: string;
36
+ onChange: (next: string) => void;
37
+ disabled?: boolean;
38
+ className?: string;
39
+ "aria-label": string;
40
+ lang: Lang;
41
+ /** Aperçu localisé sous les sélecteurs : `true` = 24 h, `false` = 12 h (AM/PM). */
42
+ use24HourClock?: boolean;
43
+ t: Pick<SettingsCopy, "timePickerPopoverTimeLabel" | "timePickerPopoverHourAria" | "timePickerPopoverMinuteAria">;
44
+ };
45
+
46
+ export function KronosysTimePopoverField({
47
+ value,
48
+ onChange,
49
+ disabled,
50
+ className = "",
51
+ "aria-label": ariaLabel,
52
+ lang,
53
+ use24HourClock = true,
54
+ t,
55
+ }: KronosysTimePopoverFieldProps) {
56
+ const [open, setOpen] = useState(false);
57
+ const [pos, setPos] = useState({ top: 0, left: 0, maxW: 280 });
58
+ const triggerRef = useRef<HTMLButtonElement>(null);
59
+ const popoverRef = useRef<HTMLDivElement>(null);
60
+
61
+ const init = parseTimeHHmm(value);
62
+ const [hour, setHour] = useState(init.h);
63
+ const [minute, setMinute] = useState(init.m);
64
+
65
+ useEffect(() => {
66
+ const { h, m } = parseTimeHHmm(value);
67
+ setHour(h);
68
+ setMinute(m);
69
+ }, [value]);
70
+
71
+ const commit = useCallback(
72
+ (h: number, m: number) => {
73
+ onChange(formatTimeHHmm(h, m));
74
+ },
75
+ [onChange]
76
+ );
77
+
78
+ const display = formatTimeHHmm(hour, minute);
79
+ const displayLocalized = (() => {
80
+ const d = new Date();
81
+ d.setHours(hour, minute, 0, 0);
82
+ const loc = lang === "fr" ? "fr-CA" : "en-CA";
83
+ return new Intl.DateTimeFormat(loc, { timeStyle: "short", hour12: !use24HourClock }).format(d);
84
+ })();
85
+
86
+ const updatePosition = useCallback(() => {
87
+ const el = triggerRef.current;
88
+ if (!el) {
89
+ return;
90
+ }
91
+ const r = el.getBoundingClientRect();
92
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
93
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
94
+ const maxW = Math.min(280, vw - 16);
95
+ let top = r.bottom + GAP;
96
+ let left = r.left + r.width / 2 - maxW / 2;
97
+ left = Math.max(8, Math.min(left, vw - maxW - 8));
98
+ if (top + POPOVER_MAX_H > vh - 8) {
99
+ top = Math.max(8, r.top - GAP - POPOVER_MAX_H);
100
+ }
101
+ setPos({ top, left, maxW });
102
+ }, []);
103
+
104
+ useLayoutEffect(() => {
105
+ if (!open || disabled) {
106
+ return;
107
+ }
108
+ updatePosition();
109
+ const ro = () => updatePosition();
110
+ window.addEventListener("resize", ro);
111
+ window.addEventListener("scroll", ro, true);
112
+ return () => {
113
+ window.removeEventListener("resize", ro);
114
+ window.removeEventListener("scroll", ro, true);
115
+ };
116
+ }, [open, disabled, updatePosition]);
117
+
118
+ useEffect(() => {
119
+ if (!open || disabled) {
120
+ return;
121
+ }
122
+ const onDoc = (e: MouseEvent) => {
123
+ const tNode = e.target as Node;
124
+ if (popoverRef.current?.contains(tNode) || triggerRef.current?.contains(tNode)) {
125
+ return;
126
+ }
127
+ setOpen(false);
128
+ };
129
+ const onKey = (e: KeyboardEvent) => {
130
+ if (e.key === "Escape") {
131
+ setOpen(false);
132
+ }
133
+ };
134
+ document.addEventListener("mousedown", onDoc);
135
+ document.addEventListener("keydown", onKey);
136
+ return () => {
137
+ document.removeEventListener("mousedown", onDoc);
138
+ document.removeEventListener("keydown", onKey);
139
+ };
140
+ }, [open, disabled]);
141
+
142
+ const hours = Array.from({ length: 24 }, (_, i) => i);
143
+ const minutes = Array.from({ length: 60 }, (_, i) => i);
144
+
145
+ const popover =
146
+ open && !disabled && typeof document !== "undefined"
147
+ ? createPortal(
148
+ <div
149
+ ref={popoverRef}
150
+ role="dialog"
151
+ aria-modal="true"
152
+ aria-label={ariaLabel}
153
+ className="kronosys-time-popover fixed z-[200] rounded-xl border border-violet-500/45 bg-zinc-50 p-3 shadow-2xl dark:border-violet-400/45 dark:bg-zinc-900"
154
+ style={{
155
+ top: pos.top,
156
+ left: pos.left,
157
+ width: pos.maxW,
158
+ maxHeight: POPOVER_MAX_H,
159
+ }}
160
+ >
161
+ <div className="flex flex-wrap items-center justify-center gap-2">
162
+ <span className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t.timePickerPopoverTimeLabel}</span>
163
+ <div className="inline-flex items-center gap-1.5">
164
+ <select
165
+ className="h-9 rounded-lg border border-violet-500/45 bg-white px-2.5 text-sm font-mono text-zinc-900 outline-none dark:border-violet-400/50 dark:bg-zinc-800 dark:text-zinc-100"
166
+ value={hour}
167
+ onChange={(e) => {
168
+ const h = Number(e.target.value);
169
+ setHour(h);
170
+ commit(h, minute);
171
+ }}
172
+ aria-label={t.timePickerPopoverHourAria}
173
+ >
174
+ {hours.map((h) => (
175
+ <option key={h} value={h}>
176
+ {pad2(h)}
177
+ </option>
178
+ ))}
179
+ </select>
180
+ <span className="font-mono text-zinc-500">:</span>
181
+ <select
182
+ className="h-9 rounded-lg border border-violet-500/45 bg-white px-2.5 text-sm font-mono text-zinc-900 outline-none dark:border-violet-400/50 dark:bg-zinc-800 dark:text-zinc-100"
183
+ value={minute}
184
+ onChange={(e) => {
185
+ const m = Number(e.target.value);
186
+ setMinute(m);
187
+ commit(hour, m);
188
+ }}
189
+ aria-label={t.timePickerPopoverMinuteAria}
190
+ >
191
+ {minutes.map((m) => (
192
+ <option key={m} value={m}>
193
+ {pad2(m)}
194
+ </option>
195
+ ))}
196
+ </select>
197
+ </div>
198
+ </div>
199
+ <p className="mt-2 text-center text-xs text-zinc-500 dark:text-zinc-400" aria-hidden>
200
+ {display} · {displayLocalized}
201
+ </p>
202
+ </div>,
203
+ document.body
204
+ )
205
+ : null;
206
+
207
+ return (
208
+ <div className={`inline-flex min-w-0 max-w-full ${className}`}>
209
+ <button
210
+ ref={triggerRef}
211
+ type="button"
212
+ disabled={disabled}
213
+ className={
214
+ "inline-flex w-full max-w-md items-center justify-between gap-2 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-left text-sm outline-none ring-violet-500/30 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 " +
215
+ (disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600")
216
+ }
217
+ aria-label={ariaLabel}
218
+ aria-expanded={open}
219
+ aria-haspopup="dialog"
220
+ onClick={() => {
221
+ if (disabled) {
222
+ return;
223
+ }
224
+ setOpen((o) => !o);
225
+ }}
226
+ >
227
+ <span className="min-w-0 flex-1 truncate font-mono text-sm tabular-nums text-zinc-900 dark:text-zinc-100">{display}</span>
228
+ <Clock className="h-4 w-4 shrink-0 opacity-70" strokeWidth={2} aria-hidden />
229
+ </button>
230
+ {popover}
231
+ </div>
232
+ );
233
+ }