@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.
- package/README.md +81 -0
- package/app/api/action/route.ts +16 -0
- package/app/api/backup/route.ts +84 -0
- package/app/api/health/route.ts +22 -0
- package/app/api/state/route.ts +27 -0
- package/app/apple-icon.png +0 -0
- package/app/changelog/page.tsx +122 -0
- package/app/globals.css +210 -0
- package/app/guide/layout.tsx +11 -0
- package/app/guide/page.tsx +278 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +77 -0
- package/app/licenses/layout.tsx +11 -0
- package/app/licenses/page.tsx +246 -0
- package/app/manifest.ts +32 -0
- package/app/page.tsx +1610 -0
- package/app/reporting/page.tsx +2943 -0
- package/app/settings/layout.tsx +10 -0
- package/app/settings/page.tsx +3518 -0
- package/bin/kronosys.mjs +46 -0
- package/components/KronosysPackageVersionProvider.tsx +19 -0
- package/components/KronosysPayloadProvider.tsx +109 -0
- package/components/PwaRegister.tsx +25 -0
- package/components/SiteLegalFooter.tsx +21 -0
- package/components/ThemeProvider.tsx +78 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
- package/components/dashboard/AppShellRouteNav.tsx +131 -0
- package/components/dashboard/AppVersionStamp.tsx +16 -0
- package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
- package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
- package/components/dashboard/DashboardCommandCenter.tsx +470 -0
- package/components/dashboard/DashboardLangGateModal.tsx +118 -0
- package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
- package/components/dashboard/DashboardSimpleModal.tsx +337 -0
- package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
- package/components/dashboard/DashboardToastProvider.tsx +64 -0
- package/components/dashboard/DashboardTour.tsx +435 -0
- package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
- package/components/dashboard/DeleteSessionModal.tsx +130 -0
- package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
- package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
- package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
- package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
- package/components/dashboard/IssuePickerModal.tsx +168 -0
- package/components/dashboard/KronoFocusPanel.tsx +834 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
- package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
- package/components/dashboard/LanguageMenu.tsx +123 -0
- package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
- package/components/dashboard/NewSessionScopeModal.tsx +410 -0
- package/components/dashboard/PageRefreshButton.tsx +130 -0
- package/components/dashboard/PlainHelpPopover.tsx +97 -0
- package/components/dashboard/ReportingPageToc.tsx +68 -0
- package/components/dashboard/ReportingTour.tsx +342 -0
- package/components/dashboard/SavedProjectPicker.tsx +92 -0
- package/components/dashboard/SavedTagPicker.tsx +115 -0
- package/components/dashboard/ScrollToTopFab.tsx +41 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
- package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
- package/components/dashboard/SessionListPanel.tsx +320 -0
- package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
- package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
- package/components/dashboard/SettingsTour.tsx +332 -0
- package/components/dashboard/TagPills.tsx +149 -0
- package/components/dashboard/TagsHelpTrigger.tsx +84 -0
- package/components/dashboard/TaskFocusPanel.tsx +1261 -0
- package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
- package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
- package/components/dashboard/ThemeToggle.test.tsx +26 -0
- package/components/dashboard/ThemeToggle.tsx +36 -0
- package/components/dashboard/UserGuideBodyText.tsx +62 -0
- package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
- package/components/dashboard/taskFieldStyles.ts +139 -0
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
- package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
- package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
- package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
- package/lib/appShellHeaderClasses.ts +12 -0
- package/lib/backupCsvExport.test.ts +149 -0
- package/lib/backupCsvExport.ts +392 -0
- package/lib/changelogCopy.ts +34 -0
- package/lib/concurrentTaskStartPreference.ts +29 -0
- package/lib/dashboardClockFormat.ts +13 -0
- package/lib/dashboardColumnChrome.ts +3 -0
- package/lib/dashboardColumnHintsStorage.ts +57 -0
- package/lib/dashboardCopy.ts +1831 -0
- package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
- package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
- package/lib/dashboardLangStorage.ts +72 -0
- package/lib/dashboardQuickSearch.ts +476 -0
- package/lib/dashboardQuickSearchQuery.test.ts +63 -0
- package/lib/dashboardQuickSearchQuery.ts +179 -0
- package/lib/dashboardSessionNav.ts +33 -0
- package/lib/dashboardShortcuts.ts +268 -0
- package/lib/dashboardTimeZone.ts +91 -0
- package/lib/dashboardTourStorage.ts +68 -0
- package/lib/dataDir.test.ts +87 -0
- package/lib/dataDir.ts +83 -0
- package/lib/devDataPreferenceFile.ts +55 -0
- package/lib/devDataRuntimeInfo.ts +34 -0
- package/lib/formatIsoShort.test.ts +46 -0
- package/lib/formatIsoShort.ts +29 -0
- package/lib/generatedUserChangelog.ts +34 -0
- package/lib/gitlabIssueSearch.ts +8 -0
- package/lib/kronoFocusDurationHistory.ts +71 -0
- package/lib/kronoFocusRhythm.test.ts +130 -0
- package/lib/kronoFocusRhythm.ts +46 -0
- package/lib/kronoFocusTimerUrgency.test.ts +74 -0
- package/lib/kronoFocusTimerUrgency.ts +24 -0
- package/lib/kronosysApi.ts +143 -0
- package/lib/legacyEditorPayloadKeys.ts +52 -0
- package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
- package/lib/legacyKronoFocusStorageKeys.ts +32 -0
- package/lib/licensesCopy.ts +128 -0
- package/lib/openPlainTextInNewTab.ts +49 -0
- package/lib/readKronosysPackageVersion.ts +10 -0
- package/lib/reportingAggregate.test.ts +325 -0
- package/lib/reportingAggregate.ts +819 -0
- package/lib/reportingDatePresets.ts +41 -0
- package/lib/reportingMetricHelp.ts +430 -0
- package/lib/reportingNonFinalIndicators.test.ts +157 -0
- package/lib/reportingNonFinalIndicators.ts +102 -0
- package/lib/reportingStrings.ts +491 -0
- package/lib/reportingTagWeekBreakdown.test.ts +141 -0
- package/lib/reportingTagWeekBreakdown.ts +181 -0
- package/lib/reportingWeekLayout.test.ts +239 -0
- package/lib/reportingWeekLayout.ts +313 -0
- package/lib/sessionAssiduity.test.ts +25 -0
- package/lib/sessionAssiduity.ts +33 -0
- package/lib/sessionEndReason.ts +55 -0
- package/lib/sessionEndWarnings.test.ts +200 -0
- package/lib/sessionEndWarnings.ts +125 -0
- package/lib/sessionListMerge.test.ts +101 -0
- package/lib/sessionListMerge.ts +70 -0
- package/lib/sessionTaskSidebarStats.test.ts +24 -0
- package/lib/sessionTaskSidebarStats.ts +54 -0
- package/lib/settingsCopy.ts +1276 -0
- package/lib/taskParsing.test.ts +153 -0
- package/lib/taskParsing.ts +737 -0
- package/lib/theme.ts +15 -0
- package/lib/translucentButtonClasses.ts +34 -0
- package/lib/usageProfile.test.ts +84 -0
- package/lib/usageProfile.ts +52 -0
- package/lib/userGuideCopy.ts +464 -0
- package/lib/workspaceLocDefaults.ts +21 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +15 -0
- package/package.json +87 -0
- package/postcss.config.mjs +12 -0
- package/public/apple-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.png +0 -0
- package/public/next.svg +1 -0
- package/public/sw.js +13 -0
- package/public/traceback.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/actionDispatch.test.ts +723 -0
- package/server/actionDispatch.ts +1476 -0
- package/server/actionTaskSession.test.ts +713 -0
- package/server/actionTaskSession.ts +717 -0
- package/server/db.ts +42 -0
- package/server/defaultCfg.ts +87 -0
- package/server/gitlabTokenStore.ts +34 -0
- package/server/kronoFocusHydrate.test.ts +142 -0
- package/server/kronoFocusHydrate.ts +69 -0
- package/server/kronoFocusMigrate.test.ts +53 -0
- package/server/kronoFocusMigrate.ts +78 -0
- package/server/mainTimerHydrate.test.ts +65 -0
- package/server/mainTimerHydrate.ts +53 -0
- package/server/payloadStore.test.ts +78 -0
- package/server/payloadStore.ts +83 -0
- package/server/sessionWallHydrate.test.ts +46 -0
- package/server/sessionWallHydrate.ts +88 -0
- 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
|
+
}
|