Compare commits

..

3 Commits

15 changed files with 1064 additions and 672 deletions

1193
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,7 @@
"vitest-browser-svelte": "^2.0.2" "vitest-browser-svelte": "^2.0.2"
}, },
"dependencies": { "dependencies": {
"@owlboard/owlboard-ts": "^3.0.0-dev.20260503t0947", "@owlboard/owlboard-ts": "^3.0.0-dev.20260509t2101",
"@tabler/icons-svelte": "^3.40.0" "@tabler/icons-svelte": "^3.40.0"
} }
} }

View File

@@ -1,12 +1,36 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
let { message = 'Loading...' } = $props(); let { message = 'Loading...' } = $props();
let messageIndex = $state(0);
onMount(() => {
const interval = setInterval(() => {
if (messageIndex === 0) {
messageIndex = 1;
} else {
messageIndex = messageIndex === 1 ? 2 : 1;
}
}, 1500);
return () => clearInterval(interval);
});
</script> </script>
<div class="loading-state"> <div class="loading-state">
<div class="track"> <div class="track">
<div class="shuttle"></div> <div class="shuttle"></div>
</div> </div>
<p>{message}</p> <div class="message-container">
{#if messageIndex === 0}
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>{message}</p>
{:else if messageIndex === 1}
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>Slow connection...</p>
{:else}
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>Still trying...</p>
{/if}
</div>
</div> </div>
<style> <style>
@@ -38,11 +62,18 @@
animation: data-travel 1.6s infinite ease-in-out; animation: data-travel 1.6s infinite ease-in-out;
} }
.message-container {
display: grid;
place-items: center;
height: 2rem;
margin-top: 1rem;
}
p { p {
grid-area: 1 / 1;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.15em; letter-spacing: 0.15em;
color: var(--color-title); color: var(--color-title);
animation: pulse 2s infinite ease-in-out;
} }
@keyframes data-travel { @keyframes data-travel {
@@ -53,14 +84,4 @@
left: 100%; left: 100%;
} }
} }
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
</style> </style>

View File

@@ -33,7 +33,7 @@
if (onsubmit && value) { if (onsubmit && value) {
onsubmit(value); onsubmit(value);
} }
} };
</script> </script>
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}> <div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
@@ -97,6 +97,17 @@
color: var(--color-title); color: var(--color-title);
} }
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
input { input {
width: 100%; width: 100%;
background: transparent; background: transparent;
@@ -125,7 +136,9 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0.75; opacity: 0.75;
transition: opacity 0.2s, transform 0.2s; transition:
opacity 0.2s,
transform 0.2s;
} }
.submit-icon:hover:not(:disabled) { .submit-icon:hover:not(:disabled) {

View File

@@ -26,7 +26,7 @@
details = result.data; details = result.data;
isExpanded = true; isExpanded = true;
} catch (e) { } catch (e) {
console.Error('Failed to load train details'); console.error('Failed to load train details');
loadingDetailsError = true; loadingDetailsError = true;
loadingDetailsErrorMsg = e.message; loadingDetailsErrorMsg = e.message;
} finally { } finally {
@@ -93,7 +93,7 @@
}; };
</script> </script>
<div class="train-service" class:isExpanded={isExpanded}> <div class="train-service" class:isExpanded>
<button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}> <button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}>
{#if loadingDetails} {#if loadingDetails}
<div class="loading-state"><div class="loading-spinner"></div></div> <div class="loading-state"><div class="loading-spinner"></div></div>
@@ -147,7 +147,15 @@
{/if} {/if}
{#if details.pis} {#if details.pis}
<div class="pis-detail"> <div class="pis-detail">
{details.pis.code} <span class="pis-code">PIS: {details.pis.code}</span>
{#if details.pis.skip?.skip > 0}
<span class="pis-skip">
(skip {details.pis.skip.position === 'head' ? 'first' : 'last'}
{details.pis.skip.skip}
{details.pis.skip.skip === 1 ? 'stop' : 'stops'}
)
</span>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -174,7 +182,6 @@
</thead> </thead>
{#each details.locations as loc} {#each details.locations as loc}
<tbody class="location-group"> <tbody class="location-group">
<tr class:pass-loc={loc.r === 'PASS'} class:can-loc={loc.can}> <tr class:pass-loc={loc.r === 'PASS'} class:can-loc={loc.can}>
<td class="tpl-cell" class:tpl-stop={loc.r != 'PASS'}> <td class="tpl-cell" class:tpl-stop={loc.r != 'PASS'}>
{loc.t} {loc.t}
@@ -212,7 +219,6 @@
</td> </td>
</tr> </tr>
{/if} {/if}
</tbody>{/each} </tbody>{/each}
</table> </table>
</div> </div>
@@ -318,6 +324,29 @@
color: red; color: red;
} }
/*
PIS Data
*/
.pis-detail {
display: flex;
align-items: center;
flex-direction: column;
gap: 0.2rem;
padding: 8px 0;
width: 100%;
}
.pis-code {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: 0.05em;
}
.pis-skip {
font-size: 0.85rem;
opacity: 0.75;
font-style: oblique;
}
/* /*
Box Extention Box Extention
*/ */

View File

@@ -18,7 +18,12 @@
<BaseCard header={'Search Train & PIS'}> <BaseCard header={'Search Train & PIS'}>
<div class="card-content"> <div class="card-content">
<Textbox placeholder="Enter Headcode" bind:value={headcode} maxLength={4} onsubmit={handleSearch} /> <Textbox
placeholder="Enter Headcode"
bind:value={headcode}
maxLength={4}
onsubmit={handleSearch}
/>
</div> </div>
</BaseCard> </BaseCard>

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { IconAlertOctagonFilled, IconChevronDownFilled } from '@tabler/icons-svelte';
let { messages = [] }: { messages: ApiStationsBoard.BoardMsgs[] } = $props();
let isOpen = $state(false);
</script>
{#if messages.length > 0}
<aside class="alert-card">
<button onclick={() => (isOpen = !isOpen)} class="trigger" class:active={isOpen}>
<span class="warning-icon">
<IconAlertOctagonFilled />
</span>
<span class="summary">
{messages.length}
{messages.length === 1 ? 'active alert' : 'active alerts'}
</span>
<span class="chevron" class:rotated={isOpen}><IconChevronDownFilled /></span>
</button>
{#if isOpen}
<div transition:slide={{ duration: 450 }} class="content">
{#each messages as msg}
<div class="message-item">
<p class="message-text">
{msg.t}
{#if msg.l && msg.lt}
<a href={msg.l} target="_blank" rel="noopener noreferrer" class="alert-link">
{msg.lt}
</a>
{/if}
</p>
</div>
{/each}
</div>
{/if}
</aside>
{/if}
<style>
.alert-card {
margin: 1rem 0;
border-radius: 8px;
background: transparent;
position: relative;
z-index: 10;
width: 95%;
max-width: 750px;
}
.trigger {
width: 100%;
display: flex;
border-radius: 8px;
align-items: center;
padding: 0.4rem 1rem;
background: var(--alert-orange);
color: white;
border: none;
cursor: pointer;
text-align: left;
position: relative;
z-index: 2;
height: 46px;
transition: all 0.65s 0.2s;
}
.trigger.active {
border-radius: 8px 8px 0 0;
transition: all 0.1s 0s;
}
.warning-icon {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0;
margin-top: 0;
margin-bottom: 0;
margin-right: 1rem;
padding: 0;
}
.summary {
flex: 1;
font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.05em;
font-weight: 600;
font-size: 1rem;
}
.chevron {
transition: transform 0.5s ease;
display: inline-flex;
justify-content: center;
}
.chevron.rotated {
transform: rotateX(180deg);
}
.content {
padding: 1rem;
position: absolute;
top: 46px;
border-radius: 0 0 8px 8px;
left: 0;
right: 0;
background: var(--alert-orange);
filter: brightness(1.2);
color: var(--color-title);
font-family: 'URW Gothic', sans-serif;
font-size: 1.0rem;
font-weight: 600;
line-height: 1.2;
z-index: 1;
}
.message-item {
margin: 10px;
padding-bottom: 0.75rem;
}
.message-item p {
margin: 0;
}
.message-item a {
color: #f2ff00;
}
.message-item:last-child {
padding-bottom: 0;
}
</style>

View File

@@ -4,12 +4,6 @@
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (c: string) => void } = $props(); let { onsearch }: { onsearch: (c: string) => void } = $props();
let codeValue = $state('');
function resetValues(): void {
codeValue = '';
}
</script> </script>
<BaseCard header={'Find by Code'}> <BaseCard header={'Find by Code'}>
@@ -19,16 +13,13 @@
<Textbox <Textbox
placeholder={'Code'} placeholder={'Code'}
uppercase={true} uppercase={true}
type={'number'} type={'text'}
max={9999} onsubmit={onsearch}
bind:value={codeValue} maxLength={4}
inputmode={'numeric'}
/> />
</div> </div>
</div> </div>
<div class="button-wrapper">
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
</div> </div>
</BaseCard> </BaseCard>
@@ -48,7 +39,7 @@
} }
.textbox-item-wrapper { .textbox-item-wrapper {
width: 30%; width: 60%;
} }
.button-wrapper { .button-wrapper {

View File

@@ -43,11 +43,11 @@
display: flex; display: flex;
width: 100%; width: 100%;
justify-content: center; justify-content: center;
gap: 4rem; gap: 1.1rem;
} }
.textbox-item-wrapper { .textbox-item-wrapper {
width: 30%; width: 50%;
} }
.button-wrapper { .button-wrapper {

View File

@@ -147,6 +147,7 @@
/* Functional Colours */ /* Functional Colours */
--location-yellow: #edff22; --location-yellow: #edff22;
--delay-orange: #ff914d; --delay-orange: #ff914d;
--alert-orange: #f87728;
--cancel-red: #c60000; --cancel-red: #c60000;
--early-blue: #5ec1ff; --early-blue: #5ec1ff;
} }

View File

@@ -1,9 +1,9 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts'; import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
/** /**
* Converts ISO/JSON time to UK-formatted HH:MM string. * Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds
* Ensures Europe/London timezone irrespective of browser timezone. * Ensures Europe/London timezone irrespective of browser timezone.
*/ */
export function formatUkTime(dateStr: string | Date | undefined): string { 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; const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
@@ -12,11 +12,35 @@ export function formatUkTime(dateStr: string | Date | undefined): string {
return date.toLocaleTimeString('en-GB', { return date.toLocaleTimeString('en-GB', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
...(includeSeconds && { second: '2-digit' }),
hour12: false, hour12: false,
timeZone: 'Europe/London' timeZone: 'Europe/London'
}); });
} }
/**
* Converts ISO/JSON time to UK-formatted DD/MM/YY HH:MM:SS string.
* Ensures Europe/London timezone irrespective of browser timezone.
*/
export function formatUkDateTime(dateStr: string | Date | undefined, includeSeconds: boolean = false): string {
if (!dateStr) return '--/--/-- --:--:--';
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
if (isNaN(date.getTime())) return '--/--/-- --:--:--';
return date.toLocaleString('en-GB', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
...(includeSeconds && { second: '2-digit' }),
hour12: false,
timeZone: 'Europe/London'
}).replace(',', '');
}
/** /**
* Calculates a 'delay' value, in the formats: * Calculates a 'delay' value, in the formats:
* RT, 1E, 7L, etc. * RT, 1E, 7L, etc.

View File

@@ -10,6 +10,9 @@
{#if page.status == 404} {#if page.status == 404}
<!-- Warning no data image --> <!-- Warning no data image -->
<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 == 499}
<!-- Change to a GSM-R X Sign?? -->
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{: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" />
@@ -26,7 +29,11 @@
</div> </div>
{/if} {/if}
{#if $page.error?.owlCode === 'NETWORK_DISCONNECTED'}
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button>
{:else}
<Button href={'/'} color={'accent'}>Return to Home</Button> <Button href={'/'} color={'accent'}>Return to Home</Button>
{/if}
</div> </div>
<style> <style>

View File

@@ -1,8 +1,19 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte'; import { quickLinks } from '$lib/quick-links.svelte';
import StationAlertCard from '$lib/components/ui/cards/StationAlertCard.svelte';
import { formatUkDateTime, formatUkTime } from '$lib/utils/time';
let { data } = $props(); let { data } = $props();
let now = $state(new Date());
onMount(() => {
const interval = setInterval(() => {
now = new Date();
}, 1000);
return () => clearInterval(interval);
})
// Update 'QuickLinks' // Update 'QuickLinks'
$effect(() => { $effect(() => {
@@ -17,12 +28,60 @@
} }
} }
}); });
</script>
<section>Live board are not yet implemented on the server</section> // Wake Lock API Handling
// Load Data Invalidation Handling
// Refresh countdown logic
</script>
<section class="board-wrapper">
{#if data.boardData.data.m?.length}
<StationAlertCard messages={data.boardData.data.m} />
<div class="time-data">
<span class="time-loaded">Fetched: {formatUkDateTime(data.boardData.producedAt, true)}</span>
<span class="time-now">{formatUkTime(now, true)}</span>
</div>
{/if}
</section>
<section class="section-t">Live boards are not yet fully implemented on the server</section>
<pre class="json-dump">{JSON.stringify(data.boardData.data.s, null, 2)}</pre>
<style> <style>
section { .board-wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin: 0 auto;
gap: 0;
}
.time-data {
display: flex;
justify-content: space-between;
align-items: center;
width: 90%;
max-width: 700px;
margin: 0 auto;
font-family: 'URW Gothic', sans-serif;
}
.time-loaded, .time-now {
font-variant-numeric: tabular-nums;
}
.time-loaded {
font-size: 0.9rem;
}
.time-now {
font-weight: 600;
font-size: 1rem;
}
.section-t {
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
text-align: center; text-align: center;
font-size: 2rem; font-size: 2rem;
@@ -31,4 +90,13 @@
padding-top: 25px; padding-top: 25px;
max-width: 500px; max-width: 500px;
} }
.json-dump {
background: #222;
color: #0f0;
padding: 1rem;
border-radius: 4px;
font-size: 0.9rem;
width: 95%;
margin: 1rem auto; }
</style> </style>

View File

@@ -1,16 +1,14 @@
import { LOCATIONS } from '$lib/locations-object.svelte'; import { LOCATIONS } from '$lib/locations-object.svelte';
import { ApiError, OwlClient, ValidationError } from '$lib/owlClient';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ url }) => { export const load: PageLoad = async ({ url, fetch }) => {
const locId = url.searchParams.get('loc'); const locId = url.searchParams.get('loc');
if (!LOCATIONS.loaded) { if (!LOCATIONS.loaded) {
await LOCATIONS.init(); await LOCATIONS.init();
} }
let title: string = '';
if (!locId) { if (!locId) {
error(400, { error(400, {
message: 'Location not provided', message: 'Location not provided',
@@ -18,22 +16,49 @@ export const load: PageLoad = async ({ url }) => {
}); });
} }
let BoardLocation; const BoardLocation = LOCATIONS.find(locId);
if (locId) { if (!BoardLocation) {
BoardLocation = LOCATIONS.find(locId);
if (BoardLocation) {
title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
} else {
error(404, { error(404, {
message: `Location (${locId.toUpperCase()}) not found`, message: `Location (${locId.toUpperCase()}) not found`,
owlCode: 'INVALID_LOCATION_CODE' owlCode: 'INVALID_LOCATION_CODE'
}); });
} }
} const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
try {
const boardData = await OwlClient.board.getByLocation(locId, fetch);
return { return {
title, title,
BoardLocation BoardLocation,
boardData
}; };
} catch (e: unknown) {
if (
e instanceof TypeError &&
(e.message == 'Failed to fetch' || e.message.includes('network'))
) {
throw error(503, {
message: 'Network error: Please check your connection',
owlCode: 'NETWORK_DISCONNECTED'
});
}
if (e instanceof ValidationError) {
throw error(400, { message: e.message, owlCode: 'VALIDATION_ERROR' });
} else if (e instanceof ApiError) {
// If the API returns 404, it means the backend doesn't recognize this CRS/TIPLOC
if (e.code === 'NOT_FOUND') {
throw error(404, {
message: `Location (${locId.toUpperCase()}) is not recognized by the server.`,
owlCode: 'LOCATION_NOT_IN_BACKEND'
});
}
throw error(e.status, { message: e.message, owlCode: 'API_ERROR' });
} else if (e instanceof Error) {
throw error(500, { message: e.message, owlCode: 'GEN_ERROR' });
}
throw error(500, { message: 'Unexpected error', owlCode: 'UNKNOWN_ERR' });
}
}; };

View File

@@ -3,7 +3,7 @@ import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ url }) => { export const load: PageLoad = async ({ fetch, url }) => {
const headcode = url.searchParams.get('h'); const headcode = url.searchParams.get('h');
let dateParam = url.searchParams.get('d'); let dateParam = url.searchParams.get('d');
const toc = url.searchParams.get('t') || ''; const toc = url.searchParams.get('t') || '';
@@ -21,7 +21,7 @@ export const load: PageLoad = async ({ url }) => {
let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[]; let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[];
try { try {
const response = await OwlClient.trains.getByHeadcode(headcode, date, toc); const response = await OwlClient.trains.getByHeadcode(headcode, date, toc, fetch);
results = response.data; results = response.data;
return { return {