Compare commits

..

8 Commits

17 changed files with 1507 additions and 638 deletions

1194
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,8 @@
"vitest-browser-svelte": "^2.0.2" "vitest-browser-svelte": "^2.0.2"
}, },
"dependencies": { "dependencies": {
"@owlboard/owlboard-ts": "^3.0.0-dev.20260503t0947", "@owlboard/api-schema-types": "^3.0.3-alpha18",
"@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

@@ -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;

View File

@@ -7,6 +7,7 @@
import TocStyle from '$lib/components/ui/TocStyle.svelte'; import TocStyle from '$lib/components/ui/TocStyle.svelte';
import TiplocConverter from '$lib/components/ui/TiplocConverter.svelte'; import TiplocConverter from '$lib/components/ui/TiplocConverter.svelte';
import { IconChevronDownFilled } from '@tabler/icons-svelte'; import { IconChevronDownFilled } from '@tabler/icons-svelte';
import { estClass } from '$lib/utils/time';
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props(); let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props();
let isExpanded = $state(false); let isExpanded = $state(false);
let loadingDetails = $state(false); let loadingDetails = $state(false);
@@ -26,7 +27,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 {
@@ -45,8 +46,6 @@
loadingDetails = false; loadingDetails = false;
} }
const estClass = (act, est) => (!act && est ? 'est' : 'act');
const activityMap: Record<string, string> = { const activityMap: Record<string, string> = {
'-D': 'Vehicles detatched', '-D': 'Vehicles detatched',
'-T': 'Vehicles attached & detached', '-T': 'Vehicles attached & detached',

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

@@ -0,0 +1,418 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
const getRowKey = (s: ApiStationsBoard.BoardService) =>
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script>
<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
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">
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r}
<tr class="cancel-row">
<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">
<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.20rem;
}
}
.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: 200;
}
.act {
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,143 @@
<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: 0;
border-radius: 8px;
background: transparent;
position: relative;
z-index: 10;
margin-bottom: 0;
width: 95%;
max-width: 750px;
-webkit-tap-highlight-color: transparent;
}
.alert-card:active {
filter: brightness(1.2);
}
.trigger {
width: 100%;
display: flex;
border-radius: 8px;
align-items: center;
padding: 0.1rem 1.05rem;
background: var(--alert-orange);
color: white;
border: none;
cursor: pointer;
text-align: left;
position: relative;
z-index: 2;
height: 40px;
transition: all 0.65s 0.2s;
}
.trigger.active {
border-radius: 8px 8px 0 0;
transition: all 0.01s 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: 40px;
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: 1rem;
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

@@ -129,7 +129,41 @@
font-style: normal; font-style: normal;
font-display: swap; 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 { :root {
/* Fixed Heights */
--header-height: 80px;
--nav-height: 60px;
/* Brand Colours */ /* Brand Colours */
--color-brand: #4fd1d1; --color-brand: #4fd1d1;
--color-accent: #3c6f79; --color-accent: #3c6f79;
@@ -147,6 +181,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;
} }
@@ -158,7 +193,7 @@
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.3; opacity: 0.5;
} }
} }
@@ -170,7 +205,7 @@
} }
25%, 25%,
75% { 75% {
opacity: 0; opacity: 0.25;
} }
} }

View File

@@ -1,36 +1,96 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts'; import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
export const estClass = (act: any, est: any) => (act ? 'act' : 'est');
/** /**
* 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(
if (!dateStr) return '--:--'; dateStr: string | Date | undefined,
includeSeconds: boolean = false
): string {
if (!dateStr) return '-';
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
if (isNaN(date.getTime())) return '--:--'; if (isNaN(date.getTime())) return '-';
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(',', '');
}
/**
* Specific type that can handle the TrainDetails.ServiceLocation and the ApiStationsBoard.BoardService types for delay calculation
*/
interface DelayInput {
// Board types
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;
etp?: string | null;
}
/** /**
* Calculates a 'delay' value, in the formats: * Calculates a 'delay' value, in the formats:
* RT, 1E, 7L, etc. * RT, 1E, 7L, etc.
* @param 'Schedule Location' object * @param 'Schedule Location' or 'Board Service' object
* @returns Delay string for departure boards * @returns Delay string for departure boards
*/ */
export function calculateDelay(loc: ApiTrainsTrainDetails.ServiceLocation): { export function calculateDelay(loc: DelayInput): {
val: string; val: string;
type: string; type: string;
} { } {
const pairs = [ const pairs = [
{ actual: loc.atd, sched: loc.ptd ?? loc.wtd }, // Departure check (Board: atd/etd vs std | Journey: atd vs ptd/wtd)
{ actual: loc.ata, sched: loc.pta ?? loc.wta }, { actual: loc.atd || loc.etd, sched: loc.std || loc.ptd || loc.wtd },
{ actual: loc.atp, sched: loc.wtp } // Arrival check (Board: ata/eta vs sta | Journey: ata vs pta/wta)
{ actual: loc.ata || loc.eta, sched: loc.sta || loc.pta || loc.wta },
// Passing check
{ actual: loc.atp || loc.etp, sched: loc.wtp }
]; ];
const match = pairs.find((p) => p.actual && p.sched); const match = pairs.find((p) => p.actual && p.sched);
@@ -48,3 +108,27 @@ export function calculateDelay(loc: ApiTrainsTrainDetails.ServiceLocation): {
return { val: `${absDiff}E`, type: 'early' }; return { val: `${absDiff}E`, type: 'early' };
} }
} }
/**
* Accepts a pair of times (string or Date) and outputs a delay class 'delay-early', 'delay-late' or 'delay-rt'
* @param sched Scheduled Time (string, Date)
* @param act Actual Time (string, Date)
*/
export function delayClassFromTimePair(sched: any, act: any): string {
if (!sched || !act) return '';
const s = new Date(sched).getTime();
const a = new Date(act).getTime();
if (isNaN(s) || isNaN(a)) return '';
const diff = a - s;
const oneMinute = 60000;
// on-time if within one minute
if (Math.abs(diff) < oneMinute) {
return 'delay-rt';
}
return diff > 0 ? 'delay-late' : 'delay-early';
}

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

@@ -147,14 +147,14 @@
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1> <h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
<p> <p>
Just as trains need the right track width, our data needs a bit more room to stay on the rails. 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> </p>
</div> </div>
<style> <style>
header { header {
top: 0; top: 0;
height: 80px; height: var(--header-height);
box-shadow: var(--shadow-std); box-shadow: var(--shadow-std);
} }
.logo-link { .logo-link {
@@ -201,13 +201,12 @@
min-height: 100dvh; min-height: 100dvh;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--color-bg-dark); background-color: var(--color-bg-dark);
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
} }
nav { nav {
display: flex; display: flex;
bottom: 0; bottom: 0;
height: 60px; height: var(--nav-height);
box-shadow: var(--shadow-up); box-shadow: var(--shadow-up);
} }
@@ -320,7 +319,7 @@
display: none; display: none;
} }
@media (max-width: 299px) { @media (max-width: 319px) {
.viewport-guard { .viewport-guard {
display: block; display: block;
position: fixed; position: fixed;

View File

@@ -1,8 +1,20 @@
<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/station-board/StationAlertCard.svelte';
import { formatUkDateTime, formatUkTime } from '$lib/utils/time';
import StaffServicesTable from '$lib/components/ui/station-board/StaffServicesTable.svelte';
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,18 +29,69 @@
} }
} }
}); });
// Wake Lock API Handling
// Load Data Invalidation Handling
// Refresh countdown logic
</script> </script>
<section>Live board are not yet implemented on the server</section> <section class="board-wrapper">
<div class="time-data">
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span>
<span class="time-now">{formatUkTime(now, true)}</span>
</div>
{#if data.boardData.data.m?.length}
<StationAlertCard messages={data.boardData.data.m} />
{/if}
{#if data.boardData.data.s?.length}
<div class="service-list-wrapper">
<StaffServicesTable services={data.boardData.data.s} />
</div>
{/if}
</section>
<style> <style>
section { .board-wrapper {
font-family: 'URW Gothic', sans-serif; display: flex;
text-align: center; height: calc(100dvh - var(--nav-height) - var(--header-height));
font-size: 2rem; flex-direction: column;
align-items: center;
width: 100%;
margin: 0 auto;
overflow: hidden;
gap: 0;
}
.time-data {
display: flex;
justify-content: space-between;
align-items: center;
width: 90%; width: 90%;
margin: auto; max-width: 700px;
padding-top: 25px; margin: 10px auto;
max-width: 500px; 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;
}
.service-list-wrapper {
flex: 1;
overflow-y: auto;
height: calc(100dvh - 60px);
width: 97%;
max-width: 700px;
margin-bottom: 5px;
} }
</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 {

Binary file not shown.