@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
package/server/actionDispatch.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
normalizeTaskTagsForStorage,
|
|
11
11
|
parseTaskWithAutoTags,
|
|
12
12
|
readTaskDefaultTagBucketEnabled,
|
|
13
|
+
resolvePersonalProjectForTaskUpdate,
|
|
14
|
+
resolveProjectForTaskUpdate,
|
|
13
15
|
type TaskTagsStorageNormalizeOpts,
|
|
14
16
|
} from "@/lib/taskParsing";
|
|
15
17
|
|
|
@@ -19,14 +21,23 @@ import {
|
|
|
19
21
|
asRecord,
|
|
20
22
|
deleteSubtaskInSession,
|
|
21
23
|
deleteTaskInSession,
|
|
24
|
+
ensureMainTimerSegmentForRunningTask,
|
|
25
|
+
findTaskRecord,
|
|
22
26
|
finishTaskInSession,
|
|
27
|
+
flushMainTimerSegmentOnTask,
|
|
28
|
+
flushSubtaskTimerOnTask,
|
|
29
|
+
forEachTaskRecordInSession,
|
|
23
30
|
prepareSessionForExclusiveMainTimerUnpaused,
|
|
24
31
|
purgeProjectEverywhere,
|
|
25
32
|
purgeTagEverywhere,
|
|
33
|
+
renameProjectEverywhere,
|
|
34
|
+
renameTagEverywhere,
|
|
26
35
|
reorderSubtasksInSession,
|
|
27
36
|
resolveTaskSession,
|
|
37
|
+
SUBTASK_TIMER_STARTED_AT,
|
|
28
38
|
setActiveSubtaskTimerInSession,
|
|
29
39
|
setTaskPausedInSession,
|
|
40
|
+
scheduleTaskEndTimeInSession,
|
|
30
41
|
toggleSubtaskInSession,
|
|
31
42
|
updateSubtaskTitleInSession,
|
|
32
43
|
updateTaskStartTimeInSession,
|
|
@@ -43,7 +54,13 @@ import {
|
|
|
43
54
|
import {
|
|
44
55
|
DEFAULT_DASHBOARD_TIME_ZONE,
|
|
45
56
|
isValidIanaTimeZone,
|
|
57
|
+
readDashboardTimeZoneFromCfg,
|
|
46
58
|
} from "@/lib/dashboardTimeZone";
|
|
59
|
+
import {
|
|
60
|
+
formatSessionNameTemplate,
|
|
61
|
+
SESSION_NAME_TEMPLATE_CFG_MAX_LEN,
|
|
62
|
+
SESSION_NAME_TEMPLATE_MAX_LEN,
|
|
63
|
+
} from "@/lib/formatSessionNameTemplate";
|
|
47
64
|
import { defaultKronosysCfg } from "./defaultCfg";
|
|
48
65
|
import { clearGitlabPatFromStore, readGitlabPatFromStore, writeGitlabPatToStore } from "./gitlabTokenStore";
|
|
49
66
|
import {
|
|
@@ -59,10 +76,13 @@ import {
|
|
|
59
76
|
clampWorkDurationSeconds,
|
|
60
77
|
readWorkDurationSeconds,
|
|
61
78
|
} from "@/lib/kronoFocusRhythm";
|
|
62
|
-
import {
|
|
79
|
+
import { withPayloadWriteAsync, writePayload } from "./payloadStore";
|
|
80
|
+
import { syncLiveIntoHistory } from "./liveHistorySync";
|
|
63
81
|
import {
|
|
64
82
|
ensureSessionWallSegmentOnLive,
|
|
83
|
+
finalizeLiveSessionClosedAt,
|
|
65
84
|
flushSessionWallSegmentOnLive,
|
|
85
|
+
resumeLiveSessionWallIfPaused,
|
|
66
86
|
SESSION_WALL_SEGMENT_STARTED_AT,
|
|
67
87
|
} from "./sessionWallHydrate";
|
|
68
88
|
|
|
@@ -71,6 +91,22 @@ type ActionResult = {
|
|
|
71
91
|
result?: Record<string, unknown>;
|
|
72
92
|
};
|
|
73
93
|
|
|
94
|
+
type TaskTemplateRecord = {
|
|
95
|
+
id: string;
|
|
96
|
+
name: string;
|
|
97
|
+
tags: string[];
|
|
98
|
+
project: string | null;
|
|
99
|
+
personalProject?: boolean;
|
|
100
|
+
createdAt: string;
|
|
101
|
+
updatedAt: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type ActionHandlerContext = {
|
|
105
|
+
p: KronosysUpdatePayload;
|
|
106
|
+
body: Record<string, unknown>;
|
|
107
|
+
finish: (result?: Record<string, unknown>) => ActionResult;
|
|
108
|
+
};
|
|
109
|
+
|
|
74
110
|
function tryPersistDevDataPreferenceFromCfg(merged: Record<string, unknown>): void {
|
|
75
111
|
if (process.env.NODE_ENV !== "development") {
|
|
76
112
|
return;
|
|
@@ -86,52 +122,133 @@ function tryPersistDevDataPreferenceFromCfg(merged: Record<string, unknown>): vo
|
|
|
86
122
|
}
|
|
87
123
|
}
|
|
88
124
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return;
|
|
125
|
+
function ensureLive(p: KronosysUpdatePayload): Record<string, unknown> {
|
|
126
|
+
if (!p.current) {
|
|
127
|
+
p.current = {};
|
|
93
128
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
129
|
+
return p.current as Record<string, unknown>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type GlobalPauseContextSnapshot = {
|
|
133
|
+
sessionWasPaused: boolean;
|
|
134
|
+
taskIds: string[];
|
|
135
|
+
subtaskTimers: Array<{ taskId: string; subtaskId: string }>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function readGlobalPauseContext(cur: Record<string, unknown>): GlobalPauseContextSnapshot | null {
|
|
139
|
+
const rec = asRecord(cur.globalPauseContext);
|
|
140
|
+
if (!rec) {
|
|
141
|
+
return null;
|
|
97
142
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
143
|
+
const taskIds = Array.isArray(rec.taskIds)
|
|
144
|
+
? rec.taskIds
|
|
145
|
+
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
|
146
|
+
.filter((v) => v.length > 0)
|
|
147
|
+
: [];
|
|
148
|
+
const subtaskTimers = Array.isArray(rec.subtaskTimers)
|
|
149
|
+
? rec.subtaskTimers
|
|
150
|
+
.map((row) => {
|
|
151
|
+
const rr = asRecord(row);
|
|
152
|
+
const taskId = typeof rr?.taskId === "string" ? rr.taskId.trim() : "";
|
|
153
|
+
const subtaskId = typeof rr?.subtaskId === "string" ? rr.subtaskId.trim() : "";
|
|
154
|
+
return taskId && subtaskId ? { taskId, subtaskId } : null;
|
|
155
|
+
})
|
|
156
|
+
.filter((row): row is { taskId: string; subtaskId: string } => row !== null)
|
|
157
|
+
: [];
|
|
158
|
+
return {
|
|
159
|
+
sessionWasPaused: rec.sessionWasPaused === true,
|
|
160
|
+
taskIds,
|
|
161
|
+
subtaskTimers,
|
|
111
162
|
};
|
|
112
|
-
|
|
113
|
-
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Instantané des minuteurs actifs mis en pause par la pause session (reprise ciblée). */
|
|
166
|
+
type SessionPauseContextSnapshot = {
|
|
167
|
+
taskIds: string[];
|
|
168
|
+
subtaskTimers: Array<{ taskId: string; subtaskId: string }>;
|
|
169
|
+
pausedAt: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function readSessionPauseContext(cur: Record<string, unknown>): SessionPauseContextSnapshot | null {
|
|
173
|
+
const rec = asRecord(cur.sessionPauseContext);
|
|
174
|
+
if (!rec) {
|
|
175
|
+
return null;
|
|
114
176
|
}
|
|
115
|
-
|
|
116
|
-
|
|
177
|
+
const taskIds = Array.isArray(rec.taskIds)
|
|
178
|
+
? rec.taskIds
|
|
179
|
+
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
|
180
|
+
.filter((v) => v.length > 0)
|
|
181
|
+
: [];
|
|
182
|
+
const subtaskTimers = Array.isArray(rec.subtaskTimers)
|
|
183
|
+
? rec.subtaskTimers
|
|
184
|
+
.map((row) => {
|
|
185
|
+
const rr = asRecord(row);
|
|
186
|
+
const taskId = typeof rr?.taskId === "string" ? rr.taskId.trim() : "";
|
|
187
|
+
const subtaskId = typeof rr?.subtaskId === "string" ? rr.subtaskId.trim() : "";
|
|
188
|
+
return taskId && subtaskId ? { taskId, subtaskId } : null;
|
|
189
|
+
})
|
|
190
|
+
.filter((row): row is { taskId: string; subtaskId: string } => row !== null)
|
|
191
|
+
: [];
|
|
192
|
+
const pausedAt = typeof rec.pausedAt === "string" ? rec.pausedAt.trim() : "";
|
|
193
|
+
if (!pausedAt) {
|
|
194
|
+
return null;
|
|
117
195
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
196
|
+
return { taskIds, subtaskTimers, pausedAt };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function pruneSessionPauseContextForTask(sess: Record<string, unknown>, taskId: string): void {
|
|
200
|
+
const tid = String(taskId ?? "").trim();
|
|
201
|
+
if (!tid) {
|
|
202
|
+
return;
|
|
121
203
|
}
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
|
|
204
|
+
const rec = asRecord(sess.sessionPauseContext);
|
|
205
|
+
if (!rec) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const taskIds = Array.isArray(rec.taskIds)
|
|
209
|
+
? rec.taskIds.map((v) => String(v)).filter((id) => id !== tid)
|
|
210
|
+
: [];
|
|
211
|
+
const subtaskTimers = Array.isArray(rec.subtaskTimers)
|
|
212
|
+
? rec.subtaskTimers.filter((row) => {
|
|
213
|
+
const rr = asRecord(row);
|
|
214
|
+
return String(rr?.taskId ?? "").trim() !== tid;
|
|
215
|
+
})
|
|
216
|
+
: [];
|
|
217
|
+
if (taskIds.length === 0 && subtaskTimers.length === 0) {
|
|
218
|
+
delete sess.sessionPauseContext;
|
|
219
|
+
} else {
|
|
220
|
+
sess.sessionPauseContext = {
|
|
221
|
+
...rec,
|
|
222
|
+
taskIds,
|
|
223
|
+
subtaskTimers,
|
|
224
|
+
};
|
|
125
225
|
}
|
|
126
|
-
hist.unshift(snap);
|
|
127
|
-
p.history = hist;
|
|
128
226
|
}
|
|
129
227
|
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
228
|
+
function restoreLiveTasksFromSessionPauseSnapshot(
|
|
229
|
+
cur: Record<string, unknown>,
|
|
230
|
+
snap: SessionPauseContextSnapshot,
|
|
231
|
+
): void {
|
|
232
|
+
const nowIso = new Date().toISOString();
|
|
233
|
+
const taskIds = new Set(snap.taskIds);
|
|
234
|
+
const subtaskMap = new Map(snap.subtaskTimers.map((row) => [row.taskId, row.subtaskId]));
|
|
235
|
+
forEachTaskRecordInSession(cur, (task, taskId) => {
|
|
236
|
+
if (task.isDone === true) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const subId = subtaskMap.get(taskId);
|
|
240
|
+
if (subId) {
|
|
241
|
+
task.activeSubtaskTimerId = subId;
|
|
242
|
+
task.manualTaskTimerPaused = false;
|
|
243
|
+
task[SUBTASK_TIMER_STARTED_AT] = nowIso;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (!taskIds.has(taskId)) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
task.manualTaskTimerPaused = false;
|
|
250
|
+
ensureMainTimerSegmentForRunningTask(task);
|
|
251
|
+
});
|
|
135
252
|
}
|
|
136
253
|
|
|
137
254
|
/** Enrichit `knownTags` avec des étiquettes vues sur une tâche (suggestions / datalist). */
|
|
@@ -170,11 +287,157 @@ function mergeDiscoveredProjectIntoPayloadKnownProjects(
|
|
|
170
287
|
p.knownProjects = known;
|
|
171
288
|
}
|
|
172
289
|
|
|
290
|
+
/** Enrichit `knownPersonalProjects` (jetons `!`) — distinct des projets `@`. */
|
|
291
|
+
function mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(
|
|
292
|
+
p: KronosysUpdatePayload,
|
|
293
|
+
project: string | null | undefined,
|
|
294
|
+
personal: boolean | undefined
|
|
295
|
+
): void {
|
|
296
|
+
if (!personal || project === undefined || project === null) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const pk = normalizeProjectKey(String(project).trim());
|
|
300
|
+
if (!pk) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const lk = pk.toLowerCase();
|
|
304
|
+
const known = [...(((p as Record<string, unknown>).knownPersonalProjects || []) as string[])];
|
|
305
|
+
if (known.some((x) => normalizeProjectKey(String(x)).toLowerCase() === lk)) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
known.push(pk);
|
|
309
|
+
(p as Record<string, unknown>).knownPersonalProjects = known;
|
|
310
|
+
}
|
|
311
|
+
|
|
173
312
|
function mergeCfg(p: KronosysUpdatePayload, patch: Record<string, unknown>): void {
|
|
174
313
|
const base = { ...defaultKronosysCfg(), ...(asRecord(p.cfg) ?? {}) };
|
|
175
314
|
p.cfg = { ...base, ...patch };
|
|
176
315
|
}
|
|
177
316
|
|
|
317
|
+
function handleUpdateKronosysSettings({
|
|
318
|
+
p,
|
|
319
|
+
body,
|
|
320
|
+
finish,
|
|
321
|
+
}: ActionHandlerContext): ActionResult {
|
|
322
|
+
const s = asRecord(body.settings);
|
|
323
|
+
if (!s) {
|
|
324
|
+
return finish();
|
|
325
|
+
}
|
|
326
|
+
const merged = { ...s };
|
|
327
|
+
if (typeof merged.gitlabApiBaseUrl === "string") {
|
|
328
|
+
const parsed = parseGitlabInstanceOrigin(merged.gitlabApiBaseUrl);
|
|
329
|
+
merged.gitlabApiBaseUrl = parsed ?? "";
|
|
330
|
+
}
|
|
331
|
+
tryPersistDevDataPreferenceFromCfg(merged);
|
|
332
|
+
if (typeof merged.dashboardDisplayTimeZone === "string") {
|
|
333
|
+
const z = merged.dashboardDisplayTimeZone.trim();
|
|
334
|
+
merged.dashboardDisplayTimeZone = isValidIanaTimeZone(z) ? z : DEFAULT_DASHBOARD_TIME_ZONE;
|
|
335
|
+
}
|
|
336
|
+
if ("dashboardUse24HourClock" in merged) {
|
|
337
|
+
merged.dashboardUse24HourClock = merged.dashboardUse24HourClock !== false;
|
|
338
|
+
}
|
|
339
|
+
if (typeof merged.dashboardDefaultSessionNameTemplate === "string") {
|
|
340
|
+
merged.dashboardDefaultSessionNameTemplate = merged.dashboardDefaultSessionNameTemplate
|
|
341
|
+
.trim()
|
|
342
|
+
.slice(0, SESSION_NAME_TEMPLATE_CFG_MAX_LEN);
|
|
343
|
+
}
|
|
344
|
+
p.cfg = { ...defaultKronosysCfg(), ...(p.cfg as Record<string, unknown>), ...merged };
|
|
345
|
+
return finish();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function handleResetKronosysSettings({ p, finish }: ActionHandlerContext): ActionResult {
|
|
349
|
+
resetCfgToDefaults(p);
|
|
350
|
+
tryPersistDevDataPreferenceFromCfg({ developmentUseProductionData: false });
|
|
351
|
+
return finish();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function handleSetGitIdentity({ p, body, finish }: ActionHandlerContext): ActionResult {
|
|
355
|
+
const name = typeof body.gitUserName === "string" ? body.gitUserName.trim() : "";
|
|
356
|
+
const email = typeof body.gitUserEmail === "string" ? body.gitUserEmail.trim() : "";
|
|
357
|
+
const login = typeof body.gitAccountLogin === "string" ? body.gitAccountLogin.trim() : "";
|
|
358
|
+
p.gitIdentity = {
|
|
359
|
+
gitUserName: name.length > 0 ? name : null,
|
|
360
|
+
gitUserEmail: email.length > 0 ? email : null,
|
|
361
|
+
gitAccountLogin: login.length > 0 ? login : null,
|
|
362
|
+
};
|
|
363
|
+
return finish();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readTaskTemplates(p: KronosysUpdatePayload): TaskTemplateRecord[] {
|
|
367
|
+
const raw = Array.isArray((p as Record<string, unknown>).taskTemplates)
|
|
368
|
+
? (((p as Record<string, unknown>).taskTemplates as unknown[]) ?? [])
|
|
369
|
+
: [];
|
|
370
|
+
const out: TaskTemplateRecord[] = [];
|
|
371
|
+
for (const row of raw) {
|
|
372
|
+
const rec = asRecord(row);
|
|
373
|
+
if (!rec) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const id = typeof rec.id === "string" ? rec.id.trim() : "";
|
|
377
|
+
const name = typeof rec.name === "string" ? rec.name.trim() : "";
|
|
378
|
+
if (!id || !name) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const tags = Array.isArray(rec.tags)
|
|
382
|
+
? rec.tags
|
|
383
|
+
.map((t) => (typeof t === "string" ? normalizeTagKey(t) : ""))
|
|
384
|
+
.filter((t) => t.length > 0)
|
|
385
|
+
: [];
|
|
386
|
+
const projectRaw = typeof rec.project === "string" ? rec.project.trim() : "";
|
|
387
|
+
const project = projectRaw ? normalizeProjectKey(projectRaw) : null;
|
|
388
|
+
const personalProject = rec.personalProject === true;
|
|
389
|
+
const createdAt = typeof rec.createdAt === "string" && rec.createdAt.trim() ? rec.createdAt : new Date().toISOString();
|
|
390
|
+
const updatedAt = typeof rec.updatedAt === "string" && rec.updatedAt.trim() ? rec.updatedAt : createdAt;
|
|
391
|
+
out.push({ id, name, tags, project, personalProject, createdAt, updatedAt });
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function transformTaskTagsInPayload(
|
|
397
|
+
p: KronosysUpdatePayload,
|
|
398
|
+
sourceTag: string,
|
|
399
|
+
targetTag: string,
|
|
400
|
+
mode: "move" | "copy",
|
|
401
|
+
tagNormOpts: TaskTagsStorageNormalizeOpts
|
|
402
|
+
): void {
|
|
403
|
+
const sourceNorm = normalizeTagKey(sourceTag);
|
|
404
|
+
const targetNorm = normalizeTagKey(targetTag);
|
|
405
|
+
if (!sourceNorm || !targetNorm || sourceNorm.toLowerCase() === targetNorm.toLowerCase()) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const applyOnSession = (sess: Record<string, unknown> | undefined): void => {
|
|
409
|
+
if (!sess) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
forEachTaskRecordInSession(sess, (task) => {
|
|
413
|
+
const tagsRaw = Array.isArray(task.tags) ? (task.tags as string[]) : [];
|
|
414
|
+
if (tagsRaw.length === 0) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const lowered = tagsRaw.map((t) => normalizeTagKey(String(t)).toLowerCase());
|
|
418
|
+
const sourceL = sourceNorm.toLowerCase();
|
|
419
|
+
if (!lowered.some((x) => x === sourceL)) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
let next = tagsRaw.filter((t) => normalizeTagKey(String(t)).toLowerCase() !== sourceL);
|
|
423
|
+
if (mode === "copy") {
|
|
424
|
+
next = [...tagsRaw];
|
|
425
|
+
}
|
|
426
|
+
if (!next.some((t) => normalizeTagKey(String(t)).toLowerCase() === targetNorm.toLowerCase())) {
|
|
427
|
+
next.push(targetNorm);
|
|
428
|
+
}
|
|
429
|
+
task.tags = normalizeTaskTagsForStorage(next, tagNormOpts);
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
applyOnSession(asRecord(p.current));
|
|
433
|
+
for (const row of (p.history || []) as Record<string, unknown>[]) {
|
|
434
|
+
applyOnSession(row);
|
|
435
|
+
}
|
|
436
|
+
for (const row of (p.historyArchived || []) as Record<string, unknown>[]) {
|
|
437
|
+
applyOnSession(row);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
178
441
|
const CFG_KEYS_PRESERVED_ON_RESET: readonly string[] = [
|
|
179
442
|
"gitlabTokenStored",
|
|
180
443
|
"gitlabTokenFromEnv",
|
|
@@ -599,6 +862,8 @@ function newTaskRecord(
|
|
|
599
862
|
name: string;
|
|
600
863
|
tags?: string[];
|
|
601
864
|
project?: string | null;
|
|
865
|
+
personalProject?: boolean;
|
|
866
|
+
note?: string;
|
|
602
867
|
},
|
|
603
868
|
tagNormOpts: TaskTagsStorageNormalizeOpts
|
|
604
869
|
): Record<string, unknown> {
|
|
@@ -612,21 +877,50 @@ function newTaskRecord(
|
|
|
612
877
|
kronoFocusCycles: 0,
|
|
613
878
|
tags: normalizeTaskTagsForStorage(input.tags, tagNormOpts),
|
|
614
879
|
project: input.project ?? null,
|
|
880
|
+
personalProject: input.personalProject === true,
|
|
881
|
+
note: typeof input.note === "string" ? input.note : "",
|
|
615
882
|
subtasks: [],
|
|
616
883
|
};
|
|
617
884
|
}
|
|
618
885
|
|
|
886
|
+
let dispatchQueue: Promise<void> = Promise.resolve();
|
|
887
|
+
|
|
888
|
+
function enqueueDispatch<T>(run: () => Promise<T>): Promise<T> {
|
|
889
|
+
const next = dispatchQueue.then(run, run);
|
|
890
|
+
dispatchQueue = next.then(
|
|
891
|
+
() => undefined,
|
|
892
|
+
() => undefined,
|
|
893
|
+
);
|
|
894
|
+
return next;
|
|
895
|
+
}
|
|
896
|
+
|
|
619
897
|
export async function dispatchKronosysAction(body: Record<string, unknown>): Promise<ActionResult> {
|
|
898
|
+
return enqueueDispatch(async () =>
|
|
899
|
+
withPayloadWriteAsync((p) => dispatchKronosysActionWithPayload(body, p)),
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function dispatchKronosysActionWithPayload(
|
|
904
|
+
body: Record<string, unknown>,
|
|
905
|
+
p: KronosysUpdatePayload,
|
|
906
|
+
): Promise<ActionResult> {
|
|
620
907
|
const type = typeof body.type === "string" ? body.type : "";
|
|
621
|
-
const p = readPayload();
|
|
622
908
|
const tagNormOpts: TaskTagsStorageNormalizeOpts = {
|
|
623
909
|
assignDefaultTagBucket: readTaskDefaultTagBucketEnabled(p.cfg),
|
|
624
910
|
};
|
|
625
911
|
|
|
626
|
-
const finish = (): ActionResult => {
|
|
627
|
-
|
|
628
|
-
|
|
912
|
+
const finish = (result: Record<string, unknown> = {}): ActionResult => {
|
|
913
|
+
return { ok: true, result };
|
|
914
|
+
};
|
|
915
|
+
const domainHandlers: Record<string, (ctx: ActionHandlerContext) => ActionResult> = {
|
|
916
|
+
updateKronosysSettings: handleUpdateKronosysSettings,
|
|
917
|
+
resetKronosysSettings: handleResetKronosysSettings,
|
|
918
|
+
setGitIdentity: handleSetGitIdentity,
|
|
629
919
|
};
|
|
920
|
+
const domainHandler = domainHandlers[type];
|
|
921
|
+
if (domainHandler) {
|
|
922
|
+
return domainHandler({ p, body, finish });
|
|
923
|
+
}
|
|
630
924
|
|
|
631
925
|
switch (type) {
|
|
632
926
|
case "updateKronosysSettings": {
|
|
@@ -645,6 +939,10 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
645
939
|
if ("dashboardUse24HourClock" in merged) {
|
|
646
940
|
merged.dashboardUse24HourClock = merged.dashboardUse24HourClock !== false;
|
|
647
941
|
}
|
|
942
|
+
if (typeof merged.dashboardDefaultSessionNameTemplate === "string") {
|
|
943
|
+
merged.dashboardDefaultSessionNameTemplate =
|
|
944
|
+
merged.dashboardDefaultSessionNameTemplate.trim().slice(0, SESSION_NAME_TEMPLATE_CFG_MAX_LEN);
|
|
945
|
+
}
|
|
648
946
|
p.cfg = { ...defaultKronosysCfg(), ...(p.cfg as Record<string, unknown>), ...merged };
|
|
649
947
|
}
|
|
650
948
|
return finish();
|
|
@@ -677,16 +975,123 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
677
975
|
}
|
|
678
976
|
case "setPaused": {
|
|
679
977
|
const cur = ensureLive(p);
|
|
978
|
+
if (readGlobalPauseContext(cur)) {
|
|
979
|
+
syncLiveIntoHistory(p);
|
|
980
|
+
return finish();
|
|
981
|
+
}
|
|
680
982
|
if (body.paused === true) {
|
|
983
|
+
if (cur.isPaused === true) {
|
|
984
|
+
syncLiveIntoHistory(p);
|
|
985
|
+
return finish();
|
|
986
|
+
}
|
|
987
|
+
const pausedAt = new Date().toISOString();
|
|
988
|
+
const snapshot: SessionPauseContextSnapshot = {
|
|
989
|
+
taskIds: [],
|
|
990
|
+
subtaskTimers: [],
|
|
991
|
+
pausedAt,
|
|
992
|
+
};
|
|
993
|
+
forEachTaskRecordInSession(cur, (task, taskId) => {
|
|
994
|
+
if (task.isDone === true) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const activeSubtaskId =
|
|
998
|
+
typeof task.activeSubtaskTimerId === "string" ? task.activeSubtaskTimerId.trim() : "";
|
|
999
|
+
if (activeSubtaskId.length > 0) {
|
|
1000
|
+
snapshot.subtaskTimers.push({ taskId, subtaskId: activeSubtaskId });
|
|
1001
|
+
flushSubtaskTimerOnTask(task);
|
|
1002
|
+
}
|
|
1003
|
+
if (task.manualTaskTimerPaused !== true) {
|
|
1004
|
+
snapshot.taskIds.push(taskId);
|
|
1005
|
+
flushMainTimerSegmentOnTask(task);
|
|
1006
|
+
task.manualTaskTimerPaused = true;
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
cur.sessionPauseContext = {
|
|
1010
|
+
taskIds: snapshot.taskIds,
|
|
1011
|
+
subtaskTimers: snapshot.subtaskTimers,
|
|
1012
|
+
pausedAt: snapshot.pausedAt,
|
|
1013
|
+
};
|
|
681
1014
|
flushSessionWallSegmentOnLive(cur);
|
|
682
1015
|
cur.isPaused = true;
|
|
683
1016
|
} else {
|
|
1017
|
+
const sessSnap = readSessionPauseContext(cur);
|
|
1018
|
+
if (sessSnap) {
|
|
1019
|
+
restoreLiveTasksFromSessionPauseSnapshot(cur, sessSnap);
|
|
1020
|
+
delete cur.sessionPauseContext;
|
|
1021
|
+
}
|
|
684
1022
|
cur.isPaused = false;
|
|
685
1023
|
ensureSessionWallSegmentOnLive(cur);
|
|
686
1024
|
}
|
|
687
1025
|
syncLiveIntoHistory(p);
|
|
688
1026
|
return finish();
|
|
689
1027
|
}
|
|
1028
|
+
case "toggleGlobalPauseContext": {
|
|
1029
|
+
const cur = ensureLive(p);
|
|
1030
|
+
const existing = readGlobalPauseContext(cur);
|
|
1031
|
+
if (existing) {
|
|
1032
|
+
const nowIso = new Date().toISOString();
|
|
1033
|
+
const taskIds = new Set(existing.taskIds);
|
|
1034
|
+
const subtaskMap = new Map(existing.subtaskTimers.map((row) => [row.taskId, row.subtaskId]));
|
|
1035
|
+
if (!existing.sessionWasPaused) {
|
|
1036
|
+
cur.isPaused = false;
|
|
1037
|
+
ensureSessionWallSegmentOnLive(cur);
|
|
1038
|
+
}
|
|
1039
|
+
forEachTaskRecordInSession(cur, (task, taskId) => {
|
|
1040
|
+
if (task.isDone === true) {
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const taskSubtaskId = subtaskMap.get(taskId);
|
|
1044
|
+
if (taskSubtaskId) {
|
|
1045
|
+
task.activeSubtaskTimerId = taskSubtaskId;
|
|
1046
|
+
task.manualTaskTimerPaused = false;
|
|
1047
|
+
task[SUBTASK_TIMER_STARTED_AT] = nowIso;
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (!taskIds.has(taskId)) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
task.manualTaskTimerPaused = false;
|
|
1054
|
+
ensureMainTimerSegmentForRunningTask(task);
|
|
1055
|
+
});
|
|
1056
|
+
delete cur.globalPauseContext;
|
|
1057
|
+
syncLiveIntoHistory(p);
|
|
1058
|
+
return finish();
|
|
1059
|
+
}
|
|
1060
|
+
delete cur.sessionPauseContext;
|
|
1061
|
+
const snapshot: GlobalPauseContextSnapshot = {
|
|
1062
|
+
sessionWasPaused: cur.isPaused === true,
|
|
1063
|
+
taskIds: [],
|
|
1064
|
+
subtaskTimers: [],
|
|
1065
|
+
};
|
|
1066
|
+
forEachTaskRecordInSession(cur, (task, taskId) => {
|
|
1067
|
+
if (task.isDone === true) {
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const activeSubtaskId =
|
|
1071
|
+
typeof task.activeSubtaskTimerId === "string" ? task.activeSubtaskTimerId.trim() : "";
|
|
1072
|
+
if (activeSubtaskId.length > 0) {
|
|
1073
|
+
snapshot.subtaskTimers.push({ taskId, subtaskId: activeSubtaskId });
|
|
1074
|
+
flushSubtaskTimerOnTask(task);
|
|
1075
|
+
}
|
|
1076
|
+
if (task.manualTaskTimerPaused !== true) {
|
|
1077
|
+
snapshot.taskIds.push(taskId);
|
|
1078
|
+
flushMainTimerSegmentOnTask(task);
|
|
1079
|
+
task.manualTaskTimerPaused = true;
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
if (cur.isPaused !== true) {
|
|
1083
|
+
flushSessionWallSegmentOnLive(cur);
|
|
1084
|
+
cur.isPaused = true;
|
|
1085
|
+
}
|
|
1086
|
+
cur.globalPauseContext = {
|
|
1087
|
+
sessionWasPaused: snapshot.sessionWasPaused,
|
|
1088
|
+
taskIds: snapshot.taskIds,
|
|
1089
|
+
subtaskTimers: snapshot.subtaskTimers,
|
|
1090
|
+
pausedAt: new Date().toISOString(),
|
|
1091
|
+
};
|
|
1092
|
+
syncLiveIntoHistory(p);
|
|
1093
|
+
return finish();
|
|
1094
|
+
}
|
|
690
1095
|
case "setSessionName": {
|
|
691
1096
|
const name = typeof body.name === "string" ? body.name : "";
|
|
692
1097
|
const sid = typeof body.sessionId === "string" ? body.sessionId : undefined;
|
|
@@ -710,6 +1115,29 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
710
1115
|
syncLiveIntoHistory(p);
|
|
711
1116
|
return finish();
|
|
712
1117
|
}
|
|
1118
|
+
case "setSessionNote": {
|
|
1119
|
+
const note = typeof body.note === "string" ? body.note : "";
|
|
1120
|
+
const sid = typeof body.sessionId === "string" ? body.sessionId : undefined;
|
|
1121
|
+
const cur = asRecord(p.current);
|
|
1122
|
+
if (sid && cur?.sessionId === sid) {
|
|
1123
|
+
cur.sessionNote = note;
|
|
1124
|
+
} else if (!sid && cur) {
|
|
1125
|
+
cur.sessionNote = note;
|
|
1126
|
+
}
|
|
1127
|
+
const hist = (p.history || []) as Record<string, unknown>[];
|
|
1128
|
+
const id = sid ?? (typeof cur?.sessionId === "string" ? cur.sessionId : "");
|
|
1129
|
+
const row = hist.find((h) => h.sessionId === id);
|
|
1130
|
+
if (row) {
|
|
1131
|
+
row.sessionNote = note;
|
|
1132
|
+
}
|
|
1133
|
+
const arch = (p.historyArchived || []) as Record<string, unknown>[];
|
|
1134
|
+
const ar = arch.find((h) => h.sessionId === id);
|
|
1135
|
+
if (ar) {
|
|
1136
|
+
ar.sessionNote = note;
|
|
1137
|
+
}
|
|
1138
|
+
syncLiveIntoHistory(p);
|
|
1139
|
+
return finish();
|
|
1140
|
+
}
|
|
713
1141
|
case "setSessionStartTime": {
|
|
714
1142
|
const sidFromBody = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
715
1143
|
const isoRaw = typeof body.startAt === "string" ? body.startAt.trim() : "";
|
|
@@ -729,9 +1157,12 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
729
1157
|
if (!row) {
|
|
730
1158
|
return;
|
|
731
1159
|
}
|
|
732
|
-
row.startAt = startAtIso;
|
|
733
1160
|
const endRaw = typeof row.endAt === "string" ? row.endAt.trim() : "";
|
|
734
1161
|
const endMs = Date.parse(endRaw);
|
|
1162
|
+
if (Number.isFinite(endMs) && startMs > endMs) {
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
row.startAt = startAtIso;
|
|
735
1166
|
if (Number.isFinite(endMs) && endMs >= startMs) {
|
|
736
1167
|
row.sessionDurationMinutes = (endMs - startMs) / 60000;
|
|
737
1168
|
return;
|
|
@@ -770,24 +1201,34 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
770
1201
|
return;
|
|
771
1202
|
}
|
|
772
1203
|
const prevEnd = typeof row.endAt === "string" ? row.endAt.trim() : "";
|
|
773
|
-
if (!prevEnd) {
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
1204
|
const startRaw = typeof row.startAt === "string" ? row.startAt.trim() : "";
|
|
777
1205
|
const startMs = Date.parse(startRaw);
|
|
778
1206
|
if (!Number.isFinite(startMs) || endMs < startMs) {
|
|
779
1207
|
return;
|
|
780
1208
|
}
|
|
1209
|
+
if (!prevEnd) {
|
|
1210
|
+
if (row === cur && endMs <= Date.now()) {
|
|
1211
|
+
finalizeLiveSessionClosedAt(p, endMs);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
row.scheduledEndAt = endAtIso;
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
781
1217
|
row.endAt = endAtIso;
|
|
782
1218
|
row.sessionDurationMinutes = (endMs - startMs) / 60000;
|
|
783
1219
|
};
|
|
1220
|
+
let finalizedLiveSession = false;
|
|
784
1221
|
if (cur?.sessionId === id) {
|
|
1222
|
+
const hadCurrentBefore = !!asRecord(p.current);
|
|
785
1223
|
applyToRow(cur);
|
|
1224
|
+
finalizedLiveSession = hadCurrentBefore && !asRecord(p.current);
|
|
1225
|
+
}
|
|
1226
|
+
if (!finalizedLiveSession) {
|
|
1227
|
+
const hist = (p.history || []) as Record<string, unknown>[];
|
|
1228
|
+
applyToRow(hist.find((h) => h.sessionId === id));
|
|
1229
|
+
const arch = (p.historyArchived || []) as Record<string, unknown>[];
|
|
1230
|
+
applyToRow(arch.find((h) => h.sessionId === id));
|
|
786
1231
|
}
|
|
787
|
-
const hist = (p.history || []) as Record<string, unknown>[];
|
|
788
|
-
applyToRow(hist.find((h) => h.sessionId === id));
|
|
789
|
-
const arch = (p.historyArchived || []) as Record<string, unknown>[];
|
|
790
|
-
applyToRow(arch.find((h) => h.sessionId === id));
|
|
791
1232
|
syncLiveIntoHistory(p);
|
|
792
1233
|
return finish();
|
|
793
1234
|
}
|
|
@@ -828,29 +1269,152 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
828
1269
|
}
|
|
829
1270
|
case "newSession": {
|
|
830
1271
|
const id = randomUUID();
|
|
831
|
-
const
|
|
832
|
-
const
|
|
1272
|
+
const recordMs = Date.now();
|
|
1273
|
+
const recordIso = new Date(recordMs).toISOString();
|
|
1274
|
+
const bodyRec = body as Record<string, unknown>;
|
|
1275
|
+
const startAtOverrideRaw =
|
|
1276
|
+
typeof bodyRec.sessionStartAt === "string" ? bodyRec.sessionStartAt.trim() : "";
|
|
1277
|
+
const parsedOverrideMs =
|
|
1278
|
+
startAtOverrideRaw !== "" ? Date.parse(startAtOverrideRaw) : Number.NaN;
|
|
1279
|
+
const hadExplicitStart = startAtOverrideRaw !== "" && Number.isFinite(parsedOverrideMs);
|
|
1280
|
+
/** Début réellement dans le passé : on n’écrase pas la session live, on ajoute une ligne d’historique. */
|
|
1281
|
+
const isBackdatedOnly =
|
|
1282
|
+
hadExplicitStart && parsedOverrideMs < recordMs - 1000;
|
|
1283
|
+
const mergedCfg = { ...defaultKronosysCfg(), ...(asRecord(p.cfg) ?? {}) };
|
|
1284
|
+
const tmplRaw =
|
|
1285
|
+
typeof mergedCfg.dashboardDefaultSessionNameTemplate === "string"
|
|
1286
|
+
? mergedCfg.dashboardDefaultSessionNameTemplate.trim()
|
|
1287
|
+
: "";
|
|
1288
|
+
const tz = readDashboardTimeZoneFromCfg(mergedCfg);
|
|
833
1289
|
const prevCur = asRecord(p.current);
|
|
834
1290
|
const prevLang = typeof prevCur?.language === "string" ? String(prevCur.language) : "en";
|
|
835
|
-
const bodyRec = body as Record<string, unknown>;
|
|
836
1291
|
const schedIn = bodyRec.scheduledStartAt;
|
|
837
|
-
|
|
1292
|
+
|
|
1293
|
+
if (isBackdatedOnly) {
|
|
1294
|
+
const endAtOverrideRaw =
|
|
1295
|
+
typeof bodyRec.sessionEndAt === "string" ? bodyRec.sessionEndAt.trim() : "";
|
|
1296
|
+
const parsedEndMs =
|
|
1297
|
+
endAtOverrideRaw !== "" ? Date.parse(endAtOverrideRaw) : Number.NaN;
|
|
1298
|
+
const hadExplicitEnd = endAtOverrideRaw !== "" && Number.isFinite(parsedEndMs);
|
|
1299
|
+
if (!hadExplicitEnd) {
|
|
1300
|
+
return {
|
|
1301
|
+
ok: false,
|
|
1302
|
+
result: {
|
|
1303
|
+
newSessionError:
|
|
1304
|
+
"Une session passée exige une date et une heure de fin (`sessionEndAt`).",
|
|
1305
|
+
},
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
if (parsedEndMs <= parsedOverrideMs) {
|
|
1309
|
+
return {
|
|
1310
|
+
ok: false,
|
|
1311
|
+
result: {
|
|
1312
|
+
newSessionError: "La fin de session doit être strictement après le début.",
|
|
1313
|
+
},
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
if (parsedEndMs > recordMs + 1000) {
|
|
1317
|
+
return {
|
|
1318
|
+
ok: false,
|
|
1319
|
+
result: {
|
|
1320
|
+
newSessionError: "La fin de session ne peut pas être dans le futur.",
|
|
1321
|
+
},
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
const sessionStartMs = parsedOverrideMs;
|
|
1325
|
+
const sessionStartIso = new Date(sessionStartMs).toISOString();
|
|
1326
|
+
const sessionEndIso = new Date(parsedEndMs).toISOString();
|
|
1327
|
+
const computedSessionName =
|
|
1328
|
+
tmplRaw === ""
|
|
1329
|
+
? ""
|
|
1330
|
+
: formatSessionNameTemplate(tmplRaw, {
|
|
1331
|
+
sessionId: id,
|
|
1332
|
+
atMs: sessionStartMs,
|
|
1333
|
+
timeZone: tz,
|
|
1334
|
+
}).slice(0, SESSION_NAME_TEMPLATE_MAX_LEN);
|
|
1335
|
+
const ass: SessionStartAssiduity | undefined =
|
|
1336
|
+
typeof schedIn === "string" && schedIn.trim() !== ""
|
|
1337
|
+
? (assiduityFromScheduledStart(sessionStartMs, schedIn) ?? undefined)
|
|
1338
|
+
: undefined;
|
|
1339
|
+
const hist = [...((p.history || []) as Record<string, unknown>[])];
|
|
1340
|
+
const newRow: Record<string, unknown> = {
|
|
1341
|
+
sessionId: id,
|
|
1342
|
+
sessionName: computedSessionName,
|
|
1343
|
+
sessionNote: "",
|
|
1344
|
+
archived: false,
|
|
1345
|
+
isPaused: true,
|
|
1346
|
+
savedAt: recordIso,
|
|
1347
|
+
createdAt: sessionStartIso,
|
|
1348
|
+
startAt: sessionStartIso,
|
|
1349
|
+
endAt: sessionEndIso,
|
|
1350
|
+
...(ass
|
|
1351
|
+
? { scheduledStartAt: ass.scheduledStartAt, sessionStartOffsetMinutes: ass.sessionStartOffsetMinutes }
|
|
1352
|
+
: {}),
|
|
1353
|
+
sessionDurationMinutes: Math.max(0, (parsedEndMs - sessionStartMs) / 60000),
|
|
1354
|
+
codingMinutesSession: 0,
|
|
1355
|
+
activeMinutes: 0,
|
|
1356
|
+
totalEvents: 0,
|
|
1357
|
+
language: prevLang,
|
|
1358
|
+
tasks: [],
|
|
1359
|
+
activeTasks: [],
|
|
1360
|
+
activeTask: null,
|
|
1361
|
+
sessionScope: body.sessionScope,
|
|
1362
|
+
};
|
|
1363
|
+
hist.unshift(newRow);
|
|
1364
|
+
p.history = hist;
|
|
1365
|
+
return finish({ newHistorySessionId: id });
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Lancement immédiat : instantané d’ouverture = maintenant (le client impose la fin de session live si besoin).
|
|
1369
|
+
const immediateStartIso = recordIso;
|
|
1370
|
+
const immediateSessionName =
|
|
1371
|
+
tmplRaw === ""
|
|
1372
|
+
? ""
|
|
1373
|
+
: formatSessionNameTemplate(tmplRaw, {
|
|
1374
|
+
sessionId: id,
|
|
1375
|
+
atMs: recordMs,
|
|
1376
|
+
timeZone: tz,
|
|
1377
|
+
}).slice(0, SESSION_NAME_TEMPLATE_MAX_LEN);
|
|
1378
|
+
/* Si la session courante est encore vivante (pas de `endAt`) au moment où l’on en démarre une
|
|
1379
|
+
* nouvelle, on la finalise proprement avant de la sortir de `current` :
|
|
1380
|
+
* - flush du segment de durée murale ;
|
|
1381
|
+
* - `endAt` au moment de l’ouverture de la nouvelle session ;
|
|
1382
|
+
* - `isPaused = true` (la session quitte le rôle de session live).
|
|
1383
|
+
* Sans ça, l’ancienne session se retrouvait dans `history` avec `endAt: null` et
|
|
1384
|
+
* `sessionDurationMinutes: 0` (orpheline), faisant disparaître son temps mural des rapports.
|
|
1385
|
+
*/
|
|
1386
|
+
const prevLive = asRecord(p.current);
|
|
1387
|
+
if (prevLive) {
|
|
1388
|
+
const prevSid = typeof prevLive.sessionId === "string" ? prevLive.sessionId.trim() : "";
|
|
1389
|
+
const prevEndRaw = typeof prevLive.endAt === "string" ? prevLive.endAt.trim() : "";
|
|
1390
|
+
if (prevSid && prevEndRaw === "") {
|
|
1391
|
+
flushSessionWallSegmentOnLive(prevLive);
|
|
1392
|
+
prevLive.endAt = recordIso;
|
|
1393
|
+
prevLive.isPaused = true;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
syncLiveIntoHistory(p);
|
|
1397
|
+
const assImmediate: SessionStartAssiduity | undefined =
|
|
838
1398
|
typeof schedIn === "string" && schedIn.trim() !== ""
|
|
839
|
-
? (assiduityFromScheduledStart(
|
|
1399
|
+
? (assiduityFromScheduledStart(recordMs, schedIn) ?? undefined)
|
|
840
1400
|
: undefined;
|
|
841
1401
|
p.current = {
|
|
842
1402
|
sessionId: id,
|
|
843
|
-
sessionName:
|
|
1403
|
+
sessionName: immediateSessionName,
|
|
1404
|
+
sessionNote: "",
|
|
844
1405
|
archived: false,
|
|
845
1406
|
isPaused: false,
|
|
846
|
-
savedAt:
|
|
847
|
-
createdAt:
|
|
848
|
-
startAt:
|
|
1407
|
+
savedAt: recordIso,
|
|
1408
|
+
createdAt: immediateStartIso,
|
|
1409
|
+
startAt: immediateStartIso,
|
|
849
1410
|
endAt: null,
|
|
850
|
-
...(
|
|
851
|
-
? {
|
|
1411
|
+
...(assImmediate
|
|
1412
|
+
? {
|
|
1413
|
+
scheduledStartAt: assImmediate.scheduledStartAt,
|
|
1414
|
+
sessionStartOffsetMinutes: assImmediate.sessionStartOffsetMinutes,
|
|
1415
|
+
}
|
|
852
1416
|
: {}),
|
|
853
|
-
[SESSION_WALL_SEGMENT_STARTED_AT]:
|
|
1417
|
+
[SESSION_WALL_SEGMENT_STARTED_AT]: recordIso,
|
|
854
1418
|
sessionDurationMinutes: 0,
|
|
855
1419
|
codingMinutesSession: 0,
|
|
856
1420
|
activeMinutes: 0,
|
|
@@ -880,16 +1444,42 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
880
1444
|
Array.isArray(body.tags) ? (body.tags as string[]) : [],
|
|
881
1445
|
tagNormOpts
|
|
882
1446
|
);
|
|
883
|
-
const
|
|
1447
|
+
const parsedTitle = parseTaskWithAutoTags(name);
|
|
1448
|
+
const projFromBody = typeof body.project === "string" ? body.project.trim() : "";
|
|
1449
|
+
const projectResolved = projFromBody
|
|
1450
|
+
? normalizeProjectKey(projFromBody)
|
|
1451
|
+
: parsedTitle.project
|
|
1452
|
+
? normalizeProjectKey(parsedTitle.project)
|
|
1453
|
+
: null;
|
|
1454
|
+
const personalResolved =
|
|
1455
|
+
(typeof body.personalProject === "boolean" ? body.personalProject : parsedTitle.personalProject) &&
|
|
1456
|
+
Boolean(projectResolved);
|
|
1457
|
+
const note = typeof body.note === "string" ? body.note : "";
|
|
884
1458
|
mergeDiscoveredTagsIntoPayloadKnownTags(p, mergeTagsForDisplay(name, tags));
|
|
885
|
-
mergeDiscoveredProjectIntoPayloadKnownProjects(p,
|
|
886
|
-
|
|
887
|
-
|
|
1459
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, projectResolved);
|
|
1460
|
+
mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, projectResolved, personalResolved);
|
|
1461
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, parsedTitle.project);
|
|
1462
|
+
mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(
|
|
1463
|
+
p,
|
|
1464
|
+
parsedTitle.project,
|
|
1465
|
+
parsedTitle.personalProject,
|
|
1466
|
+
);
|
|
1467
|
+
const task = newTaskRecord(
|
|
1468
|
+
{
|
|
1469
|
+
name,
|
|
1470
|
+
tags,
|
|
1471
|
+
project: projectResolved,
|
|
1472
|
+
personalProject: personalResolved,
|
|
1473
|
+
note,
|
|
1474
|
+
},
|
|
1475
|
+
tagNormOpts,
|
|
1476
|
+
);
|
|
888
1477
|
const active = Array.isArray(cur.activeTasks) ? ([...cur.activeTasks] as Record<string, unknown>[]) : [];
|
|
889
1478
|
active.push(task);
|
|
890
1479
|
cur.activeTasks = active;
|
|
891
1480
|
cur.activeTask = task;
|
|
892
1481
|
prepareSessionForExclusiveMainTimerUnpaused(cur, String(task.id));
|
|
1482
|
+
resumeLiveSessionWallIfPaused(cur);
|
|
893
1483
|
const bodyRec = body as Record<string, unknown>;
|
|
894
1484
|
if (body.startKronoFocus === true || bodyRec[LEGACY_START_TASK_WITH_TIMER_BODY_KEY] === true) {
|
|
895
1485
|
const pm = ensureKronoFocus(cur);
|
|
@@ -950,10 +1540,12 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
950
1540
|
p.inspectingSessionId = null;
|
|
951
1541
|
p.knownTags = [];
|
|
952
1542
|
p.knownProjects = [];
|
|
1543
|
+
(p as Record<string, unknown>).knownPersonalProjects = [];
|
|
953
1544
|
p.userKnownTags = [];
|
|
954
1545
|
p.excludedSuggestionTags = [];
|
|
955
1546
|
p.tagDescriptions = {};
|
|
956
1547
|
p.projectDescriptions = {};
|
|
1548
|
+
(p as Record<string, unknown>).taskTemplates = [];
|
|
957
1549
|
p.gitIdentity = {};
|
|
958
1550
|
delete p.gitStats;
|
|
959
1551
|
delete p.dismissArchiveSessionConfirm;
|
|
@@ -964,8 +1556,37 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
964
1556
|
case "endLiveSession": {
|
|
965
1557
|
const cur = asRecord(p.current);
|
|
966
1558
|
if (cur) {
|
|
1559
|
+
const taskHandling =
|
|
1560
|
+
body.taskHandling === "finish"
|
|
1561
|
+
? "finish"
|
|
1562
|
+
: body.taskHandling === "moveToPausedSession"
|
|
1563
|
+
? "moveToPausedSession"
|
|
1564
|
+
: "keep";
|
|
1565
|
+
/* Une session terminée ne peut pas laisser des minuteurs « en cours » : on vide les segments
|
|
1566
|
+
* et on met en pause, quel que soit le mode (keep / finish / transposition). */
|
|
1567
|
+
forEachTaskRecordInSession(cur, (task) => {
|
|
1568
|
+
if (task.isDone === true) {
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
flushSubtaskTimerOnTask(task);
|
|
1572
|
+
flushMainTimerSegmentOnTask(task);
|
|
1573
|
+
task.manualTaskTimerPaused = true;
|
|
1574
|
+
});
|
|
1575
|
+
if (taskHandling === "finish") {
|
|
1576
|
+
const openTaskIds: string[] = [];
|
|
1577
|
+
forEachTaskRecordInSession(cur, (_task, taskId) => {
|
|
1578
|
+
const task = findTaskRecord(cur, taskId);
|
|
1579
|
+
if (task && task.isDone !== true) {
|
|
1580
|
+
openTaskIds.push(taskId);
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
for (const taskId of openTaskIds) {
|
|
1584
|
+
finishTaskInSession(cur, taskId, false, tagNormOpts);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
967
1587
|
flushSessionWallSegmentOnLive(cur);
|
|
968
1588
|
cur.endAt = new Date().toISOString();
|
|
1589
|
+
delete cur.scheduledEndAt;
|
|
969
1590
|
const rk = normalizeSessionEndReasonKind(body.sessionEndReasonKind);
|
|
970
1591
|
if (rk) {
|
|
971
1592
|
cur.sessionEndReasonKind = rk;
|
|
@@ -978,7 +1599,50 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
978
1599
|
} else {
|
|
979
1600
|
delete cur.sessionEndReasonNote;
|
|
980
1601
|
}
|
|
1602
|
+
let movedSession: Record<string, unknown> | null = null;
|
|
1603
|
+
if (taskHandling === "moveToPausedSession") {
|
|
1604
|
+
const carriedTasks: Record<string, unknown>[] = [];
|
|
1605
|
+
forEachTaskRecordInSession(cur, (task) => {
|
|
1606
|
+
if (task.isDone === true) {
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
carriedTasks.push({
|
|
1610
|
+
...task,
|
|
1611
|
+
manualTaskTimerPaused: true,
|
|
1612
|
+
activeSubtaskTimerId: null,
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
if (carriedTasks.length > 0) {
|
|
1616
|
+
const now = new Date().toISOString();
|
|
1617
|
+
movedSession = {
|
|
1618
|
+
sessionId: randomUUID(),
|
|
1619
|
+
sessionName:
|
|
1620
|
+
typeof cur.sessionName === "string" && cur.sessionName.trim() !== ""
|
|
1621
|
+
? `${cur.sessionName} (continued)`
|
|
1622
|
+
: "Continued session",
|
|
1623
|
+
archived: false,
|
|
1624
|
+
isPaused: true,
|
|
1625
|
+
savedAt: now,
|
|
1626
|
+
createdAt: now,
|
|
1627
|
+
startAt: now,
|
|
1628
|
+
endAt: null,
|
|
1629
|
+
sessionDurationMinutes: 0,
|
|
1630
|
+
codingMinutesSession: 0,
|
|
1631
|
+
activeMinutes: 0,
|
|
1632
|
+
totalEvents: 0,
|
|
1633
|
+
language: typeof cur.language === "string" ? cur.language : "en",
|
|
1634
|
+
tasks: carriedTasks,
|
|
1635
|
+
activeTasks: [],
|
|
1636
|
+
activeTask: null,
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
981
1640
|
syncLiveIntoHistory(p);
|
|
1641
|
+
if (movedSession) {
|
|
1642
|
+
p.current = movedSession;
|
|
1643
|
+
syncLiveIntoHistory(p);
|
|
1644
|
+
return finish();
|
|
1645
|
+
}
|
|
982
1646
|
}
|
|
983
1647
|
p.current = undefined;
|
|
984
1648
|
return finish();
|
|
@@ -995,6 +1659,8 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
995
1659
|
: typeof body.project === "string"
|
|
996
1660
|
? body.project
|
|
997
1661
|
: undefined;
|
|
1662
|
+
const personalPatch =
|
|
1663
|
+
body.personalProject === undefined ? undefined : body.personalProject === true;
|
|
998
1664
|
if (
|
|
999
1665
|
!updateTaskInSession(
|
|
1000
1666
|
ctx.session,
|
|
@@ -1003,12 +1669,27 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1003
1669
|
name: typeof body.name === "string" ? body.name : undefined,
|
|
1004
1670
|
tags: Array.isArray(body.tags) ? (body.tags as string[]) : undefined,
|
|
1005
1671
|
project: projectPatch,
|
|
1672
|
+
personalProject: personalPatch,
|
|
1673
|
+
note: typeof body.note === "string" ? body.note : undefined,
|
|
1006
1674
|
},
|
|
1007
1675
|
tagNormOpts
|
|
1008
1676
|
)
|
|
1009
1677
|
) {
|
|
1010
1678
|
return finish();
|
|
1011
1679
|
}
|
|
1680
|
+
if (typeof body.name === "string") {
|
|
1681
|
+
const inferredProj = resolveProjectForTaskUpdate(body.name);
|
|
1682
|
+
const inferredPers = resolvePersonalProjectForTaskUpdate(body.name);
|
|
1683
|
+
const t = findTaskRecord(ctx.session, taskId);
|
|
1684
|
+
if (t) {
|
|
1685
|
+
if (inferredProj !== undefined) {
|
|
1686
|
+
t.project = inferredProj;
|
|
1687
|
+
}
|
|
1688
|
+
if (inferredPers !== undefined) {
|
|
1689
|
+
t.personalProject = inferredPers;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1012
1693
|
if (Array.isArray(body.tags)) {
|
|
1013
1694
|
mergeDiscoveredTagsIntoPayloadKnownTags(p, body.tags as string[]);
|
|
1014
1695
|
} else if (typeof body.name === "string") {
|
|
@@ -1016,8 +1697,22 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1016
1697
|
}
|
|
1017
1698
|
if (typeof projectPatch === "string") {
|
|
1018
1699
|
mergeDiscoveredProjectIntoPayloadKnownProjects(p, projectPatch);
|
|
1700
|
+
const tAfter = findTaskRecord(ctx.session, taskId);
|
|
1701
|
+
const pers =
|
|
1702
|
+
personalPatch !== undefined
|
|
1703
|
+
? personalPatch
|
|
1704
|
+
: typeof body.name === "string"
|
|
1705
|
+
? parseTaskWithAutoTags(body.name).personalProject
|
|
1706
|
+
: tAfter?.personalProject === true;
|
|
1707
|
+
mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(
|
|
1708
|
+
p,
|
|
1709
|
+
normalizeProjectKey(projectPatch),
|
|
1710
|
+
Boolean(pers),
|
|
1711
|
+
);
|
|
1019
1712
|
} else if (typeof body.name === "string") {
|
|
1020
|
-
|
|
1713
|
+
const pt = parseTaskWithAutoTags(body.name);
|
|
1714
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, pt.project);
|
|
1715
|
+
mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, pt.project, pt.personalProject);
|
|
1021
1716
|
}
|
|
1022
1717
|
if (ctx.isLive) {
|
|
1023
1718
|
syncLiveIntoHistory(p);
|
|
@@ -1034,7 +1729,18 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1034
1729
|
if (!taskId || !startTime) {
|
|
1035
1730
|
return finish();
|
|
1036
1731
|
}
|
|
1037
|
-
const
|
|
1732
|
+
const durationAdjustModeRaw =
|
|
1733
|
+
body.durationAdjustMode === "keep" || body.durationAdjustMode === "manual" || body.durationAdjustMode === "from_bounds"
|
|
1734
|
+
? body.durationAdjustMode
|
|
1735
|
+
: undefined;
|
|
1736
|
+
const manualDurationMs =
|
|
1737
|
+
typeof body.manualDurationMs === "number" && Number.isFinite(body.manualDurationMs)
|
|
1738
|
+
? Math.max(0, Math.floor(body.manualDurationMs))
|
|
1739
|
+
: undefined;
|
|
1740
|
+
const ok = updateTaskStartTimeInSession(ctx.session, taskId, startTime, {
|
|
1741
|
+
durationAdjustMode: durationAdjustModeRaw,
|
|
1742
|
+
manualDurationMs,
|
|
1743
|
+
});
|
|
1038
1744
|
if (ok && ctx.isLive) {
|
|
1039
1745
|
syncLiveIntoHistory(p);
|
|
1040
1746
|
}
|
|
@@ -1050,7 +1756,20 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1050
1756
|
if (!taskId || !endTime) {
|
|
1051
1757
|
return finish();
|
|
1052
1758
|
}
|
|
1053
|
-
const
|
|
1759
|
+
const durationAdjustModeRaw =
|
|
1760
|
+
body.durationAdjustMode === "keep" || body.durationAdjustMode === "manual" || body.durationAdjustMode === "from_bounds"
|
|
1761
|
+
? body.durationAdjustMode
|
|
1762
|
+
: undefined;
|
|
1763
|
+
const manualDurationMs =
|
|
1764
|
+
typeof body.manualDurationMs === "number" && Number.isFinite(body.manualDurationMs)
|
|
1765
|
+
? Math.max(0, Math.floor(body.manualDurationMs))
|
|
1766
|
+
: undefined;
|
|
1767
|
+
const ok = updateTaskEndTimeInSession(ctx.session, taskId, endTime, {
|
|
1768
|
+
durationAdjustMode: durationAdjustModeRaw,
|
|
1769
|
+
manualDurationMs,
|
|
1770
|
+
})
|
|
1771
|
+
? true
|
|
1772
|
+
: scheduleTaskEndTimeInSession(ctx.session, taskId, endTime);
|
|
1054
1773
|
if (ok && ctx.isLive) {
|
|
1055
1774
|
syncLiveIntoHistory(p);
|
|
1056
1775
|
}
|
|
@@ -1088,7 +1807,13 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1088
1807
|
if (!ctx) {
|
|
1089
1808
|
return finish();
|
|
1090
1809
|
}
|
|
1091
|
-
|
|
1810
|
+
const paused = body.paused === true;
|
|
1811
|
+
const taskId = String(body.taskId ?? "");
|
|
1812
|
+
setTaskPausedInSession(ctx.session, taskId, paused);
|
|
1813
|
+
if (ctx.isLive && !paused) {
|
|
1814
|
+
pruneSessionPauseContextForTask(ctx.session, taskId);
|
|
1815
|
+
resumeLiveSessionWallIfPaused(ctx.session);
|
|
1816
|
+
}
|
|
1092
1817
|
if (ctx.isLive) {
|
|
1093
1818
|
syncLiveIntoHistory(p);
|
|
1094
1819
|
}
|
|
@@ -1099,8 +1824,11 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1099
1824
|
if (!ctx) {
|
|
1100
1825
|
return finish();
|
|
1101
1826
|
}
|
|
1102
|
-
|
|
1827
|
+
const taskId = String(body.taskId ?? "");
|
|
1828
|
+
setTaskPausedInSession(ctx.session, taskId, false);
|
|
1103
1829
|
if (ctx.isLive) {
|
|
1830
|
+
pruneSessionPauseContextForTask(ctx.session, taskId);
|
|
1831
|
+
resumeLiveSessionWallIfPaused(ctx.session);
|
|
1104
1832
|
syncLiveIntoHistory(p);
|
|
1105
1833
|
}
|
|
1106
1834
|
return finish();
|
|
@@ -1117,20 +1845,49 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1117
1845
|
);
|
|
1118
1846
|
const project =
|
|
1119
1847
|
body.project === null ? null : typeof body.project === "string" ? body.project : undefined;
|
|
1848
|
+
const parsedTitle = parseTaskWithAutoTags(name);
|
|
1849
|
+
const projectResolved =
|
|
1850
|
+
project !== undefined && project !== null && String(project).trim()
|
|
1851
|
+
? normalizeProjectKey(String(project).trim())
|
|
1852
|
+
: parsedTitle.project
|
|
1853
|
+
? normalizeProjectKey(parsedTitle.project)
|
|
1854
|
+
: null;
|
|
1855
|
+
const personalResolved =
|
|
1856
|
+
(typeof body.personalProject === "boolean" ? body.personalProject : parsedTitle.personalProject) &&
|
|
1857
|
+
Boolean(projectResolved);
|
|
1120
1858
|
const durationMs = typeof body.durationMs === "number" ? Math.round(body.durationMs) : 0;
|
|
1859
|
+
const note = typeof body.note === "string" ? body.note : "";
|
|
1121
1860
|
const startTime = typeof body.startTime === "string" ? body.startTime : "";
|
|
1122
1861
|
const endTime = typeof body.endTime === "string" ? body.endTime : "";
|
|
1123
|
-
addHistoricalTaskToSession(
|
|
1862
|
+
const added = addHistoricalTaskToSession(
|
|
1124
1863
|
ctx.session,
|
|
1125
|
-
{
|
|
1864
|
+
{
|
|
1865
|
+
name,
|
|
1866
|
+
tags,
|
|
1867
|
+
project: projectResolved,
|
|
1868
|
+
personalProject: personalResolved,
|
|
1869
|
+
note,
|
|
1870
|
+
durationMs,
|
|
1871
|
+
startTime,
|
|
1872
|
+
endTime,
|
|
1873
|
+
},
|
|
1126
1874
|
randomUUID(),
|
|
1127
1875
|
tagNormOpts
|
|
1128
1876
|
);
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
|
|
1877
|
+
if (!added) {
|
|
1878
|
+
return finish();
|
|
1132
1879
|
}
|
|
1133
|
-
|
|
1880
|
+
mergeDiscoveredTagsIntoPayloadKnownTags(p, mergeTagsForDisplay(name, tags));
|
|
1881
|
+
if (projectResolved) {
|
|
1882
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, projectResolved);
|
|
1883
|
+
mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, projectResolved, personalResolved);
|
|
1884
|
+
}
|
|
1885
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, parsedTitle.project);
|
|
1886
|
+
mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(
|
|
1887
|
+
p,
|
|
1888
|
+
parsedTitle.project,
|
|
1889
|
+
parsedTitle.personalProject,
|
|
1890
|
+
);
|
|
1134
1891
|
if (ctx.isLive) {
|
|
1135
1892
|
syncLiveIntoHistory(p);
|
|
1136
1893
|
}
|
|
@@ -1343,6 +2100,110 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1343
2100
|
p.userKnownTags = user;
|
|
1344
2101
|
return finish();
|
|
1345
2102
|
}
|
|
2103
|
+
case "saveTaskTemplate": {
|
|
2104
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
2105
|
+
const tags = normalizeTaskTagsForStorage(
|
|
2106
|
+
Array.isArray(body.tags) ? (body.tags as string[]) : [],
|
|
2107
|
+
tagNormOpts
|
|
2108
|
+
);
|
|
2109
|
+
const projectRaw = typeof body.project === "string" ? body.project.trim() : "";
|
|
2110
|
+
const project = projectRaw ? normalizeProjectKey(projectRaw) : null;
|
|
2111
|
+
if (!name && tags.length === 0 && !project) {
|
|
2112
|
+
return finish();
|
|
2113
|
+
}
|
|
2114
|
+
const incomingPersonal =
|
|
2115
|
+
typeof body.personalProject === "boolean" ? body.personalProject : undefined;
|
|
2116
|
+
const now = new Date().toISOString();
|
|
2117
|
+
const list = readTaskTemplates(p);
|
|
2118
|
+
const idOpt = typeof body.id === "string" ? body.id.trim() : "";
|
|
2119
|
+
if (idOpt) {
|
|
2120
|
+
const ix = list.findIndex((tpl) => tpl.id === idOpt);
|
|
2121
|
+
if (ix < 0) {
|
|
2122
|
+
return finish();
|
|
2123
|
+
}
|
|
2124
|
+
const prev = list[ix]!;
|
|
2125
|
+
const personalProject =
|
|
2126
|
+
incomingPersonal !== undefined ? incomingPersonal : prev.personalProject === true;
|
|
2127
|
+
list[ix] = {
|
|
2128
|
+
...prev,
|
|
2129
|
+
name: name || prev.name,
|
|
2130
|
+
tags,
|
|
2131
|
+
project,
|
|
2132
|
+
personalProject,
|
|
2133
|
+
updatedAt: now,
|
|
2134
|
+
};
|
|
2135
|
+
(p as Record<string, unknown>).taskTemplates = list.slice(0, 200);
|
|
2136
|
+
if (tags.length > 0) {
|
|
2137
|
+
mergeDiscoveredTagsIntoPayloadKnownTags(p, tags);
|
|
2138
|
+
}
|
|
2139
|
+
if (project) {
|
|
2140
|
+
if (personalProject) {
|
|
2141
|
+
mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, project, true);
|
|
2142
|
+
} else {
|
|
2143
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
return finish();
|
|
2147
|
+
}
|
|
2148
|
+
const sigPersonal = incomingPersonal === true;
|
|
2149
|
+
const sig = `${name.toLowerCase()}|${sigPersonal ? "p" : "w"}|${project ? project.toLowerCase() : ""}|${tags
|
|
2150
|
+
.map((t) => t.toLowerCase())
|
|
2151
|
+
.sort()
|
|
2152
|
+
.join(",")}`;
|
|
2153
|
+
const idx = list.findIndex((tpl) => {
|
|
2154
|
+
const rowSig = `${tpl.name.toLowerCase()}|${tpl.personalProject === true ? "p" : "w"}|${tpl.project ? tpl.project.toLowerCase() : ""}|${[...tpl.tags]
|
|
2155
|
+
.map((t) => t.toLowerCase())
|
|
2156
|
+
.sort()
|
|
2157
|
+
.join(",")}`;
|
|
2158
|
+
return rowSig === sig;
|
|
2159
|
+
});
|
|
2160
|
+
if (idx >= 0) {
|
|
2161
|
+
const prev = list[idx]!;
|
|
2162
|
+
const personalProject =
|
|
2163
|
+
incomingPersonal !== undefined ? incomingPersonal : prev.personalProject === true;
|
|
2164
|
+
list[idx] = {
|
|
2165
|
+
...prev,
|
|
2166
|
+
name: name || prev.name,
|
|
2167
|
+
tags,
|
|
2168
|
+
project,
|
|
2169
|
+
personalProject,
|
|
2170
|
+
updatedAt: now,
|
|
2171
|
+
};
|
|
2172
|
+
} else {
|
|
2173
|
+
list.unshift({
|
|
2174
|
+
id: randomUUID(),
|
|
2175
|
+
name: name || tags.join(" "),
|
|
2176
|
+
tags,
|
|
2177
|
+
project,
|
|
2178
|
+
personalProject: incomingPersonal === true,
|
|
2179
|
+
createdAt: now,
|
|
2180
|
+
updatedAt: now,
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
(p as Record<string, unknown>).taskTemplates = list.slice(0, 200);
|
|
2184
|
+
if (tags.length > 0) {
|
|
2185
|
+
mergeDiscoveredTagsIntoPayloadKnownTags(p, tags);
|
|
2186
|
+
}
|
|
2187
|
+
if (project) {
|
|
2188
|
+
const row = list[idx >= 0 ? idx : 0]!;
|
|
2189
|
+
if (row.personalProject === true) {
|
|
2190
|
+
mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, project, true);
|
|
2191
|
+
} else {
|
|
2192
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
return finish();
|
|
2196
|
+
}
|
|
2197
|
+
case "removeTaskTemplate": {
|
|
2198
|
+
const rawId =
|
|
2199
|
+
typeof body.taskTemplateId === "string" ? body.taskTemplateId.trim() : "";
|
|
2200
|
+
if (!rawId) {
|
|
2201
|
+
return finish();
|
|
2202
|
+
}
|
|
2203
|
+
const list = readTaskTemplates(p).filter((t) => t.id !== rawId);
|
|
2204
|
+
(p as Record<string, unknown>).taskTemplates = list;
|
|
2205
|
+
return finish();
|
|
2206
|
+
}
|
|
1346
2207
|
case "removeUserKnownTag": {
|
|
1347
2208
|
const raw = typeof body.tag === "string" ? body.tag : "";
|
|
1348
2209
|
const lk = normalizeTagKey(raw).toLowerCase();
|
|
@@ -1369,6 +2230,11 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1369
2230
|
p.projectDescriptions = pd;
|
|
1370
2231
|
return finish();
|
|
1371
2232
|
}
|
|
2233
|
+
case "rememberKnownProject": {
|
|
2234
|
+
const raw = typeof body.name === "string" ? body.name : "";
|
|
2235
|
+
mergeDiscoveredProjectIntoPayloadKnownProjects(p, raw);
|
|
2236
|
+
return finish();
|
|
2237
|
+
}
|
|
1372
2238
|
case "includeTagFromSuggestions": {
|
|
1373
2239
|
const raw = typeof body.tag === "string" ? body.tag : "";
|
|
1374
2240
|
const lk = normalizeTagKey(raw).toLowerCase();
|
|
@@ -1401,6 +2267,63 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
|
|
|
1401
2267
|
purgeProjectEverywhere(p, raw);
|
|
1402
2268
|
return finish();
|
|
1403
2269
|
}
|
|
2270
|
+
case "renameTagMetadata": {
|
|
2271
|
+
const source = typeof body.sourceTag === "string" ? body.sourceTag : "";
|
|
2272
|
+
const target = typeof body.targetTag === "string" ? body.targetTag : "";
|
|
2273
|
+
renameTagEverywhere(p, source, target, tagNormOpts);
|
|
2274
|
+
return finish();
|
|
2275
|
+
}
|
|
2276
|
+
case "renameProjectMetadata": {
|
|
2277
|
+
const source = typeof body.sourceName === "string" ? body.sourceName : "";
|
|
2278
|
+
const target = typeof body.targetName === "string" ? body.targetName : "";
|
|
2279
|
+
const personalOnly = body.personalOnly === true;
|
|
2280
|
+
renameProjectEverywhere(p, source, target, { personalOnly });
|
|
2281
|
+
return finish();
|
|
2282
|
+
}
|
|
2283
|
+
case "transformKnownTagScope": {
|
|
2284
|
+
const sourceRaw = typeof body.sourceTag === "string" ? body.sourceTag : "";
|
|
2285
|
+
const targetRaw = typeof body.targetTag === "string" ? body.targetTag : "";
|
|
2286
|
+
const mode: "move" | "copy" = body.mode === "copy" ? "copy" : "move";
|
|
2287
|
+
const source = normalizeTagKey(sourceRaw);
|
|
2288
|
+
const target = normalizeTagKey(targetRaw);
|
|
2289
|
+
if (!source || !target || source.toLowerCase() === target.toLowerCase()) {
|
|
2290
|
+
return finish();
|
|
2291
|
+
}
|
|
2292
|
+
transformTaskTagsInPayload(p, source, target, mode, tagNormOpts);
|
|
2293
|
+
const sourceL = source.toLowerCase();
|
|
2294
|
+
const targetL = target.toLowerCase();
|
|
2295
|
+
const known = [...((p.knownTags || []) as string[])];
|
|
2296
|
+
if (!known.some((t) => normalizeTagKey(t).toLowerCase() === targetL)) {
|
|
2297
|
+
known.push(target);
|
|
2298
|
+
}
|
|
2299
|
+
p.knownTags = mode === "move" ? known.filter((t) => normalizeTagKey(t).toLowerCase() !== sourceL) : known;
|
|
2300
|
+
const userKnown = [...((p.userKnownTags || []) as string[])];
|
|
2301
|
+
if (userKnown.some((t) => normalizeTagKey(t).toLowerCase() === sourceL)) {
|
|
2302
|
+
if (!userKnown.some((t) => normalizeTagKey(t).toLowerCase() === targetL)) {
|
|
2303
|
+
userKnown.push(target);
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
p.userKnownTags =
|
|
2307
|
+
mode === "move" ? userKnown.filter((t) => normalizeTagKey(t).toLowerCase() !== sourceL) : userKnown;
|
|
2308
|
+
const excluded = [...((p.excludedSuggestionTags || []) as string[])];
|
|
2309
|
+
if (excluded.some((t) => normalizeTagKey(t).toLowerCase() === sourceL)) {
|
|
2310
|
+
if (!excluded.some((t) => normalizeTagKey(t).toLowerCase() === targetL)) {
|
|
2311
|
+
excluded.push(target);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
p.excludedSuggestionTags =
|
|
2315
|
+
mode === "move" ? excluded.filter((t) => normalizeTagKey(t).toLowerCase() !== sourceL) : excluded;
|
|
2316
|
+
const td = { ...(asRecord(p.tagDescriptions) ?? {}) } as Record<string, string>;
|
|
2317
|
+
const sourceDesc = typeof td[sourceL] === "string" ? td[sourceL] : "";
|
|
2318
|
+
if (sourceDesc && !td[targetL]) {
|
|
2319
|
+
td[targetL] = sourceDesc;
|
|
2320
|
+
}
|
|
2321
|
+
if (mode === "move") {
|
|
2322
|
+
delete td[sourceL];
|
|
2323
|
+
}
|
|
2324
|
+
p.tagDescriptions = td;
|
|
2325
|
+
return finish();
|
|
2326
|
+
}
|
|
1404
2327
|
case "fetchRemoteIssues":
|
|
1405
2328
|
return await dispatchFetchRemoteIssues(p, body);
|
|
1406
2329
|
case "pushSessionToMongo":
|