@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,123 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ import { Check, ChevronDown, Globe } from "lucide-react";
5
+ import type { Lang } from "@/lib/dashboardCopy";
6
+
7
+ const LANGS: Lang[] = ["en", "fr"];
8
+
9
+ export function LanguageMenu({
10
+ lang,
11
+ labelEn,
12
+ labelFr,
13
+ menuHeading,
14
+ triggerAriaLabel,
15
+ onSelect,
16
+ }: {
17
+ lang: Lang;
18
+ labelEn: string;
19
+ labelFr: string;
20
+ /** Titre court au-dessus des options (ex. « Langue » / « Language »). */
21
+ menuHeading: string;
22
+ triggerAriaLabel: string;
23
+ onSelect: (next: Lang) => void;
24
+ }) {
25
+ const [open, setOpen] = useState(false);
26
+ const rootRef = useRef<HTMLDivElement>(null);
27
+ const menuId = useId();
28
+
29
+ const labelFor = (code: Lang) => (code === "fr" ? labelFr : labelEn);
30
+ const currentLabel = labelFor(lang);
31
+
32
+ useEffect(() => {
33
+ if (!open) {
34
+ return;
35
+ }
36
+ const onDoc = (e: MouseEvent) => {
37
+ if (!rootRef.current?.contains(e.target as Node)) {
38
+ setOpen(false);
39
+ }
40
+ };
41
+ const onKey = (e: KeyboardEvent) => {
42
+ if (e.key === "Escape") {
43
+ setOpen(false);
44
+ }
45
+ };
46
+ document.addEventListener("mousedown", onDoc);
47
+ document.addEventListener("keydown", onKey);
48
+ return () => {
49
+ document.removeEventListener("mousedown", onDoc);
50
+ document.removeEventListener("keydown", onKey);
51
+ };
52
+ }, [open]);
53
+
54
+ return (
55
+ <div className="relative inline-flex shrink-0" ref={rootRef}>
56
+ <button
57
+ type="button"
58
+ className={`inline-flex h-10 min-h-10 shrink-0 items-center gap-2 rounded-lg border px-2.5 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/45 sm:px-3 ${
59
+ open
60
+ ? "border-violet-500/50 bg-violet-50 text-zinc-900 shadow-[0_0_0_1px_rgba(139,92,246,0.12)] dark:bg-zinc-800/90 dark:text-zinc-100"
61
+ : "border-zinc-300 bg-white text-zinc-800 hover:border-zinc-400 hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800/60"
62
+ }`}
63
+ aria-label={triggerAriaLabel}
64
+ aria-haspopup="menu"
65
+ aria-expanded={open ? "true" : "false"}
66
+ aria-controls={menuId}
67
+ onClick={() => setOpen((o) => !o)}
68
+ >
69
+ <Globe size={18} className="shrink-0 text-violet-600 dark:text-violet-400/90" strokeWidth={1.75} aria-hidden />
70
+ <span className="max-w-36 truncate font-medium">{currentLabel}</span>
71
+ <ChevronDown
72
+ size={18}
73
+ className={`shrink-0 text-zinc-600 transition-transform duration-200 dark:text-zinc-500 ${open ? "rotate-180" : ""}`}
74
+ strokeWidth={2}
75
+ aria-hidden
76
+ />
77
+ </button>
78
+ {open ? (
79
+ <div
80
+ id={menuId}
81
+ role="menu"
82
+ aria-orientation="vertical"
83
+ className="absolute right-0 top-[calc(100%+0.375rem)] z-80 min-w-46 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg shadow-zinc-900/12 ring-1 ring-zinc-200/80 backdrop-blur-sm dark:border-zinc-600/90 dark:bg-zinc-900/98 dark:shadow-2xl dark:shadow-black/50 dark:ring-violet-500/15"
84
+ >
85
+ <div className="border-b border-zinc-200 bg-zinc-50/80 px-3 py-2 dark:border-zinc-800/90 dark:bg-transparent">
86
+ <p className="text-[0.65rem] font-medium uppercase tracking-wider text-zinc-600 dark:text-zinc-500">
87
+ {menuHeading}
88
+ </p>
89
+ </div>
90
+ <div className="bg-white p-1 dark:bg-zinc-900/95">
91
+ {LANGS.map((code) => {
92
+ const selected = lang === code;
93
+ return (
94
+ <button
95
+ key={code}
96
+ type="button"
97
+ role="menuitemradio"
98
+ aria-checked={selected ? "true" : "false"}
99
+ className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition ${
100
+ selected
101
+ ? "bg-violet-100 text-violet-950 dark:bg-violet-950/55 dark:text-violet-100"
102
+ : "text-zinc-800 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800/75"
103
+ }`}
104
+ onClick={() => {
105
+ onSelect(code);
106
+ setOpen(false);
107
+ }}
108
+ >
109
+ <span className="font-medium">{labelFor(code)}</span>
110
+ {selected ? (
111
+ <Check size={16} className="shrink-0 text-violet-600 dark:text-violet-400" strokeWidth={2.5} aria-hidden />
112
+ ) : (
113
+ <span className="h-4 w-4 shrink-0" aria-hidden />
114
+ )}
115
+ </button>
116
+ );
117
+ })}
118
+ </div>
119
+ </div>
120
+ ) : null}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { InlineMetricHelpTrigger } from "@/components/dashboard/InlineMetricHelpTrigger";
4
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
5
+
6
+ export type MongoMirrorSyncStatus = "disabled" | "unknown" | "aligned" | "localAhead" | "mongoAhead";
7
+
8
+ export type MongoMirrorSyncPayload = {
9
+ localPersistedCount: number;
10
+ mongoDocumentCount: number | null;
11
+ status: MongoMirrorSyncStatus;
12
+ };
13
+
14
+ export function MongoMirrorSyncLine({
15
+ t,
16
+ sync,
17
+ mongoEnabled,
18
+ }: Readonly<{
19
+ t: DashboardStrings;
20
+ sync: MongoMirrorSyncPayload | undefined;
21
+ mongoEnabled: boolean;
22
+ }>) {
23
+ if (!mongoEnabled || !sync || sync.status === "disabled") {
24
+ return null;
25
+ }
26
+
27
+ const mongoDisplay = sync.mongoDocumentCount === null ? "—" : String(sync.mongoDocumentCount);
28
+ const statusText =
29
+ sync.status === "aligned"
30
+ ? t.mongoSyncStatusAligned
31
+ : sync.status === "unknown"
32
+ ? t.mongoSyncStatusUnknown
33
+ : sync.status === "localAhead"
34
+ ? t.mongoSyncStatusLocalAhead
35
+ : t.mongoSyncStatusMongoAhead;
36
+
37
+ const statusClass =
38
+ sync.status === "aligned" ? "text-emerald-400/90" : "text-amber-400/90";
39
+
40
+ return (
41
+ <div
42
+ className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-400"
43
+ role="status"
44
+ aria-live="polite"
45
+ >
46
+ <span>
47
+ <span className="text-zinc-500">{t.mongoSyncLocalLabel}:</span> {sync.localPersistedCount}
48
+ <span className="mx-1.5 text-zinc-600" aria-hidden>
49
+ ·
50
+ </span>
51
+ <span className="text-zinc-500">{t.mongoSyncMongoLabel}:</span> {mongoDisplay}
52
+ </span>
53
+ <span className={`font-medium ${statusClass}`}>{statusText}</span>
54
+ <InlineMetricHelpTrigger ariaLabel={t.mongoSyncHelpAriaLabel} body={t.mongoSyncHelpBody} />
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,410 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useId, useState } from "react";
4
+ import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
5
+ import { tbModalPrimary } from "@/lib/translucentButtonClasses";
6
+ import { InlineMetricHelpTrigger } from "@/components/dashboard/InlineMetricHelpTrigger";
7
+
8
+ export type NewSessionScopePayload =
9
+ | { mode: "none" }
10
+ | { mode: "maxWallClock"; maxWallClockMinutes: number }
11
+ | { mode: "calendar"; calendarStart?: string; calendarEnd?: string }
12
+ | {
13
+ mode: "weekly";
14
+ weekdays: number[];
15
+ timeStartLocal?: string;
16
+ timeEndLocal?: string;
17
+ };
18
+
19
+ const WD_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
20
+ const WD_FR = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
21
+
22
+ function localYmdToday(): string {
23
+ const now = new Date();
24
+ const y = now.getFullYear();
25
+ const m = String(now.getMonth() + 1).padStart(2, "0");
26
+ const d = String(now.getDate()).padStart(2, "0");
27
+ return `${y}-${m}-${d}`;
28
+ }
29
+
30
+ function localHmNow(): string {
31
+ const now = new Date();
32
+ const h = String(now.getHours()).padStart(2, "0");
33
+ const m = String(now.getMinutes()).padStart(2, "0");
34
+ return `${h}:${m}`;
35
+ }
36
+
37
+ function useEscapeClose(open: boolean, onClose: () => void) {
38
+ useEffect(() => {
39
+ if (!open) {
40
+ return;
41
+ }
42
+ const onKey = (e: KeyboardEvent) => {
43
+ if (e.key === "Escape") {
44
+ onClose();
45
+ }
46
+ };
47
+ document.addEventListener("keydown", onKey);
48
+ return () => document.removeEventListener("keydown", onKey);
49
+ }, [open, onClose]);
50
+ }
51
+
52
+ export function NewSessionScopeModal({
53
+ open,
54
+ lang,
55
+ t,
56
+ onCancel,
57
+ onConfirm,
58
+ }: {
59
+ open: boolean;
60
+ lang: Lang;
61
+ t: DashboardStrings;
62
+ onCancel: () => void;
63
+ onConfirm: (scope: NewSessionScopePayload) => void;
64
+ }) {
65
+ const titleId = useId();
66
+ const [mode, setMode] = useState<NewSessionScopePayload["mode"]>("none");
67
+ const [maxHoursStr, setMaxHoursStr] = useState("4");
68
+ const [dateFrom, setDateFrom] = useState("");
69
+ const [dateTo, setDateTo] = useState("");
70
+ const [weekdays, setWeekdays] = useState<boolean[]>(() => [
71
+ false,
72
+ true,
73
+ true,
74
+ true,
75
+ true,
76
+ true,
77
+ false,
78
+ ]);
79
+ const [useTimeWindow, setUseTimeWindow] = useState(false);
80
+ const [timeFrom, setTimeFrom] = useState("09:00");
81
+ const [timeTo, setTimeTo] = useState("17:00");
82
+ const [error, setError] = useState<string | null>(null);
83
+
84
+ useEscapeClose(open, onCancel);
85
+
86
+ useEffect(() => {
87
+ if (!open) {
88
+ return;
89
+ }
90
+ setMode("none");
91
+ setMaxHoursStr("4");
92
+ setDateFrom("");
93
+ setDateTo("");
94
+ setWeekdays([false, true, true, true, true, true, false]);
95
+ setUseTimeWindow(false);
96
+ setTimeFrom("09:00");
97
+ setTimeTo("17:00");
98
+ setError(null);
99
+ }, [open]);
100
+
101
+ const wdLabels = lang === "fr" ? WD_FR : WD_EN;
102
+
103
+ const toggleDay = useCallback((i: number) => {
104
+ setWeekdays((prev) => {
105
+ const next = [...prev];
106
+ next[i] = !next[i];
107
+ return next;
108
+ });
109
+ }, []);
110
+
111
+ const handleSubmit = useCallback(() => {
112
+ setError(null);
113
+ if (mode === "none") {
114
+ onConfirm({ mode: "none" });
115
+ return;
116
+ }
117
+ if (mode === "maxWallClock") {
118
+ const h = Number(maxHoursStr.replace(",", "."));
119
+ if (!Number.isFinite(h) || h < 0.1 || h > 8760) {
120
+ setError(t.newSessionErrorMax);
121
+ return;
122
+ }
123
+ onConfirm({
124
+ mode: "maxWallClock",
125
+ maxWallClockMinutes: Math.round(h * 60),
126
+ });
127
+ return;
128
+ }
129
+ if (mode === "calendar") {
130
+ const a = dateFrom.trim();
131
+ const b = dateTo.trim();
132
+ if (!a && !b) {
133
+ setError(t.newSessionErrorCalendar);
134
+ return;
135
+ }
136
+ onConfirm({
137
+ mode: "calendar",
138
+ ...(a ? { calendarStart: a } : {}),
139
+ ...(b ? { calendarEnd: b } : {}),
140
+ });
141
+ return;
142
+ }
143
+ if (mode === "weekly") {
144
+ const days = weekdays.map((on, i) => (on ? i : -1)).filter((i) => i >= 0);
145
+ if (days.length === 0) {
146
+ setError(t.newSessionErrorWeekly);
147
+ return;
148
+ }
149
+ if (useTimeWindow) {
150
+ const ts = timeFrom.trim();
151
+ const te = timeTo.trim();
152
+ if (
153
+ !/^([01]?\d|2[0-3]):[0-5]\d$/.test(ts) ||
154
+ !/^([01]?\d|2[0-3]):[0-5]\d$/.test(te)
155
+ ) {
156
+ setError(
157
+ lang === "fr"
158
+ ? "Heures au format HH:mm (24 h)."
159
+ : "Use HH:mm (24 h) for times.",
160
+ );
161
+ return;
162
+ }
163
+ onConfirm({
164
+ mode: "weekly",
165
+ weekdays: days,
166
+ timeStartLocal: ts,
167
+ timeEndLocal: te,
168
+ });
169
+ return;
170
+ }
171
+ onConfirm({ mode: "weekly", weekdays: days });
172
+ }
173
+ }, [
174
+ dateFrom,
175
+ dateTo,
176
+ lang,
177
+ maxHoursStr,
178
+ mode,
179
+ onConfirm,
180
+ t.newSessionErrorCalendar,
181
+ t.newSessionErrorMax,
182
+ t.newSessionErrorWeekly,
183
+ timeFrom,
184
+ timeTo,
185
+ useTimeWindow,
186
+ weekdays,
187
+ ]);
188
+
189
+ if (!open) {
190
+ return null;
191
+ }
192
+
193
+ return (
194
+ <div
195
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
196
+ role="dialog"
197
+ aria-modal="true"
198
+ aria-labelledby={titleId}
199
+ >
200
+ <div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900">
201
+ <div className="flex items-start justify-between gap-2">
202
+ <h2
203
+ id={titleId}
204
+ className="text-lg font-semibold text-zinc-900 dark:text-zinc-100"
205
+ >
206
+ {t.newSessionModalTitle}
207
+ </h2>
208
+ <InlineMetricHelpTrigger
209
+ ariaLabel={t.newSessionModalHelpAria}
210
+ body={t.newSessionModalHelpBody}
211
+ preserveLineBreaks
212
+ panelClassName="w-[min(calc(100vw-2rem),26rem)]"
213
+ />
214
+ </div>
215
+
216
+ <fieldset className="mt-4 space-y-2">
217
+ <legend className="sr-only">Mode</legend>
218
+ {(
219
+ [
220
+ ["none", t.newSessionModeNone],
221
+ ["maxWallClock", t.newSessionModeMax],
222
+ ["calendar", t.newSessionModeCalendar],
223
+ ["weekly", t.newSessionModeWeekly],
224
+ ] as const
225
+ ).map(([value, label]) => (
226
+ <label
227
+ key={value}
228
+ 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"
229
+ >
230
+ <input
231
+ type="radio"
232
+ name="session-scope-mode"
233
+ value={value}
234
+ checked={mode === value}
235
+ onChange={() => setMode(value)}
236
+ className="size-4 accent-violet-500"
237
+ />
238
+ {label}
239
+ </label>
240
+ ))}
241
+ </fieldset>
242
+
243
+ {mode === "maxWallClock" ? (
244
+ <label className="mt-4 block text-sm text-zinc-700 dark:text-zinc-300">
245
+ <span className="mb-1 block font-medium text-zinc-800 dark:text-zinc-200">
246
+ {t.newSessionMaxHoursLabel}
247
+ </span>
248
+ <input
249
+ type="number"
250
+ min={0.1}
251
+ max={8760}
252
+ step={0.1}
253
+ value={maxHoursStr}
254
+ onChange={(e) => setMaxHoursStr(e.target.value)}
255
+ 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"
256
+ />
257
+ </label>
258
+ ) : null}
259
+
260
+ {mode === "calendar" ? (
261
+ <div className="mt-4 grid gap-3 sm:grid-cols-2">
262
+ <label className="block text-sm text-zinc-700 dark:text-zinc-300">
263
+ <span className="mb-1 block font-medium text-zinc-800 dark:text-zinc-200">
264
+ {t.newSessionDateFromLabel}
265
+ </span>
266
+ <div className="flex items-center gap-2">
267
+ <input
268
+ type="date"
269
+ value={dateFrom}
270
+ onChange={(e) => setDateFrom(e.target.value)}
271
+ 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
+ />
273
+ <button
274
+ type="button"
275
+ 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"
276
+ onClick={() => setDateFrom(localYmdToday())}
277
+ >
278
+ {t.newSessionTodayBtn}
279
+ </button>
280
+ </div>
281
+ </label>
282
+ <label className="block text-sm text-zinc-700 dark:text-zinc-300">
283
+ <span className="mb-1 block font-medium text-zinc-800 dark:text-zinc-200">
284
+ {t.newSessionDateToLabel}
285
+ </span>
286
+ <div className="flex items-center gap-2">
287
+ <input
288
+ type="date"
289
+ value={dateTo}
290
+ onChange={(e) => setDateTo(e.target.value)}
291
+ 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
+ />
293
+ <button
294
+ type="button"
295
+ 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"
296
+ onClick={() => setDateTo(localYmdToday())}
297
+ >
298
+ {t.newSessionTodayBtn}
299
+ </button>
300
+ </div>
301
+ </label>
302
+ </div>
303
+ ) : null}
304
+
305
+ {mode === "weekly" ? (
306
+ <div className="mt-4 space-y-3">
307
+ <p className="text-sm font-medium text-zinc-200">
308
+ {t.newSessionWeekdaysLegend}
309
+ </p>
310
+ <div className="flex flex-wrap gap-2">
311
+ {wdLabels.map((label, i) => (
312
+ <label
313
+ key={label}
314
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-zinc-300 px-2 py-1.5 text-xs text-zinc-800 hover:bg-zinc-100 has-[:checked]:border-violet-500/50 has-[:checked]:bg-violet-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800/60 dark:has-[:checked]:border-violet-500/60 dark:has-[:checked]:bg-violet-950/35"
315
+ >
316
+ <input
317
+ type="checkbox"
318
+ checked={weekdays[i]}
319
+ onChange={() => toggleDay(i)}
320
+ className="size-3.5 accent-violet-500"
321
+ />
322
+ {label}
323
+ </label>
324
+ ))}
325
+ </div>
326
+ <label className="flex cursor-pointer items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
327
+ <input
328
+ type="checkbox"
329
+ checked={useTimeWindow}
330
+ onChange={(e) => setUseTimeWindow(e.target.checked)}
331
+ className="size-4 accent-violet-500"
332
+ />
333
+ {t.newSessionUseTimeWindow}
334
+ </label>
335
+ {useTimeWindow ? (
336
+ <div className="grid gap-3 sm:grid-cols-2">
337
+ <label className="block text-sm text-zinc-700 dark:text-zinc-300">
338
+ <span className="mb-1 block font-medium text-zinc-800 dark:text-zinc-200">
339
+ {t.newSessionTimeFromLabel}
340
+ </span>
341
+ <div className="flex items-center gap-2">
342
+ <input
343
+ type="time"
344
+ value={timeFrom}
345
+ onChange={(e) => setTimeFrom(e.target.value)}
346
+ 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"
347
+ />
348
+ <button
349
+ type="button"
350
+ 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"
351
+ onClick={() => setTimeFrom(localHmNow())}
352
+ >
353
+ {t.newSessionNowBtn}
354
+ </button>
355
+ </div>
356
+ </label>
357
+ <label className="block text-sm text-zinc-700 dark:text-zinc-300">
358
+ <span className="mb-1 block font-medium text-zinc-800 dark:text-zinc-200">
359
+ {t.newSessionTimeToLabel}
360
+ </span>
361
+ <div className="flex items-center gap-2">
362
+ <input
363
+ type="time"
364
+ value={timeTo}
365
+ onChange={(e) => setTimeTo(e.target.value)}
366
+ 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"
367
+ />
368
+ <button
369
+ type="button"
370
+ 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"
371
+ onClick={() => setTimeTo(localHmNow())}
372
+ >
373
+ {t.newSessionNowBtn}
374
+ </button>
375
+ </div>
376
+ </label>
377
+ </div>
378
+ ) : null}
379
+ </div>
380
+ ) : null}
381
+
382
+ {error ? (
383
+ <p
384
+ className="mt-3 text-sm text-red-700 dark:text-amber-200"
385
+ role="alert"
386
+ >
387
+ {error}
388
+ </p>
389
+ ) : null}
390
+
391
+ <div className="mt-6 flex flex-wrap justify-end gap-2">
392
+ <button
393
+ type="button"
394
+ className="rounded-lg border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
395
+ onClick={onCancel}
396
+ >
397
+ {t.newSessionCancelBtn}
398
+ </button>
399
+ <button
400
+ type="button"
401
+ className={tbModalPrimary}
402
+ onClick={handleSubmit}
403
+ >
404
+ {t.newSessionStartBtn}
405
+ </button>
406
+ </div>
407
+ </div>
408
+ </div>
409
+ );
410
+ }