Compare commits

...

11 Commits

Author SHA1 Message Date
22aee0b915 Improve live indications (Smaller, but maybe marginally less user friendly?) Shorten poll interval for boards due to backend optimizations.
ensure data is reloaded when tab/page becomes visible.
2026-05-21 20:06:33 +01:00
f30a2745d4 Add initial staff grid view 2026-05-18 20:44:05 +01:00
9e0f53b7fe Adjust live flasher animated saturation 2026-05-18 20:36:47 +01:00
03495764e0 Adjust live indicator location 2026-05-18 20:30:22 +01:00
9657a77c41 Adjust poll frequency 2026-05-18 20:20:41 +01:00
437b0b8cf1 Add live indicator to board page 2026-05-18 20:20:01 +01:00
40e926bf11 Features:
- Add preferences for WakeLock and AutoRefresh
 - Add WakeLock and AutoRefresh to Board
 - Attempt adding transitions to Board elements

UI:
 - Add '3D' styling to preference toggles
2026-05-18 11:25:18 +01:00
631d324cba Add preferences and privacy policy 2026-05-16 21:06:29 +01:00
07579310b2 Improve +error.svelte handling 2026-05-16 19:19:31 +01:00
b51845528f 'Genericise' errors ready for +error.svelte upgrade to handle known errors 2026-05-16 18:19:47 +01:00
808b576df1 Improve error handling 2026-05-16 15:10:49 +01:00
20 changed files with 1206 additions and 110 deletions

4
src/app.d.ts vendored
View File

@@ -4,7 +4,9 @@ declare global {
namespace App { namespace App {
interface Error { interface Error {
message: string; message: string;
owlCode?: string; code?: string;
name?: string;
msg?: string;
} }
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}

11
src/hooks.client.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { HandleClientError } from '@sveltejs/kit';
export const handleError: HandleClientError = ({ error }) => {
const err = error as any;
return {
message: err?.message || 'An unexpected application error occurred.',
code: err?.code || 'CRITICAL',
name: err?.name || 'UNHANDLED_EXCEPTION'
};
};

View File

@@ -2,6 +2,8 @@
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte'; import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/form-elements/Button.svelte'; import Button from '$lib/components/ui/form-elements/Button.svelte';
import { prefs } from '$lib/preferences.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
@@ -12,7 +14,9 @@
<BaseCard header={'Nearby Stations'}> <BaseCard header={'Nearby Stations'}>
<div class="card-content"> <div class="card-content">
{#if nearestStationsState.error && nearestStationsState.list.length === 0} {#if !prefs.current.NearToMe}
<Button onclick={() => prefs.toggleNearToMe(true)}>Enable Location</Button>
{:else if nearestStationsState.error && nearestStationsState.list.length === 0}
<p class="msg">{nearestStationsState.error}</p> <p class="msg">{nearestStationsState.error}</p>
{:else if nearestStationsState.loading && nearestStationsState.list.length === 0} {:else if nearestStationsState.loading && nearestStationsState.list.length === 0}
<p class="msg">Locating stations...</p> <p class="msg">Locating stations...</p>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
interface Props {
checked: boolean;
id: string;
label: string;
disabled?: boolean;
onchange?: (checked: boolean) => void;
}
let { checked = $bindable(false), id, label, disabled = false, onchange }: Props = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
checked = target.checked;
onchange?.(checked);
}
</script>
<div class="toggle-wrapper" class:is-disabled={disabled}>
<!-- 1. Interactive Switch Mechanism (Sits Left) -->
<div class="toggle-control">
<input type="checkbox" {id} {disabled} {checked} onchange={handleInput} class="toggle-input" />
<label for={id} class="toggle-track">
<span class="toggle-thumb"></span>
</label>
</div>
<!-- 2. Visible Text Label (Sits Right) -->
<label for={id} class="toggle-label-text">
{label}
</label>
</div>
<style>
.toggle-wrapper {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 1.75rem;
width: 100%;
}
.toggle-control {
position: relative;
display: inline-flex;
align-items: center;
}
.toggle-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.toggle-track {
position: relative;
display: inline-block;
width: 3.75rem;
height: 2rem;
background-color: rgb(93, 45, 45);
border-radius: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
}
.toggle-thumb {
position: absolute;
top: 0.25rem;
left: 0.25rem;
width: 1.5rem;
height: 1.5rem;
background-color: var(--color-accent);
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
filter: brightness(1.45);
}
.toggle-input:checked + .toggle-track {
background-color: rgb(11, 70, 11);
}
.toggle-input:checked + .toggle-track .toggle-thumb {
transform: translateX(1.75rem);
}
.toggle-input:focus-visible + .toggle-track {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.toggle-label-text {
font-weight: 600;
cursor: pointer;
user-select: none;
color: var(--color-title);
}
.is-disabled {
opacity: 0.5;
}
.is-disabled .toggle-track,
.is-disabled .toggle-label-text {
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,431 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
import { fade } from 'svelte/transition';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
const getRowKey = (s: ApiStationsBoard.BoardService) =>
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script>
<section class="departure-board">
<div class="header">
<div class="header-row"></div>
<div class="header-row"></div>
</div>
<div class="services">
<!-- Keyed EACH Here -->
</div>
</section>
<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>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Arrival">Arr</abbr></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Departure">Dep</abbr></th>
</tr>
<tr>
<th scope="col"><abbr title="Headcode">ID</abbr></th>
<th scope="col"><abbr title="Origin">Orig</abbr></th>
<th scope="col"><abbr title="Destination">Dest</abbr></th>
<th scope="col"><abbr title="Platform">Plt</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
</tr>
</thead>
{#each services as service (getRowKey(service))}
<tbody>
<tr
in:fade={{duration: 150, delay:150}}
out:fade={{duration:150}}
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"
><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>
<!-- 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>
<!-- 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>
<!-- 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" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r}
<tr class="cancel-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8">
{service.cr.r}
{#if service.cr.l}
{service.cr.n ? 'near' : 'at'}
{service.cr.l}
{/if}
</td>
</tr>
{/if}
{#if service.dr?.r}
<tr class="delay-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="9">
{service.dr.r}
{#if service.dr.l}
{service.dr.n ? 'near' : 'at'}
{service.dr.l}
{/if}
</td>
</tr>
{/if}
</tbody>
{/each}
</table>
<style>
.departure-board {
width: 100%;
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 {
color: rgb(255, 131, 131);
}
.servicePass td {
opacity: 0.75;
font-weight: 200;
}
.serviceNonPassenger td {
opacity: 0.5;
font-weight: 290;
font-style: italic;
}
/* Special Row Styles */
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
text-align: left;
}
.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%;
}
@media (min-width: 525px) {
.plt-cell {
font-stretch: 110%;
}
}
.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: italic;
opacity: 0.75;
font-weight: 350;
}
.act {
font-weight: 600;
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts'; import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time'; import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
import { fade } from 'svelte/transition';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props(); let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
@@ -48,6 +49,8 @@
{#each services as service (getRowKey(service))} {#each services as service (getRowKey(service))}
<tbody> <tbody>
<tr <tr
in:fade={{duration: 150, delay:150}}
out:fade={{duration:150}}
class="service-row" class="service-row"
class:serviceCancelled={service.c} class:serviceCancelled={service.c}
class:servicePass={service.wtp} class:servicePass={service.wtp}
@@ -123,14 +126,14 @@
</tr> </tr>
{#if service.o} {#if service.o}
<tr class="toc-coach-row"> <tr class="toc-coach-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8"> <td colspan="8">
{service.o} {service.o}
</td> </td>
</tr> </tr>
{/if} {/if}
{#if service.c && service.cr?.r} {#if service.c && service.cr?.r}
<tr class="cancel-row"> <tr class="cancel-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8"> <td colspan="8">
{service.cr.r} {service.cr.r}
{#if service.cr.l} {#if service.cr.l}
@@ -141,7 +144,7 @@
</tr> </tr>
{/if} {/if}
{#if service.dr?.r} {#if service.dr?.r}
<tr class="delay-row"> <tr class="delay-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="9"> <td colspan="9">
{service.dr.r} {service.dr.r}
{#if service.dr.l} {#if service.dr.l}

View File

@@ -1,12 +1,15 @@
import { OwlClient, ValidationError, ApiError } from './owlClient'; import { OwlClient, ValidationError, ApiError } from './owlClient';
import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts'; import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts';
import { prefs } from './preferences.svelte';
class NearestStationsState { class NearestStationsState {
list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]); list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]);
currentHash = $state(''); currentHash = $state('');
loading = $state(true); loading = $state(false);
error = $state<string | null>(null); error = $state<string | null>(null);
private watcherId: number | null = null;
private initGeoConfig: PositionOptions = { private initGeoConfig: PositionOptions = {
enableHighAccuracy: false, enableHighAccuracy: false,
timeout: 500, timeout: 500,
@@ -20,10 +23,35 @@ class NearestStationsState {
}; };
constructor() { constructor() {
if (typeof window !== 'undefined' && 'geolocation' in navigator) { if (typeof window === 'undefined' || !('geolocation' in navigator)) return;
this.jumpstart();
this.initWatcher(); $effect.root(() => {
$effect(() => {
if (prefs.current.NearToMe) {
this.startTracking();
} else {
this.stopTracking();
}
});
});
}
private startTracking() {
if (this.watcherId !== null) return; // Already tracking
this.loading = true;
this.jumpstart();
this.initWatcher();
}
private stopTracking() {
if (this.watcherId !== null) {
navigator.geolocation.clearWatch(this.watcherId);
this.watcherId = null;
} }
this.list = [];
this.currentHash = '';
this.loading = false;
this.error = null;
} }
private jumpstart() { private jumpstart() {
@@ -35,7 +63,7 @@ class NearestStationsState {
} }
private initWatcher() { private initWatcher() {
navigator.geolocation.watchPosition( this.watcherId = navigator.geolocation.watchPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude), (pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err), (err) => this.handleError(err),
this.geoConfig this.geoConfig

View File

@@ -1,5 +1,6 @@
import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts'; import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts';
import { browser, dev } from '$app/environment'; import { browser, dev } from '$app/environment';
import { error } from '@sveltejs/kit';
// Import the runes containing the API Key config Here... // Import the runes containing the API Key config Here...
@@ -18,4 +19,48 @@ export const OwlClient = new OwlBoardClient(
// API Key Here when ready!!! // API Key Here when ready!!!
); );
export function ThrowApiError(e: unknown): never {
// Handle Request failure
if (e instanceof TypeError && e.message === 'Failed to fetch') {
throw error(503, {
message: 'Unable to connect to the OwlBoard server',
name: 'NETWORK_ERROR',
code: 'NETWORK_UNREACHABLE'
});
}
// Map ApiError 'code' values
if (e instanceof ApiError) {
console.error(JSON.stringify(e), e.code, 'ERRCODE');
let status = 500;
if (e.code === 'AUTH') {
status = 401;
} else if (e.code === 'NOT_FOUND') {
status = 404;
} else if (e.code === 'RATE_LIMIT') {
status = 421;
} else if (e.code === 'NETWORK_DISCONNECTED') {
status = 503;
} else if (e.code === 'VALIDATION') {
status = 400;
}
throw error(status, {
message: e.message || 'An operational error has occurred',
name: e.name || 'API_ERROR',
code: e.code || 'UNKNOWN_API_ERROR'
});
}
// Fallback for other error kind
const genericMessage = e instanceof Error ? e.message : 'An unexpected error occurred.';
console.log(e);
throw error(500, {
message: genericMessage,
name: 'CRITICAL_ERROR',
code: 'INTERNAL_ERROR',
msg: 'UNHANDLED_EXCEPTION'
});
}
export { ValidationError, ApiError }; export { ValidationError, ApiError };

View File

@@ -1,13 +1,19 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
export interface Preferences { export interface Preferences {
locationEnabled: boolean; NearToMe: boolean;
ShowPassingTrains: boolean;
BoardWakeLock: boolean;
AutoRefresh: boolean;
} }
const STORAGE_KEY = 'ob_pref'; const STORAGE_KEY = 'ob_pref';
const DEFAULT_PREFS: Preferences = { const DEFAULT_PREFS: Preferences = {
locationEnabled: false NearToMe: false,
ShowPassingTrains: true,
BoardWakeLock: true,
AutoRefresh: true,
}; };
function loadPrefs(): Preferences { function loadPrefs(): Preferences {
@@ -16,7 +22,13 @@ function loadPrefs(): Preferences {
if (!stored) return DEFAULT_PREFS; if (!stored) return DEFAULT_PREFS;
try { try {
return { ...DEFAULT_PREFS, ...JSON.parse(stored) }; const parsed = JSON.parse(stored);
const cleaned = {} as Preferences;
for (const key of Object.keys(DEFAULT_PREFS) as Array<keyof Preferences>) {
cleaned[key] = key in parsed ? parsed[key] : DEFAULT_PREFS[key];
}
return cleaned;
} catch { } catch {
return DEFAULT_PREFS; return DEFAULT_PREFS;
} }
@@ -35,8 +47,20 @@ class PreferencesStore {
}); });
} }
toggleLocation(enabled: boolean) { toggleNearToMe(enabled: boolean) {
this.current.locationEnabled = enabled; this.current.NearToMe = enabled;
}
toggleShowPassingTrains(enabled: boolean) {
this.current.ShowPassingTrains = enabled;
}
toggleBoardWakeLock(enabled: boolean) {
this.current.BoardWakeLock = enabled;
}
toggleAutoRefresh(enabled: boolean) {
this.current.AutoRefresh = enabled;
} }
} }

View File

@@ -4,21 +4,35 @@
import stopErr from '$lib/assets/img/stop-error.svg'; import stopErr from '$lib/assets/img/stop-error.svg';
import noResult from '$lib/assets/img/no-data.svg'; import noResult from '$lib/assets/img/no-data.svg';
import Button from '$lib/components/ui/form-elements/Button.svelte'; import Button from '$lib/components/ui/form-elements/Button.svelte';
import { IconAntennaBarsOff } from '@tabler/icons-svelte-runes';
</script> </script>
<!-- Will need to check error type, using the upstream code combined with response code --> <!-- Will need to check error type, using the upstream code combined with response code -->
<!-- {#if page.error}
{JSON.stringify(page.error)}
STATUS: {page.status}
{/if} -->
<div class="error-wrapper"> <div class="error-wrapper">
{#if page.status == 404} {#if page.error?.code}
<!-- Warning no data image --> {#if page.error.code === 'NOT_FOUND'}
<img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" /> <img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" />
{:else if page.status == 503} {:else if page.error.code === 'RATE_LIMIT'}
<!-- Change to a GSM-R X Sign?? --> <img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
<span>OFFLINE!</span> {:else if page.error.code === 'VALIDATION'}
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" /> <img class="err-img" src={noResult} alt="" role="presentation" width="150" height="210" />
{:else if page.status == 403} {:else if page.error.code === 'AUTH'}
<span>UNAUTH</span> <img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" /> {:else if page.error.code === 'SERVER'}
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{:else if page.error.code === 'NETWORK_DISCONNECTED'}
<div class="err-img gentle-flash">
<IconAntennaBarsOff size={120} />
</div>
{:else}
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{/if}
{:else} {:else}
<!-- STOP Error image --> <!-- STOP Error image -->
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" /> <img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
@@ -29,17 +43,16 @@
{page.error?.message ?? 'An unexpected derailment occurred.'} {page.error?.message ?? 'An unexpected derailment occurred.'}
</p> </p>
{#if page.error?.owlCode == 'NETWORK_DISCONNECTED'} {#if page.error?.code}
<p>THISISANETWORKERR</p>
<p>Operational data is unavaliable when offline</p>
{:else}
<div class="debug-info"> <div class="debug-info">
<code>Ref: {page.error?.owlCode}</code> <code>Ref: {page.error.code}</code>
</div> </div>
{/if} {/if}
{#if page.error?.owlCode === 'NETWORK_DISCONNECTED'} {#if page.error?.code === 'NETWORK_DISCONNECTED'}
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button> <Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button>
{:else if page.error?.code === 'NOT_FOUND'}
<Button onclick={() => history.back()} color={'accent'}>Go Back</Button>
{:else} {:else}
<Button href={'/'} color={'accent'}>Return to Home</Button> <Button href={'/'} color={'accent'}>Return to Home</Button>
{/if} {/if}
@@ -70,6 +83,19 @@
} }
} }
@keyframes gentle-flash {
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.gentle-flash {
animation: gentle-flash ease-in-out 2s infinite;
}
.label { .label {
color: #ff4444; color: #ff4444;
letter-spacing: 0.2rem; letter-spacing: 0.2rem;

View File

@@ -15,7 +15,14 @@
import logoPlain from '$lib/assets/round-logo.svg'; import logoPlain from '$lib/assets/round-logo.svg';
import favicon from '$lib/assets/round-logo.svg'; import favicon from '$lib/assets/round-logo.svg';
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte-runes'; import {
IconHome,
IconDialpad,
IconSettings,
IconHelp,
IconDots,
IconSpy,
} from '@tabler/icons-svelte-runes';
onMount(async () => { onMount(async () => {
LOCATIONS.init(); LOCATIONS.init();
@@ -41,13 +48,14 @@
{ label: 'Home', path: '/', icon: IconHome }, { label: 'Home', path: '/', icon: IconHome },
{ label: 'PIS', path: '/pis/', icon: IconDialpad }, { label: 'PIS', path: '/pis/', icon: IconDialpad },
{ label: 'Options', path: '/preferences/', icon: IconSettings }, { label: 'Options', path: '/preferences/', icon: IconSettings },
{ label: 'About', path: '/about/', icon: IconHelp } { label: 'About', path: '/about/', icon: IconHelp },
{ label: 'Privacy Policy', path: '/privacy/', icon: IconSpy },
]; ];
let navWidth = $state(0); let navWidth = $state(0);
let menuOpen = $state(false); let menuOpen = $state(false);
const ITEM_WIDTH = 70; const ITEM_WIDTH = 110;
const MORE_BUTTON_WIDTH = 70; const MORE_BUTTON_WIDTH = 110;
const activePath = $derived(page.url?.pathname ?? ''); const activePath = $derived(page.url?.pathname ?? '');
@@ -106,6 +114,7 @@
<!-- Dynamic Nav Elements Here! --> <!-- Dynamic Nav Elements Here! -->
{#each visibleItems as item} {#each visibleItems as item}
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')} {@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
{@const Icon = item.icon}
<a <a
href={item.path} href={item.path}
class="nav-item" class="nav-item"
@@ -113,7 +122,7 @@
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
> >
<item.icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" /> <Icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" />
<span class="label">{item.label}</span> <span class="label">{item.label}</span>
</a> </a>
@@ -138,6 +147,7 @@
<div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}> <div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}>
{#each hiddenItems as item} {#each hiddenItems as item}
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')} {@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
{@const Icon = item.icon}
<a <a
href={item.path} href={item.path}
class="menu-popover-item" class="menu-popover-item"
@@ -145,7 +155,7 @@
class:active={isActive} class:active={isActive}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
> >
<item.icon size={20} /> <Icon size={20} />
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
{/each} {/each}

View File

@@ -11,19 +11,15 @@
</script> </script>
<div class="logo-container"> <div class="logo-container">
<button <button
type="button" type="button"
class="logo-btn" class="logo-btn"
onclick={handleLogoTap} onclick={handleLogoTap}
class:animate={isSpinning} class:animate={isSpinning}
aria-label="Spin the OwlBoard logo" aria-label="Spin the OwlBoard logo"
> >
<img <img class="logo-img" src={logo} alt="OwlBoard Logo" />
class="logo-img" </button>
src={logo}
alt="OwlBoard Logo"
/>
</button>
</div> </div>
<section class="about"> <section class="about">
@@ -66,17 +62,15 @@
} }
.logo-btn { .logo-btn {
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
padding-top: 25px; padding-top: 25px;
margin: auto; margin: auto;
width: clamp(80px, 20vw, 200px); width: clamp(80px, 20vw, 200px);
} }
@keyframes owl-spin { @keyframes owl-spin {
0% { 0% {
@@ -91,14 +85,14 @@
} }
.logo-img { .logo-img {
width: 100%; width: 100%;
height: auto; height: auto;
display: block; display: block;
} }
.logo-btn.animate { .logo-btn.animate {
animation: owl-spin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; animation: owl-spin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
} }
section { section {
margin: auto; margin: auto;

View File

@@ -1,12 +1,91 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte'; import { quickLinks } from '$lib/quick-links.svelte';
import { invalidateAll } from '$app/navigation';
import StationAlertCard from '$lib/components/ui/station-board/StationAlertCard.svelte'; import StationAlertCard from '$lib/components/ui/station-board/StationAlertCard.svelte';
import { formatUkDateTime, formatUkTime } from '$lib/utils/time'; import { formatUkDateTime, formatUkTime } from '$lib/utils/time';
import StaffServicesTable from '$lib/components/ui/station-board/StaffServicesTable.svelte'; import StaffServicesTable from '$lib/components/ui/station-board/StaffServicesTable.svelte';
import StaffServicesGrid from '$lib/components/ui/station-board/StaffServicesGrid.svelte';
let { data } = $props(); let { data } = $props();
let now = $state(new Date()); let now = $state(new Date());
let dataAgeInSeconds = $derived(
Math.floor((now.getTime() - new Date(data.boardData.producedAt).getTime()) / 1000)
);
let live = $derived(dataAgeInSeconds < 45);
// Handle auto-refreshing & Wake-lock
$effect(() => {
let intervalId: number | undefined;
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
if ('wakeLock' in navigator && !wakeLock) {
try {
wakeLock = await navigator.wakeLock.request('screen');
console.log("Wake lock obtained");
} catch (error) {
console.warn("Wake lock request rejected: ", error);
}
}
};
const releaseWakeLock = async () => {
if (wakeLock) {
try {
await wakeLock.release();
console.log("Wake lock released");
} catch (error) {
console.error("Wake lock release failed: ", error);
}
wakeLock = null;
}
};
const startPolling = async () => {
await requestWakeLock();
if (intervalId) return;
intervalId = window.setInterval(async () => {
try {
console.log("Invalidating data")
await invalidateAll();
} catch (error) {
console.error("Background data fetch failed: ", error)
}
}, 20000);
console.log("Polling started")
};
const stopPolling = async () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = undefined;
console.log("Polling ended")
}
await releaseWakeLock();
};
const handleVisibilityChange = async () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
await invalidateAll();
}
};
if (!document.hidden) {
startPolling();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
})
onMount(() => { onMount(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -16,28 +95,31 @@
return () => clearInterval(interval); return () => clearInterval(interval);
}); });
// Update 'QuickLinks' const currentBoardId = $derived(
$effect(() => { data.BoardLocation ? (data.BoardLocation.c ?? data.BoardLocation.t) : null
if (data.BoardLocation) { );
const id = data.BoardLocation?.c ?? data.BoardLocation?.t;
if (id) { // Update 'QuickLinks' when currentBoardId changes
// Untrack, as we do not need to handle changes to quickLinks - this is WRITE_ONLY $effect(() => {
untrack(() => { if (currentBoardId) {
quickLinks.recordVisit(id); // Keep the execution and logs inside untrack to isolate dependencies completely
console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`); untrack(() => {
}); quickLinks.recordVisit(currentBoardId);
} console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
} });
}); }
});
// Wake Lock API Handling // Wake Lock API Handling
// Load Data Invalidation Handling // Load Data Invalidation Handling
// Refresh countdown logic // Refresh countdown logic
</script> </script>
<section class="board-wrapper"> <section class="board-wrapper">
<div class="time-data"> <div class="time-data">
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span> <div class="loadedlive">
<div class="liveflash {live ? 'isLive' : 'notLive'}"></div>
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span>
</div>
<span class="time-now">{formatUkTime(now, true)}</span> <span class="time-now">{formatUkTime(now, true)}</span>
</div> </div>
{#if data.boardData.data.m?.length} {#if data.boardData.data.m?.length}
@@ -45,7 +127,7 @@
{/if} {/if}
{#if data.boardData.data.s?.length} {#if data.boardData.data.s?.length}
<div class="service-list-wrapper"> <div class="service-list-wrapper">
<StaffServicesTable services={data.boardData.data.s} /> <StaffServicesGrid services={data.boardData.data.s} />
</div> </div>
{/if} {/if}
</section> </section>
@@ -72,6 +154,42 @@
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
} }
.loadedlive {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.liveflash {
width: 12px;
height: 12px;
border-radius: 50%;
transition: all 0.3s ease;
}
div.notLive {
background-color: #ef4444;
animation:
led-blink 1s steps(1, start) infinite,
led-halo-expand 1s steps(1, start) infinite;
}
div.isLive {
background-color: #10b981;
animation:
led-pulse 2.5s infinite ease-in-out;
}
@keyframes led-blink {
0%,100%{opacity:1;}
50%{opacity:0.2;filter:saturate(0.2)}
}
@keyframes led-pulse{
0%,100%{opacity: 1;filter:saturate(1);box-shadow: 0 0 6px 1px rgba(16,185,129,0.4)}
50%{opacity: 0.2;filter:saturate(0.2);box-shadow:0 0 2px 0px rgba(16,185,129,0.1)}
}
.time-loaded, .time-loaded,
.time-now { .time-now {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -86,6 +204,10 @@
font-size: 1rem; font-size: 1rem;
} }
.live-indicator {
margin: 0.25rem auto;
}
.service-list-wrapper { .service-list-wrapper {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;

View File

@@ -12,7 +12,7 @@ export const load: PageLoad = async ({ url, fetch }) => {
if (!locId) { if (!locId) {
error(400, { error(400, {
message: 'Location not provided', message: 'Location not provided',
owlCode: 'NO_LOCATION_IN_PATH' code: 'VALIDATION'
}); });
} }
@@ -21,7 +21,7 @@ export const load: PageLoad = async ({ url, fetch }) => {
if (!BoardLocation) { if (!BoardLocation) {
error(404, { error(404, {
message: `Location (${locId.toUpperCase()}) not found`, message: `Location (${locId.toUpperCase()}) not found`,
owlCode: 'INVALID_LOCATION_CODE' code: 'VALIDATION'
}); });
} }
const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep'; const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
@@ -35,12 +35,6 @@ export const load: PageLoad = async ({ url, fetch }) => {
boardData boardData
}; };
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof TypeError && e.message === 'Failed to fetch') {
error(503, {
message: 'Cannot connect to the OwlBoard server',
owlCode: 'NETWORK_DISCONNECTED'
});
}
throw e; throw e;
} }
}; };

View File

@@ -25,7 +25,10 @@
console.log(e); console.log(e);
errorState = { status: 20, message: e.message }; errorState = { status: 20, message: e.message };
} else { } else {
errorState = { status: 0, message: `Unknown Error: ${e instanceof Error ? e.message : String(e)}` }; errorState = {
status: 0,
message: `Unknown Error: ${e instanceof Error ? e.message : String(e)}`
};
} }
} finally { } finally {
resultsLoaded = true; resultsLoaded = true;
@@ -45,7 +48,10 @@
console.log(e); console.log(e);
errorState = { status: 20, message: e.message }; errorState = { status: 20, message: e.message };
} else { } else {
errorState = { status: 0, message: `Unknown Error: ${e instanceof Error ? e.message : String(e)}` }; errorState = {
status: 0,
message: `Unknown Error: ${e instanceof Error ? e.message : String(e)}`
};
} }
} finally { } finally {
resultsLoaded = true; resultsLoaded = true;

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/form-elements/Button.svelte';
import Toggle from '$lib/components/ui/form-elements/Toggle.svelte';
import { quickLinks } from '$lib/quick-links.svelte';
import { prefs } from '$lib/preferences.svelte';
</script>
<section class="page-body">
<BaseCard header={'Display Settings'}>
<section class="card-content">
<Button onclick={() => quickLinks.reset()}>Reset QuickLinks</Button>
<div class="toggle-container">
<Toggle
label={'Show stations near to you'}
id={'toggle-nearby-stations'}
checked={prefs.current.NearToMe}
onchange={(checked) => prefs.toggleNearToMe(checked)}
/>
<Toggle
label={'Show passsing trains on boards'}
id={'toggle-passing-trains'}
checked={prefs.current.ShowPassingTrains}
onchange={(checked) => prefs.toggleShowPassingTrains(checked)}
/>
<Toggle
label={'Keep screen awake on live pages'}
id={'toggle-board-wake-lock'}
checked={prefs.current.BoardWakeLock}
onchange={(checked) => prefs.toggleBoardWakeLock(checked)}
/>
<Toggle
label={'Auto-refresh data'}
id={'toggle-auto-refresh'}
checked={prefs.current.AutoRefresh}
onchange={(checked) => prefs.toggleAutoRefresh(checked)}
/>
</div>
</section>
</BaseCard>
</section>
<style>
.page-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 0 1rem 0;
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 5px 0 5px 0;
}
.toggle-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,5 @@
export const load = () => {
return {
title: 'Preferences'
};
};

View File

@@ -0,0 +1,218 @@
<script lang="ts">
</script>
<section class="policy-block-container">
<p class="policy-meta"><em>Last updated: May 2026</em></p>
<p class="policy-intro">
The OwlBoard project is built to help you track trains, not track you. Here is exactly what data
is used, how it is used, and why.
</p>
<hr class="policy-divider" />
<section class="policy-section">
<h3 class="section-heading">1. The Data Processed (And What Is Discarded)</h3>
<ul class="policy-list">
<li class="list-item">
<strong class="highlight-text">Email Addresses:</strong> During sign-up, an email address is
requested solely to send a verification code. This needs to happen, as some data is
restricted to rail staff only due to licensing restrictions.
<strong
>The moment the verification code is sent, the email address is permanently deleted
from the system.</strong
> It is not stored, it is not logged, and it cannot be looked up later.
</li>
<li class="list-item">
<strong class="highlight-text">Platform Identity:</strong> Instead of an email, an account
is identified by two things:
<ol class="policy-nested-list">
<li>
A unique alphanumeric <strong class="code-text">"key"</strong> generated randomly for the
device upon verification.
</li>
<li>The <strong class="code-text">domain portion</strong> of the registration email.</li>
</ol>
<blockquote class="policy-example">
<strong>Example:</strong> If signing up as <code>user@owlboard.info</code>, the email is
wiped, and the account identifier becomes <code>[Your-Secret-Key] @ owlboard.info</code>.
This allows OwlBoard to manage access without knowing who you are.
</blockquote>
</li>
<li class="list-item">
<strong class="highlight-text">Fuzzy Location Data:</strong> If the "Near to Me" toggle is
enabled, exact GPS coordinates are calculated, but never leave your device. Your device compresses the position
into a <strong class="highlight-text">"fuzzy location block"</strong> (a geohash) roughly 1.2km
by 0.6km in size before contacting the server to request nearby stations. OwlBoard only processes
that neighborhood block—never an exact location.
</li>
<li class="list-item">
<strong class="highlight-text">Server Logs &amp; IP Addresses:</strong> Like any web application,
self-hosted security tools and the Web Application Firewall (WAF) temporarily process network
IP addresses. This is strictly for system health, infrastructure monitoring, and blocking malicious or
automated attacks.
</li>
</ul>
</section>
<hr class="policy-divider" />
<section class="policy-section">
<h3 class="section-heading">2. What Is Considered "Personal Data"?</h3>
<p class="policy-text">
Under the GDPR, "Personal Data" is anything that can directly or indirectly pinpoint a living
individual.
</p>
<p class="policy-text">
Because email addresses are discarded instantly and location hardware telemetry is fuzzed
directly on the device, <strong
>station boards, preferences, and account keys are completely pseudonymized.</strong
> However, because network IP addresses are technically classified as personal data under European
law, transient security logs are treated with the highest level of guardrail protection.
</p>
</section>
<hr class="policy-divider" />
<section class="policy-section">
<h3 class="section-heading">
3. Subject Access Requests (SAR) &amp; The Right to be Forgotten
</h3>
<p class="policy-text">
Absolute sovereignty over your digital footprint should be standard, without jumping through
bureaucratic hoops. There is no need to send an email to exercise GDPR rights:
</p>
<ul class="policy-list">
<li class="list-item">
<strong class="highlight-text">Instant SAR (Right of Access):</strong> The
<strong>Account</strong> dashboard contains a dedicated section displaying
every single line of data which can be identified with the account key block. It can be viewed or copied instantly.
</li>
<li class="list-item">
<strong class="highlight-text">The Right to be Forgotten (Instant Erasure):</strong> A
prominent <strong>"Delete Account"</strong> button is available on that same page. Clicking this
instantly wipes the account key. You will be able to re-register at any time, and all of your preferences remain only on your device.
</li>
</ul>
<p class="policy-text">
While the pseudonymized settings left behind are not technically personal data once
disconnected from an identity, the OwlBoard project operates on a simple philosophy.
When the delete button is pressed, the data is erased.
</p>
</section>
<hr class="policy-divider" />
<section class="policy-section">
<h3 class="section-heading">4. No Third-Party Conglomerates</h3>
<p class="policy-text">
All data is hosted transparently on isolated, self-hosted server infrastructure in Somerset. OwlBoard does
not use third-party analytics trackers, tracking cookies, or advertising networks.
</p>
</section>
</section>
<style>
.policy-block-container {
margin: 1rem auto;
width: 90%;
max-width: 650px;
font-family: 'URW Gothic', sans-serif;
text-align: center;
display: flex;
flex-direction: column;
gap: 0;
}
.policy-block-container:last-child {
padding-bottom: 15px;
}
.policy-meta {
font-size: 0.875rem;
color: var(--color-text-muted, #64748b);
margin-bottom: 2rem;
}
.policy-intro {
font-size: 1.125rem;
line-height: 1.6;
margin-bottom: 2rem;
}
.policy-divider {
border: 0;
height: 1px;
background: var(--color-border, #e2e8f0);
margin: 2rem 0;
}
/* --- Section Blocks --- */
.policy-section {
margin-bottom: 2.5rem;
}
.section-heading {
font-size: 1.5rem;
font-weight: 600;
margin-top: 0;
margin-bottom: 1.25rem;
}
.policy-text {
line-height: 1.6;
margin-top: 0;
margin-bottom: 1rem;
}
/* --- Lists & Typography Elements --- */
.policy-list {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0 0 1.5rem 0;
}
.list-item {
line-height: 1.6;
margin-bottom: 1.25rem;
}
.list-item:last-child {
margin-bottom: 0;
}
.policy-nested-list {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.policy-nested-list li {
margin-bottom: 0.5rem;
}
.policy-example {
background-color: var(--color-bg-light);
border-left: 4px solid var(--color-accent);
margin: 1rem 0;
padding: 1rem;
font-style: normal;
font-size: 0.95rem;
line-height: 1.5;
border-radius: 0 4px 4px 0;
}
.code-text,
.policy-example code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem;
background-color: var(--color-accent);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
</style>

View File

@@ -0,0 +1,5 @@
export const load = () => {
return {
title: 'Privacy'
};
};

View File

@@ -1,7 +1,6 @@
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient'; import { OwlClient, ApiError, ValidationError, ThrowApiError } from '$lib/owlClient';
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts'; import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ fetch, url }) => { export const load: PageLoad = async ({ fetch, url }) => {
const headcode = url.searchParams.get('h'); const headcode = url.searchParams.get('h');
@@ -10,31 +9,21 @@ export const load: PageLoad = async ({ fetch, url }) => {
const date: string | Date = dateParam === '' || dateParam === null ? new Date() : dateParam; const date: string | Date = dateParam === '' || dateParam === null ? new Date() : dateParam;
if (!headcode) { // Validation removed, handled by the API Client Lib
throw error(400, {
message: 'Headcode not provided',
owlCode: 'INVALID_DATA'
});
}
// Declared outside of the try so that it can be used in both the try and catch blocks // Declared outside of the try so that it can be used in both the try and catch blocks
let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[]; let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[];
try { try {
const response = await OwlClient.trains.getByHeadcode(headcode, date, toc, fetch); const response = await OwlClient.trains.getByHeadcode(headcode || '', date, toc, fetch);
let title = !!headcode ? headcode.toUpperCase() : 'Error';
results = response.data; results = response.data;
return { return {
title: headcode.toUpperCase(), title: title,
results: results results: results
}; };
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof TypeError && e.message === 'Failed to fetch') { ThrowApiError(e);
error(503, {
message: 'Cannot connect to the OwlBoard server',
owlCode: 'NETWORK_DISCONNECTED'
});
}
throw e;
} }
}; };