@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,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
|
+
}
|