Compare commits

...

8 Commits

9 changed files with 362 additions and 371 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

@@ -66,7 +66,7 @@
border-radius: 1rem; border-radius: 1rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5); box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
} }
.toggle-thumb { .toggle-thumb {

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts'; import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time'; import { formatUkTime, estClass, delayClassFromTimePair, isRightTime } from '$lib/utils/time';
import { fade } from 'svelte/transition'; import { fade, slide, fly } from 'svelte/transition';
import { flip } from 'svelte/animate';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props(); let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
@@ -10,364 +11,311 @@
</script> </script>
<section class="departure-board"> <section class="departure-board">
<div class="header"> <div class="header container">
<div class="header-row"></div> <div class="header row">
<div class="header-row"></div> <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>
<div class="services"> <div class="services">
<!-- Keyed EACH Here --> {#each services as service (getRowKey(service))}
{@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 }}
>
<div
class="service row"
class:passing={service.wtp}
class:nonPax={!service.ip}
class:cancelled={service.c}
>
<div class="cell id">{service.h}</div>
<div class="cell orig">
{#key service.og.t}
<div transition:fade={{ duration: 300 }}>
{service.og.t}
</div>
{/key}
</div>
<div class="cell dest">
{#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}
<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)}"
>
{#if isRightTime(service.wtp, service.atp || service.etp)}
RT
{:else if service.c}
CANC
{:else}
{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)}"
>
{#if isRightTime(service.sta, service.ata || service.eta)}
RT
{:else if service.sta && service.c} CAN {: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)}">
{#if isRightTime(service.std, service.atd || service.etd)}
RT
{:else if service.std && service.c} CANC {:else}
{realDeparture}
{/if}
</div>
{/if}
</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}
{:else if service?.dr?.r}
<div class="delay-reason row" transition:slide={{ duration: 300 }}>{service.dr.r}</div>
{/if}
</div>
{/each}
</div> </div>
</section> </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> <style>
.departure-board { .departure-board {
width: 100%; width: 100%;
max-width: 100%;
margin: 5px auto; margin: 5px auto;
border-collapse: collapse; box-sizing: border-box;
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; overflow: hidden;
} }
thead, .header.container {
.cancel-row td,
.delay-row td {
letter-spacing: -0.15ch;
}
thead {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
background: var(--color-bg-dark); background: var(--color-bg-dark);
} }
abbr { .row.header {
text-decoration: none; font-weight: 875;
border-bottom: none; }
cursor: help; .row.header:last-child {
padding-bottom: 1rem;
} }
tbody tr:first-child td { .upper-header-blank {
border-top: 5px solid transparent; grid-column: span 4;
}
.upper-header-text {
grid-column: span 2;
text-align: center;
} }
/* Row Logic */ .row {
.serviceCancelled { display: grid;
color: rgb(255, 131, 131); 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 { .service.row.passing {
opacity: 0.75; font-style: italic;
opacity: 0.25;
font-weight: 200; font-weight: 200;
} }
.serviceNonPassenger td { .service.row.nonPax {
opacity: 0.5; opacity: 0.25;
font-weight: 290; font-weight: 200;
font-style: italic; }
.service-block {
width: 100%;
max-width: 100%;
padding-bottom: 7px;
}
.cell > div {
display: block;
width: 100%;
}
/* Row Logic */
.service.cancelled {
color: rgb(255, 131, 131);
} }
/* Special Row Styles */ /* Special Row Styles */
.cancel-row td, .operator.row,
.delay-row td { .delay-reason.row,
font-size: 0.88rem; .cancel-reason.row {
grid-template-columns: 1fr;
text-align: left; text-align: left;
font-size: 0.88rem;
} }
.cancel-row td[colspan], .cancel-reason.row {
.delay-row td[colspan] {
padding-left: 0ch;
}
.cancel-row td {
color: rgb(255, 131, 131); color: rgb(255, 131, 131);
font-weight: 400; font-weight: 400;
letter-spacing: -0.1ch;
} }
.delay-row td { .delay-reason.row {
color: var(--delay-orange); color: var(--delay-orange);
font-style: italic; font-weight: 400;
letter-spacing: -0.1ch;
} }
@media (min-width: 375px) { @media (min-width: 375px) {
.cancel-row td, .operator.row,
.delay-row td { .delay-reason.row,
.cancel-reason.row {
font-size: 1.08rem; font-size: 1.08rem;
} }
} }
@media (min-width: 420px) { @media (min-width: 420px) {
.cancel-row td, .operator.row,
.delay-row td { .delay-reason.row,
.cancel-reason.row {
font-size: 1.12rem; font-size: 1.12rem;
} }
} }
@media (min-width: 550px) { @media (min-width: 550px) {
.cancel-row td, .operator.row,
.delay-row td { .delay-reason.row,
.cancel-reason.row {
font-size: 1.18rem; font-size: 1.18rem;
} }
} }
@media (min-width: 620px) { @media (min-width: 620px) {
.cancel-row td, .operator.row,
.delay-row td { .delay-reason.row,
.cancel-reason.row {
font-size: 1.2rem; font-size: 1.2rem;
} }
} }
.toc-coach-row td { .operator.row {
font-family: 'URW Gothic', sans-serif; /* font-family: 'URW Gothic', sans-serif; */
font-size: 0.7rem;
padding: 0; padding: 0;
color: rgb(187, 187, 255); 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 */ /* Column Specifics */
.id-cell { .cell.id {
text-align: left; text-align: left;
font-weight: 400; font-weight: 400;
font-stretch: 85%; font-stretch: 85%;
filter: brightness(0.75); filter: brightness(0.75);
} }
@media (min-width: 400px) { @media (min-width: 400px) {
.id-cell { .cell.id {
font-stretch: 90%; font-stretch: 90%;
} }
} }
.orig-cell, .cell.orig,
.dest-cell { .cell.dest {
font-stretch: 80%; font-stretch: 80%;
font-weight: 400; font-weight: 400;
} }
.orig-cell { .cell.orig {
text-align: left; text-align: left;
color: var(--location-yellow); color: var(--location-yellow);
} }
.dest-cell { .cell.dest {
text-align: right; text-align: right;
color: var(--location-yellow); color: var(--location-yellow);
} }
@media (min-width: 350px) { @media (min-width: 350px) {
.orig-cell, .cell.orig,
.dest-cell { .cell.dest {
font-stretch: 85%; font-stretch: 85%;
} }
@media (min-width: 400px) { @media (min-width: 400px) {
.orig-cell, .cell.orig,
.dest-cell { .cell.dest {
font-stretch: 90%; font-stretch: 90%;
} }
} }
@media (min-width: 490px) { @media (min-width: 490px) {
.orig-cell, .cell.orig,
.dest-cell { .cell.dest {
font-stretch: 95%; font-stretch: 95%;
} }
} }
@media (min-width: 520px) { @media (min-width: 520px) {
.orig-cell, .cell.orig,
.dest-cell { .cell.dest {
font-stretch: 100%; font-stretch: 100%;
} }
.orig-cell { .cell.orig {
text-align: right; text-align: right;
padding-right: 7px; padding-right: 7px;
} }
.dest-cell { .cell.dest {
text-align: left; text-align: left;
padding-left: 7px; padding-left: 7px;
} }
} }
@media (min-width: 600px) { @media (min-width: 600px) {
.orig-cell, .cell.orig,
.dest-cell { .cell.dest {
letter-spacing: 0.05ch; letter-spacing: 0.05ch;
} }
} }
} }
.plt-cell { .cell.plt {
text-align: center; text-align: center;
font-weight: 410; font-weight: 410;
font-stretch: 100%; font-stretch: 100%;
} }
@media (min-width: 525px) { @media (min-width: 525px) {
.plt-cell { .cell.plt {
font-stretch: 110%; font-stretch: 110%;
} }
} }
.plt-cell.platSup { .cell.plt.platsup {
font-weight: 200; font-weight: 150;
opacity: 0.3;
} }
.plt-cell.platChange { .cell.plt.platchange {
animation: fast-pulse 2s ease-out infinite; animation: fast-pulse 2s ease-out infinite;
} }
.service-row.serviceCancelled .plt-cell {
text-decoration: line-through;
}
.pass-cell { .pass-cell {
text-align: center; text-align: center;
font-stretch: 100%; font-stretch: 100%;
@@ -378,54 +326,58 @@
color: rgb(255, 131, 131); color: rgb(255, 131, 131);
} }
.time-cell { .cell.pass {
grid-column: span 2;
}
.cell.sch,
.cell.act {
text-align: center; text-align: center;
font-stretch: 72%; font-stretch: 72%;
} }
.cell.act.delay-late {
color: var(--delay-orange);
}
.cell.act.delay-early {
color: var(--early-blue);
}
@media (min-width: 350px) { @media (min-width: 350px) {
.time-cell { .cell.sch,
.cell.act {
font-stretch: 77%; font-stretch: 77%;
} }
} }
@media (min-width: 400px) { @media (min-width: 400px) {
.time-cell { .cell.sch,
.cell.act {
font-stretch: 82%; font-stretch: 82%;
} }
} }
@media (min-width: 525px) { @media (min-width: 525px) {
.time-cell { .cell.sch,
.cell.act {
font-stretch: 90%; font-stretch: 90%;
} }
} }
@media (min-width: 600px) { @media (min-width: 600px) {
.time-cell { .cell.sch,
.cell.act {
font-stretch: 100%; font-stretch: 100%;
} }
} }
.cell.act.est {
/* RT Logic */ font-style: italic;
.time-cell.delay-rt span { font-weight: 300;
display: none; opacity: 0.5;
}
.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 */ /* Time Types */
.est { .cell.act.estimate {
font-style: italic; font-style: italic;
opacity: 0.75; opacity: 0.75;
font-weight: 350; font-weight: 350;
} }
.act { .cell.act.actual {
font-weight: 600; font-weight: 600;
} }
</style> </style>

View File

@@ -49,8 +49,8 @@
{#each services as service (getRowKey(service))} {#each services as service (getRowKey(service))}
<tbody> <tbody>
<tr <tr
in:fade={{duration: 150, delay:150}} in:fade={{ duration: 150, delay: 150 }}
out:fade={{duration:150}} out:fade={{ duration: 150 }}
class="service-row" class="service-row"
class:serviceCancelled={service.c} class:serviceCancelled={service.c}
class:servicePass={service.wtp} class:servicePass={service.wtp}
@@ -126,7 +126,11 @@
</tr> </tr>
{#if service.o} {#if service.o}
<tr class="toc-coach-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}> <tr
class="toc-coach-row"
in:fade={{ duration: 150, delay: 150 }}
out:fade={{ duration: 150 }}
>
<td colspan="8"> <td colspan="8">
{service.o} {service.o}
</td> </td>

View File

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

View File

@@ -1,6 +1,6 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts'; 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 * 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'; 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, IconSettings,
IconHelp, IconHelp,
IconDots, IconDots,
IconSpy, IconSpy
} from '@tabler/icons-svelte-runes'; } from '@tabler/icons-svelte-runes';
onMount(async () => { onMount(async () => {
@@ -49,7 +49,7 @@
{ label: 'PIS', path: '/pis/', icon: IconDialpad }, { label: 'PIS', path: '/pis/', icon: IconDialpad },
{ label: 'Options', path: '/preferences/', icon: IconSettings }, { label: 'Options', path: '/preferences/', icon: IconSettings },
{ label: 'About', path: '/about/', icon: IconHelp }, { label: 'About', path: '/about/', icon: IconHelp },
{ label: 'Privacy Policy', path: '/privacy/', icon: IconSpy }, { label: 'Privacy Policy', path: '/privacy/', icon: IconSpy }
]; ];
let navWidth = $state(0); let navWidth = $state(0);

View File

@@ -6,12 +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'; 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(() => {
@@ -22,9 +24,9 @@
if ('wakeLock' in navigator && !wakeLock) { if ('wakeLock' in navigator && !wakeLock) {
try { try {
wakeLock = await navigator.wakeLock.request('screen'); wakeLock = await navigator.wakeLock.request('screen');
console.log("Wake lock obtained"); console.log('Wake lock obtained');
} catch (error) { } catch (error) {
console.warn("Wake lock request rejected: ", error); console.warn('Wake lock request rejected: ', error);
} }
} }
}; };
@@ -33,9 +35,9 @@
if (wakeLock) { if (wakeLock) {
try { try {
await wakeLock.release(); await wakeLock.release();
console.log("Wake lock released"); console.log('Wake lock released');
} catch (error) { } catch (error) {
console.error("Wake lock release failed: ", error); console.error('Wake lock release failed: ', error);
} }
wakeLock = null; wakeLock = null;
} }
@@ -46,31 +48,30 @@
if (intervalId) return; if (intervalId) return;
intervalId = window.setInterval(async () => { intervalId = window.setInterval(async () => {
try { try {
console.log("Invalidating data") console.log('Invalidating data');
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")
}; };
const stopPolling = async () => { const stopPolling = async () => {
if (intervalId) { if (intervalId) {
window.clearInterval(intervalId); window.clearInterval(intervalId);
intervalId = undefined; intervalId = undefined;
console.log("Polling ended") console.log('Polling ended');
} }
await releaseWakeLock(); await releaseWakeLock();
}; };
const handleVisibilityChange = () => { const handleVisibilityChange = async () => {
if (document.hidden) { if (document.hidden) {
stopPolling(); stopPolling();
} else { } else {
startPolling(); startPolling();
await invalidateAll();
} }
}; };
@@ -84,7 +85,7 @@
stopPolling(); stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}) });
onMount(() => { onMount(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -95,41 +96,44 @@
}); });
const currentBoardId = $derived( const currentBoardId = $derived(
data.BoardLocation ? (data.BoardLocation.c ?? data.BoardLocation.t) : null data.BoardLocation ? (data.BoardLocation.c ?? data.BoardLocation.t) : null
); );
// Update 'QuickLinks' when currentBoardId changes // Update 'QuickLinks' when currentBoardId changes
$effect(() => { $effect(() => {
if (currentBoardId) { if (currentBoardId) {
// Keep the execution and logs inside untrack to isolate dependencies completely // Keep the execution and logs inside untrack to isolate dependencies completely
untrack(() => { untrack(() => {
quickLinks.recordVisit(currentBoardId); quickLinks.recordVisit(currentBoardId);
console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`); console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
}); });
} }
}); });
// Wake Lock API Handling // Wake Lock API Handling
// Load Data Invalidation Handling // Load Data Invalidation Handling
// Refresh countdown logic // Refresh countdown logic
</script> </script>
<section class="board-wrapper"> <section class="board-wrapper">
<div class="time-data"> <div class="time-data">
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span> <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> <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">
<StaffServicesGrid services={data.boardData.data.s} /> <StaffServicesGrid services={data.boardData.data.s} />
</div> </div>
{:else}
<div class="no-service">
No services at this location in the next three hours
</div>
{/if} {/if}
</section> </section>
@@ -145,6 +149,13 @@ $effect(() => {
gap: 0; gap: 0;
} }
.no-service {
display: block;
margin: auto;
max-width: 75%;
font-family: 'URW Gothic', sans-serif;
}
.time-data { .time-data {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -155,6 +166,56 @@ $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;

View File

@@ -20,8 +20,8 @@
requested solely to send a verification code. This needs to happen, as some data is requested solely to send a verification code. This needs to happen, as some data is
restricted to rail staff only due to licensing restrictions. restricted to rail staff only due to licensing restrictions.
<strong <strong
>The moment the verification code is sent, the email address is permanently deleted >The moment the verification code is sent, the email address is permanently deleted from
from the system.</strong the system.</strong
> It is not stored, it is not logged, and it cannot be looked up later. > It is not stored, it is not logged, and it cannot be looked up later.
</li> </li>
<li class="list-item"> <li class="list-item">
@@ -42,16 +42,17 @@
</li> </li>
<li class="list-item"> <li class="list-item">
<strong class="highlight-text">Fuzzy Location Data:</strong> If the "Near to Me" toggle is <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 enabled, exact GPS coordinates are calculated, but never leave your device. Your device
into a <strong class="highlight-text">"fuzzy location block"</strong> (a geohash) roughly 1.2km compresses the position into a
by 0.6km in size before contacting the server to request nearby stations. OwlBoard only processes <strong class="highlight-text">"fuzzy location block"</strong> (a geohash) roughly 1.2km by 0.6km
that neighborhood block—never an exact location. in size before contacting the server to request nearby stations. OwlBoard only processes that
neighborhood block—never an exact location.
</li> </li>
<li class="list-item"> <li class="list-item">
<strong class="highlight-text">Server Logs &amp; IP Addresses:</strong> Like any web application, <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 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 IP addresses. This is strictly for system health, infrastructure monitoring, and blocking malicious
automated attacks. or automated attacks.
</li> </li>
</ul> </ul>
</section> </section>
@@ -89,19 +90,20 @@
<ul class="policy-list"> <ul class="policy-list">
<li class="list-item"> <li class="list-item">
<strong class="highlight-text">Instant SAR (Right of Access):</strong> The <strong class="highlight-text">Instant SAR (Right of Access):</strong> The
<strong>Account</strong> dashboard contains a dedicated section displaying <strong>Account</strong> dashboard contains a dedicated section displaying every single line of
every single line of data which can be identified with the account key block. It can be viewed or copied instantly. data which can be identified with the account key block. It can be viewed or copied instantly.
</li> </li>
<li class="list-item"> <li class="list-item">
<strong class="highlight-text">The Right to be Forgotten (Instant Erasure):</strong> A <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 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> </li>
</ul> </ul>
<p class="policy-text"> <p class="policy-text">
While the pseudonymized settings left behind are not technically personal data once While the pseudonymized settings left behind are not technically personal data once
disconnected from an identity, the OwlBoard project operates on a simple philosophy. disconnected from an identity, the OwlBoard project operates on a simple philosophy. When the
When the delete button is pressed, the data is erased. delete button is pressed, the data is erased.
</p> </p>
</section> </section>
@@ -111,8 +113,9 @@
<h3 class="section-heading">4. No Third-Party Conglomerates</h3> <h3 class="section-heading">4. No Third-Party Conglomerates</h3>
<p class="policy-text"> <p class="policy-text">
All data is hosted transparently on isolated, self-hosted server infrastructure in Somerset. OwlBoard does All data is hosted transparently on isolated, self-hosted server infrastructure in Somerset.
not use third-party analytics trackers, tracking cookies, or advertising networks. OwlBoard does not use third-party analytics trackers, tracking cookies, or advertising
networks.
</p> </p>
</section> </section>
</section> </section>