@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
|
@@ -2,8 +2,11 @@ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
|
|
|
2
2
|
import {
|
|
3
3
|
asRecord,
|
|
4
4
|
ensureTaskParentDurationCoversSubtasksMs,
|
|
5
|
+
finishTaskInSession,
|
|
5
6
|
forEachTaskRecordInSession,
|
|
6
7
|
MAIN_TIMER_SEGMENT_STARTED_AT,
|
|
8
|
+
TASK_CURRENT_LAP_STARTED_AT,
|
|
9
|
+
TASK_SCHEDULED_END_AT,
|
|
7
10
|
} from "./actionTaskSession";
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -16,6 +19,28 @@ export function materializeRunningMainTimersInPayload(p: KronosysUpdatePayload,
|
|
|
16
19
|
return false;
|
|
17
20
|
}
|
|
18
21
|
let needWrite = false;
|
|
22
|
+
|
|
23
|
+
const scheduledFinishes: Array<{ taskId: string; endMs: number }> = [];
|
|
24
|
+
forEachTaskRecordInSession(cur, (task, taskId) => {
|
|
25
|
+
if (task.isDone === true) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const scheduledRaw =
|
|
29
|
+
typeof task[TASK_SCHEDULED_END_AT] === "string" ? String(task[TASK_SCHEDULED_END_AT]).trim() : "";
|
|
30
|
+
if (!scheduledRaw) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const scheduledMs = Date.parse(scheduledRaw);
|
|
34
|
+
if (Number.isFinite(scheduledMs) && scheduledMs <= nowMs) {
|
|
35
|
+
scheduledFinishes.push({ taskId, endMs: scheduledMs });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
for (const { taskId, endMs } of scheduledFinishes) {
|
|
39
|
+
if (finishTaskInSession(cur, taskId, false, undefined, { completionInstantMs: endMs })) {
|
|
40
|
+
needWrite = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
forEachTaskRecordInSession(cur, (task) => {
|
|
20
45
|
if (task.isDone === true) {
|
|
21
46
|
return;
|
|
@@ -28,13 +53,21 @@ export function materializeRunningMainTimersInPayload(p: KronosysUpdatePayload,
|
|
|
28
53
|
}
|
|
29
54
|
const raw = task[MAIN_TIMER_SEGMENT_STARTED_AT];
|
|
30
55
|
if (typeof raw !== "string" || raw.trim() === "") {
|
|
31
|
-
|
|
56
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
57
|
+
task[MAIN_TIMER_SEGMENT_STARTED_AT] = nowIso;
|
|
58
|
+
if (typeof task[TASK_CURRENT_LAP_STARTED_AT] !== "string" || String(task[TASK_CURRENT_LAP_STARTED_AT]).trim() === "") {
|
|
59
|
+
task[TASK_CURRENT_LAP_STARTED_AT] = nowIso;
|
|
60
|
+
}
|
|
32
61
|
needWrite = true;
|
|
33
62
|
return;
|
|
34
63
|
}
|
|
35
64
|
const startedMs = Date.parse(raw);
|
|
36
65
|
if (!Number.isFinite(startedMs)) {
|
|
37
|
-
|
|
66
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
67
|
+
task[MAIN_TIMER_SEGMENT_STARTED_AT] = nowIso;
|
|
68
|
+
if (typeof task[TASK_CURRENT_LAP_STARTED_AT] !== "string" || String(task[TASK_CURRENT_LAP_STARTED_AT]).trim() === "") {
|
|
69
|
+
task[TASK_CURRENT_LAP_STARTED_AT] = nowIso;
|
|
70
|
+
}
|
|
38
71
|
needWrite = true;
|
|
39
72
|
return;
|
|
40
73
|
}
|
|
@@ -46,6 +79,9 @@ export function materializeRunningMainTimersInPayload(p: KronosysUpdatePayload,
|
|
|
46
79
|
typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Number(task.durationMs) : 0;
|
|
47
80
|
task.durationMs = Math.floor(parentPrev + elapsed);
|
|
48
81
|
task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
|
|
82
|
+
if (typeof task[TASK_CURRENT_LAP_STARTED_AT] !== "string" || String(task[TASK_CURRENT_LAP_STARTED_AT]).trim() === "") {
|
|
83
|
+
task[TASK_CURRENT_LAP_STARTED_AT] = new Date(nowMs - elapsed).toISOString();
|
|
84
|
+
}
|
|
49
85
|
ensureTaskParentDurationCoversSubtasksMs(task);
|
|
50
86
|
needWrite = true;
|
|
51
87
|
});
|
package/server/payloadStore.ts
CHANGED
|
@@ -11,17 +11,7 @@ import { materializeSessionWallClockInPayload } from "./sessionWallHydrate";
|
|
|
11
11
|
|
|
12
12
|
const PAYLOAD_KEY = "dashboard_payload_v1";
|
|
13
13
|
|
|
14
|
-
function ensureTables(): void {
|
|
15
|
-
getSqlite().exec(`
|
|
16
|
-
CREATE TABLE IF NOT EXISTS kv_store (
|
|
17
|
-
k TEXT PRIMARY KEY NOT NULL,
|
|
18
|
-
v TEXT NOT NULL
|
|
19
|
-
);
|
|
20
|
-
`);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
14
|
export function readPayload(): KronosysUpdatePayload {
|
|
24
|
-
ensureTables();
|
|
25
15
|
const row = getSqlite().prepare("SELECT v FROM kv_store WHERE k = ?").get(PAYLOAD_KEY) as { v: string } | undefined;
|
|
26
16
|
if (!row?.v) {
|
|
27
17
|
const initial = createInitialPayload();
|
|
@@ -56,10 +46,41 @@ export function readPayload(): KronosysUpdatePayload {
|
|
|
56
46
|
}
|
|
57
47
|
|
|
58
48
|
export function writePayload(p: KronosysUpdatePayload): void {
|
|
59
|
-
ensureTables();
|
|
60
49
|
getSqlite().prepare("INSERT OR REPLACE INTO kv_store (k, v) VALUES (?, ?)").run(PAYLOAD_KEY, JSON.stringify(p));
|
|
61
50
|
}
|
|
62
51
|
|
|
52
|
+
export function withPayloadWrite<T>(mutate: (payload: KronosysUpdatePayload) => T): T {
|
|
53
|
+
const db = getSqlite();
|
|
54
|
+
db.exec("BEGIN IMMEDIATE TRANSACTION;");
|
|
55
|
+
try {
|
|
56
|
+
const payload = readPayload();
|
|
57
|
+
const out = mutate(payload);
|
|
58
|
+
writePayload(payload);
|
|
59
|
+
db.exec("COMMIT;");
|
|
60
|
+
return out;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
db.exec("ROLLBACK;");
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function withPayloadWriteAsync<T>(
|
|
68
|
+
mutate: (payload: KronosysUpdatePayload) => Promise<T>,
|
|
69
|
+
): Promise<T> {
|
|
70
|
+
const db = getSqlite();
|
|
71
|
+
db.exec("BEGIN IMMEDIATE TRANSACTION;");
|
|
72
|
+
try {
|
|
73
|
+
const payload = readPayload();
|
|
74
|
+
const out = await mutate(payload);
|
|
75
|
+
writePayload(payload);
|
|
76
|
+
db.exec("COMMIT;");
|
|
77
|
+
return out;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
db.exec("ROLLBACK;");
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
function createInitialPayload(): KronosysUpdatePayload {
|
|
64
85
|
const cfg = { ...defaultKronosysCfg() } as Record<string, unknown>;
|
|
65
86
|
if (process.env.NODE_ENV === "development" && !process.env.TRACE_DATA_DIR?.trim()) {
|
|
@@ -73,6 +94,7 @@ function createInitialPayload(): KronosysUpdatePayload {
|
|
|
73
94
|
inspectingSessionId: null,
|
|
74
95
|
knownTags: [],
|
|
75
96
|
knownProjects: [],
|
|
97
|
+
knownPersonalProjects: [],
|
|
76
98
|
userKnownTags: [],
|
|
77
99
|
excludedSuggestionTags: [],
|
|
78
100
|
tagDescriptions: {},
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
asRecord,
|
|
4
|
+
flushMainTimerSegmentOnTask,
|
|
5
|
+
flushSubtaskTimerOnTask,
|
|
6
|
+
forEachTaskRecordInSession,
|
|
7
|
+
} from "./actionTaskSession";
|
|
8
|
+
import { syncLiveIntoHistory } from "./liveHistorySync";
|
|
3
9
|
|
|
4
10
|
/** Horodatage ISO du segment de durée murale en cours (session live). */
|
|
5
11
|
export const SESSION_WALL_SEGMENT_STARTED_AT = "sessionWallSegmentStartedAt";
|
|
@@ -25,7 +31,10 @@ function liveSessionWallClockActive(cur: Record<string, unknown>): boolean {
|
|
|
25
31
|
* Consolide le segment de durée murale dans `sessionDurationMinutes` (minutes, valeur réelle)
|
|
26
32
|
* et retire l’horodatage de segment.
|
|
27
33
|
*/
|
|
28
|
-
export function flushSessionWallSegmentOnLive(
|
|
34
|
+
export function flushSessionWallSegmentOnLive(
|
|
35
|
+
cur: Record<string, unknown>,
|
|
36
|
+
wallClockEndMs: number = Date.now(),
|
|
37
|
+
): void {
|
|
29
38
|
const raw = cur[SESSION_WALL_SEGMENT_STARTED_AT];
|
|
30
39
|
if (typeof raw !== "string" || raw.trim() === "") {
|
|
31
40
|
return;
|
|
@@ -35,7 +44,7 @@ export function flushSessionWallSegmentOnLive(cur: Record<string, unknown>): voi
|
|
|
35
44
|
delete cur[SESSION_WALL_SEGMENT_STARTED_AT];
|
|
36
45
|
return;
|
|
37
46
|
}
|
|
38
|
-
const elapsed = Math.max(0,
|
|
47
|
+
const elapsed = Math.max(0, wallClockEndMs - startedMs);
|
|
39
48
|
const prevMin =
|
|
40
49
|
typeof cur.sessionDurationMinutes === "number" && Number.isFinite(cur.sessionDurationMinutes)
|
|
41
50
|
? Number(cur.sessionDurationMinutes)
|
|
@@ -44,6 +53,52 @@ export function flushSessionWallSegmentOnLive(cur: Record<string, unknown>): voi
|
|
|
44
53
|
delete cur[SESSION_WALL_SEGMENT_STARTED_AT];
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Termine la session live à l’instant donné (mur consolidé jusqu’à ce moment, tâches ouvertes figées
|
|
58
|
+
* comme avec « fin de session » en mode conserver), archive l’instantané puis vide `current`.
|
|
59
|
+
*/
|
|
60
|
+
export function finalizeLiveSessionClosedAt(
|
|
61
|
+
p: KronosysUpdatePayload,
|
|
62
|
+
wallClockEndMs: number,
|
|
63
|
+
): void {
|
|
64
|
+
const cur = asRecord(p.current);
|
|
65
|
+
if (!cur) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const sid = typeof cur.sessionId === "string" ? cur.sessionId.trim() : "";
|
|
69
|
+
if (!sid) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
forEachTaskRecordInSession(cur, (task) => {
|
|
73
|
+
if (task.isDone === true) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
flushSubtaskTimerOnTask(task);
|
|
77
|
+
flushMainTimerSegmentOnTask(task);
|
|
78
|
+
task.manualTaskTimerPaused = true;
|
|
79
|
+
});
|
|
80
|
+
flushSessionWallSegmentOnLive(cur, wallClockEndMs);
|
|
81
|
+
cur.endAt = new Date(wallClockEndMs).toISOString();
|
|
82
|
+
delete cur.scheduledEndAt;
|
|
83
|
+
delete cur.sessionEndReasonKind;
|
|
84
|
+
delete cur.sessionEndReasonNote;
|
|
85
|
+
syncLiveIntoHistory(p);
|
|
86
|
+
p.current = undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Si la session live est en pause collecte (`isPaused`), reprend le suivi mural
|
|
91
|
+
* (utilisé quand l’utilisateur reprend une tâche ou démarre du travail).
|
|
92
|
+
*/
|
|
93
|
+
export function resumeLiveSessionWallIfPaused(cur: Record<string, unknown>): boolean {
|
|
94
|
+
if (cur.isPaused !== true) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
cur.isPaused = false;
|
|
98
|
+
ensureSessionWallSegmentOnLive(cur);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
47
102
|
/** Démarre un segment de durée murale si la session est ouverte et non en pause. */
|
|
48
103
|
export function ensureSessionWallSegmentOnLive(cur: Record<string, unknown>): void {
|
|
49
104
|
if (!liveSessionWallClockActive(cur)) {
|
|
@@ -64,6 +119,14 @@ export function materializeSessionWallClockInPayload(p: KronosysUpdatePayload, n
|
|
|
64
119
|
if (!cur || !liveSessionWallClockActive(cur)) {
|
|
65
120
|
return false;
|
|
66
121
|
}
|
|
122
|
+
const scheduledRaw = typeof cur.scheduledEndAt === "string" ? cur.scheduledEndAt.trim() : "";
|
|
123
|
+
if (scheduledRaw) {
|
|
124
|
+
const scheduledMs = Date.parse(scheduledRaw);
|
|
125
|
+
if (Number.isFinite(scheduledMs) && scheduledMs <= nowMs) {
|
|
126
|
+
finalizeLiveSessionClosedAt(p, scheduledMs);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
67
130
|
const raw = cur[SESSION_WALL_SEGMENT_STARTED_AT];
|
|
68
131
|
if (typeof raw !== "string" || raw.trim() === "") {
|
|
69
132
|
cur[SESSION_WALL_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { getSqlite } from "./db";
|
|
2
|
+
|
|
3
|
+
export type UserActionLogContext = {
|
|
4
|
+
sourceIp?: string | null;
|
|
5
|
+
userAgent?: string | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type UserActionLogEntry = {
|
|
9
|
+
id: number;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
actionType: string;
|
|
12
|
+
ok: boolean;
|
|
13
|
+
sourceIp: string | null;
|
|
14
|
+
userAgent: string | null;
|
|
15
|
+
sessionId: string | null;
|
|
16
|
+
payloadJson: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type LogUserActionInput = {
|
|
20
|
+
actionType: string;
|
|
21
|
+
ok: boolean;
|
|
22
|
+
body: Record<string, unknown>;
|
|
23
|
+
context?: UserActionLogContext;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function safeTrimmedString(v: unknown): string | null {
|
|
27
|
+
if (typeof v !== "string") {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const out = v.trim();
|
|
31
|
+
return out.length > 0 ? out : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractSessionId(body: Record<string, unknown>): string | null {
|
|
35
|
+
return safeTrimmedString(body.sessionId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sanitizePayload(body: Record<string, unknown>): string | null {
|
|
39
|
+
const clone: Record<string, unknown> = { ...body };
|
|
40
|
+
const sensitiveKeys = ["token", "password", "uri"];
|
|
41
|
+
for (const key of sensitiveKeys) {
|
|
42
|
+
if (key in clone && typeof clone[key] === "string" && String(clone[key]).trim().length > 0) {
|
|
43
|
+
clone[key] = "[REDACTED]";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.stringify(clone);
|
|
48
|
+
if (!raw) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return raw.length > 4000 ? `${raw.slice(0, 4000)}…` : raw;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function logUserAction(input: LogUserActionInput): void {
|
|
58
|
+
const createdAt = new Date().toISOString();
|
|
59
|
+
const actionType = safeTrimmedString(input.actionType) ?? "unknown";
|
|
60
|
+
const sourceIp = safeTrimmedString(input.context?.sourceIp);
|
|
61
|
+
const userAgent = safeTrimmedString(input.context?.userAgent);
|
|
62
|
+
const sessionId = extractSessionId(input.body);
|
|
63
|
+
const payloadJson = sanitizePayload(input.body);
|
|
64
|
+
getSqlite()
|
|
65
|
+
.prepare(
|
|
66
|
+
`
|
|
67
|
+
INSERT INTO user_action_logs (
|
|
68
|
+
created_at,
|
|
69
|
+
action_type,
|
|
70
|
+
ok,
|
|
71
|
+
source_ip,
|
|
72
|
+
user_agent,
|
|
73
|
+
session_id,
|
|
74
|
+
payload_json
|
|
75
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
76
|
+
`
|
|
77
|
+
)
|
|
78
|
+
.run(createdAt, actionType, input.ok ? 1 : 0, sourceIp, userAgent, sessionId, payloadJson);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readUserActionLogs(limit = 100, offset = 0): UserActionLogEntry[] {
|
|
82
|
+
const safeLimit = Math.min(500, Math.max(1, Math.floor(limit)));
|
|
83
|
+
const safeOffset = Math.max(0, Math.floor(offset));
|
|
84
|
+
const rows = getSqlite()
|
|
85
|
+
.prepare(
|
|
86
|
+
`
|
|
87
|
+
SELECT
|
|
88
|
+
id,
|
|
89
|
+
created_at,
|
|
90
|
+
action_type,
|
|
91
|
+
ok,
|
|
92
|
+
source_ip,
|
|
93
|
+
user_agent,
|
|
94
|
+
session_id,
|
|
95
|
+
payload_json
|
|
96
|
+
FROM user_action_logs
|
|
97
|
+
ORDER BY id DESC
|
|
98
|
+
LIMIT ? OFFSET ?
|
|
99
|
+
`
|
|
100
|
+
)
|
|
101
|
+
.all(safeLimit, safeOffset) as Array<{
|
|
102
|
+
id: number;
|
|
103
|
+
created_at: string;
|
|
104
|
+
action_type: string;
|
|
105
|
+
ok: number;
|
|
106
|
+
source_ip: string | null;
|
|
107
|
+
user_agent: string | null;
|
|
108
|
+
session_id: string | null;
|
|
109
|
+
payload_json: string | null;
|
|
110
|
+
}>;
|
|
111
|
+
|
|
112
|
+
return rows.map((row) => ({
|
|
113
|
+
id: row.id,
|
|
114
|
+
createdAt: row.created_at,
|
|
115
|
+
actionType: row.action_type,
|
|
116
|
+
ok: row.ok === 1,
|
|
117
|
+
sourceIp: row.source_ip,
|
|
118
|
+
userAgent: row.user_agent,
|
|
119
|
+
sessionId: row.session_id,
|
|
120
|
+
payloadJson: row.payload_json,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function clearUserActionLogs(): void {
|
|
125
|
+
getSqlite().prepare("DELETE FROM user_action_logs").run();
|
|
126
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
sonar.projectKey=nightkatana_project-kronosys
|
|
2
|
+
sonar.projectName=Kronosys
|
|
3
|
+
sonar.sourceEncoding=UTF-8
|
|
4
|
+
|
|
5
|
+
sonar.sources=app,components,lib,server
|
|
6
|
+
sonar.tests=app,components,lib,server,test,e2e
|
|
7
|
+
sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,test/**/*.ts,e2e/**/*.ts
|
|
8
|
+
|
|
9
|
+
sonar.exclusions=.next/**,coverage/**,node_modules/**,playwright-report/**,test-results/**,public/**,**/*.config.*,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,next-env.d.ts
|
|
10
|
+
sonar.coverage.exclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,test/**,e2e/**,next-env.d.ts
|
|
11
|
+
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
package/tsconfig.json
CHANGED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import { Loader2, X } from "lucide-react";
|
|
5
|
-
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
6
|
-
|
|
7
|
-
export type RemoteIssue = { title?: string; number: number | string; source?: string };
|
|
8
|
-
|
|
9
|
-
const SEARCH_DEBOUNCE_MS = 350;
|
|
10
|
-
const SEARCH_MIN_CHARS = 2;
|
|
11
|
-
|
|
12
|
-
/** IID seul : le serveur utilise `iids[]` ; un seul chiffre suffit (ex. « 5 » ou « #12 »). */
|
|
13
|
-
function issueSearchMinChars(trimmed: string): number {
|
|
14
|
-
const compact = trimmed.replace(/\s+/g, "");
|
|
15
|
-
return /^#?\d{1,10}$/.test(compact) ? 1 : SEARCH_MIN_CHARS;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function IssuePickerModal({
|
|
19
|
-
t,
|
|
20
|
-
onClose,
|
|
21
|
-
onSelect,
|
|
22
|
-
fetchIssues,
|
|
23
|
-
}: {
|
|
24
|
-
t: DashboardStrings;
|
|
25
|
-
onClose: () => void;
|
|
26
|
-
onSelect: (issue: RemoteIssue) => void;
|
|
27
|
-
fetchIssues: (query: string) => Promise<{ issues: RemoteIssue[]; error?: string }>;
|
|
28
|
-
}) {
|
|
29
|
-
const [search, setSearch] = useState("");
|
|
30
|
-
const [results, setResults] = useState<RemoteIssue[]>([]);
|
|
31
|
-
const [loading, setLoading] = useState(false);
|
|
32
|
-
const [error, setError] = useState<string | undefined>();
|
|
33
|
-
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
-
const requestSeq = useRef(0);
|
|
35
|
-
|
|
36
|
-
const runFetch = useCallback(
|
|
37
|
-
async (q: string) => {
|
|
38
|
-
const seq = ++requestSeq.current;
|
|
39
|
-
setError(undefined);
|
|
40
|
-
try {
|
|
41
|
-
const out = await fetchIssues(q);
|
|
42
|
-
if (seq !== requestSeq.current) {
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
setResults(out.issues);
|
|
46
|
-
setError(out.error);
|
|
47
|
-
} catch (e) {
|
|
48
|
-
if (seq !== requestSeq.current) {
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
setResults([]);
|
|
52
|
-
setError(e instanceof Error ? e.message : String(e));
|
|
53
|
-
} finally {
|
|
54
|
-
if (seq === requestSeq.current) {
|
|
55
|
-
setLoading(false);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
[fetchIssues],
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
if (debounceRef.current) {
|
|
64
|
-
clearTimeout(debounceRef.current);
|
|
65
|
-
debounceRef.current = null;
|
|
66
|
-
}
|
|
67
|
-
const q = search.trim();
|
|
68
|
-
if (q.length < issueSearchMinChars(q)) {
|
|
69
|
-
requestSeq.current += 1;
|
|
70
|
-
setResults([]);
|
|
71
|
-
setLoading(false);
|
|
72
|
-
setError(undefined);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
setLoading(true);
|
|
76
|
-
setError(undefined);
|
|
77
|
-
debounceRef.current = setTimeout(() => {
|
|
78
|
-
debounceRef.current = null;
|
|
79
|
-
void runFetch(q);
|
|
80
|
-
}, SEARCH_DEBOUNCE_MS);
|
|
81
|
-
return () => {
|
|
82
|
-
if (debounceRef.current) {
|
|
83
|
-
clearTimeout(debounceRef.current);
|
|
84
|
-
debounceRef.current = null;
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
}, [search, runFetch]);
|
|
88
|
-
|
|
89
|
-
const q = search.trim();
|
|
90
|
-
const ready = q.length >= issueSearchMinChars(q);
|
|
91
|
-
|
|
92
|
-
return (
|
|
93
|
-
<div
|
|
94
|
-
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
|
95
|
-
role="dialog"
|
|
96
|
-
aria-modal="true"
|
|
97
|
-
aria-labelledby="issue-picker-title"
|
|
98
|
-
onClick={onClose}
|
|
99
|
-
>
|
|
100
|
-
<div
|
|
101
|
-
className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
|
102
|
-
onClick={(e) => e.stopPropagation()}
|
|
103
|
-
>
|
|
104
|
-
<div className="flex shrink-0 items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
|
105
|
-
<h3 id="issue-picker-title" className="font-semibold text-zinc-900 dark:text-zinc-100">
|
|
106
|
-
{t.selectIssue}
|
|
107
|
-
</h3>
|
|
108
|
-
<button
|
|
109
|
-
type="button"
|
|
110
|
-
className="rounded p-1 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
|
111
|
-
aria-label={t.issuePickerCloseAria}
|
|
112
|
-
onClick={onClose}
|
|
113
|
-
>
|
|
114
|
-
<X size={20} aria-hidden />
|
|
115
|
-
</button>
|
|
116
|
-
</div>
|
|
117
|
-
<div className="shrink-0 border-b border-zinc-200 dark:border-zinc-800">
|
|
118
|
-
<input
|
|
119
|
-
className="w-full bg-white px-4 py-2.5 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-2 focus:ring-violet-500/40 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
|
120
|
-
placeholder={t.issuePickerSearchPlaceholder}
|
|
121
|
-
value={search}
|
|
122
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
123
|
-
autoFocus
|
|
124
|
-
aria-label={t.issuePickerSearchPlaceholder}
|
|
125
|
-
/>
|
|
126
|
-
</div>
|
|
127
|
-
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
128
|
-
{loading ? (
|
|
129
|
-
<div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-zinc-600 dark:text-zinc-400">
|
|
130
|
-
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
|
131
|
-
<span>{t.issuePickerLoading}</span>
|
|
132
|
-
</div>
|
|
133
|
-
) : error ? (
|
|
134
|
-
<div className="px-4 py-6 text-center text-sm text-red-600 dark:text-red-400">{error}</div>
|
|
135
|
-
) : !ready ? (
|
|
136
|
-
<div className="px-4 py-6 text-center text-sm text-zinc-600 dark:text-zinc-500">
|
|
137
|
-
{t.issuePickerSearchMinHint}
|
|
138
|
-
</div>
|
|
139
|
-
) : results.length === 0 ? (
|
|
140
|
-
<div className="px-4 py-6 text-center text-sm text-zinc-600 dark:text-zinc-500">
|
|
141
|
-
{t.issuePickerNoResults}
|
|
142
|
-
</div>
|
|
143
|
-
) : (
|
|
144
|
-
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
|
145
|
-
{results.map((issue, idx) => (
|
|
146
|
-
<li key={`${String(issue.source)}-${String(issue.number)}-${idx}`}>
|
|
147
|
-
<button
|
|
148
|
-
type="button"
|
|
149
|
-
className="w-full px-4 py-3 text-left text-sm hover:bg-zinc-100/90 dark:hover:bg-zinc-800/60"
|
|
150
|
-
onClick={() => onSelect(issue)}
|
|
151
|
-
>
|
|
152
|
-
<div className="font-medium text-zinc-900 dark:text-zinc-200">{issue.title}</div>
|
|
153
|
-
<div className="mt-1 flex flex-wrap gap-2 text-xs text-zinc-600 dark:text-zinc-500">
|
|
154
|
-
{issue.source ? (
|
|
155
|
-
<span className="rounded bg-zinc-200 px-1.5 py-0.5 dark:bg-zinc-800">{issue.source}</span>
|
|
156
|
-
) : null}
|
|
157
|
-
<span>#{issue.number}</span>
|
|
158
|
-
</div>
|
|
159
|
-
</button>
|
|
160
|
-
</li>
|
|
161
|
-
))}
|
|
162
|
-
</ul>
|
|
163
|
-
)}
|
|
164
|
-
</div>
|
|
165
|
-
</div>
|
|
166
|
-
</div>
|
|
167
|
-
);
|
|
168
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { ThemeProvider } from "@/components/ThemeProvider";
|
|
5
|
-
import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
|
|
6
|
-
|
|
7
|
-
describe("ThemeToggle", () => {
|
|
8
|
-
it("bascule le thème au clic", async () => {
|
|
9
|
-
const user = userEvent.setup();
|
|
10
|
-
render(
|
|
11
|
-
<ThemeProvider>
|
|
12
|
-
<ThemeToggle lang="fr" />
|
|
13
|
-
</ThemeProvider>
|
|
14
|
-
);
|
|
15
|
-
const btn = screen.getByRole("button");
|
|
16
|
-
await waitFor(() => {
|
|
17
|
-
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
|
18
|
-
});
|
|
19
|
-
const before = btn.getAttribute("aria-label");
|
|
20
|
-
await user.click(btn);
|
|
21
|
-
const after = btn.getAttribute("aria-label");
|
|
22
|
-
expect(before).toBeTruthy();
|
|
23
|
-
expect(after).toBeTruthy();
|
|
24
|
-
expect(before).not.toBe(after);
|
|
25
|
-
});
|
|
26
|
-
});
|