Compare commits

..

15 Commits

Author SHA1 Message Date
893fc727e7 Further styling to staff view grid 2026-05-24 00:37:00 +01:00
0a9fb02b9f Adjust display logic 2026-05-23 01:18:39 +01:00
6a99ffc806 Minot animation again... 2026-05-22 00:16:15 +01:00
9ea8bef99c Minor animation adjustments - still not as desired! 2026-05-22 00:15:58 +01:00
9088de9277 Minor adjustments to improve transitions... not currently working on time changes 2026-05-22 00:07:22 +01:00
0877d98bef Adjust header sizing and padding 2026-05-22 00:00:20 +01:00
0eb2f4349c Initial grid staff view layout 2026-05-21 23:56:59 +01:00
d10ca44023 Display cancellation reason ELSE delay reason... there is no need to display both if the train is cancelled! 2026-05-21 22:11:53 +01:00
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
9e0f53b7fe Adjust live flasher animated saturation 2026-05-18 20:36:47 +01:00
03495764e0 Adjust live indicator location 2026-05-18 20:30:22 +01:00
9657a77c41 Adjust poll frequency 2026-05-18 20:20:41 +01:00
437b0b8cf1 Add live indicator to board page 2026-05-18 20:20:01 +01:00
40e926bf11 Features:
- Add preferences for WakeLock and AutoRefresh
 - Add WakeLock and AutoRefresh to Board
 - Attempt adding transitions to Board elements

UI:
 - Add '3D' styling to preference toggles
2026-05-18 11:25:18 +01:00
11 changed files with 618 additions and 458 deletions

View File

@@ -15,7 +15,7 @@
<BaseCard header={'Nearby Stations'}> <BaseCard header={'Nearby Stations'}>
<div class="card-content"> <div class="card-content">
{#if !prefs.current.NearToMe} {#if !prefs.current.NearToMe}
<Button onclick={() => prefs.toggleLocation(true)}>Enable Location</Button> <Button onclick={() => prefs.toggleNearToMe(true)}>Enable Location</Button>
{:else if nearestStationsState.error && nearestStationsState.list.length === 0} {:else if nearestStationsState.error && nearestStationsState.list.length === 0}
<p class="msg">{nearestStationsState.error}</p> <p class="msg">{nearestStationsState.error}</p>
{:else if nearestStationsState.loading && nearestStationsState.list.length === 0} {:else if nearestStationsState.loading && nearestStationsState.list.length === 0}

View File

@@ -66,6 +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);
} }
.toggle-thumb { .toggle-thumb {
@@ -77,8 +78,8 @@
background-color: var(--color-accent); background-color: var(--color-accent);
border-radius: 50%; border-radius: 50%;
transition: transform 0.2s ease; transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
filter: brightness(0.95); filter: brightness(1.45);
} }
.toggle-input:checked + .toggle-track { .toggle-input:checked + .toggle-track {

View File

@@ -0,0 +1,386 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair, isRightTime } from '$lib/utils/time';
import { fade, slide, fly } from 'svelte/transition';
import { flip } from 'svelte/animate';
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 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">
{#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} 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)}">
{#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>
</section>
<style>
.departure-board {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0 auto;
box-sizing: border-box;
overscroll-behavior-y: contain;
overflow-y: auto;
}
.header.container {
position: sticky;
padding-top: 7px;
top: 0;
z-index: 10;
background: var(--color-bg-dark);
}
.row.header {
font-weight: 875;
}
.row.header:last-child {
padding-bottom: 1rem;
}
.upper-header-blank {
grid-column: span 4;
}
.upper-header-text {
grid-column: span 2;
text-align: center;
}
.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;
}
.service.row.passing {
font-style: italic;
opacity: 0.25;
font-weight: 200;
}
.service.row.nonPax {
opacity: 0.25;
font-weight: 200;
}
.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-reason.row {
color: rgb(255, 131, 131);
font-weight: 400;
letter-spacing: -0.1ch;
}
.delay-reason.row {
color: var(--delay-orange);
font-weight: 400;
letter-spacing: -0.1ch;
}
@media (min-width: 375px) {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.2rem;
}
}
.operator.row {
font-stretch: 110%;
padding: 0;
color: rgb(187, 187, 255);
}
/* Column Specifics */
.cell.id {
text-align: left;
font-weight: 400;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.cell.id {
font-stretch: 90%;
}
}
.cell.orig,
.cell.dest {
font-stretch: 80%;
font-weight: 400;
}
.cell.orig {
text-align: left;
color: var(--location-yellow);
}
.cell.dest {
text-align: right;
color: var(--location-yellow);
}
@media (min-width: 350px) {
.cell.orig,
.cell.dest {
font-stretch: 85%;
}
@media (min-width: 400px) {
.cell.orig,
.cell.dest {
font-stretch: 90%;
}
}
@media (min-width: 490px) {
.cell.orig,
.cell.dest {
font-stretch: 95%;
}
}
@media (min-width: 520px) {
.cell.orig,
.cell.dest {
font-stretch: 100%;
}
.cell.orig {
text-align: right;
padding-right: 7px;
}
.cell.dest {
text-align: left;
padding-left: 7px;
}
}
@media (min-width: 600px) {
.cell.orig,
.cell.dest {
letter-spacing: 0.05ch;
}
}
}
.cell.plt {
text-align: center;
font-weight: 410;
font-stretch: 100%;
}
@media (min-width: 525px) {
.cell.plt {
font-stretch: 110%;
}
}
.cell.plt.platsup {
font-weight: 150;
}
.cell.plt.platchange {
animation: fast-pulse 2s ease-out infinite;
}
.pass-cell {
text-align: center;
font-stretch: 100%;
}
.cell.pass {
grid-column: span 2;
}
.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) {
.cell.sch,
.cell.act {
font-stretch: 77%;
}
}
@media (min-width: 400px) {
.cell.sch,
.cell.act {
font-stretch: 82%;
}
}
@media (min-width: 525px) {
.cell.sch,
.cell.act {
font-stretch: 90%;
}
}
@media (min-width: 600px) {
.cell.sch,
.cell.act {
font-stretch: 100%;
}
}
.cell.act.est {
font-style: italic;
font-weight: 300;
opacity: 0.5;
}
/* Time Types */
.cell.act.estimate {
font-style: italic;
opacity: 0.75;
font-weight: 350;
}
.cell.act.actual {
font-weight: 600;
}
</style>

View File

@@ -1,418 +0,0 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
const getRowKey = (s: ApiStationsBoard.BoardService) =>
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script>
<table class="departure-board">
<colgroup>
<col style="width:10%;" />
<!-- ID (Headcode) -->
<col style="width:17%;" />
<!-- ORIG (Origin) -->
<col style="width:17%;" />
<!-- DEST (Destination) -->
<col style="width:9%;" />
<!-- PLT (Platform) -->
<col style="width: 12%;" />
<!-- STA -->
<col style="width: 12%;" />
<!-- ETA/ATA -->
<col style="width: 12%;" />
<!-- STD -->
<col style="width: 11%;" />
<!-- ETD/ATD -->
</colgroup>
<thead>
<tr>
<th colspan="4" aria-hidden="true"></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Arrival">Arr</abbr></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Departure">Dep</abbr></th>
</tr>
<tr>
<th scope="col"><abbr title="Headcode">ID</abbr></th>
<th scope="col"><abbr title="Origin">Orig</abbr></th>
<th scope="col"><abbr title="Destination">Dest</abbr></th>
<th scope="col"><abbr title="Platform">Plt</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
</tr>
</thead>
{#each services as service (getRowKey(service))}
<tbody>
<tr
class="service-row"
class:serviceCancelled={service.c}
class:servicePass={service.wtp}
class:serviceNonPassenger={!service.ip}
>
<td class="id-cell">{service.h}</td>
<td class="orig-cell"
><abbr title={service.og.n.toLocaleUpperCase()}>{service.og.t}</abbr></td
>
<td class="dest-cell"
><abbr title={service.dt.n.toLocaleUpperCase()}>{service.dt.t}</abbr></td
>
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}
>{service.p || '-'}</td
>
<!-- Handle different display for a passing train -->
{#if service.wtp}
<td class="pass-cell" colspan="2">Pass</td>
<td class="time-cell">{formatUkTime(service.wtp)}</td>
<!-- If cancelled, show '-', otherwise check for RT or show time -->
<td
class="time-cell {estClass(service.atp, service.etp)} {delayClassFromTimePair(
service.wtp,
service.atp || service.etp
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.wtp, service.atp || service.etp) === 'delay-rt'
? 'RT'
: formatUkTime(service.atp || service.etp)}
</span>
</td>
{:else}
<td class="time-cell">{formatUkTime(service.sta)}</td>
<!-- Arrival Actual/Expected -->
<td
class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(
service.sta,
service.ata || service.eta
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.sta, service.ata || service.eta) === 'delay-rt'
? 'RT'
: formatUkTime(service.ata || service.eta)}
</span>
</td>
<td class="time-cell">{formatUkTime(service.std)}</td>
<!-- Departure Actual/Expected -->
<td
class="time-cell {estClass(service.atd, service.etd)} {delayClassFromTimePair(
service.std,
service.atd || service.etd
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.std, service.atd || service.etd) === 'delay-rt'
? 'RT'
: formatUkTime(service.atd || service.etd)}
</span>
</td>
{/if}
</tr>
{#if service.o}
<tr class="toc-coach-row">
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r}
<tr class="cancel-row">
<td colspan="8">
{service.cr.r}
{#if service.cr.l}
{service.cr.n ? 'near' : 'at'}
{service.cr.l}
{/if}
</td>
</tr>
{/if}
{#if service.dr?.r}
<tr class="delay-row">
<td colspan="9">
{service.dr.r}
{#if service.dr.l}
{service.dr.n ? 'near' : 'at'}
{service.dr.l}
{/if}
</td>
</tr>
{/if}
</tbody>
{/each}
</table>
<style>
.departure-board {
width: 100%;
margin: 5px auto;
border-collapse: collapse;
table-layout: fixed;
}
.departure-board td {
font-family: 'Inconsolata Variable', monospace;
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
font-variant-ligatures: additional-ligatures;
overflow: hidden;
}
thead,
.cancel-row td,
.delay-row td {
letter-spacing: -0.15ch;
}
thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-dark);
}
abbr {
text-decoration: none;
border-bottom: none;
cursor: help;
}
tbody tr:first-child td {
border-top: 5px solid transparent;
}
/* Row Logic */
.serviceCancelled {
color: rgb(255, 131, 131);
}
.servicePass td {
opacity: 0.75;
font-weight: 200;
}
.serviceNonPassenger td {
opacity: 0.5;
font-weight: 290;
font-style: italic;
}
/* Special Row Styles */
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
text-align: left;
}
.cancel-row td[colspan],
.delay-row td[colspan] {
padding-left: 0ch;
}
.cancel-row td {
color: rgb(255, 131, 131);
font-weight: 400;
}
.delay-row td {
color: var(--delay-orange);
font-style: italic;
}
@media (min-width: 375px) {
.cancel-row td,
.delay-row td {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.cancel-row td,
.delay-row td {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.cancel-row td,
.delay-row td {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.cancel-row td,
.delay-row td {
font-size: 1.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

@@ -3,13 +3,17 @@ import { browser } from '$app/environment';
export interface Preferences { export interface Preferences {
NearToMe: boolean; NearToMe: boolean;
ShowPassingTrains: boolean; ShowPassingTrains: boolean;
BoardWakeLock: boolean;
AutoRefresh: boolean;
} }
const STORAGE_KEY = 'ob_pref'; const STORAGE_KEY = 'ob_pref';
const DEFAULT_PREFS: Preferences = { const DEFAULT_PREFS: Preferences = {
NearToMe: false, NearToMe: false,
ShowPassingTrains: true ShowPassingTrains: true,
BoardWakeLock: true,
AutoRefresh: true
}; };
function loadPrefs(): Preferences { function loadPrefs(): Preferences {
@@ -50,6 +54,14 @@ class PreferencesStore {
toggleShowPassingTrains(enabled: boolean) { toggleShowPassingTrains(enabled: boolean) {
this.current.ShowPassingTrains = enabled; this.current.ShowPassingTrains = enabled;
} }
toggleBoardWakeLock(enabled: boolean) {
this.current.BoardWakeLock = enabled;
}
toggleAutoRefresh(enabled: boolean) {
this.current.AutoRefresh = enabled;
}
} }
export const prefs = new PreferencesStore(); export const prefs = new PreferencesStore();

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

@@ -1,12 +1,90 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte'; import { quickLinks } from '$lib/quick-links.svelte';
import { invalidateAll } from '$app/navigation';
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';
let { data } = $props(); let { data } = $props();
let now = $state(new Date()); let now = $state(new Date());
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(() => {
let intervalId: number | undefined;
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
if ('wakeLock' in navigator && !wakeLock) {
try {
wakeLock = await navigator.wakeLock.request('screen');
console.log('Wake lock obtained');
} catch (error) {
console.warn('Wake lock request rejected: ', error);
}
}
};
const releaseWakeLock = async () => {
if (wakeLock) {
try {
await wakeLock.release();
console.log('Wake lock released');
} catch (error) {
console.error('Wake lock release failed: ', error);
}
wakeLock = null;
}
};
const startPolling = async () => {
await requestWakeLock();
if (intervalId) return;
intervalId = window.setInterval(async () => {
try {
console.log('Invalidating data');
await invalidateAll();
} catch (error) {
console.error('Background data fetch failed: ', error);
}
}, 20000);
console.log('Polling started');
};
const stopPolling = async () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = undefined;
console.log('Polling ended');
}
await releaseWakeLock();
};
const handleVisibilityChange = async () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
await invalidateAll();
}
};
if (!document.hidden) {
startPolling();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
});
onMount(() => { onMount(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -16,28 +94,28 @@
return () => clearInterval(interval); return () => clearInterval(interval);
}); });
// Update 'QuickLinks' const currentBoardId = $derived(
data.BoardLocation ? (data.BoardLocation.c ?? data.BoardLocation.t) : null
);
// Update 'QuickLinks' when currentBoardId changes
$effect(() => { $effect(() => {
if (data.BoardLocation) { if (currentBoardId) {
const id = data.BoardLocation?.c ?? data.BoardLocation?.t; // Keep the execution and logs inside untrack to isolate dependencies completely
if (id) {
// Untrack, as we do not need to handle changes to quickLinks - this is WRITE_ONLY
untrack(() => { untrack(() => {
quickLinks.recordVisit(id); 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
// Load Data Invalidation Handling
// Refresh countdown logic
</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}
@@ -45,7 +123,11 @@
{/if} {/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>
{:else}
<div class="no-service">
No services at this location in the next three hours
</div> </div>
{/if} {/if}
</section> </section>
@@ -58,10 +140,22 @@
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;
} }
.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 { .time-data {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -72,6 +166,56 @@
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

@@ -23,6 +23,18 @@
checked={prefs.current.ShowPassingTrains} checked={prefs.current.ShowPassingTrains}
onchange={(checked) => prefs.toggleShowPassingTrains(checked)} onchange={(checked) => prefs.toggleShowPassingTrains(checked)}
/> />
<Toggle
label={'Keep screen awake on live pages'}
id={'toggle-board-wake-lock'}
checked={prefs.current.BoardWakeLock}
onchange={(checked) => prefs.toggleBoardWakeLock(checked)}
/>
<Toggle
label={'Auto-refresh data'}
id={'toggle-auto-refresh'}
checked={prefs.current.AutoRefresh}
onchange={(checked) => prefs.toggleAutoRefresh(checked)}
/>
</div> </div>
</section> </section>
</BaseCard> </BaseCard>

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>