Compare commits

...

3 Commits

8 changed files with 595 additions and 471 deletions

View File

@@ -0,0 +1,268 @@
<script lang="ts">
import { ApiPisObject, ApiTrainsTrainDetails } from '@owlboard/api-schema-types';
import { formatTrainStatusMessage } from '$lib/utils/delayCancelMsg';
import { formatUkTime, estClass, calculateDelay } from '$lib/utils/time';
interface props {
trainDetail: ApiTrainsTrainDetails.TrainDetailsResponse;
}
let { trainDetail }: props = $props();
let pisData = $derived(
trainDetail.pis ? (trainDetail.pis as unknown as ApiPisObject.PisObjects) : undefined
);
const activityMap: Record<string, string> = {
'-D': 'Vehicles detatched',
'-T': 'Vehicles attached & detached',
'-U': 'Vehicles attached',
AE: 'Assist locomotive attached',
C: 'Traincrew change only',
D: 'Set down only',
E: 'Stops for examination',
K: 'Passenger count point',
KC: 'Ticket collection point',
KE: 'Ticket examination point',
KF: 'First class ticket examination point',
KS: 'Selective ticket examination point',
L: 'Locomotive changed',
N: 'Unadvertised stop',
OP: 'Operational stop only',
OR: 'Locomotive attached on rear',
R: 'Request stop',
RM: 'Train changes direction',
RR: 'Locomotive run round',
S: 'Staff only stop',
TW: 'Stops for token/staff/tablet',
U: 'Pick up only',
W: 'Watering coaches',
X: 'Passes another train'
};
const activityRegex = new RegExp(
Object.keys(activityMap)
.sort((a, b) => b.length - a.length) // Longest codes first
.map((key) => key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')) // Escape special chars
.join('|'),
'g'
);
const getRelevantActivities = (act: string) => {
if (!act) return [];
// Find all matches in the string
const matches = act.match(activityRegex) || [];
// Map to labels and remove duplicates
return [...new Set(matches)].map((code) => activityMap[code]);
};
</script>
<div class="trainServiceContainer">
{#if trainDetail.header.cr}
<span class="cancelReason"
>{formatTrainStatusMessage(
trainDetail.header.cr,
trainDetail.header.cl,
trainDetail.header.cn
)}</span
>
{/if}
{#if trainDetail.header.dr}
<span class="delayReason"
>{formatTrainStatusMessage(
trainDetail.header.dr,
trainDetail.header.dl,
trainDetail.header.dn
)}</span
>
{/if}
{#if pisData}
<div class="pisData">
<span class="pisCode">PIS: {pisData.code}</span>
{#if pisData.skip?.skip}
<span class="pis-skip">
(skip {pisData.skip.position === 'head' ? 'first' : 'last'}
{pisData.skip.skip}
{pisData.skip.skip === 1 ? 'stop' : 'stops'}
)
</span>
{/if}
</div>
{/if}
<div class="tableContainer">
<table class="table">
<thead>
<tr>
<th colspan="2"></th>
<th colspan="2">Arr</th>
<th colspan="2">Dep</th>
</tr>
<tr>
<th class="tiploc-col">Location</th>
<th>Plat</th>
<th>Sch</th>
<th>Act</th>
<th>Sch</th>
<th>Act</th>
<th></th>
</tr>
</thead>
{#each trainDetail.locations as loc}
<tbody class="LocGroup">
<tr class:pass={loc.r === 'PASS'} class:canc={loc.can}>
<td class="tiploc-col tpl" class:stop={loc.r !== 'PASS'}>
{loc.t}
</td>
<td class="plat" class:platchange={loc.pc}>
{loc.p}
</td>
{#if loc.r === 'PASS'}
<td colspan="2">Pass</td>
<td class="actual">{formatUkTime(loc.wtp)}</td>
<td class={estClass(loc.atp, loc.etp)}>{formatUkTime(loc.atp || loc.etp || '--')}</td>
{:else}
<td class="actual">{formatUkTime(loc.pta || loc.wta)}</td>
<td class={estClass(loc.ata, loc.eta)}>{formatUkTime(loc.ata || loc.eta || '--')}</td>
<td class="actual">{formatUkTime(loc.ptd || loc.wtd)}</td>
<td class={estClass(loc.atd, loc.etd)}>{formatUkTime(loc.atd || loc.etd || '--')}</td>
{/if}
{#if loc}
{@const delay = calculateDelay(loc)}
{@const arrType = estClass(loc.ata, loc.eta)}
{@const depType = estClass(loc.atd, loc.etd)}
{@const passType = estClass(loc.atp, loc.etp)}
{@const classType = depType || arrType || passType}
<td class="delay-{delay.type} {classType}">{delay.val}</td>
{/if}
</tr>
{#if loc.act && getRelevantActivities(loc.act).length > 0}
<tr class="activity-row">
<td colspan="7">
<div class="activity-container">
{#each getRelevantActivities(loc.act) as note}
<span class="activity-tag">{note}</span>
{/each}
</div>
</td>
</tr>
{/if}
</tbody>
{/each}
</table>
</div>
</div>
<style>
.trainServiceContainer {
width: 100%;
margin: 0 auto;
display: flex;
justify-items: center;
flex-direction: column;
}
.cancelReason {
color: var(--cancel-red);
text-align: center;
}
.delayReason {
color: var(--delay-orange);
text-align: center;
}
.pisData {
margin: auto;
text-align: center;
font-family: 'URW Gothic', sans-serif;
font-weight: 600;
font-size: 1.25rem;
}
.pis-skip {
font-weight: 200;
font-size: 1rem;
}
.tableContainer {
width: 100%;
margin: 0 auto;
padding-top: 10px;
}
.table {
width: 97%;
margin: 0 auto;
}
tr th {
font-family: 'URW Gothic', sans-serif;
text-align: center;
}
tr td {
font-family: 'Inconsolata Variable', monospace;
font-size: 1.171rem;
font-variant-ligatures: additional-ligatures;
text-align: center;
}
tr.pass td {
opacity: 0.5;
}
tr.canc {
text-decoration: line-through;
color: var(--cancel-red);
}
tr.canc .tpl {
text-decoration: none;
color: var(--cancel-red);
}
.tiploc-col {
text-align: left;
}
.tpl {
color: var(--location-yellow);
}
tr.pass td.pass-text {
font-style: italic;
}
tr td.estimate {
color: rgb(196, 189, 189);
font-style: italic;
font-weight: 350;
}
tr td.actual {
font-weight: 700;
}
tr td.delay-late {
color: var(--delay-orange);
}
tr td.delay-early {
color: var(--early-blue);
}
.activity-container {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-bottom: 5px;
}
.activity-tag {
background: rgb(0 0 0 / 15%);
font-family: 'URW Gothic', sans-serif;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
font-size: 1rem;
}
</style>

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { IconX } from '@tabler/icons-svelte-runes';
import { OwlClient } from '$lib/owlClient';
import { ApiTrainsTrainDetails } from '@owlboard/api-schema-types';
import TrainServiceTable from '../TrainServiceTable.svelte';
interface Props {
rid: string;
isOpen: boolean;
onClose: () => void;
}
let { rid, isOpen, onClose }: Props = $props();
let dialogElement = $state<HTMLDialogElement | null>(null);
let trainDetail = $state<ApiTrainsTrainDetails.TrainDetailsResponse | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
$effect(() => {
if (isOpen && rid) {
loadTrainDetails(rid);
}
});
$effect(() => {
if (!dialogElement) return;
if (isOpen && !dialogElement.open) {
dialogElement.showModal();
loadTrainDetails(rid);
} else if (!isOpen && dialogElement.open) {
dialogElement.close();
}
});
async function loadTrainDetails(rid: string) {
loading = true;
error = null;
try {
const result = await OwlClient.trains.getByRid(rid);
trainDetail = result.data;
} catch (err: any) {
error = err.message || 'An error occurred fetching train details.';
} finally {
loading = false;
}
}
</script>
<dialog
bind:this={dialogElement}
onclose={onClose}
onclick={(e) => {
// Optional: close when clicking the native backdrop area outside the modal box
const rect = dialogElement?.getBoundingClientRect();
if (
rect &&
(e.clientX < rect.left ||
e.clientX > rect.right ||
e.clientY < rect.top ||
e.clientY > rect.bottom)
) {
onClose();
}
}}
>
<div class="modal-content">
<div class="modal-header">
<h1>
{#if loading}
Loading Train Detail
{:else if error}
Error
{:else if trainDetail}
{trainDetail.header.h}
{/if}
</h1>
<button class="close-btn" onclick={onClose} aria-label="Close modal">
<IconX size={20} />
</button>
</div>
<div class="trainServiceModalWrapper">
{#if loading}
<div class="loading-state">Loading train details...</div>
{:else if error}
<div class="error-state">Error: {error}</div>
{:else if trainDetail}
<div class="scrollable-table-container">
<TrainServiceTable {trainDetail} />
</div>
{/if}
</div>
</div>
</dialog>
<style>
dialog {
background: var(--color-accent);
color: var(--color-title);
border: none;
border-radius: 12px;
width: 90%;
max-width: 400px;
padding: 0 1.5rem 1.5rem 1.5rem;
box-shadow: var(--shadow-std);
opacity: 0;
transform: scale(0.8);
transition:
opacity 0.3s ease,
transform 0.5s ease,
overlay 0.5s ease allow-discrete,
display 0.5s ease allow-discrete;
}
dialog[open] {
opacity: 1;
transform: scale(1);
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.8);
}
}
dialog::backdrop {
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
transition:
background 1.15s ease,
backdrop-filter 1.15s ease,
overlay 1.15s ease allow-discrete,
display 1.15s ease allow-discrete;
}
dialog[open]::backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
}
@starting-style {
dialog[open]::backdrop {
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
}
}
.modal-content {
position: relative;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
font-family: 'URW Gothic', sans-serif;
margin: 0;
padding: 0;
}
.close-btn {
background: red;
border: none;
color: inherit;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.close-btn:hover {
background: rgb(141, 0, 0);
}
.trainServiceModalWrapper {
height: 75vh;
display: flex;
flex-direction: column;
overflow: hidden;
margin-top: 0;
}
.scrollable-table-container {
margin-top: 0px;
width: 100%;
overflow-y: auto;
}
.loading-state,
.error-state {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
width: 100%;
font-weight: 500;
}
.error-state {
color: var(--error-color, #ff6b6b);
}
</style>

View File

@@ -4,12 +4,26 @@
import { fade, slide, fly } from 'svelte/transition'; import { fade, slide, fly } from 'svelte/transition';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import ServiceDetailModal from './ServiceDetailModal.svelte';
let isModalOpen = $state(false);
let selectedRid = $state('');
function handleOpenTrainDetails(rid: string) {
selectedRid = rid;
isModalOpen = true;
}
function handleCloseModal() {
isModalOpen = false;
}
let { services }: { services: ApiStationsBoard.BoardService[] } = $props(); let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
const getRowKey = (s: ApiStationsBoard.BoardService) => const getRowKey = (s: ApiStationsBoard.BoardService) =>
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`; `${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script> </script>
<!-- Train Detail Modal -->
<ServiceDetailModal rid={selectedRid} isOpen={isModalOpen} onClose={handleCloseModal} />
<section class="departure-board"> <section class="departure-board">
<div class="header container"> <div class="header container">
<div class="header row"> <div class="header row">
@@ -45,14 +59,38 @@
class:cancelled={service.c} class:cancelled={service.c}
> >
<div class="cell id">{service.h}</div> <div class="cell id">{service.h}</div>
<div class="cell orig"> <div
class="cell orig"
role="button"
tabindex="0"
aria-label="View details for train {service.r}"
onclick={() => handleOpenTrainDetails(service.r)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenTrainDetails(service.r);
}
}}
>
{#key service.og.t} {#key service.og.t}
<div transition:fade={{ duration: 300 }}> <div transition:fade={{ duration: 300 }}>
{service.og.t} {service.og.t}
</div> </div>
{/key} {/key}
</div> </div>
<div class="cell dest"> <div
class="cell dest"
role="button"
tabindex="0"
aria-label="View details for train {service.r}"
onclick={() => handleOpenTrainDetails(service.r)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenTrainDetails(service.r);
}
}}
>
{#key service.dt.t}<div transition:fade={{ duration: 300 }}>{service.dt.t}</div>{/key} {#key service.dt.t}<div transition:fade={{ duration: 300 }}>{service.dt.t}</div>{/key}
</div> </div>
<div class="cell plt" class:platsup={service.ps} class:platchange={service.pc}> <div class="cell plt" class:platsup={service.ps} class:platchange={service.pc}>
@@ -64,7 +102,10 @@
{formatUkTime(service.wtp)} {formatUkTime(service.wtp)}
</div> </div>
<div <div
class="cell act {estClass(service.atp, service.etp)} {delayClassFromTimePair(service.wtp, service.atp || service.etp)}" class="cell act {estClass(service.atp, service.etp)} {delayClassFromTimePair(
service.wtp,
service.atp || service.etp
)}"
> >
{#if isRightTime(service.wtp, service.atp || service.etp)} {#if isRightTime(service.wtp, service.atp || service.etp)}
RT RT
@@ -77,19 +118,31 @@
{:else} {:else}
<div class="cell sch">{formatUkTime(service.sta)}</div> <div class="cell sch">{formatUkTime(service.sta)}</div>
<div <div
class="cell act {estClass(service.ata, service.eta)} {delayClassFromTimePair(service.sta, service.ata || service.eta)}" class="cell act {estClass(service.ata, service.eta)} {delayClassFromTimePair(
service.sta,
service.ata || service.eta
)}"
> >
{#if isRightTime(service.sta, service.ata || service.eta)} {#if isRightTime(service.sta, service.ata || service.eta)}
RT RT
{:else if service.sta && service.c} CAN {:else} {:else if service.sta && service.c}
CANC
{:else}
{realArrival} {realArrival}
{/if} {/if}
</div> </div>
<div class="cell sch">{formatUkTime(service.std)}</div> <div class="cell sch">{formatUkTime(service.std)}</div>
<div class="cell act {estClass(service.atd, service.etd)} {delayClassFromTimePair(service.std, service.atd || service.etd)}"> <div
class="cell act {estClass(service.atd, service.etd)} {delayClassFromTimePair(
service.std,
service.atd || service.etd
)}"
>
{#if isRightTime(service.std, service.atd || service.etd)} {#if isRightTime(service.std, service.atd || service.etd)}
RT RT
{:else if service.std && service.c} CANC {:else} {:else if service.std && service.c}
CANC
{:else}
{realDeparture} {realDeparture}
{/if} {/if}
</div> </div>
@@ -116,15 +169,19 @@
.departure-board { .departure-board {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
margin: 5px auto; height: 100%;
max-height: 100%;
margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overscroll-behavior-y: contain;
overflow-y: auto;
} }
.header.container { .header.container {
position: sticky; position: sticky;
padding-top: 7px;
top: 0; top: 0;
z-index: 2; z-index: 10;
background: var(--color-bg-dark); background: var(--color-bg-dark);
} }
@@ -172,23 +229,29 @@
padding-bottom: 7px; padding-bottom: 7px;
} }
.cell > div { /* Cancellation Logic */
display: block; .service.cancelled,
width: 100%; .service.cancelled .cell.orig,
} .service.cancelled .cell.dest {
/* Row Logic */
.service.cancelled {
color: rgb(255, 131, 131); color: rgb(255, 131, 131);
} }
.service.cancelled .cell.plt,
.service.cancelled .cell.act {
text-decoration: line-through;
opacity: 0.25;
}
.service.cancelled .cell.sch {
text-decoration: line-through;
}
/* Special Row Styles */ /* Meta Row Styles */
.operator.row, .operator.row,
.delay-reason.row, .delay-reason.row,
.cancel-reason.row { .cancel-reason.row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
text-align: left; text-align: left;
font-size: 0.88rem; font-size: 0.88rem;
font-stretch: 80%;
} }
.cancel-reason.row { .cancel-reason.row {
@@ -232,7 +295,7 @@
} }
.operator.row { .operator.row {
/* font-family: 'URW Gothic', sans-serif; */ font-stretch: 110%;
padding: 0; padding: 0;
color: rgb(187, 187, 255); color: rgb(187, 187, 255);
} }
@@ -253,6 +316,7 @@
.cell.dest { .cell.dest {
font-stretch: 80%; font-stretch: 80%;
font-weight: 400; font-weight: 400;
cursor: pointer;
} }
.cell.orig { .cell.orig {
text-align: left; text-align: left;
@@ -320,16 +384,9 @@
text-align: center; text-align: center;
font-stretch: 100%; 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);
}
.cell.pass { .cell.pass {
grid-column: span 2; grid-column: span 2;
} }
.cell.sch, .cell.sch,
.cell.act { .cell.act {
text-align: center; text-align: center;

View File

@@ -1,425 +0,0 @@
<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>
<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

@@ -45,7 +45,7 @@
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
position: relative; position: relative;
z-index: 10; z-index: 12;
margin-bottom: 0; margin-bottom: 0;
width: 95%; width: 95%;
max-width: 750px; max-width: 750px;

View File

@@ -0,0 +1,17 @@
export function formatTrainStatusMessage(
reason: string | undefined,
location: string | undefined,
near: boolean | undefined
) {
let output = '';
if (reason) {
output += reason.trim();
}
if (location) {
const preposition = near ? 'near' : 'at';
output += ` ${preposition} ${location}`;
}
return output.trim();
}

View File

@@ -1,6 +1,7 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts'; import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
export const estClass = (act: any, est: any) => (act ? 'actual' : 'estimate'); export const estClass = (act: any, est: any) =>
act ? 'actual' : est ? 'estimate' : undefined;
/** /**
* Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds * Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds

View File

@@ -5,7 +5,6 @@
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 StaffServicesGrid from '$lib/components/ui/station-board/StaffServicesGrid.svelte'; import StaffServicesGrid from '$lib/components/ui/station-board/StaffServicesGrid.svelte';
let { data } = $props(); let { data } = $props();
@@ -109,10 +108,6 @@
}); });
} }
}); });
// Wake Lock API Handling
// Load Data Invalidation Handling
// Refresh countdown logic
</script> </script>
<section class="board-wrapper"> <section class="board-wrapper">
@@ -131,9 +126,7 @@
<StaffServicesGrid services={data.boardData.data.s} /> <StaffServicesGrid services={data.boardData.data.s} />
</div> </div>
{:else} {:else}
<div class="no-service"> <div class="no-service">No services at this location in the next three hours</div>
No services at this location in the next three hours
</div>
{/if} {/if}
</section> </section>
@@ -145,6 +138,7 @@
align-items: center; align-items: center;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 0;
overflow: hidden; overflow: hidden;
gap: 0; gap: 0;
} }
@@ -152,6 +146,10 @@
.no-service { .no-service {
display: block; display: block;
margin: auto; margin: auto;
text-align: center;
font-weight: 600;
letter-spacing: 0.15ch;
font-size: 1.4rem;
max-width: 75%; max-width: 75%;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
} }
@@ -230,10 +228,6 @@
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;