Progressive Web App
The dashboard is installable and works as a standalone app on mobile. Four
pieces make that up: the Angular service worker, a web app manifest, an
in-app update prompt, and an iOS-specific install nudge — iOS has never
supported the beforeinstallprompt event, so Safari users have to be walked
through Share → Add to Home Screen by hand.
Everything here is production only. In development there is no service
worker, no caching, no update banner and no install prompt, which means PWA
behaviour cannot be reproduced with npm start.
Registration
AppModule registers the worker, gated on the build flavour:
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerImmediately',
}),
registerImmediately overrides Angular’s default of waiting for app
stability. The default never fires here because the application holds long-lived
timers and polling subscriptions, so without this the worker would never
register.
AppComponent then starts the runtime behaviour behind the same flag:
private initServiceWorker() {
this.environmentService.production && this.serviceWorkerService.init();
}
Note the two checks read different things: ServiceWorkerModule reads
environment.production directly, while AppComponent goes through
EnvironmentService so tests can stub it. See
API Client for how that service wraps the
generated environment file.
Caching — ngsw-config.json
Asset groups
| Group | Install mode | Contents |
|---|---|---|
|
|
|
|
|
Everything under |
|
|
|
Data groups
|
|
The blog feed |
|
|
The second data group’s name key is misspelled |
No API responses are cached. Every request to the Payability API goes to the network, which is deliberate: financial balances must never be served stale.
A performance strategy means the cache is served first and the network is
only consulted after maxAge expires. For the theme files that is the right
trade — a three-day-old stylesheet is fine. Do not extend this pattern to
anything a supplier would read as current.
Update flow
Production builds are hash-named (outputHashing: "all"), so a deploy produces
a new manifest the worker can detect. ServiceWorkerService polls for it and
prompts rather than reloading underneath the user, who may be mid-form.
private readonly fifteenMinutes = 1500000;
private checkIsUpdateAvailable(): void {
this.updates.isEnabled &&
this.addSubscription(
interval(this.fifteenMinutes)
.pipe(takeWhile(() => !this.isUpdateBannerShowed))
.subscribe(() => this.updates.checkForUpdate().then()),
);
}
private listenForUpdate(): void {
this.addSubscription(
this.updates.available.pipe(takeWhile(() => !this.isUpdateBannerShowed)).subscribe(() => {
this.showUpdateBanner();
}),
);
}
Both streams complete once the banner has been shown, so a supplier who dismisses the prompt is not asked again for the life of the tab.
|
|
UpdateAppBannerComponent renders inside the banner slot and does the reload:
update(): void {
this.updates.activateUpdate().then(() => document.location.reload());
}
The banner is registered as a component banner rather than a message string, which is how the banner system supports an action button:
private readonly updateBannerSettings: Banner = {
message: '',
component: UpdateAppBannerComponent,
type: BannerTypes.INFO,
};
See Dashboard Notifications for the banner queue itself.
|
|
Offline detection
The connection banner is driven by the browser’s own events, not by the service worker:
private isOnline$(): Observable<boolean> {
return merge(
of(navigator.onLine),
fromEvent(window, 'online').pipe(mapTo(true)),
fromEvent(window, 'offline').pipe(mapTo(false)),
);
}
Going offline shows a WARNING banner with hideClose: true so it cannot be
dismissed while the condition holds; coming back online removes it.
|
|
getConnectionBannerSettings() rebuilds the settings object each time it shows
the banner, and removeConnectionBanner() passes the stored reference to
deleteBanner() — so removal depends on the field still holding the same object
identity that was queued.
Manifest
src/manifest.webmanifest, copied to the build output and linked from
index.html:
{
"name": "Payability Dashboard",
"short_name": "Payability",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone",
"scope": "./",
"start_url": "/index.html",
"icons": [
{
"src": "assets/icons/icon-144x144.png",
"sizes": "any 512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}
|
The icon declaration is wrong in two ways. There is a single 144×144 file
declared as Fixing this means adding real 192, 384 and 512 pixel exports plus a separate maskable variant with padding, and declaring each with its true size. |
theme_color is #ffffff in the manifest but #1976d2 in the index.html
<meta name="theme-color">. The two disagree; installed apps use the manifest,
browser chrome uses the meta tag.
Also note display: standalone combined with scope: "./" and
start_url: "/index.html" — the app uses hash routing
(Route Tree), so every route lives under
/index.html#/… and the scope covers them all.
iOS install prompt
index.html carries the Apple-specific tags:
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="./assets/icons/icon-144x144.png" />
<link href="./assets/icons/icon-144x144.png" rel="apple-touch-startup-image" />
Detection is user-agent based, because Safari exposes no install API:
isIOSMobileUser(): boolean {
const userAgent = navigator?.userAgent || navigator?.vendor;
return /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream;
}
isStandaloneMode(): boolean {
return 'standalone' in window.navigator && (window.navigator as any).standalone;
}
If the visitor is on iOS and not already running installed, a bottom-anchored modal explains the Add to Home Screen steps:
private showInstallAppPopupIfIOSUser(): void {
this.isIOSMobileUser() &&
!this.isStandaloneMode() &&
this.pybDialog.openMobile(PwaIOSInstallModalComponent, {
hasBackdrop: false,
panelClass: ['pyb-ios-pwa-install'],
});
}
hasBackdrop: false keeps the page usable behind the prompt.
PwaIOSInstallModalComponent is presentation only — a close button and
instructional copy.
|
Since iPadOS 13, Safari on iPad reports a desktop user agent, so
Dismissing the modal is not remembered. Every full page load on an uninstalled iOS device shows it again. |
iOS layout fix
The same detection drives a layout workaround in BaseComponent, because
Safari’s toolbars are excluded from 100vh:
private checkIfIOSUserAndFixPaddingIssue(): void {
if (this.serviceWorkerService.isIOSMobileUser() && !this.serviceWorkerService.isStandaloneMode()) {
this.renderer2.addClass(this.footer.nativeElement, 'pyb-ios-padding');
this.renderer2.setStyle(this.drawer.nativeElement, 'height', window.innerHeight + 'px');
}
}
The drawer height is pinned once at init, so it does not follow a rotation or a toolbar collapse. See Application Shell.
Testing PWA behaviour
None of this runs under npm start. To exercise it:
npm run build:prod
npx http-server dist -p 4200 # any static server; the SW needs a real origin
Then in Chrome DevTools:
-
Application → Service Workers — confirm
ngsw-worker.jsis activated. -
Application → Manifest — installability and icon warnings.
-
/ngsw/state— the worker’s own debug page, listing cached asset groups and the current manifest hash. -
Network → Offline — verify the shell loads and the connection banner appears.
To exercise the update banner, build twice with a change between builds, load
the first, then swap the served output and call
navigator.serviceWorker.ready.then(r ⇒ r.update()) from the console rather
than waiting out the interval.
|
A stale service worker is the most common cause of "my fix isn’t deployed".
|