Compare commits

...

10 Commits

13 changed files with 788 additions and 808 deletions

View File

@@ -1,49 +0,0 @@
<div class="cont">
<div class="flash"></div>
<span class="txt">Live</span>
</div>
<style>
.cont {
display: inline-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
font-family: 'URW Gothic', sans-serif;
font-weight: 600;
letter-spacing: 0.15ch;
}
.flash {
height: 0.6rem;
width: 0.6rem;
margin: 0 0.35rem;
border-radius: 50%;
background-color: forestgreen;
animation: live-pulse 1s ease-in-out infinite;
}
.txt {
animation: live-text-pulse 1s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% {
box-shadow: 0 0 14px forestgreen;
filter: opacity(1) saturate(1);
}
50% {
box-shadow: 0 0 2px forestgreen;
filter: opacity(0.2) saturate(0.95);
}
}
@keyframes live-text-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.65;
}
}
</style>

View File

@@ -0,0 +1,152 @@
<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>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="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>{formatUkTime(loc.wtp)}</td>
<td>{formatUkTime(loc.atp || loc.etp || '--')}</td>
{:else}
<td>{formatUkTime(loc.pta || loc.wta)}</td>
<td>{formatUkTime(loc.ata || loc.eta || '--')}</td>
<td>{formatUkTime(loc.ptd || loc.wtd)}</td>
<td>{formatUkTime(loc.atd || loc.etd || '--')}</td>
{/if}
{#if loc}
{@const delay = calculateDelay(loc)}
<td class="delay-{delay.type}">{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>

View File

@@ -0,0 +1,194 @@
<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">
<button class="close-btn" onclick={onClose} aria-label="Close modal">
<IconX size={20} />
</button>
<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(--modal-bg, #1e1e1e);
color: var(--modal-text, #fff);
border: none;
border-radius: 12px;
width: 90%;
max-width: 400px;
padding: 1.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
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;
}
.close-btn {
position: absolute;
top: 0;
right: 0;
background: transparent;
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: rgba(255, 255, 255, 0.1);
}
.trainServiceModalWrapper {
height: 75vh;
display: flex;
flex-direction: column;
overflow: hidden;
margin-top: 1.5rem;
}
.scrollable-table-container {
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

@@ -1,7 +1,19 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
import { fade } from 'svelte/transition';
import { formatUkTime, estClass, delayClassFromTimePair, isRightTime } from '$lib/utils/time';
import { fade, slide, fly } from 'svelte/transition';
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();
@@ -9,423 +21,420 @@
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script>
<!-- Train Detail Modal -->
<ServiceDetailModal rid={selectedRid} isOpen={isModalOpen} onClose={handleCloseModal} />
<section class="departure-board">
<div class="header">
<div class="header-row"></div>
<div class="header-row"></div>
<div class="header container">
<div class="header row">
<div class="upper-header-blank"></div>
<div class="upper-header-text">Arr</div>
<div class="upper-header-text">Dep</div>
</div>
<div class="header row">
<div class="cell">ID</div>
<div class="cell">Orig</div>
<div class="cell">Dest</div>
<div class="cell">Plt</div>
<div class="cell">Sch</div>
<div class="cell">Act</div>
<div class="cell">Sch</div>
<div class="cell">Act</div>
</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}
{@const realArrival = formatUkTime(service.ata || service.eta)}
{@const realDeparture = formatUkTime(service.atd || service.etd)}
{@const realPass = formatUkTime(service.atp || service.etp)}
<div
class="service-block"
animate:flip={{ duration: 400 }}
transition:slide={{ duration: 300 }}
>
<td class="id-cell">{service.h}</td>
<td class="orig-cell"
><abbr title={service.og.n.toLocaleUpperCase()}>{service.og.t}</abbr></td
<div
class="service row"
class:passing={service.wtp}
class:nonPax={!service.ip}
class:cancelled={service.c}
>
<td class="dest-cell"
><abbr title={service.dt.n.toLocaleUpperCase()}>{service.dt.t}</abbr></td
<div class="cell id">{service.h}</div>
<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);
}
}}
>
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}
>{service.p || '-'}</td
{#key service.og.t}
<div transition:fade={{ duration: 300 }}>
{service.og.t}
</div>
{/key}
</div>
<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);
}
}}
>
<!-- Handle different display for a passing train -->
{#key service.dt.t}<div transition:fade={{ duration: 300 }}>{service.dt.t}</div>{/key}
</div>
<div class="cell plt" class:platsup={service.ps} class:platchange={service.pc}>
{#key service.p}<div transition:fade={{ duration: 300 }}>{service.p || '-'}</div>{/key}
</div>
{#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(
<div class="cell pass">PASS</div>
<div class="cell sch">
{formatUkTime(service.wtp)}
</div>
<div
class="cell act {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>
{#if isRightTime(service.wtp, service.atp || service.etp)}
RT
{:else if service.c}
CANC
{:else}
<td class="time-cell">{formatUkTime(service.sta)}</td>
<!-- Arrival Actual/Expected -->
<td
class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(
{realPass}
{/if}
</div>
{:else}
<div class="cell sch">{formatUkTime(service.sta)}</div>
<div
class="cell act {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(
{#if isRightTime(service.sta, service.ata || service.eta)}
RT
{:else if service.sta && service.c}
CANC
{:else}
{realArrival}
{/if}
</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
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.std, service.atd || service.etd) === 'delay-rt'
? 'RT'
: formatUkTime(service.atd || service.etd)}
</span>
</td>
{#if isRightTime(service.std, service.atd || service.etd)}
RT
{:else if service.std && service.c}
CANC
{:else}
{realDeparture}
{/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>
</div>
{/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}
</div>
<div class="operator row">{service.o}</div>
{#if service?.cr?.r}
{#if service.c}
<div class="cancel-reason row" transition:slide={{ duration: 300 }}>{service.cr.r}</div>
{:else}
<div class="cancel-reason row" transition:slide={{ duration: 300 }}>
Part of this service has been cancelled
</div>
{/if}
</td>
</tr>
{:else if service?.dr?.r}
<div class="delay-reason row" transition:slide={{ duration: 300 }}>{service.dr.r}</div>
{/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>
</div>
{/each}
</table>
</div>
</section>
<style>
.departure-board {
width: 100%;
margin: 5px auto;
border-collapse: collapse;
table-layout: fixed;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0 auto;
box-sizing: border-box;
overscroll-behavior-y: contain;
overflow-y: auto;
}
.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 {
.header.container {
position: sticky;
padding-top: 7px;
top: 0;
z-index: 2;
z-index: 10;
background: var(--color-bg-dark);
}
abbr {
text-decoration: none;
border-bottom: none;
cursor: help;
.row.header {
font-weight: 875;
}
.row.header:last-child {
padding-bottom: 1rem;
}
tbody tr:first-child td {
border-top: 5px solid transparent;
.upper-header-blank {
grid-column: span 4;
}
.upper-header-text {
grid-column: span 2;
text-align: center;
}
/* Row Logic */
.serviceCancelled {
color: rgb(255, 131, 131);
.row {
display: grid;
grid-template-columns: 10% 17% 17% 9% 12% 12% 12% 11%;
align-items: center;
text-align: center;
gap: 0;
box-sizing: border-box;
font-family: 'Inconsolata Variable', monospace;
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
font-variant-ligatures: additional-ligatures;
}
.servicePass td {
opacity: 0.75;
.service.row.passing {
font-style: italic;
opacity: 0.25;
font-weight: 200;
}
.serviceNonPassenger td {
opacity: 0.5;
font-weight: 290;
font-style: italic;
.service.row.nonPax {
opacity: 0.25;
font-weight: 200;
}
/* Special Row Styles */
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
.service-block {
width: 100%;
max-width: 100%;
padding-bottom: 7px;
}
/* Cancellation Logic */
.service.cancelled,
.service.cancelled .cell.orig,
.service.cancelled .cell.dest {
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;
}
/* Meta Row Styles */
.operator.row,
.delay-reason.row,
.cancel-reason.row {
grid-template-columns: 1fr;
text-align: left;
font-size: 0.88rem;
font-stretch: 80%;
}
.cancel-row td[colspan],
.delay-row td[colspan] {
padding-left: 0ch;
}
.cancel-row td {
.cancel-reason.row {
color: rgb(255, 131, 131);
font-weight: 400;
letter-spacing: -0.1ch;
}
.delay-row td {
.delay-reason.row {
color: var(--delay-orange);
font-style: italic;
font-weight: 400;
letter-spacing: -0.1ch;
}
@media (min-width: 375px) {
.cancel-row td,
.delay-row td {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.cancel-row td,
.delay-row td {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.cancel-row td,
.delay-row td {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.cancel-row td,
.delay-row td {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.2rem;
}
}
.toc-coach-row td {
font-family: 'URW Gothic', sans-serif;
font-size: 0.7rem;
.operator.row {
font-stretch: 110%;
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 {
.cell.id {
text-align: left;
font-weight: 400;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.id-cell {
.cell.id {
font-stretch: 90%;
}
}
.orig-cell,
.dest-cell {
.cell.orig,
.cell.dest {
font-stretch: 80%;
font-weight: 400;
cursor: pointer;
}
.orig-cell {
.cell.orig {
text-align: left;
color: var(--location-yellow);
}
.dest-cell {
.cell.dest {
text-align: right;
color: var(--location-yellow);
}
@media (min-width: 350px) {
.orig-cell,
.dest-cell {
.cell.orig,
.cell.dest {
font-stretch: 85%;
}
@media (min-width: 400px) {
.orig-cell,
.dest-cell {
.cell.orig,
.cell.dest {
font-stretch: 90%;
}
}
@media (min-width: 490px) {
.orig-cell,
.dest-cell {
.cell.orig,
.cell.dest {
font-stretch: 95%;
}
}
@media (min-width: 520px) {
.orig-cell,
.dest-cell {
.cell.orig,
.cell.dest {
font-stretch: 100%;
}
.orig-cell {
.cell.orig {
text-align: right;
padding-right: 7px;
}
.dest-cell {
.cell.dest {
text-align: left;
padding-left: 7px;
}
}
@media (min-width: 600px) {
.orig-cell,
.dest-cell {
.cell.orig,
.cell.dest {
letter-spacing: 0.05ch;
}
}
}
.plt-cell {
.cell.plt {
text-align: center;
font-weight: 410;
font-stretch: 100%;
}
@media (min-width: 525px) {
.plt-cell {
.cell.plt {
font-stretch: 110%;
}
}
.plt-cell.platSup {
font-weight: 200;
opacity: 0.3;
.cell.plt.platsup {
font-weight: 150;
}
.plt-cell.platChange {
.cell.plt.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);
.cell.pass {
grid-column: span 2;
}
.time-cell {
.cell.sch,
.cell.act {
text-align: center;
font-stretch: 72%;
}
.cell.act.delay-late {
color: var(--delay-orange);
}
.cell.act.delay-early {
color: var(--early-blue);
}
@media (min-width: 350px) {
.time-cell {
.cell.sch,
.cell.act {
font-stretch: 77%;
}
}
@media (min-width: 400px) {
.time-cell {
.cell.sch,
.cell.act {
font-stretch: 82%;
}
}
@media (min-width: 525px) {
.time-cell {
.cell.sch,
.cell.act {
font-stretch: 90%;
}
}
@media (min-width: 600px) {
.time-cell {
.cell.sch,
.cell.act {
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);
.cell.act.est {
font-style: italic;
font-weight: 300;
opacity: 0.5;
}
/* Time Types */
.est {
.cell.act.estimate {
font-style: italic;
opacity: 0.75;
font-weight: 350;
}
.act {
.cell.act.actual {
font-weight: 600;
}
</style>

View File

@@ -1,421 +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;
background: transparent;
position: relative;
z-index: 10;
z-index: 12;
margin-bottom: 0;
width: 95%;
max-width: 750px;

View File

@@ -13,7 +13,7 @@ const DEFAULT_PREFS: Preferences = {
NearToMe: false,
ShowPassingTrains: true,
BoardWakeLock: true,
AutoRefresh: true,
AutoRefresh: true
};
function loadPrefs(): Preferences {

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,6 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
export const estClass = (act: any, est: any) => (act ? 'act' : 'est');
export const estClass = (act: any, est: any) => (act ? 'actual' : 'estimate');
/**
* Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds
@@ -132,3 +132,23 @@ export function delayClassFromTimePair(sched: any, act: any): string {
return diff > 0 ? 'delay-late' : 'delay-early';
}
/**
* Accepts a pair of times (string or Date) and returns true if service considered 'on-time'
* @param sched Scheduled Time (string, Date)
* @param act Actual Time (string, Date)
*/
export function isRightTime(
sched: string | Date | undefined,
act: string | Date | undefined
): boolean {
console.log(`Checking [sched: ${sched}, act: ${act}]`);
if (!sched || !act) return false;
const s = new Date(sched).getTime();
const a = new Date(act).getTime();
if (isNaN(s) || isNaN(a)) return false;
return Math.abs(a - s) < 60000;
}

View File

@@ -21,7 +21,7 @@
IconSettings,
IconHelp,
IconDots,
IconSpy,
IconSpy
} from '@tabler/icons-svelte-runes';
onMount(async () => {
@@ -49,7 +49,7 @@
{ label: 'PIS', path: '/pis/', icon: IconDialpad },
{ label: 'Options', path: '/preferences/', icon: IconSettings },
{ label: 'About', path: '/about/', icon: IconHelp },
{ label: 'Privacy Policy', path: '/privacy/', icon: IconSpy },
{ label: 'Privacy Policy', path: '/privacy/', icon: IconSpy }
];
let navWidth = $state(0);

View File

@@ -5,13 +5,14 @@
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';
import LiveIndicator from '$lib/components/ui/LiveIndicator.svelte';
import StaffServicesGrid from '$lib/components/ui/station-board/StaffServicesGrid.svelte';
let { data } = $props();
let now = $state(new Date());
let live = $state(false);
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(() => {
@@ -22,9 +23,9 @@
if ('wakeLock' in navigator && !wakeLock) {
try {
wakeLock = await navigator.wakeLock.request('screen');
console.log("Wake lock obtained");
console.log('Wake lock obtained');
} catch (error) {
console.warn("Wake lock request rejected: ", error);
console.warn('Wake lock request rejected: ', error);
}
}
};
@@ -33,9 +34,9 @@
if (wakeLock) {
try {
await wakeLock.release();
console.log("Wake lock released");
console.log('Wake lock released');
} catch (error) {
console.error("Wake lock release failed: ", error);
console.error('Wake lock release failed: ', error);
}
wakeLock = null;
}
@@ -46,31 +47,30 @@
if (intervalId) return;
intervalId = window.setInterval(async () => {
try {
console.log("Invalidating data")
console.log('Invalidating data');
await invalidateAll();
} catch (error) {
console.error("Background data fetch failed: ", error)
live = false;
console.error('Background data fetch failed: ', error);
}
}, 60100);
live = true;
console.log("Polling started")
}, 20000);
console.log('Polling started');
};
const stopPolling = async () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = undefined;
console.log("Polling ended")
console.log('Polling ended');
}
await releaseWakeLock();
};
const handleVisibilityChange = () => {
const handleVisibilityChange = async () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
await invalidateAll();
}
};
@@ -84,7 +84,7 @@
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
})
});
onMount(() => {
const interval = setInterval(() => {
@@ -108,28 +108,25 @@ $effect(() => {
});
}
});
// Wake Lock API Handling
// Load Data Invalidation Handling
// Refresh countdown logic
</script>
<section class="board-wrapper">
<div class="time-data">
<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>
</div>
{#if data.boardData.data.m?.length}
<StationAlertCard messages={data.boardData.data.m} />
{/if}
{#if live}
<div class="live-indicator">
<LiveIndicator />
</div>
{/if}
{#if data.boardData.data.s?.length}
<div class="service-list-wrapper">
<StaffServicesGrid services={data.boardData.data.s} />
</div>
{:else}
<div class="no-service">No services at this location in the next three hours</div>
{/if}
</section>
@@ -141,10 +138,22 @@ $effect(() => {
align-items: center;
width: 100%;
margin: 0 auto;
padding: 0;
overflow: hidden;
gap: 0;
}
.no-service {
display: block;
margin: auto;
text-align: center;
font-weight: 600;
letter-spacing: 0.15ch;
font-size: 1.4rem;
max-width: 75%;
font-family: 'URW Gothic', sans-serif;
}
.time-data {
display: flex;
justify-content: space-between;
@@ -155,6 +164,56 @@ $effect(() => {
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-now {
font-variant-numeric: tabular-nums;
@@ -169,10 +228,6 @@ $effect(() => {
font-size: 1rem;
}
.live-indicator {
margin: 0.25rem auto;
}
.service-list-wrapper {
flex: 1;
overflow-y: auto;

View File

@@ -20,8 +20,8 @@
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
>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">
@@ -42,16 +42,17 @@
</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.
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.
IP addresses. This is strictly for system health, infrastructure monitoring, and blocking malicious
or automated attacks.
</li>
</ul>
</section>
@@ -89,19 +90,20 @@
<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.
<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.
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.
disconnected from an identity, the OwlBoard project operates on a simple philosophy. When the
delete button is pressed, the data is erased.
</p>
</section>
@@ -111,8 +113,9 @@
<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.
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>