@memberjunction/ng-explorer-service-worker 0.0.1 → 5.32.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.
package/README.md CHANGED
@@ -1,45 +1,151 @@
1
1
  # @memberjunction/ng-explorer-service-worker
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Service worker app-shell pre-cache + update-notification toast for MJ Explorer-style apps.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ ## What this package gives you
6
6
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
7
+ - **`ngsw-config.json`** shipped at the package root — pre-tuned cache strategy (app-shell, lazy assets, GraphQL exclusions) that updates with `npm update`.
8
+ - **`MJServiceWorkerModule.forRoot({ enabled })`** — wraps `ServiceWorkerModule.register(...)` with sensible defaults (deferred registration so the SW doesn't fight initial boot for bandwidth).
9
+ - **`UpdateNotificationService`** — RxJS-friendly wrapper around `SwUpdate`.
10
+ - **`<mj-update-notification>`** — standalone toast component, MJ design tokens, OnPush.
8
11
 
9
- ## Purpose
12
+ If you're using `@memberjunction/ng-explorer-app`, `MJExplorerAppModule.forRoot(environment)` already wires this up internally. You only need to touch your shell directly if you're building a non-Explorer Angular app.
10
13
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@memberjunction/ng-explorer-service-worker`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
14
+ ## Consumer setup
15
15
 
16
- ## What is OIDC Trusted Publishing?
16
+ For a typical MJ Explorer-style app, you only edit two files:
17
17
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
18
+ ### 1. `angular.json` — point the SW config at our shipped file
19
19
 
20
- ## Setup Instructions
20
+ ```json
21
+ {
22
+ "projects": {
23
+ "your-app": {
24
+ "architect": {
25
+ "build": {
26
+ "configurations": {
27
+ "production": {
28
+ "serviceWorker": "node_modules/@memberjunction/ng-explorer-service-worker/ngsw-config.json"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
21
37
 
22
- To properly configure OIDC trusted publishing for this package:
38
+ ### 2. `environment.ts` — opt-in kill switch
23
39
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
40
+ ```typescript
41
+ export const environment = {
42
+ production: true,
43
+ enableServiceWorker: true, // master kill switch — turn off in an emergency
44
+ // ...
45
+ };
46
+ ```
28
47
 
29
- ## DO NOT USE THIS PACKAGE
48
+ That's it. `MJExplorerAppModule.forRoot(environment)` reads `environment.enableServiceWorker` and turns the SW on when (and only when) `production && enableServiceWorker` are both true.
30
49
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
50
+ If you skip the angular.json edit, no `ngsw-worker.js` is built and the runtime registration silently no-ops. App behaves exactly as it does today.
36
51
 
37
- ## More Information
52
+ ## Optional consumer enhancements
38
53
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
54
+ These are recommended but not required. The SW works fine without them — they're polish items that depend on consumer-side files we can't reach from this npm package.
42
55
 
43
- ---
56
+ ### Backport for older versions: inline theme preload script
44
57
 
45
- **Maintained for OIDC setup purposes only**
58
+ **MJExplorer ships this by default starting with the same release as this package** — new installs get instant correct theming for free, no action required.
59
+
60
+ If you're maintaining an older MJExplorer-based app (or a non-Explorer app that uses MJ's theme system) and want the same benefit, add the snippet below to your app's `src/index.html` inside `<head>`, anywhere before any stylesheets. We can't inject HTML into your `index.html` from an npm package, so this lives on the consumer side.
61
+
62
+ Without this, dark-mode users see a brief light-mode flash on every load before MJ's bootstrap code applies their saved theme. The Angular bootstrap path is too late to set the theme before first paint — the script must be inline in `<head>`.
63
+
64
+ ```html
65
+ <script>
66
+ (function () {
67
+ try {
68
+ var saved = localStorage.getItem('mj-theme');
69
+ var isDark = saved
70
+ ? saved === 'dark'
71
+ : window.matchMedia('(prefers-color-scheme: dark)').matches;
72
+ if (isDark) document.documentElement.setAttribute('data-theme', 'dark');
73
+ } catch (e) { /* swallow — theme just falls back to light */ }
74
+ })();
75
+ </script>
76
+ ```
77
+
78
+ Reads `mj-theme`, the unified theme key written by:
79
+
80
+ - `ThemeService.applyBaseThemeAttribute()` post-login — mirrors the resolved base theme (`light` or `dark`), including the OS-resolved value for `'system'` preference
81
+ - `MJExplorerAppComponent.ToggleTheme()` when the user toggles theme on the login screen
82
+
83
+ The three readers (this inline script, `MJExplorerAppComponent.applyLoginTheme`, `ThemeService`) all share `mj-theme` as the single source of truth, so they paint in sync with no flash.
84
+
85
+ `mj-theme` is preserved across logout by `MJExplorerAuthBase.preservedLocalStorageKeys` so the login screen continues to show the user's last theme without flashing back to OS default.
86
+
87
+ Falls back to OS `prefers-color-scheme` on true first visits with no saved preference. Wrapped in try/catch so localStorage exceptions (Safari private mode, disabled storage) can never block app load.
88
+
89
+ Combined with the SW app-shell pre-cache, dark-mode users see correct theme paint within ~100ms of navigation start.
90
+
91
+ ### Backport for older versions: build-time SW cache-shape gate
92
+
93
+ **MJExplorer ships this by default starting with the same release as this package** — new installs get the build-time defense automatically.
94
+
95
+ If you're maintaining an older MJExplorer-based app and want the same safety net, add the snippet below to your `package.json`. The gate guards against future Angular releases that introduce new build-output filename prefixes (`runtime-*`, `vendor-*`, etc.) that our `ngsw-config.json` doesn't classify. Without it, such a change would silently bypass the SW cache for those new files. The gate fails the build with a diagnostic listing the unrecognized filenames so the issue is caught at your build time, not in production.
96
+
97
+ Add to your app's `package.json` scripts:
98
+
99
+ ```json
100
+ {
101
+ "scripts": {
102
+ "postbuild": "node node_modules/@memberjunction/ng-explorer-service-worker/scripts/verify-sw-cache-shape.js dist/<your-app-name>/browser"
103
+ }
104
+ }
105
+ ```
106
+
107
+ Replace `<your-app-name>` with whatever Angular emits to (check your `angular.json` `outputPath`).
108
+
109
+ The script exits 0 when every root-level `.js` and `.css` file matches a known prefix (`main-`, `polyfills-`, `chunk-`, `styles-`) and ignores SW infrastructure files (`ngsw-worker.js`, `ngsw.json`, `safety-worker.js`, `worker-basic.min.js`). Exits 1 with a clear diagnostic on anything unrecognized.
110
+
111
+ Without this opt-in, the regression tests inside the SW package (`ngsw-config-shape.test.ts`, `verify-sw-cache-shape.test.ts`) still catch config-edit regressions at MJ-monorepo PR review time — but only consumer build-time invocation can catch downstream Angular naming changes.
112
+
113
+ ## Manual wiring (non-Explorer apps)
114
+
115
+ If you're not using `MJExplorerAppModule`, wire it yourself:
116
+
117
+ ```typescript
118
+ import { MJServiceWorkerModule } from '@memberjunction/ng-explorer-service-worker';
119
+
120
+ @NgModule({
121
+ imports: [
122
+ MJServiceWorkerModule.forRoot({
123
+ enabled: environment.production && environment.enableServiceWorker
124
+ })
125
+ ]
126
+ })
127
+ export class AppModule {}
128
+ ```
129
+
130
+ And drop the toast somewhere in your shell template:
131
+
132
+ ```html
133
+ <mj-update-notification />
134
+ ```
135
+
136
+ ## How update notifications work
137
+
138
+ 1. SW polls for new versions in the background (Angular default cadence).
139
+ 2. When a new version is fully downloaded, `SwUpdate.versionUpdates` emits `VERSION_READY`.
140
+ 3. `UpdateNotificationService` flips `updateAvailable$` to `true`.
141
+ 4. `<mj-update-notification>` renders a bottom-right toast with **Reload** / **Later**.
142
+ 5. **Reload** triggers `document.location.reload()`, which activates the new SW version.
143
+ 6. **Later** dismisses for the session; next reload picks up the update naturally.
144
+
145
+ ## Customization
146
+
147
+ To override the cache strategy for your app, copy `ngsw-config.json` into your repo and point `angular.json` at the local copy. You'll lose automatic updates to the cache rules but can tune freely.
148
+
149
+ ## Ops runbook
150
+
151
+ See `docs/operations/SERVICE_WORKER_RUNBOOK.md` in the MJ repo for kill-switch procedures, cache invalidation, and rollback playbook.
@@ -0,0 +1,68 @@
1
+ import { ModuleWithProviders } from '@angular/core';
2
+ import * as i0 from "@angular/core";
3
+ import * as i1 from "./update-notification.component";
4
+ /**
5
+ * Options for configuring `MJServiceWorkerModule.forRoot()`.
6
+ */
7
+ export interface MJServiceWorkerOptions {
8
+ /**
9
+ * Master enable flag — when `false`, no service worker is registered and
10
+ * `SwUpdate.isEnabled` resolves to `false`. The toast component is always
11
+ * exported, but it renders nothing when SW is disabled.
12
+ *
13
+ * Wire this to something like `environment.production && environment.enableServiceWorker`
14
+ * so dev builds and ops kill-switches both turn it off cleanly.
15
+ */
16
+ enabled: boolean;
17
+ /**
18
+ * The path to the generated SW worker script, relative to the deployed
19
+ * app root. Defaults to `'ngsw-worker.js'` (the Angular CLI default).
20
+ * Override only if you've customized the build output.
21
+ */
22
+ scriptPath?: string;
23
+ /**
24
+ * Registration strategy passed to `ServiceWorkerModule.register`.
25
+ * Defaults to `'registerWhenStable:30000'` — defer registration until
26
+ * the app is idle for 30s OR 30s have elapsed, whichever first. Avoids
27
+ * competing with initial app boot for network bandwidth.
28
+ */
29
+ registrationStrategy?: string;
30
+ /**
31
+ * How often (in ms) UpdateNotificationService should ask the SW to check
32
+ * for a new manifest while the tab is visible. Defaults to 15 minutes.
33
+ * Pass `0` to disable periodic checking entirely (the SW's internal poll
34
+ * still runs, but on a much longer cadence — typically hours).
35
+ *
36
+ * Note: this is wired by `UpdateNotificationService` itself in its
37
+ * constructor; we don't currently provide a DI token for it. To override
38
+ * at runtime, inject `UpdateNotificationService` and call
39
+ * `service.startAutoCheck(yourIntervalMs)`.
40
+ */
41
+ pollIntervalMs?: number;
42
+ }
43
+ /**
44
+ * Bundles MJ Explorer's service worker concerns into one module:
45
+ * - Conditionally registers `@angular/service-worker` based on `enabled`
46
+ * - Exports `UpdateNotificationComponent` (standalone toast)
47
+ * - Exposes `UpdateNotificationService` via `providedIn: 'root'`
48
+ *
49
+ * Consumers should call `MJServiceWorkerModule.forRoot({ enabled: ... })`
50
+ * exactly once in their root NgModule. Typically this is done internally by
51
+ * `MJExplorerAppModule.forRoot(environment)` so MJExplorer (and downstream
52
+ * Explorer-style apps) don't have to wire it themselves.
53
+ *
54
+ * Pre-built `ngsw-config.json` ships at the package root; consumers point
55
+ * their `angular.json` `serviceWorker` field at:
56
+ * `node_modules/@memberjunction/ng-explorer-service-worker/ngsw-config.json`
57
+ *
58
+ * If a consumer doesn't update `angular.json` (no SW worker built) AND
59
+ * doesn't flip `enabled` true in their environment, this module is a no-op.
60
+ * The app keeps working exactly as if the SW didn't exist.
61
+ */
62
+ export declare class MJServiceWorkerModule {
63
+ static forRoot(options: MJServiceWorkerOptions): ModuleWithProviders<MJServiceWorkerModule>;
64
+ static ɵfac: i0.ɵɵFactoryDeclaration<MJServiceWorkerModule, never>;
65
+ static ɵmod: i0.ɵɵNgModuleDeclaration<MJServiceWorkerModule, never, [typeof i1.UpdateNotificationComponent], [typeof i1.UpdateNotificationComponent]>;
66
+ static ɵinj: i0.ɵɵInjectorDeclaration<MJServiceWorkerModule>;
67
+ }
68
+ //# sourceMappingURL=mj-service-worker.module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mj-service-worker.module.d.ts","sourceRoot":"","sources":["../../src/lib/mj-service-worker.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,mBAAmB,EAAE,MAAM,eAAe,CAAC;;;AAI9D;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACnC;;;;;;;OAOG;IACH,OAAO,EAAE,OAAO,CAAC;IAEjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAIa,qBAAqB;IAC9B,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,sBAAsB,GAAG,mBAAmB,CAAC,qBAAqB,CAAC;yCADlF,qBAAqB;0CAArB,qBAAqB;0CAArB,qBAAqB;CAmBjC"}
@@ -0,0 +1,54 @@
1
+ import { NgModule } from '@angular/core';
2
+ import { ServiceWorkerModule } from '@angular/service-worker';
3
+ import { UpdateNotificationComponent } from './update-notification.component';
4
+ import * as i0 from "@angular/core";
5
+ /**
6
+ * Bundles MJ Explorer's service worker concerns into one module:
7
+ * - Conditionally registers `@angular/service-worker` based on `enabled`
8
+ * - Exports `UpdateNotificationComponent` (standalone toast)
9
+ * - Exposes `UpdateNotificationService` via `providedIn: 'root'`
10
+ *
11
+ * Consumers should call `MJServiceWorkerModule.forRoot({ enabled: ... })`
12
+ * exactly once in their root NgModule. Typically this is done internally by
13
+ * `MJExplorerAppModule.forRoot(environment)` so MJExplorer (and downstream
14
+ * Explorer-style apps) don't have to wire it themselves.
15
+ *
16
+ * Pre-built `ngsw-config.json` ships at the package root; consumers point
17
+ * their `angular.json` `serviceWorker` field at:
18
+ * `node_modules/@memberjunction/ng-explorer-service-worker/ngsw-config.json`
19
+ *
20
+ * If a consumer doesn't update `angular.json` (no SW worker built) AND
21
+ * doesn't flip `enabled` true in their environment, this module is a no-op.
22
+ * The app keeps working exactly as if the SW didn't exist.
23
+ */
24
+ export class MJServiceWorkerModule {
25
+ static forRoot(options) {
26
+ // ServiceWorkerModule.register is always called — the `enabled` flag
27
+ // tells Angular whether to actually attempt registration. We pass the
28
+ // module reference into the providers chain so the SwUpdate token tree
29
+ // is set up regardless (so injection always succeeds; it just reports
30
+ // isEnabled = false when disabled).
31
+ const swModule = ServiceWorkerModule.register(options.scriptPath ?? 'ngsw-worker.js', {
32
+ enabled: options.enabled,
33
+ registrationStrategy: options.registrationStrategy ?? 'registerWhenStable:30000'
34
+ });
35
+ return {
36
+ ngModule: MJServiceWorkerModule,
37
+ providers: [
38
+ ...(swModule.providers ?? [])
39
+ ]
40
+ };
41
+ }
42
+ static ɵfac = function MJServiceWorkerModule_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MJServiceWorkerModule)(); };
43
+ static ɵmod = /*@__PURE__*/ i0.ɵɵdefineNgModule({ type: MJServiceWorkerModule });
44
+ static ɵinj = /*@__PURE__*/ i0.ɵɵdefineInjector({ imports: [UpdateNotificationComponent] });
45
+ }
46
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MJServiceWorkerModule, [{
47
+ type: NgModule,
48
+ args: [{
49
+ imports: [UpdateNotificationComponent],
50
+ exports: [UpdateNotificationComponent]
51
+ }]
52
+ }], null, null); })();
53
+ (function () { (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵɵsetNgModuleScope(MJServiceWorkerModule, { imports: [UpdateNotificationComponent], exports: [UpdateNotificationComponent] }); })();
54
+ //# sourceMappingURL=mj-service-worker.module.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mj-service-worker.module.js","sourceRoot":"","sources":["../../src/lib/mj-service-worker.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAuB,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,iCAAiC,CAAC;;AA6C9E;;;;;;;;;;;;;;;;;;GAkBG;AAKH,MAAM,OAAO,qBAAqB;IAC9B,MAAM,CAAC,OAAO,CAAC,OAA+B;QAC1C,qEAAqE;QACrE,sEAAsE;QACtE,uEAAuE;QACvE,sEAAsE;QACtE,oCAAoC;QACpC,MAAM,QAAQ,GAAG,mBAAmB,CAAC,QAAQ,CAAC,OAAO,CAAC,UAAU,IAAI,gBAAgB,EAAE;YAClF,OAAO,EAAE,OAAO,CAAC,OAAO;YACxB,oBAAoB,EAAE,OAAO,CAAC,oBAAoB,IAAI,0BAA0B;SACnF,CAAC,CAAC;QAEH,OAAO;YACH,QAAQ,EAAE,qBAAqB;YAC/B,SAAS,EAAE;gBACP,GAAG,CAAC,QAAQ,CAAC,SAAS,IAAI,EAAE,CAAC;aAChC;SACJ,CAAC;IACN,CAAC;+GAlBQ,qBAAqB;4DAArB,qBAAqB;gEAHpB,2BAA2B;;iFAG5B,qBAAqB;cAJjC,QAAQ;eAAC;gBACN,OAAO,EAAE,CAAC,2BAA2B,CAAC;gBACtC,OAAO,EAAE,CAAC,2BAA2B,CAAC;aACzC;;wFACY,qBAAqB,cAHpB,2BAA2B,aAC3B,2BAA2B","sourcesContent":["import { NgModule, ModuleWithProviders } from '@angular/core';\nimport { ServiceWorkerModule } from '@angular/service-worker';\nimport { UpdateNotificationComponent } from './update-notification.component';\n\n/**\n * Options for configuring `MJServiceWorkerModule.forRoot()`.\n */\nexport interface MJServiceWorkerOptions {\n /**\n * Master enable flag — when `false`, no service worker is registered and\n * `SwUpdate.isEnabled` resolves to `false`. The toast component is always\n * exported, but it renders nothing when SW is disabled.\n *\n * Wire this to something like `environment.production && environment.enableServiceWorker`\n * so dev builds and ops kill-switches both turn it off cleanly.\n */\n enabled: boolean;\n\n /**\n * The path to the generated SW worker script, relative to the deployed\n * app root. Defaults to `'ngsw-worker.js'` (the Angular CLI default).\n * Override only if you've customized the build output.\n */\n scriptPath?: string;\n\n /**\n * Registration strategy passed to `ServiceWorkerModule.register`.\n * Defaults to `'registerWhenStable:30000'` — defer registration until\n * the app is idle for 30s OR 30s have elapsed, whichever first. Avoids\n * competing with initial app boot for network bandwidth.\n */\n registrationStrategy?: string;\n\n /**\n * How often (in ms) UpdateNotificationService should ask the SW to check\n * for a new manifest while the tab is visible. Defaults to 15 minutes.\n * Pass `0` to disable periodic checking entirely (the SW's internal poll\n * still runs, but on a much longer cadence — typically hours).\n *\n * Note: this is wired by `UpdateNotificationService` itself in its\n * constructor; we don't currently provide a DI token for it. To override\n * at runtime, inject `UpdateNotificationService` and call\n * `service.startAutoCheck(yourIntervalMs)`.\n */\n pollIntervalMs?: number;\n}\n\n/**\n * Bundles MJ Explorer's service worker concerns into one module:\n * - Conditionally registers `@angular/service-worker` based on `enabled`\n * - Exports `UpdateNotificationComponent` (standalone toast)\n * - Exposes `UpdateNotificationService` via `providedIn: 'root'`\n *\n * Consumers should call `MJServiceWorkerModule.forRoot({ enabled: ... })`\n * exactly once in their root NgModule. Typically this is done internally by\n * `MJExplorerAppModule.forRoot(environment)` so MJExplorer (and downstream\n * Explorer-style apps) don't have to wire it themselves.\n *\n * Pre-built `ngsw-config.json` ships at the package root; consumers point\n * their `angular.json` `serviceWorker` field at:\n * `node_modules/@memberjunction/ng-explorer-service-worker/ngsw-config.json`\n *\n * If a consumer doesn't update `angular.json` (no SW worker built) AND\n * doesn't flip `enabled` true in their environment, this module is a no-op.\n * The app keeps working exactly as if the SW didn't exist.\n */\n@NgModule({\n imports: [UpdateNotificationComponent],\n exports: [UpdateNotificationComponent]\n})\nexport class MJServiceWorkerModule {\n static forRoot(options: MJServiceWorkerOptions): ModuleWithProviders<MJServiceWorkerModule> {\n // ServiceWorkerModule.register is always called — the `enabled` flag\n // tells Angular whether to actually attempt registration. We pass the\n // module reference into the providers chain so the SwUpdate token tree\n // is set up regardless (so injection always succeeds; it just reports\n // isEnabled = false when disabled).\n const swModule = ServiceWorkerModule.register(options.scriptPath ?? 'ngsw-worker.js', {\n enabled: options.enabled,\n registrationStrategy: options.registrationStrategy ?? 'registerWhenStable:30000'\n });\n\n return {\n ngModule: MJServiceWorkerModule,\n providers: [\n ...(swModule.providers ?? [])\n ]\n };\n }\n}\n"]}
@@ -0,0 +1,21 @@
1
+ import { UpdateNotificationService } from './update-notification.service';
2
+ import * as i0 from "@angular/core";
3
+ /**
4
+ * Renders a small bottom-right toast when a new app version is available.
5
+ * Two actions: "Reload" applies the update; "Later" dismisses for the session.
6
+ *
7
+ * Standalone component — no NgModule needed, drop into the shell template
8
+ * with `<mj-update-notification></mj-update-notification>`.
9
+ *
10
+ * Visual design uses MJ design tokens (no hardcoded colors) so it picks up
11
+ * dark mode + brand customization automatically. Slide-up entrance animation
12
+ * + subtle pulse on the icon to draw attention without being aggressive.
13
+ */
14
+ export declare class UpdateNotificationComponent {
15
+ readonly updateService: UpdateNotificationService;
16
+ onReload(): void;
17
+ onDismiss(): void;
18
+ static ɵfac: i0.ɵɵFactoryDeclaration<UpdateNotificationComponent, never>;
19
+ static ɵcmp: i0.ɵɵComponentDeclaration<UpdateNotificationComponent, "mj-update-notification", never, {}, {}, never, never, true, never>;
20
+ }
21
+ //# sourceMappingURL=update-notification.component.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-notification.component.d.ts","sourceRoot":"","sources":["../../src/lib/update-notification.component.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;;AAE1E;;;;;;;;;;GAUG;AACH,qBA8Na,2BAA2B;IACpC,SAAgB,aAAa,4BAAqC;IAElE,QAAQ,IAAI,IAAI;IAIhB,SAAS,IAAI,IAAI;yCAPR,2BAA2B;2CAA3B,2BAA2B;CAUvC"}
@@ -0,0 +1,109 @@
1
+ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { UpdateNotificationService } from './update-notification.service';
4
+ import * as i0 from "@angular/core";
5
+ import * as i1 from "@angular/common";
6
+ function UpdateNotificationComponent_Conditional_0_Template(rf, ctx) { if (rf & 1) {
7
+ const _r1 = i0.ɵɵgetCurrentView();
8
+ i0.ɵɵdomElementStart(0, "div", 0)(1, "div", 1);
9
+ i0.ɵɵdomElement(2, "i", 2);
10
+ i0.ɵɵdomElementEnd();
11
+ i0.ɵɵdomElementStart(3, "div", 3)(4, "div", 4);
12
+ i0.ɵɵtext(5, " Update available (test build #7) ");
13
+ i0.ɵɵdomElementEnd();
14
+ i0.ɵɵdomElementStart(6, "div", 5);
15
+ i0.ɵɵtext(7, " A newer version of MemberJunction has been downloaded. Reload now to apply, or keep working and we'll prompt you again next time. ");
16
+ i0.ɵɵdomElementEnd();
17
+ i0.ɵɵdomElementStart(8, "div", 6)(9, "button", 7);
18
+ i0.ɵɵdomListener("click", function UpdateNotificationComponent_Conditional_0_Template_button_click_9_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onReload()); });
19
+ i0.ɵɵdomElement(10, "i", 8);
20
+ i0.ɵɵdomElementStart(11, "span");
21
+ i0.ɵɵtext(12, "Reload now");
22
+ i0.ɵɵdomElementEnd()();
23
+ i0.ɵɵdomElementStart(13, "button", 9);
24
+ i0.ɵɵdomListener("click", function UpdateNotificationComponent_Conditional_0_Template_button_click_13_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onDismiss()); });
25
+ i0.ɵɵtext(14, " Later ");
26
+ i0.ɵɵdomElementEnd()()();
27
+ i0.ɵɵdomElementStart(15, "button", 10);
28
+ i0.ɵɵdomListener("click", function UpdateNotificationComponent_Conditional_0_Template_button_click_15_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onDismiss()); });
29
+ i0.ɵɵdomElement(16, "i", 11);
30
+ i0.ɵɵdomElementEnd()();
31
+ } }
32
+ /**
33
+ * Renders a small bottom-right toast when a new app version is available.
34
+ * Two actions: "Reload" applies the update; "Later" dismisses for the session.
35
+ *
36
+ * Standalone component — no NgModule needed, drop into the shell template
37
+ * with `<mj-update-notification></mj-update-notification>`.
38
+ *
39
+ * Visual design uses MJ design tokens (no hardcoded colors) so it picks up
40
+ * dark mode + brand customization automatically. Slide-up entrance animation
41
+ * + subtle pulse on the icon to draw attention without being aggressive.
42
+ */
43
+ export class UpdateNotificationComponent {
44
+ updateService = inject(UpdateNotificationService);
45
+ onReload() {
46
+ this.updateService.applyUpdate();
47
+ }
48
+ onDismiss() {
49
+ this.updateService.dismissForSession();
50
+ }
51
+ static ɵfac = function UpdateNotificationComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || UpdateNotificationComponent)(); };
52
+ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: UpdateNotificationComponent, selectors: [["mj-update-notification"]], decls: 2, vars: 3, consts: [["role", "status", "aria-live", "polite", 1, "mj-sw-update-toast"], ["aria-hidden", "true", 1, "mj-sw-update-toast__icon"], [1, "fa-solid", "fa-arrows-rotate"], [1, "mj-sw-update-toast__content"], [1, "mj-sw-update-toast__title"], [1, "mj-sw-update-toast__subtitle"], [1, "mj-sw-update-toast__actions"], ["type", "button", 1, "mj-sw-update-toast__btn", "mj-sw-update-toast__btn--primary", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-rotate-right"], ["type", "button", 1, "mj-sw-update-toast__btn", "mj-sw-update-toast__btn--secondary", 3, "click"], ["type", "button", "aria-label", "Dismiss", 1, "mj-sw-update-toast__close", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-xmark"]], template: function UpdateNotificationComponent_Template(rf, ctx) { if (rf & 1) {
53
+ i0.ɵɵconditionalCreate(0, UpdateNotificationComponent_Conditional_0_Template, 17, 0, "div", 0);
54
+ i0.ɵɵpipe(1, "async");
55
+ } if (rf & 2) {
56
+ i0.ɵɵconditional(i0.ɵɵpipeBind1(1, 1, ctx.updateService.updateAvailable$) ? 0 : -1);
57
+ } }, dependencies: [CommonModule, i1.AsyncPipe], styles: ["[_nghost-%COMP%] {\n position: fixed;\n bottom: 24px;\n right: 24px;\n z-index: 1000;\n pointer-events: none;\n max-width: 100%;\n }\n\n @keyframes _ngcontent-%COMP%_mj-sw-toast-in {\n from {\n opacity: 0;\n transform: translateY(16px) scale(0.98);\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n }\n\n @keyframes _ngcontent-%COMP%_mj-sw-toast-pulse {\n 0%, 100% {\n box-shadow: 0 0 0 0 color-mix(in srgb, var(--mj-brand-primary) 35%, transparent);\n }\n 50% {\n box-shadow: 0 0 0 8px color-mix(in srgb, var(--mj-brand-primary) 0%, transparent);\n }\n }\n\n .mj-sw-update-toast[_ngcontent-%COMP%] {\n display: flex;\n align-items: flex-start;\n gap: 14px;\n position: relative;\n background: var(--mj-bg-surface-elevated);\n color: var(--mj-text-primary);\n border: 1px solid var(--mj-border-default);\n border-radius: 12px;\n padding: 16px 18px 16px 16px;\n width: 380px;\n max-width: calc(100vw - 48px);\n box-shadow:\n 0 1px 2px color-mix(in srgb, var(--mj-text-primary) 6%, transparent),\n 0 12px 32px color-mix(in srgb, var(--mj-text-primary) 16%, transparent);\n pointer-events: auto;\n font-size: 13.5px;\n line-height: 1.45;\n animation: _ngcontent-%COMP%_mj-sw-toast-in 220ms cubic-bezier(0.2, 0.9, 0.32, 1.15) both;\n }\n\n .mj-sw-update-toast__icon[_ngcontent-%COMP%] {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n display: grid;\n place-items: center;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 16px;\n animation: _ngcontent-%COMP%_mj-sw-toast-pulse 2.4s ease-in-out 0.4s 2;\n }\n\n .mj-sw-update-toast__content[_ngcontent-%COMP%] {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n .mj-sw-update-toast__title[_ngcontent-%COMP%] {\n color: var(--mj-text-primary);\n font-weight: 600;\n font-size: 14px;\n letter-spacing: -0.005em;\n }\n\n .mj-sw-update-toast__subtitle[_ngcontent-%COMP%] {\n color: var(--mj-text-secondary);\n font-size: 12.5px;\n line-height: 1.5;\n margin-bottom: 4px;\n }\n\n .mj-sw-update-toast__actions[_ngcontent-%COMP%] {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n }\n\n .mj-sw-update-toast__btn[_ngcontent-%COMP%] {\n font: inherit;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid transparent;\n line-height: 1.2;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.08s ease;\n }\n .mj-sw-update-toast__btn[_ngcontent-%COMP%]:active {\n transform: translateY(1px);\n }\n .mj-sw-update-toast__btn[_ngcontent-%COMP%]:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 2px;\n }\n\n .mj-sw-update-toast__btn--primary[_ngcontent-%COMP%] {\n background: var(--mj-brand-primary);\n color: var(--mj-text-inverse);\n }\n .mj-sw-update-toast__btn--primary[_ngcontent-%COMP%]:hover {\n background: var(--mj-brand-primary-hover);\n }\n .mj-sw-update-toast__btn--primary[_ngcontent-%COMP%]:active {\n background: var(--mj-brand-primary-active);\n }\n\n .mj-sw-update-toast__btn--secondary[_ngcontent-%COMP%] {\n background: transparent;\n border-color: var(--mj-border-default);\n color: var(--mj-text-secondary);\n }\n .mj-sw-update-toast__btn--secondary[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n border-color: var(--mj-border-strong);\n }\n\n .mj-sw-update-toast__close[_ngcontent-%COMP%] {\n position: absolute;\n top: 8px;\n right: 8px;\n width: 24px;\n height: 24px;\n border: none;\n background: transparent;\n color: var(--mj-text-muted);\n border-radius: 6px;\n cursor: pointer;\n display: grid;\n place-items: center;\n font-size: 12px;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .mj-sw-update-toast__close[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n }\n .mj-sw-update-toast__close[_ngcontent-%COMP%]:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 1px;\n }\n\n @media (prefers-reduced-motion: reduce) {\n .mj-sw-update-toast[_ngcontent-%COMP%] {\n animation: none;\n }\n .mj-sw-update-toast__icon[_ngcontent-%COMP%] {\n animation: none;\n }\n }"], changeDetection: 0 });
58
+ }
59
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(UpdateNotificationComponent, [{
60
+ type: Component,
61
+ args: [{ standalone: true, selector: 'mj-update-notification', imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
62
+ @if (updateService.updateAvailable$ | async) {
63
+ <div
64
+ class="mj-sw-update-toast"
65
+ role="status"
66
+ aria-live="polite"
67
+ >
68
+ <div class="mj-sw-update-toast__icon" aria-hidden="true">
69
+ <i class="fa-solid fa-arrows-rotate"></i>
70
+ </div>
71
+ <div class="mj-sw-update-toast__content">
72
+ <div class="mj-sw-update-toast__title">
73
+ Update available (test build #7)
74
+ </div>
75
+ <div class="mj-sw-update-toast__subtitle">
76
+ A newer version of MemberJunction has been downloaded. Reload now to apply, or keep working and we'll prompt you again next time.
77
+ </div>
78
+ <div class="mj-sw-update-toast__actions">
79
+ <button
80
+ type="button"
81
+ class="mj-sw-update-toast__btn mj-sw-update-toast__btn--primary"
82
+ (click)="onReload()"
83
+ >
84
+ <i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
85
+ <span>Reload now</span>
86
+ </button>
87
+ <button
88
+ type="button"
89
+ class="mj-sw-update-toast__btn mj-sw-update-toast__btn--secondary"
90
+ (click)="onDismiss()"
91
+ >
92
+ Later
93
+ </button>
94
+ </div>
95
+ </div>
96
+ <button
97
+ type="button"
98
+ class="mj-sw-update-toast__close"
99
+ aria-label="Dismiss"
100
+ (click)="onDismiss()"
101
+ >
102
+ <i class="fa-solid fa-xmark" aria-hidden="true"></i>
103
+ </button>
104
+ </div>
105
+ }
106
+ `, styles: ["\n :host {\n position: fixed;\n bottom: 24px;\n right: 24px;\n z-index: 1000;\n pointer-events: none;\n max-width: 100%;\n }\n\n @keyframes mj-sw-toast-in {\n from {\n opacity: 0;\n transform: translateY(16px) scale(0.98);\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n }\n\n @keyframes mj-sw-toast-pulse {\n 0%, 100% {\n box-shadow: 0 0 0 0 color-mix(in srgb, var(--mj-brand-primary) 35%, transparent);\n }\n 50% {\n box-shadow: 0 0 0 8px color-mix(in srgb, var(--mj-brand-primary) 0%, transparent);\n }\n }\n\n .mj-sw-update-toast {\n display: flex;\n align-items: flex-start;\n gap: 14px;\n position: relative;\n background: var(--mj-bg-surface-elevated);\n color: var(--mj-text-primary);\n border: 1px solid var(--mj-border-default);\n border-radius: 12px;\n padding: 16px 18px 16px 16px;\n width: 380px;\n max-width: calc(100vw - 48px);\n box-shadow:\n 0 1px 2px color-mix(in srgb, var(--mj-text-primary) 6%, transparent),\n 0 12px 32px color-mix(in srgb, var(--mj-text-primary) 16%, transparent);\n pointer-events: auto;\n font-size: 13.5px;\n line-height: 1.45;\n animation: mj-sw-toast-in 220ms cubic-bezier(0.2, 0.9, 0.32, 1.15) both;\n }\n\n .mj-sw-update-toast__icon {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n display: grid;\n place-items: center;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 16px;\n animation: mj-sw-toast-pulse 2.4s ease-in-out 0.4s 2;\n }\n\n .mj-sw-update-toast__content {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n .mj-sw-update-toast__title {\n color: var(--mj-text-primary);\n font-weight: 600;\n font-size: 14px;\n letter-spacing: -0.005em;\n }\n\n .mj-sw-update-toast__subtitle {\n color: var(--mj-text-secondary);\n font-size: 12.5px;\n line-height: 1.5;\n margin-bottom: 4px;\n }\n\n .mj-sw-update-toast__actions {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n }\n\n .mj-sw-update-toast__btn {\n font: inherit;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid transparent;\n line-height: 1.2;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.08s ease;\n }\n .mj-sw-update-toast__btn:active {\n transform: translateY(1px);\n }\n .mj-sw-update-toast__btn:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 2px;\n }\n\n .mj-sw-update-toast__btn--primary {\n background: var(--mj-brand-primary);\n color: var(--mj-text-inverse);\n }\n .mj-sw-update-toast__btn--primary:hover {\n background: var(--mj-brand-primary-hover);\n }\n .mj-sw-update-toast__btn--primary:active {\n background: var(--mj-brand-primary-active);\n }\n\n .mj-sw-update-toast__btn--secondary {\n background: transparent;\n border-color: var(--mj-border-default);\n color: var(--mj-text-secondary);\n }\n .mj-sw-update-toast__btn--secondary:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n border-color: var(--mj-border-strong);\n }\n\n .mj-sw-update-toast__close {\n position: absolute;\n top: 8px;\n right: 8px;\n width: 24px;\n height: 24px;\n border: none;\n background: transparent;\n color: var(--mj-text-muted);\n border-radius: 6px;\n cursor: pointer;\n display: grid;\n place-items: center;\n font-size: 12px;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .mj-sw-update-toast__close:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n }\n .mj-sw-update-toast__close:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 1px;\n }\n\n @media (prefers-reduced-motion: reduce) {\n .mj-sw-update-toast {\n animation: none;\n }\n .mj-sw-update-toast__icon {\n animation: none;\n }\n }\n "] }]
107
+ }], null, null); })();
108
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(UpdateNotificationComponent, { className: "UpdateNotificationComponent", filePath: "src/lib/update-notification.component.ts", lineNumber: 238 }); })();
109
+ //# sourceMappingURL=update-notification.component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-notification.component.js","sourceRoot":"","sources":["../../src/lib/update-notification.component.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;;;;;IAyB1D,AALJ,iCAIC,aAC4D;IACrD,0BAAyC;IAC7C,oBAAM;IAEF,AADJ,iCAAyC,aACE;IACnC,kDACJ;IAAA,oBAAM;IACN,iCAA0C;IACtC,mJACJ;IAAA,oBAAM;IAEF,AADJ,iCAAyC,gBAKpC;IADG,kMAAS,iBAAU,KAAC;IAEpB,2BAA2D;IAC3D,gCAAM;IAAA,2BAAU;IACpB,AADoB,oBAAO,EAClB;IACT,qCAIC;IADG,mMAAS,kBAAW,KAAC;IAErB,wBACJ;IAER,AADI,AADI,oBAAS,EACP,EACJ;IACN,sCAKC;IADG,mMAAS,kBAAW,KAAC;IAErB,4BAAoD;IAE5D,AADI,oBAAS,EACP;;AA3DlB;;;;;;;;;;GAUG;AA+NH,MAAM,OAAO,2BAA2B;IACpB,aAAa,GAAG,MAAM,CAAC,yBAAyB,CAAC,CAAC;IAElE,QAAQ;QACJ,IAAI,CAAC,aAAa,CAAC,WAAW,EAAE,CAAC;IACrC,CAAC;IAED,SAAS;QACL,IAAI,CAAC,aAAa,CAAC,iBAAiB,EAAE,CAAC;IAC3C,CAAC;qHATQ,2BAA2B;6DAA3B,2BAA2B;YAxNhC,8FAA8C;;;YAA9C,mFA2CC;4BA9CK,YAAY;;iFA2Nb,2BAA2B;cA9NvC,SAAS;6BACM,IAAI,YACN,wBAAwB,WACzB,CAAC,YAAY,CAAC,mBACN,uBAAuB,CAAC,MAAM,YACrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA6CT;;kFA4KQ,2BAA2B","sourcesContent":["import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { UpdateNotificationService } from './update-notification.service';\n\n/**\n * Renders a small bottom-right toast when a new app version is available.\n * Two actions: \"Reload\" applies the update; \"Later\" dismisses for the session.\n *\n * Standalone component — no NgModule needed, drop into the shell template\n * with `<mj-update-notification></mj-update-notification>`.\n *\n * Visual design uses MJ design tokens (no hardcoded colors) so it picks up\n * dark mode + brand customization automatically. Slide-up entrance animation\n * + subtle pulse on the icon to draw attention without being aggressive.\n */\n@Component({\n standalone: true,\n selector: 'mj-update-notification',\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (updateService.updateAvailable$ | async) {\n <div\n class=\"mj-sw-update-toast\"\n role=\"status\"\n aria-live=\"polite\"\n >\n <div class=\"mj-sw-update-toast__icon\" aria-hidden=\"true\">\n <i class=\"fa-solid fa-arrows-rotate\"></i>\n </div>\n <div class=\"mj-sw-update-toast__content\">\n <div class=\"mj-sw-update-toast__title\">\n Update available (test build #7)\n </div>\n <div class=\"mj-sw-update-toast__subtitle\">\n A newer version of MemberJunction has been downloaded. Reload now to apply, or keep working and we'll prompt you again next time.\n </div>\n <div class=\"mj-sw-update-toast__actions\">\n <button\n type=\"button\"\n class=\"mj-sw-update-toast__btn mj-sw-update-toast__btn--primary\"\n (click)=\"onReload()\"\n >\n <i class=\"fa-solid fa-rotate-right\" aria-hidden=\"true\"></i>\n <span>Reload now</span>\n </button>\n <button\n type=\"button\"\n class=\"mj-sw-update-toast__btn mj-sw-update-toast__btn--secondary\"\n (click)=\"onDismiss()\"\n >\n Later\n </button>\n </div>\n </div>\n <button\n type=\"button\"\n class=\"mj-sw-update-toast__close\"\n aria-label=\"Dismiss\"\n (click)=\"onDismiss()\"\n >\n <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n </button>\n </div>\n }\n `,\n styles: [`\n :host {\n position: fixed;\n bottom: 24px;\n right: 24px;\n z-index: 1000;\n pointer-events: none;\n max-width: 100%;\n }\n\n @keyframes mj-sw-toast-in {\n from {\n opacity: 0;\n transform: translateY(16px) scale(0.98);\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n }\n\n @keyframes mj-sw-toast-pulse {\n 0%, 100% {\n box-shadow: 0 0 0 0 color-mix(in srgb, var(--mj-brand-primary) 35%, transparent);\n }\n 50% {\n box-shadow: 0 0 0 8px color-mix(in srgb, var(--mj-brand-primary) 0%, transparent);\n }\n }\n\n .mj-sw-update-toast {\n display: flex;\n align-items: flex-start;\n gap: 14px;\n position: relative;\n background: var(--mj-bg-surface-elevated);\n color: var(--mj-text-primary);\n border: 1px solid var(--mj-border-default);\n border-radius: 12px;\n padding: 16px 18px 16px 16px;\n width: 380px;\n max-width: calc(100vw - 48px);\n box-shadow:\n 0 1px 2px color-mix(in srgb, var(--mj-text-primary) 6%, transparent),\n 0 12px 32px color-mix(in srgb, var(--mj-text-primary) 16%, transparent);\n pointer-events: auto;\n font-size: 13.5px;\n line-height: 1.45;\n animation: mj-sw-toast-in 220ms cubic-bezier(0.2, 0.9, 0.32, 1.15) both;\n }\n\n .mj-sw-update-toast__icon {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n display: grid;\n place-items: center;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 16px;\n animation: mj-sw-toast-pulse 2.4s ease-in-out 0.4s 2;\n }\n\n .mj-sw-update-toast__content {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n .mj-sw-update-toast__title {\n color: var(--mj-text-primary);\n font-weight: 600;\n font-size: 14px;\n letter-spacing: -0.005em;\n }\n\n .mj-sw-update-toast__subtitle {\n color: var(--mj-text-secondary);\n font-size: 12.5px;\n line-height: 1.5;\n margin-bottom: 4px;\n }\n\n .mj-sw-update-toast__actions {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n }\n\n .mj-sw-update-toast__btn {\n font: inherit;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid transparent;\n line-height: 1.2;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.08s ease;\n }\n .mj-sw-update-toast__btn:active {\n transform: translateY(1px);\n }\n .mj-sw-update-toast__btn:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 2px;\n }\n\n .mj-sw-update-toast__btn--primary {\n background: var(--mj-brand-primary);\n color: var(--mj-text-inverse);\n }\n .mj-sw-update-toast__btn--primary:hover {\n background: var(--mj-brand-primary-hover);\n }\n .mj-sw-update-toast__btn--primary:active {\n background: var(--mj-brand-primary-active);\n }\n\n .mj-sw-update-toast__btn--secondary {\n background: transparent;\n border-color: var(--mj-border-default);\n color: var(--mj-text-secondary);\n }\n .mj-sw-update-toast__btn--secondary:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n border-color: var(--mj-border-strong);\n }\n\n .mj-sw-update-toast__close {\n position: absolute;\n top: 8px;\n right: 8px;\n width: 24px;\n height: 24px;\n border: none;\n background: transparent;\n color: var(--mj-text-muted);\n border-radius: 6px;\n cursor: pointer;\n display: grid;\n place-items: center;\n font-size: 12px;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .mj-sw-update-toast__close:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n }\n .mj-sw-update-toast__close:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 1px;\n }\n\n @media (prefers-reduced-motion: reduce) {\n .mj-sw-update-toast {\n animation: none;\n }\n .mj-sw-update-toast__icon {\n animation: none;\n }\n }\n `]\n})\nexport class UpdateNotificationComponent {\n public readonly updateService = inject(UpdateNotificationService);\n\n onReload(): void {\n this.updateService.applyUpdate();\n }\n\n onDismiss(): void {\n this.updateService.dismissForSession();\n }\n}\n"]}
@@ -0,0 +1,92 @@
1
+ import { OnDestroy } from '@angular/core';
2
+ import { VersionReadyEvent } from '@angular/service-worker';
3
+ import { Observable } from 'rxjs';
4
+ import * as i0 from "@angular/core";
5
+ export declare class UpdateNotificationService implements OnDestroy {
6
+ private readonly _updateAvailable$;
7
+ private readonly _destroy$;
8
+ private readonly _swUpdate;
9
+ /** Last `VERSION_READY` event captured (for diagnostics / tests). */
10
+ private _lastVersionEvent;
11
+ /** Handle from `setInterval` for the periodic poll; cleared in ngOnDestroy. */
12
+ private _pollHandle;
13
+ /** Bound listener for `visibilitychange`; needs the same reference for removal. */
14
+ private _visibilityListener;
15
+ /** Effective poll interval — set in `startAutoCheck()`. */
16
+ private _pollIntervalMs;
17
+ constructor();
18
+ /**
19
+ * Start a periodic update check on the given interval. Called automatically
20
+ * by the constructor with `DEFAULT_POLL_INTERVAL_MS`. Consumers may call
21
+ * this again with a different interval to retune at runtime — the previous
22
+ * timer is cleared first.
23
+ *
24
+ * Pass `0` (or any non-positive number) to disable periodic checking.
25
+ * Polling is automatically suspended while the tab is hidden and resumed
26
+ * (with an immediate check) when the tab becomes visible again.
27
+ */
28
+ startAutoCheck(intervalMs: number): void;
29
+ /** Stop the periodic poll started by `startAutoCheck()`. */
30
+ stopAutoCheck(): void;
31
+ /**
32
+ * Attach this service to `window.__mjUpdateNotificationService__` so it can
33
+ * be poked from the DevTools console. Useful for ops/QA verification of the
34
+ * update-detection roundtrip without code changes.
35
+ *
36
+ * No-op in non-browser contexts.
37
+ */
38
+ private exposeDebugHandle;
39
+ /**
40
+ * Emits `true` once a new SW-cached version is ready to activate.
41
+ * Stays `true` until either `applyUpdate()` (which reloads the page) or
42
+ * `dismissForSession()` (which resets the flag without reloading).
43
+ */
44
+ get updateAvailable$(): Observable<boolean>;
45
+ /** Synchronous accessor for current update-available state. */
46
+ get isUpdateAvailable(): boolean;
47
+ /** True if the underlying SwUpdate API is active (SW registered + supported). */
48
+ get isServiceWorkerEnabled(): boolean;
49
+ /** The most recent `VERSION_READY` event, if any. Useful for diagnostics. */
50
+ get lastVersionEvent(): VersionReadyEvent | null;
51
+ /**
52
+ * Reload the page to activate the newly-downloaded SW version.
53
+ *
54
+ * Implementation note: the SW activates new versions on the next
55
+ * navigation, not via any in-process API. So we just reload — `SwUpdate`
56
+ * doesn't expose a non-reload way to swap the running JS without losing
57
+ * application state, and any such mechanism would be unsafe anyway.
58
+ *
59
+ * This method is split out (vs. inlining `location.reload()`) so consumers
60
+ * can be tested without actually reloading the page.
61
+ */
62
+ applyUpdate(): void;
63
+ /**
64
+ * Dismiss the prompt for this session without reloading. The flag will
65
+ * re-fire on the next page load if the SW is still pointing at the
66
+ * pending version (which it will be, until something triggers a reload).
67
+ *
68
+ * Use case: a power user is in the middle of editing something and doesn't
69
+ * want to interrupt; they'll get prompted again on their next reload.
70
+ */
71
+ dismissForSession(): void;
72
+ /**
73
+ * Manually nudge the SW to check for an update. The Angular SW polls
74
+ * automatically on a schedule, but consumers can call this to force an
75
+ * immediate check (e.g., from a "Check for updates" menu item).
76
+ *
77
+ * Returns `true` if a new version was found and downloaded; `false` if
78
+ * we're already on the latest. Resolves to `false` if the SW is disabled.
79
+ */
80
+ checkForUpdate(): Promise<boolean>;
81
+ /**
82
+ * Indirection for test injection — overriding `location.reload` directly
83
+ * is not possible in a JSDOM/happy-dom environment without warnings, and
84
+ * spying on `globalThis.location` is brittle. This wrapper lets tests
85
+ * mock the reload via a subclass / `vi.spyOn(service as any, 'performReload')`.
86
+ */
87
+ protected performReload(): void;
88
+ ngOnDestroy(): void;
89
+ static ɵfac: i0.ɵɵFactoryDeclaration<UpdateNotificationService, never>;
90
+ static ɵprov: i0.ɵɵInjectableDeclaration<UpdateNotificationService>;
91
+ }
92
+ //# sourceMappingURL=update-notification.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-notification.service.d.ts","sourceRoot":"","sources":["../../src/lib/update-notification.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,SAAS,EAAU,MAAM,eAAe,CAAC;AAC9D,OAAO,EAA0B,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACpF,OAAO,EAAmB,UAAU,EAAkB,MAAM,MAAM,CAAC;;AA2CnE,qBACa,yBAA0B,YAAW,SAAS;IACvD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAuC;IACzE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuB;IACjD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoB;IAE9C,qEAAqE;IACrE,OAAO,CAAC,iBAAiB,CAAkC;IAE3D,+EAA+E;IAC/E,OAAO,CAAC,WAAW,CAA+C;IAElE,mFAAmF;IACnF,OAAO,CAAC,mBAAmB,CAA6B;IAExD,2DAA2D;IAC3D,OAAO,CAAC,eAAe,CAA4B;;IA6BnD;;;;;;;;;OASG;IACI,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAqB/C,4DAA4D;IACrD,aAAa,IAAI,IAAI;IAW5B;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAMzB;;;;OAIG;IACH,IAAW,gBAAgB,IAAI,UAAU,CAAC,OAAO,CAAC,CAEjD;IAED,+DAA+D;IAC/D,IAAW,iBAAiB,IAAI,OAAO,CAEtC;IAED,iFAAiF;IACjF,IAAW,sBAAsB,IAAI,OAAO,CAE3C;IAED,6EAA6E;IAC7E,IAAW,gBAAgB,IAAI,iBAAiB,GAAG,IAAI,CAEtD;IAED;;;;;;;;;;OAUG;IACI,WAAW,IAAI,IAAI;IAQ1B;;;;;;;OAOG;IACI,iBAAiB,IAAI,IAAI;IAIhC;;;;;;;OAOG;IACU,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC;IAW/C;;;;;OAKG;IAEH,SAAS,CAAC,aAAa,IAAI,IAAI;IAO/B,WAAW,IAAI,IAAI;yCA5LV,yBAAyB;6CAAzB,yBAAyB;CAkMrC"}
@@ -0,0 +1,226 @@
1
+ import { Injectable, inject } from '@angular/core';
2
+ import { SwUpdate } from '@angular/service-worker';
3
+ import { BehaviorSubject, Subject, EMPTY } from 'rxjs';
4
+ import { catchError, filter, takeUntil } from 'rxjs/operators';
5
+ import * as i0 from "@angular/core";
6
+ /**
7
+ * Coordinates notifying the user when a new app-shell version has been
8
+ * downloaded by the service worker and is waiting to activate.
9
+ *
10
+ * Background — the Angular service worker downloads new versions silently in
11
+ * the background; the currently-loaded page keeps using the old cached version
12
+ * until a navigation reload activates the new one. This service watches for
13
+ * `VERSION_READY` events from `SwUpdate` and exposes them via an observable
14
+ * the shell can subscribe to (e.g. to render an "Update available" toast).
15
+ *
16
+ * Use in MJExplorer:
17
+ * 1. Inject this service in `AppComponent` (or wherever the shell lives)
18
+ * 2. Subscribe to `updateAvailable$` and render UI when it emits
19
+ * 3. Call `applyUpdate()` when the user accepts the prompt — this performs
20
+ * a full page reload, which causes the SW to activate the new version
21
+ *
22
+ * **Dev note**: when the service worker is disabled (development builds, or
23
+ * `enableServiceWorker = false` in the environment), `SwUpdate.isEnabled`
24
+ * returns `false` and this service is effectively a no-op. The shell can
25
+ * always subscribe; it just never receives an emission.
26
+ */
27
+ /**
28
+ * How often (in ms) to ask the SW to check for a new manifest while the tab
29
+ * is visible. 15 minutes balances responsiveness against pointless network
30
+ * chatter on idle tabs. Checks are skipped while the tab is hidden (Page
31
+ * Visibility API), and a check is also fired immediately when the tab becomes
32
+ * visible after being backgrounded.
33
+ *
34
+ * Override via `MJServiceWorkerOptions.pollIntervalMs`.
35
+ */
36
+ const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000;
37
+ /**
38
+ * Window-attached debug handle. Type-safe accessor avoids polluting the global
39
+ * namespace with `any`. Use from DevTools console:
40
+ * __mjUpdateNotificationService__.checkForUpdate()
41
+ * __mjUpdateNotificationService__.isUpdateAvailable
42
+ */
43
+ const WINDOW_DEBUG_KEY = '__mjUpdateNotificationService__';
44
+ export class UpdateNotificationService {
45
+ _updateAvailable$ = new BehaviorSubject(false);
46
+ _destroy$ = new Subject();
47
+ _swUpdate = inject(SwUpdate);
48
+ /** Last `VERSION_READY` event captured (for diagnostics / tests). */
49
+ _lastVersionEvent = null;
50
+ /** Handle from `setInterval` for the periodic poll; cleared in ngOnDestroy. */
51
+ _pollHandle = null;
52
+ /** Bound listener for `visibilitychange`; needs the same reference for removal. */
53
+ _visibilityListener = null;
54
+ /** Effective poll interval — set in `startAutoCheck()`. */
55
+ _pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
56
+ constructor() {
57
+ if (this._swUpdate.isEnabled) {
58
+ // Subscribe to all version events; filter to ones that mean a new
59
+ // version is fully downloaded and waiting to activate.
60
+ this._swUpdate.versionUpdates
61
+ .pipe(filter((e) => e.type === 'VERSION_READY'), catchError(err => {
62
+ // Defensive — observable should never error in practice, but if the
63
+ // SW connection breaks for any reason, swallow it. Caller can re-init
64
+ // the service later if needed.
65
+ // eslint-disable-next-line no-console
66
+ console.warn('[UpdateNotificationService] versionUpdates errored:', err);
67
+ return EMPTY;
68
+ }), takeUntil(this._destroy$))
69
+ .subscribe((event) => {
70
+ this._lastVersionEvent = event;
71
+ this._updateAvailable$.next(true);
72
+ });
73
+ this.startAutoCheck(DEFAULT_POLL_INTERVAL_MS);
74
+ this.exposeDebugHandle();
75
+ }
76
+ }
77
+ /**
78
+ * Start a periodic update check on the given interval. Called automatically
79
+ * by the constructor with `DEFAULT_POLL_INTERVAL_MS`. Consumers may call
80
+ * this again with a different interval to retune at runtime — the previous
81
+ * timer is cleared first.
82
+ *
83
+ * Pass `0` (or any non-positive number) to disable periodic checking.
84
+ * Polling is automatically suspended while the tab is hidden and resumed
85
+ * (with an immediate check) when the tab becomes visible again.
86
+ */
87
+ startAutoCheck(intervalMs) {
88
+ this.stopAutoCheck();
89
+ this._pollIntervalMs = intervalMs;
90
+ if (!this._swUpdate.isEnabled || intervalMs <= 0)
91
+ return;
92
+ this._pollHandle = setInterval(() => {
93
+ // Only fire when visible. Hidden tabs catch up via the
94
+ // visibilitychange handler.
95
+ if (typeof document === 'undefined' || !document.hidden) {
96
+ void this.checkForUpdate();
97
+ }
98
+ }, intervalMs);
99
+ if (typeof document !== 'undefined') {
100
+ this._visibilityListener = () => {
101
+ if (!document.hidden)
102
+ void this.checkForUpdate();
103
+ };
104
+ document.addEventListener('visibilitychange', this._visibilityListener);
105
+ }
106
+ }
107
+ /** Stop the periodic poll started by `startAutoCheck()`. */
108
+ stopAutoCheck() {
109
+ if (this._pollHandle !== null) {
110
+ clearInterval(this._pollHandle);
111
+ this._pollHandle = null;
112
+ }
113
+ if (this._visibilityListener && typeof document !== 'undefined') {
114
+ document.removeEventListener('visibilitychange', this._visibilityListener);
115
+ this._visibilityListener = null;
116
+ }
117
+ }
118
+ /**
119
+ * Attach this service to `window.__mjUpdateNotificationService__` so it can
120
+ * be poked from the DevTools console. Useful for ops/QA verification of the
121
+ * update-detection roundtrip without code changes.
122
+ *
123
+ * No-op in non-browser contexts.
124
+ */
125
+ exposeDebugHandle() {
126
+ if (typeof window === 'undefined')
127
+ return;
128
+ // Cast through unknown to avoid a global declaration just for the debug hook.
129
+ window[WINDOW_DEBUG_KEY] = this;
130
+ }
131
+ /**
132
+ * Emits `true` once a new SW-cached version is ready to activate.
133
+ * Stays `true` until either `applyUpdate()` (which reloads the page) or
134
+ * `dismissForSession()` (which resets the flag without reloading).
135
+ */
136
+ get updateAvailable$() {
137
+ return this._updateAvailable$.asObservable();
138
+ }
139
+ /** Synchronous accessor for current update-available state. */
140
+ get isUpdateAvailable() {
141
+ return this._updateAvailable$.value;
142
+ }
143
+ /** True if the underlying SwUpdate API is active (SW registered + supported). */
144
+ get isServiceWorkerEnabled() {
145
+ return this._swUpdate.isEnabled;
146
+ }
147
+ /** The most recent `VERSION_READY` event, if any. Useful for diagnostics. */
148
+ get lastVersionEvent() {
149
+ return this._lastVersionEvent;
150
+ }
151
+ /**
152
+ * Reload the page to activate the newly-downloaded SW version.
153
+ *
154
+ * Implementation note: the SW activates new versions on the next
155
+ * navigation, not via any in-process API. So we just reload — `SwUpdate`
156
+ * doesn't expose a non-reload way to swap the running JS without losing
157
+ * application state, and any such mechanism would be unsafe anyway.
158
+ *
159
+ * This method is split out (vs. inlining `location.reload()`) so consumers
160
+ * can be tested without actually reloading the page.
161
+ */
162
+ applyUpdate() {
163
+ // Hide the prompt before reloading so it doesn't briefly flash again
164
+ // on the new page if anything in the shell renders before the SW
165
+ // activates.
166
+ this._updateAvailable$.next(false);
167
+ this.performReload();
168
+ }
169
+ /**
170
+ * Dismiss the prompt for this session without reloading. The flag will
171
+ * re-fire on the next page load if the SW is still pointing at the
172
+ * pending version (which it will be, until something triggers a reload).
173
+ *
174
+ * Use case: a power user is in the middle of editing something and doesn't
175
+ * want to interrupt; they'll get prompted again on their next reload.
176
+ */
177
+ dismissForSession() {
178
+ this._updateAvailable$.next(false);
179
+ }
180
+ /**
181
+ * Manually nudge the SW to check for an update. The Angular SW polls
182
+ * automatically on a schedule, but consumers can call this to force an
183
+ * immediate check (e.g., from a "Check for updates" menu item).
184
+ *
185
+ * Returns `true` if a new version was found and downloaded; `false` if
186
+ * we're already on the latest. Resolves to `false` if the SW is disabled.
187
+ */
188
+ async checkForUpdate() {
189
+ if (!this._swUpdate.isEnabled)
190
+ return false;
191
+ try {
192
+ return await this._swUpdate.checkForUpdate();
193
+ }
194
+ catch (err) {
195
+ // eslint-disable-next-line no-console
196
+ console.warn('[UpdateNotificationService] checkForUpdate failed:', err);
197
+ return false;
198
+ }
199
+ }
200
+ /**
201
+ * Indirection for test injection — overriding `location.reload` directly
202
+ * is not possible in a JSDOM/happy-dom environment without warnings, and
203
+ * spying on `globalThis.location` is brittle. This wrapper lets tests
204
+ * mock the reload via a subclass / `vi.spyOn(service as any, 'performReload')`.
205
+ */
206
+ /* istanbul ignore next — exercised manually, hard to unit-test cleanly */
207
+ performReload() {
208
+ // Use document.location for cross-environment reliability. `window`
209
+ // can be shadowed in test environments; `document.location` is more
210
+ // consistently available.
211
+ document.location.reload();
212
+ }
213
+ ngOnDestroy() {
214
+ this.stopAutoCheck();
215
+ this._destroy$.next();
216
+ this._destroy$.complete();
217
+ this._updateAvailable$.complete();
218
+ }
219
+ static ɵfac = function UpdateNotificationService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || UpdateNotificationService)(); };
220
+ static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: UpdateNotificationService, factory: UpdateNotificationService.ɵfac, providedIn: 'root' });
221
+ }
222
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(UpdateNotificationService, [{
223
+ type: Injectable,
224
+ args: [{ providedIn: 'root' }]
225
+ }], () => [], null); })();
226
+ //# sourceMappingURL=update-notification.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-notification.service.js","sourceRoot":"","sources":["../../src/lib/update-notification.service.ts"],"names":[],"mappings":"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","sourcesContent":["import { Injectable, OnDestroy, inject } from '@angular/core';\nimport { SwUpdate, VersionEvent, VersionReadyEvent } from '@angular/service-worker';\nimport { BehaviorSubject, Observable, Subject, EMPTY } from 'rxjs';\nimport { catchError, filter, takeUntil } from 'rxjs/operators';\n\n/**\n * Coordinates notifying the user when a new app-shell version has been\n * downloaded by the service worker and is waiting to activate.\n *\n * Background — the Angular service worker downloads new versions silently in\n * the background; the currently-loaded page keeps using the old cached version\n * until a navigation reload activates the new one. This service watches for\n * `VERSION_READY` events from `SwUpdate` and exposes them via an observable\n * the shell can subscribe to (e.g. to render an \"Update available\" toast).\n *\n * Use in MJExplorer:\n * 1. Inject this service in `AppComponent` (or wherever the shell lives)\n * 2. Subscribe to `updateAvailable$` and render UI when it emits\n * 3. Call `applyUpdate()` when the user accepts the prompt — this performs\n * a full page reload, which causes the SW to activate the new version\n *\n * **Dev note**: when the service worker is disabled (development builds, or\n * `enableServiceWorker = false` in the environment), `SwUpdate.isEnabled`\n * returns `false` and this service is effectively a no-op. The shell can\n * always subscribe; it just never receives an emission.\n */\n/**\n * How often (in ms) to ask the SW to check for a new manifest while the tab\n * is visible. 15 minutes balances responsiveness against pointless network\n * chatter on idle tabs. Checks are skipped while the tab is hidden (Page\n * Visibility API), and a check is also fired immediately when the tab becomes\n * visible after being backgrounded.\n *\n * Override via `MJServiceWorkerOptions.pollIntervalMs`.\n */\nconst DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000;\n\n/**\n * Window-attached debug handle. Type-safe accessor avoids polluting the global\n * namespace with `any`. Use from DevTools console:\n * __mjUpdateNotificationService__.checkForUpdate()\n * __mjUpdateNotificationService__.isUpdateAvailable\n */\nconst WINDOW_DEBUG_KEY = '__mjUpdateNotificationService__';\n\n@Injectable({ providedIn: 'root' })\nexport class UpdateNotificationService implements OnDestroy {\n private readonly _updateAvailable$ = new BehaviorSubject<boolean>(false);\n private readonly _destroy$ = new Subject<void>();\n private readonly _swUpdate = inject(SwUpdate);\n\n /** Last `VERSION_READY` event captured (for diagnostics / tests). */\n private _lastVersionEvent: VersionReadyEvent | null = null;\n\n /** Handle from `setInterval` for the periodic poll; cleared in ngOnDestroy. */\n private _pollHandle: ReturnType<typeof setInterval> | null = null;\n\n /** Bound listener for `visibilitychange`; needs the same reference for removal. */\n private _visibilityListener: (() => void) | null = null;\n\n /** Effective poll interval — set in `startAutoCheck()`. */\n private _pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;\n\n constructor() {\n if (this._swUpdate.isEnabled) {\n // Subscribe to all version events; filter to ones that mean a new\n // version is fully downloaded and waiting to activate.\n this._swUpdate.versionUpdates\n .pipe(\n filter((e: VersionEvent): e is VersionReadyEvent => e.type === 'VERSION_READY'),\n catchError(err => {\n // Defensive — observable should never error in practice, but if the\n // SW connection breaks for any reason, swallow it. Caller can re-init\n // the service later if needed.\n // eslint-disable-next-line no-console\n console.warn('[UpdateNotificationService] versionUpdates errored:', err);\n return EMPTY;\n }),\n takeUntil(this._destroy$)\n )\n .subscribe((event: VersionReadyEvent) => {\n this._lastVersionEvent = event;\n this._updateAvailable$.next(true);\n });\n\n this.startAutoCheck(DEFAULT_POLL_INTERVAL_MS);\n this.exposeDebugHandle();\n }\n }\n\n /**\n * Start a periodic update check on the given interval. Called automatically\n * by the constructor with `DEFAULT_POLL_INTERVAL_MS`. Consumers may call\n * this again with a different interval to retune at runtime — the previous\n * timer is cleared first.\n *\n * Pass `0` (or any non-positive number) to disable periodic checking.\n * Polling is automatically suspended while the tab is hidden and resumed\n * (with an immediate check) when the tab becomes visible again.\n */\n public startAutoCheck(intervalMs: number): void {\n this.stopAutoCheck();\n this._pollIntervalMs = intervalMs;\n if (!this._swUpdate.isEnabled || intervalMs <= 0) return;\n\n this._pollHandle = setInterval(() => {\n // Only fire when visible. Hidden tabs catch up via the\n // visibilitychange handler.\n if (typeof document === 'undefined' || !document.hidden) {\n void this.checkForUpdate();\n }\n }, intervalMs);\n\n if (typeof document !== 'undefined') {\n this._visibilityListener = () => {\n if (!document.hidden) void this.checkForUpdate();\n };\n document.addEventListener('visibilitychange', this._visibilityListener);\n }\n }\n\n /** Stop the periodic poll started by `startAutoCheck()`. */\n public stopAutoCheck(): void {\n if (this._pollHandle !== null) {\n clearInterval(this._pollHandle);\n this._pollHandle = null;\n }\n if (this._visibilityListener && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this._visibilityListener);\n this._visibilityListener = null;\n }\n }\n\n /**\n * Attach this service to `window.__mjUpdateNotificationService__` so it can\n * be poked from the DevTools console. Useful for ops/QA verification of the\n * update-detection roundtrip without code changes.\n *\n * No-op in non-browser contexts.\n */\n private exposeDebugHandle(): void {\n if (typeof window === 'undefined') return;\n // Cast through unknown to avoid a global declaration just for the debug hook.\n (window as unknown as Record<string, unknown>)[WINDOW_DEBUG_KEY] = this;\n }\n\n /**\n * Emits `true` once a new SW-cached version is ready to activate.\n * Stays `true` until either `applyUpdate()` (which reloads the page) or\n * `dismissForSession()` (which resets the flag without reloading).\n */\n public get updateAvailable$(): Observable<boolean> {\n return this._updateAvailable$.asObservable();\n }\n\n /** Synchronous accessor for current update-available state. */\n public get isUpdateAvailable(): boolean {\n return this._updateAvailable$.value;\n }\n\n /** True if the underlying SwUpdate API is active (SW registered + supported). */\n public get isServiceWorkerEnabled(): boolean {\n return this._swUpdate.isEnabled;\n }\n\n /** The most recent `VERSION_READY` event, if any. Useful for diagnostics. */\n public get lastVersionEvent(): VersionReadyEvent | null {\n return this._lastVersionEvent;\n }\n\n /**\n * Reload the page to activate the newly-downloaded SW version.\n *\n * Implementation note: the SW activates new versions on the next\n * navigation, not via any in-process API. So we just reload — `SwUpdate`\n * doesn't expose a non-reload way to swap the running JS without losing\n * application state, and any such mechanism would be unsafe anyway.\n *\n * This method is split out (vs. inlining `location.reload()`) so consumers\n * can be tested without actually reloading the page.\n */\n public applyUpdate(): void {\n // Hide the prompt before reloading so it doesn't briefly flash again\n // on the new page if anything in the shell renders before the SW\n // activates.\n this._updateAvailable$.next(false);\n this.performReload();\n }\n\n /**\n * Dismiss the prompt for this session without reloading. The flag will\n * re-fire on the next page load if the SW is still pointing at the\n * pending version (which it will be, until something triggers a reload).\n *\n * Use case: a power user is in the middle of editing something and doesn't\n * want to interrupt; they'll get prompted again on their next reload.\n */\n public dismissForSession(): void {\n this._updateAvailable$.next(false);\n }\n\n /**\n * Manually nudge the SW to check for an update. The Angular SW polls\n * automatically on a schedule, but consumers can call this to force an\n * immediate check (e.g., from a \"Check for updates\" menu item).\n *\n * Returns `true` if a new version was found and downloaded; `false` if\n * we're already on the latest. Resolves to `false` if the SW is disabled.\n */\n public async checkForUpdate(): Promise<boolean> {\n if (!this._swUpdate.isEnabled) return false;\n try {\n return await this._swUpdate.checkForUpdate();\n } catch (err) {\n // eslint-disable-next-line no-console\n console.warn('[UpdateNotificationService] checkForUpdate failed:', err);\n return false;\n }\n }\n\n /**\n * Indirection for test injection — overriding `location.reload` directly\n * is not possible in a JSDOM/happy-dom environment without warnings, and\n * spying on `globalThis.location` is brittle. This wrapper lets tests\n * mock the reload via a subclass / `vi.spyOn(service as any, 'performReload')`.\n */\n /* istanbul ignore next — exercised manually, hard to unit-test cleanly */\n protected performReload(): void {\n // Use document.location for cross-environment reliability. `window`\n // can be shadowed in test environments; `document.location` is more\n // consistently available.\n document.location.reload();\n }\n\n ngOnDestroy(): void {\n this.stopAutoCheck();\n this._destroy$.next();\n this._destroy$.complete();\n this._updateAvailable$.complete();\n }\n}\n"]}
@@ -0,0 +1,4 @@
1
+ export * from './lib/mj-service-worker.module';
2
+ export * from './lib/update-notification.service';
3
+ export * from './lib/update-notification.component';
4
+ //# sourceMappingURL=public-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-api.d.ts","sourceRoot":"","sources":["../src/public-api.ts"],"names":[],"mappings":"AAIA,cAAc,gCAAgC,CAAC;AAC/C,cAAc,mCAAmC,CAAC;AAClD,cAAc,qCAAqC,CAAC"}
@@ -0,0 +1,7 @@
1
+ /*
2
+ * Public API Surface of @memberjunction/ng-explorer-service-worker
3
+ */
4
+ export * from './lib/mj-service-worker.module';
5
+ export * from './lib/update-notification.service';
6
+ export * from './lib/update-notification.component';
7
+ //# sourceMappingURL=public-api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-api.js","sourceRoot":"","sources":["../src/public-api.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,cAAc,gCAAgC,CAAC;AAC/C,cAAc,mCAAmC,CAAC;AAClD,cAAc,qCAAqC,CAAC","sourcesContent":["/*\n * Public API Surface of @memberjunction/ng-explorer-service-worker\n */\n\nexport * from './lib/mj-service-worker.module';\nexport * from './lib/update-notification.service';\nexport * from './lib/update-notification.component';\n"]}
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "../../../../node_modules/@angular/service-worker/config/schema.json",
3
+ "index": "/index.html",
4
+ "assetGroups": [
5
+ {
6
+ "name": "app-shell",
7
+ "installMode": "prefetch",
8
+ "updateMode": "prefetch",
9
+ "resources": {
10
+ "files": [
11
+ "/favicon.ico",
12
+ "/index.html",
13
+ "/manifest.webmanifest",
14
+ "/main-*.js",
15
+ "/polyfills-*.js",
16
+ "/styles-*.css"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "lazy-chunks",
22
+ "installMode": "lazy",
23
+ "updateMode": "prefetch",
24
+ "resources": {
25
+ "files": [
26
+ "/chunk-*.js"
27
+ ]
28
+ }
29
+ },
30
+ {
31
+ "name": "lazy-assets",
32
+ "installMode": "lazy",
33
+ "updateMode": "prefetch",
34
+ "resources": {
35
+ "files": [
36
+ "/assets/**",
37
+ "/media/**",
38
+ "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2|ico)"
39
+ ]
40
+ }
41
+ }
42
+ ],
43
+ "navigationUrls": [
44
+ "/**",
45
+ "!/**/graphql",
46
+ "!/**/graphql-ws",
47
+ "!/api/**",
48
+ "!/auth/**",
49
+ "!/**/?msal*",
50
+ "!/**/*__*"
51
+ ]
52
+ }
package/package.json CHANGED
@@ -1,10 +1,44 @@
1
1
  {
2
2
  "name": "@memberjunction/ng-explorer-service-worker",
3
- "version": "0.0.1",
4
- "description": "OIDC trusted publishing setup package for @memberjunction/ng-explorer-service-worker",
3
+ "version": "5.32.0",
4
+ "description": "MemberJunction: Service worker app-shell pre-cache + update notification UI for MJ Explorer",
5
+ "main": "./dist/public-api.js",
6
+ "typings": "./dist/public-api.d.ts",
7
+ "files": [
8
+ "/dist",
9
+ "/ngsw-config.json",
10
+ "/scripts"
11
+ ],
12
+ "scripts": {
13
+ "test": "vitest run",
14
+ "build": "ngc"
15
+ },
5
16
  "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
17
+ "MemberJunction",
18
+ "Angular",
19
+ "ServiceWorker",
20
+ "PWA",
21
+ "Explorer"
22
+ ],
23
+ "author": "",
24
+ "license": "ISC",
25
+ "devDependencies": {
26
+ "@angular/compiler": "21.1.3",
27
+ "@angular/compiler-cli": "21.1.3",
28
+ "vitest": "^4.0.18"
29
+ },
30
+ "peerDependencies": {
31
+ "@angular/common": "21.1.3",
32
+ "@angular/core": "21.1.3",
33
+ "@angular/service-worker": "21.1.3",
34
+ "rxjs": "^7.8.0"
35
+ },
36
+ "dependencies": {
37
+ "tslib": "^2.8.1"
38
+ },
39
+ "sideEffects": false,
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/MemberJunction/MJ"
43
+ }
10
44
  }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Verify that every root-level .js file in the consumer's prod build matches
4
+ * one of the filename prefixes that `ngsw-config.json` knows how to classify.
5
+ *
6
+ * Why this exists: Angular's `application` builder emits all bundles flat at
7
+ * the dist root with hashed filenames. Our `ngsw-config.json` uses prefix
8
+ * globs (`main-*.js`, `polyfills-*.js`, `chunk-*.js`, `styles-*.css`) to
9
+ * decide which group each file belongs to (eager prefetch vs lazy). If a
10
+ * future Angular release introduces a new prefix (`runtime-*`, `vendor-*`,
11
+ * something nobody saw coming), the SW would silently *not* cache those
12
+ * files — leaving offline users with a half-broken app. This gate fails the
13
+ * build the moment that happens, forcing a conscious update to the config.
14
+ *
15
+ * Usage:
16
+ * node node_modules/@memberjunction/ng-explorer-service-worker/scripts/verify-sw-cache-shape.js \
17
+ * path/to/dist/<app>/browser
18
+ *
19
+ * Suggested wire-up: add a `postbuild` script to your app's package.json:
20
+ * "postbuild": "node node_modules/.../verify-sw-cache-shape.js dist/<app>/browser"
21
+ *
22
+ * Exits 0 on success (every root JS file matches a known prefix), 1 on
23
+ * failure (with a diagnostic listing the unrecognized files).
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const KNOWN_JS_PREFIXES = ['main-', 'polyfills-', 'chunk-', 'styles-'];
32
+ const KNOWN_CSS_PREFIXES = ['styles-'];
33
+
34
+ // SW-infrastructure files that the SW itself manages — NOT application assets.
35
+ // These live at the dist root but should never appear in any asset group.
36
+ const SW_INFRASTRUCTURE_FILES = new Set([
37
+ 'ngsw-worker.js',
38
+ 'ngsw.json',
39
+ 'safety-worker.js',
40
+ 'worker-basic.min.js',
41
+ ]);
42
+
43
+ function fail(message) {
44
+ // eslint-disable-next-line no-console
45
+ console.error('\n[verify-sw-cache-shape] ❌ ' + message + '\n');
46
+ process.exit(1);
47
+ }
48
+
49
+ function ok(message) {
50
+ // eslint-disable-next-line no-console
51
+ console.log('[verify-sw-cache-shape] ✅ ' + message);
52
+ }
53
+
54
+ const distArg = process.argv[2];
55
+ if (!distArg) {
56
+ fail('Usage: verify-sw-cache-shape.js <path-to-browser-dist>');
57
+ }
58
+
59
+ const distDir = path.resolve(distArg);
60
+ if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
61
+ fail('Directory not found: ' + distDir);
62
+ }
63
+
64
+ const entries = fs.readdirSync(distDir, { withFileTypes: true });
65
+ const rootJs = entries
66
+ .filter((e) => e.isFile() && e.name.endsWith('.js'))
67
+ .map((e) => e.name);
68
+ const rootCss = entries
69
+ .filter((e) => e.isFile() && e.name.endsWith('.css'))
70
+ .map((e) => e.name);
71
+
72
+ const unknownJs = rootJs.filter((name) => {
73
+ if (SW_INFRASTRUCTURE_FILES.has(name)) return false;
74
+ return !KNOWN_JS_PREFIXES.some((p) => name.startsWith(p));
75
+ });
76
+
77
+ const unknownCss = rootCss.filter(
78
+ (name) => !KNOWN_CSS_PREFIXES.some((p) => name.startsWith(p))
79
+ );
80
+
81
+ if (unknownJs.length > 0 || unknownCss.length > 0) {
82
+ const lines = [
83
+ 'Unrecognized root-level files in dist — these are NOT classified by',
84
+ 'ngsw-config.json and will silently bypass the service worker cache:',
85
+ '',
86
+ ];
87
+ if (unknownJs.length > 0) {
88
+ lines.push(' Unrecognized .js prefixes:');
89
+ unknownJs.forEach((n) => lines.push(' - ' + n));
90
+ }
91
+ if (unknownCss.length > 0) {
92
+ lines.push(' Unrecognized .css prefixes:');
93
+ unknownCss.forEach((n) => lines.push(' - ' + n));
94
+ }
95
+ lines.push('');
96
+ lines.push('Either:');
97
+ lines.push(' 1. Update @memberjunction/ng-explorer-service-worker/ngsw-config.json');
98
+ lines.push(' to add an asset-group entry for the new prefix.');
99
+ lines.push(' 2. Update KNOWN_JS_PREFIXES / KNOWN_CSS_PREFIXES in this script');
100
+ lines.push(' if the file should be intentionally excluded from caching.');
101
+ fail(lines.join('\n'));
102
+ }
103
+
104
+ ok(
105
+ `${rootJs.length} root .js + ${rootCss.length} root .css verified ` +
106
+ `(${SW_INFRASTRUCTURE_FILES.size} SW infrastructure files ignored)`
107
+ );