Compare commits

..

2 Commits

Author SHA1 Message Date
22aee0b915 Improve live indications (Smaller, but maybe marginally less user friendly?) Shorten poll interval for boards due to backend optimizations.
ensure data is reloaded when tab/page becomes visible.
2026-05-21 20:06:33 +01:00
f30a2745d4 Add initial staff grid view 2026-05-18 20:44:05 +01:00
3 changed files with 480 additions and 62 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,431 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
import { fade } from 'svelte/transition';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
const getRowKey = (s: ApiStationsBoard.BoardService) =>
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script>
<section class="departure-board">
<div class="header">
<div class="header-row"></div>
<div class="header-row"></div>
</div>
<div class="services">
<!-- Keyed EACH Here -->
</div>
</section>
<table class="departure-board">
<colgroup>
<col style="width:10%;" />
<!-- ID (Headcode) -->
<col style="width:17%;" />
<!-- ORIG (Origin) -->
<col style="width:17%;" />
<!-- DEST (Destination) -->
<col style="width:9%;" />
<!-- PLT (Platform) -->
<col style="width: 12%;" />
<!-- STA -->
<col style="width: 12%;" />
<!-- ETA/ATA -->
<col style="width: 12%;" />
<!-- STD -->
<col style="width: 11%;" />
<!-- ETD/ATD -->
</colgroup>
<thead>
<tr>
<th colspan="4" aria-hidden="true"></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Arrival">Arr</abbr></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Departure">Dep</abbr></th>
</tr>
<tr>
<th scope="col"><abbr title="Headcode">ID</abbr></th>
<th scope="col"><abbr title="Origin">Orig</abbr></th>
<th scope="col"><abbr title="Destination">Dest</abbr></th>
<th scope="col"><abbr title="Platform">Plt</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
</tr>
</thead>
{#each services as service (getRowKey(service))}
<tbody>
<tr
in:fade={{duration: 150, delay:150}}
out:fade={{duration:150}}
class="service-row"
class:serviceCancelled={service.c}
class:servicePass={service.wtp}
class:serviceNonPassenger={!service.ip}
>
<td class="id-cell">{service.h}</td>
<td class="orig-cell"
><abbr title={service.og.n.toLocaleUpperCase()}>{service.og.t}</abbr></td
>
<td class="dest-cell"
><abbr title={service.dt.n.toLocaleUpperCase()}>{service.dt.t}</abbr></td
>
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}
>{service.p || '-'}</td
>
<!-- Handle different display for a passing train -->
{#if service.wtp}
<td class="pass-cell" colspan="2">Pass</td>
<td class="time-cell">{formatUkTime(service.wtp)}</td>
<!-- If cancelled, show '-', otherwise check for RT or show time -->
<td
class="time-cell {estClass(service.atp, service.etp)} {delayClassFromTimePair(
service.wtp,
service.atp || service.etp
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.wtp, service.atp || service.etp) === 'delay-rt'
? 'RT'
: formatUkTime(service.atp || service.etp)}
</span>
</td>
{:else}
<td class="time-cell">{formatUkTime(service.sta)}</td>
<!-- Arrival Actual/Expected -->
<td
class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(
service.sta,
service.ata || service.eta
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.sta, service.ata || service.eta) === 'delay-rt'
? 'RT'
: formatUkTime(service.ata || service.eta)}
</span>
</td>
<td class="time-cell">{formatUkTime(service.std)}</td>
<!-- Departure Actual/Expected -->
<td
class="time-cell {estClass(service.atd, service.etd)} {delayClassFromTimePair(
service.std,
service.atd || service.etd
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.std, service.atd || service.etd) === 'delay-rt'
? 'RT'
: formatUkTime(service.atd || service.etd)}
</span>
</td>
{/if}
</tr>
{#if service.o}
<tr class="toc-coach-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r}
<tr class="cancel-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8">
{service.cr.r}
{#if service.cr.l}
{service.cr.n ? 'near' : 'at'}
{service.cr.l}
{/if}
</td>
</tr>
{/if}
{#if service.dr?.r}
<tr class="delay-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="9">
{service.dr.r}
{#if service.dr.l}
{service.dr.n ? 'near' : 'at'}
{service.dr.l}
{/if}
</td>
</tr>
{/if}
</tbody>
{/each}
</table>
<style>
.departure-board {
width: 100%;
margin: 5px auto;
border-collapse: collapse;
table-layout: fixed;
}
.departure-board td {
font-family: 'Inconsolata Variable', monospace;
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
font-variant-ligatures: additional-ligatures;
overflow: hidden;
}
thead,
.cancel-row td,
.delay-row td {
letter-spacing: -0.15ch;
}
thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-dark);
}
abbr {
text-decoration: none;
border-bottom: none;
cursor: help;
}
tbody tr:first-child td {
border-top: 5px solid transparent;
}
/* Row Logic */
.serviceCancelled {
color: rgb(255, 131, 131);
}
.servicePass td {
opacity: 0.75;
font-weight: 200;
}
.serviceNonPassenger td {
opacity: 0.5;
font-weight: 290;
font-style: italic;
}
/* Special Row Styles */
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
text-align: left;
}
.cancel-row td[colspan],
.delay-row td[colspan] {
padding-left: 0ch;
}
.cancel-row td {
color: rgb(255, 131, 131);
font-weight: 400;
}
.delay-row td {
color: var(--delay-orange);
font-style: italic;
}
@media (min-width: 375px) {
.cancel-row td,
.delay-row td {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.cancel-row td,
.delay-row td {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.cancel-row td,
.delay-row td {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.cancel-row td,
.delay-row td {
font-size: 1.2rem;
}
}
.toc-coach-row td {
font-family: 'URW Gothic', sans-serif;
font-size: 0.7rem;
padding: 0;
color: rgb(187, 187, 255);
}
@media (min-width: 400px) {
.toc-coach-row td {
font-size: 0.8rem;
}
}
@media (min-width: 550px) {
.toc-coach-row td {
font-size: 0.85rem;
}
}
/* Column Specifics */
.id-cell {
text-align: left;
font-weight: 400;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.id-cell {
font-stretch: 90%;
}
}
.orig-cell,
.dest-cell {
font-stretch: 80%;
font-weight: 400;
}
.orig-cell {
text-align: left;
color: var(--location-yellow);
}
.dest-cell {
text-align: right;
color: var(--location-yellow);
}
@media (min-width: 350px) {
.orig-cell,
.dest-cell {
font-stretch: 85%;
}
@media (min-width: 400px) {
.orig-cell,
.dest-cell {
font-stretch: 90%;
}
}
@media (min-width: 490px) {
.orig-cell,
.dest-cell {
font-stretch: 95%;
}
}
@media (min-width: 520px) {
.orig-cell,
.dest-cell {
font-stretch: 100%;
}
.orig-cell {
text-align: right;
padding-right: 7px;
}
.dest-cell {
text-align: left;
padding-left: 7px;
}
}
@media (min-width: 600px) {
.orig-cell,
.dest-cell {
letter-spacing: 0.05ch;
}
}
}
.plt-cell {
text-align: center;
font-weight: 410;
font-stretch: 100%;
}
@media (min-width: 525px) {
.plt-cell {
font-stretch: 110%;
}
}
.plt-cell.platSup {
font-weight: 200;
opacity: 0.3;
}
.plt-cell.platChange {
animation: fast-pulse 2s ease-out infinite;
}
.service-row.serviceCancelled .plt-cell {
text-decoration: line-through;
}
.pass-cell {
text-align: center;
font-stretch: 100%;
}
/* Colour orig and dest values when cancelled */
.service-row.serviceCancelled .orig-cell,
.service-row.serviceCancelled .dest-cell {
color: rgb(255, 131, 131);
}
.time-cell {
text-align: center;
font-stretch: 72%;
}
@media (min-width: 350px) {
.time-cell {
font-stretch: 77%;
}
}
@media (min-width: 400px) {
.time-cell {
font-stretch: 82%;
}
}
@media (min-width: 525px) {
.time-cell {
font-stretch: 90%;
}
}
@media (min-width: 600px) {
.time-cell {
font-stretch: 100%;
}
}
/* RT Logic */
.time-cell.delay-rt span {
display: none;
}
.time-cell.delay-rt::after {
content: 'RT';
}
.time-cell.delay-early {
color: var(--early-blue);
}
.time-cell.delay-late {
color: var(--delay-orange);
}
/* Time Types */
.est {
font-style: italic;
opacity: 0.75;
font-weight: 350;
}
.act {
font-weight: 600;
}
</style>

View File

@@ -6,11 +6,14 @@
import { formatUkDateTime, formatUkTime } from '$lib/utils/time'; import { formatUkDateTime, formatUkTime } from '$lib/utils/time';
import StaffServicesTable from '$lib/components/ui/station-board/StaffServicesTable.svelte'; import StaffServicesTable from '$lib/components/ui/station-board/StaffServicesTable.svelte';
import LiveIndicator from '$lib/components/ui/LiveIndicator.svelte'; import StaffServicesGrid from '$lib/components/ui/station-board/StaffServicesGrid.svelte';
let { data } = $props(); let { data } = $props();
let now = $state(new Date()); let now = $state(new Date());
let 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 // Handle auto-refreshing & Wake-lock
$effect(() => { $effect(() => {
@@ -49,10 +52,8 @@
await invalidateAll(); await invalidateAll();
} catch (error) { } catch (error) {
console.error("Background data fetch failed: ", error) console.error("Background data fetch failed: ", error)
live = false;
} }
}, 60100); }, 20000);
live = true;
console.log("Polling started") console.log("Polling started")
}; };
@@ -65,11 +66,12 @@
await releaseWakeLock(); await releaseWakeLock();
}; };
const handleVisibilityChange = () => { const handleVisibilityChange = async () => {
if (document.hidden) { if (document.hidden) {
stopPolling(); stopPolling();
} else { } else {
startPolling(); startPolling();
await invalidateAll();
} }
}; };
@@ -114,20 +116,18 @@ $effect(() => {
</script> </script>
<section class="board-wrapper"> <section class="board-wrapper">
<div class="time-data"> <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> <span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span>
</div>
<span class="time-now">{formatUkTime(now, true)}</span> <span class="time-now">{formatUkTime(now, true)}</span>
</div> </div>
{#if data.boardData.data.m?.length} {#if data.boardData.data.m?.length}
<StationAlertCard messages={data.boardData.data.m} /> <StationAlertCard messages={data.boardData.data.m} />
{/if} {/if}
{#if live}
<div class="live-indicator">
<LiveIndicator />
</div>
{/if}
{#if data.boardData.data.s?.length} {#if data.boardData.data.s?.length}
<div class="service-list-wrapper"> <div class="service-list-wrapper">
<StaffServicesTable services={data.boardData.data.s} /> <StaffServicesGrid services={data.boardData.data.s} />
</div> </div>
{/if} {/if}
</section> </section>
@@ -154,6 +154,42 @@ $effect(() => {
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
} }
.loadedlive {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.liveflash {
width: 12px;
height: 12px;
border-radius: 50%;
transition: all 0.3s ease;
}
div.notLive {
background-color: #ef4444;
animation:
led-blink 1s steps(1, start) infinite,
led-halo-expand 1s steps(1, start) infinite;
}
div.isLive {
background-color: #10b981;
animation:
led-pulse 2.5s infinite ease-in-out;
}
@keyframes led-blink {
0%,100%{opacity:1;}
50%{opacity:0.2;filter:saturate(0.2)}
}
@keyframes led-pulse{
0%,100%{opacity: 1;filter:saturate(1);box-shadow: 0 0 6px 1px rgba(16,185,129,0.4)}
50%{opacity: 0.2;filter:saturate(0.2);box-shadow:0 0 2px 0px rgba(16,185,129,0.1)}
}
.time-loaded, .time-loaded,
.time-now { .time-now {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;