@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
@@ -7,44 +7,126 @@ const AUTO_TAG_COMBINED = /(?:^|\s)#([^\s#]+)|(?<=[^\s#])#([^\s#]+)/g;
7
7
 
8
8
  /**
9
9
  * Détection des `@projet` (mêmes idées que les `#tags`).
10
+ * @deprecated Utiliser {@link findProjectTokens} pour `@` et `!`.
10
11
  */
11
12
  const AUTO_PROJECT_COMBINED = /(?:^|\s)@([^\s@]+)|(?<=[^\s@])@([^\s@]+)/g;
12
13
 
14
+ type ProjectTokenMatch = {
15
+ start: number;
16
+ end: number;
17
+ plain: boolean;
18
+ sigil: "@" | "!";
19
+ projRaw: string;
20
+ loc?: string;
21
+ };
22
+
13
23
  /**
14
- * `@projet#suffixe` : étiquette liée à un projet (stockée comme `projet#suffixe`, sans `@`).
15
- * Traitée avant les `@projet` seuls pour ne pas confondre le corps `projet#…` avec un nom de projet.
24
+ * Repère les jetons `@projet`, `!projet`, `@p#t` et `!p#t` (ordre d’apparition dans la chaîne).
25
+ *
26
+ * - `@` : uniquement après début de chaîne ou un espace (évite `courriel@domaine`).
27
+ * - `!` : idem **ou** collé au caractère précédent (comme les `#tags`), ex. `Courrier!perso#dentiste`.
16
28
  */
17
- const PROJECT_SCOPED_AT = /(?:^|\s)@([^\s@#]+)#([^\s#]+)/g;
29
+ export function findProjectTokens(raw: string): ProjectTokenMatch[] {
30
+ const norm = raw.replace(/\s+/g, " ").trim();
31
+ /** Jetons `@` / `!` précédés d’une frontière « début ou espace ». */
32
+ const reBoundary = /(?:^|\s)([@!])([^\s@!#]+)(?:#([^\s#]+))?/g;
33
+ /** `!` seul : autorisé après un caractère non blanc, non `!`, non `#` (aligné sur les `#tags` collés). */
34
+ const reBangGlued = /(?<=[^\s!#])(!)([^\s@!#]+)(?:#([^\s#]+))?/g;
35
+
36
+ const pushMatch = (
37
+ out: ProjectTokenMatch[],
38
+ sigil: "@" | "!",
39
+ projRaw: string,
40
+ locRaw: string | undefined,
41
+ start: number,
42
+ fullLen: number,
43
+ ) => {
44
+ const end = start + fullLen;
45
+ const proj = projRaw.trim();
46
+ if (!proj) {
47
+ return;
48
+ }
49
+ if (locRaw !== undefined && locRaw.length > 0) {
50
+ const loc = locRaw.trim();
51
+ if (!loc || loc.includes("#")) {
52
+ return;
53
+ }
54
+ out.push({ start, end, plain: false, sigil, projRaw: proj, loc });
55
+ } else if (!proj.includes("#")) {
56
+ out.push({ start, end, plain: true, sigil, projRaw: proj });
57
+ }
58
+ };
18
59
 
19
- /** Extrait les jetons `@projet#suffixe` et retourne le texte restant. */
20
- export function stripScopedProjectAtTokens(raw: string): {
60
+ const buf: ProjectTokenMatch[] = [];
61
+ let m: RegExpExecArray | null;
62
+ while ((m = reBoundary.exec(norm)) !== null) {
63
+ pushMatch(buf, m[1] as "@" | "!", m[2], m[3], m.index, m[0].length);
64
+ }
65
+ while ((m = reBangGlued.exec(norm)) !== null) {
66
+ pushMatch(buf, "!", m[2], m[3], m.index, m[0].length);
67
+ }
68
+
69
+ buf.sort((a, b) => a.start - b.start || b.end - a.end);
70
+ const out: ProjectTokenMatch[] = [];
71
+ let lastEnd = -1;
72
+ for (const t of buf) {
73
+ if (t.start < lastEnd) {
74
+ continue;
75
+ }
76
+ out.push(t);
77
+ lastEnd = t.end;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Retire uniquement les jetons de portée `@p#t` / `!p#t` (les `@p` / `!p` simples restent). */
83
+ function stripScopedProjectSigilTokens(raw: string): {
21
84
  core: string;
22
85
  scopedTags: string[];
23
86
  scopedProjectHint?: string;
87
+ scopedHintPersonal?: boolean;
24
88
  } {
89
+ const norm = raw.replace(/\s+/g, " ").trim();
90
+ const tokens = findProjectTokens(norm).filter((t) => !t.plain);
25
91
  const scopedTags: string[] = [];
26
- let scopedProjectHint: string | undefined;
27
92
  const seen = new Set<string>();
28
- const core = raw
29
- .replace(PROJECT_SCOPED_AT, (_full, g1: string, g2: string) => {
30
- const proj = (g1 || "").trim();
31
- const loc = (g2 || "").trim();
32
- if (!proj || !loc) {
33
- return "";
34
- }
35
- if (!scopedProjectHint) {
36
- scopedProjectHint = proj;
37
- }
38
- const canon = `${proj}#${loc}`;
39
- const k = canon.toLowerCase();
40
- if (!seen.has(k)) {
41
- seen.add(k);
42
- scopedTags.push(canon);
43
- }
44
- return "";
45
- })
46
- .replace(/\s+/g, " ")
47
- .trim();
93
+ let scopedProjectHint: string | undefined;
94
+ let scopedHintPersonal: boolean | undefined;
95
+ for (const t of tokens) {
96
+ if (!t.loc) {
97
+ continue;
98
+ }
99
+ const proj = normalizeProjectKey(t.projRaw);
100
+ const loc = t.loc.trim();
101
+ if (!proj || !loc) {
102
+ continue;
103
+ }
104
+ if (scopedProjectHint === undefined) {
105
+ scopedProjectHint = proj;
106
+ scopedHintPersonal = t.sigil === "!";
107
+ }
108
+ const canon = `${proj}#${loc}`;
109
+ const k = canon.toLowerCase();
110
+ if (!seen.has(k)) {
111
+ seen.add(k);
112
+ scopedTags.push(canon);
113
+ }
114
+ }
115
+ let core = norm;
116
+ for (const tok of [...tokens].sort((a, b) => b.start - a.start)) {
117
+ core = `${core.slice(0, tok.start)} ${core.slice(tok.end)}`;
118
+ }
119
+ core = core.replace(/\s+/g, " ").trim();
120
+ return { core, scopedTags, scopedProjectHint, scopedHintPersonal };
121
+ }
122
+
123
+ /** @deprecated Préférer {@link stripProjectsAndTakeFirst} ; conservé pour tests et rétrocompat. */
124
+ export function stripScopedProjectAtTokens(raw: string): {
125
+ core: string;
126
+ scopedTags: string[];
127
+ scopedProjectHint?: string;
128
+ } {
129
+ const { core, scopedTags, scopedProjectHint } = stripScopedProjectSigilTokens(raw);
48
130
  return { core, scopedTags, scopedProjectHint };
49
131
  }
50
132
 
@@ -92,40 +174,64 @@ export function filterTaskTagsForProject(tags: string[], project: string | null
92
174
  return tags.filter((t) => isTagAllowedForProject(t, p));
93
175
  }
94
176
 
95
- /** Retire les `@projet` simples et retient le premier projet (une seule assignation par tâche). */
96
- function stripPlainProjectsAndTakeFirst(raw: string): { core: string; project?: string } {
177
+ /** Retire les `@projet` / `!projet` simples (tous les jetons plain) et retient le **premier** en ordre de lecture. */
178
+ function stripPlainProjectSigilTokensAndTakeFirst(raw: string): {
179
+ core: string;
180
+ project?: string;
181
+ plainPersonal?: boolean;
182
+ } {
183
+ const norm = raw.replace(/\s+/g, " ").trim();
184
+ const tokens = findProjectTokens(norm).filter((t) => t.plain);
97
185
  let first: string | undefined;
98
- const core = raw
99
- .replace(AUTO_PROJECT_COMBINED, (_full, g1: string, g2: string) => {
100
- const body = (g1 || g2 || "").trim();
101
- if (body && !body.includes("#") && !first) {
102
- first = normalizeProjectKey(body);
103
- }
104
- return "";
105
- })
106
- .replace(/\s+/g, " ")
107
- .trim();
108
- return { core, project: first };
186
+ let plainPersonal: boolean | undefined;
187
+ for (const t of tokens) {
188
+ if (first === undefined) {
189
+ first = normalizeProjectKey(t.projRaw);
190
+ plainPersonal = t.sigil === "!";
191
+ }
192
+ }
193
+ let core = norm;
194
+ for (const tok of [...tokens].sort((a, b) => b.start - a.start)) {
195
+ core = `${core.slice(0, tok.start)} ${core.slice(tok.end)}`;
196
+ }
197
+ core = core.replace(/\s+/g, " ").trim();
198
+ return { core, project: first, plainPersonal };
109
199
  }
110
200
 
111
- /** Retire `@projet#suffixe` puis `@projet` ; fusionne l’indice de projet issu des jetons liés. */
112
- export function stripProjectsAndTakeFirst(raw: string): { core: string; project?: string; scopedTags: string[] } {
113
- const { core: c0, scopedTags, scopedProjectHint } = stripScopedProjectAtTokens(raw);
114
- const { core, project: plainProject } = stripPlainProjectsAndTakeFirst(c0);
201
+ /**
202
+ * Retire `@p#t` / `!p#t` puis `@p` / `!p` ; fusionne l’indice de projet (premier jeton plain,
203
+ * sinon premier indice issu d’un jeton de portée).
204
+ */
205
+ export function stripProjectsAndTakeFirst(raw: string): {
206
+ core: string;
207
+ project?: string;
208
+ scopedTags: string[];
209
+ /** Vrai lorsque le projet retenu provient d’un jeton `!` (projet personnel). */
210
+ personalProject: boolean;
211
+ } {
212
+ const { core: c0, scopedTags, scopedProjectHint, scopedHintPersonal } = stripScopedProjectSigilTokens(raw);
213
+ const { core, project: plainProject, plainPersonal } = stripPlainProjectSigilTokensAndTakeFirst(c0);
115
214
  const project = plainProject ?? scopedProjectHint;
116
- return { core, project, scopedTags };
215
+ const personalFlag =
216
+ plainProject !== undefined ? plainPersonal === true : scopedHintPersonal === true;
217
+ return {
218
+ core,
219
+ project,
220
+ scopedTags,
221
+ personalProject: Boolean(project && personalFlag),
222
+ };
117
223
  }
118
224
 
119
225
  export function normalizeProjectKey(s: string): string {
120
- return s.replace(/^@+/, "").trim();
226
+ return s.replace(/^[@!]+/, "").trim();
121
227
  }
122
228
 
123
229
  /**
124
- * Si le brouillon contient au moins un `@`, met à jour le projet (ou null si aucun jeton valide).
230
+ * Si le brouillon contient au moins un `@` ou `!`, met à jour le projet (ou null si aucun jeton valide).
125
231
  * Sinon ne pas modifier le champ projet côté API (`undefined`).
126
232
  */
127
233
  export function resolveProjectForTaskUpdate(rawTitle: string): string | null | undefined {
128
- if (!/@/.test(rawTitle)) {
234
+ if (!/[@!]/.test(rawTitle)) {
129
235
  return undefined;
130
236
  }
131
237
  const { project } = stripProjectsAndTakeFirst(rawTitle);
@@ -133,18 +239,49 @@ export function resolveProjectForTaskUpdate(rawTitle: string): string | null | u
133
239
  }
134
240
 
135
241
  /**
136
- * Projet à enregistrer à partir d’une ligne de tâche complète (champ unique avec `#`, `@`, `@p#t`).
137
- * Aucun `@` dans la chaîne ⇒ aucun projet (`null`). Sinon, premier projet déduit comme à la saisie.
242
+ * Lorsque le titre contient `@` ou `!`, indique si le projet déduit est **personnel** (`!`).
243
+ * Sinon `undefined` (ne pas modifier `personalProject` côté API).
244
+ */
245
+ export function resolvePersonalProjectForTaskUpdate(rawTitle: string): boolean | undefined {
246
+ if (!/[@!]/.test(rawTitle)) {
247
+ return undefined;
248
+ }
249
+ const { project, personalProject } = stripProjectsAndTakeFirst(rawTitle);
250
+ if (!project) {
251
+ return false;
252
+ }
253
+ return personalProject;
254
+ }
255
+
256
+ /**
257
+ * Projet à enregistrer à partir d’une ligne de tâche complète (champ unique avec `#`, `@`, `!`, `@p#t`, `!p#t`).
258
+ * Aucun `@` ni `!` dans la chaîne ⇒ aucun projet (`null`). Sinon, premier projet déduit comme à la saisie.
138
259
  */
139
260
  export function resolveProjectFromFullTaskLine(rawTitle: string): string | null {
140
261
  const t = rawTitle.replace(/\s+/g, " ").trim();
141
- if (!/@/.test(t)) {
262
+ if (!/[@!]/.test(t)) {
142
263
  return null;
143
264
  }
144
265
  const { project } = stripProjectsAndTakeFirst(t);
145
266
  return project ? normalizeProjectKey(project) : null;
146
267
  }
147
268
 
269
+ /** Même logique que {@link resolveProjectFromFullTaskLine} avec le drapeau projet personnel. */
270
+ export function resolveProjectMetaFromFullTaskLine(rawTitle: string): {
271
+ project: string | null;
272
+ personalProject: boolean;
273
+ } {
274
+ const t = rawTitle.replace(/\s+/g, " ").trim();
275
+ if (!/[@!]/.test(t)) {
276
+ return { project: null, personalProject: false };
277
+ }
278
+ const { project, personalProject } = stripProjectsAndTakeFirst(t);
279
+ return {
280
+ project: project ? normalizeProjectKey(project) : null,
281
+ personalProject: Boolean(project && personalProject),
282
+ };
283
+ }
284
+
148
285
  /** Découpe le titre brut pour afficher le texte et les segments `#tag` comme pastilles (mêmes règles que {@link parseTaskWithAutoTags}). */
149
286
  export function splitTaskTitleSegments(raw: string): Array<{ kind: "text" | "tag"; value: string }> {
150
287
  const re = new RegExp(AUTO_TAG_COMBINED.source, "g");
@@ -205,10 +342,10 @@ function mergeDedupedTagLists(primary: string[], secondary: string[]): string[]
205
342
  }
206
343
 
207
344
  function collectAutoTagsFromRaw(raw: string): string[] {
208
- const { core: afterScoped, scopedTags } = stripScopedProjectAtTokens(raw);
345
+ const { core, scopedTags } = stripProjectsAndTakeFirst(raw);
209
346
  const fromHash: string[] = [];
210
347
  const seen = new Set<string>();
211
- for (const segment of splitTaskTitleSegments(afterScoped)) {
348
+ for (const segment of splitTaskTitleSegments(core)) {
212
349
  if (segment.kind !== "tag") {
213
350
  continue;
214
351
  }
@@ -235,47 +372,71 @@ function parseNameAndTagsFromCore(core: string): { name: string; tags: string[]
235
372
  return { name, tags };
236
373
  }
237
374
 
238
- export function parseTaskWithAutoTags(raw: string): { name: string; tags: string[]; project?: string } {
239
- const { core, project, scopedTags } = stripProjectsAndTakeFirst(raw);
375
+ export function parseTaskWithAutoTags(raw: string): {
376
+ name: string;
377
+ tags: string[];
378
+ project?: string;
379
+ personalProject: boolean;
380
+ } {
381
+ const { core, project, scopedTags, personalProject } = stripProjectsAndTakeFirst(raw);
240
382
  const { name, tags } = parseNameAndTagsFromCore(core);
241
383
  const merged = mergeDedupedTagLists(scopedTags, tags);
242
- return { name, tags: filterTaskTagsForProject(merged, project ?? null), project };
384
+ return {
385
+ name,
386
+ tags: filterTaskTagsForProject(merged, project ?? null),
387
+ project,
388
+ personalProject: Boolean(project && personalProject),
389
+ };
243
390
  }
244
391
 
245
392
  /**
246
393
  * Interprète une ligne de saisie rapide sous une tâche (`#tag`, `@projet`, `@p#code`, texte seul comme étiquette).
247
394
  * Les étiquettes retournées sont déjà filtrées par projet si un `@projet` est détecté dans la même chaîne.
248
395
  */
249
- export function parseQuickAddTagOrProjectLine(input: string): { tags: string[]; project?: string | null } {
396
+ export function parseQuickAddTagOrProjectLine(input: string): {
397
+ tags: string[];
398
+ project?: string | null;
399
+ personalProject?: boolean;
400
+ } {
250
401
  const v = input.trim();
251
402
  if (!v) {
252
403
  return { tags: [] };
253
404
  }
254
- /** Seul `@` : retirer le projet (équivalent d’un clic « désactiver » sur la pastille projet enregistrée). */
255
- if (v === "@") {
256
- return { tags: [], project: null };
405
+ /** Seul `@` ou `!` : retirer le projet (équivalent d’un clic « désactiver » sur la pastille projet). */
406
+ if (v === "@" || v === "!") {
407
+ return { tags: [], project: null, personalProject: false };
257
408
  }
258
409
  const parsed = parseTaskWithAutoTags(v);
259
410
  const name = parsed.name.replace(/\s+/g, "").trim();
260
411
  if (parsed.tags.length === 0 && parsed.project === undefined && name.length > 0) {
261
- return { tags: [normalizeTagKey(name)], project: undefined };
412
+ return { tags: [normalizeTagKey(name)], project: undefined, personalProject: undefined };
262
413
  }
263
414
  if (parsed.project === undefined) {
264
- return { tags: parsed.tags, project: undefined };
415
+ return { tags: parsed.tags, project: undefined, personalProject: undefined };
265
416
  }
266
417
  const proj = parsed.project ? normalizeProjectKey(parsed.project) : null;
267
- return { tags: parsed.tags, project: proj };
418
+ return {
419
+ tags: parsed.tags,
420
+ project: proj,
421
+ personalProject: proj ? parsed.personalProject : false,
422
+ };
268
423
  }
269
424
 
270
425
  /**
271
426
  * Fusionne plusieurs lignes choisies (ex. valeurs d’un `<select multiple>` : `#tag`, `@projet`, `@`).
272
427
  * Les étiquettes sont dédupliquées (casse ignorée). Le dernier jeton qui fixe le projet l’emporte ; `@` seul retire le projet.
273
428
  */
274
- export function mergeQuickAddFromOptionValues(values: string[]): { tags: string[]; project?: string | null } {
429
+ export function mergeQuickAddFromOptionValues(values: string[]): {
430
+ tags: string[];
431
+ project?: string | null;
432
+ personalProject?: boolean;
433
+ } {
275
434
  const seen = new Set<string>();
276
435
  const tags: string[] = [];
277
436
  let project: string | null | undefined = undefined;
278
437
  let projectTouched = false;
438
+ let personalProject: boolean | undefined = undefined;
439
+ let personalTouched = false;
279
440
 
280
441
  for (const raw of values) {
281
442
  const r = parseQuickAddTagOrProjectLine(raw);
@@ -295,12 +456,19 @@ export function mergeQuickAddFromOptionValues(values: string[]): { tags: string[
295
456
  projectTouched = true;
296
457
  project = r.project;
297
458
  }
459
+ if (r.personalProject !== undefined) {
460
+ personalTouched = true;
461
+ personalProject = r.personalProject;
462
+ }
298
463
  }
299
464
 
300
- const out: { tags: string[]; project?: string | null } = { tags };
465
+ const out: { tags: string[]; project?: string | null; personalProject?: boolean } = { tags };
301
466
  if (projectTouched) {
302
467
  out.project = project;
303
468
  }
469
+ if (personalTouched) {
470
+ out.personalProject = personalProject;
471
+ }
304
472
  return out;
305
473
  }
306
474
 
@@ -339,22 +507,38 @@ export function taskTitleEditBaseline(storedName: string): string {
339
507
  * Même extraction que {@link parseTaskWithAutoTags}, mais sans rogner le titre :
340
508
  * nécessaire pour le champ de saisie contrôlé.
341
509
  */
342
- export function parseTaskDraftParts(raw: string): { name: string; tags: string[]; project?: string } {
343
- const { core, project, scopedTags } = stripProjectsAndTakeFirst(raw);
510
+ export function parseTaskDraftParts(raw: string): {
511
+ name: string;
512
+ tags: string[];
513
+ project?: string;
514
+ personalProject: boolean;
515
+ } {
516
+ const { core, project, scopedTags, personalProject } = stripProjectsAndTakeFirst(raw);
344
517
  const fromCore = collectAutoTagsFromRaw(core);
345
518
  const tags = mergeDedupedTagLists(scopedTags, fromCore);
346
519
  const name = core.replace(new RegExp(AUTO_TAG_COMBINED.source, "g"), "");
347
- return { name, tags, project };
520
+ return { name, tags, project, personalProject: Boolean(project && personalProject) };
348
521
  }
349
522
 
350
- function appendTagTokenToDraft(s: string, tag: string): string {
523
+ function appendTagTokenToDraft(
524
+ s: string,
525
+ tag: string,
526
+ ctx?: { taskProject?: string | null; personalProject?: boolean },
527
+ ): string {
351
528
  const t = normalizeTagKey(tag).trim();
352
529
  if (!t) {
353
530
  return s;
354
531
  }
355
532
  const scoped = parseProjectScopedTag(t);
356
533
  if (scoped) {
357
- const piece = `@${scoped.projectKey}#${scoped.localTag}`;
534
+ const pk = ctx?.taskProject ? normalizeProjectKey(String(ctx.taskProject)) : "";
535
+ const useBang =
536
+ Boolean(ctx?.personalProject) &&
537
+ pk.length > 0 &&
538
+ scoped.projectKey.toLowerCase() === pk.toLowerCase();
539
+ const piece = useBang
540
+ ? `!${scoped.projectKey}#${scoped.localTag}`
541
+ : `@${scoped.projectKey}#${scoped.localTag}`;
358
542
  const trimmed = s.replace(/\s+$/, "");
359
543
  return trimmed ? `${trimmed}${piece}` : piece;
360
544
  }
@@ -362,11 +546,20 @@ function appendTagTokenToDraft(s: string, tag: string): string {
362
546
  return trimmedEnd ? `${trimmedEnd}#${t}` : `#${t}`;
363
547
  }
364
548
 
365
- /** Reconstruit le brouillon (titre + #tags + `@projet#code` + @projet optionnel). */
366
- export function rebuildTaskDraftCore(name: string, tags: string[], project?: string): string {
549
+ /** Reconstruit le brouillon (titre + #tags + jetons de portée + `@` / `!` projet optionnel). */
550
+ export function rebuildTaskDraftCore(
551
+ name: string,
552
+ tags: string[],
553
+ project?: string,
554
+ personalProject?: boolean,
555
+ ): string {
367
556
  let s = name;
557
+ const pTrim = project?.trim();
558
+ const ctx = pTrim
559
+ ? { taskProject: normalizeProjectKey(pTrim), personalProject: Boolean(personalProject) }
560
+ : undefined;
368
561
  for (const t of tags) {
369
- s = appendTagTokenToDraft(s, t);
562
+ s = appendTagTokenToDraft(s, t, ctx);
370
563
  }
371
564
  const p = project?.trim();
372
565
  if (p) {
@@ -377,18 +570,28 @@ export function rebuildTaskDraftCore(name: string, tags: string[], project?: str
377
570
  });
378
571
  if (!implied) {
379
572
  const trimmed = s.replace(/\s+$/, "");
380
- s = trimmed ? `${trimmed}@${pk}` : `@${pk}`;
573
+ const projTok = personalProject ? `!${pk}` : `@${pk}`;
574
+ s = trimmed ? `${trimmed}${projTok}` : projTok;
381
575
  }
382
576
  }
383
577
  return s;
384
578
  }
385
579
 
386
- /** Reconstruit la chaîne « titre + #tags + `@p#t` [+ @projet] » alignée sur {@link parseTaskWithAutoTags}. */
387
- export function rebuildTaskRawString(name: string, tags: string[], project?: string | null): string {
580
+ /** Reconstruit la chaîne « titre + #tags + `@p#t` / `!p#t` [+ @ ou ! projet] » alignée sur {@link parseTaskWithAutoTags}. */
581
+ export function rebuildTaskRawString(
582
+ name: string,
583
+ tags: string[],
584
+ project?: string | null,
585
+ personalProject?: boolean,
586
+ ): string {
388
587
  const n = name.replace(/\s+/g, " ").trim();
588
+ const pTrim = project?.trim();
589
+ const ctx = pTrim
590
+ ? { taskProject: normalizeProjectKey(pTrim), personalProject: Boolean(personalProject) }
591
+ : undefined;
389
592
  let tagPart = n;
390
593
  for (const t of tags) {
391
- tagPart = appendTagTokenToDraft(tagPart, t);
594
+ tagPart = appendTagTokenToDraft(tagPart, t, ctx);
392
595
  }
393
596
  const p = project?.trim();
394
597
  if (!p) {
@@ -403,27 +606,35 @@ export function rebuildTaskRawString(name: string, tags: string[], project?: str
403
606
  return tagPart;
404
607
  }
405
608
  const trimmed = tagPart.replace(/\s+$/, "");
406
- return trimmed ? `${trimmed}@${pk}` : `@${pk}`;
609
+ const projTok = personalProject ? `!${pk}` : `@${pk}`;
610
+ return trimmed ? `${trimmed}${projTok}` : projTok;
407
611
  }
408
612
 
409
613
  /**
410
- * Chaîne pour le champ d’édition : nom stocké sans `#` / `@` + pastilles fusionnées + projet.
614
+ * Chaîne pour le champ d’édition : nom stocké sans `#` / `@` / `!` + pastilles fusionnées + projet.
411
615
  */
412
616
  export function displayRawTaskTitle(
413
617
  storedName: string,
414
618
  mergedTags: string[],
415
- mergedProject?: string | null
619
+ mergedProject?: string | null,
620
+ mergedPersonalProject?: boolean,
416
621
  ): string {
417
622
  const { name: stripped } = parseTaskWithAutoTags(storedName);
418
623
  const base = stripped.replace(/\s+/g, " ").trim();
419
624
  const baseKey = base.toLowerCase();
420
625
  const suffixTags = mergedTags.filter((t) => normalizeTagKey(t).toLowerCase() !== baseKey);
626
+ const personal = Boolean(mergedPersonalProject);
627
+ const pTrim = mergedProject?.trim();
628
+ const pkForScoped =
629
+ pTrim && personal ? normalizeProjectKey(pTrim).toLowerCase() : "";
421
630
  const suffix = suffixTags
422
631
  .map((t) => {
423
632
  const tk = normalizeTagKey(t);
424
633
  const sc = parseProjectScopedTag(tk);
425
634
  if (sc) {
426
- return `@${sc.projectKey}#${sc.localTag}`;
635
+ const useBang =
636
+ personal && sc.projectKey.toLowerCase() === pkForScoped;
637
+ return useBang ? `!${sc.projectKey}#${sc.localTag}` : `@${sc.projectKey}#${sc.localTag}`;
427
638
  }
428
639
  return `#${tk}`;
429
640
  })
@@ -445,13 +656,14 @@ export function displayRawTaskTitle(
445
656
  });
446
657
  if (!implied) {
447
658
  const trimmed = out.replace(/\s+$/, "");
448
- out = trimmed ? `${trimmed}@${pk}` : `@${pk}`;
659
+ const projTok = personal ? `!${pk}` : `@${pk}`;
660
+ out = trimmed ? `${trimmed}${projTok}` : projTok;
449
661
  }
450
662
  }
451
663
  return out;
452
664
  }
453
665
 
454
- /** Ajoute un tag enregistré à la fin du brouillon (`#tag` ou `@projet#code`). */
666
+ /** Ajoute un tag enregistré à la fin du brouillon (`#tag` ou `@projet#code` / `!projet#code`). */
455
667
  export function appendSavedTagToDraft(current: string, tag: string): string {
456
668
  const token = normalizeTagKey(tag);
457
669
  if (!token) {
@@ -459,9 +671,9 @@ export function appendSavedTagToDraft(current: string, tag: string): string {
459
671
  }
460
672
  const parsed = parseTaskDraftParts(current);
461
673
  const nextTags = mergeDedupedTagLists(parsed.tags, [token]);
462
- const prevCore = rebuildTaskDraftCore(parsed.name, parsed.tags, parsed.project);
674
+ const prevCore = rebuildTaskDraftCore(parsed.name, parsed.tags, parsed.project, parsed.personalProject);
463
675
  const suff = current.startsWith(prevCore) ? current.slice(prevCore.length) : "";
464
- const core = rebuildTaskDraftCore(parsed.name, nextTags, parsed.project);
676
+ const core = rebuildTaskDraftCore(parsed.name, nextTags, parsed.project, parsed.personalProject);
465
677
  return core + suff;
466
678
  }
467
679
 
@@ -474,17 +686,21 @@ export function removeSavedTagFromDraft(current: string, tag: string): string {
474
686
  const key = token.toLowerCase();
475
687
  const parsed = parseTaskDraftParts(current);
476
688
  const nextTags = parsed.tags.filter((t) => normalizeTagKey(t).toLowerCase() !== key);
477
- return rebuildTaskRawString(parsed.name, nextTags, parsed.project ?? null);
689
+ return rebuildTaskRawString(parsed.name, nextTags, parsed.project ?? null, parsed.personalProject);
478
690
  }
479
691
 
480
- /** Retire le projet (`@projet`) du brouillon sans modifier le libellé ni les étiquettes. */
692
+ /** Retire le projet (`@projet` / `!projet`) du brouillon sans modifier le libellé ni les étiquettes. */
481
693
  export function removeProjectFromDraft(current: string): string {
482
694
  const parsed = parseTaskDraftParts(current);
483
- return rebuildTaskRawString(parsed.name, parsed.tags, null);
695
+ return rebuildTaskRawString(parsed.name, parsed.tags, null, false);
484
696
  }
485
697
 
486
- /** Ajoute un projet connu à la fin du brouillon (`@nom`, sans espace avant `@` si le cœur se termine déjà par du texte). */
487
- export function appendSavedProjectToDraft(current: string, project: string): string {
698
+ /** Ajoute un projet connu à la fin du brouillon (`@nom` ou `!nom`). */
699
+ export function appendSavedProjectToDraft(
700
+ current: string,
701
+ project: string,
702
+ opts?: { personal?: boolean },
703
+ ): string {
488
704
  const token = normalizeProjectKey(project);
489
705
  if (!token) {
490
706
  return current;
@@ -493,16 +709,30 @@ export function appendSavedProjectToDraft(current: string, project: string): str
493
709
  if (parsed.project && parsed.project.toLowerCase() === token.toLowerCase()) {
494
710
  return current;
495
711
  }
496
- const core = rebuildTaskDraftCore(parsed.name, parsed.tags, token);
712
+ const personal = opts?.personal ?? false;
713
+ const core = rebuildTaskDraftCore(parsed.name, parsed.tags, token, personal);
497
714
  const suff = current.startsWith(core) ? current.slice(core.length) : "";
498
715
  return core + suff;
499
716
  }
500
717
 
501
- export function buildStartTaskFromInput(raw: string): { name: string; tags: string[]; project?: string } {
718
+ export function buildStartTaskFromInput(raw: string): {
719
+ name: string;
720
+ tags: string[];
721
+ project?: string;
722
+ personalProject?: boolean;
723
+ } {
502
724
  const trimmed = raw.trim();
503
- const { name, tags, project } = parseTaskWithAutoTags(trimmed);
725
+ const { name, tags, project, personalProject } = parseTaskWithAutoTags(trimmed);
504
726
  const displayName = name || tags[0] || trimmed;
505
- return { name: displayName, tags, project };
727
+ const out: { name: string; tags: string[]; project?: string; personalProject?: boolean } = {
728
+ name: displayName,
729
+ tags,
730
+ };
731
+ if (project) {
732
+ out.project = project;
733
+ out.personalProject = personalProject;
734
+ }
735
+ return out;
506
736
  }
507
737
 
508
738
  /**
@@ -512,24 +742,35 @@ export function buildStartTaskFromDraft(
512
742
  titleLine: string,
513
743
  pickerTags: string[],
514
744
  /** `undefined` : projet issu du titre ; chaîne vide : aucun projet (pastille désactivée). */
515
- pickerProject: string | undefined
516
- ): { name: string; tags: string[]; project?: string } {
745
+ pickerProject: string | undefined,
746
+ opts?: { pickerPersonalProject?: boolean },
747
+ ): { name: string; tags: string[]; project?: string; personalProject?: boolean } {
517
748
  const trimmed = titleLine.trim();
518
749
  const parsed = parseTaskWithAutoTags(trimmed);
519
750
  const allTags = mergeTagsForDisplay(trimmed, pickerTags);
520
751
  const project =
521
752
  pickerProject === undefined ? parsed.project : pickerProject.trim() ? pickerProject : undefined;
753
+ const personalProject =
754
+ pickerProject === undefined
755
+ ? parsed.personalProject
756
+ : pickerProject.trim()
757
+ ? opts?.pickerPersonalProject ?? false
758
+ : false;
522
759
  const tags = filterTaskTagsForProject(allTags, project ?? null);
523
760
  const displayName =
524
761
  parsed.name.trim() ||
525
762
  (tags.length > 0 ? tags[0] : "") ||
526
763
  trimmed ||
527
764
  (project ? normalizeProjectKey(project) : "");
528
- return {
765
+ const out: { name: string; tags: string[]; project?: string; personalProject?: boolean } = {
529
766
  name: displayName,
530
767
  tags,
531
- ...(project ? { project } : {}),
532
768
  };
769
+ if (project) {
770
+ out.project = project;
771
+ out.personalProject = personalProject;
772
+ }
773
+ return out;
533
774
  }
534
775
 
535
776
  export function normalizeTagKey(s: string): string {
@@ -624,21 +865,27 @@ export function mergeTagsForDisplay(rawName: string, storedTags?: string[] | nul
624
865
  return out;
625
866
  }
626
867
 
627
- export function formatTagDisplay(tag: string): string {
868
+ export function formatTagDisplay(tag: string, opts?: { personalScoped?: boolean }): string {
628
869
  const trimmed = tag.trim();
629
870
  if (!trimmed) {
630
871
  return trimmed;
631
872
  }
632
873
  const scoped = parseProjectScopedTag(trimmed.replace(/^#/, ""));
633
874
  if (scoped) {
634
- return `@${scoped.projectKey}#${scoped.localTag}`;
875
+ return opts?.personalScoped
876
+ ? `!${scoped.projectKey}#${scoped.localTag}`
877
+ : `@${scoped.projectKey}#${scoped.localTag}`;
635
878
  }
636
879
  const noHash = trimmed.replace(/^#/, "");
637
880
  return trimmed.startsWith("#") ? trimmed : `#${noHash}`;
638
881
  }
639
882
 
640
883
  /** Libellé lisible pour une pastille d’étiquette ; `defaultBucketLabel` remplace l’affichage de l’étiquette réservée {@link DEFAULT_FALLBACK_TASK_TAG}. */
641
- export function formatTagDisplayForTask(tag: string, defaultBucketLabel?: string): string {
884
+ export function formatTagDisplayForTask(
885
+ tag: string,
886
+ defaultBucketLabel?: string,
887
+ opts?: { personalProject?: boolean; taskProject?: string | null },
888
+ ): string {
642
889
  const trimmed = normalizeTagKey(String(tag)).trim();
643
890
  if (!trimmed) {
644
891
  return "";
@@ -646,15 +893,25 @@ export function formatTagDisplayForTask(tag: string, defaultBucketLabel?: string
646
893
  if (trimmed.toLowerCase() === DEFAULT_FALLBACK_TASK_TAG && defaultBucketLabel?.trim()) {
647
894
  return defaultBucketLabel.trim();
648
895
  }
649
- return formatTagDisplay(tag);
896
+ const sc = parseProjectScopedTag(trimmed);
897
+ const tp = opts?.taskProject;
898
+ const usePersonalScoped =
899
+ sc !== null &&
900
+ Boolean(opts?.personalProject && tp) &&
901
+ sc.projectKey.toLowerCase() === normalizeProjectKey(String(tp)).toLowerCase();
902
+ return formatTagDisplay(tag, { personalScoped: usePersonalScoped });
650
903
  }
651
904
 
652
- export function formatProjectDisplay(project: string): string {
905
+ export function formatProjectDisplay(project: string, opts?: { personal?: boolean }): string {
653
906
  const trimmed = project.trim();
654
907
  if (!trimmed) {
655
908
  return trimmed;
656
909
  }
657
- return trimmed.startsWith("@") ? trimmed : `@${normalizeProjectKey(trimmed)}`;
910
+ const k = normalizeProjectKey(trimmed);
911
+ if (opts?.personal) {
912
+ return trimmed.startsWith("!") ? trimmed : `!${k}`;
913
+ }
914
+ return trimmed.startsWith("@") ? trimmed : `@${k}`;
658
915
  }
659
916
 
660
917
  /**