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

app

prefetch

index.html, manifest.webmanifest, pyb-glyph-dark.svg, and all top-level .css and .js. Fetched during install so the shell works offline immediately.

assets

lazy

Everything under /assets/** plus fonts and images by extension. Cached on first request.

blog-media

lazy

https://www.payability.com/wp-content/uploads/*/.* — images for the dashboard’s blog widget, with updateMode: lazy.

Data groups

themes

/themes/**, performance strategy, maxSize: 100, maxAge: 3d. This is what keeps the runtime theme CSS (Styling and Theming) from being re-fetched on every navigation.

The blog feed

https://www.payability.com/wp-json/wp/v2/posts, performance, maxSize: 10, maxAge: 1d, ignoreSearch: false so paginated requests cache separately.

The second data group’s name key is misspelled "mane" instead of "name". The Angular service worker config schema does not reject the unknown key, so the group is built with an undefined name. It still functions, but it cannot be referenced by name and shows up unnamed in debugging output at /ngsw/state. Worth fixing, carefully — renaming a data group invalidates its cache for every installed client.

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.

1500000 milliseconds is 25 minutes, not the 15 the field name claims. The interval is also the only trigger after startup — SwUpdate fires available on its own when the worker finds a new version, but the periodic checkForUpdate() is what makes it look.

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.

activateUpdate() then reload() discards unsaved form state without warning. Because the prompt can appear at any moment, a supplier halfway through a withdrawal or onboarding step can lose their input by clicking it. There is no guard on the current route.

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.

navigator.onLine reports whether the device has a network interface, not whether the API is reachable. A supplier on captive-portal Wi-Fi is "online" and will see request failures with no banner. Conversely the banner clears the moment the interface returns, before the API is necessarily reachable.

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 "sizes": "any 512x512", so Android will upscale it for the splash screen and app drawer. And purpose: "maskable any" promises safe-zone padding that a plain 144px glyph does not have, so the icon will be cropped inside Android’s adaptive icon mask.

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 isIOSMobileUser() returns false there and iPad users never see the prompt. Checking navigator.maxTouchPoints > 1 alongside the platform string is the usual workaround.

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.js is 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". ngsw-worker.js itself is served with no-cache headers, but if a deploy leaves a mismatched manifest, clients keep serving the previous build from cache. Clear it with Application → Clear storage in DevTools, and check /ngsw/state for the hash the client actually has.