@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
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` (
|
|
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
|
package/app/api/action/route.ts
CHANGED
|
@@ -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
|
|
17
|
+
let parsedBody: unknown;
|
|
9
18
|
try {
|
|
10
|
-
|
|
19
|
+
parsedBody = await req.json();
|
|
11
20
|
} catch {
|
|
12
|
-
return
|
|
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
|
+
}
|
package/app/api/backup/route.ts
CHANGED
|
@@ -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
|
+
}
|
package/app/changelog/page.tsx
CHANGED
|
@@ -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
|
-
<
|
|
94
|
-
{entry.items.map((
|
|
95
|
-
<
|
|
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
|
-
</
|
|
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
|
}
|