Compare commits

...

6 Commits

Author SHA1 Message Date
1105a9f7c5 Add extra message to +error.svelte to handle, and clarify 'OFFLINE' error. 2026-05-14 19:43:22 +01:00
25b64edabd Add features:
- manifest.json with PNG icon set.
 - reset option against QuickLinks storage
 - 'Preferences' store, ready for preferences page - including location boolean... Important!
 - Minor service-worker improvements for better offline fallbacks
2026-05-14 19:39:05 +01:00
bc56e66178 Add initlal service-worker with basic offline handling. Manifest is not yet present. 2026-05-13 20:47:01 +01:00
0ef9e5b56f Adjust dynamic styles on the departure board component 2026-05-12 20:35:12 +01:00
b88f4f3b35 Dynamically adjust font size based on vw value 2026-05-12 19:06:25 +01:00
a2e6f3b99a Switch departure board to use Inconsolata monospaced font (varying widths, weights, etc.) 2026-05-11 20:28:22 +01:00
19 changed files with 711 additions and 182 deletions

View File

@@ -3,12 +3,14 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="title" content="OwlBoard | Your fastest route to live and reference data" />
<meta
name="description"
content="Live station departures, Live train tracking, PIS Codes & more"
/>
<!-- PWA Requirements -->
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
<meta name="theme-color" content="#4fd1d1" />
<link rel="canonical" href="https://owlboard.info" />
<meta property="og:type" content="website" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" viewBox="0 0 562.1 562.1"><defs><radialGradient xlink:href="#a" id="b" cx="256" cy="256" r="254" fx="256" fy="256" gradientTransform="translate(27 27)" gradientUnits="userSpaceOnUse"/><linearGradient id="a"><stop offset="0" stop-color="#2b343c"/><stop offset="1" stop-color="#404c55"/></linearGradient></defs><path fill="url(#b)" d="M694.3 283a411.3 394 0 0 1-410.7 394 411.3 394 0 0 1-412-392.8 411.3 394 0 0 1 409.5-395.1 411.3 394 0 0 1 413.2 391.4" transform="translate(-2 -2)"/><path fill="#4fd1d1" d="M281 81a200 200 0 0 0-153.4 71.8 120 120 0 0 0 50.5 25.8c9 2.3 8.9 2.3 15.6-.6 50-21.6 124.4-21.6 175.2-.1 13.3 5.6 44.6-6.5 65.9-24.8a200 200 0 0 0-153.8-72m-161.9 82.7a200 200 0 0 0-38 117.3 200 200 0 0 0 200 200 200 200 0 0 0 200-200 200 200 0 0 0-37.8-116.8 96 96 0 0 1-17.7 46.6l-2.8 4 1.3 1.8c73.3 99.2-14.3 222.2-116.7 163.6-2.5-1.4-4.7-2.4-5-2.1s-10.5 19.6-18 34c-3.4 6.5-2.7 6.6-6.3-.3-9.2-17.5-18-34-18.3-34-.1 0-2.3 1-4.7 2.5-101.3 58-190.7-65.7-117.4-162.4q2.3-3 1.8-3.6a102 102 0 0 1-20.4-50.6m86.5 57q-14.6 0-29.8 7.5C108 261.8 143.1 366.4 217.5 352a68 68 0 0 0 43.3-28.4c2.4-3.4 3.3-4.2 3.7-3.7l8.5 14.6c9.4 16.3 8 15 11.4 9 5.1-9 13.7-23.5 14-24 .3-.2 1.9 1.8 3.6 4.3 31 45.5 101.6 35.6 118.3-16.5a66.4 66.4 0 0 0-83.9-83c-23.3 7.7-38.7 24.4-53.8 58.4l-1.3 3-2.7-6c-17-38.5-43.7-59.5-73-59.2m147.2 34.7a32 32 0 0 1 23.7 9.5c22.8 22.8.7 60.8-30.9 53.1a32 32 0 0 1-23.2-37.3 31.5 31.5 0 0 1 30.4-25.3m-145.8 0c16.4-.8 32.7 11 33.9 30.1a32.2 32.2 0 0 1-40.1 32.5 31.5 31.5 0 0 1-15-52.9 32 32 0 0 1 21.2-9.6m12 9.6q-3.3 0-5.8 3c-6.3 7.4 2.5 17.7 11 13a9 9 0 0 0 4.2-7.5c0-5.4-4.8-8.9-9.4-8.5m123.1 0c-6 .5-9.6 6.5-7 12.3 2 4.5 8.3 6.3 12.2 3.6 7.2-4.9 4.7-15.3-4-15.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -8,7 +8,25 @@
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script>
<table class="departure-board">
<table class="departure-board">
<colgroup>
<col style="width:10%;" />
<!-- ID (Headcode) -->
<col style="width:17%;" />
<!-- ORIG (Origin) -->
<col style="width:17%;" />
<!-- DEST (Destination) -->
<col style="width:9%;" />
<!-- PLT (Platform) -->
<col style="width: 12%;" />
<!-- STA -->
<col style="width: 12%;" />
<!-- ETA/ATA -->
<col style="width: 12%;" />
<!-- STD -->
<col style="width: 11%;" />
<!-- ETD/ATD -->
</colgroup>
<thead>
<tr>
<th colspan="4" aria-hidden="true"></th>
@@ -29,44 +47,105 @@
{#each services as service (getRowKey(service))}
<tbody>
<tr class="service-row" class:serviceCancelled={service.c} class:servicePass={service.wtp}>
<tr
class="service-row"
class:serviceCancelled={service.c}
class:servicePass={service.wtp}
class:serviceNonPassenger={!service.ip}
>
<td class="id-cell">{service.h}</td>
<td class="orig-cell">{service.og.t}</td>
<td class="dest-cell">{service.dt.t}</td>
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}>{service.p}</td>
<td class="orig-cell"
><abbr title={service.og.n.toLocaleUpperCase()}>{service.og.t}</abbr></td
>
<td class="dest-cell"
><abbr title={service.dt.n.toLocaleUpperCase()}>{service.dt.t}</abbr></td
>
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}
>{service.p || '-'}</td
>
<!-- Handle different display for a passing train -->
{#if service.wtp}
<td class="pass-cell" colspan="2">Pass</td>
<td class="time-cell">{formatUkTime(service.wtp)}</td>
<td class="time-cell {estClass(service.atp, service.etp)} {delayClassFromTimePair(service.wtp, service.atp || service.etp)}">{formatUkTime(service.atp || service.etp)}</td>
<!-- If cancelled, show '-', otherwise check for RT or show time -->
<td
class="time-cell {estClass(service.atp, service.etp)} {delayClassFromTimePair(
service.wtp,
service.atp || service.etp
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.wtp, service.atp || service.etp) === 'delay-rt'
? 'RT'
: formatUkTime(service.atp || service.etp)}
</span>
</td>
{:else}
<td class="time-cell">{formatUkTime(service.sta)}</td>
<td class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(service.sta, service.ata || service.eta)}">{formatUkTime(service.ata || service.eta)}</td>
<!-- Arrival Actual/Expected -->
<td
class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(
service.sta,
service.ata || service.eta
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.sta, service.ata || service.eta) === 'delay-rt'
? 'RT'
: formatUkTime(service.ata || service.eta)}
</span>
</td>
<td class="time-cell">{formatUkTime(service.std)}</td>
<td class="time-cell {estClass(service.atd, service.etd)} {delayClassFromTimePair(service.std, service.atd || service.etd)}">{formatUkTime(service.atd || service.etd)}</td>
<!-- Departure Actual/Expected -->
<td
class="time-cell {estClass(service.atd, service.etd)} {delayClassFromTimePair(
service.std,
service.atd || service.etd
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.std, service.atd || service.etd) === 'delay-rt'
? 'RT'
: formatUkTime(service.atd || service.etd)}
</span>
</td>
{/if}
</tr>
{#if service.o}
<tr class="toc-coach-row">
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r}
<tr>
<td aria-hidden="true"></td>
<tr class="cancel-row">
<td colspan="8">
{service.cr.r}
{#if service.cr.l}
{service.cr.n ? "near" : "at"}
{service.cr.n ? 'near' : 'at'}
{service.cr.l}
{/if}
</td>
</tr>
{/if}
{#if service.dr?.r}
<tr>
<td aria-hidden="true"></td>
<td colspan="8">
<tr class="delay-row">
<td colspan="9">
{service.dr.r}
{#if service.dr.l}
{service.dr.n ? "near" : "at"}
{service.dr.n ? 'near' : 'at'}
{service.dr.l}
{/if}
</td>
@@ -74,75 +153,266 @@
{/if}
</tbody>
{/each}
</table>
</table>
<style>
.departure-board {
width: 100%;
margin: 0 auto;
font-family: 'URW Gothic', sans-serif;
font-variant-numeric: tabular-nums;
margin: 5px auto;
border-collapse: collapse;
table-layout: fixed;
}
.departure-board td {
font-family: 'Inconsolata Variable', monospace;
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
font-variant-ligatures: additional-ligatures;
overflow: hidden;
}
thead,
.cancel-row td,
.delay-row td {
letter-spacing: -0.15ch;
}
thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-dark);
}
abbr {
text-decoration: none;
border-bottom: none;
cursor: help;
}
tbody tr:first-child td {
border-top: 5px solid transparent;
}
/* Row Logic */
.serviceCancelled {
text-decoration: line-through;
color: rgb(255, 131, 131);
}
.servicePass {
font-style: italic;
.servicePass td {
opacity: 0.75;
font-weight: 200;
}
.serviceNonPassenger td {
opacity: 0.5;
font-weight: 290;
font-style: italic;
}
.id-cell{
font-family:'Courier New', Courier, monospace;
text-align: center;
/* Special Row Styles */
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
text-align: left;
}
.orig-cell, .dest-cell {
color: var(--location-yellow);
.cancel-row td[colspan],
.delay-row td[colspan] {
padding-left: 0ch;
}
.cancel-row td {
color: rgb(255, 131, 131);
font-weight: 400;
}
.delay-row td {
color: var(--delay-orange);
font-style: italic;
}
@media (min-width: 375px) {
.cancel-row td,
.delay-row td {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.cancel-row td,
.delay-row td {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.cancel-row td,
.delay-row td {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.cancel-row td,
.delay-row td {
font-size: 1.2rem;
}
}
.toc-coach-row td {
font-family: 'URW Gothic', sans-serif;
font-size: 0.7rem;
padding: 0;
color: rgb(187, 187, 255);
}
@media (min-width: 400px) {
.toc-coach-row td {
font-size: 0.8rem;
}
}
@media (min-width: 550px) {
.toc-coach-row td {
font-size: 0.85rem;
}
}
/* Column Specifics */
.id-cell {
text-align: left;
font-weight: 400;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.id-cell {
font-stretch: 90%;
}
}
.orig-cell,
.dest-cell {
font-stretch: 80%;
font-weight: 400;
}
.orig-cell {
text-align: left;
color: var(--location-yellow);
}
.dest-cell {
text-align: right;
color: var(--location-yellow);
}
@media (min-width: 350px) {
.orig-cell,
.dest-cell {
font-stretch: 85%;
}
@media (min-width: 400px) {
.orig-cell,
.dest-cell {
font-stretch: 90%;
}
}
@media (min-width: 490px) {
.orig-cell,
.dest-cell {
font-stretch: 95%;
}
}
@media (min-width: 520px) {
.orig-cell,
.dest-cell {
font-stretch: 100%;
}
.orig-cell {
text-align: right;
padding-right: 7px;
}
.dest-cell {
text-align: left;
padding-left: 7px;
}
}
@media (min-width: 600px) {
.orig-cell,
.dest-cell {
letter-spacing: 0.05ch;
}
}
}
.plt-cell {
text-align: center;
font-weight: 410;
font-stretch: 100%;
}
.platChange {
animation: 2s fast-pulse ease-in-out infinite;
@media (min-width: 525px) {
.plt-cell {
font-stretch: 110%;
}
.platSup {
filter: opacity(0.5);
}
.plt-cell.platSup {
font-weight: 200;
opacity: 0.3;
}
.plt-cell.platChange {
animation: fast-pulse 2s ease-out infinite;
}
.service-row.serviceCancelled .plt-cell {
text-decoration: line-through;
}
.pass-cell {
text-align: center;
font-stretch: 100%;
}
/* Colour orig and dest values when cancelled */
.service-row.serviceCancelled .orig-cell,
.service-row.serviceCancelled .dest-cell {
color: rgb(255, 131, 131);
}
.time-cell {
text-align: center;
font-stretch: 72%;
}
@media (min-width: 350px) {
.time-cell {
font-stretch: 77%;
}
}
@media (min-width: 400px) {
.time-cell {
font-stretch: 82%;
}
}
@media (min-width: 525px) {
.time-cell {
font-stretch: 90%;
}
}
@media (min-width: 600px) {
.time-cell {
font-stretch: 100%;
}
}
/* RT Logic */
.time-cell.delay-rt span {
display: none;
}
.time-cell.delay-rt::after {
content: 'RT';
}
.time-cell.delay-early {
color: var(--early-blue);
}
.time-cell.delay-late {
color: var(--delay-orange);
}
/* Time Types */
.est {
font-style: oblique;
font-style: italic;
opacity: 0.75;
font-weight: 200;
}
.act {
font-weight: 600;
color: green;
}
.delay-cell {
text-align: center;
}
.delay-late {
color: var(--delay-orange);
animation: 2s fast-pulse ease-in-out infinite;
}
.delay-early {
color: var(--early-blue);
animation: 2s fast-pulse ease-in-out infinite;
}
</style>

View File

@@ -49,6 +49,11 @@
margin-bottom: 0;
width: 95%;
max-width: 750px;
-webkit-tap-highlight-color: transparent;
}
.alert-card:active {
filter: brightness(1.2);
}
.trigger {
@@ -56,7 +61,7 @@
display: flex;
border-radius: 8px;
align-items: center;
padding: 0.4rem 1rem;
padding: 0.1rem 1.05rem;
background: var(--alert-orange);
color: white;
border: none;
@@ -64,13 +69,13 @@
text-align: left;
position: relative;
z-index: 2;
height: 46px;
height: 40px;
transition: all 0.65s 0.2s;
}
.trigger.active {
border-radius: 8px 8px 0 0;
transition: all 0.1s 0s;
transition: all 0.01s 0s;
}
.warning-icon {
@@ -105,7 +110,7 @@
.content {
padding: 1rem;
position: absolute;
top: 46px;
top: 40px;
border-radius: 0 0 8px 8px;
left: 0;
right: 0;

View File

@@ -129,6 +129,36 @@
font-style: normal;
font-display: swap;
}
/* Fira Code - Variable with fallback */
@font-face {
font-family: 'Fira Code';
src: url('/type/fira-code/FiraCode-VF.woff2') format('woff2-variations');
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('/type/fira-code/FiraCode-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Inconsolata Variable */
@font-face {
font-family: 'Inconsolata Variable';
font-style: normal;
font-display: swap;
font-weight: 200 900;
font-stretch: 50% 200%;
src: url(/type/inconsolata/latin-wdth-normal.woff2) format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
/* Fixed Heights */
--header-height: 80px;
@@ -163,7 +193,7 @@
opacity: 1;
}
50% {
opacity: 0.3;
opacity: 0.5;
}
}

View File

@@ -0,0 +1,43 @@
import { browser } from '$app/environment';
export interface Preferences {
locationEnabled: boolean;
}
const STORAGE_KEY = 'ob_pref';
const DEFAULT_PREFS: Preferences = {
locationEnabled: false
};
function loadPrefs(): Preferences {
if (!browser) return DEFAULT_PREFS;
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return DEFAULT_PREFS;
try {
return { ...DEFAULT_PREFS, ...JSON.parse(stored) };
} catch {
return DEFAULT_PREFS;
}
}
class PreferencesStore {
current = $state<Preferences>(loadPrefs());
constructor() {
$effect.root(() => {
$effect(() => {
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.current));
}
});
});
}
toggleLocation(enabled: boolean) {
this.current.locationEnabled = enabled;
}
}
export const prefs = new PreferencesStore();

View File

@@ -15,7 +15,11 @@ class QuickLinksService {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ql');
if (saved) {
try {
this.#links = JSON.parse(saved);
} catch {
this.#links = [];
}
}
}
}
@@ -53,8 +57,17 @@ class QuickLinksService {
this.#links = sorted.slice(0, MAX_ENTRIES);
if (typeof window !== 'undefined') {
localStorage.setItem('ql', JSON.stringify(this.#links));
}
}
reset() {
this.#links = [];
if (typeof window !== 'undefined') {
localStorage.removeItem('ql');
}
}
}
export const quickLinks = new QuickLinksService();

View File

@@ -1,6 +1,6 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
export const estClass = (act: any, est: any) => (!act && est ? 'est' : 'act');
export const estClass = (act: any, est: any) => (act ? 'act' : 'est');
/**
* Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds
@@ -10,10 +10,10 @@ export function formatUkTime(
dateStr: string | Date | undefined,
includeSeconds: boolean = false
): string {
if (!dateStr) return '--:--';
if (!dateStr) return '-';
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
if (isNaN(date.getTime())) return '--:--';
if (isNaN(date.getTime())) return '-';
return date.toLocaleTimeString('en-GB', {
hour: '2-digit',
@@ -57,14 +57,20 @@ export function formatUkDateTime(
*/
interface DelayInput {
// Board types
sta?: string | null; std?: string | null;
eta?: string | null; etd?: string | null;
ata?: string | null; atd?: string | null;
sta?: string | null;
std?: string | null;
eta?: string | null;
etd?: string | null;
ata?: string | null;
atd?: string | null;
// Journey types
pta?: string | null; ptd?: string | null;
wta?: string | null; wtd?: string | null;
atp?: string | null; wtp?: string | null;
pta?: string | null;
ptd?: string | null;
wta?: string | null;
wtd?: string | null;
atp?: string | null;
wtp?: string | null;
etp?: string | null;
}

View File

@@ -12,6 +12,7 @@
<img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" />
{:else if page.status == 499}
<!-- Change to a GSM-R X Sign?? -->
<span>OFFLINE!</span>
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{:else}
<!-- STOP Error image -->
@@ -29,7 +30,7 @@
</div>
{/if}
{#if $page.error?.owlCode === 'NETWORK_DISCONNECTED'}
{#if page.error?.owlCode === 'NETWORK_DISCONNECTED'}
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button>
{:else}
<Button href={'/'} color={'accent'}>Return to Home</Button>

View File

@@ -2,9 +2,9 @@
import { page } from '$app/state';
import { slide, fade } from 'svelte/transition';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { navigating } from '$app/state';
import { LOCATIONS } from '$lib/locations-object.svelte';
import { nearestStationsState } from '$lib/geohash.svelte';
import Loading from '$lib/components/ui/Loading.svelte';
import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
@@ -17,7 +17,19 @@
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
onMount(() => LOCATIONS.init());
onMount(async () => {
LOCATIONS.init();
if (browser && 'serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js', {
type: 'module'
});
console.info('OwlBoard Service Worker registered', registration.scope);
} catch (error) {
console.error('Service Worker installation failed: ', error);
}
}
});
let { children } = $props();
@@ -147,7 +159,7 @@
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
<p>
Just as trains need the right track width, our data needs a bit more room to stay on the rails.
Please expand your view to at least 300px to view the app.
Please expand your view to at least 320px to view the app.
</p>
</div>
@@ -319,7 +331,7 @@
display: none;
}
@media (max-width: 299px) {
@media (max-width: 319px) {
.viewport-guard {
display: block;
position: fixed;

View File

@@ -90,7 +90,8 @@
flex: 1;
overflow-y: auto;
height: calc(100dvh - 60px);
width: 100%;
width: 97%;
max-width: 700px;
margin-bottom: 5px;
}
</style>

83
src/service-worker.ts Normal file
View File

@@ -0,0 +1,83 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
import type { ApiEnvelope } from '@owlboard/api-schema-types';
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE_NAME = `owlboard-cache-${version}`;
const ASSETS = [
...build,
...files.filter((file) => !file.endsWith('manifest.json') && !file.startsWith('/icons/')),
'/'
];
sw.addEventListener('install', (event) => {
sw.skipWaiting();
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
});
sw.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
caches.keys().then((keys) => {
return Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
);
}),
sw.clients.claim()
])
);
});
sw.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
// Uncached static assets
if (url.pathname.endsWith('manifest.json') || url.pathname.includes('/icons/')) {
event.respondWith(fetch(request));
return;
}
// 1. Static Assets (Cache-First)
if (ASSETS.includes(url.pathname) || url.pathname.startsWith('/_app/')) {
event.respondWith(caches.match(request).then((res) => res || fetch(request)));
return;
}
// 2. Offline API Fallback (Network-First)
if (url.pathname.startsWith('/api') || url.hostname.includes('api')) {
event.respondWith(
fetch(request).catch(() => {
const errorObject: ApiEnvelope.Envelope = {
e: {
code: 'NETWORK_DISCONNECTED',
msg: 'Cannot connect to the OwlBoard server'
},
t: Math.floor(Date.now() / 1000)
};
return new Response(JSON.stringify(errorObject), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
})
);
return;
}
// 3. Navigation Fallback (The Offline 404 Catch-All)
// This catches top-level page navigations and hard refreshes
if (request.mode === 'navigate' || request.headers.get('accept')?.includes('text/html')) {
event.respondWith(
fetch(request).catch(() => {
// The network request failed entirely (offline).
// Serve the cached root shell so SvelteKit can boot.
return caches.match('/') as Promise<Response>;
})
);
return;
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
static/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
static/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

62
static/manifest.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "OwlBoard",
"short_name": "OwlBoard",
"description": "Real-time UK rail data and operational tools for staff and enthusiasts.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": [
"window-controls-overlay",
"standalone",
"minimal-ui"
],
"background_color": "#404c55",
"theme_color": "#4fd1d1",
"orientation": "portrait",
"categories": [
"transportation",
"utilities"
],
"dir": "ltr",
"lang": "en-GB",
"prefer_related_applications": false,
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Find PIS",
"short_name": "PIS",
"description": "Find PIS Codes fast",
"url": "/pis",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192"
}
]
}
],
"handle_links": "preferred",
"launch_handler": {
"client_mode": "focus-existing"
}
}

Binary file not shown.