@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/app/api/action/route.ts +39 -3
- package/app/api/action-logs/route.ts +24 -0
- package/app/api/backup/route.ts +1 -1
- package/app/api/restore/route.ts +145 -0
- package/app/changelog/page.tsx +71 -4
- package/app/globals.css +127 -0
- package/app/guide/page.tsx +61 -15
- package/app/implementation/page.tsx +700 -0
- package/app/layout.tsx +14 -3
- package/app/licenses/page.tsx +99 -37
- package/app/logs/page.tsx +258 -0
- package/app/manifest.ts +5 -5
- package/app/page.tsx +784 -229
- package/app/reporting/page.tsx +1266 -474
- package/app/settings/page.tsx +252 -18
- package/bin/kronosys.mjs +140 -15
- package/components/KronosysPayloadProvider.tsx +2 -0
- package/components/RouteTransition.tsx +18 -0
- package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
- package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
- package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
- package/components/dashboard/AppShellRouteNav.tsx +323 -48
- package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
- package/components/dashboard/DashboardSimpleModal.tsx +168 -25
- package/components/dashboard/DashboardTour.tsx +115 -29
- package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
- package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
- package/components/dashboard/NewSessionScopeModal.tsx +211 -20
- package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
- package/components/dashboard/ReportingTour.tsx +87 -21
- package/components/dashboard/SavedProjectPicker.tsx +16 -3
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
- package/components/dashboard/SessionListPanel.tsx +327 -44
- package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
- package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
- package/components/dashboard/SettingsTour.tsx +86 -21
- package/components/dashboard/TagPills.tsx +14 -1
- package/components/dashboard/TaskFocusPanel.tsx +1081 -478
- package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
- package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
- package/components/dashboard/taskFieldStyles.ts +20 -4
- package/components/dashboard/useReportingInteractionState.ts +80 -0
- package/lib/appShellHeaderClasses.ts +13 -0
- package/lib/businessRulesMatrix.ts +210 -0
- package/lib/copyToClipboard.ts +43 -0
- package/lib/dashboardCopy.ts +494 -84
- package/lib/dashboardQuickSearch.ts +54 -2
- package/lib/dashboardTimeZone.ts +109 -0
- package/lib/formatAppShellWallClock.ts +66 -0
- package/lib/formatSessionNameTemplate.ts +141 -0
- package/lib/generatedUserChangelog.ts +177 -6
- package/lib/globalPausePreview.ts +292 -0
- package/lib/implementationNotes.ts +1188 -0
- package/lib/kronosysApi.ts +6 -0
- package/lib/kronosysDashboardModalGates.ts +24 -0
- package/lib/plannedBoundaryAttention.ts +9 -0
- package/lib/plannedBoundaryConflict.ts +23 -0
- package/lib/reportingAggregate.ts +517 -75
- package/lib/reportingMetricHelp.ts +8 -0
- package/lib/reportingStrings.ts +37 -3
- package/lib/sessionListMerge.ts +4 -0
- package/lib/sessionTaskSidebarStats.ts +182 -21
- package/lib/settingsCopy.ts +178 -4
- package/lib/taskParsing.ts +360 -103
- package/lib/taskTemplateDraft.ts +135 -0
- package/lib/taskTimelineGantt.ts +265 -0
- package/lib/temporalDisplayPlanned.ts +71 -0
- package/lib/userGuideCopy.ts +121 -47
- package/next.config.ts +7 -0
- package/package.json +12 -24
- package/server/actionDispatch.ts +1000 -77
- package/server/actionTaskSession.ts +337 -24
- package/server/db.ts +7 -15
- package/server/dbSchema.ts +24 -0
- package/server/defaultCfg.ts +5 -0
- package/server/gitlabTokenStore.ts +0 -12
- package/server/liveHistorySync.ts +53 -0
- package/server/mainTimerHydrate.ts +38 -2
- package/server/payloadStore.ts +33 -11
- package/server/sessionWallHydrate.ts +66 -3
- package/server/userActionLog.ts +126 -0
- package/sonar-project.properties +11 -0
- package/tsconfig.json +2 -1
- package/components/dashboard/IssuePickerModal.tsx +0 -168
- package/components/dashboard/ThemeToggle.test.tsx +0 -26
- package/lib/backupCsvExport.test.ts +0 -149
- package/lib/dashboardQuickSearchQuery.test.ts +0 -63
- package/lib/dataDir.test.ts +0 -87
- package/lib/formatIsoShort.test.ts +0 -46
- package/lib/kronoFocusRhythm.test.ts +0 -130
- package/lib/kronoFocusTimerUrgency.test.ts +0 -74
- package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
- package/lib/reportingAggregate.test.ts +0 -325
- package/lib/reportingNonFinalIndicators.test.ts +0 -157
- package/lib/reportingTagWeekBreakdown.test.ts +0 -141
- package/lib/reportingWeekLayout.test.ts +0 -239
- package/lib/sessionAssiduity.test.ts +0 -25
- package/lib/sessionEndWarnings.test.ts +0 -200
- package/lib/sessionListMerge.test.ts +0 -101
- package/lib/sessionTaskSidebarStats.test.ts +0 -24
- package/lib/taskParsing.test.ts +0 -153
- package/lib/usageProfile.test.ts +0 -84
- package/server/actionDispatch.test.ts +0 -723
- package/server/actionTaskSession.test.ts +0 -713
- package/server/kronoFocusHydrate.test.ts +0 -142
- package/server/kronoFocusMigrate.test.ts +0 -53
- package/server/mainTimerHydrate.test.ts +0 -65
- package/server/payloadStore.test.ts +0 -78
- package/server/sessionWallHydrate.test.ts +0 -46
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useMemo, useState } from "react";
|
|
4
|
+
import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
|
|
5
|
+
import { formatIsoInstantShort } from "@/lib/formatIsoShort";
|
|
6
|
+
import {
|
|
7
|
+
formatDuration,
|
|
8
|
+
formatProjectDisplay,
|
|
9
|
+
formatWallDurationMs,
|
|
10
|
+
taskTitleForDisplay,
|
|
11
|
+
} from "@/lib/taskParsing";
|
|
12
|
+
import type { TaskTimelineGanttRow } from "@/lib/taskTimelineGantt";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_DASHBOARD_TIME_ZONE,
|
|
15
|
+
isValidIanaTimeZone,
|
|
16
|
+
} from "@/lib/dashboardTimeZone";
|
|
17
|
+
|
|
18
|
+
export type { TaskTimelineGanttRow } from "@/lib/taskTimelineGantt";
|
|
19
|
+
|
|
20
|
+
const FILL_BG: readonly string[] = [
|
|
21
|
+
"bg-violet-500/85 dark:bg-violet-500/75",
|
|
22
|
+
"bg-emerald-500/85 dark:bg-emerald-500/75",
|
|
23
|
+
"bg-amber-500/85 dark:bg-amber-500/75",
|
|
24
|
+
"bg-sky-500/85 dark:bg-sky-500/75",
|
|
25
|
+
"bg-rose-500/85 dark:bg-rose-500/75",
|
|
26
|
+
"bg-fuchsia-500/80 dark:bg-fuchsia-500/70",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function fillClassForId(id: string): string {
|
|
30
|
+
let h = 0;
|
|
31
|
+
for (let i = 0; i < id.length; i++) {
|
|
32
|
+
h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
|
33
|
+
}
|
|
34
|
+
return FILL_BG[h % FILL_BG.length] ?? FILL_BG[0];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const GAP_STRIPES =
|
|
38
|
+
"bg-[repeating-linear-gradient(-45deg,theme(colors.zinc.200),theme(colors.zinc.200)_5px,theme(colors.zinc.100)_5px,theme(colors.zinc.100)_10px)] dark:bg-[repeating-linear-gradient(-45deg,theme(colors.zinc.700),theme(colors.zinc.700)_5px,theme(colors.zinc.800)_5px,theme(colors.zinc.800)_10px)]";
|
|
39
|
+
|
|
40
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
41
|
+
const THIRTY_MS = 30 * 60 * 1000;
|
|
42
|
+
const PX_PER_MINUTE = 3;
|
|
43
|
+
const MIN_TRACK_PX = 520;
|
|
44
|
+
|
|
45
|
+
function useEscapeClose(open: boolean, onClose: () => void) {
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const onKey = (e: KeyboardEvent) => {
|
|
51
|
+
if (e.key === "Escape") {
|
|
52
|
+
onClose();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener("keydown", onKey);
|
|
56
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
57
|
+
}, [open, onClose]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clampPct(n: number): number {
|
|
61
|
+
if (!Number.isFinite(n)) {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
return Math.min(100, Math.max(0, n));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatAxisTickLabel(
|
|
68
|
+
ms: number,
|
|
69
|
+
tier: "major" | "half" | "minor",
|
|
70
|
+
lang: Lang,
|
|
71
|
+
displayTimeZone: string,
|
|
72
|
+
use24HourClock: boolean,
|
|
73
|
+
): string {
|
|
74
|
+
const iso = new Date(ms).toISOString();
|
|
75
|
+
if (tier === "major") {
|
|
76
|
+
return (
|
|
77
|
+
formatIsoInstantShort(iso, lang, displayTimeZone, use24HourClock) ?? ""
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const tz =
|
|
81
|
+
displayTimeZone.trim() && isValidIanaTimeZone(displayTimeZone.trim())
|
|
82
|
+
? displayTimeZone.trim()
|
|
83
|
+
: DEFAULT_DASHBOARD_TIME_ZONE;
|
|
84
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
85
|
+
hour: "numeric",
|
|
86
|
+
minute: "2-digit",
|
|
87
|
+
hour12: !use24HourClock,
|
|
88
|
+
};
|
|
89
|
+
if (tz) {
|
|
90
|
+
opts.timeZone = tz;
|
|
91
|
+
}
|
|
92
|
+
return new Intl.DateTimeFormat(
|
|
93
|
+
lang === "fr" ? "fr-CA" : "en-CA",
|
|
94
|
+
opts,
|
|
95
|
+
).format(new Date(ms));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type AxisTick = { ms: number; tier: "major" | "half" | "minor"; label: string };
|
|
99
|
+
|
|
100
|
+
function buildAxisTicks(
|
|
101
|
+
windowStartMs: number,
|
|
102
|
+
windowEndMs: number,
|
|
103
|
+
spanMs: number,
|
|
104
|
+
lang: Lang,
|
|
105
|
+
displayTimeZone: string,
|
|
106
|
+
use24HourClock: boolean,
|
|
107
|
+
): AxisTick[] {
|
|
108
|
+
if (!Number.isFinite(spanMs) || spanMs <= 0) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const out: AxisTick[] = [];
|
|
112
|
+
const firstTickMs = Math.floor(windowStartMs / THIRTY_MS) * THIRTY_MS;
|
|
113
|
+
for (let t = firstTickMs; t <= windowEndMs + 1; t += THIRTY_MS) {
|
|
114
|
+
const tier: AxisTick["tier"] = t % HOUR_MS === 0 ? "major" : "half";
|
|
115
|
+
if (t > windowEndMs + 1) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
const label = formatAxisTickLabel(
|
|
119
|
+
t,
|
|
120
|
+
tier,
|
|
121
|
+
lang,
|
|
122
|
+
displayTimeZone,
|
|
123
|
+
use24HourClock,
|
|
124
|
+
);
|
|
125
|
+
out.push({ ms: t, tier, label });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function barTooltipText(
|
|
131
|
+
r: TaskTimelineGanttRow,
|
|
132
|
+
effEndMs: number,
|
|
133
|
+
t: DashboardStrings,
|
|
134
|
+
lang: Lang,
|
|
135
|
+
displayTimeZone: string,
|
|
136
|
+
use24HourClock: boolean,
|
|
137
|
+
): string {
|
|
138
|
+
const wallMs = Math.max(0, effEndMs - r.startMs);
|
|
139
|
+
const endIso = formatIsoInstantShort(
|
|
140
|
+
new Date(effEndMs).toISOString(),
|
|
141
|
+
lang,
|
|
142
|
+
displayTimeZone,
|
|
143
|
+
use24HourClock,
|
|
144
|
+
);
|
|
145
|
+
const timerMs =
|
|
146
|
+
r.timerDurationMs !== null && Number.isFinite(r.timerDurationMs)
|
|
147
|
+
? r.timerDurationMs
|
|
148
|
+
: null;
|
|
149
|
+
const timerStr =
|
|
150
|
+
timerMs !== null
|
|
151
|
+
? formatWallDurationMs(timerMs)
|
|
152
|
+
: t.tasksTimelineGanttTooltipTimerUnknown;
|
|
153
|
+
const wallStr = formatWallDurationMs(wallMs);
|
|
154
|
+
return [
|
|
155
|
+
`${t.tasksTimelineGanttTooltipStart}: ${r.startLabel}`,
|
|
156
|
+
`${t.tasksTimelineGanttTooltipEnd}: ${endIso ?? r.endLabel}`,
|
|
157
|
+
`${t.tasksTimelineGanttTooltipTimer}: ${timerStr}`,
|
|
158
|
+
`${t.tasksTimelineGanttTooltipWall}: ${wallStr}`,
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function TaskTimelineGanttModal({
|
|
163
|
+
open,
|
|
164
|
+
onClose,
|
|
165
|
+
lang,
|
|
166
|
+
displayTimeZone,
|
|
167
|
+
use24HourClock,
|
|
168
|
+
t,
|
|
169
|
+
title,
|
|
170
|
+
description,
|
|
171
|
+
rows,
|
|
172
|
+
sessionStartMs,
|
|
173
|
+
sessionEndMs,
|
|
174
|
+
}: {
|
|
175
|
+
open: boolean;
|
|
176
|
+
onClose: () => void;
|
|
177
|
+
lang: Lang;
|
|
178
|
+
displayTimeZone: string;
|
|
179
|
+
use24HourClock: boolean;
|
|
180
|
+
t: DashboardStrings;
|
|
181
|
+
title?: string;
|
|
182
|
+
description?: string;
|
|
183
|
+
rows: TaskTimelineGanttRow[];
|
|
184
|
+
sessionStartMs: number | null;
|
|
185
|
+
sessionEndMs: number | null;
|
|
186
|
+
}) {
|
|
187
|
+
const titleId = useId();
|
|
188
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
189
|
+
useEscapeClose(open, onClose);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!open) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
setNowMs(Date.now());
|
|
196
|
+
const id = globalThis.setInterval(() => {
|
|
197
|
+
setNowMs(Date.now());
|
|
198
|
+
}, 1000);
|
|
199
|
+
return () => globalThis.clearInterval(id);
|
|
200
|
+
}, [open]);
|
|
201
|
+
|
|
202
|
+
const durationTotals = useMemo(() => {
|
|
203
|
+
let productive = 0;
|
|
204
|
+
let personal = 0;
|
|
205
|
+
for (const r of rows) {
|
|
206
|
+
const ms =
|
|
207
|
+
r.timerDurationMs !== null && Number.isFinite(r.timerDurationMs)
|
|
208
|
+
? Math.max(0, r.timerDurationMs)
|
|
209
|
+
: 0;
|
|
210
|
+
if (ms <= 0) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (r.personalProject) {
|
|
214
|
+
personal += ms;
|
|
215
|
+
} else {
|
|
216
|
+
productive += ms;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { productive, personal };
|
|
220
|
+
}, [rows]);
|
|
221
|
+
|
|
222
|
+
const { windowStartMs, windowEndMs, spanMs, trackWidthPx } = useMemo(() => {
|
|
223
|
+
if (rows.length === 0) {
|
|
224
|
+
return {
|
|
225
|
+
windowStartMs: 0,
|
|
226
|
+
windowEndMs: 0,
|
|
227
|
+
spanMs: 1,
|
|
228
|
+
trackWidthPx: MIN_TRACK_PX,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const taskStarts = rows.map((r) => r.startMs);
|
|
233
|
+
const minTaskStart = Math.min(...taskStarts);
|
|
234
|
+
|
|
235
|
+
let ws: number;
|
|
236
|
+
if (sessionStartMs !== null) {
|
|
237
|
+
ws = sessionStartMs;
|
|
238
|
+
} else {
|
|
239
|
+
ws = minTaskStart;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let we = nowMs;
|
|
243
|
+
if (sessionEndMs !== null) {
|
|
244
|
+
we = Math.max(we, sessionEndMs);
|
|
245
|
+
}
|
|
246
|
+
for (const r of rows) {
|
|
247
|
+
const effEnd = typeof r.endMs === "number" ? r.endMs : nowMs;
|
|
248
|
+
we = Math.max(we, effEnd);
|
|
249
|
+
}
|
|
250
|
+
we = Math.max(we, ws + 60_000);
|
|
251
|
+
const alignedWindowStart = Math.floor(ws / THIRTY_MS) * THIRTY_MS;
|
|
252
|
+
let alignedWindowEnd = Math.ceil(we / THIRTY_MS) * THIRTY_MS;
|
|
253
|
+
if (alignedWindowEnd <= alignedWindowStart) {
|
|
254
|
+
alignedWindowEnd = alignedWindowStart + THIRTY_MS;
|
|
255
|
+
}
|
|
256
|
+
const span = Math.max(alignedWindowEnd - alignedWindowStart, THIRTY_MS);
|
|
257
|
+
const spanMin = span / 60000;
|
|
258
|
+
const trackWidthPx = Math.max(MIN_TRACK_PX, spanMin * PX_PER_MINUTE);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
windowStartMs: alignedWindowStart,
|
|
262
|
+
windowEndMs: alignedWindowEnd,
|
|
263
|
+
spanMs: span,
|
|
264
|
+
trackWidthPx,
|
|
265
|
+
};
|
|
266
|
+
}, [rows, sessionStartMs, sessionEndMs, nowMs]);
|
|
267
|
+
|
|
268
|
+
const axisTicks = useMemo(
|
|
269
|
+
() =>
|
|
270
|
+
rows.length === 0
|
|
271
|
+
? ([] as AxisTick[])
|
|
272
|
+
: buildAxisTicks(
|
|
273
|
+
windowStartMs,
|
|
274
|
+
windowEndMs,
|
|
275
|
+
spanMs,
|
|
276
|
+
lang,
|
|
277
|
+
displayTimeZone,
|
|
278
|
+
use24HourClock,
|
|
279
|
+
),
|
|
280
|
+
[
|
|
281
|
+
rows.length,
|
|
282
|
+
windowStartMs,
|
|
283
|
+
windowEndMs,
|
|
284
|
+
spanMs,
|
|
285
|
+
lang,
|
|
286
|
+
displayTimeZone,
|
|
287
|
+
use24HourClock,
|
|
288
|
+
],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const sessionOriginLabel = useMemo(
|
|
292
|
+
() =>
|
|
293
|
+
formatIsoInstantShort(
|
|
294
|
+
new Date(windowStartMs).toISOString(),
|
|
295
|
+
lang,
|
|
296
|
+
displayTimeZone,
|
|
297
|
+
use24HourClock,
|
|
298
|
+
),
|
|
299
|
+
[windowStartMs, lang, displayTimeZone, use24HourClock],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const sessionEndLabel = useMemo(
|
|
303
|
+
() =>
|
|
304
|
+
formatIsoInstantShort(
|
|
305
|
+
new Date(windowEndMs).toISOString(),
|
|
306
|
+
lang,
|
|
307
|
+
displayTimeZone,
|
|
308
|
+
use24HourClock,
|
|
309
|
+
),
|
|
310
|
+
[windowEndMs, lang, displayTimeZone, use24HourClock],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const axisBorderClass = (tier: AxisTick["tier"]) => {
|
|
314
|
+
if (tier === "major") {
|
|
315
|
+
return "border-l-[1.5px] border-zinc-400 dark:border-zinc-500";
|
|
316
|
+
}
|
|
317
|
+
if (tier === "half") {
|
|
318
|
+
return "border-l border-zinc-400/85 dark:border-zinc-500/70";
|
|
319
|
+
}
|
|
320
|
+
return "border-l border-zinc-300/75 dark:border-zinc-600/55";
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const axisLabelClass = (tier: AxisTick["tier"]) => {
|
|
324
|
+
if (tier === "major") {
|
|
325
|
+
return "max-w-[10rem] truncate text-[0.62rem] font-medium leading-none text-zinc-600 dark:text-zinc-300";
|
|
326
|
+
}
|
|
327
|
+
if (tier === "half") {
|
|
328
|
+
return "max-w-[6rem] truncate text-[0.58rem] leading-none text-zinc-500 dark:text-zinc-400";
|
|
329
|
+
}
|
|
330
|
+
return "max-w-[5rem] truncate text-[0.54rem] leading-none text-zinc-400/95 dark:text-zinc-500";
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
if (!open) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div
|
|
339
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
|
|
340
|
+
role="dialog"
|
|
341
|
+
aria-modal="true"
|
|
342
|
+
aria-labelledby={titleId}
|
|
343
|
+
onClick={onClose}
|
|
344
|
+
>
|
|
345
|
+
<div
|
|
346
|
+
className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
|
347
|
+
onClick={(e) => e.stopPropagation()}
|
|
348
|
+
>
|
|
349
|
+
<div className="border-b border-zinc-200 px-5 py-4 dark:border-zinc-700/80">
|
|
350
|
+
<h2
|
|
351
|
+
id={titleId}
|
|
352
|
+
className="text-lg font-semibold text-zinc-900 dark:text-zinc-100"
|
|
353
|
+
>
|
|
354
|
+
{title ?? t.tasksTimelineGanttTitle}
|
|
355
|
+
</h2>
|
|
356
|
+
<p className="mt-1.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
357
|
+
{description ?? t.tasksTimelineGanttDescription}
|
|
358
|
+
</p>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div className="min-h-0 flex-1 overflow-hidden px-5 py-4">
|
|
362
|
+
{rows.length === 0 ? (
|
|
363
|
+
<p className="text-sm text-zinc-500">{t.tasksTimelineEmpty}</p>
|
|
364
|
+
) : (
|
|
365
|
+
<div className="flex max-h-[min(70vh,540px)] min-h-[240px] flex-col gap-0 overflow-hidden">
|
|
366
|
+
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
|
367
|
+
<div className="flex min-w-0">
|
|
368
|
+
<div className="flex w-[min(13rem,32vw)] shrink-0 flex-col border-r border-zinc-200 bg-white pr-2 dark:border-zinc-700/80 dark:bg-zinc-900">
|
|
369
|
+
<div className="h-10 shrink-0" aria-hidden />
|
|
370
|
+
{rows.map((r) => (
|
|
371
|
+
<div
|
|
372
|
+
key={`label-${r.id}`}
|
|
373
|
+
className="flex min-h-11 items-start gap-2 border-b border-zinc-100 py-1.5 dark:border-zinc-800/90"
|
|
374
|
+
>
|
|
375
|
+
<div className="min-w-0 flex-1">
|
|
376
|
+
<p className="truncate text-left text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
377
|
+
{taskTitleForDisplay(r.name)}
|
|
378
|
+
</p>
|
|
379
|
+
{typeof r.project === "string" && r.project.trim() ? (
|
|
380
|
+
<p className="mt-0.5 truncate text-left text-[0.65rem] text-zinc-500 dark:text-zinc-400">
|
|
381
|
+
{formatProjectDisplay(r.project, {
|
|
382
|
+
personal: r.personalProject === true,
|
|
383
|
+
})}
|
|
384
|
+
</p>
|
|
385
|
+
) : null}
|
|
386
|
+
</div>
|
|
387
|
+
{r.durationLabel ? (
|
|
388
|
+
<span className="shrink-0 rounded-full border border-zinc-500/40 px-1.5 py-0.5 text-[0.6rem] tabular-nums text-zinc-500 dark:text-zinc-400">
|
|
389
|
+
{r.durationLabel}
|
|
390
|
+
</span>
|
|
391
|
+
) : null}
|
|
392
|
+
</div>
|
|
393
|
+
))}
|
|
394
|
+
<div className="h-10 shrink-0" aria-hidden />
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<div className="min-w-0 flex-1 overflow-x-auto">
|
|
398
|
+
<div
|
|
399
|
+
className="flex flex-col"
|
|
400
|
+
style={{ width: `${trackWidthPx}px`, minWidth: "100%" }}
|
|
401
|
+
>
|
|
402
|
+
<div className="sticky top-0 z-[1] h-10 shrink-0 border-b border-zinc-200 bg-white dark:border-zinc-700/80 dark:bg-zinc-900">
|
|
403
|
+
<div className="relative h-full">
|
|
404
|
+
{axisTicks.map((tick) => {
|
|
405
|
+
const leftPct = clampPct(
|
|
406
|
+
((tick.ms - windowStartMs) / spanMs) * 100,
|
|
407
|
+
);
|
|
408
|
+
return (
|
|
409
|
+
<div
|
|
410
|
+
key={`top-${tick.ms}`}
|
|
411
|
+
className={`absolute top-0 bottom-0 ${axisBorderClass(
|
|
412
|
+
tick.tier,
|
|
413
|
+
)}`}
|
|
414
|
+
style={{ left: `${leftPct}%` }}
|
|
415
|
+
>
|
|
416
|
+
<span
|
|
417
|
+
className={`absolute left-0.5 top-1 whitespace-nowrap ${axisLabelClass(
|
|
418
|
+
tick.tier,
|
|
419
|
+
)}`}
|
|
420
|
+
>
|
|
421
|
+
{tick.label}
|
|
422
|
+
</span>
|
|
423
|
+
</div>
|
|
424
|
+
);
|
|
425
|
+
})}
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{rows.map((r) => {
|
|
430
|
+
const effEnd =
|
|
431
|
+
typeof r.endMs === "number" ? r.endMs : nowMs;
|
|
432
|
+
const clipStart = Math.max(r.startMs, windowStartMs);
|
|
433
|
+
const clipEnd = Math.min(effEnd, windowEndMs);
|
|
434
|
+
const fillClass = r.personalProject
|
|
435
|
+
? "bg-rose-500/85 dark:bg-rose-500/75"
|
|
436
|
+
: fillClassForId(r.id);
|
|
437
|
+
const left = clampPct(
|
|
438
|
+
((clipStart - windowStartMs) / spanMs) * 100,
|
|
439
|
+
);
|
|
440
|
+
const width = clampPct(
|
|
441
|
+
((clipEnd - clipStart) / spanMs) * 100,
|
|
442
|
+
);
|
|
443
|
+
const barW = Math.max(
|
|
444
|
+
width,
|
|
445
|
+
clipEnd > clipStart ? 0.2 : 0,
|
|
446
|
+
);
|
|
447
|
+
const right = left + barW;
|
|
448
|
+
const tip =
|
|
449
|
+
clipEnd > clipStart
|
|
450
|
+
? barTooltipText(
|
|
451
|
+
r,
|
|
452
|
+
effEnd,
|
|
453
|
+
t,
|
|
454
|
+
lang,
|
|
455
|
+
displayTimeZone,
|
|
456
|
+
use24HourClock,
|
|
457
|
+
)
|
|
458
|
+
: "";
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<div
|
|
462
|
+
key={`row-${r.id}`}
|
|
463
|
+
className="relative min-h-11 shrink-0 border-b border-zinc-100 dark:border-zinc-800/90"
|
|
464
|
+
>
|
|
465
|
+
<div
|
|
466
|
+
className={`absolute inset-0 ${GAP_STRIPES}`}
|
|
467
|
+
aria-hidden
|
|
468
|
+
/>
|
|
469
|
+
<div
|
|
470
|
+
className={`absolute top-0 bottom-0 ${fillClass} z-[4] cursor-default rounded-sm shadow-sm ring-1 ring-black/5 dark:ring-white/10`}
|
|
471
|
+
style={{
|
|
472
|
+
left: `${left}%`,
|
|
473
|
+
width: `${barW}%`,
|
|
474
|
+
minWidth:
|
|
475
|
+
clipEnd > clipStart && barW > 0 ? "4px" : 0,
|
|
476
|
+
opacity: clipEnd > clipStart ? 0.92 : 0,
|
|
477
|
+
pointerEvents:
|
|
478
|
+
clipEnd > clipStart ? "auto" : "none",
|
|
479
|
+
}}
|
|
480
|
+
title={tip || undefined}
|
|
481
|
+
aria-hidden={clipEnd <= clipStart}
|
|
482
|
+
/>
|
|
483
|
+
<div
|
|
484
|
+
className={`pointer-events-none absolute inset-0 z-[2] ${GAP_STRIPES}`}
|
|
485
|
+
style={{
|
|
486
|
+
clipPath: `polygon(0% 0%, ${left}% 0%, ${left}% 100%, 0% 100%)`,
|
|
487
|
+
}}
|
|
488
|
+
aria-hidden
|
|
489
|
+
/>
|
|
490
|
+
<div
|
|
491
|
+
className={`pointer-events-none absolute inset-0 z-[2] ${GAP_STRIPES}`}
|
|
492
|
+
style={{
|
|
493
|
+
clipPath: `polygon(${right}% 0%, 100% 0%, 100% 100%, ${right}% 100%)`,
|
|
494
|
+
}}
|
|
495
|
+
aria-hidden
|
|
496
|
+
/>
|
|
497
|
+
</div>
|
|
498
|
+
);
|
|
499
|
+
})}
|
|
500
|
+
|
|
501
|
+
<div className="h-10 shrink-0 border-t border-zinc-200 bg-white dark:border-zinc-700/80 dark:bg-zinc-900">
|
|
502
|
+
<div className="relative h-full">
|
|
503
|
+
{axisTicks.map((tick) => {
|
|
504
|
+
const leftPct = clampPct(
|
|
505
|
+
((tick.ms - windowStartMs) / spanMs) * 100,
|
|
506
|
+
);
|
|
507
|
+
return (
|
|
508
|
+
<div
|
|
509
|
+
key={`bottom-${tick.ms}`}
|
|
510
|
+
className={`absolute top-0 bottom-0 ${axisBorderClass(
|
|
511
|
+
tick.tier,
|
|
512
|
+
)}`}
|
|
513
|
+
style={{ left: `${leftPct}%` }}
|
|
514
|
+
>
|
|
515
|
+
<span
|
|
516
|
+
className={`absolute bottom-1 left-0.5 whitespace-nowrap ${axisLabelClass(
|
|
517
|
+
tick.tier,
|
|
518
|
+
)}`}
|
|
519
|
+
>
|
|
520
|
+
{tick.label}
|
|
521
|
+
</span>
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
})}
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 border-t border-zinc-200 pt-2 text-xs text-zinc-600 dark:border-zinc-700/80 dark:text-zinc-400">
|
|
533
|
+
<span>
|
|
534
|
+
{t.tasksTimelineGanttSummaryProductive.replace(
|
|
535
|
+
"{duration}",
|
|
536
|
+
formatDuration(durationTotals.productive / 60000),
|
|
537
|
+
)}
|
|
538
|
+
</span>
|
|
539
|
+
<span>
|
|
540
|
+
{t.tasksTimelineGanttSummaryPersonal.replace(
|
|
541
|
+
"{duration}",
|
|
542
|
+
formatDuration(durationTotals.personal / 60000),
|
|
543
|
+
)}
|
|
544
|
+
</span>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<div className="mt-2 flex flex-wrap justify-between gap-2 border-t border-zinc-200 pt-2 text-[0.65rem] text-zinc-500 dark:border-zinc-700/80 dark:text-zinc-500">
|
|
548
|
+
<span>
|
|
549
|
+
<span className="font-semibold uppercase tracking-wide">
|
|
550
|
+
{t.tasksTimelineGanttWindowStartLabel}
|
|
551
|
+
</span>{" "}
|
|
552
|
+
<span className="font-mono tabular-nums text-zinc-700 dark:text-zinc-300">
|
|
553
|
+
{sessionOriginLabel}
|
|
554
|
+
</span>
|
|
555
|
+
</span>
|
|
556
|
+
<span>
|
|
557
|
+
<span className="font-semibold uppercase tracking-wide">
|
|
558
|
+
{t.tasksTimelineGanttWindowEndLabel}
|
|
559
|
+
</span>{" "}
|
|
560
|
+
<span className="font-mono tabular-nums text-zinc-700 dark:text-zinc-300">
|
|
561
|
+
{sessionEndLabel}
|
|
562
|
+
</span>
|
|
563
|
+
</span>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<div className="border-t border-zinc-200 px-5 py-4 dark:border-zinc-700/80">
|
|
570
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
571
|
+
<span
|
|
572
|
+
className={`inline-block size-3 shrink-0 rounded-sm ${FILL_BG[0]}`}
|
|
573
|
+
/>
|
|
574
|
+
<span className="text-xs text-zinc-600 dark:text-zinc-400">
|
|
575
|
+
{t.tasksTimelineGanttLegendProductive}
|
|
576
|
+
</span>
|
|
577
|
+
<span className="inline-block size-3 shrink-0 rounded-sm bg-rose-500/85 dark:bg-rose-500/75" />
|
|
578
|
+
<span className="text-xs text-zinc-600 dark:text-zinc-400">
|
|
579
|
+
{t.tasksTimelineGanttLegendPersonal}
|
|
580
|
+
</span>
|
|
581
|
+
<span
|
|
582
|
+
className={`inline-block h-3 w-8 shrink-0 rounded-sm ${GAP_STRIPES} ring-1 ring-zinc-300/80 dark:ring-zinc-600/80`}
|
|
583
|
+
/>
|
|
584
|
+
<span className="text-xs text-zinc-600 dark:text-zinc-400">
|
|
585
|
+
{t.tasksTimelineGanttLegendGap}
|
|
586
|
+
</span>
|
|
587
|
+
</div>
|
|
588
|
+
<div className="mt-4 flex justify-end">
|
|
589
|
+
<button
|
|
590
|
+
type="button"
|
|
591
|
+
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
|
592
|
+
onClick={onClose}
|
|
593
|
+
>
|
|
594
|
+
{t.tasksTimelineGanttCloseBtn}
|
|
595
|
+
</button>
|
|
596
|
+
</div>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
@@ -14,10 +14,11 @@ export const TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS =
|
|
|
14
14
|
"h-10 min-h-10 min-w-0 flex-1 border-0 border-b border-zinc-400 bg-transparent px-0 text-xl leading-snug text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-600 focus:ring-0 rounded-none dark:border-zinc-600 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-zinc-200";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Déclencheur date-heure
|
|
17
|
+
* Déclencheur date-heure : même ton que les libellés « début » / « fin » (zinc-500), sans encadré ;
|
|
18
|
+
* l’icône calendrier ouvre le popover (thème du calendrier inchangé).
|
|
18
19
|
*/
|
|
19
20
|
export const TASK_PAST_DATETIME_TRIGGER_CLASS =
|
|
20
|
-
"h-
|
|
21
|
+
"group/datetime-trigger inline-flex min-h-0 w-auto min-w-0 max-w-[13rem] shrink-0 cursor-pointer items-center gap-1 border-0 bg-transparent p-0 font-mono text-xs tabular-nums shadow-none outline-none transition-[opacity,color] [color-scheme:light] hover:opacity-90 focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-zinc-400/45 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] dark:[color-scheme:dark] dark:focus-visible:ring-zinc-500/40 dark:focus-visible:ring-offset-[var(--background)]";
|
|
21
22
|
|
|
22
23
|
/** Titre tâche en session (lecture) : volontairement plus petit que le champ « nouvelle tâche ». */
|
|
23
24
|
export const TASK_ACTIVE_TITLE_READ_CLASS =
|
|
@@ -77,8 +78,23 @@ export const TAG_SUGGEST_CHIP_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_
|
|
|
77
78
|
/** Suggestion de projet enregistré (`@nom`) : même rendu « éteint » que les tags tant que le projet n’est pas appliqué. */
|
|
78
79
|
export const PROJECT_SUGGEST_CHIP_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-pointer select-none rounded border border-zinc-300 bg-zinc-100 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-zinc-700 hover:border-sky-500/50 hover:bg-sky-50 hover:text-sky-900 dark:border-zinc-600/50 dark:bg-zinc-800/40 dark:text-zinc-400 dark:hover:border-sky-700/50 dark:hover:bg-sky-950/35 dark:hover:text-sky-200/90`;
|
|
79
80
|
|
|
80
|
-
/** Pastille projet appliquée (
|
|
81
|
-
export const PROJECT_CHIP_APPLIED_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-sky-500/50 bg-sky-50 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-sky-
|
|
81
|
+
/** Pastille projet travail appliquée (`@`) — teinte ciel. */
|
|
82
|
+
export const PROJECT_CHIP_APPLIED_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-sky-500/50 bg-sky-50 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-sky-950 dark:border-sky-500/55 dark:bg-sky-950/40 dark:text-sky-100`;
|
|
83
|
+
|
|
84
|
+
/** Pastille projet personnel appliquée (`!`) — teinte rose, distincte des projets `@`. */
|
|
85
|
+
export const PROJECT_CHIP_APPLIED_PERSONAL_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-rose-500/50 bg-rose-50 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-rose-950 dark:border-rose-500/55 dark:bg-rose-950/40 dark:text-rose-100`;
|
|
86
|
+
|
|
87
|
+
/** Raccourci projet personnel inactif (suggestions). */
|
|
88
|
+
export const PROJECT_INLINE_SUGGEST_PERSONAL =
|
|
89
|
+
"shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-medium text-zinc-600 transition-colors hover:text-rose-800 dark:text-zinc-400 dark:hover:text-rose-200";
|
|
90
|
+
|
|
91
|
+
/** Raccourci projet personnel actif. */
|
|
92
|
+
export const PROJECT_INLINE_SUGGEST_PERSONAL_SELECTED =
|
|
93
|
+
"shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-semibold text-rose-900 underline decoration-rose-500/80 decoration-2 underline-offset-4 dark:text-rose-100";
|
|
94
|
+
|
|
95
|
+
/** Projet personnel appliqué en style texte. */
|
|
96
|
+
export const PROJECT_INLINE_APPLIED_PERSONAL =
|
|
97
|
+
"text-[0.9375rem] font-semibold text-rose-900 dark:text-rose-100";
|
|
82
98
|
|
|
83
99
|
// --- Rendu « texte » (tâches : sans boîtes type pastille) ----------------------------------------
|
|
84
100
|
|