@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
|
@@ -200,16 +200,48 @@ export function ensureTaskParentDurationCoversSubtasksMs(task: Record<string, un
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
/** Clé ISO : début du segment de minuteur courant sur une sous-tâche (tâche parente). */
|
|
203
|
-
const SUBTASK_TIMER_STARTED_AT = "subtaskTimerStartedAt";
|
|
203
|
+
export const SUBTASK_TIMER_STARTED_AT = "subtaskTimerStartedAt";
|
|
204
204
|
|
|
205
205
|
/** Clé ISO : début du segment du minuteur principal (hors sous-tâche) — persisté pour ne pas perdre le temps hors tableau de bord. */
|
|
206
206
|
export const MAIN_TIMER_SEGMENT_STARTED_AT = "mainTimerSegmentStartedAt";
|
|
207
|
+
/** Clé ISO : heure de fin planifiée d'une tâche en cours. */
|
|
208
|
+
export const TASK_SCHEDULED_END_AT = "scheduledEndAt";
|
|
209
|
+
/** Liste chronologique des segments de suivi (laps) d'une tâche. */
|
|
210
|
+
export const TASK_TIMER_LAPS = "taskTimerLaps";
|
|
211
|
+
/** Clé ISO : début du lap courant (stable entre les matérialisations de segment). */
|
|
212
|
+
export const TASK_CURRENT_LAP_STARTED_AT = "taskCurrentLapStartedAt";
|
|
213
|
+
|
|
214
|
+
function ensureTaskTimerLaps(task: Record<string, unknown>): Record<string, unknown>[] {
|
|
215
|
+
if (!Array.isArray(task[TASK_TIMER_LAPS])) {
|
|
216
|
+
task[TASK_TIMER_LAPS] = [];
|
|
217
|
+
}
|
|
218
|
+
return task[TASK_TIMER_LAPS] as Record<string, unknown>[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function appendTaskTimerLap(task: Record<string, unknown>, startMs: number, endMs: number): void {
|
|
222
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const durationMs = Math.max(0, Math.floor(endMs - startMs));
|
|
226
|
+
if (durationMs < 1000) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const laps = ensureTaskTimerLaps(task);
|
|
230
|
+
laps.push({
|
|
231
|
+
startTime: new Date(startMs).toISOString(),
|
|
232
|
+
endTime: new Date(endMs).toISOString(),
|
|
233
|
+
durationMs,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
207
236
|
|
|
208
237
|
/**
|
|
209
238
|
* Vide le segment de minuteur principal en cours : ajoute le temps écoulé à `durationMs`.
|
|
210
239
|
* Sans effet s’un suivi sous-tâche est actif (le parent ne cumule pas en parallèle).
|
|
211
240
|
*/
|
|
212
|
-
export function flushMainTimerSegmentOnTask(
|
|
241
|
+
export function flushMainTimerSegmentOnTask(
|
|
242
|
+
task: Record<string, unknown>,
|
|
243
|
+
flushClockEndMs: number = Date.now(),
|
|
244
|
+
): void {
|
|
213
245
|
if (String(task.activeSubtaskTimerId ?? "").trim() !== "") {
|
|
214
246
|
return;
|
|
215
247
|
}
|
|
@@ -222,11 +254,18 @@ export function flushMainTimerSegmentOnTask(task: Record<string, unknown>): void
|
|
|
222
254
|
delete task[MAIN_TIMER_SEGMENT_STARTED_AT];
|
|
223
255
|
return;
|
|
224
256
|
}
|
|
225
|
-
const elapsed = Math.max(0, Math.floor(
|
|
257
|
+
const elapsed = Math.max(0, Math.floor(flushClockEndMs - startedMs));
|
|
226
258
|
const parentPrev =
|
|
227
259
|
typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Number(task.durationMs) : 0;
|
|
228
260
|
task.durationMs = Math.floor(parentPrev + elapsed);
|
|
261
|
+
const lapRaw = task[TASK_CURRENT_LAP_STARTED_AT];
|
|
262
|
+
const lapStartedMs =
|
|
263
|
+
typeof lapRaw === "string" && lapRaw.trim() !== ""
|
|
264
|
+
? Date.parse(lapRaw)
|
|
265
|
+
: Number.NaN;
|
|
266
|
+
appendTaskTimerLap(task, Number.isFinite(lapStartedMs) ? lapStartedMs : startedMs, flushClockEndMs);
|
|
229
267
|
delete task[MAIN_TIMER_SEGMENT_STARTED_AT];
|
|
268
|
+
delete task[TASK_CURRENT_LAP_STARTED_AT];
|
|
230
269
|
ensureTaskParentDurationCoversSubtasksMs(task);
|
|
231
270
|
}
|
|
232
271
|
|
|
@@ -243,9 +282,15 @@ export function ensureMainTimerSegmentForRunningTask(task: Record<string, unknow
|
|
|
243
282
|
}
|
|
244
283
|
const raw = task[MAIN_TIMER_SEGMENT_STARTED_AT];
|
|
245
284
|
if (typeof raw === "string" && raw.trim() !== "") {
|
|
285
|
+
const lapRaw = task[TASK_CURRENT_LAP_STARTED_AT];
|
|
286
|
+
if (!(typeof lapRaw === "string" && lapRaw.trim() !== "")) {
|
|
287
|
+
task[TASK_CURRENT_LAP_STARTED_AT] = raw;
|
|
288
|
+
}
|
|
246
289
|
return;
|
|
247
290
|
}
|
|
248
|
-
|
|
291
|
+
const nowIso = new Date().toISOString();
|
|
292
|
+
task[MAIN_TIMER_SEGMENT_STARTED_AT] = nowIso;
|
|
293
|
+
task[TASK_CURRENT_LAP_STARTED_AT] = nowIso;
|
|
249
294
|
}
|
|
250
295
|
|
|
251
296
|
/**
|
|
@@ -255,7 +300,10 @@ export function ensureMainTimerSegmentForRunningTask(task: Record<string, unknow
|
|
|
255
300
|
* — rattrapage via `ensureTaskParentDurationCoversSubtasksMs` en fin de flush.
|
|
256
301
|
* Efface ensuite `activeSubtaskTimerId` et l’horodatage de segment.
|
|
257
302
|
*/
|
|
258
|
-
export function flushSubtaskTimerOnTask(
|
|
303
|
+
export function flushSubtaskTimerOnTask(
|
|
304
|
+
task: Record<string, unknown>,
|
|
305
|
+
flushClockEndMs: number = Date.now(),
|
|
306
|
+
): void {
|
|
259
307
|
const activeId = String(task.activeSubtaskTimerId ?? "").trim();
|
|
260
308
|
if (!activeId) {
|
|
261
309
|
return;
|
|
@@ -272,7 +320,7 @@ export function flushSubtaskTimerOnTask(task: Record<string, unknown>): void {
|
|
|
272
320
|
delete task[SUBTASK_TIMER_STARTED_AT];
|
|
273
321
|
return;
|
|
274
322
|
}
|
|
275
|
-
const elapsed = Math.max(0,
|
|
323
|
+
const elapsed = Math.max(0, flushClockEndMs - startedMs);
|
|
276
324
|
const st = ensureSubtasks(task).find((s) => String(s.id) === activeId);
|
|
277
325
|
if (st && typeof st === "object" && !Array.isArray(st)) {
|
|
278
326
|
const row = st as Record<string, unknown>;
|
|
@@ -282,8 +330,15 @@ export function flushSubtaskTimerOnTask(task: Record<string, unknown>): void {
|
|
|
282
330
|
const parentPrev =
|
|
283
331
|
typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Number(task.durationMs) : 0;
|
|
284
332
|
task.durationMs = Math.floor(parentPrev + elapsed);
|
|
333
|
+
const lapRaw = task[TASK_CURRENT_LAP_STARTED_AT];
|
|
334
|
+
const lapStartedMs =
|
|
335
|
+
typeof lapRaw === "string" && lapRaw.trim() !== ""
|
|
336
|
+
? Date.parse(lapRaw)
|
|
337
|
+
: Number.NaN;
|
|
338
|
+
appendTaskTimerLap(task, Number.isFinite(lapStartedMs) ? lapStartedMs : startedMs, flushClockEndMs);
|
|
285
339
|
task.activeSubtaskTimerId = null;
|
|
286
340
|
delete task[SUBTASK_TIMER_STARTED_AT];
|
|
341
|
+
delete task[TASK_CURRENT_LAP_STARTED_AT];
|
|
287
342
|
ensureTaskParentDurationCoversSubtasksMs(task);
|
|
288
343
|
}
|
|
289
344
|
|
|
@@ -291,7 +346,8 @@ export function finishTaskInSession(
|
|
|
291
346
|
sess: Record<string, unknown>,
|
|
292
347
|
taskId: string,
|
|
293
348
|
shouldCommit: boolean,
|
|
294
|
-
tagNormOpts?: TaskTagsStorageNormalizeOpts
|
|
349
|
+
tagNormOpts?: TaskTagsStorageNormalizeOpts,
|
|
350
|
+
options?: { completionInstantMs?: number },
|
|
295
351
|
): boolean {
|
|
296
352
|
const id = taskId;
|
|
297
353
|
let task: Record<string, unknown> | null = null;
|
|
@@ -322,10 +378,16 @@ export function finishTaskInSession(
|
|
|
322
378
|
}
|
|
323
379
|
}
|
|
324
380
|
|
|
325
|
-
|
|
326
|
-
|
|
381
|
+
const flushEndMs =
|
|
382
|
+
typeof options?.completionInstantMs === "number" && Number.isFinite(options.completionInstantMs)
|
|
383
|
+
? options.completionInstantMs
|
|
384
|
+
: Date.now();
|
|
385
|
+
|
|
386
|
+
flushSubtaskTimerOnTask(task, flushEndMs);
|
|
387
|
+
flushMainTimerSegmentOnTask(task, flushEndMs);
|
|
327
388
|
task.isDone = true;
|
|
328
|
-
task.endTime = new Date().toISOString();
|
|
389
|
+
task.endTime = new Date(flushEndMs).toISOString();
|
|
390
|
+
delete task[TASK_SCHEDULED_END_AT];
|
|
329
391
|
task.shouldCommit = shouldCommit;
|
|
330
392
|
task.manualTaskTimerPaused = false;
|
|
331
393
|
for (const st of ensureSubtasks(task)) {
|
|
@@ -380,7 +442,13 @@ export function deleteTaskInSession(sess: Record<string, unknown>, taskId: strin
|
|
|
380
442
|
export function updateTaskInSession(
|
|
381
443
|
sess: Record<string, unknown>,
|
|
382
444
|
taskId: string,
|
|
383
|
-
patch: {
|
|
445
|
+
patch: {
|
|
446
|
+
name?: string;
|
|
447
|
+
tags?: string[];
|
|
448
|
+
project?: string | null;
|
|
449
|
+
personalProject?: boolean;
|
|
450
|
+
note?: string;
|
|
451
|
+
},
|
|
384
452
|
tagNormOpts?: TaskTagsStorageNormalizeOpts
|
|
385
453
|
): boolean {
|
|
386
454
|
const task = findTaskRecord(sess, taskId);
|
|
@@ -396,13 +464,23 @@ export function updateTaskInSession(
|
|
|
396
464
|
if (patch.project !== undefined) {
|
|
397
465
|
task.project = patch.project;
|
|
398
466
|
}
|
|
467
|
+
if (patch.personalProject !== undefined) {
|
|
468
|
+
task.personalProject = patch.personalProject === true;
|
|
469
|
+
}
|
|
470
|
+
if (typeof patch.note === "string") {
|
|
471
|
+
task.note = patch.note;
|
|
472
|
+
}
|
|
399
473
|
return true;
|
|
400
474
|
}
|
|
401
475
|
|
|
402
476
|
export function updateTaskStartTimeInSession(
|
|
403
477
|
sess: Record<string, unknown>,
|
|
404
478
|
taskId: string,
|
|
405
|
-
startTimeIso: string
|
|
479
|
+
startTimeIso: string,
|
|
480
|
+
options?: {
|
|
481
|
+
durationAdjustMode?: "keep" | "manual" | "from_bounds";
|
|
482
|
+
manualDurationMs?: number;
|
|
483
|
+
}
|
|
406
484
|
): boolean {
|
|
407
485
|
const task = findTaskRecord(sess, taskId);
|
|
408
486
|
if (!task) {
|
|
@@ -412,20 +490,39 @@ export function updateTaskStartTimeInSession(
|
|
|
412
490
|
if (!Number.isFinite(startMs)) {
|
|
413
491
|
return false;
|
|
414
492
|
}
|
|
493
|
+
const boundEndRaw = typeof task.endTime === "string" ? task.endTime : "";
|
|
494
|
+
const boundEndMs = Date.parse(boundEndRaw);
|
|
495
|
+
if (Number.isFinite(boundEndMs) && startMs > boundEndMs) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
415
498
|
const nowMs = Date.now();
|
|
499
|
+
const prevDurationMs =
|
|
500
|
+
typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Math.max(0, Math.floor(task.durationMs)) : 0;
|
|
501
|
+
const mode = options?.durationAdjustMode ?? "from_bounds";
|
|
502
|
+
const manualMs =
|
|
503
|
+
typeof options?.manualDurationMs === "number" && Number.isFinite(options.manualDurationMs)
|
|
504
|
+
? Math.max(0, Math.floor(options.manualDurationMs))
|
|
505
|
+
: null;
|
|
416
506
|
task.startTime = new Date(startMs).toISOString();
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
507
|
+
const endMs = boundEndMs;
|
|
508
|
+
if (mode === "keep") {
|
|
509
|
+
task.durationMs = prevDurationMs;
|
|
510
|
+
} else if (mode === "manual" && manualMs !== null) {
|
|
511
|
+
task.durationMs = manualMs;
|
|
512
|
+
} else if (Number.isFinite(endMs) && endMs >= startMs) {
|
|
420
513
|
task.durationMs = Math.max(0, Math.floor(endMs - startMs));
|
|
421
|
-
|
|
514
|
+
} else {
|
|
515
|
+
task.durationMs = Math.max(0, Math.floor(nowMs - startMs));
|
|
422
516
|
}
|
|
423
|
-
task.durationMs = Math.max(0, Math.floor(nowMs - startMs));
|
|
424
517
|
const subtaskRunning = String(task.activeSubtaskTimerId ?? "").trim() !== "";
|
|
425
518
|
if (!subtaskRunning && task.manualTaskTimerPaused !== true && task.isDone !== true) {
|
|
426
519
|
task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
|
|
427
520
|
}
|
|
428
|
-
|
|
521
|
+
// Ne pas réimposer la somme des sous-tâches après un recalcul explicite (bornes ou manuel) :
|
|
522
|
+
// sinon la durée choisie par l’utilisateur·rice est écrasée.
|
|
523
|
+
if (mode === "keep") {
|
|
524
|
+
ensureTaskParentDurationCoversSubtasksMs(task);
|
|
525
|
+
}
|
|
429
526
|
return true;
|
|
430
527
|
}
|
|
431
528
|
|
|
@@ -436,7 +533,11 @@ export function updateTaskStartTimeInSession(
|
|
|
436
533
|
export function updateTaskEndTimeInSession(
|
|
437
534
|
sess: Record<string, unknown>,
|
|
438
535
|
taskId: string,
|
|
439
|
-
endTimeIso: string
|
|
536
|
+
endTimeIso: string,
|
|
537
|
+
options?: {
|
|
538
|
+
durationAdjustMode?: "keep" | "manual" | "from_bounds";
|
|
539
|
+
manualDurationMs?: number;
|
|
540
|
+
}
|
|
440
541
|
): boolean {
|
|
441
542
|
const task = findTaskRecord(sess, taskId);
|
|
442
543
|
if (!task || task.isDone !== true) {
|
|
@@ -451,9 +552,55 @@ export function updateTaskEndTimeInSession(
|
|
|
451
552
|
if (!Number.isFinite(startMs) || endMs < startMs) {
|
|
452
553
|
return false;
|
|
453
554
|
}
|
|
555
|
+
const prevDurationMs =
|
|
556
|
+
typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Math.max(0, Math.floor(task.durationMs)) : 0;
|
|
557
|
+
const mode = options?.durationAdjustMode ?? "from_bounds";
|
|
558
|
+
const manualMs =
|
|
559
|
+
typeof options?.manualDurationMs === "number" && Number.isFinite(options.manualDurationMs)
|
|
560
|
+
? Math.max(0, Math.floor(options.manualDurationMs))
|
|
561
|
+
: null;
|
|
454
562
|
task.endTime = new Date(endMs).toISOString();
|
|
455
|
-
|
|
456
|
-
|
|
563
|
+
if (mode === "keep") {
|
|
564
|
+
task.durationMs = prevDurationMs;
|
|
565
|
+
} else if (mode === "manual" && manualMs !== null) {
|
|
566
|
+
task.durationMs = manualMs;
|
|
567
|
+
} else {
|
|
568
|
+
task.durationMs = Math.max(0, Math.floor(endMs - startMs));
|
|
569
|
+
}
|
|
570
|
+
// Même logique que `updateTaskStartTimeInSession` : après « depuis début/fin » ou « manuel »,
|
|
571
|
+
// ne pas forcer le parent ≥ somme des sous-tâches (sinon le recalcul est annulé).
|
|
572
|
+
if (mode === "keep") {
|
|
573
|
+
ensureTaskParentDurationCoversSubtasksMs(task);
|
|
574
|
+
}
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Programme une heure de fin pour une tâche en cours ; si l'échéance est déjà passée, termine la tâche à cet instant.
|
|
580
|
+
*/
|
|
581
|
+
export function scheduleTaskEndTimeInSession(
|
|
582
|
+
sess: Record<string, unknown>,
|
|
583
|
+
taskId: string,
|
|
584
|
+
endTimeIso: string,
|
|
585
|
+
nowMs = Date.now()
|
|
586
|
+
): boolean {
|
|
587
|
+
const task = findTaskRecord(sess, taskId);
|
|
588
|
+
if (!task || task.isDone === true) {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
const endMs = Date.parse(endTimeIso);
|
|
592
|
+
if (!Number.isFinite(endMs)) {
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
const startRaw = typeof task.startTime === "string" ? task.startTime : "";
|
|
596
|
+
const startMs = Date.parse(startRaw);
|
|
597
|
+
if (!Number.isFinite(startMs) || endMs < startMs) {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
if (endMs <= nowMs) {
|
|
601
|
+
return finishTaskInSession(sess, taskId, false, undefined, { completionInstantMs: endMs });
|
|
602
|
+
}
|
|
603
|
+
task[TASK_SCHEDULED_END_AT] = new Date(endMs).toISOString();
|
|
457
604
|
return true;
|
|
458
605
|
}
|
|
459
606
|
|
|
@@ -536,7 +683,9 @@ export function setActiveSubtaskTimerInSession(
|
|
|
536
683
|
prepareSessionForSubtaskTimer(sess, String(taskId));
|
|
537
684
|
flushSubtaskTimerOnTask(task);
|
|
538
685
|
task.activeSubtaskTimerId = sid;
|
|
539
|
-
|
|
686
|
+
const nowIso = new Date().toISOString();
|
|
687
|
+
task[SUBTASK_TIMER_STARTED_AT] = nowIso;
|
|
688
|
+
task[TASK_CURRENT_LAP_STARTED_AT] = nowIso;
|
|
540
689
|
task.manualTaskTimerPaused = false;
|
|
541
690
|
return true;
|
|
542
691
|
}
|
|
@@ -612,28 +761,43 @@ export function addHistoricalTaskToSession(
|
|
|
612
761
|
name: string;
|
|
613
762
|
tags: string[];
|
|
614
763
|
project?: string | null;
|
|
764
|
+
personalProject?: boolean;
|
|
765
|
+
note?: string;
|
|
615
766
|
durationMs: number;
|
|
616
767
|
startTime: string;
|
|
617
768
|
endTime: string;
|
|
618
769
|
},
|
|
619
770
|
newId: string,
|
|
620
771
|
tagNormOpts?: TaskTagsStorageNormalizeOpts
|
|
621
|
-
):
|
|
772
|
+
): boolean {
|
|
773
|
+
const startMs = Date.parse(typeof input.startTime === "string" ? input.startTime : "");
|
|
774
|
+
const endMs = Date.parse(typeof input.endTime === "string" ? input.endTime : "");
|
|
775
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
const spanMs = endMs - startMs;
|
|
779
|
+
const dmRaw = typeof input.durationMs === "number" && Number.isFinite(input.durationMs) ? Math.floor(input.durationMs) : NaN;
|
|
780
|
+
if (!Number.isFinite(dmRaw) || dmRaw <= 0 || dmRaw > spanMs) {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
622
783
|
const task: Record<string, unknown> = {
|
|
623
784
|
id: newId,
|
|
624
785
|
name: input.name.trim(),
|
|
625
786
|
startTime: input.startTime,
|
|
626
787
|
endTime: input.endTime,
|
|
627
|
-
durationMs:
|
|
788
|
+
durationMs: dmRaw,
|
|
628
789
|
isDone: true,
|
|
629
790
|
kronoFocusCycles: 0,
|
|
630
791
|
tags: normalizeTaskTagsForStorage(input.tags, tagNormOpts),
|
|
631
792
|
project: input.project ?? null,
|
|
793
|
+
personalProject: input.personalProject === true,
|
|
794
|
+
note: typeof input.note === "string" ? input.note : "",
|
|
632
795
|
subtasks: [],
|
|
633
796
|
};
|
|
634
797
|
const tasks = getTasksArray(sess);
|
|
635
798
|
tasks.push(task);
|
|
636
799
|
sess.tasks = tasks;
|
|
800
|
+
return true;
|
|
637
801
|
}
|
|
638
802
|
|
|
639
803
|
function eachSessionRecord(p: KronosysUpdatePayload, fn: (sess: Record<string, unknown>) => void): void {
|
|
@@ -715,3 +879,152 @@ export function purgeProjectEverywhere(p: KronosysUpdatePayload, rawName: string
|
|
|
715
879
|
}
|
|
716
880
|
});
|
|
717
881
|
}
|
|
882
|
+
|
|
883
|
+
export function renameTagEverywhere(
|
|
884
|
+
p: KronosysUpdatePayload,
|
|
885
|
+
sourceRawTag: string,
|
|
886
|
+
targetRawTag: string,
|
|
887
|
+
tagNormOpts?: TaskTagsStorageNormalizeOpts
|
|
888
|
+
): void {
|
|
889
|
+
const source = normalizeTagKey(sourceRawTag);
|
|
890
|
+
const target = normalizeTagKey(targetRawTag);
|
|
891
|
+
if (!source || !target || source.toLowerCase() === target.toLowerCase()) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const sourceL = source.toLowerCase();
|
|
895
|
+
const targetL = target.toLowerCase();
|
|
896
|
+
const remapUnique = (list: string[]): string[] => {
|
|
897
|
+
const out: string[] = [];
|
|
898
|
+
const seen = new Set<string>();
|
|
899
|
+
for (const raw of list) {
|
|
900
|
+
const n = normalizeTagKey(raw);
|
|
901
|
+
if (!n) {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const lk = n.toLowerCase();
|
|
905
|
+
const mapped = lk === sourceL ? target : n;
|
|
906
|
+
const mk = mapped.toLowerCase();
|
|
907
|
+
if (seen.has(mk)) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
seen.add(mk);
|
|
911
|
+
out.push(mapped);
|
|
912
|
+
}
|
|
913
|
+
return out;
|
|
914
|
+
};
|
|
915
|
+
p.knownTags = remapUnique((p.knownTags || []) as string[]);
|
|
916
|
+
p.userKnownTags = remapUnique((p.userKnownTags || []) as string[]);
|
|
917
|
+
p.excludedSuggestionTags = remapUnique((p.excludedSuggestionTags || []) as string[]);
|
|
918
|
+
const td = { ...(asRecord(p.tagDescriptions) ?? {}) } as Record<string, string>;
|
|
919
|
+
const sourceDesc = typeof td[sourceL] === "string" ? td[sourceL] : "";
|
|
920
|
+
if (sourceDesc && !td[targetL]) {
|
|
921
|
+
td[targetL] = sourceDesc;
|
|
922
|
+
}
|
|
923
|
+
delete td[sourceL];
|
|
924
|
+
p.tagDescriptions = td;
|
|
925
|
+
eachSessionRecord(p, (sess) => {
|
|
926
|
+
for (const t of [...getActiveTasksArray(sess), ...getTasksArray(sess)]) {
|
|
927
|
+
if (!Array.isArray(t.tags)) {
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const mapped = (t.tags as string[]).map((x) => {
|
|
931
|
+
const n = normalizeTagKey(x);
|
|
932
|
+
return n.toLowerCase() === sourceL ? target : n;
|
|
933
|
+
});
|
|
934
|
+
t.tags = normalizeTaskTagsForStorage(mapped, tagNormOpts);
|
|
935
|
+
}
|
|
936
|
+
const at = asRecord(sess.activeTask);
|
|
937
|
+
if (!at || !Array.isArray(at.tags)) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const mapped = (at.tags as string[]).map((x) => {
|
|
941
|
+
const n = normalizeTagKey(x);
|
|
942
|
+
return n.toLowerCase() === sourceL ? target : n;
|
|
943
|
+
});
|
|
944
|
+
at.tags = normalizeTaskTagsForStorage(mapped, tagNormOpts);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export function renameProjectEverywhere(
|
|
949
|
+
p: KronosysUpdatePayload,
|
|
950
|
+
sourceRawName: string,
|
|
951
|
+
targetRawName: string,
|
|
952
|
+
opts?: { personalOnly?: boolean },
|
|
953
|
+
): void {
|
|
954
|
+
const source = normalizeProjectKey(sourceRawName);
|
|
955
|
+
const target = normalizeProjectKey(targetRawName);
|
|
956
|
+
if (!source || !target || source.toLowerCase() === target.toLowerCase()) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const sourceL = source.toLowerCase();
|
|
960
|
+
const targetL = target.toLowerCase();
|
|
961
|
+
const personalOnly = opts?.personalOnly === true;
|
|
962
|
+
|
|
963
|
+
if (personalOnly) {
|
|
964
|
+
const known: string[] = [];
|
|
965
|
+
const seen = new Set<string>();
|
|
966
|
+
for (const raw of (((p as Record<string, unknown>).knownPersonalProjects || []) as string[])) {
|
|
967
|
+
const n = normalizeProjectKey(raw);
|
|
968
|
+
if (!n) {
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
const mapped = n.toLowerCase() === sourceL ? target : n;
|
|
972
|
+
const lk = mapped.toLowerCase();
|
|
973
|
+
if (seen.has(lk)) {
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
seen.add(lk);
|
|
977
|
+
known.push(mapped);
|
|
978
|
+
}
|
|
979
|
+
(p as Record<string, unknown>).knownPersonalProjects = known;
|
|
980
|
+
} else {
|
|
981
|
+
const known: string[] = [];
|
|
982
|
+
const seen = new Set<string>();
|
|
983
|
+
for (const raw of (p.knownProjects || []) as string[]) {
|
|
984
|
+
const n = normalizeProjectKey(raw);
|
|
985
|
+
if (!n) {
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
const mapped = n.toLowerCase() === sourceL ? target : n;
|
|
989
|
+
const lk = mapped.toLowerCase();
|
|
990
|
+
if (seen.has(lk)) {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
seen.add(lk);
|
|
994
|
+
known.push(mapped);
|
|
995
|
+
}
|
|
996
|
+
p.knownProjects = known;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const pd = { ...(asRecord(p.projectDescriptions) ?? {}) } as Record<string, string>;
|
|
1000
|
+
const sourceDesc = typeof pd[sourceL] === "string" ? pd[sourceL] : "";
|
|
1001
|
+
if (sourceDesc && !pd[targetL]) {
|
|
1002
|
+
pd[targetL] = sourceDesc;
|
|
1003
|
+
}
|
|
1004
|
+
delete pd[sourceL];
|
|
1005
|
+
p.projectDescriptions = pd;
|
|
1006
|
+
eachSessionRecord(p, (sess) => {
|
|
1007
|
+
for (const t of [...getActiveTasksArray(sess), ...getTasksArray(sess)]) {
|
|
1008
|
+
const proj = typeof t.project === "string" ? normalizeProjectKey(t.project) : "";
|
|
1009
|
+
if (proj.toLowerCase() !== sourceL) {
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
const isPersonal = t.personalProject === true;
|
|
1013
|
+
if (personalOnly !== isPersonal) {
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
t.project = target;
|
|
1017
|
+
}
|
|
1018
|
+
const at = asRecord(sess.activeTask);
|
|
1019
|
+
if (!at) {
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const proj = typeof at.project === "string" ? normalizeProjectKey(at.project) : "";
|
|
1023
|
+
if (proj.toLowerCase() === sourceL) {
|
|
1024
|
+
const isPersonal = at.personalProject === true;
|
|
1025
|
+
if (personalOnly === isPersonal) {
|
|
1026
|
+
at.project = target;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
}
|
package/server/db.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { ensureDataDirectory, resetDataDirectoryCache } from "@/lib/dataDir";
|
|
5
|
+
import { ensureCoreSchema } from "./dbSchema";
|
|
5
6
|
|
|
6
7
|
const DB_NAME = "kronosys.sqlite";
|
|
7
8
|
|
|
8
|
-
let db:
|
|
9
|
+
let db: DatabaseSync | null = null;
|
|
9
10
|
|
|
10
11
|
/** Ferme la connexion SQLite (tests ou changement de `TRACE_DATA_DIR` / cache de résolution). */
|
|
11
12
|
export function resetSqliteConnection(): void {
|
|
@@ -20,23 +21,14 @@ export function resetSqliteConnection(): void {
|
|
|
20
21
|
resetDataDirectoryCache();
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
export function getSqlite():
|
|
24
|
+
export function getSqlite(): DatabaseSync {
|
|
24
25
|
if (db) {
|
|
25
26
|
return db;
|
|
26
27
|
}
|
|
27
28
|
const dir = ensureDataDirectory();
|
|
28
29
|
const file = path.join(dir, DB_NAME);
|
|
29
|
-
db = new
|
|
30
|
-
db.
|
|
31
|
-
db
|
|
32
|
-
CREATE TABLE IF NOT EXISTS app_meta (
|
|
33
|
-
key TEXT PRIMARY KEY NOT NULL,
|
|
34
|
-
value TEXT NOT NULL
|
|
35
|
-
);
|
|
36
|
-
CREATE TABLE IF NOT EXISTS kv_store (
|
|
37
|
-
k TEXT PRIMARY KEY NOT NULL,
|
|
38
|
-
v TEXT NOT NULL
|
|
39
|
-
);
|
|
40
|
-
`);
|
|
30
|
+
db = new DatabaseSync(file);
|
|
31
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
32
|
+
ensureCoreSchema(db);
|
|
41
33
|
return db;
|
|
42
34
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
|
|
3
|
+
export function ensureCoreSchema(db: DatabaseSync): void {
|
|
4
|
+
db.exec(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS app_meta (
|
|
6
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
7
|
+
value TEXT NOT NULL
|
|
8
|
+
);
|
|
9
|
+
CREATE TABLE IF NOT EXISTS kv_store (
|
|
10
|
+
k TEXT PRIMARY KEY NOT NULL,
|
|
11
|
+
v TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS user_action_logs (
|
|
14
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
15
|
+
created_at TEXT NOT NULL,
|
|
16
|
+
action_type TEXT NOT NULL,
|
|
17
|
+
ok INTEGER NOT NULL,
|
|
18
|
+
source_ip TEXT,
|
|
19
|
+
user_agent TEXT,
|
|
20
|
+
session_id TEXT,
|
|
21
|
+
payload_json TEXT
|
|
22
|
+
);
|
|
23
|
+
`);
|
|
24
|
+
}
|
package/server/defaultCfg.ts
CHANGED
|
@@ -70,6 +70,11 @@ export function defaultKronosysCfg(): Record<string, unknown> {
|
|
|
70
70
|
dashboardDisplayTimeZone: "America/Toronto",
|
|
71
71
|
/** Affichage des heures : `true` = 24 h, `false` = 12 h (AM/PM). */
|
|
72
72
|
dashboardUse24HourClock: true,
|
|
73
|
+
/**
|
|
74
|
+
* Gabarit appliqué au libellé de session lors du `newSession` (motif façon POSIX strftime ; voir aussi `%UUID`).
|
|
75
|
+
* Chaîne vide = aucun nom par défaut (champ vide comme aujourd’hui).
|
|
76
|
+
*/
|
|
77
|
+
dashboardDefaultSessionNameTemplate: "",
|
|
73
78
|
scheduleEnabled: false,
|
|
74
79
|
scheduleDays: [1, 2, 3, 4, 5],
|
|
75
80
|
scheduleStartTime: "09:00",
|
|
@@ -6,17 +6,7 @@ import { getSqlite } from "./db";
|
|
|
6
6
|
*/
|
|
7
7
|
const GITLAB_PAT_KV_KEY = "gitlab_secret_pat_v1";
|
|
8
8
|
|
|
9
|
-
function ensureKv(): void {
|
|
10
|
-
getSqlite().exec(`
|
|
11
|
-
CREATE TABLE IF NOT EXISTS kv_store (
|
|
12
|
-
k TEXT PRIMARY KEY NOT NULL,
|
|
13
|
-
v TEXT NOT NULL
|
|
14
|
-
);
|
|
15
|
-
`);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
9
|
export function readGitlabPatFromStore(): string {
|
|
19
|
-
ensureKv();
|
|
20
10
|
const row = getSqlite().prepare("SELECT v FROM kv_store WHERE k = ?").get(GITLAB_PAT_KV_KEY) as
|
|
21
11
|
| { v: string }
|
|
22
12
|
| undefined;
|
|
@@ -24,11 +14,9 @@ export function readGitlabPatFromStore(): string {
|
|
|
24
14
|
}
|
|
25
15
|
|
|
26
16
|
export function writeGitlabPatToStore(token: string): void {
|
|
27
|
-
ensureKv();
|
|
28
17
|
getSqlite().prepare("INSERT OR REPLACE INTO kv_store (k, v) VALUES (?, ?)").run(GITLAB_PAT_KV_KEY, token);
|
|
29
18
|
}
|
|
30
19
|
|
|
31
20
|
export function clearGitlabPatFromStore(): void {
|
|
32
|
-
ensureKv();
|
|
33
21
|
getSqlite().prepare("DELETE FROM kv_store WHERE k = ?").run(GITLAB_PAT_KV_KEY);
|
|
34
22
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
|
|
2
|
+
import {
|
|
3
|
+
normalizeSessionEndReasonKind,
|
|
4
|
+
normalizeSessionEndReasonNote,
|
|
5
|
+
} from "@/lib/sessionEndReason";
|
|
6
|
+
|
|
7
|
+
import { asRecord } from "./actionTaskSession";
|
|
8
|
+
|
|
9
|
+
/** Copie l’instantané de la session live en tête de `history` (sans vider `current`). */
|
|
10
|
+
export function syncLiveIntoHistory(p: KronosysUpdatePayload): void {
|
|
11
|
+
const cur = asRecord(p.current);
|
|
12
|
+
if (!cur) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const sid = typeof cur.sessionId === "string" ? cur.sessionId.trim() : "";
|
|
16
|
+
if (!sid) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const hist = ([...(p.history || [])] as Record<string, unknown>[]).filter((h) => h.sessionId !== sid);
|
|
20
|
+
const snap: Record<string, unknown> = {
|
|
21
|
+
sessionId: sid,
|
|
22
|
+
sessionName: cur.sessionName ?? "",
|
|
23
|
+
sessionNote: cur.sessionNote ?? "",
|
|
24
|
+
savedAt: new Date().toISOString(),
|
|
25
|
+
createdAt: cur.createdAt ?? cur.startAt ?? null,
|
|
26
|
+
startAt: cur.startAt ?? null,
|
|
27
|
+
endAt: cur.endAt ?? null,
|
|
28
|
+
sessionDurationMinutes: cur.sessionDurationMinutes,
|
|
29
|
+
tasks: cur.tasks ?? [],
|
|
30
|
+
activeTasks: cur.activeTasks ?? [],
|
|
31
|
+
activeTask: cur.activeTask ?? null,
|
|
32
|
+
archived: cur.archived === true,
|
|
33
|
+
};
|
|
34
|
+
if (typeof cur.scheduledStartAt === "string" && cur.scheduledStartAt.trim() !== "") {
|
|
35
|
+
snap.scheduledStartAt = cur.scheduledStartAt;
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
typeof cur.sessionStartOffsetMinutes === "number" &&
|
|
39
|
+
Number.isFinite(cur.sessionStartOffsetMinutes)
|
|
40
|
+
) {
|
|
41
|
+
snap.sessionStartOffsetMinutes = cur.sessionStartOffsetMinutes;
|
|
42
|
+
}
|
|
43
|
+
const rk = normalizeSessionEndReasonKind(cur.sessionEndReasonKind);
|
|
44
|
+
if (rk) {
|
|
45
|
+
snap.sessionEndReasonKind = rk;
|
|
46
|
+
}
|
|
47
|
+
const rn = normalizeSessionEndReasonNote(cur.sessionEndReasonNote);
|
|
48
|
+
if (rn.length > 0) {
|
|
49
|
+
snap.sessionEndReasonNote = rn;
|
|
50
|
+
}
|
|
51
|
+
hist.unshift(snap);
|
|
52
|
+
p.history = hist;
|
|
53
|
+
}
|