@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/app/api/action/route.ts +39 -3
- package/app/api/action-logs/route.ts +24 -0
- package/app/api/backup/route.ts +1 -1
- package/app/api/restore/route.ts +145 -0
- package/app/changelog/page.tsx +71 -4
- package/app/globals.css +127 -0
- package/app/guide/page.tsx +61 -15
- package/app/implementation/page.tsx +700 -0
- package/app/layout.tsx +14 -3
- package/app/licenses/page.tsx +99 -37
- package/app/logs/page.tsx +258 -0
- package/app/manifest.ts +5 -5
- package/app/page.tsx +784 -229
- package/app/reporting/page.tsx +1266 -474
- package/app/settings/page.tsx +252 -18
- package/bin/kronosys.mjs +140 -15
- package/components/KronosysPayloadProvider.tsx +2 -0
- package/components/RouteTransition.tsx +18 -0
- package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
- package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
- package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
- package/components/dashboard/AppShellRouteNav.tsx +323 -48
- package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
- package/components/dashboard/DashboardSimpleModal.tsx +168 -25
- package/components/dashboard/DashboardTour.tsx +115 -29
- package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
- package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
- package/components/dashboard/NewSessionScopeModal.tsx +211 -20
- package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
- package/components/dashboard/ReportingTour.tsx +87 -21
- package/components/dashboard/SavedProjectPicker.tsx +16 -3
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
- package/components/dashboard/SessionListPanel.tsx +327 -44
- package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
- package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
- package/components/dashboard/SettingsTour.tsx +86 -21
- package/components/dashboard/TagPills.tsx +14 -1
- package/components/dashboard/TaskFocusPanel.tsx +1081 -478
- package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
- package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
- package/components/dashboard/taskFieldStyles.ts +20 -4
- package/components/dashboard/useReportingInteractionState.ts +80 -0
- package/lib/appShellHeaderClasses.ts +13 -0
- package/lib/businessRulesMatrix.ts +210 -0
- package/lib/copyToClipboard.ts +43 -0
- package/lib/dashboardCopy.ts +494 -84
- package/lib/dashboardQuickSearch.ts +54 -2
- package/lib/dashboardTimeZone.ts +109 -0
- package/lib/formatAppShellWallClock.ts +66 -0
- package/lib/formatSessionNameTemplate.ts +141 -0
- package/lib/generatedUserChangelog.ts +177 -6
- package/lib/globalPausePreview.ts +292 -0
- package/lib/implementationNotes.ts +1188 -0
- package/lib/kronosysApi.ts +6 -0
- package/lib/kronosysDashboardModalGates.ts +24 -0
- package/lib/plannedBoundaryAttention.ts +9 -0
- package/lib/plannedBoundaryConflict.ts +23 -0
- package/lib/reportingAggregate.ts +517 -75
- package/lib/reportingMetricHelp.ts +8 -0
- package/lib/reportingStrings.ts +37 -3
- package/lib/sessionListMerge.ts +4 -0
- package/lib/sessionTaskSidebarStats.ts +182 -21
- package/lib/settingsCopy.ts +178 -4
- package/lib/taskParsing.ts +360 -103
- package/lib/taskTemplateDraft.ts +135 -0
- package/lib/taskTimelineGantt.ts +265 -0
- package/lib/temporalDisplayPlanned.ts +71 -0
- package/lib/userGuideCopy.ts +121 -47
- package/next.config.ts +7 -0
- package/package.json +12 -24
- package/server/actionDispatch.ts +1000 -77
- package/server/actionTaskSession.ts +337 -24
- package/server/db.ts +7 -15
- package/server/dbSchema.ts +24 -0
- package/server/defaultCfg.ts +5 -0
- package/server/gitlabTokenStore.ts +0 -12
- package/server/liveHistorySync.ts +53 -0
- package/server/mainTimerHydrate.ts +38 -2
- package/server/payloadStore.ts +33 -11
- package/server/sessionWallHydrate.ts +66 -3
- package/server/userActionLog.ts +126 -0
- package/sonar-project.properties +11 -0
- package/tsconfig.json +2 -1
- package/components/dashboard/IssuePickerModal.tsx +0 -168
- package/components/dashboard/ThemeToggle.test.tsx +0 -26
- package/lib/backupCsvExport.test.ts +0 -149
- package/lib/dashboardQuickSearchQuery.test.ts +0 -63
- package/lib/dataDir.test.ts +0 -87
- package/lib/formatIsoShort.test.ts +0 -46
- package/lib/kronoFocusRhythm.test.ts +0 -130
- package/lib/kronoFocusTimerUrgency.test.ts +0 -74
- package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
- package/lib/reportingAggregate.test.ts +0 -325
- package/lib/reportingNonFinalIndicators.test.ts +0 -157
- package/lib/reportingTagWeekBreakdown.test.ts +0 -141
- package/lib/reportingWeekLayout.test.ts +0 -239
- package/lib/sessionAssiduity.test.ts +0 -25
- package/lib/sessionEndWarnings.test.ts +0 -200
- package/lib/sessionListMerge.test.ts +0 -101
- package/lib/sessionTaskSidebarStats.test.ts +0 -24
- package/lib/taskParsing.test.ts +0 -153
- package/lib/usageProfile.test.ts +0 -84
- package/server/actionDispatch.test.ts +0 -723
- package/server/actionTaskSession.test.ts +0 -713
- package/server/kronoFocusHydrate.test.ts +0 -142
- package/server/kronoFocusMigrate.test.ts +0 -53
- package/server/mainTimerHydrate.test.ts +0 -65
- package/server/payloadStore.test.ts +0 -78
- package/server/sessionWallHydrate.test.ts +0 -46
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatProjectDisplay,
|
|
3
|
+
formatTagDisplay,
|
|
4
|
+
normalizeProjectKey,
|
|
5
|
+
normalizeTagKey,
|
|
6
|
+
taskTitleForDisplay,
|
|
7
|
+
} from "@/lib/taskParsing";
|
|
8
|
+
|
|
9
|
+
/** Entrée `taskTemplates` du payload (après validation légère). */
|
|
10
|
+
export type ParsedTaskTemplateRow = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
tags: string[];
|
|
14
|
+
project: string | null;
|
|
15
|
+
/** Projet personnel (`!`) — distinct des projets `@`. */
|
|
16
|
+
personalProject?: boolean;
|
|
17
|
+
updatedAt?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function formatTaskTemplateDraftLine(tpl: {
|
|
21
|
+
name: string;
|
|
22
|
+
tags?: string[];
|
|
23
|
+
project?: string | null;
|
|
24
|
+
personalProject?: boolean;
|
|
25
|
+
}): string {
|
|
26
|
+
const title = taskTitleForDisplay(tpl.name).trim();
|
|
27
|
+
const tags = (tpl.tags ?? [])
|
|
28
|
+
.map((tag) => `#${normalizeTagKey(tag)}`)
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
const project =
|
|
31
|
+
typeof tpl.project === "string" && tpl.project.trim()
|
|
32
|
+
? formatProjectDisplay(tpl.project, {
|
|
33
|
+
personal: tpl.personalProject === true,
|
|
34
|
+
})
|
|
35
|
+
: "";
|
|
36
|
+
return [title, ...tags, project].filter(Boolean).join(" ").trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Libellé lisible pour datalist / aide : préfixe localisé + titre · métadonnées.
|
|
41
|
+
*/
|
|
42
|
+
export function formatTaskTemplateDatalistLabel(
|
|
43
|
+
tpl: {
|
|
44
|
+
name: string;
|
|
45
|
+
tags?: string[];
|
|
46
|
+
project?: string | null;
|
|
47
|
+
personalProject?: boolean;
|
|
48
|
+
},
|
|
49
|
+
prefix: string,
|
|
50
|
+
): string {
|
|
51
|
+
const title = taskTitleForDisplay(tpl.name).trim() || "—";
|
|
52
|
+
const metaParts: string[] = [];
|
|
53
|
+
for (const tag of tpl.tags ?? []) {
|
|
54
|
+
const k = normalizeTagKey(tag);
|
|
55
|
+
if (k) {
|
|
56
|
+
metaParts.push(formatTagDisplay(k));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (typeof tpl.project === "string" && tpl.project.trim()) {
|
|
60
|
+
metaParts.push(
|
|
61
|
+
formatProjectDisplay(tpl.project, {
|
|
62
|
+
personal: tpl.personalProject === true,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const suffix = metaParts.length > 0 ? ` · ${metaParts.join(" · ")}` : "";
|
|
67
|
+
return `${prefix} ${title}${suffix}`.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseTaskTemplatesFromPayload(
|
|
71
|
+
raw: unknown,
|
|
72
|
+
): ParsedTaskTemplateRow[] {
|
|
73
|
+
if (!Array.isArray(raw)) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const out: ParsedTaskTemplateRow[] = [];
|
|
77
|
+
for (const row of raw) {
|
|
78
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const rec = row as Record<string, unknown>;
|
|
82
|
+
const id = typeof rec.id === "string" ? rec.id.trim() : "";
|
|
83
|
+
const name = typeof rec.name === "string" ? rec.name.trim() : "";
|
|
84
|
+
if (!id || !name) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const tags = Array.isArray(rec.tags)
|
|
88
|
+
? rec.tags
|
|
89
|
+
.map((t) => (typeof t === "string" ? normalizeTagKey(t) : ""))
|
|
90
|
+
.filter((t) => t.length > 0)
|
|
91
|
+
: [];
|
|
92
|
+
const project =
|
|
93
|
+
typeof rec.project === "string"
|
|
94
|
+
? normalizeProjectKey(rec.project)
|
|
95
|
+
: null;
|
|
96
|
+
const personalProject = rec.personalProject === true;
|
|
97
|
+
const updatedAt =
|
|
98
|
+
typeof rec.updatedAt === "string" ? rec.updatedAt : undefined;
|
|
99
|
+
out.push({ id, name, tags, project, personalProject, updatedAt });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function dedupeAndSortTagKeys(tags: string[]): string[] {
|
|
105
|
+
const seen = new Set<string>();
|
|
106
|
+
const out: string[] = [];
|
|
107
|
+
for (const t of tags) {
|
|
108
|
+
const key = normalizeTagKey(t).trim().toLowerCase();
|
|
109
|
+
if (!key || seen.has(key)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
seen.add(key);
|
|
113
|
+
out.push(key);
|
|
114
|
+
}
|
|
115
|
+
out.sort();
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Signature stable pour comparer une tâche avec un template (insensible à la casse / ordre des tags). */
|
|
120
|
+
export function buildTaskTemplateSignature(input: {
|
|
121
|
+
name: string;
|
|
122
|
+
tags?: string[];
|
|
123
|
+
project?: string | null;
|
|
124
|
+
/** Même clé projet `@` / `!` doit produire des signatures distinctes. */
|
|
125
|
+
personalProject?: boolean;
|
|
126
|
+
}): string {
|
|
127
|
+
const name = taskTitleForDisplay(input.name).trim().toLowerCase();
|
|
128
|
+
const project =
|
|
129
|
+
typeof input.project === "string" && input.project.trim()
|
|
130
|
+
? normalizeProjectKey(input.project).toLowerCase()
|
|
131
|
+
: "";
|
|
132
|
+
const scope = input.personalProject === true ? "p" : "w";
|
|
133
|
+
const tags = dedupeAndSortTagKeys(input.tags ?? []);
|
|
134
|
+
return `${name}|${scope}|${project}|${tags.join(",")}`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { Lang } from "@/lib/dashboardCopy";
|
|
2
|
+
import { formatIsoInstantShort } from "@/lib/formatIsoShort";
|
|
3
|
+
import { formatDuration } from "@/lib/taskParsing";
|
|
4
|
+
|
|
5
|
+
export type TaskTimelineGanttRow = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
project: string | null;
|
|
9
|
+
/** Temps personnel (`!projet`) vs productif (`@`). */
|
|
10
|
+
personalProject?: boolean;
|
|
11
|
+
startMs: number;
|
|
12
|
+
/** Absent lorsque la tâche est encore au minuteur (fin = « maintenant » dans la modale). */
|
|
13
|
+
endMs?: number;
|
|
14
|
+
/** Durée suivie par le minuteur (ms), tel que stocké côté tâche ; absent si inconnu. */
|
|
15
|
+
timerDurationMs: number | null;
|
|
16
|
+
startLabel: string;
|
|
17
|
+
endLabel: string;
|
|
18
|
+
durationLabel: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type TaskLike = {
|
|
22
|
+
id?: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
startTime?: string;
|
|
25
|
+
endTime?: string;
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
project?: string | null;
|
|
28
|
+
personalProject?: boolean;
|
|
29
|
+
taskTimerLaps?: Array<{
|
|
30
|
+
startTime?: string;
|
|
31
|
+
endTime?: string;
|
|
32
|
+
durationMs?: number;
|
|
33
|
+
}>;
|
|
34
|
+
taskCurrentLapStartedAt?: string | number | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Même fusion que le panneau Tâches : `tasks` + pile active (sans doublon id).
|
|
39
|
+
*/
|
|
40
|
+
export function mergeSessionTasksForTimeline(
|
|
41
|
+
sess: {
|
|
42
|
+
tasks?: unknown;
|
|
43
|
+
activeTasks?: unknown;
|
|
44
|
+
activeTask?: unknown;
|
|
45
|
+
} | null | undefined,
|
|
46
|
+
): TaskLike[] {
|
|
47
|
+
if (!sess) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const map = new Map<string, TaskLike>();
|
|
51
|
+
const base = Array.isArray(sess.tasks) ? sess.tasks : [];
|
|
52
|
+
for (const raw of base) {
|
|
53
|
+
const t = raw as TaskLike;
|
|
54
|
+
if (t?.id) {
|
|
55
|
+
map.set(String(t.id), t);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const stack: TaskLike[] =
|
|
59
|
+
Array.isArray(sess.activeTasks) && sess.activeTasks.length > 0
|
|
60
|
+
? [...(sess.activeTasks as TaskLike[])]
|
|
61
|
+
: sess.activeTask
|
|
62
|
+
? [sess.activeTask as TaskLike]
|
|
63
|
+
: [];
|
|
64
|
+
for (const t of stack) {
|
|
65
|
+
if (t?.id) {
|
|
66
|
+
map.set(String(t.id), t);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...map.values()];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildTaskTimelineGanttRows(
|
|
73
|
+
mergedTasks: TaskLike[],
|
|
74
|
+
opts: {
|
|
75
|
+
isInspecting: boolean;
|
|
76
|
+
lang: Lang;
|
|
77
|
+
displayTimeZone: string;
|
|
78
|
+
use24HourClock: boolean;
|
|
79
|
+
},
|
|
80
|
+
): TaskTimelineGanttRow[] {
|
|
81
|
+
const out: TaskTimelineGanttRow[] = [];
|
|
82
|
+
const { isInspecting, lang, displayTimeZone, use24HourClock } = opts;
|
|
83
|
+
for (const task of mergedTasks) {
|
|
84
|
+
const taskId = String(task.id ?? "").trim();
|
|
85
|
+
if (!taskId) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const taskName = typeof task.name === "string" ? task.name : "";
|
|
89
|
+
const taskProject = task.project ?? null;
|
|
90
|
+
const personalProject = task.personalProject === true;
|
|
91
|
+
const laps = Array.isArray(task.taskTimerLaps) ? task.taskTimerLaps : [];
|
|
92
|
+
let lapRowCount = 0;
|
|
93
|
+
for (let i = 0; i < laps.length; i++) {
|
|
94
|
+
const lap = laps[i];
|
|
95
|
+
const startMs =
|
|
96
|
+
typeof lap?.startTime === "string" ? Date.parse(lap.startTime) : Number.NaN;
|
|
97
|
+
const endMsRaw =
|
|
98
|
+
typeof lap?.endTime === "string" ? Date.parse(lap.endTime) : Number.NaN;
|
|
99
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMsRaw) || endMsRaw < startMs) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const timerDurationMs =
|
|
103
|
+
typeof lap?.durationMs === "number" && Number.isFinite(lap.durationMs)
|
|
104
|
+
? Math.max(0, Math.floor(lap.durationMs))
|
|
105
|
+
: Math.max(0, Math.floor(endMsRaw - startMs));
|
|
106
|
+
if (timerDurationMs <= 0) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const startLabel =
|
|
110
|
+
formatIsoInstantShort(
|
|
111
|
+
new Date(startMs).toISOString(),
|
|
112
|
+
lang,
|
|
113
|
+
displayTimeZone,
|
|
114
|
+
use24HourClock,
|
|
115
|
+
) ?? "—";
|
|
116
|
+
const endLabel =
|
|
117
|
+
formatIsoInstantShort(
|
|
118
|
+
new Date(endMsRaw).toISOString(),
|
|
119
|
+
lang,
|
|
120
|
+
displayTimeZone,
|
|
121
|
+
use24HourClock,
|
|
122
|
+
) ?? "—";
|
|
123
|
+
lapRowCount += 1;
|
|
124
|
+
out.push({
|
|
125
|
+
id: `${taskId}::lap::${i + 1}`,
|
|
126
|
+
name: taskName,
|
|
127
|
+
project: taskProject,
|
|
128
|
+
personalProject,
|
|
129
|
+
startMs,
|
|
130
|
+
endMs: endMsRaw,
|
|
131
|
+
timerDurationMs,
|
|
132
|
+
startLabel,
|
|
133
|
+
endLabel,
|
|
134
|
+
durationLabel: formatDuration(timerDurationMs / 60000),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const runningLapStartRaw = task.taskCurrentLapStartedAt;
|
|
138
|
+
const runningLapStartMs =
|
|
139
|
+
typeof runningLapStartRaw === "string"
|
|
140
|
+
? Date.parse(runningLapStartRaw)
|
|
141
|
+
: typeof runningLapStartRaw === "number" && Number.isFinite(runningLapStartRaw)
|
|
142
|
+
? runningLapStartRaw
|
|
143
|
+
: Number.NaN;
|
|
144
|
+
let hasLapRows = lapRowCount > 0;
|
|
145
|
+
if (!isInspecting && Number.isFinite(runningLapStartMs)) {
|
|
146
|
+
const nowMs = Date.now();
|
|
147
|
+
if (nowMs >= runningLapStartMs) {
|
|
148
|
+
const timerDurationMs = Math.max(0, Math.floor(nowMs - runningLapStartMs));
|
|
149
|
+
const startLabel =
|
|
150
|
+
formatIsoInstantShort(
|
|
151
|
+
new Date(runningLapStartMs).toISOString(),
|
|
152
|
+
lang,
|
|
153
|
+
displayTimeZone,
|
|
154
|
+
use24HourClock,
|
|
155
|
+
) ?? "—";
|
|
156
|
+
out.push({
|
|
157
|
+
id: `${taskId}::lap::running`,
|
|
158
|
+
name: taskName,
|
|
159
|
+
project: taskProject,
|
|
160
|
+
personalProject,
|
|
161
|
+
startMs: runningLapStartMs,
|
|
162
|
+
endMs: undefined,
|
|
163
|
+
timerDurationMs,
|
|
164
|
+
startLabel,
|
|
165
|
+
endLabel: lang === "fr" ? "en cours" : "running",
|
|
166
|
+
durationLabel: timerDurationMs > 0 ? formatDuration(timerDurationMs / 60000) : null,
|
|
167
|
+
});
|
|
168
|
+
hasLapRows = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (hasLapRows) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
let startMs =
|
|
175
|
+
typeof task.startTime === "string"
|
|
176
|
+
? Date.parse(task.startTime)
|
|
177
|
+
: Number.NaN;
|
|
178
|
+
const endMsRaw =
|
|
179
|
+
typeof task.endTime === "string"
|
|
180
|
+
? Date.parse(task.endTime)
|
|
181
|
+
: Number.NaN;
|
|
182
|
+
if (!Number.isFinite(startMs) && !Number.isFinite(endMsRaw)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!Number.isFinite(startMs) && Number.isFinite(endMsRaw)) {
|
|
186
|
+
const dur =
|
|
187
|
+
typeof task.durationMs === "number" &&
|
|
188
|
+
Number.isFinite(task.durationMs)
|
|
189
|
+
? task.durationMs
|
|
190
|
+
: 60_000;
|
|
191
|
+
startMs = endMsRaw - dur;
|
|
192
|
+
}
|
|
193
|
+
if (!Number.isFinite(startMs)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const hasEnd = Number.isFinite(endMsRaw);
|
|
197
|
+
const endMs = hasEnd ? endMsRaw : undefined;
|
|
198
|
+
|
|
199
|
+
const startLabel =
|
|
200
|
+
formatIsoInstantShort(
|
|
201
|
+
new Date(startMs).toISOString(),
|
|
202
|
+
lang,
|
|
203
|
+
displayTimeZone,
|
|
204
|
+
use24HourClock,
|
|
205
|
+
) ?? "—";
|
|
206
|
+
const endLabel = hasEnd
|
|
207
|
+
? formatIsoInstantShort(
|
|
208
|
+
new Date(endMsRaw).toISOString(),
|
|
209
|
+
lang,
|
|
210
|
+
displayTimeZone,
|
|
211
|
+
use24HourClock,
|
|
212
|
+
) ?? "—"
|
|
213
|
+
: isInspecting
|
|
214
|
+
? "—"
|
|
215
|
+
: lang === "fr"
|
|
216
|
+
? "en cours"
|
|
217
|
+
: "running";
|
|
218
|
+
const durationMin =
|
|
219
|
+
typeof task.durationMs === "number" && Number.isFinite(task.durationMs)
|
|
220
|
+
? task.durationMs / 60000
|
|
221
|
+
: Number.NaN;
|
|
222
|
+
const timerDurationMs =
|
|
223
|
+
typeof task.durationMs === "number" && Number.isFinite(task.durationMs)
|
|
224
|
+
? task.durationMs
|
|
225
|
+
: null;
|
|
226
|
+
out.push({
|
|
227
|
+
id: taskId,
|
|
228
|
+
name: taskName,
|
|
229
|
+
project: taskProject,
|
|
230
|
+
personalProject,
|
|
231
|
+
startMs,
|
|
232
|
+
endMs,
|
|
233
|
+
timerDurationMs,
|
|
234
|
+
startLabel,
|
|
235
|
+
endLabel,
|
|
236
|
+
durationLabel:
|
|
237
|
+
Number.isFinite(durationMin) && durationMin > 0
|
|
238
|
+
? formatDuration(durationMin)
|
|
239
|
+
: null,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
out.sort(
|
|
243
|
+
(a, b) => a.startMs - b.startMs || String(a.id).localeCompare(b.id),
|
|
244
|
+
);
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function parseSessionBoundsMs(session: {
|
|
249
|
+
startAt?: string | null;
|
|
250
|
+
endAt?: string | null;
|
|
251
|
+
} | null | undefined): {
|
|
252
|
+
sessionStartMs: number | null;
|
|
253
|
+
sessionEndMs: number | null;
|
|
254
|
+
} {
|
|
255
|
+
const rawStart =
|
|
256
|
+
typeof session?.startAt === "string" ? session.startAt.trim() : "";
|
|
257
|
+
const rawEnd =
|
|
258
|
+
typeof session?.endAt === "string" ? session.endAt.trim() : "";
|
|
259
|
+
const sessionStartMs = rawStart ? Date.parse(rawStart) : Number.NaN;
|
|
260
|
+
const sessionEndMs = rawEnd ? Date.parse(rawEnd) : Number.NaN;
|
|
261
|
+
return {
|
|
262
|
+
sessionStartMs: Number.isFinite(sessionStartMs) ? sessionStartMs : null,
|
|
263
|
+
sessionEndMs: Number.isFinite(sessionEndMs) ? sessionEndMs : null,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Détermine si une tâche doit s’afficher comme « planifiée » (fenêtre temporelle encore dans le futur),
|
|
3
|
+
* plutôt que comme en cours ou terminée.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function parseIsoMs(raw: string | undefined | null): number {
|
|
7
|
+
if (typeof raw !== "string") {
|
|
8
|
+
return Number.NaN;
|
|
9
|
+
}
|
|
10
|
+
const t = raw.trim();
|
|
11
|
+
if (!t) {
|
|
12
|
+
return Number.NaN;
|
|
13
|
+
}
|
|
14
|
+
const ms = Date.parse(t);
|
|
15
|
+
return Number.isFinite(ms) ? ms : Number.NaN;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TaskTemporalPlannedInput = {
|
|
19
|
+
isDone?: boolean;
|
|
20
|
+
startTime?: string;
|
|
21
|
+
endTime?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* - Tâche non terminée : `startTime` strictement postérieur à `nowMs`.
|
|
26
|
+
* - Tâche terminée : affichage « planifié » seulement si **tout** le créneau est encore dans le futur —
|
|
27
|
+
* `startTime` et `endTime` parseables et strictement postérieurs à `nowMs`. Sinon (début déjà passé,
|
|
28
|
+
* fin encore future : ex. fin programmée sur une tâche déjà terminée), la carte suit la section **terminées**,
|
|
29
|
+
* pas **Planifiées**, pour ne pas bloquer visuellement entre deux mondes jusqu’à l’heure de fin.
|
|
30
|
+
* - Si `startTime` est absent ou non parseable mais `endTime` est future : on conserve le comportement
|
|
31
|
+
* hérité (planifié tant que la fin est future) pour les entrées rétro sans début fiable.
|
|
32
|
+
*/
|
|
33
|
+
export function isTaskDisplayPlanned(
|
|
34
|
+
task: TaskTemporalPlannedInput,
|
|
35
|
+
nowMs: number = Date.now(),
|
|
36
|
+
): boolean {
|
|
37
|
+
if (task.isDone === true) {
|
|
38
|
+
const endMs = parseIsoMs(task.endTime);
|
|
39
|
+
if (!Number.isFinite(endMs) || endMs <= nowMs) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const startMs = parseIsoMs(task.startTime);
|
|
43
|
+
if (Number.isFinite(startMs)) {
|
|
44
|
+
return startMs > nowMs;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const startMs = parseIsoMs(task.startTime);
|
|
49
|
+
return Number.isFinite(startMs) && startMs > nowMs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type SessionTemporalPlannedInput = {
|
|
53
|
+
startAt?: string | null;
|
|
54
|
+
endAt?: string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Session ouverte dont l’horodatage officiel de début est encore dans le futur. */
|
|
58
|
+
export function isOpenSessionDisplayPlanned(
|
|
59
|
+
session: SessionTemporalPlannedInput | null | undefined,
|
|
60
|
+
nowMs: number = Date.now(),
|
|
61
|
+
): boolean {
|
|
62
|
+
const endRaw =
|
|
63
|
+
typeof session?.endAt === "string" ? session.endAt.trim() : "";
|
|
64
|
+
if (endRaw) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const startMs = parseIsoMs(
|
|
68
|
+
typeof session?.startAt === "string" ? session.startAt : null,
|
|
69
|
+
);
|
|
70
|
+
return Number.isFinite(startMs) && startMs > nowMs;
|
|
71
|
+
}
|