Initial grid staff view layout

This commit is contained in:
2026-05-21 23:56:59 +01:00
parent d10ca44023
commit 0eb2f4349c
7 changed files with 240 additions and 264 deletions

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 } from '$lib/utils/time';
import { fade } from 'svelte/transition'; import { fade, slide } from 'svelte/transition';
import { flip } from 'svelte/animate';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props(); let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
@@ -11,168 +12,122 @@
<section class="departure-board"> <section class="departure-board">
<div class="header"> <div class="header">
<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">
<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">
{#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">
{#key realPass}
<div transition:fade={{ duration: 300 }}>
{realPass}
</div>
{/key}
</div>
{:else}
<div class="cell sch">{formatUkTime(service.sta)}</div>
<div class="cell act">
{#key realArrival}
<div transition:fade={{ duration: 300 }}>
{realArrival}
</div>
{/key}
</div>
<div class="cell sch">{formatUkTime(service.std)}</div>
<div class="cell act">
{#key realDeparture}
<div transition:fade={{ duration: 300 }}>
{realDeparture}
</div>
{/key}
</div>
{/if}
</div>
<div class="operator row">{service.o}</div>
{#if service?.cr?.r}
<div class="cancel-reason row" transition:slide={{ duration: 300 }}>{service.cr.r}</div>
{: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>
{:else 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; overflow: hidden;
}
.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-block {
width: 100%;
max-width: 100%;
padding-bottom: 7px;
} }
.departure-board td { .departure-board td {
@@ -222,146 +177,136 @@
} }
/* 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;
} }
.delay-row td { .delay-reason.row {
color: var(--delay-orange); color: var(--delay-orange);
font-style: italic; font-style: italic;
} }
@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: 200;
opacity: 0.3; 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 { .service-row.serviceCancelled .plt-cell {
@@ -377,27 +322,36 @@
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%;
} }
@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%;
} }
} }
@@ -419,12 +373,12 @@
} }
/* 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 {
font-weight: 600; font-weight: 600;
} }
</style> </style>

View File

@@ -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

@@ -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

@@ -24,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);
} }
} }
}; };
@@ -35,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;
} }
@@ -48,20 +48,20 @@
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);
} }
}, 20000); }, 20000);
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();
}; };
@@ -85,7 +85,7 @@
stopPolling(); stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}) });
onMount(() => { onMount(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -114,6 +114,7 @@ $effect(() => {
// 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">
<div class="loadedlive"> <div class="loadedlive">
@@ -177,17 +178,31 @@ $effect(() => {
div.isLive { div.isLive {
background-color: #10b981; background-color: #10b981;
animation: animation: led-pulse 2.5s infinite ease-in-out;
led-pulse 2.5s infinite ease-in-out;
} }
@keyframes led-blink { @keyframes led-blink {
0%,100%{opacity:1;} 0%,
50%{opacity:0.2;filter:saturate(0.2)} 100% {
opacity: 1;
}
50% {
opacity: 0.2;
filter: saturate(0.2);
}
} }
@keyframes led-pulse { @keyframes led-pulse {
0%,100%{opacity: 1;filter:saturate(1);box-shadow: 0 0 6px 1px rgba(16,185,129,0.4)} 0%,
50%{opacity: 0.2;filter:saturate(0.2);box-shadow:0 0 2px 0px rgba(16,185,129,0.1)} 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,

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>