@nightkatana/kronosys-app 1.0.0-beta.17 → 1.0.0-beta.19

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 (101) 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 +18 -24
  6. package/app/changelog/page.tsx +2 -0
  7. package/app/globals.css +33 -4
  8. package/app/guide/page.tsx +2 -0
  9. package/app/implementation/page.tsx +450 -78
  10. package/app/layout.tsx +4 -1
  11. package/app/licenses/page.tsx +2 -0
  12. package/app/logs/page.tsx +258 -0
  13. package/app/manifest.ts +5 -5
  14. package/app/page.tsx +103 -5
  15. package/app/reporting/page.tsx +298 -376
  16. package/app/settings/page.tsx +2 -0
  17. package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
  18. package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
  19. package/components/dashboard/AppShellRouteNav.tsx +94 -8
  20. package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
  21. package/components/dashboard/DashboardTour.tsx +115 -29
  22. package/components/dashboard/KronosysDatetimePopoverField.tsx +42 -6
  23. package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
  24. package/components/dashboard/NewSessionScopeModal.tsx +198 -20
  25. package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
  26. package/components/dashboard/ReportingTour.tsx +87 -21
  27. package/components/dashboard/SavedProjectPicker.tsx +16 -3
  28. package/components/dashboard/SelectedSessionSidebarBlock.tsx +84 -6
  29. package/components/dashboard/SessionListPanel.tsx +21 -2
  30. package/components/dashboard/SettingsTaskTemplatesSection.tsx +2 -0
  31. package/components/dashboard/SettingsTour.tsx +86 -21
  32. package/components/dashboard/TagPills.tsx +14 -1
  33. package/components/dashboard/TaskFocusPanel.tsx +206 -26
  34. package/components/dashboard/TaskSessionLiveCard.tsx +238 -16
  35. package/components/dashboard/TaskTimelineGanttModal.tsx +47 -3
  36. package/components/dashboard/taskFieldStyles.ts +17 -2
  37. package/components/dashboard/useReportingInteractionState.ts +80 -0
  38. package/lib/businessRulesMatrix.ts +210 -0
  39. package/lib/copyToClipboard.ts +43 -0
  40. package/lib/dashboardCopy.ts +148 -8
  41. package/lib/dashboardTimeZone.ts +109 -0
  42. package/lib/formatAppShellWallClock.ts +66 -0
  43. package/lib/generatedUserChangelog.ts +40 -0
  44. package/lib/implementationNotes.ts +510 -31
  45. package/lib/kronosysApi.ts +6 -0
  46. package/lib/kronosysDashboardModalGates.ts +24 -0
  47. package/lib/plannedBoundaryAttention.ts +9 -0
  48. package/lib/plannedBoundaryConflict.ts +23 -0
  49. package/lib/reportingAggregate.ts +450 -81
  50. package/lib/reportingStrings.ts +9 -0
  51. package/lib/sessionListMerge.ts +4 -0
  52. package/lib/sessionTaskSidebarStats.ts +59 -13
  53. package/lib/settingsCopy.ts +4 -4
  54. package/lib/taskParsing.ts +332 -103
  55. package/lib/taskTemplateDraft.ts +18 -4
  56. package/lib/taskTimelineGantt.ts +103 -3
  57. package/lib/temporalDisplayPlanned.ts +71 -0
  58. package/lib/userGuideCopy.ts +75 -31
  59. package/next-env.d.ts +1 -1
  60. package/package.json +7 -20
  61. package/server/actionDispatch.ts +513 -93
  62. package/server/actionTaskSession.ts +134 -34
  63. package/server/db.ts +7 -15
  64. package/server/dbSchema.ts +24 -0
  65. package/server/gitlabTokenStore.ts +0 -12
  66. package/server/liveHistorySync.ts +53 -0
  67. package/server/mainTimerHydrate.ts +37 -16
  68. package/server/payloadStore.ts +33 -11
  69. package/server/sessionWallHydrate.ts +46 -6
  70. package/server/userActionLog.ts +126 -0
  71. package/sonar-project.properties +11 -0
  72. package/components/dashboard/TaskNotesDisplay.test.tsx +0 -110
  73. package/components/dashboard/ThemeToggle.test.tsx +0 -26
  74. package/lib/backupCsvExport.test.ts +0 -149
  75. package/lib/dashboardQuickSearchQuery.test.ts +0 -63
  76. package/lib/dataDir.test.ts +0 -87
  77. package/lib/formatIsoShort.test.ts +0 -46
  78. package/lib/formatSessionNameTemplate.test.ts +0 -53
  79. package/lib/globalPausePreview.test.ts +0 -170
  80. package/lib/kronoFocusRhythm.test.ts +0 -130
  81. package/lib/kronoFocusTimerUrgency.test.ts +0 -74
  82. package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
  83. package/lib/reportingAggregate.test.ts +0 -352
  84. package/lib/reportingNonFinalIndicators.test.ts +0 -157
  85. package/lib/reportingTagWeekBreakdown.test.ts +0 -142
  86. package/lib/reportingWeekLayout.test.ts +0 -239
  87. package/lib/sessionAssiduity.test.ts +0 -25
  88. package/lib/sessionEndWarnings.test.ts +0 -200
  89. package/lib/sessionListMerge.test.ts +0 -101
  90. package/lib/sessionTaskSidebarStats.test.ts +0 -50
  91. package/lib/taskParsing.test.ts +0 -153
  92. package/lib/taskTemplateDraft.test.ts +0 -52
  93. package/lib/taskTimelineGantt.test.ts +0 -50
  94. package/lib/usageProfile.test.ts +0 -84
  95. package/server/actionDispatch.test.ts +0 -1036
  96. package/server/actionTaskSession.test.ts +0 -723
  97. package/server/kronoFocusHydrate.test.ts +0 -142
  98. package/server/kronoFocusMigrate.test.ts +0 -53
  99. package/server/mainTimerHydrate.test.ts +0 -93
  100. package/server/payloadStore.test.ts +0 -78
  101. package/server/sessionWallHydrate.test.ts +0 -64
package/README.md CHANGED
@@ -12,11 +12,38 @@ Stack : **Next 16** (App Router), **React 19**, **Tailwind 4**, **better-sqlite3
12
12
  | `npm run build` / `npm run start` | Production |
13
13
  | `npm run test` | Vitest |
14
14
  | `npm run test:coverage` | Vitest avec couverture LCOV pour SonarQube Cloud |
15
+ | `npm run test:e2e` | Playwright — voir **Tests E2E** ci‑dessous ; démarre Next sur **5555** si besoin (`playwright.config.mjs`) |
16
+ | `npm run test:e2e:install` | Télécharge Chromium, Chrome Headless Shell et ffmpeg pour la version installée de Playwright |
17
+ | `npm run test:e2e:podman` | E2E dans une image Ubuntu officielle Playwright via Podman (voir ci‑dessous) |
15
18
  | `npm run hooks:install` | Installe Husky et active les hooks Git du dépôt |
16
19
  | `npm run release:dry` | Simule bump semver + `CHANGELOG.md` + tag (aucune écriture) |
17
20
  | `npm run release` | Bump `package.json` / `package-lock.json`, met à jour le journal, commit, tag `v…` |
18
21
  | `npm run release:first` | Une fois : journal complet pour la version courante **sans** bump (puis tag `v…`) |
19
22
 
23
+ ### Tests E2E (Playwright)
24
+
25
+ Après **`npm install`** ou une mise à jour de **`@playwright/test`**, exécuter une fois :
26
+
27
+ ```bash
28
+ npm run test:e2e:install
29
+ ```
30
+
31
+ Sans cela, les tests échouent souvent avec **`Executable doesn't exist`** ou **`browserType.launch`** : Playwright doit télécharger **Chromium**, **Chrome Headless Shell** (utilisé pour le projet « chromium ») et **ffmpeg**.
32
+
33
+ Sous **Fedora**, ne pas utiliser `npx playwright install-deps` (script prévu pour Debian/Ubuntu). Si la validation « host » bloque le téléchargement :
34
+ `PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1 npm run test:e2e:install`.
35
+
36
+ Sous **Fedora** (ou si les binaires Playwright hôtent sur un fallback Ubuntu pénible), on peut lancer les E2E dans la **même image** que le pipeline GitLab (Ubuntu Noble, navigateurs inclus) avec **Podman** :
37
+
38
+ ```bash
39
+ npm run test:e2e:podman
40
+ ```
41
+
42
+ (équivalent : `bash scripts/e2e-podman.sh` — exécute `npm ci` puis `npm run test:e2e` *dans* le conteneur : ne pas réutiliser le `node_modules` de l’hôte).
43
+ Image par défaut : `mcr.microsoft.com/playwright:v1.59.1-noble` (voir `PLAYWRIGHT_IMAGE=…` pour surcharger). Le montage utilise le suffixe **`:Z`** pour SELinux.
44
+
45
+ En cas de conflit avec un `.next` déjà présent sur l’hôte (autre `next dev`), le script utilise un **volume Podman** pour `/work/.next` (`kronosys-e2e-dot-next` par défaut, surcharge avec `PODMAN_E2E_NEXT_VOLUME`). Pour repartir à zéro : `podman volume rm kronosys-e2e-dot-next`.
46
+
20
47
  Les messages de commit doivent suivre les [Conventional Commits](https://www.conventionalcommits.org/) (`feat`, `fix`, etc.) pour que le journal regroupe correctement **nouveautés**, **corrections** et autres rubriques. Après `npm run release`, pousser avec `git push --follow-tags`. Si le dépôt n’a pas encore de tag pour la dernière entrée du `CHANGELOG.md`, créez `git tag vX.Y.Z <commit>` avant le prochain `release`, sinon l’historique serait dupliqué (voir l’en-tête du journal). Sur **GitHub**, le workflow **Release** crée une **GitHub Release** à chaque tag `v*.*.*`. Sur **GitLab**, créez une release à partir du même tag (interface ou `glab release create`) en réutilisant la section extraite : `bash scripts/extract-changelog-for-tag.sh CHANGELOG.md X.Y.Z`.
21
48
 
22
49
  Depuis la **racine** du monorepo : `npm run dev` délègue ici.
@@ -72,7 +99,7 @@ Sinon : `NODE_EXTRA_CA_CERTS` pointant vers le PEM racine de l’organisation, o
72
99
 
73
100
  ## Publication npm (`@nightkatana/kronosys-app`)
74
101
 
75
- La version est définie dans `package.json` (actuellement **0.1.0**). Avant la première publication : créer le scope **@nightkatana** sur [npmjs.com](https://www.npmjs.com/) si besoin, puis `npm login`. Depuis ce dossier :
102
+ La version est définie dans `package.json` (référence actuelle : **`1.0.0-beta.19`**). Avant la première publication : créer le scope **@nightkatana** sur [npmjs.com](https://www.npmjs.com/) si besoin, puis `npm login`. Depuis ce dossier :
76
103
 
77
104
  ```bash
78
105
  npm publish --access public
@@ -1,16 +1,52 @@
1
1
  import { NextResponse } from "next/server";
2
2
 
3
3
  import { dispatchKronosysAction } from "@/server/actionDispatch";
4
+ import { clearUserActionLogs, logUserAction } from "@/server/userActionLog";
4
5
 
5
6
  export const runtime = "nodejs";
6
7
 
8
+ function badRequest(error: string, status = 400) {
9
+ return NextResponse.json({ ok: false, error }, { status });
10
+ }
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+
7
16
  export async function POST(req: Request) {
8
- let body: Record<string, unknown> = {};
17
+ let parsedBody: unknown;
9
18
  try {
10
- body = (await req.json()) as Record<string, unknown>;
19
+ parsedBody = await req.json();
11
20
  } catch {
12
- return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
21
+ return badRequest("invalid_json");
22
+ }
23
+ if (!isRecord(parsedBody)) {
24
+ return badRequest("invalid_action_request");
13
25
  }
26
+ const actionType = typeof parsedBody.type === "string" ? parsedBody.type.trim() : "";
27
+ if (!actionType) {
28
+ return badRequest("missing_action_type");
29
+ }
30
+ const body = parsedBody;
14
31
  const out = await dispatchKronosysAction(body);
32
+ try {
33
+ if (actionType === "clearHistory") {
34
+ clearUserActionLogs();
35
+ return NextResponse.json({ ok: out.ok, result: out.result ?? {} });
36
+ }
37
+ const sourceIp =
38
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
39
+ req.headers.get("x-real-ip") ||
40
+ null;
41
+ const userAgent = req.headers.get("user-agent");
42
+ logUserAction({
43
+ actionType,
44
+ ok: out.ok,
45
+ body,
46
+ context: { sourceIp, userAgent },
47
+ });
48
+ } catch {
49
+ /* ignore logging failure */
50
+ }
15
51
  return NextResponse.json({ ok: out.ok, result: out.result ?? {} });
16
52
  }
@@ -0,0 +1,24 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ import { readUserActionLogs } from "@/server/userActionLog";
4
+
5
+ export const runtime = "nodejs";
6
+
7
+ function parsePositiveInt(raw: string | null, fallback: number): number {
8
+ if (!raw) {
9
+ return fallback;
10
+ }
11
+ const n = Number.parseInt(raw, 10);
12
+ if (!Number.isFinite(n)) {
13
+ return fallback;
14
+ }
15
+ return n;
16
+ }
17
+
18
+ export function GET(req: Request) {
19
+ const url = new URL(req.url);
20
+ const limit = parsePositiveInt(url.searchParams.get("limit"), 100);
21
+ const offset = parsePositiveInt(url.searchParams.get("offset"), 0);
22
+ const logs = readUserActionLogs(limit, offset);
23
+ return NextResponse.json({ ok: true, logs });
24
+ }
@@ -27,7 +27,7 @@ export function GET(req: Request) {
27
27
 
28
28
  if (format === "sqlite") {
29
29
  try {
30
- getSqlite().pragma("wal_checkpoint(TRUNCATE)");
30
+ getSqlite().exec("PRAGMA wal_checkpoint(TRUNCATE);");
31
31
  } catch {
32
32
  /* ignore */
33
33
  }
@@ -12,6 +12,10 @@ export const runtime = "nodejs";
12
12
  const DB_NAME = "kronosys.sqlite";
13
13
  const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
14
14
 
15
+ function badRequest(error: string, extra?: Record<string, unknown>, status = 400) {
16
+ return NextResponse.json({ ok: false, error, ...(extra ?? {}) }, { status });
17
+ }
18
+
15
19
  function nowStamp(): string {
16
20
  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replaceAll(":", "-");
17
21
  }
@@ -58,7 +62,7 @@ function restoreFromSqliteBuffer(buf: Buffer): { backupPath: string | null } {
58
62
 
59
63
  try {
60
64
  const db = getSqlite();
61
- db.pragma("integrity_check");
65
+ db.exec("PRAGMA integrity_check;");
62
66
  db.exec("SELECT 1;");
63
67
  } catch (error) {
64
68
  resetSqliteConnection();
@@ -75,25 +79,24 @@ export async function POST(req: Request) {
75
79
  const ctype = req.headers.get("content-type") ?? "";
76
80
  const isMultipart = ctype.toLowerCase().includes("multipart/form-data");
77
81
  if (!isMultipart) {
78
- return NextResponse.json(
79
- { ok: false, error: "unsupported_content_type" },
80
- { status: 415 },
81
- );
82
+ return badRequest("unsupported_content_type", undefined, 415);
82
83
  }
83
84
 
84
- const form = await req.formData();
85
+ let form: FormData;
86
+ try {
87
+ form = await req.formData();
88
+ } catch {
89
+ return badRequest("invalid_form_data");
90
+ }
85
91
  const file = form.get("file");
86
92
  const formatField = form.get("format");
87
93
  const formatRaw =
88
94
  typeof formatField === "string" ? formatField.trim().toLowerCase() : "";
89
95
  if (!(file instanceof File)) {
90
- return NextResponse.json({ ok: false, error: "file_required" }, { status: 400 });
96
+ return badRequest("file_required");
91
97
  }
92
98
  if (file.size <= 0 || file.size > MAX_UPLOAD_BYTES) {
93
- return NextResponse.json(
94
- { ok: false, error: "invalid_file_size", maxBytes: MAX_UPLOAD_BYTES },
95
- { status: 400 },
96
- );
99
+ return badRequest("invalid_file_size", { maxBytes: MAX_UPLOAD_BYTES });
97
100
  }
98
101
 
99
102
  const lowerName = file.name.toLowerCase();
@@ -110,13 +113,10 @@ export async function POST(req: Request) {
110
113
  try {
111
114
  parsed = JSON.parse(text);
112
115
  } catch {
113
- return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
116
+ return badRequest("invalid_json");
114
117
  }
115
118
  if (!validatePayloadShape(parsed)) {
116
- return NextResponse.json(
117
- { ok: false, error: "invalid_kronosys_payload" },
118
- { status: 400 },
119
- );
119
+ return badRequest("invalid_kronosys_payload");
120
120
  }
121
121
  writePayload(parsed);
122
122
  return NextResponse.json({ ok: true, restored: "json" });
@@ -125,17 +125,11 @@ export async function POST(req: Request) {
125
125
  const bytes = await file.arrayBuffer();
126
126
  const buf = Buffer.from(bytes);
127
127
  if (buf.length < 1024) {
128
- return NextResponse.json(
129
- { ok: false, error: "sqlite_too_small" },
130
- { status: 400 },
131
- );
128
+ return badRequest("sqlite_too_small");
132
129
  }
133
130
  const header = buf.subarray(0, 16).toString("utf8");
134
131
  if (!header.startsWith("SQLite format 3")) {
135
- return NextResponse.json(
136
- { ok: false, error: "invalid_sqlite_file" },
137
- { status: 400 },
138
- );
132
+ return badRequest("invalid_sqlite_file");
139
133
  }
140
134
 
141
135
  try {
@@ -5,6 +5,7 @@ import Link from "next/link";
5
5
  import { useSearchParams } from "next/navigation";
6
6
  import ReactMarkdown, { type Components } from "react-markdown";
7
7
  import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
8
+ import { AppShellHeaderWallClock } from "@/components/dashboard/AppShellHeaderWallClock";
8
9
  import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
9
10
  import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
10
11
  import { ScrollToTopFab } from "@/components/dashboard/ScrollToTopFab";
@@ -105,6 +106,7 @@ function ChangelogBody() {
105
106
  </p>
106
107
  </div>
107
108
  <div className="flex flex-wrap items-center gap-1.5">
109
+ <AppShellHeaderWallClock lang={lang} dt={dt} />
108
110
  <ThemeToggle lang={lang} />
109
111
  <PageRefreshButton
110
112
  title={dt.pageRefreshTitle}
package/app/globals.css CHANGED
@@ -51,17 +51,15 @@ body {
51
51
  @keyframes kronosys-route-transition-in-kf {
52
52
  from {
53
53
  opacity: 0;
54
- transform: translateY(6px);
55
54
  }
56
55
  to {
57
56
  opacity: 1;
58
- transform: translateY(0);
59
57
  }
60
58
  }
61
59
 
62
60
  .kronosys-route-transition-in {
63
61
  animation: kronosys-route-transition-in-kf 170ms ease-out both;
64
- will-change: opacity, transform;
62
+ will-change: opacity;
65
63
  }
66
64
 
67
65
  /* Minuteur focus : clignotement sous 5 min (travail / longue pause), accent violet sous 30 s */
@@ -99,6 +97,32 @@ html.dark .kronosys-session-duration-alert {
99
97
  color: rgb(248 113 113);
100
98
  }
101
99
 
100
+ /* Fin planifiée imminente (sessions/tâches): clignotement progressif avant échéance. */
101
+ @keyframes kronosys-end-time-alert-blink-kf {
102
+ 0%,
103
+ 100% {
104
+ opacity: 1;
105
+ }
106
+ 50% {
107
+ opacity: 0.42;
108
+ }
109
+ }
110
+
111
+ .kronosys-end-time-alert-slow {
112
+ color: rgb(220 38 38);
113
+ animation: kronosys-end-time-alert-blink-kf 2.2s ease-in-out infinite;
114
+ }
115
+
116
+ .kronosys-end-time-alert-fast {
117
+ color: rgb(220 38 38);
118
+ animation: kronosys-end-time-alert-blink-kf 0.95s ease-in-out infinite;
119
+ }
120
+
121
+ html.dark .kronosys-end-time-alert-slow,
122
+ html.dark .kronosys-end-time-alert-fast {
123
+ color: rgb(248 113 113);
124
+ }
125
+
102
126
  /* Saisie tâche « passé » : exactement deux assombrissements (pas de 3e pic dû au easing) */
103
127
  @keyframes kronosys-past-datetime-blink-twice-kf {
104
128
  0%,
@@ -271,7 +295,6 @@ html.dark .kronosys-datetime-popover select option {
271
295
 
272
296
  .kronosys-route-transition-in {
273
297
  animation: none;
274
- transform: none;
275
298
  }
276
299
 
277
300
  .kronosys-krono-focus-time-blink {
@@ -285,6 +308,12 @@ html.dark .kronosys-datetime-popover select option {
285
308
  transform: none;
286
309
  }
287
310
 
311
+ .kronosys-end-time-alert-slow,
312
+ .kronosys-end-time-alert-fast {
313
+ animation: none;
314
+ opacity: 1;
315
+ }
316
+
288
317
  .kronosys-past-datetime-blink-twice {
289
318
  animation: none;
290
319
  opacity: 1;
@@ -14,6 +14,7 @@ import {
14
14
  } from "@/lib/appShellHeaderClasses";
15
15
  import { AppShellCommandCenterPlaceholder } from "@/components/dashboard/AppShellCommandCenterPlaceholder";
16
16
  import { AppShellHeaderSessionMeta } from "@/components/dashboard/AppShellHeaderSessionMeta";
17
+ import { AppShellHeaderWallClock } from "@/components/dashboard/AppShellHeaderWallClock";
17
18
  import { AppShellRouteNav } from "@/components/dashboard/AppShellRouteNav";
18
19
  import { UserGuideBodyText } from "@/components/dashboard/UserGuideBodyText";
19
20
  import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
@@ -121,6 +122,7 @@ function GuideContent() {
121
122
  </div>
122
123
  <div className="flex w-full justify-end">
123
124
  <div className={appShellHeaderToolbarClassName}>
125
+ <AppShellHeaderWallClock lang={lang} dt={dt} />
124
126
  <AppShellCommandCenterPlaceholder />
125
127
  <AppShellRouteNav
126
128
  current="guide"