@igstack/app-catalog-frontend-core 2.0.4 → 3.0.0-alpha-20260917212201

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 (26) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/utils/areaGrouping.test.d.ts +1 -0
  2. package/dist/esm/__tests__/modules/pwa/PwaAutoUpdateController.test.d.ts +1 -0
  3. package/dist/esm/index.d.ts +2 -1
  4. package/dist/esm/index.js +2 -0
  5. package/dist/esm/index.js.map +1 -1
  6. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.d.ts +6 -3
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +112 -32
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +0 -5
  10. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/utils/areaGrouping.d.ts +22 -0
  12. package/dist/esm/modules/appCatalog/utils/areaGrouping.js +46 -0
  13. package/dist/esm/modules/appCatalog/utils/areaGrouping.js.map +1 -0
  14. package/dist/esm/modules/pwa/PwaAutoUpdateController.d.ts +8 -0
  15. package/dist/esm/modules/pwa/PwaAutoUpdateController.js +15 -1
  16. package/dist/esm/modules/pwa/PwaAutoUpdateController.js.map +1 -1
  17. package/dist/esm/types/uiSettings.d.ts +28 -0
  18. package/package.json +4 -4
  19. package/src/__tests__/modules/appCatalog/utils/areaGrouping.test.ts +83 -0
  20. package/src/__tests__/modules/pwa/PwaAutoUpdateController.test.ts +61 -0
  21. package/src/index.tsx +2 -1
  22. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +159 -34
  23. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +0 -10
  24. package/src/modules/appCatalog/utils/areaGrouping.ts +85 -0
  25. package/src/modules/pwa/PwaAutoUpdateController.ts +19 -3
  26. package/src/types/uiSettings.ts +30 -0
@@ -27,6 +27,14 @@ export declare class PwaAutoUpdateController {
27
27
  private onUserActivity;
28
28
  private checkIdleState;
29
29
  private checkForUpdate;
30
+ /**
31
+ * A freshly installed worker stays in `waiting` for as long as this page is
32
+ * controlled by the old one — a reload does not release it, only closing
33
+ * every tab on the origin does. Ask it to take over now; the generated
34
+ * worker answers this message with `skipWaiting()`, and the registration's
35
+ * own `activated` listener reloads the page onto the new build.
36
+ */
37
+ private activateWaitingWorker;
30
38
  private reload;
31
39
  private log;
32
40
  }
@@ -1,4 +1,4 @@
1
- const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1e3;
1
+ const DEFAULT_IDLE_TIMEOUT_MS = 60 * 1e3;
2
2
  const DEFAULT_MIN_CHECK_INTERVAL_MS = 30 * 1e3;
3
3
  const VISIBILITY_DEBOUNCE_MS = 1e3;
4
4
  const ERROR_UPDATE_WAIT_MS = 3e3;
@@ -68,6 +68,7 @@ class PwaAutoUpdateController {
68
68
  } catch {
69
69
  this.log("registration.update() failed, reloading anyway");
70
70
  }
71
+ this.activateWaitingWorker(registration);
71
72
  }
72
73
  await new Promise((resolve) => setTimeout(resolve, ERROR_UPDATE_WAIT_MS));
73
74
  this.reload();
@@ -116,6 +117,19 @@ class PwaAutoUpdateController {
116
117
  } catch (error) {
117
118
  this.log("registration.update() failed:", error);
118
119
  }
120
+ this.activateWaitingWorker(registration);
121
+ }
122
+ /**
123
+ * A freshly installed worker stays in `waiting` for as long as this page is
124
+ * controlled by the old one — a reload does not release it, only closing
125
+ * every tab on the origin does. Ask it to take over now; the generated
126
+ * worker answers this message with `skipWaiting()`, and the registration's
127
+ * own `activated` listener reloads the page onto the new build.
128
+ */
129
+ activateWaitingWorker(registration) {
130
+ if (!registration.waiting) return;
131
+ this.log("New worker is waiting, sending SKIP_WAITING");
132
+ registration.waiting.postMessage({ type: "SKIP_WAITING" });
119
133
  }
120
134
  reload() {
121
135
  if (this.isReloading) return;
@@ -1 +1 @@
1
- {"version":3,"file":"PwaAutoUpdateController.js","sources":["../../../../src/modules/pwa/PwaAutoUpdateController.ts"],"sourcesContent":["import type { PwaAutoUpdateOptions, PwaUpdateHandle } from './types'\n\nconst DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes\nconst DEFAULT_MIN_CHECK_INTERVAL_MS = 30 * 1000 // 30 seconds\nconst VISIBILITY_DEBOUNCE_MS = 1000\nconst ERROR_UPDATE_WAIT_MS = 3000\nconst IDLE_POLL_INTERVAL_MS = 10 * 1000 // check idle state every 10s\n\nconst USER_ACTIVITY_EVENTS = [\n 'mousemove',\n 'keydown',\n 'scroll',\n 'touchstart',\n 'click',\n] as const\n\n/**\n * Framework-agnostic controller that triggers service worker update checks\n * when the user is idle, switches tabs, or encounters errors.\n */\nexport class PwaAutoUpdateController {\n private handle: PwaUpdateHandle\n private options: Required<PwaAutoUpdateOptions>\n\n private lastCheckTimestamp = 0\n private lastActivityTimestamp = Date.now()\n private isReloading = false\n private visibilityDebounceTimer: ReturnType<typeof setTimeout> | undefined\n private idlePollTimer: ReturnType<typeof setInterval> | undefined\n\n private boundOnVisibilityChange = this.onVisibilityChange.bind(this)\n private boundOnUserActivity = this.onUserActivity.bind(this)\n\n constructor(handle: PwaUpdateHandle, options?: PwaAutoUpdateOptions) {\n this.handle = handle\n this.options = {\n idleTimeoutMs: options?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,\n minCheckIntervalMs:\n options?.minCheckIntervalMs ?? DEFAULT_MIN_CHECK_INTERVAL_MS,\n debug: options?.debug ?? false,\n }\n }\n\n start(): void {\n this.log('Starting auto-update controller')\n\n document.addEventListener('visibilitychange', this.boundOnVisibilityChange)\n\n for (const event of USER_ACTIVITY_EVENTS) {\n document.addEventListener(event, this.boundOnUserActivity, {\n passive: true,\n })\n }\n\n this.idlePollTimer = setInterval(() => {\n this.checkIdleState()\n }, IDLE_POLL_INTERVAL_MS)\n }\n\n destroy(): void {\n this.log('Destroying auto-update controller')\n\n document.removeEventListener(\n 'visibilitychange',\n this.boundOnVisibilityChange,\n )\n\n for (const event of USER_ACTIVITY_EVENTS) {\n document.removeEventListener(event, this.boundOnUserActivity)\n }\n\n if (this.visibilityDebounceTimer != null) {\n clearTimeout(this.visibilityDebounceTimer)\n this.visibilityDebounceTimer = undefined\n }\n\n if (this.idlePollTimer != null) {\n clearInterval(this.idlePollTimer)\n this.idlePollTimer = undefined\n }\n }\n\n /**\n * Called by the error boundary. Immediately checks for update,\n * waits briefly for a new SW, then reloads regardless.\n */\n async triggerUpdateOnError(): Promise<void> {\n this.log('Error-triggered update check')\n\n if (this.isReloading) return\n\n const { registration } = this.handle\n if (registration) {\n try {\n await registration.update()\n } catch {\n this.log('registration.update() failed, reloading anyway')\n }\n }\n\n // Wait briefly for a new SW to activate, then reload\n await new Promise((resolve) => setTimeout(resolve, ERROR_UPDATE_WAIT_MS))\n this.reload()\n }\n\n /** Update the handle when registration becomes available async */\n updateHandle(handle: PwaUpdateHandle): void {\n this.handle = handle\n }\n\n private onVisibilityChange(): void {\n if (document.visibilityState !== 'visible') return\n\n // Debounce rapid tab switches\n if (this.visibilityDebounceTimer != null) {\n clearTimeout(this.visibilityDebounceTimer)\n }\n\n this.visibilityDebounceTimer = setTimeout(() => {\n this.visibilityDebounceTimer = undefined\n this.log('Tab became visible, checking for update')\n this.checkForUpdate()\n }, VISIBILITY_DEBOUNCE_MS)\n }\n\n private onUserActivity(): void {\n this.lastActivityTimestamp = Date.now()\n }\n\n private checkIdleState(): void {\n const idleTime = Date.now() - this.lastActivityTimestamp\n if (idleTime >= this.options.idleTimeoutMs) {\n this.log(`Idle for ${Math.round(idleTime / 1000)}s, checking for update`)\n this.checkForUpdate()\n }\n }\n\n private async checkForUpdate(): Promise<void> {\n if (this.isReloading) return\n\n const now = Date.now()\n if (now - this.lastCheckTimestamp < this.options.minCheckIntervalMs) {\n this.log('Skipping check — too soon since last check')\n return\n }\n\n const { registration } = this.handle\n if (!registration) {\n this.log('No SW registration available, skipping check')\n return\n }\n\n this.lastCheckTimestamp = now\n this.log('Calling registration.update()')\n\n try {\n await registration.update()\n } catch (error) {\n this.log('registration.update() failed:', error)\n }\n // With autoUpdate registerType, vite-plugin-pwa handles\n // skipWaiting + clientsClaim + calling updateSW() automatically\n }\n\n private reload(): void {\n if (this.isReloading) return\n this.isReloading = true\n this.log('Reloading page')\n this.handle.updateSW(true)\n }\n\n private log(...args: unknown[]): void {\n if (this.options.debug) {\n console.log('[PWA Auto-Update]', ...args)\n }\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PwaAutoUpdateController.js","sources":["../../../../src/modules/pwa/PwaAutoUpdateController.ts"],"sourcesContent":["import type { PwaAutoUpdateOptions, PwaUpdateHandle } from './types'\n\nconst DEFAULT_IDLE_TIMEOUT_MS = 60 * 1000 // 1 minute\nconst DEFAULT_MIN_CHECK_INTERVAL_MS = 30 * 1000 // 30 seconds\nconst VISIBILITY_DEBOUNCE_MS = 1000\nconst ERROR_UPDATE_WAIT_MS = 3000\nconst IDLE_POLL_INTERVAL_MS = 10 * 1000 // check idle state every 10s\n\nconst USER_ACTIVITY_EVENTS = [\n 'mousemove',\n 'keydown',\n 'scroll',\n 'touchstart',\n 'click',\n] as const\n\n/**\n * Framework-agnostic controller that triggers service worker update checks\n * when the user is idle, switches tabs, or encounters errors.\n */\nexport class PwaAutoUpdateController {\n private handle: PwaUpdateHandle\n private options: Required<PwaAutoUpdateOptions>\n\n private lastCheckTimestamp = 0\n private lastActivityTimestamp = Date.now()\n private isReloading = false\n private visibilityDebounceTimer: ReturnType<typeof setTimeout> | undefined\n private idlePollTimer: ReturnType<typeof setInterval> | undefined\n\n private boundOnVisibilityChange = this.onVisibilityChange.bind(this)\n private boundOnUserActivity = this.onUserActivity.bind(this)\n\n constructor(handle: PwaUpdateHandle, options?: PwaAutoUpdateOptions) {\n this.handle = handle\n this.options = {\n idleTimeoutMs: options?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,\n minCheckIntervalMs:\n options?.minCheckIntervalMs ?? DEFAULT_MIN_CHECK_INTERVAL_MS,\n debug: options?.debug ?? false,\n }\n }\n\n start(): void {\n this.log('Starting auto-update controller')\n\n document.addEventListener('visibilitychange', this.boundOnVisibilityChange)\n\n for (const event of USER_ACTIVITY_EVENTS) {\n document.addEventListener(event, this.boundOnUserActivity, {\n passive: true,\n })\n }\n\n this.idlePollTimer = setInterval(() => {\n this.checkIdleState()\n }, IDLE_POLL_INTERVAL_MS)\n }\n\n destroy(): void {\n this.log('Destroying auto-update controller')\n\n document.removeEventListener(\n 'visibilitychange',\n this.boundOnVisibilityChange,\n )\n\n for (const event of USER_ACTIVITY_EVENTS) {\n document.removeEventListener(event, this.boundOnUserActivity)\n }\n\n if (this.visibilityDebounceTimer != null) {\n clearTimeout(this.visibilityDebounceTimer)\n this.visibilityDebounceTimer = undefined\n }\n\n if (this.idlePollTimer != null) {\n clearInterval(this.idlePollTimer)\n this.idlePollTimer = undefined\n }\n }\n\n /**\n * Called by the error boundary. Immediately checks for update,\n * waits briefly for a new SW, then reloads regardless.\n */\n async triggerUpdateOnError(): Promise<void> {\n this.log('Error-triggered update check')\n\n if (this.isReloading) return\n\n const { registration } = this.handle\n if (registration) {\n try {\n await registration.update()\n } catch {\n this.log('registration.update() failed, reloading anyway')\n }\n\n this.activateWaitingWorker(registration)\n }\n\n // Wait briefly for a new SW to activate, then reload\n await new Promise((resolve) => setTimeout(resolve, ERROR_UPDATE_WAIT_MS))\n this.reload()\n }\n\n /** Update the handle when registration becomes available async */\n updateHandle(handle: PwaUpdateHandle): void {\n this.handle = handle\n }\n\n private onVisibilityChange(): void {\n if (document.visibilityState !== 'visible') return\n\n // Debounce rapid tab switches\n if (this.visibilityDebounceTimer != null) {\n clearTimeout(this.visibilityDebounceTimer)\n }\n\n this.visibilityDebounceTimer = setTimeout(() => {\n this.visibilityDebounceTimer = undefined\n this.log('Tab became visible, checking for update')\n this.checkForUpdate()\n }, VISIBILITY_DEBOUNCE_MS)\n }\n\n private onUserActivity(): void {\n this.lastActivityTimestamp = Date.now()\n }\n\n private checkIdleState(): void {\n const idleTime = Date.now() - this.lastActivityTimestamp\n if (idleTime >= this.options.idleTimeoutMs) {\n this.log(`Idle for ${Math.round(idleTime / 1000)}s, checking for update`)\n this.checkForUpdate()\n }\n }\n\n private async checkForUpdate(): Promise<void> {\n if (this.isReloading) return\n\n const now = Date.now()\n if (now - this.lastCheckTimestamp < this.options.minCheckIntervalMs) {\n this.log('Skipping check — too soon since last check')\n return\n }\n\n const { registration } = this.handle\n if (!registration) {\n this.log('No SW registration available, skipping check')\n return\n }\n\n this.lastCheckTimestamp = now\n this.log('Calling registration.update()')\n\n try {\n await registration.update()\n } catch (error) {\n this.log('registration.update() failed:', error)\n }\n\n this.activateWaitingWorker(registration)\n }\n\n /**\n * A freshly installed worker stays in `waiting` for as long as this page is\n * controlled by the old one — a reload does not release it, only closing\n * every tab on the origin does. Ask it to take over now; the generated\n * worker answers this message with `skipWaiting()`, and the registration's\n * own `activated` listener reloads the page onto the new build.\n */\n private activateWaitingWorker(registration: ServiceWorkerRegistration): void {\n if (!registration.waiting) return\n\n this.log('New worker is waiting, sending SKIP_WAITING')\n registration.waiting.postMessage({ type: 'SKIP_WAITING' })\n }\n\n private reload(): void {\n if (this.isReloading) return\n this.isReloading = true\n this.log('Reloading page')\n this.handle.updateSW(true)\n }\n\n private log(...args: unknown[]): void {\n if (this.options.debug) {\n console.log('[PWA Auto-Update]', ...args)\n }\n }\n}\n"],"names":[],"mappings":"AAEA,MAAM,0BAA0B,KAAK;AACrC,MAAM,gCAAgC,KAAK;AAC3C,MAAM,yBAAyB;AAC/B,MAAM,uBAAuB;AAC7B,MAAM,wBAAwB,KAAK;AAEnC,MAAM,uBAAuB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAMO,MAAM,wBAAwB;AAAA,EAanC,YAAY,QAAyB,SAAgC;AATrE,SAAQ,qBAAqB;AAC7B,SAAQ,wBAAwB,KAAK,IAAA;AACrC,SAAQ,cAAc;AAItB,SAAQ,0BAA0B,KAAK,mBAAmB,KAAK,IAAI;AACnE,SAAQ,sBAAsB,KAAK,eAAe,KAAK,IAAI;AAGzD,SAAK,SAAS;AACd,SAAK,UAAU;AAAA,MACb,gBAAe,mCAAS,kBAAiB;AAAA,MACzC,qBACE,mCAAS,uBAAsB;AAAA,MACjC,QAAO,mCAAS,UAAS;AAAA,IAAA;AAAA,EAE7B;AAAA,EAEA,QAAc;AACZ,SAAK,IAAI,iCAAiC;AAE1C,aAAS,iBAAiB,oBAAoB,KAAK,uBAAuB;AAE1E,eAAW,SAAS,sBAAsB;AACxC,eAAS,iBAAiB,OAAO,KAAK,qBAAqB;AAAA,QACzD,SAAS;AAAA,MAAA,CACV;AAAA,IACH;AAEA,SAAK,gBAAgB,YAAY,MAAM;AACrC,WAAK,eAAA;AAAA,IACP,GAAG,qBAAqB;AAAA,EAC1B;AAAA,EAEA,UAAgB;AACd,SAAK,IAAI,mCAAmC;AAE5C,aAAS;AAAA,MACP;AAAA,MACA,KAAK;AAAA,IAAA;AAGP,eAAW,SAAS,sBAAsB;AACxC,eAAS,oBAAoB,OAAO,KAAK,mBAAmB;AAAA,IAC9D;AAEA,QAAI,KAAK,2BAA2B,MAAM;AACxC,mBAAa,KAAK,uBAAuB;AACzC,WAAK,0BAA0B;AAAA,IACjC;AAEA,QAAI,KAAK,iBAAiB,MAAM;AAC9B,oBAAc,KAAK,aAAa;AAChC,WAAK,gBAAgB;AAAA,IACvB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,uBAAsC;AAC1C,SAAK,IAAI,8BAA8B;AAEvC,QAAI,KAAK,YAAa;AAEtB,UAAM,EAAE,iBAAiB,KAAK;AAC9B,QAAI,cAAc;AAChB,UAAI;AACF,cAAM,aAAa,OAAA;AAAA,MACrB,QAAQ;AACN,aAAK,IAAI,gDAAgD;AAAA,MAC3D;AAEA,WAAK,sBAAsB,YAAY;AAAA,IACzC;AAGA,UAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,oBAAoB,CAAC;AACxE,SAAK,OAAA;AAAA,EACP;AAAA;AAAA,EAGA,aAAa,QAA+B;AAC1C,SAAK,SAAS;AAAA,EAChB;AAAA,EAEQ,qBAA2B;AACjC,QAAI,SAAS,oBAAoB,UAAW;AAG5C,QAAI,KAAK,2BAA2B,MAAM;AACxC,mBAAa,KAAK,uBAAuB;AAAA,IAC3C;AAEA,SAAK,0BAA0B,WAAW,MAAM;AAC9C,WAAK,0BAA0B;AAC/B,WAAK,IAAI,yCAAyC;AAClD,WAAK,eAAA;AAAA,IACP,GAAG,sBAAsB;AAAA,EAC3B;AAAA,EAEQ,iBAAuB;AAC7B,SAAK,wBAAwB,KAAK,IAAA;AAAA,EACpC;AAAA,EAEQ,iBAAuB;AAC7B,UAAM,WAAW,KAAK,IAAA,IAAQ,KAAK;AACnC,QAAI,YAAY,KAAK,QAAQ,eAAe;AAC1C,WAAK,IAAI,YAAY,KAAK,MAAM,WAAW,GAAI,CAAC,wBAAwB;AACxE,WAAK,eAAA;AAAA,IACP;AAAA,EACF;AAAA,EAEA,MAAc,iBAAgC;AAC5C,QAAI,KAAK,YAAa;AAEtB,UAAM,MAAM,KAAK,IAAA;AACjB,QAAI,MAAM,KAAK,qBAAqB,KAAK,QAAQ,oBAAoB;AACnE,WAAK,IAAI,4CAA4C;AACrD;AAAA,IACF;AAEA,UAAM,EAAE,iBAAiB,KAAK;AAC9B,QAAI,CAAC,cAAc;AACjB,WAAK,IAAI,8CAA8C;AACvD;AAAA,IACF;AAEA,SAAK,qBAAqB;AAC1B,SAAK,IAAI,+BAA+B;AAExC,QAAI;AACF,YAAM,aAAa,OAAA;AAAA,IACrB,SAAS,OAAO;AACd,WAAK,IAAI,iCAAiC,KAAK;AAAA,IACjD;AAEA,SAAK,sBAAsB,YAAY;AAAA,EACzC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASQ,sBAAsB,cAA+C;AAC3E,QAAI,CAAC,aAAa,QAAS;AAE3B,SAAK,IAAI,6CAA6C;AACtD,iBAAa,QAAQ,YAAY,EAAE,MAAM,gBAAgB;AAAA,EAC3D;AAAA,EAEQ,SAAe;AACrB,QAAI,KAAK,YAAa;AACtB,SAAK,cAAc;AACnB,SAAK,IAAI,gBAAgB;AACzB,SAAK,OAAO,SAAS,IAAI;AAAA,EAC3B;AAAA,EAEQ,OAAO,MAAuB;AACpC,QAAI,KAAK,QAAQ,OAAO;AACtB,cAAQ,IAAI,qBAAqB,GAAG,IAAI;AAAA,IAC1C;AAAA,EACF;AACF;"}
@@ -1,4 +1,30 @@
1
+ import { ComponentType } from 'react';
1
2
  import { PwaAutoUpdateOptions } from '~/modules/pwa/types';
3
+ /** Icon component for an area header. Lucide icon components satisfy this. */
4
+ export type AreaIcon = ComponentType<{
5
+ size?: number;
6
+ strokeWidth?: number;
7
+ className?: string;
8
+ }>;
9
+ /**
10
+ * Presentation of the area sections on the home view. The taxonomy is
11
+ * deployment-specific, so the core ships none of it: labels come from the
12
+ * catalog's own `category` tag definition and everything below is optional.
13
+ */
14
+ export interface AreasSettings {
15
+ /**
16
+ * Icon per `category:<value>`; the key `DAY_TO_DAY_AREA_KEY` targets the
17
+ * merged first group. Areas without an icon render title-only.
18
+ */
19
+ icons?: Record<string, AreaIcon>;
20
+ /**
21
+ * Category values that fold into the day-to-day group instead of getting an
22
+ * area of their own (e.g. perks, office services).
23
+ */
24
+ dayToDayCategories?: string[];
25
+ /** Title of the merged first group. Defaults to "Day-to-day tools". */
26
+ dayToDayLabel?: string;
27
+ }
2
28
  /**
3
29
  * UI settings passed from the app entry point.
4
30
  */
@@ -10,6 +36,8 @@ export interface UiSettings {
10
36
  };
11
37
  /** Frontend build identifier baked at build time (e.g., pipeline ID via VITE env var) */
12
38
  frontendBuildId?: string;
39
+ /** Area sections on the home view: icons and the day-to-day fold. */
40
+ areas?: AreasSettings;
13
41
  /** PWA auto-update configuration (idle timeout, check interval, debug) */
14
42
  pwaAutoUpdate?: PwaAutoUpdateOptions;
15
43
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "2.0.4",
3
+ "version": "3.0.0-alpha-20260917212201",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -113,7 +113,7 @@
113
113
  "tailwindcss": "^4.1.7",
114
114
  "tiptap-markdown": "^0.8.0",
115
115
  "tslib": "^2.3.0",
116
- "@igstack/app-catalog-shared-core": "2.0.4"
116
+ "@igstack/app-catalog-shared-core": "3.0.0-alpha-20260917212201"
117
117
  },
118
118
  "devDependencies": {
119
119
  "@tailwindcss/vite": "^4.0.5",
@@ -140,7 +140,7 @@
140
140
  "vite-plugin-static-copy": "^3.1.4",
141
141
  "vite-plugin-svgr": "^4.2.0",
142
142
  "vitest": "^4.1.2",
143
- "@igstack/app-catalog-backend-core": "2.0.4"
143
+ "@igstack/app-catalog-backend-core": "3.0.0-alpha-20260917212201"
144
144
  },
145
145
  "peerDependencies": {
146
146
  "react": "19.1.2",
@@ -149,7 +149,7 @@
149
149
  "vite": "^6.3.5",
150
150
  "vite-plugin-svgr": "^4.2.0"
151
151
  },
152
- "gitHead": "d52df9105e77a6e1efdecc74faf291b237bd0363",
152
+ "gitHead": "aabe7732c6f3ac7e928420c17e51adab0ece16ca",
153
153
  "scripts": {
154
154
  "build": "vite build",
155
155
  "build:lenient": "vite build --mode lenient",
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type {
3
+ GroupingTagDefinition,
4
+ Resource,
5
+ } from '@igstack/app-catalog-backend-core'
6
+ import {
7
+ DAY_TO_DAY_AREA_KEY,
8
+ areaLabel,
9
+ categoryOf,
10
+ groupByArea,
11
+ } from '~/modules/appCatalog/utils/areaGrouping'
12
+
13
+ const app = (slug: string, tags: string[] = []) =>
14
+ ({ slug, displayName: slug, tags }) as Resource
15
+
16
+ const definitions: GroupingTagDefinition[] = [
17
+ {
18
+ prefix: 'category',
19
+ displayName: 'Category',
20
+ description: '',
21
+ values: [
22
+ { value: 'finance', displayName: 'Money things', description: '' },
23
+ { value: 'perks', displayName: 'Perks', description: '' },
24
+ ],
25
+ },
26
+ ]
27
+
28
+ describe('categoryOf', () => {
29
+ it('falls back to "other" without a category tag', () => {
30
+ expect(categoryOf(app('a', ['universality:common']))).toBe('other')
31
+ })
32
+ })
33
+
34
+ describe('groupByArea', () => {
35
+ it('puts the day-to-day group first, then areas biggest-first', () => {
36
+ const groups = groupByArea([
37
+ app('f1', ['category:finance']),
38
+ app('s1', ['category:security']),
39
+ app('f2', ['category:finance']),
40
+ app('everyone', ['category:security', 'universality:everyone']),
41
+ ])
42
+ expect(groups.map(([key, list]) => [key, list.length])).toEqual([
43
+ [DAY_TO_DAY_AREA_KEY, 1],
44
+ ['finance', 2],
45
+ ['security', 1],
46
+ ])
47
+ })
48
+
49
+ it('folds a configured category into the day-to-day group', () => {
50
+ const groups = groupByArea(
51
+ [app('lunch', ['category:perks']), app('bank', ['category:finance'])],
52
+ ['perks'],
53
+ )
54
+ expect(groups.map(([key]) => key)).toEqual([DAY_TO_DAY_AREA_KEY, 'finance'])
55
+ })
56
+
57
+ it('omits the day-to-day group when nothing belongs there', () => {
58
+ expect(groupByArea([app('bank', ['category:finance'])])).toEqual([
59
+ ['finance', [expect.objectContaining({ slug: 'bank' })]],
60
+ ])
61
+ })
62
+ })
63
+
64
+ describe('areaLabel', () => {
65
+ it('uses the catalog display name for a known category', () => {
66
+ expect(areaLabel('finance', definitions)).toBe('Money things')
67
+ })
68
+
69
+ it('humanizes an unknown category slug', () => {
70
+ expect(areaLabel('field_service', definitions)).toBe('Field service')
71
+ })
72
+
73
+ it('labels the day-to-day group, overridable', () => {
74
+ expect(areaLabel(DAY_TO_DAY_AREA_KEY, definitions)).toBe('Day-to-day tools')
75
+ expect(areaLabel(DAY_TO_DAY_AREA_KEY, definitions, 'Everyday')).toBe(
76
+ 'Everyday',
77
+ )
78
+ })
79
+
80
+ it('works with no definitions at all', () => {
81
+ expect(areaLabel('finance')).toBe('Finance')
82
+ })
83
+ })
@@ -0,0 +1,61 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { PwaAutoUpdateController } from '~/modules/pwa/PwaAutoUpdateController'
3
+
4
+ function makeRegistration(hasWaiting: boolean) {
5
+ const postMessage = vi.fn()
6
+ const registration = {
7
+ update: vi.fn().mockResolvedValue(undefined),
8
+ waiting: hasWaiting ? { postMessage } : null,
9
+ } as unknown as ServiceWorkerRegistration
10
+
11
+ return { registration, postMessage }
12
+ }
13
+
14
+ function makeController(registration: ServiceWorkerRegistration) {
15
+ const updateSW = vi.fn()
16
+ const controller = new PwaAutoUpdateController(
17
+ { updateSW, registration },
18
+ { minCheckIntervalMs: 0 },
19
+ )
20
+ return { controller, updateSW }
21
+ }
22
+
23
+ describe('PwaAutoUpdateController', () => {
24
+ beforeEach(() => {
25
+ vi.useRealTimers()
26
+ })
27
+
28
+ it('asks a waiting worker to take over after checking for an update', async () => {
29
+ const { registration, postMessage } = makeRegistration(true)
30
+ const { controller } = makeController(registration)
31
+
32
+ // @ts-expect-error -- exercising the private check the timers/visibility call
33
+ await controller.checkForUpdate()
34
+
35
+ expect(registration.update).toHaveBeenCalledOnce()
36
+ expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' })
37
+ })
38
+
39
+ it('does nothing extra when no new worker is waiting', async () => {
40
+ const { registration } = makeRegistration(false)
41
+ const { controller } = makeController(registration)
42
+
43
+ // @ts-expect-error -- exercising the private check the timers/visibility call
44
+ await expect(controller.checkForUpdate()).resolves.toBeUndefined()
45
+
46
+ expect(registration.update).toHaveBeenCalledOnce()
47
+ })
48
+
49
+ it('activates a waiting worker before the error-triggered reload', async () => {
50
+ const { registration, postMessage } = makeRegistration(true)
51
+ const { controller, updateSW } = makeController(registration)
52
+
53
+ vi.useFakeTimers()
54
+ const done = controller.triggerUpdateOnError()
55
+ await vi.runAllTimersAsync()
56
+ await done
57
+
58
+ expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' })
59
+ expect(updateSW).toHaveBeenCalledWith(true)
60
+ })
61
+ })
package/src/index.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  export { appPropsFactory } from './appPropsFactory.js'
2
2
  export { App } from './App'
3
3
  export type { AppProps } from './App'
4
- export type { UiSettings } from './types/uiSettings'
4
+ export type { AreaIcon, AreasSettings, UiSettings } from './types/uiSettings'
5
+ export { DAY_TO_DAY_AREA_KEY } from './modules/appCatalog/utils/areaGrouping'
5
6
  export { PwaAutoUpdateProvider, usePwaAutoUpdate } from './modules/pwa'
6
7
  export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa'
@@ -1,6 +1,6 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import { ArrowUpRight, Search, X } from 'lucide-react'
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { cn } from '~/lib/utils'
5
5
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
6
6
  import { markdownToPlainText } from '../../utils/markdownToPlainText'
@@ -10,11 +10,15 @@ import { ResourceIcon } from './ResourceIcon'
10
10
  import { searchResources } from '@igstack/app-catalog-shared-core'
11
11
  import { Highlight } from '../components/Highlight'
12
12
  import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
13
+ import { useUiSettings } from '~/context/UiSettingsContext'
14
+ import type { AreaIcon } from '~/types/uiSettings'
15
+ import { AppCatalogContext } from '../../context/AppCatalogContext'
16
+ import { areaLabel, groupByArea } from '../../utils/areaGrouping'
13
17
 
14
18
  /**
15
19
  * Adaptive-home discovery spine (issue #38, increment 1) — matches the
16
20
  * option-a-launcher prototype:
17
- * Your apps (frequent) → New this week → Browse all
21
+ * Your apps (frequent) → New this week → all areas, grouped
18
22
  *
19
23
  * This is the PERSISTENT shell for the catalog: idle browse, active search and
20
24
  * an open detail overlay all render inside it. Only the area below the hero
@@ -47,8 +51,11 @@ export interface AppCatalogGridProps {
47
51
  /** Opens a matched sub-resource: its parent's detail, singled out to it. */
48
52
  onSubClick: (parentSlug: string, subSlug: string) => void
49
53
  onLaunch: (app: Resource) => void
50
- /** Total resource count for the "Browse all" label. */
51
- totalCount: number
54
+ /**
55
+ * @deprecated Unused — it labelled the removed "Browse all" header. Still
56
+ * accepted so existing callers keep compiling; drop it on the next major.
57
+ */
58
+ totalCount?: number
52
59
  /**
53
60
  * True while an app/sub-resource detail overlay is open above the launcher.
54
61
  * The results list then stops binding ↑↓/↵/Esc so the overlay owns the
@@ -118,7 +125,7 @@ function SectionHead({
118
125
  )
119
126
  }
120
127
 
121
- /** Small row used by "New this week" and "Browse all". */
128
+ /** Small row used by "New this week" and the search results. */
122
129
  function ResourceRow({
123
130
  app,
124
131
  onAppClick,
@@ -167,6 +174,129 @@ function ResourceRow({
167
174
  )
168
175
  }
169
176
 
177
+ /**
178
+ * Wide area card: icon on the left, name and description beside it.
179
+ *
180
+ * The grid cell keeps a fixed footprint and the visible panel is absolute inside
181
+ * it, so a card growing on hover OVERLAYS the row below instead of reflowing the
182
+ * whole grid. Hover reveals the rest of the description and nothing else — an
183
+ * earlier version staggered a delayed tag block in behind the growth, which read
184
+ * as jitter. Keyboard focus expands the same way, and `motion-reduce` drops the
185
+ * animation.
186
+ *
187
+ * The footprint is a fixed height rather than an aspect ratio: at two or three
188
+ * columns a ratio makes the card grow taller as the window widens, and the
189
+ * description — three lines at most — leaves the bottom half empty.
190
+ */
191
+ function AppCard({
192
+ app,
193
+ onAppClick,
194
+ onLaunch,
195
+ }: {
196
+ app: Resource
197
+ onAppClick: (app: Resource) => void
198
+ onLaunch: (app: Resource) => void
199
+ }) {
200
+ return (
201
+ <div className="relative h-[92px]">
202
+ <button
203
+ type="button"
204
+ onClick={() => onAppClick(app)}
205
+ title={`View ${app.displayName}`}
206
+ className={cn(
207
+ 'group absolute inset-x-0 top-0 min-h-full max-h-full overflow-hidden',
208
+ 'flex gap-3.5 text-left bg-card border border-border',
209
+ 'rounded-[var(--radius)] p-[15px] pr-11 outline-none',
210
+ 'transition-[max-height,box-shadow,border-color] duration-200',
211
+ 'hover:max-h-96 hover:border-ring hover:shadow-lg hover:z-10',
212
+ 'focus-visible:max-h-96 focus-visible:border-ring focus-visible:shadow-lg focus-visible:z-10',
213
+ 'motion-reduce:transition-none',
214
+ )}
215
+ >
216
+ <LaunchButton
217
+ app={app}
218
+ onLaunch={onLaunch}
219
+ className="absolute top-3 right-3 size-[26px] opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
220
+ />
221
+ <div className="shrink-0">
222
+ <ResourceIcon app={app} size={40} />
223
+ </div>
224
+ {/* min-w-0 so a long unbroken word truncates instead of pushing the
225
+ column wider than the card. */}
226
+ <div className="flex min-w-0 flex-col gap-1.5">
227
+ {/* shrink-0: while max-height animates, the un-clamped description
228
+ overflows the panel for a few frames. Without it flexbox squeezes
229
+ this line to 0 and back, which reads as the title flickering. */}
230
+ <span className="block shrink-0 text-[14.5px] font-bold leading-tight line-clamp-1 group-hover:line-clamp-2 group-focus-visible:line-clamp-2">
231
+ {app.displayName}
232
+ </span>
233
+ {app.description && (
234
+ <span
235
+ className={cn(
236
+ 'block shrink-0 text-[12.5px] leading-snug text-muted-foreground',
237
+ 'line-clamp-2 group-hover:line-clamp-none group-focus-visible:line-clamp-none',
238
+ )}
239
+ >
240
+ {markdownToPlainText(app.description)}
241
+ </span>
242
+ )}
243
+ </div>
244
+ </button>
245
+ </div>
246
+ )
247
+ }
248
+
249
+ /**
250
+ * One area: icon, big title, count, then its cards. The icon stands in for the
251
+ * accent bar the header used to carry — it does the same colour job and also
252
+ * says which area you are in. It is decorative: the `h3` carries the name.
253
+ */
254
+ function AreaSection({
255
+ label,
256
+ Icon,
257
+ apps,
258
+ onAppClick,
259
+ onLaunch,
260
+ }: {
261
+ label: string
262
+ Icon?: AreaIcon
263
+ apps: Resource[]
264
+ onAppClick: (app: Resource) => void
265
+ onLaunch: (app: Resource) => void
266
+ }) {
267
+ return (
268
+ <section className="mt-11">
269
+ <div className="flex items-center gap-2.5 mb-4">
270
+ {Icon ? (
271
+ <Icon
272
+ size={26}
273
+ strokeWidth={1.75}
274
+ className="text-primary shrink-0"
275
+ />
276
+ ) : (
277
+ <div className="h-[3px] w-11 rounded-full bg-primary" />
278
+ )}
279
+ <h3 className="font-serif font-semibold text-[27px] leading-tight tracking-tight m-0">
280
+ {label}
281
+ </h3>
282
+ <span className="text-[13px] text-muted-foreground">
283
+ {apps.length} {apps.length === 1 ? 'tool' : 'tools'}
284
+ </span>
285
+ </div>
286
+ <div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
287
+ {apps.map((app) => (
288
+ <AppCard
289
+ key={app.slug}
290
+ app={app}
291
+ onAppClick={onAppClick}
292
+ onLaunch={onLaunch}
293
+ />
294
+ ))}
295
+ </div>
296
+ </section>
297
+ )
298
+ }
299
+
170
300
  /** Matched sub-resources listed under a parent before the "… N more" row. */
171
301
  const COLLAPSED_SUB_LIMIT = 5
172
302
 
@@ -487,12 +617,15 @@ export function AppCatalogGrid({
487
617
  onAppClick,
488
618
  onSubClick,
489
619
  onLaunch,
490
- totalCount,
491
620
  detailOpen = false,
492
621
  selectedSubSlug,
493
622
  }: AppCatalogGridProps) {
494
623
  const { getTopApps } = useAppClickHistory()
495
624
  const [topSlugs, setTopSlugs] = useState<string[]>([])
625
+ // Area labels are data (the catalog's own `category` tag definition); only the
626
+ // icons and the day-to-day fold are deployment config.
627
+ const { areas } = useUiSettings()
628
+ const tagsDefinitions = use(AppCatalogContext)?.tagsDefinitions
496
629
 
497
630
  useEffect(() => {
498
631
  void getTopApps(5).then(setTopSlugs)
@@ -517,7 +650,7 @@ export function AppCatalogGrid({
517
650
  // New this week: apps ADDED in the last 7 days — see pickNewThisWeek (#96).
518
651
  const fresh = useMemo(() => pickNewThisWeek(apps), [apps])
519
652
 
520
- // Browse all: everything not already promoted to "Your apps", alpha by name.
653
+ // Everything not already promoted to "Your apps", alpha by name.
521
654
  const browse = useMemo(() => {
522
655
  const promoted = new Set(frequent.map((a) => a.slug))
523
656
  return [...apps]
@@ -525,8 +658,12 @@ export function AppCatalogGrid({
525
658
  .sort((a, b) => a.displayName.localeCompare(b.displayName))
526
659
  }, [apps, frequent])
527
660
 
528
- const browseLeft = browse.filter((_, i) => i % 2 === 0)
529
- const browseRight = browse.filter((_, i) => i % 2 === 1)
661
+ // Grouped: day-to-day tools first, then areas biggest-first so the page opens
662
+ // on the areas most people need. Within an area, alpha (from browse).
663
+ const browseGroups = useMemo(
664
+ () => groupByArea(browse, areas?.dayToDayCategories),
665
+ [browse, areas?.dayToDayCategories],
666
+ )
530
667
 
531
668
  const isSearching = searchValue.trim() !== ''
532
669
  const searchRef = useRef<HTMLInputElement>(null)
@@ -560,7 +697,7 @@ export function AppCatalogGrid({
560
697
  type="text"
561
698
  value={searchValue}
562
699
  onChange={(e) => onSearchChange(e.target.value)}
563
- placeholder="Search apps, databases, cloud accounts, account IDs…"
700
+ placeholder="Search apps, cloud accounts, or keywords"
564
701
  autoComplete="off"
565
702
  autoFocus
566
703
  ref={searchRef}
@@ -663,31 +800,19 @@ export function AppCatalogGrid({
663
800
  </section>
664
801
  )}
665
802
 
666
- {/* Browse all */}
803
+ {/* Everything else, grouped by area — the area titles carry it, so
804
+ there is no "Browse all" header above them. */}
667
805
  <section className="mt-9">
668
- <SectionHead title="Browse all" count={`${totalCount} resources`} />
669
- <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
670
- <div className="flex flex-col gap-2">
671
- {browseLeft.map((app) => (
672
- <ResourceRow
673
- key={app.slug}
674
- app={app}
675
- onAppClick={onAppClick}
676
- onLaunch={onLaunch}
677
- />
678
- ))}
679
- </div>
680
- <div className="flex flex-col gap-2">
681
- {browseRight.map((app) => (
682
- <ResourceRow
683
- key={app.slug}
684
- app={app}
685
- onAppClick={onAppClick}
686
- onLaunch={onLaunch}
687
- />
688
- ))}
689
- </div>
690
- </div>
806
+ {browseGroups.map(([key, list]) => (
807
+ <AreaSection
808
+ key={key}
809
+ label={areaLabel(key, tagsDefinitions, areas?.dayToDayLabel)}
810
+ Icon={areas?.icons?.[key]}
811
+ apps={list}
812
+ onAppClick={onAppClick}
813
+ onLaunch={onLaunch}
814
+ />
815
+ ))}
691
816
  </section>
692
817
 
693
818
  <AttributionFooter />
@@ -133,15 +133,6 @@ export function AppCatalogPage({
133
133
  )
134
134
  }, [subPageSlug, selectedApp, resources])
135
135
 
136
- // Total resource count for the "Browse all" label (respects ?deprecated=1)
137
- const totalAppsCount = useMemo(
138
- () =>
139
- filterState.showDeprecated
140
- ? rootResources.length
141
- : rootResources.filter((app) => !app.deprecated).length,
142
- [rootResources, filterState.showDeprecated],
143
- )
144
-
145
136
  if (isLoadingApps) {
146
137
  return <div className="py-6 text-muted-foreground">Loading…</div>
147
138
  }
@@ -166,7 +157,6 @@ export function AppCatalogPage({
166
157
  onAppClick={handleAppClick}
167
158
  onSubClick={handleSubClick}
168
159
  onLaunch={handleLaunch}
169
- totalCount={totalAppsCount}
170
160
  detailOpen={selectedApp !== null}
171
161
  selectedSubSlug={highlightSubSlug}
172
162
  />