@tekkare/romulus 0.1.243 → 0.1.245

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 (42) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/app.config.ts +6 -0
  3. package/layer/app/components/OipShell.vue +3 -0
  4. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  5. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  6. package/layer/app/components/layout/tool/alerts.vue +969 -0
  7. package/layer/app/components/layout/tool/export.vue +590 -0
  8. package/layer/app/components/layout/tool/views.vue +933 -0
  9. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  10. package/layer/app/components/layout/viewPill.vue +550 -0
  11. package/layer/app/components/panels/OipPanelStatistics.vue +447 -0
  12. package/layer/app/composables/useAdminAccess.ts +86 -0
  13. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  14. package/layer/app/composables/useAlertSource.ts +210 -0
  15. package/layer/app/composables/useAlerts.ts +345 -0
  16. package/layer/app/composables/useExportSource.ts +236 -0
  17. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  18. package/layer/app/composables/usePermalink.ts +108 -0
  19. package/layer/app/composables/useSavedExports.ts +286 -0
  20. package/layer/app/composables/useViewCardActions.ts +500 -0
  21. package/layer/app/composables/useViewTemplates.ts +369 -0
  22. package/layer/app/composables/useViews.ts +574 -0
  23. package/layer/app/oip-defaults/alerts.ts +34 -0
  24. package/layer/app/oip-defaults/api.ts +18 -0
  25. package/layer/app/oip-defaults/export.ts +23 -0
  26. package/layer/app/oip-defaults/ui.ts +21 -0
  27. package/layer/app/oip-defaults/views.ts +59 -0
  28. package/layer/app/types/stats.ts +49 -0
  29. package/layer/app/utils/alertFieldLabels.ts +191 -0
  30. package/layer/app/utils/oipRail.ts +29 -3
  31. package/layer/app/utils/oipScopeStore.ts +112 -0
  32. package/layer/app/utils/oipScopes.ts +376 -0
  33. package/layer/app/utils/viewPages.ts +30 -0
  34. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  35. package/layer/app/utils/viewTemplates.ts +116 -0
  36. package/layer/nuxt.config.ts +18 -0
  37. package/layer/server/api/oip/notes.get.ts +1 -1
  38. package/layer/server/api/oip/stats/users.get.ts +80 -0
  39. package/layer/server/api/oip/stats.get.ts +88 -0
  40. package/layer/server/utils/oipPosthog.ts +134 -0
  41. package/layer/server/utils/oipWorkspaces.ts +51 -0
  42. package/package.json +3 -2
@@ -0,0 +1,376 @@
1
+ // Composable for scope operations - Using Pulses REST API (Virtual Lab)
2
+
3
+ import { storeToRefs } from "pinia";
4
+ import { useOipApi } from "#oip-app/api";
5
+ import { oipSnackbar, oipTrack } from "#oip-app/ui";
6
+ import type { defineOipScopeStore } from "./oipScopeStore";
7
+
8
+ /**
9
+ * Fabrique du composable de vues enregistrees d'une famille (useScopes,
10
+ * useHeartScopes...) : memes appels a la Virtual Lab (pulses), seul le store
11
+ * change.
12
+ */
13
+ export function createOipScopes(scopeStore: ReturnType<typeof defineOipScopeStore>) {
14
+ return function useScopes() {
15
+ // variables
16
+ const scopes = ref([] as Array<any>);
17
+ const scopeLoading = ref(false as boolean);
18
+ const scopeError = ref(null as any);
19
+ const router = useRouter();
20
+ const scopePinia = scopeStore();
21
+ const scopeState = storeToRefs(scopePinia);
22
+ const elysiaApi = useOipApi();
23
+ // Par `$i18n` et non `useI18n` : ce composable est aussi appele hors d'un
24
+ // `setup` (tiroirs de selection, gestionnaires d'evenements), ou `useI18n`
25
+ // leve. Les snackbars parlent la langue de l'ecran, et de « vue », le nom
26
+ // que porte l'objet a l'ecran (« scope » ne reste que cote API).
27
+ const { $i18n } = useNuxtApp() as any;
28
+ const fr = () => $i18n?.locale?.value !== "en";
29
+ const say = (texts: { fr: string; en: string }) => (fr() ? texts.fr : texts.en);
30
+
31
+ // Load scopes using the Pulses REST API
32
+ async function getScopes() {
33
+ const domain = window.location.host;
34
+
35
+ try {
36
+ scopeLoading.value = true;
37
+ // `limit` explicite : l'API rend 100 pulses par defaut, les plus recents
38
+ // d'abord, et les exports conserves partagent ce magasin. Sans plafond
39
+ // releve, une vingtaine d'exports frais poussent les vues les plus
40
+ // anciennes hors de la fenetre, sans erreur ni message.
41
+ const response = await elysiaApi.pulses.list({ domain, limit: 500 });
42
+
43
+ // criteria must be a JSON string for backward compatibility with components
44
+ const mappedScopes = response.data.map((pulse) => ({
45
+ id: pulse.id,
46
+ user_id: pulse.userId,
47
+ title: pulse.title,
48
+ description: pulse.description || "",
49
+ url: pulse.url,
50
+ page: pulse.page,
51
+ criteria:
52
+ typeof pulse.criteria === "string"
53
+ ? pulse.criteria
54
+ : JSON.stringify(pulse.criteria || []),
55
+ starred: pulse.starred ?? false,
56
+ isDefault: pulse.isDefault ?? false,
57
+ // Partage : la vue peut venir de quelqu'un d'autre. `isOwner` decide
58
+ // de ce que l'ecran laisse faire, jamais l'egalite des identifiants
59
+ // d'utilisateur, que le pulse d'une vue partagee ne porte pas.
60
+ visibility: pulse.visibility ?? "private",
61
+ isOwner: pulse.isOwner ?? true,
62
+ ownerName: pulse.ownerName ?? null,
63
+ sharedWith: pulse.sharedWith ?? [],
64
+ // Publication en template Tekkare : le dialogue de partage en a
65
+ // besoin pour rouvrir sur ce qui est deja pose.
66
+ isTemplate: pulse.isTemplate ?? false,
67
+ targetWorkspaces: pulse.targetWorkspaces ?? [],
68
+ createdAt: pulse.createdAt,
69
+ projects: pulse.projects || [],
70
+ }));
71
+
72
+ scopes.value = mappedScopes;
73
+ scopePinia.setList(scopes.value);
74
+ scopeLoading.value = false;
75
+ } catch (err) {
76
+ scopeLoading.value = false;
77
+ scopeError.value = err;
78
+ console.error("[useScopes] Error loading scopes:", err);
79
+ }
80
+ }
81
+
82
+ interface newScopeObject {
83
+ title: String;
84
+ description?: String;
85
+ criteria: Array<criteriaArray>;
86
+ currentPage?: String;
87
+ }
88
+
89
+ interface criteriaArray {
90
+ filter: String;
91
+ page?: String;
92
+ values: any;
93
+ }
94
+
95
+ // Save new scope using the Pulses REST API
96
+ async function createScope({
97
+ title = "",
98
+ description = "",
99
+ criteria = [],
100
+ currentPage,
101
+ }: newScopeObject) {
102
+ const url = window.location.href;
103
+ const page = currentPage ?? router.currentRoute.value.path;
104
+
105
+ try {
106
+ scopeLoading.value = true;
107
+ oipSnackbar([
108
+ { attribute: "isVisible", value: true },
109
+ { attribute: "text", value: say({ fr: "Enregistrement de la vue", en: "Saving your view" }) },
110
+ { attribute: "loader", value: true },
111
+ ]);
112
+
113
+ await elysiaApi.pulses.create({
114
+ title: title as string,
115
+ description: description as string,
116
+ url: url,
117
+ page: page as string,
118
+ criteria: criteria, // sent as an object, API handles it
119
+ });
120
+
121
+ oipSnackbar([
122
+ { attribute: "isVisible", value: true },
123
+ {
124
+ attribute: "text",
125
+ value: say({ fr: "Vue enregistrée", en: "Your view has been saved" }),
126
+ },
127
+ { attribute: "loader", value: false },
128
+ { attribute: "prefixIcon", value: "Check" },
129
+ { attribute: "prefixIconColor", value: "#32D175" },
130
+ { attribute: "hasTimeOut", value: true },
131
+ ]);
132
+
133
+ scopeLoading.value = false;
134
+ oipTrack("scope_created", { title, page: page as string });
135
+
136
+ await getScopes();
137
+ } catch (err) {
138
+ scopeLoading.value = false;
139
+ scopeError.value = err;
140
+ console.error("[useScopes] Error creating scope:", err);
141
+ oipSnackbar([
142
+ { attribute: "isVisible", value: true },
143
+ {
144
+ attribute: "text",
145
+ value: say({
146
+ fr: "L'enregistrement de la vue a échoué",
147
+ en: "Something went wrong saving your view",
148
+ }),
149
+ },
150
+ { attribute: "hasTimeOut", value: true },
151
+ { attribute: "loader", value: false },
152
+ ]);
153
+ }
154
+ }
155
+
156
+ // Update existing scope using the Pulses REST API
157
+ async function updateScope({
158
+ id,
159
+ title = "",
160
+ description = "",
161
+ criteria = [],
162
+ currentPage,
163
+ refreshAlerts = true,
164
+ }: newScopeObject & {
165
+ id: string;
166
+ /**
167
+ * Faux quand les criteres ne bougent pas (un renommage) : les alertes de la
168
+ * vue n'ont rien a repointer, et la requete de l'ecran courant n'a rien a
169
+ * faire dans une vue peut-etre retouchee depuis une autre page.
170
+ */
171
+ refreshAlerts?: boolean;
172
+ }) {
173
+ const url = window.location.href;
174
+ const page = currentPage ?? router.currentRoute.value.path;
175
+
176
+ try {
177
+ scopeLoading.value = true;
178
+ oipSnackbar([
179
+ { attribute: "isVisible", value: true },
180
+ { attribute: "text", value: say({ fr: "Mise à jour de la vue", en: "Updating your view" }) },
181
+ { attribute: "loader", value: true },
182
+ ]);
183
+
184
+ // Les alertes de cette vue portent une requete figee, que seule la page
185
+ // sait construire : l'envoyer permet a l'API de les repointer elle-meme.
186
+ // Sans elle, elles s'endorment et attendent un rearmement a la main.
187
+ const result = await elysiaApi.pulses.update(id, {
188
+ title: title as string,
189
+ description: description as string,
190
+ url: url,
191
+ page: page as string,
192
+ criteria: criteria,
193
+ ...(refreshAlerts ? alertRefreshPayload(page) : {}),
194
+ });
195
+
196
+ oipSnackbar([
197
+ { attribute: "isVisible", value: true },
198
+ {
199
+ attribute: "text",
200
+ value: `${say({ fr: "Vue mise à jour", en: "Your view has been updated" })}${alertRefreshNotice(result)}`,
201
+ },
202
+ { attribute: "loader", value: false },
203
+ { attribute: "prefixIcon", value: "Check" },
204
+ { attribute: "prefixIconColor", value: "#32D175" },
205
+ { attribute: "hasTimeOut", value: true },
206
+ ]);
207
+
208
+ scopeLoading.value = false;
209
+ oipTrack("scope_updated", { id, title, page: page as string });
210
+
211
+ await getScopes();
212
+ } catch (err) {
213
+ scopeLoading.value = false;
214
+ scopeError.value = err;
215
+ console.error("[useScopes] Error updating scope:", err);
216
+ oipSnackbar([
217
+ { attribute: "isVisible", value: true },
218
+ {
219
+ attribute: "text",
220
+ value: say({
221
+ fr: "La mise à jour de la vue a échoué",
222
+ en: "Something went wrong updating your view",
223
+ }),
224
+ },
225
+ { attribute: "hasTimeOut", value: true },
226
+ { attribute: "loader", value: false },
227
+ ]);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Favori et vue par defaut : deux marques portees par le pulse, ecrites
233
+ * seules.
234
+ *
235
+ * Elles ne passent pas par `updateScope` : celui-la reecrit titre,
236
+ * description et criteres, donc marquer un favori depuis une autre page que
237
+ * celle de la vue y ecraserait ses criteres. L'unicite de la vue par defaut
238
+ * (une par page) est tenue par l'API.
239
+ */
240
+ async function setStarred(id: string, starred: boolean) {
241
+ try {
242
+ await elysiaApi.pulses.setPrefs(id, { starred });
243
+ await getScopes();
244
+ } catch (err) {
245
+ scopeError.value = err;
246
+ console.error("[useScopes] Error starring scope:", err);
247
+ }
248
+ }
249
+
250
+ async function setDefault(id: string, isDefault: boolean) {
251
+ try {
252
+ await elysiaApi.pulses.setPrefs(id, { isDefault });
253
+ await getScopes();
254
+ } catch (err) {
255
+ scopeError.value = err;
256
+ console.error("[useScopes] Error setting default scope:", err);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * A qui la vue est ouverte : personne, des gens nommes, tout l'espace.
262
+ *
263
+ * Reserve au proprietaire cote API. Les deux champs partent ensemble, la
264
+ * portee et la liste se lisant l'une l'autre : passer a « tout l'espace »
265
+ * sans vider la liste laisserait des destinataires que plus rien n'affiche.
266
+ *
267
+ * Rend l'issue de l'ecriture : refusee, elle ne change rien a l'ecran, et
268
+ * l'appelant doit pouvoir le dire. Un partage qui echoue en silence se lit
269
+ * comme un partage qui a marche.
270
+ */
271
+ async function shareScope(
272
+ id: string,
273
+ share: {
274
+ visibility: "private" | "workspace";
275
+ sharedWith: string[];
276
+ /**
277
+ * Publication en template Tekkare, quand le dialogue la propose
278
+ * (superadmin seul). Absents sinon : l'API refuse les champs a qui n'a
279
+ * pas le droit de les poser.
280
+ */
281
+ isTemplate?: boolean;
282
+ targetWorkspaces?: string[];
283
+ templateCriteria?: any;
284
+ },
285
+ ) {
286
+ try {
287
+ await elysiaApi.pulses.update(id, share);
288
+ await getScopes();
289
+ return true;
290
+ } catch (err) {
291
+ scopeError.value = err;
292
+ console.error("[useScopes] Error sharing scope:", err);
293
+ return false;
294
+ }
295
+ }
296
+
297
+ // Delete scope using the Pulses REST API
298
+ async function deleteScope(id: string) {
299
+ try {
300
+ scopeLoading.value = true;
301
+ await elysiaApi.pulses.delete(id as string);
302
+ scopeLoading.value = false;
303
+ await getScopes();
304
+ } catch (err) {
305
+ scopeLoading.value = false;
306
+ scopeError.value = err;
307
+ console.error("[useScopes] Error deleting scope:", err);
308
+ }
309
+ }
310
+
311
+ const scopeList = computed(() => {
312
+ return scopeState.list.value;
313
+ });
314
+
315
+ const selectedScope = computed(() => {
316
+ return scopeState.selectedScope.value;
317
+ });
318
+
319
+ const scopeLoadingState = computed(() => {
320
+ return scopeLoading.value as boolean;
321
+ });
322
+
323
+ function clearScope() {
324
+ scopePinia.clearScope();
325
+ clearAppFilters();
326
+ oipTrack("scope_cleared", {});
327
+ }
328
+
329
+ function setSelectedScope(scope: any) {
330
+ scopePinia.setScoped(true);
331
+ scopePinia.setSelectedScope(scope);
332
+
333
+ // Parse criteria if it's a string (backward compatibility)
334
+ let criteriaData = scope?.criteria;
335
+ if (typeof criteriaData === "string") {
336
+ try {
337
+ criteriaData = JSON.parse(criteriaData);
338
+ } catch (e) {
339
+ console.error("[useScopes] Error parsing criteria:", e);
340
+ criteriaData = [];
341
+ }
342
+ }
343
+
344
+ scopePinia.setScopeFilter(criteriaData);
345
+ oipTrack("scope_applied", { scopeId: scope?.id, title: scope?.title });
346
+ }
347
+
348
+ const getAppFilters = computed(() => {
349
+ return scopePinia.appFilters;
350
+ });
351
+
352
+ function clearAppFilters() {
353
+ scopePinia.clearAppFilters();
354
+ }
355
+
356
+ return {
357
+ scopes,
358
+ scopeLoading,
359
+ scopeError,
360
+ getScopes,
361
+ createScope,
362
+ updateScope,
363
+ setStarred,
364
+ setDefault,
365
+ shareScope,
366
+ deleteScope,
367
+ scopeList,
368
+ setSelectedScope,
369
+ selectedScope,
370
+ getAppFilters,
371
+ clearAppFilters,
372
+ clearScope,
373
+ scopeLoadingState,
374
+ };
375
+ };
376
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pages de vues : une vue enregistree sur une page vaut pour les pages de sa
3
+ * famille (portail, PHMEV...), et les anciennes cles de page restent lues.
4
+ * Les familles et les alias historiques sont propres a l'app (contrat
5
+ * #oip-app/views) ; la regle vit ici.
6
+ */
7
+ import { LEGACY_VIEW_PAGES, VIEW_PAGE_FAMILIES } from "#oip-app/views";
8
+ import { normalizePage } from "./viewTemplates";
9
+
10
+ /** La cle de page d'une vue, dans la forme du jour. */
11
+ export function canonicalViewPage(path: unknown): string {
12
+ const page = normalizePage(String(path ?? ""));
13
+ return LEGACY_VIEW_PAGES[page] ?? page;
14
+ }
15
+
16
+ /** Les pages dont les vues valent pour `path`, `path` compris. */
17
+ export function viewPageFamily(path: unknown): string[] {
18
+ const page = canonicalViewPage(path);
19
+ return VIEW_PAGE_FAMILIES.find((family) => family.includes(page)) ?? [page];
20
+ }
21
+
22
+ /**
23
+ * Deux chemins qui designent la meme page, ou deux pages d'une meme famille.
24
+ * Prefixe de langue et barre finale ne comptent pas : une vue posee par un
25
+ * lien profond porte le chemin sans barre finale, la page l'a (`trailingSlash`),
26
+ * et un lecteur en anglais enregistre sous `/en/...`.
27
+ */
28
+ export function sameViewPage(a: unknown, b: unknown): boolean {
29
+ return viewPageFamily(a).includes(canonicalViewPage(b));
30
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * viewTemplateCriteria - les criteres d'un template, tels qu'ils sont ranges.
3
+ *
4
+ * Un template publie par Tekkare est une vue enregistree, et une vue
5
+ * enregistree stocke des LIBELLES : « Insuffisant », « Oncologie: true »,
6
+ * « 30 derniers jours ». Ces libelles sont ecrits dans la langue de qui a
7
+ * pose les filtres. Range tel quel, un template ecrit en francais ouvre un
8
+ * ecran vide chez un lecteur anglais.
9
+ *
10
+ * On range donc la CLE plutot que le libelle a chaque fois qu'elle existe.
11
+ * Quatre mecanismes du catalogue en dur (`~/utils/viewTemplates`) traduisent
12
+ * leur valeur, et tous les quatre la tirent de l'i18n :
13
+ *
14
+ * | mecanisme | ce qui est traduit | ce qu'on range |
15
+ * |----------------------|-----------------------------|----------------|
16
+ * | periode relative | « 30 derniers jours » | `last30` |
17
+ * | filtre booleen | « Oncologie: true » | `oncology` |
18
+ * | onglet des avis HAS | « Indications » | `indic` |
19
+ * | page Prix | le NOM du filtre | sa cle i18n |
20
+ *
21
+ * Le reste des valeurs est neutre : « Liste en sus », « Commercialisée »,
22
+ * « Brand », les marques, les codes ATC, les laboratoires. Elles viennent des
23
+ * index, sortis de sources francaises, et un lecteur anglais les voit deja
24
+ * telles quelles aujourd'hui. Rien a faire pour elles.
25
+ *
26
+ * Reste une poignee de filtres dont les valeurs existent vraiment en deux
27
+ * orthographes, parce que l'index porte un champ `_fr` et un champ `_en`
28
+ * (cf. `LANGUAGE_BOUND`). Leur paire ne vit ni dans l'i18n ni dans le code :
29
+ * la resoudre demande une requete au cluster. La v1 ne la resout pas, elle
30
+ * range la valeur telle quelle avec la langue d'ecriture, et le dialogue de
31
+ * publication previent. Le jour ou le resolveur arrive, il remplit le cote
32
+ * manquant sans toucher une seule ligne deja enregistree : c'est tout
33
+ * l'interet de ranger une forme, et pas une chaine.
34
+ */
35
+ import {
36
+ HAS_TABS,
37
+ LANGUAGE_BOUND,
38
+ PERIOD_FILTER,
39
+ PERIOD_SHORTCUTS,
40
+ TRANSLATED_FILTER_NAMES,
41
+ } from "#oip-app/views";
42
+ import { VIEW_PREFS_FILTER } from "../composables/useViews";
43
+
44
+ export { LANGUAGE_BOUND };
45
+
46
+ /** Langue d'ecriture d'un template. */
47
+ export type TemplateLang = "fr" | "en";
48
+
49
+ export interface TemplateCriteriaContext {
50
+ lang: TemplateLang;
51
+ t: (key: string) => string;
52
+ te: (key: string) => boolean;
53
+ /** Prefixe i18n des libelles de filtres de la page, sans le point final. */
54
+ namespace: string;
55
+ }
56
+
57
+ /**
58
+ * Un critere range. `kind` dit comment le relire :
59
+ * - `values` : les valeurs telles quelles, eventuellement dans les deux
60
+ * langues quand la paire est connue ;
61
+ * - `check` / `tab` : une cle i18n, dont on refait le libelle a la lecture ;
62
+ * - `period` : un raccourci de periode, qui reste glissant.
63
+ */
64
+ export interface StoredCriterion {
65
+ filter: string;
66
+ /** Cle i18n du NOM du filtre, quand c'est lui qui est traduit (page Prix). */
67
+ filterKey?: string;
68
+ kind: "values" | "check" | "tab" | "period";
69
+ /** `kind: "values"` sans paire connue. */
70
+ values?: string[];
71
+ /** `kind: "values"` avec sa paire, quand elle est connue. */
72
+ localized?: Record<TemplateLang, string[]>;
73
+ /** `kind` autre que `values` : la cle a relire. */
74
+ key?: string;
75
+ /** Langue d'ecriture, pour les valeurs qu'on ne sait pas encore traduire. */
76
+ lang?: TemplateLang;
77
+ }
78
+
79
+ // Filtres et libelles propres a l'app (periode, onglets, filtres traduits,
80
+ // filtres lies a la langue) : contrat #oip-app/views.
81
+
82
+
83
+ /** Les valeurs d'un critere, que la periode les garde en chaine ou non. */
84
+ function valuesOf(item: any): string[] {
85
+ if (Array.isArray(item?.values)) return item.values.map(String);
86
+ return item?.values !== undefined && item?.values !== null ? [String(item.values)] : [];
87
+ }
88
+
89
+ /** La cle dont la traduction vaut `label`, parmi `keys`. */
90
+ function keyOf(
91
+ keys: string[],
92
+ prefix: string,
93
+ label: string,
94
+ ctx: TemplateCriteriaContext,
95
+ ): string | null {
96
+ return (
97
+ keys.find((key) => ctx.te(`${prefix}${key}`) && ctx.t(`${prefix}${key}`) === label) ?? null
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Range les criteres d'un ecran, dans la langue de qui les a poses.
103
+ *
104
+ * Ce qui n'est reconnu par aucun des quatre mecanismes part en valeurs
105
+ * telles quelles : c'est le cas de la grande majorite, et c'est le bon
106
+ * defaut — une valeur neutre rangee comme neutre se relit a l'identique.
107
+ */
108
+ export function encodeCriteria(
109
+ criteria: any[],
110
+ ctx: TemplateCriteriaContext,
111
+ ): StoredCriterion[] {
112
+ return (criteria ?? []).map((item) => {
113
+ const filter = String(item?.filter ?? "");
114
+ const values = valuesOf(item);
115
+
116
+ // Les preferences de cartes ne sont pas un filtre : elles voyagent dans
117
+ // les criteres faute d'un autre champ libre, et ne se traduisent pas.
118
+ if (filter === VIEW_PREFS_FILTER) {
119
+ return { filter, kind: "values" as const, values };
120
+ }
121
+
122
+ // Le NOM du filtre est traduit : on range sa cle, pas son libelle.
123
+ const filterKey = TRANSLATED_FILTER_NAMES.find(
124
+ (key) => ctx.te(key) && ctx.t(key) === filter,
125
+ );
126
+ if (filterKey) {
127
+ return { filter, filterKey, kind: "values" as const, values, lang: ctx.lang };
128
+ }
129
+
130
+ // Periode relative : le raccourci reste glissant, les deux dates non.
131
+ if (filter === PERIOD_FILTER && values.length === 1) {
132
+ const key = keyOf(PERIOD_SHORTCUTS, "heart.period.", values[0]!, ctx);
133
+ if (key) return { filter, kind: "period" as const, key };
134
+ }
135
+
136
+ // Filtre booleen : la vue stocke « <libelle du filtre>: true ».
137
+ if (values.length === 1 && values[0]!.endsWith(": true")) {
138
+ const label = values[0]!.slice(0, -": true".length);
139
+ const key = `${ctx.namespace}.${filter}`;
140
+ if (ctx.te(key) && ctx.t(key) === label) {
141
+ return { filter, kind: "check" as const, key };
142
+ }
143
+ }
144
+
145
+ // Onglet des avis HAS.
146
+ if (filter === "Type" && values.length === 1) {
147
+ const key = keyOf(HAS_TABS, "heart.tabs.", values[0]!, ctx);
148
+ if (key) return { filter, kind: "tab" as const, key };
149
+ }
150
+
151
+ return {
152
+ filter,
153
+ kind: "values" as const,
154
+ values,
155
+ ...(LANGUAGE_BOUND.has(filter) && { lang: ctx.lang }),
156
+ };
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Relit des criteres ranges, dans la langue de l'ecran.
162
+ *
163
+ * La periode rend une CHAINE et non un tableau : c'est ce que `rangePicker`
164
+ * attend, et c'est ce qui la garde relative.
165
+ */
166
+ export function decodeCriteria(
167
+ stored: StoredCriterion[],
168
+ ctx: TemplateCriteriaContext,
169
+ ): Array<{ filter: string; values: string[] | string }> {
170
+ return (stored ?? []).map((item) => {
171
+ const filter = item.filterKey && ctx.te(item.filterKey)
172
+ ? ctx.t(item.filterKey)
173
+ : item.filter;
174
+
175
+ switch (item.kind) {
176
+ case "period":
177
+ return {
178
+ filter,
179
+ values: item.key && ctx.te(`heart.period.${item.key}`)
180
+ ? ctx.t(`heart.period.${item.key}`)
181
+ : (item.values?.[0] ?? ""),
182
+ };
183
+ case "check":
184
+ return {
185
+ filter,
186
+ values: [`${item.key && ctx.te(item.key) ? ctx.t(item.key) : filter}: true`],
187
+ };
188
+ case "tab":
189
+ return {
190
+ filter,
191
+ values: [
192
+ item.key && ctx.te(`heart.tabs.${item.key}`)
193
+ ? ctx.t(`heart.tabs.${item.key}`)
194
+ : (item.values?.[0] ?? ""),
195
+ ],
196
+ };
197
+ default:
198
+ return { filter, values: item.localized?.[ctx.lang] ?? item.values ?? [] };
199
+ }
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Les filtres qui resteront dans la langue d'ecriture, parmi des criteres
205
+ * ranges. Ce que le dialogue de publication annonce avant d'enregistrer.
206
+ */
207
+ export function languageBoundFilters(stored: StoredCriterion[]): string[] {
208
+ return [
209
+ ...new Set(
210
+ (stored ?? [])
211
+ .filter((item) => item.kind === "values" && !item.localized && LANGUAGE_BOUND.has(item.filter))
212
+ .map((item) => item.filter),
213
+ ),
214
+ ];
215
+ }