@saasicat/ui-vue 0.7.0 → 0.9.0

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 (41) hide show
  1. package/dist/{chunk-A3W2N26K.js → chunk-66BUSVC5.js} +68 -217
  2. package/dist/{chunk-X5SQ5HMB.js → chunk-TBLXKSTR.js} +0 -137
  3. package/dist/{chunk-QZOSDCNP.js → chunk-TY2WYKME.js} +1 -1
  4. package/dist/client/index.cjs +180 -463
  5. package/dist/client/index.d.cts +32 -57
  6. package/dist/client/index.d.ts +32 -57
  7. package/dist/client/index.js +4 -6
  8. package/dist/index.cjs +273 -580
  9. package/dist/index.d.cts +10 -55
  10. package/dist/index.d.ts +10 -55
  11. package/dist/index.js +76 -101
  12. package/dist/{messages-7b0P8W75.d.ts → messages-BIYw32u1.d.cts} +0 -67
  13. package/dist/{messages-7b0P8W75.d.cts → messages-BIYw32u1.d.ts} +0 -67
  14. package/dist/quasar/index.cjs +0 -136
  15. package/dist/quasar/index.d.cts +2 -2
  16. package/dist/quasar/index.d.ts +2 -2
  17. package/dist/quasar/index.js +2 -2
  18. package/dist/{use-super-admin-i18n-B3v3-SWZ.d.ts → use-super-admin-i18n-CWNu_W3u.d.ts} +1 -1
  19. package/dist/{use-super-admin-i18n-DMktRVEt.d.cts → use-super-admin-i18n-DL_qI1BY.d.cts} +1 -1
  20. package/package.json +5 -5
  21. package/src/client/admin-resource-client.ts +112 -0
  22. package/src/client/i18n/messages/nav.ts +0 -2
  23. package/src/client/i18n/messages/plan-versions.ts +0 -143
  24. package/src/client/index.ts +1 -2
  25. package/src/client/nav-builder.ts +9 -4
  26. package/src/index.ts +0 -1
  27. package/src/pages-standard/AdminLayout.vue +1 -1
  28. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +1 -1
  29. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +1 -1
  30. package/src/pages-standard/plan-versions/catalog-history.types.ts +37 -0
  31. package/src/pages-standard/plan-versions/format.ts +2 -38
  32. package/src/client/plan-versions-catalog.ts +0 -380
  33. package/src/pages-standard/PlanVersionsPage.vue +0 -220
  34. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +0 -309
  35. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +0 -48
  36. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +0 -326
  37. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +0 -65
  38. package/src/pages-standard/plan-versions/PlanVersionsList.vue +0 -315
  39. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +0 -261
  40. package/src/pages-standard/plan-versions/types.ts +0 -33
  41. package/src/vue/use-plan-versions.ts +0 -83
@@ -25,7 +25,6 @@ export const DEFAULT_STANDARD_PAGE_ROUTES: Record<StandardPageKey, string> = {
25
25
  subscriptions: '/admin/subscriptions',
26
26
  promoCodes: '/admin/promo-codes',
27
27
  plans: '/admin/plans',
28
- planVersions: '/admin/plan-versions',
29
28
  audit: '/admin/audit',
30
29
  users: '/admin/users',
31
30
  pilots: '/admin/pilots',
@@ -36,6 +35,10 @@ export const DEFAULT_STANDARD_PAGE_ROUTES: Record<StandardPageKey, string> = {
36
35
  platformEmailHistory: '/admin/platform-email-history',
37
36
  };
38
37
 
38
+ function isSupportedStandardPageKey(key: string): key is StandardPageKey {
39
+ return Object.hasOwn(DEFAULT_STANDARD_PAGE_ROUTES, key);
40
+ }
41
+
39
42
  export interface BuildRouteEntry {
40
43
  /** Stable identifier — the key for standard pages, the `id` for project pages. */
41
44
  id: string;
@@ -92,7 +95,6 @@ const DEFAULT_ICONS: Record<StandardPageKey, string> = {
92
95
  subscriptions: 'card_membership',
93
96
  promoCodes: 'local_activity',
94
97
  plans: 'workspace_premium',
95
- planVersions: 'history_edu',
96
98
  audit: 'history',
97
99
  users: 'people',
98
100
  pilots: 'flight_takeoff',
@@ -110,7 +112,6 @@ const PAGE_SECTIONS: Partial<Record<StandardPageKey, SectionKey>> = {
110
112
  discovery: 'catalog',
111
113
  bundles: 'catalog',
112
114
  plans: 'catalog',
113
- planVersions: 'catalog',
114
115
  marketingCatalog: 'catalog',
115
116
  promoCodes: 'catalog',
116
117
  tenants: 'customers',
@@ -157,7 +158,11 @@ export function buildRoutes(
157
158
  const overrideIcons = { ...DEFAULT_ICONS, ...options.standardPageIcons };
158
159
  const overrideNavSection = { ...defaultNavSections, ...options.standardPageNavSection };
159
160
 
160
- for (const key of Object.keys(standard) as StandardPageKey[]) {
161
+ for (const key of Object.keys(standard)) {
162
+ // Runtime manifests can come from an older/newer backend or the ETag
163
+ // cache. Ignore standard pages this UI build does not know instead of
164
+ // emitting navigation entries with undefined path/label/icon values.
165
+ if (!isSupportedStandardPageKey(key)) continue;
161
166
  const def = standard[key] as StandardPageDef | undefined;
162
167
  if (!def?.enabled) continue;
163
168
  const cap = def.requiredCapability ?? null;
package/src/index.ts CHANGED
@@ -50,7 +50,6 @@ export * from './vue/feature-router-guard.js';
50
50
  export * from './vue/use-tenant-billing-catalog.js';
51
51
  export * from './vue/use-tenant-billing.js';
52
52
  export * from './vue/use-subscription-draft.js';
53
- export * from './vue/use-plan-versions.js';
54
53
  export * from './vue/use-bulk-publish.js';
55
54
  export * from './vue/use-plan-editor.js';
56
55
  export * from './vue/use-public-boot.js';
@@ -112,7 +112,7 @@ import { ADMIN_UI_VERSION } from '../client/version.js';
112
112
  // - `manifest` : loaded AdminManifest (or null before auth)
113
113
  // - `staticNavFallback` : sidebar before manifest load (no flicker)
114
114
  // - `localItems` : additional sidebar items not present in the
115
- // manifest (e.g. an app-specific "Plan-Versionen" page)
115
+ // manifest
116
116
  // - `availableExtensions` : set for ProjectPage capability filter
117
117
  // - `standardPageRoutes`: override per StandardPageKey (e.g.
118
118
  // `dashboard` → '/admin/' instead of '/admin/dashboard')
@@ -44,8 +44,8 @@
44
44
  <script setup lang="ts">
45
45
  import { computed } from 'vue';
46
46
  import type { VersionChange } from '@saasicat/types';
47
- import type { CatalogSnapshot, ResolvedPlan } from '../../client/plan-versions-catalog.js';
48
47
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
48
+ import type { CatalogSnapshot, ResolvedPlan } from './catalog-history.types.js';
49
49
  import PlanDiffCard from './PlanDiffCard.vue';
50
50
 
51
51
  interface SnapshotEntityDiff {
@@ -72,9 +72,9 @@
72
72
 
73
73
  <script setup lang="ts">
74
74
  import { computed } from 'vue';
75
- import type { CatalogSnapshot } from '../../client/plan-versions-catalog.js';
76
75
  import { formatMessage } from '../../client/i18n/format.js';
77
76
  import { useSaMessages, useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
77
+ import type { CatalogSnapshot } from './catalog-history.types.js';
78
78
  import { formatRelative } from './format.js';
79
79
 
80
80
  const props = defineProps<{
@@ -0,0 +1,37 @@
1
+ import type { PlanVersionRow } from '@saasicat/types';
2
+
3
+ /**
4
+ * Presentation contract retained for the reusable catalog timeline and diff
5
+ * components. A future Publication Archive / Catalog History page should map
6
+ * the immutable snapshots from saasicat#30/#35 to this shape.
7
+ */
8
+ export type CatalogSnapshotKind = 'drafts' | 'active' | 'historical';
9
+
10
+ export interface CatalogSnapshot<P extends PlanVersionRow = PlanVersionRow> {
11
+ id: string;
12
+ kind: CatalogSnapshotKind;
13
+ status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
14
+ label: string;
15
+ title: string;
16
+ description: string;
17
+ asOf: string | null;
18
+ createdAt: string | null;
19
+ publishedAt: string | null;
20
+ authorEmail: string | null;
21
+ plans: ResolvedPlan<P>[];
22
+ draftCount: number;
23
+ regressionCount: number;
24
+ }
25
+
26
+ export interface ResolvedPlan<P extends PlanVersionRow = PlanVersionRow> {
27
+ source: P;
28
+ liveBase: P | null;
29
+ isDraft: boolean;
30
+ planId: string;
31
+ features: string[];
32
+ quotas: Record<string, number>;
33
+ monthlyNet: number;
34
+ yearlyNet: number;
35
+ marketed: boolean;
36
+ version: number;
37
+ }
@@ -1,13 +1,7 @@
1
- // Formatting helpers for the plan-versions views.
2
- //
3
- // Phase 2c: Ported from a consumer admin.
4
- // The time reference point for `formatRelative` is the moment of the call,
5
- // not a hard-coded demo date (the ported template had `2026-05-04` —
6
- // the platform must work in production).
1
+ // Formatting helper retained for the reusable catalog-history timeline.
7
2
 
8
- import { formatCurrency } from '../../client/i18n/currency.js';
9
3
  import { formatMessage } from '../../client/i18n/format.js';
10
- import { DEFAULT_SA_LOCALE, SA_INTL_LOCALES, type SaLocale } from '../../client/i18n/locale.js';
4
+ import { DEFAULT_SA_LOCALE, type SaLocale } from '../../client/i18n/locale.js';
11
5
  import { planVersionsMessages } from '../../client/i18n/messages/plan-versions.js';
12
6
 
13
7
  const EMPTY_VALUE = '—';
@@ -16,17 +10,6 @@ function texts(locale: SaLocale) {
16
10
  return planVersionsMessages[locale].format;
17
11
  }
18
12
 
19
- export function fmtEuro(n: number, locale: SaLocale = DEFAULT_SA_LOCALE): string {
20
- if (n === 0) return texts(locale).priceIndividual;
21
- return formatCurrency(n, locale);
22
- }
23
-
24
- export function fmtStorage(gb: number): string {
25
- if (gb === 0) return '500 MB';
26
- if (gb >= 1000 && gb % 1000 === 0) return `${gb / 1000} TB`;
27
- return `${gb} GB`;
28
- }
29
-
30
13
  export function formatRelative(
31
14
  iso: string | undefined,
32
15
  locale: SaLocale = DEFAULT_SA_LOCALE,
@@ -48,22 +31,3 @@ export function formatRelative(
48
31
  const y = Math.floor(days / 365);
49
32
  return formatMessage(y > 1 ? msg.yearsAgoMany : msg.yearsAgoOne, { count: y });
50
33
  }
51
-
52
- export function formatDate(iso: string | undefined, locale: SaLocale = DEFAULT_SA_LOCALE): string {
53
- if (!iso) return EMPTY_VALUE;
54
- return new Date(iso).toLocaleDateString(SA_INTL_LOCALES[locale], {
55
- day: '2-digit',
56
- month: 'short',
57
- year: 'numeric',
58
- });
59
- }
60
-
61
- export function formatTimestamp(iso: string, locale: SaLocale = DEFAULT_SA_LOCALE): string {
62
- return new Date(iso).toLocaleString(SA_INTL_LOCALES[locale], {
63
- day: '2-digit',
64
- month: 'short',
65
- year: 'numeric',
66
- hour: '2-digit',
67
- minute: '2-digit',
68
- });
69
- }
@@ -1,380 +0,0 @@
1
- // Catalog snapshot builder — projects PlanVersion rows onto synthetic
2
- // catalog snapshots that the plan-versions UI renders.
3
- //
4
- // The backend knows no catalog snapshots; we derive them from the real
5
- // `publishedAt` / `supersededAt` events. Three snapshot kinds:
6
- //
7
- // - `drafts` : Hypothetical "after publishing all open drafts" state
8
- // - `active` : Currently live (publishedAt set, supersededAt null)
9
- // - `historical` : One snapshot per publish event, before `active`
10
- //
11
- // Plan IDs are sorted alphabetically; consumers may enforce a preferred
12
- // order via `planSortOrder` (e.g.
13
- // BASIC < STANDARD < PROFESSIONAL < BUSINESS < ENTERPRISE).
14
- //
15
- // Phase 2b: ported from a consumer admin,
16
- // app-specific fields kept open via generics.
17
-
18
- import type { PlanVersionRow } from '@saasicat/types';
19
-
20
- import { formatMessage } from './i18n/format.js';
21
- import { DEFAULT_SA_LOCALE, SA_INTL_LOCALES, type SaLocale } from './i18n/locale.js';
22
- import { planVersionsMessages } from './i18n/messages/plan-versions.js';
23
-
24
- export type SnapshotKind = 'drafts' | 'active' | 'historical';
25
-
26
- export interface CatalogSnapshot<P extends PlanVersionRow = PlanVersionRow> {
27
- id: string;
28
- kind: SnapshotKind;
29
- status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
30
- label: string;
31
- title: string;
32
- description: string;
33
-
34
- asOf: string | null;
35
- createdAt: string | null;
36
- publishedAt: string | null;
37
- authorEmail: string | null;
38
-
39
- plans: ResolvedPlan<P>[];
40
-
41
- /** Number of open drafts for the `drafts` snapshot, otherwise 0. */
42
- draftCount: number;
43
- /** Number of entities in this snapshot whose publication flagged at least
44
- * one regression (`nonRegressive === false`). */
45
- regressionCount: number;
46
- }
47
-
48
- export interface ResolvedPlan<P extends PlanVersionRow = PlanVersionRow> {
49
- /** Which PlanVersionRow was selected to represent this slot. */
50
- source: P;
51
- /** Live predecessor (only set when `source` is a DRAFT; otherwise null). */
52
- liveBase: P | null;
53
- isDraft: boolean;
54
- planId: string;
55
- features: string[];
56
- /** Quota map (from `source.quotas` or legacy fields; empty if none). */
57
- quotas: Record<string, number>;
58
- monthlyNet: number;
59
- yearlyNet: number;
60
- marketed: boolean;
61
- version: number;
62
-
63
- // Legacy quota mirrors for UI components that have not yet switched to
64
- // `quotas[key]`. Mirrored from `quotas['users'] / 'vehicles' / 'storageGb'`;
65
- // for apps without these quotas they stay undefined.
66
- /** @deprecated Read from `quotas['users']`. */
67
- maxUsers?: number;
68
- /** @deprecated Legacy field; read from `quotas['vehicles']`. */
69
- maxVehicles?: number;
70
- /** @deprecated Read from `quotas['storageGb']`. */
71
- maxStorageGb?: number;
72
- }
73
-
74
- export interface RawCatalogData<P extends PlanVersionRow = PlanVersionRow> {
75
- planVersions: P[];
76
- }
77
-
78
- export interface BuildSnapshotsOptions {
79
- /**
80
- * App-specific plan order, e.g. `['BASIC', 'STANDARD',
81
- * 'PROFESSIONAL', 'BUSINESS', 'ENTERPRISE']`. Plan IDs outside the list
82
- * end up sorted alphabetically at the back.
83
- */
84
- planSortOrder?: readonly string[];
85
- /** UI locale for the generated snapshot labels/titles/descriptions. */
86
- locale?: SaLocale;
87
- }
88
-
89
- // ─── Helpers ─────────────────────────────────────────────────────────────
90
-
91
- const dec = (s: string): number => Number(s);
92
-
93
- function extractQuotas(p: PlanVersionRow): Record<string, number> {
94
- if (p.quotas && Object.keys(p.quotas).length > 0) {
95
- return { ...p.quotas };
96
- }
97
- // Legacy fallback: collect the flat fields.
98
- const out: Record<string, number> = {};
99
- if (typeof p.maxUsers === 'number') out.users = p.maxUsers;
100
- if (typeof p.maxVehicles === 'number') out.vehicles = p.maxVehicles;
101
- if (typeof p.maxStorageGb === 'number') out.storageGb = p.maxStorageGb;
102
- return out;
103
- }
104
-
105
- const sortByPublishedDesc = <T extends { publishedAt: string | null; version: number }>(
106
- rows: T[],
107
- ): T[] =>
108
- [...rows].sort((a, b) => {
109
- const at = a.publishedAt ? Date.parse(a.publishedAt) : Number.POSITIVE_INFINITY;
110
- const bt = b.publishedAt ? Date.parse(b.publishedAt) : Number.POSITIVE_INFINITY;
111
- if (at !== bt) return bt - at;
112
- return b.version - a.version;
113
- });
114
-
115
- /**
116
- * Finds the live version valid for an entity slot at the point in time `asOf`.
117
- * A version was live when `publishedAt <= asOf` and (`supersededAt == null`
118
- * or `supersededAt > asOf`) held.
119
- */
120
- function findLiveAt<
121
- T extends { publishedAt: string | null; supersededAt: string | null; version: number },
122
- >(rows: T[], asOfMs: number): T | null {
123
- const candidates = rows.filter((r) => {
124
- if (r.publishedAt === null) return false;
125
- if (Date.parse(r.publishedAt) > asOfMs) return false;
126
- if (r.supersededAt !== null && Date.parse(r.supersededAt) <= asOfMs) return false;
127
- return true;
128
- });
129
- if (candidates.length === 0) return null;
130
- return [...candidates].sort((a, b) => b.version - a.version)[0] ?? null;
131
- }
132
-
133
- function findCurrentLive<T extends { publishedAt: string | null; supersededAt: string | null }>(
134
- rows: T[],
135
- ): T | null {
136
- return rows.find((r) => r.publishedAt !== null && r.supersededAt === null) ?? null;
137
- }
138
-
139
- function findDraft<T extends { publishedAt: string | null }>(rows: T[]): T | null {
140
- return rows.find((r) => r.publishedAt === null) ?? null;
141
- }
142
-
143
- function groupBy<T, K extends string>(rows: T[], keyOf: (r: T) => K): Map<K, T[]> {
144
- const out = new Map<K, T[]>();
145
- for (const r of rows) {
146
- const k = keyOf(r);
147
- const list = out.get(k) ?? [];
148
- list.push(r);
149
- out.set(k, list);
150
- }
151
- return out;
152
- }
153
-
154
- // ─── Resolver ────────────────────────────────────────────────────────────
155
-
156
- function resolvePlan<P extends PlanVersionRow>(source: P, liveBase: P | null): ResolvedPlan<P> {
157
- const quotas = extractQuotas(source);
158
- return {
159
- source,
160
- liveBase,
161
- isDraft: source.publishedAt === null,
162
- planId: source.planId,
163
- features: source.features,
164
- quotas,
165
- monthlyNet: dec(source.monthlyNet),
166
- yearlyNet: dec(source.yearlyNet),
167
- marketed: source.marketed,
168
- version: source.version,
169
- maxUsers: quotas.users,
170
- maxVehicles: quotas.vehicles,
171
- maxStorageGb: quotas.storageGb,
172
- };
173
- }
174
-
175
- // ─── Snapshot constructors ───────────────────────────────────────────────
176
-
177
- type CatalogMessages = (typeof planVersionsMessages)['de']['catalog'];
178
-
179
- function draftsTitle(draftCount: number, msg: CatalogMessages): string {
180
- if (draftCount === 0) return msg.draftsTitleEmpty;
181
- const template = draftCount === 1 ? msg.draftsTitleOne : msg.draftsTitleMany;
182
- return formatMessage(template, { count: draftCount });
183
- }
184
-
185
- function dateLabel(iso: string, locale: SaLocale): string {
186
- return new Date(iso).toLocaleDateString(SA_INTL_LOCALES[locale], {
187
- day: '2-digit',
188
- month: 'short',
189
- year: 'numeric',
190
- });
191
- }
192
-
193
- function buildDraftsSnapshot<P extends PlanVersionRow>(
194
- data: RawCatalogData<P>,
195
- sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
196
- locale: SaLocale,
197
- ): CatalogSnapshot<P> {
198
- const msg = planVersionsMessages[locale].catalog;
199
- const planByKey = groupBy(data.planVersions, (r) => r.planId);
200
-
201
- const plans: ResolvedPlan<P>[] = [];
202
- for (const [, rows] of planByKey) {
203
- const draft = findDraft(rows);
204
- const live = findCurrentLive(rows);
205
- if (draft) plans.push(resolvePlan(draft, live));
206
- else if (live) plans.push(resolvePlan(live, null));
207
- }
208
-
209
- const draftCount = plans.filter((p) => p.isDraft).length;
210
-
211
- return {
212
- id: 'drafts',
213
- kind: 'drafts',
214
- status: 'DRAFT',
215
- label: msg.draftsLabel,
216
- title: draftsTitle(draftCount, msg),
217
- description: draftCount === 0 ? msg.draftsDescriptionEmpty : msg.draftsDescription,
218
- asOf: null,
219
- createdAt: null,
220
- publishedAt: null,
221
- authorEmail: null,
222
- plans: sortPlansFn(plans),
223
- draftCount,
224
- regressionCount: 0,
225
- };
226
- }
227
-
228
- function buildActiveSnapshot<P extends PlanVersionRow>(
229
- data: RawCatalogData<P>,
230
- sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
231
- locale: SaLocale,
232
- ): CatalogSnapshot<P> | null {
233
- const msg = planVersionsMessages[locale].catalog;
234
- const planByKey = groupBy(data.planVersions, (r) => r.planId);
235
-
236
- const plans: ResolvedPlan<P>[] = [];
237
- for (const [, rows] of planByKey) {
238
- const live = findCurrentLive(rows);
239
- if (live) plans.push(resolvePlan(live, null));
240
- }
241
- if (plans.length === 0) return null;
242
-
243
- const publishedDates = plans
244
- .map((p) => p.source.publishedAt)
245
- .filter((t): t is string => t !== null)
246
- .sort();
247
- const latestPublished = publishedDates[publishedDates.length - 1];
248
-
249
- return {
250
- id: 'active',
251
- kind: 'active',
252
- status: 'ACTIVE',
253
- label: msg.activeLabel,
254
- title: msg.activeTitle,
255
- description: msg.activeDescription,
256
- asOf: latestPublished ?? null,
257
- createdAt: null,
258
- publishedAt: latestPublished ?? null,
259
- authorEmail: null,
260
- plans: sortPlansFn(plans),
261
- draftCount: 0,
262
- regressionCount: 0,
263
- };
264
- }
265
-
266
- function buildHistoricalSnapshots<P extends PlanVersionRow>(
267
- data: RawCatalogData<P>,
268
- sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
269
- locale: SaLocale,
270
- ): CatalogSnapshot<P>[] {
271
- const msg = planVersionsMessages[locale].catalog;
272
- const allPublishedAt = new Set<string>();
273
- for (const r of data.planVersions) if (r.publishedAt) allPublishedAt.add(r.publishedAt);
274
- if (allPublishedAt.size === 0) return [];
275
-
276
- const planByKey = groupBy(data.planVersions, (r) => r.planId);
277
-
278
- // The active snapshot overlaps with the last publish event. We take the
279
- // latest one in `active`, and map only the events *before* it here.
280
- const liveTimestamps = new Set<string>();
281
- for (const rows of planByKey.values()) {
282
- const live = findCurrentLive(rows);
283
- if (live?.publishedAt) liveTimestamps.add(live.publishedAt);
284
- }
285
- const liveSorted = [...liveTimestamps].sort();
286
- const latestLive = liveSorted[liveSorted.length - 1];
287
-
288
- const sortedDesc = [...allPublishedAt].sort((a, b) => Date.parse(b) - Date.parse(a));
289
-
290
- const out: CatalogSnapshot<P>[] = [];
291
- for (const ts of sortedDesc) {
292
- if (latestLive && Date.parse(ts) >= Date.parse(latestLive)) continue;
293
- const tMs = Date.parse(ts) + 1; // *after* the event
294
- const plans: ResolvedPlan<P>[] = [];
295
- for (const [, rows] of planByKey) {
296
- const live = findLiveAt(rows, tMs);
297
- if (live) plans.push(resolvePlan(live, null));
298
- }
299
- if (plans.length === 0) continue;
300
-
301
- const publishedAtThisEvent: P[] = data.planVersions.filter((r) => r.publishedAt === ts);
302
- const regressionCount = publishedAtThisEvent.filter((r) => !r.nonRegressive).length;
303
- const author = publishedAtThisEvent[0]?.publishedByUserId ?? null;
304
-
305
- out.push({
306
- id: `hist-${ts}`,
307
- kind: 'historical',
308
- status: 'ARCHIVED',
309
- label: dateLabel(ts, locale),
310
- title: summarizeEvent(publishedAtThisEvent, msg),
311
- description: detailEvent(publishedAtThisEvent, msg),
312
- asOf: ts,
313
- createdAt: ts,
314
- publishedAt: ts,
315
- authorEmail: author,
316
- plans: sortPlansFn(plans),
317
- draftCount: 0,
318
- regressionCount,
319
- });
320
- }
321
- return out;
322
- }
323
-
324
- function summarizeEvent(rows: PlanVersionRow[], msg: CatalogMessages): string {
325
- if (rows.length === 0) return msg.publishEvent;
326
- if (rows.length === 1) {
327
- const r = rows[0]!;
328
- return formatMessage(msg.publishEventSingle, { planId: r.planId, version: r.version });
329
- }
330
- return formatMessage(msg.publishEventMultiple, { count: rows.length });
331
- }
332
-
333
- function detailEvent(rows: PlanVersionRow[], msg: CatalogMessages): string {
334
- if (rows.length === 0) return '';
335
- const note = rows[0]?.changeNote ?? '';
336
- return note.length > 0 ? note : msg.reconstructedSnapshot;
337
- }
338
-
339
- // ─── Sorting ─────────────────────────────────────────────────────────────
340
-
341
- function sortPlansBy<P extends PlanVersionRow>(
342
- order: readonly string[] | undefined,
343
- ): (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[] {
344
- if (!order || order.length === 0) {
345
- return (rows) => [...rows].sort((a, b) => a.planId.localeCompare(b.planId));
346
- }
347
- const idx: Record<string, number> = {};
348
- for (let i = 0; i < order.length; i++) idx[order[i]!] = i;
349
- return (rows) =>
350
- [...rows].sort((a, b) => {
351
- const ai = idx[a.planId] ?? Number.POSITIVE_INFINITY;
352
- const bi = idx[b.planId] ?? Number.POSITIVE_INFINITY;
353
- if (ai !== bi) return ai - bi;
354
- return a.planId.localeCompare(b.planId);
355
- });
356
- }
357
-
358
- // ─── Public API ──────────────────────────────────────────────────────────
359
-
360
- export function buildSnapshots<P extends PlanVersionRow>(
361
- data: RawCatalogData<P>,
362
- options: BuildSnapshotsOptions = {},
363
- ): CatalogSnapshot<P>[] {
364
- const locale = options.locale ?? DEFAULT_SA_LOCALE;
365
- const sortPlansFn = sortPlansBy<P>(options.planSortOrder);
366
- const drafts = buildDraftsSnapshot(data, sortPlansFn, locale);
367
- const active = buildActiveSnapshot(data, sortPlansFn, locale);
368
- const historical = buildHistoricalSnapshots(data, sortPlansFn, locale);
369
-
370
- const out: CatalogSnapshot<P>[] = [drafts];
371
- if (active) out.push(active);
372
- out.push(...historical);
373
- return out;
374
- }
375
-
376
- export function listOpenDrafts<P extends PlanVersionRow>(data: RawCatalogData<P>): { plans: P[] } {
377
- return {
378
- plans: sortByPublishedDesc(data.planVersions).filter((r) => r.publishedAt === null),
379
- };
380
- }