@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
@@ -1,8 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
4
10
  import { createPortal } from "react-dom";
5
- import { Clock } from "lucide-react";
11
+ import { Check, Clock } from "lucide-react";
6
12
 
7
13
  import type { Lang } from "@/lib/dashboardCopy";
8
14
  import type { SettingsCopy } from "@/lib/settingsCopy";
@@ -40,7 +46,12 @@ type KronosysTimePopoverFieldProps = {
40
46
  lang: Lang;
41
47
  /** Aperçu localisé sous les sélecteurs : `true` = 24 h, `false` = 12 h (AM/PM). */
42
48
  use24HourClock?: boolean;
43
- t: Pick<SettingsCopy, "timePickerPopoverTimeLabel" | "timePickerPopoverHourAria" | "timePickerPopoverMinuteAria">;
49
+ t: Pick<
50
+ SettingsCopy,
51
+ | "timePickerPopoverTimeLabel"
52
+ | "timePickerPopoverHourAria"
53
+ | "timePickerPopoverMinuteAria"
54
+ >;
44
55
  };
45
56
 
46
57
  export function KronosysTimePopoverField({
@@ -72,15 +83,19 @@ export function KronosysTimePopoverField({
72
83
  (h: number, m: number) => {
73
84
  onChange(formatTimeHHmm(h, m));
74
85
  },
75
- [onChange]
86
+ [onChange],
76
87
  );
77
88
 
78
89
  const display = formatTimeHHmm(hour, minute);
90
+ const acceptAriaLabel = lang === "fr" ? "Accepter l'heure" : "Accept time";
79
91
  const displayLocalized = (() => {
80
92
  const d = new Date();
81
93
  d.setHours(hour, minute, 0, 0);
82
94
  const loc = lang === "fr" ? "fr-CA" : "en-CA";
83
- return new Intl.DateTimeFormat(loc, { timeStyle: "short", hour12: !use24HourClock }).format(d);
95
+ return new Intl.DateTimeFormat(loc, {
96
+ timeStyle: "short",
97
+ hour12: !use24HourClock,
98
+ }).format(d);
84
99
  })();
85
100
 
86
101
  const updatePosition = useCallback(() => {
@@ -121,7 +136,10 @@ export function KronosysTimePopoverField({
121
136
  }
122
137
  const onDoc = (e: MouseEvent) => {
123
138
  const tNode = e.target as Node;
124
- if (popoverRef.current?.contains(tNode) || triggerRef.current?.contains(tNode)) {
139
+ if (
140
+ popoverRef.current?.contains(tNode) ||
141
+ triggerRef.current?.contains(tNode)
142
+ ) {
125
143
  return;
126
144
  }
127
145
  setOpen(false);
@@ -159,7 +177,9 @@ export function KronosysTimePopoverField({
159
177
  }}
160
178
  >
161
179
  <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>
180
+ <span className="text-xs font-medium text-zinc-600 dark:text-zinc-400">
181
+ {t.timePickerPopoverTimeLabel}
182
+ </span>
163
183
  <div className="inline-flex items-center gap-1.5">
164
184
  <select
165
185
  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"
@@ -195,12 +215,26 @@ export function KronosysTimePopoverField({
195
215
  ))}
196
216
  </select>
197
217
  </div>
218
+ <button
219
+ type="button"
220
+ className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-emerald-500/50 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-400/60 dark:bg-emerald-950/35 dark:text-emerald-200 dark:hover:bg-emerald-900/40"
221
+ aria-label={acceptAriaLabel}
222
+ onClick={() => {
223
+ commit(hour, minute);
224
+ setOpen(false);
225
+ }}
226
+ >
227
+ <Check className="h-4 w-4" strokeWidth={2.5} aria-hidden />
228
+ </button>
198
229
  </div>
199
- <p className="mt-2 text-center text-xs text-zinc-500 dark:text-zinc-400" aria-hidden>
230
+ <p
231
+ className="mt-2 text-center text-xs text-zinc-500 dark:text-zinc-400"
232
+ aria-hidden
233
+ >
200
234
  {display} · {displayLocalized}
201
235
  </p>
202
236
  </div>,
203
- document.body
237
+ document.body,
204
238
  )
205
239
  : null;
206
240
 
@@ -212,7 +246,9 @@ export function KronosysTimePopoverField({
212
246
  disabled={disabled}
213
247
  className={
214
248
  "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")
249
+ (disabled
250
+ ? "cursor-not-allowed opacity-50"
251
+ : "cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600")
216
252
  }
217
253
  aria-label={ariaLabel}
218
254
  aria-expanded={open}
@@ -224,8 +260,14 @@ export function KronosysTimePopoverField({
224
260
  setOpen((o) => !o);
225
261
  }}
226
262
  >
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 />
263
+ <span className="min-w-0 flex-1 truncate font-mono text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
264
+ {display}
265
+ </span>
266
+ <Clock
267
+ className="h-4 w-4 shrink-0 opacity-70"
268
+ strokeWidth={2}
269
+ aria-hidden
270
+ />
229
271
  </button>
230
272
  {popover}
231
273
  </div>
@@ -16,6 +16,15 @@ export type NewSessionScopePayload =
16
16
  timeEndLocal?: string;
17
17
  };
18
18
 
19
+ /** Résultat de la modale : portée + début de session (immédiat ou ISO passé/présent). */
20
+ export type NewSessionConfirmPayload = {
21
+ scope: NewSessionScopePayload;
22
+ /** `null` = maintenant (comportement par défaut). */
23
+ sessionStartAtIso: string | null;
24
+ /** Fin de session (ISO) — obligatoire avec `sessionStartAtIso` pour une session entièrement passée. */
25
+ sessionEndAtIso: string | null;
26
+ };
27
+
19
28
  const WD_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
20
29
  const WD_FR = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
21
30
 
@@ -34,6 +43,24 @@ function localHmNow(): string {
34
43
  return `${h}:${m}`;
35
44
  }
36
45
 
46
+ /** Valeur pour `<input type="datetime-local">` (fuseau local du navigateur). */
47
+ function localDatetimeLocalNow(): string {
48
+ const d = new Date();
49
+ const pad = (n: number) => String(n).padStart(2, "0");
50
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
51
+ d.getHours(),
52
+ )}:${pad(d.getMinutes())}`;
53
+ }
54
+
55
+ /** Décalage par rapport à maintenant (ex. début de session il y a quelques minutes). */
56
+ function localDatetimeLocalOffsetMs(offsetMs: number): string {
57
+ const d = new Date(Date.now() + offsetMs);
58
+ const pad = (n: number) => String(n).padStart(2, "0");
59
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
60
+ d.getHours(),
61
+ )}:${pad(d.getMinutes())}`;
62
+ }
63
+
37
64
  function useEscapeClose(open: boolean, onClose: () => void) {
38
65
  useEffect(() => {
39
66
  if (!open) {
@@ -60,9 +87,14 @@ export function NewSessionScopeModal({
60
87
  lang: Lang;
61
88
  t: DashboardStrings;
62
89
  onCancel: () => void;
63
- onConfirm: (scope: NewSessionScopePayload) => void;
90
+ onConfirm: (payload: NewSessionConfirmPayload) => void;
64
91
  }) {
65
92
  const titleId = useId();
93
+ const [timingMode, setTimingMode] = useState<"immediate" | "past">(
94
+ "immediate",
95
+ );
96
+ const [pastStartLocal, setPastStartLocal] = useState("");
97
+ const [pastEndLocal, setPastEndLocal] = useState("");
66
98
  const [mode, setMode] = useState<NewSessionScopePayload["mode"]>("none");
67
99
  const [maxHoursStr, setMaxHoursStr] = useState("4");
68
100
  const [dateFrom, setDateFrom] = useState("");
@@ -87,6 +119,9 @@ export function NewSessionScopeModal({
87
119
  if (!open) {
88
120
  return;
89
121
  }
122
+ setTimingMode("immediate");
123
+ setPastStartLocal("");
124
+ setPastEndLocal("");
90
125
  setMode("none");
91
126
  setMaxHoursStr("4");
92
127
  setDateFrom("");
@@ -110,37 +145,81 @@ export function NewSessionScopeModal({
110
145
 
111
146
  const handleSubmit = useCallback(() => {
112
147
  setError(null);
113
- if (mode === "none") {
114
- onConfirm({ mode: "none" });
115
- return;
148
+ let sessionStartAtIso: string | null = null;
149
+ let sessionEndAtIso: string | null = null;
150
+ if (timingMode === "past") {
151
+ const rawStart = pastStartLocal.trim();
152
+ const rawEnd = pastEndLocal.trim();
153
+ if (!rawStart) {
154
+ setError(t.newSessionErrorPastMissing);
155
+ return;
156
+ }
157
+ if (!rawEnd) {
158
+ setError(t.newSessionErrorPastEndMissing);
159
+ return;
160
+ }
161
+ const msStart = Date.parse(rawStart);
162
+ const msEnd = Date.parse(rawEnd);
163
+ if (!Number.isFinite(msStart) || !Number.isFinite(msEnd)) {
164
+ setError(t.newSessionErrorPastInvalid);
165
+ return;
166
+ }
167
+ if (msStart > Date.now() + 60_000) {
168
+ setError(t.newSessionErrorPastFuture);
169
+ return;
170
+ }
171
+ if (msEnd > Date.now() + 60_000) {
172
+ setError(t.newSessionErrorPastEndFuture);
173
+ return;
174
+ }
175
+ if (msEnd <= msStart) {
176
+ setError(t.newSessionErrorPastEndBeforeStart);
177
+ return;
178
+ }
179
+ sessionStartAtIso = new Date(msStart).toISOString();
180
+ sessionEndAtIso = new Date(msEnd).toISOString();
181
+ const slackMs = 1000;
182
+ if (msStart >= Date.now() - slackMs) {
183
+ sessionStartAtIso = null;
184
+ sessionEndAtIso = null;
185
+ }
116
186
  }
117
- if (mode === "maxWallClock") {
187
+
188
+ let scope: NewSessionScopePayload;
189
+ if (mode === "none") {
190
+ scope = { mode: "none" };
191
+ } else if (mode === "maxWallClock") {
118
192
  const h = Number(maxHoursStr.replace(",", "."));
119
193
  if (!Number.isFinite(h) || h < 0.1 || h > 8760) {
120
194
  setError(t.newSessionErrorMax);
121
195
  return;
122
196
  }
123
- onConfirm({
197
+ scope = {
124
198
  mode: "maxWallClock",
125
199
  maxWallClockMinutes: Math.round(h * 60),
126
- });
127
- return;
128
- }
129
- if (mode === "calendar") {
200
+ };
201
+ } else if (mode === "calendar") {
202
+ const ymd = /^\d{4}-\d{2}-\d{2}$/;
130
203
  const a = dateFrom.trim();
131
204
  const b = dateTo.trim();
132
205
  if (!a && !b) {
133
206
  setError(t.newSessionErrorCalendar);
134
207
  return;
135
208
  }
136
- onConfirm({
209
+ if ((a && !ymd.test(a)) || (b && !ymd.test(b))) {
210
+ setError(
211
+ lang === "fr"
212
+ ? "Utilisez une date complète (AAAA-MM-JJ) pour chaque champ renseigné."
213
+ : "Use a full date (YYYY-MM-DD) for each filled field.",
214
+ );
215
+ return;
216
+ }
217
+ scope = {
137
218
  mode: "calendar",
138
219
  ...(a ? { calendarStart: a } : {}),
139
220
  ...(b ? { calendarEnd: b } : {}),
140
- });
141
- return;
142
- }
143
- if (mode === "weekly") {
221
+ };
222
+ } else {
144
223
  const days = weekdays.map((on, i) => (on ? i : -1)).filter((i) => i >= 0);
145
224
  if (days.length === 0) {
146
225
  setError(t.newSessionErrorWeekly);
@@ -160,16 +239,17 @@ export function NewSessionScopeModal({
160
239
  );
161
240
  return;
162
241
  }
163
- onConfirm({
242
+ scope = {
164
243
  mode: "weekly",
165
244
  weekdays: days,
166
245
  timeStartLocal: ts,
167
246
  timeEndLocal: te,
168
- });
169
- return;
247
+ };
248
+ } else {
249
+ scope = { mode: "weekly", weekdays: days };
170
250
  }
171
- onConfirm({ mode: "weekly", weekdays: days });
172
251
  }
252
+ onConfirm({ scope, sessionStartAtIso, sessionEndAtIso });
173
253
  }, [
174
254
  dateFrom,
175
255
  dateTo,
@@ -177,11 +257,20 @@ export function NewSessionScopeModal({
177
257
  maxHoursStr,
178
258
  mode,
179
259
  onConfirm,
260
+ pastEndLocal,
261
+ pastStartLocal,
180
262
  t.newSessionErrorCalendar,
181
263
  t.newSessionErrorMax,
264
+ t.newSessionErrorPastEndBeforeStart,
265
+ t.newSessionErrorPastEndFuture,
266
+ t.newSessionErrorPastEndMissing,
267
+ t.newSessionErrorPastFuture,
268
+ t.newSessionErrorPastInvalid,
269
+ t.newSessionErrorPastMissing,
182
270
  t.newSessionErrorWeekly,
183
271
  timeFrom,
184
272
  timeTo,
273
+ timingMode,
185
274
  useTimeWindow,
186
275
  weekdays,
187
276
  ]);
@@ -214,7 +303,105 @@ export function NewSessionScopeModal({
214
303
  </div>
215
304
 
216
305
  <fieldset className="mt-4 space-y-2">
217
- <legend className="sr-only">Mode</legend>
306
+ <legend className="mb-1 text-sm font-medium text-zinc-800 dark:text-zinc-200">
307
+ {t.newSessionTimingSectionTitle}
308
+ </legend>
309
+ {(
310
+ [
311
+ ["immediate", t.newSessionTimingImmediate],
312
+ ["past", t.newSessionTimingPast],
313
+ ] as const
314
+ ).map(([value, label]) => (
315
+ <label
316
+ key={value}
317
+ className="flex cursor-pointer items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-800 hover:bg-zinc-100/90 has-[:checked]:border-violet-500/50 has-[:checked]:bg-violet-50 dark:border-zinc-700/80 dark:text-zinc-200 dark:hover:bg-zinc-800/50 dark:has-[:checked]:border-violet-500/60 dark:has-[:checked]:bg-violet-950/30"
318
+ >
319
+ <input
320
+ type="radio"
321
+ name="session-start-timing"
322
+ value={value}
323
+ checked={timingMode === value}
324
+ onChange={() => {
325
+ setTimingMode(value);
326
+ if (value === "past") {
327
+ if (pastStartLocal.trim() === "") {
328
+ setPastStartLocal(localDatetimeLocalOffsetMs(-5 * 60_000));
329
+ }
330
+ if (pastEndLocal.trim() === "") {
331
+ setPastEndLocal(localDatetimeLocalNow());
332
+ }
333
+ }
334
+ }}
335
+ className="size-4 accent-violet-500"
336
+ />
337
+ {label}
338
+ </label>
339
+ ))}
340
+ </fieldset>
341
+
342
+ {timingMode === "past" ? (
343
+ <div className="mt-3 space-y-4">
344
+ <label className="block text-sm text-zinc-700 dark:text-zinc-300">
345
+ <span className="mb-1 block font-medium text-zinc-800 dark:text-zinc-200">
346
+ {t.newSessionPastStartLabel}
347
+ </span>
348
+ <div className="flex flex-wrap items-center gap-2">
349
+ <input
350
+ name="session-past-start"
351
+ type="datetime-local"
352
+ value={pastStartLocal}
353
+ min="1000-01-01T00:00"
354
+ max="9999-12-31T23:59"
355
+ onChange={(e) => setPastStartLocal(e.target.value)}
356
+ className="min-w-0 flex-1 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
357
+ />
358
+ <button
359
+ type="button"
360
+ className="shrink-0 rounded-lg border border-zinc-300 px-2.5 py-2 text-xs text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
361
+ onClick={() => setPastStartLocal(localDatetimeLocalOffsetMs(-5 * 60_000))}
362
+ >
363
+ {t.newSessionNowBtn}
364
+ </button>
365
+ </div>
366
+ <span className="mt-1 block text-xs text-zinc-500 dark:text-zinc-400">
367
+ {t.newSessionPastStartHint}
368
+ </span>
369
+ </label>
370
+ <label className="block text-sm text-zinc-700 dark:text-zinc-300">
371
+ <span className="mb-1 block font-medium text-zinc-800 dark:text-zinc-200">
372
+ {t.newSessionPastEndLabel}
373
+ </span>
374
+ <div className="flex flex-wrap items-center gap-2">
375
+ <input
376
+ name="session-past-end"
377
+ type="datetime-local"
378
+ value={pastEndLocal}
379
+ min={
380
+ pastStartLocal.trim() !== ""
381
+ ? pastStartLocal
382
+ : "1000-01-01T00:00"
383
+ }
384
+ max="9999-12-31T23:59"
385
+ onChange={(e) => setPastEndLocal(e.target.value)}
386
+ className="min-w-0 flex-1 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
387
+ />
388
+ <button
389
+ type="button"
390
+ className="shrink-0 rounded-lg border border-zinc-300 px-2.5 py-2 text-xs text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
391
+ onClick={() => setPastEndLocal(localDatetimeLocalNow())}
392
+ >
393
+ {t.newSessionNowBtn}
394
+ </button>
395
+ </div>
396
+ <span className="mt-1 block text-xs text-zinc-500 dark:text-zinc-400">
397
+ {t.newSessionPastEndHint}
398
+ </span>
399
+ </label>
400
+ </div>
401
+ ) : null}
402
+
403
+ <fieldset className="mt-6 space-y-2">
404
+ <legend className="sr-only">{t.newSessionScopeFieldsetLegend}</legend>
218
405
  {(
219
406
  [
220
407
  ["none", t.newSessionModeNone],
@@ -267,6 +454,8 @@ export function NewSessionScopeModal({
267
454
  <input
268
455
  type="date"
269
456
  value={dateFrom}
457
+ min="1000-01-01"
458
+ max="9999-12-31"
270
459
  onChange={(e) => setDateFrom(e.target.value)}
271
460
  className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
272
461
  />
@@ -287,6 +476,8 @@ export function NewSessionScopeModal({
287
476
  <input
288
477
  type="date"
289
478
  value={dateTo}
479
+ min="1000-01-01"
480
+ max="9999-12-31"
290
481
  onChange={(e) => setDateTo(e.target.value)}
291
482
  className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
292
483
  />