@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
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.21`**). 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
  }
@@ -0,0 +1,145 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { NextResponse } from "next/server";
4
+
5
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
6
+ import { ensureDataDirectory } from "@/lib/dataDir";
7
+ import { getSqlite, resetSqliteConnection } from "@/server/db";
8
+ import { writePayload } from "@/server/payloadStore";
9
+
10
+ export const runtime = "nodejs";
11
+
12
+ const DB_NAME = "kronosys.sqlite";
13
+ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
14
+
15
+ function badRequest(error: string, extra?: Record<string, unknown>, status = 400) {
16
+ return NextResponse.json({ ok: false, error, ...(extra ?? {}) }, { status });
17
+ }
18
+
19
+ function nowStamp(): string {
20
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replaceAll(":", "-");
21
+ }
22
+
23
+ function isRecord(v: unknown): v is Record<string, unknown> {
24
+ return typeof v === "object" && v !== null && !Array.isArray(v);
25
+ }
26
+
27
+ function validatePayloadShape(v: unknown): v is KronosysUpdatePayload {
28
+ if (!isRecord(v)) {
29
+ return false;
30
+ }
31
+ return typeof v.viewType === "string" && v.viewType.trim().length > 0;
32
+ }
33
+
34
+ function cleanSqliteSidecars(dbPath: string): void {
35
+ for (const ext of [".wal", ".shm"]) {
36
+ const p = `${dbPath}${ext}`;
37
+ try {
38
+ if (fs.existsSync(p)) {
39
+ fs.unlinkSync(p);
40
+ }
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ }
45
+ }
46
+
47
+ function restoreFromSqliteBuffer(buf: Buffer): { backupPath: string | null } {
48
+ const dir = ensureDataDirectory();
49
+ const dbPath = path.join(dir, DB_NAME);
50
+ const tmpPath = `${dbPath}.restore-tmp`;
51
+ const backupPath = `${dbPath}.pre-restore-${nowStamp()}.bak`;
52
+ let createdBackup: string | null = null;
53
+
54
+ resetSqliteConnection();
55
+ if (fs.existsSync(dbPath)) {
56
+ fs.copyFileSync(dbPath, backupPath);
57
+ createdBackup = backupPath;
58
+ }
59
+ cleanSqliteSidecars(dbPath);
60
+ fs.writeFileSync(tmpPath, buf);
61
+ fs.renameSync(tmpPath, dbPath);
62
+
63
+ try {
64
+ const db = getSqlite();
65
+ db.exec("PRAGMA integrity_check;");
66
+ db.exec("SELECT 1;");
67
+ } catch (error) {
68
+ resetSqliteConnection();
69
+ if (createdBackup && fs.existsSync(createdBackup)) {
70
+ fs.copyFileSync(createdBackup, dbPath);
71
+ }
72
+ throw error;
73
+ }
74
+
75
+ return { backupPath: createdBackup };
76
+ }
77
+
78
+ export async function POST(req: Request) {
79
+ const ctype = req.headers.get("content-type") ?? "";
80
+ const isMultipart = ctype.toLowerCase().includes("multipart/form-data");
81
+ if (!isMultipart) {
82
+ return badRequest("unsupported_content_type", undefined, 415);
83
+ }
84
+
85
+ let form: FormData;
86
+ try {
87
+ form = await req.formData();
88
+ } catch {
89
+ return badRequest("invalid_form_data");
90
+ }
91
+ const file = form.get("file");
92
+ const formatField = form.get("format");
93
+ const formatRaw =
94
+ typeof formatField === "string" ? formatField.trim().toLowerCase() : "";
95
+ if (!(file instanceof File)) {
96
+ return badRequest("file_required");
97
+ }
98
+ if (file.size <= 0 || file.size > MAX_UPLOAD_BYTES) {
99
+ return badRequest("invalid_file_size", { maxBytes: MAX_UPLOAD_BYTES });
100
+ }
101
+
102
+ const lowerName = file.name.toLowerCase();
103
+ let format: "json" | "sqlite";
104
+ if (formatRaw === "json" || formatRaw === "sqlite") {
105
+ format = formatRaw;
106
+ } else {
107
+ format = lowerName.endsWith(".json") ? "json" : "sqlite";
108
+ }
109
+
110
+ if (format === "json") {
111
+ const text = await file.text();
112
+ let parsed: unknown;
113
+ try {
114
+ parsed = JSON.parse(text);
115
+ } catch {
116
+ return badRequest("invalid_json");
117
+ }
118
+ if (!validatePayloadShape(parsed)) {
119
+ return badRequest("invalid_kronosys_payload");
120
+ }
121
+ writePayload(parsed);
122
+ return NextResponse.json({ ok: true, restored: "json" });
123
+ }
124
+
125
+ const bytes = await file.arrayBuffer();
126
+ const buf = Buffer.from(bytes);
127
+ if (buf.length < 1024) {
128
+ return badRequest("sqlite_too_small");
129
+ }
130
+ const header = buf.subarray(0, 16).toString("utf8");
131
+ if (!header.startsWith("SQLite format 3")) {
132
+ return badRequest("invalid_sqlite_file");
133
+ }
134
+
135
+ try {
136
+ const { backupPath } = restoreFromSqliteBuffer(buf);
137
+ return NextResponse.json({ ok: true, restored: "sqlite", backupPath });
138
+ } catch (error) {
139
+ const detail = error instanceof Error ? error.message : String(error);
140
+ return NextResponse.json(
141
+ { ok: false, error: "restore_failed", detail },
142
+ { status: 500 },
143
+ );
144
+ }
145
+ }
@@ -3,7 +3,9 @@
3
3
  import { Suspense, useMemo } from "react";
4
4
  import Link from "next/link";
5
5
  import { useSearchParams } from "next/navigation";
6
+ import ReactMarkdown, { type Components } from "react-markdown";
6
7
  import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
8
+ import { AppShellHeaderWallClock } from "@/components/dashboard/AppShellHeaderWallClock";
7
9
  import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
8
10
  import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
9
11
  import { ScrollToTopFab } from "@/components/dashboard/ScrollToTopFab";
@@ -18,6 +20,55 @@ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
18
20
 
19
21
  type LiveShape = { language?: string };
20
22
 
23
+ type ChangelogItemGroup = {
24
+ heading: string;
25
+ items: string[];
26
+ };
27
+
28
+ const CHANGELOG_MARKDOWN_COMPONENTS: Components = {
29
+ p: ({ children }) => <span>{children}</span>,
30
+ a: ({ children, href }) => (
31
+ <a
32
+ href={href}
33
+ className="text-violet-700 underline decoration-violet-400/80 underline-offset-2 hover:text-violet-900 dark:text-violet-300 dark:decoration-violet-500/60 dark:hover:text-violet-200"
34
+ target="_blank"
35
+ rel="noopener noreferrer"
36
+ >
37
+ {children}
38
+ </a>
39
+ ),
40
+ code: ({ children }) => (
41
+ <code className="rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[0.82em] dark:bg-zinc-700/80">
42
+ {children}
43
+ </code>
44
+ ),
45
+ };
46
+
47
+ function groupChangelogItems(items: string[]): ChangelogItemGroup[] {
48
+ const groups: ChangelogItemGroup[] = [];
49
+ const indexByHeading = new Map<string, number>();
50
+ const fallbackHeading = "Notes";
51
+
52
+ for (const raw of items) {
53
+ const item = raw.trim();
54
+ const m = /^([^—:]+)\s*[—:]\s*(.+)$/u.exec(item);
55
+ const heading = m?.[1]?.trim() || fallbackHeading;
56
+ const content = m?.[2]?.trim() || item;
57
+ if (!content) {
58
+ continue;
59
+ }
60
+ const existingIndex = indexByHeading.get(heading);
61
+ if (existingIndex !== undefined) {
62
+ groups[existingIndex].items.push(content);
63
+ continue;
64
+ }
65
+ indexByHeading.set(heading, groups.length);
66
+ groups.push({ heading, items: [content] });
67
+ }
68
+
69
+ return groups;
70
+ }
71
+
21
72
  function ChangelogBody() {
22
73
  const searchParams = useSearchParams();
23
74
  const dashboardSessionNavId = searchParams.get("session");
@@ -55,6 +106,7 @@ function ChangelogBody() {
55
106
  </p>
56
107
  </div>
57
108
  <div className="flex flex-wrap items-center gap-1.5">
109
+ <AppShellHeaderWallClock lang={lang} dt={dt} />
58
110
  <ThemeToggle lang={lang} />
59
111
  <PageRefreshButton
60
112
  title={dt.pageRefreshTitle}
@@ -90,11 +142,26 @@ function ChangelogBody() {
90
142
  <h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
91
143
  v{entry.version}
92
144
  </h2>
93
- <ul className="mt-3 list-inside list-disc space-y-1.5 text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
94
- {entry.items.map((item) => (
95
- <li key={item}>{item}</li>
145
+ <div className="mt-3 space-y-3">
146
+ {groupChangelogItems(entry.items).map((group) => (
147
+ <div key={group.heading} className="space-y-1.5">
148
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
149
+ {group.heading}
150
+ </h3>
151
+ <ul className="list-inside list-disc space-y-1.5 text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
152
+ {group.items.map((item) => (
153
+ <li key={`${group.heading}:${item}`}>
154
+ <ReactMarkdown
155
+ components={CHANGELOG_MARKDOWN_COMPONENTS}
156
+ >
157
+ {item}
158
+ </ReactMarkdown>
159
+ </li>
160
+ ))}
161
+ </ul>
162
+ </div>
96
163
  ))}
97
- </ul>
164
+ </div>
98
165
  </section>
99
166
  ))
100
167
  )}
package/app/globals.css CHANGED
@@ -48,6 +48,20 @@ body {
48
48
  color: var(--foreground);
49
49
  }
50
50
 
51
+ @keyframes kronosys-route-transition-in-kf {
52
+ from {
53
+ opacity: 0;
54
+ }
55
+ to {
56
+ opacity: 1;
57
+ }
58
+ }
59
+
60
+ .kronosys-route-transition-in {
61
+ animation: kronosys-route-transition-in-kf 170ms ease-out both;
62
+ will-change: opacity;
63
+ }
64
+
51
65
  /* Minuteur focus : clignotement sous 5 min (travail / longue pause), accent violet sous 30 s */
52
66
  @keyframes kronosys-krono-focus-blink {
53
67
  0%,
@@ -83,6 +97,32 @@ html.dark .kronosys-session-duration-alert {
83
97
  color: rgb(248 113 113);
84
98
  }
85
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
+
86
126
  /* Saisie tâche « passé » : exactement deux assombrissements (pas de 3e pic dû au easing) */
87
127
  @keyframes kronosys-past-datetime-blink-twice-kf {
88
128
  0%,
@@ -187,11 +227,76 @@ html.dark .kronosys-datetime-popover select option {
187
227
  transform-origin: center center;
188
228
  }
189
229
 
230
+ /* Tâche en pause : halo ambre léger (minuteur arrêté) */
231
+ @keyframes kronosys-task-paused-ring-kf {
232
+ 0%,
233
+ 100% {
234
+ box-shadow:
235
+ 0 0 0 1px rgb(234 179 8 / 0.28),
236
+ 0 0 12px -4px rgb(234 179 8 / 0.12);
237
+ }
238
+ 50% {
239
+ box-shadow:
240
+ 0 0 0 3px rgb(234 179 8 / 0.42),
241
+ 0 0 18px -2px rgb(234 179 8 / 0.22);
242
+ }
243
+ }
244
+
245
+ .kronosys-task-paused-blink {
246
+ animation: kronosys-task-paused-ring-kf 1.75s ease-in-out infinite;
247
+ }
248
+
249
+ /* Tâche lancée : pulse léger tant que le minuteur roule */
250
+ @keyframes kronosys-task-running-pulse-kf {
251
+ 0%,
252
+ 100% {
253
+ box-shadow:
254
+ 0 0 0 1px rgb(16 185 129 / 0.22),
255
+ 0 0 10px -5px rgb(16 185 129 / 0.1);
256
+ }
257
+ 50% {
258
+ box-shadow:
259
+ 0 0 0 2px rgb(16 185 129 / 0.36),
260
+ 0 0 18px -4px rgb(16 185 129 / 0.18);
261
+ }
262
+ }
263
+
264
+ .kronosys-task-running-pulse {
265
+ animation: kronosys-task-running-pulse-kf 2.2s ease-in-out infinite;
266
+ }
267
+
268
+ /* Tâche terminée : pulse unique vert (succès) */
269
+ @keyframes kronosys-task-finish-celebrate-kf {
270
+ 0% {
271
+ box-shadow: 0 0 0 0 rgb(16 185 129 / 0);
272
+ transform: scale(1);
273
+ }
274
+ 45% {
275
+ box-shadow:
276
+ 0 0 0 4px rgb(16 185 129 / 0.35),
277
+ 0 0 20px -4px rgb(16 185 129 / 0.28);
278
+ transform: scale(1.012);
279
+ }
280
+ 100% {
281
+ box-shadow: 0 0 0 0 rgb(16 185 129 / 0);
282
+ transform: scale(1);
283
+ }
284
+ }
285
+
286
+ .kronosys-task-finish-celebrate {
287
+ animation: kronosys-task-finish-celebrate-kf 0.7s ease-out 1 both;
288
+ transform-origin: center center;
289
+ }
290
+
190
291
  @media (prefers-reduced-motion: reduce) {
191
292
  html {
192
293
  scroll-behavior: auto !important;
193
294
  }
194
295
 
296
+ .kronosys-route-transition-in {
297
+ animation: none;
298
+ }
299
+
195
300
  .kronosys-krono-focus-time-blink {
196
301
  animation: none;
197
302
  opacity: 1;
@@ -203,8 +308,30 @@ html.dark .kronosys-datetime-popover select option {
203
308
  transform: none;
204
309
  }
205
310
 
311
+ .kronosys-end-time-alert-slow,
312
+ .kronosys-end-time-alert-fast {
313
+ animation: none;
314
+ opacity: 1;
315
+ }
316
+
206
317
  .kronosys-past-datetime-blink-twice {
207
318
  animation: none;
208
319
  opacity: 1;
209
320
  }
321
+
322
+ .kronosys-task-paused-blink {
323
+ animation: none;
324
+ box-shadow: none;
325
+ }
326
+
327
+ .kronosys-task-running-pulse {
328
+ animation: none;
329
+ box-shadow: none;
330
+ }
331
+
332
+ .kronosys-task-finish-celebrate {
333
+ animation: none;
334
+ transform: none;
335
+ box-shadow: none;
336
+ }
210
337
  }