@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.
Files changed (112) hide show
  1. package/README.md +28 -1
  2. package/app/api/action/route.ts +39 -3
  3. package/app/api/action-logs/route.ts +24 -0
  4. package/app/api/backup/route.ts +1 -1
  5. package/app/api/restore/route.ts +145 -0
  6. package/app/changelog/page.tsx +71 -4
  7. package/app/globals.css +127 -0
  8. package/app/guide/page.tsx +61 -15
  9. package/app/implementation/page.tsx +700 -0
  10. package/app/layout.tsx +14 -3
  11. package/app/licenses/page.tsx +99 -37
  12. package/app/logs/page.tsx +258 -0
  13. package/app/manifest.ts +5 -5
  14. package/app/page.tsx +784 -229
  15. package/app/reporting/page.tsx +1266 -474
  16. package/app/settings/page.tsx +252 -18
  17. package/bin/kronosys.mjs +140 -15
  18. package/components/KronosysPayloadProvider.tsx +2 -0
  19. package/components/RouteTransition.tsx +18 -0
  20. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
  21. package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
  22. package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
  23. package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
  24. package/components/dashboard/AppShellRouteNav.tsx +323 -48
  25. package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
  26. package/components/dashboard/DashboardSimpleModal.tsx +168 -25
  27. package/components/dashboard/DashboardTour.tsx +115 -29
  28. package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
  29. package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
  30. package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
  31. package/components/dashboard/NewSessionScopeModal.tsx +211 -20
  32. package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
  33. package/components/dashboard/ReportingTour.tsx +87 -21
  34. package/components/dashboard/SavedProjectPicker.tsx +16 -3
  35. package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
  36. package/components/dashboard/SessionListPanel.tsx +327 -44
  37. package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
  38. package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
  39. package/components/dashboard/SettingsTour.tsx +86 -21
  40. package/components/dashboard/TagPills.tsx +14 -1
  41. package/components/dashboard/TaskFocusPanel.tsx +1081 -478
  42. package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
  43. package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
  44. package/components/dashboard/taskFieldStyles.ts +20 -4
  45. package/components/dashboard/useReportingInteractionState.ts +80 -0
  46. package/lib/appShellHeaderClasses.ts +13 -0
  47. package/lib/businessRulesMatrix.ts +210 -0
  48. package/lib/copyToClipboard.ts +43 -0
  49. package/lib/dashboardCopy.ts +494 -84
  50. package/lib/dashboardQuickSearch.ts +54 -2
  51. package/lib/dashboardTimeZone.ts +109 -0
  52. package/lib/formatAppShellWallClock.ts +66 -0
  53. package/lib/formatSessionNameTemplate.ts +141 -0
  54. package/lib/generatedUserChangelog.ts +177 -6
  55. package/lib/globalPausePreview.ts +292 -0
  56. package/lib/implementationNotes.ts +1188 -0
  57. package/lib/kronosysApi.ts +6 -0
  58. package/lib/kronosysDashboardModalGates.ts +24 -0
  59. package/lib/plannedBoundaryAttention.ts +9 -0
  60. package/lib/plannedBoundaryConflict.ts +23 -0
  61. package/lib/reportingAggregate.ts +517 -75
  62. package/lib/reportingMetricHelp.ts +8 -0
  63. package/lib/reportingStrings.ts +37 -3
  64. package/lib/sessionListMerge.ts +4 -0
  65. package/lib/sessionTaskSidebarStats.ts +182 -21
  66. package/lib/settingsCopy.ts +178 -4
  67. package/lib/taskParsing.ts +360 -103
  68. package/lib/taskTemplateDraft.ts +135 -0
  69. package/lib/taskTimelineGantt.ts +265 -0
  70. package/lib/temporalDisplayPlanned.ts +71 -0
  71. package/lib/userGuideCopy.ts +121 -47
  72. package/next.config.ts +7 -0
  73. package/package.json +12 -24
  74. package/server/actionDispatch.ts +1000 -77
  75. package/server/actionTaskSession.ts +337 -24
  76. package/server/db.ts +7 -15
  77. package/server/dbSchema.ts +24 -0
  78. package/server/defaultCfg.ts +5 -0
  79. package/server/gitlabTokenStore.ts +0 -12
  80. package/server/liveHistorySync.ts +53 -0
  81. package/server/mainTimerHydrate.ts +38 -2
  82. package/server/payloadStore.ts +33 -11
  83. package/server/sessionWallHydrate.ts +66 -3
  84. package/server/userActionLog.ts +126 -0
  85. package/sonar-project.properties +11 -0
  86. package/tsconfig.json +2 -1
  87. package/components/dashboard/IssuePickerModal.tsx +0 -168
  88. package/components/dashboard/ThemeToggle.test.tsx +0 -26
  89. package/lib/backupCsvExport.test.ts +0 -149
  90. package/lib/dashboardQuickSearchQuery.test.ts +0 -63
  91. package/lib/dataDir.test.ts +0 -87
  92. package/lib/formatIsoShort.test.ts +0 -46
  93. package/lib/kronoFocusRhythm.test.ts +0 -130
  94. package/lib/kronoFocusTimerUrgency.test.ts +0 -74
  95. package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
  96. package/lib/reportingAggregate.test.ts +0 -325
  97. package/lib/reportingNonFinalIndicators.test.ts +0 -157
  98. package/lib/reportingTagWeekBreakdown.test.ts +0 -141
  99. package/lib/reportingWeekLayout.test.ts +0 -239
  100. package/lib/sessionAssiduity.test.ts +0 -25
  101. package/lib/sessionEndWarnings.test.ts +0 -200
  102. package/lib/sessionListMerge.test.ts +0 -101
  103. package/lib/sessionTaskSidebarStats.test.ts +0 -24
  104. package/lib/taskParsing.test.ts +0 -153
  105. package/lib/usageProfile.test.ts +0 -84
  106. package/server/actionDispatch.test.ts +0 -723
  107. package/server/actionTaskSession.test.ts +0 -713
  108. package/server/kronoFocusHydrate.test.ts +0 -142
  109. package/server/kronoFocusMigrate.test.ts +0 -53
  110. package/server/mainTimerHydrate.test.ts +0 -65
  111. package/server/payloadStore.test.ts +0 -78
  112. 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
- task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
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
- task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
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
  });
@@ -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 { asRecord } from "./actionTaskSession";
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(cur: Record<string, unknown>): void {
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, Date.now() - startedMs);
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
@@ -17,6 +17,7 @@
17
17
  "isolatedModules": true,
18
18
  "jsx": "react-jsx",
19
19
  "incremental": true,
20
+ "baseUrl": ".",
20
21
  "plugins": [
21
22
  {
22
23
  "name": "next"
@@ -38,4 +39,4 @@
38
39
  "exclude": [
39
40
  "node_modules"
40
41
  ]
41
- }
42
+ }
@@ -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
- });