From 631d324cbaa2bf948ef9414c8323a32cdb351b81 Mon Sep 17 00:00:00 2001 From: Fred Boniface Date: Sat, 16 May 2026 21:06:29 +0100 Subject: [PATCH] Add preferences and privacy policy --- .../ui/cards/NearbyStationsCard.svelte | 6 +- .../components/ui/form-elements/Toggle.svelte | 112 +++++++++ src/lib/geohash.svelte.ts | 38 ++- src/lib/owlClient.ts | 4 +- src/lib/preferences.svelte.ts | 22 +- src/routes/+layout.svelte | 10 +- src/routes/preferences/+page.svelte | 54 +++++ src/routes/preferences/+page.ts | 5 + src/routes/privacy/+page.svelte | 218 ++++++++++++++++++ src/routes/privacy/+page.ts | 5 + 10 files changed, 457 insertions(+), 17 deletions(-) create mode 100644 src/lib/components/ui/form-elements/Toggle.svelte create mode 100644 src/routes/preferences/+page.svelte create mode 100644 src/routes/preferences/+page.ts create mode 100644 src/routes/privacy/+page.svelte create mode 100644 src/routes/privacy/+page.ts diff --git a/src/lib/components/ui/cards/NearbyStationsCard.svelte b/src/lib/components/ui/cards/NearbyStationsCard.svelte index ba391c5..2974176 100644 --- a/src/lib/components/ui/cards/NearbyStationsCard.svelte +++ b/src/lib/components/ui/cards/NearbyStationsCard.svelte @@ -2,6 +2,8 @@ import BaseCard from '$lib/components/ui/cards/BaseCard.svelte'; import Button from '$lib/components/ui/form-elements/Button.svelte'; + import { prefs } from '$lib/preferences.svelte'; + import { fade } from 'svelte/transition'; import { flip } from 'svelte/animate'; @@ -12,7 +14,9 @@
- {#if nearestStationsState.error && nearestStationsState.list.length === 0} + {#if !prefs.current.NearToMe} + + {:else if nearestStationsState.error && nearestStationsState.list.length === 0}

{nearestStationsState.error}

{:else if nearestStationsState.loading && nearestStationsState.list.length === 0}

Locating stations...

diff --git a/src/lib/components/ui/form-elements/Toggle.svelte b/src/lib/components/ui/form-elements/Toggle.svelte new file mode 100644 index 0000000..cc68eca --- /dev/null +++ b/src/lib/components/ui/form-elements/Toggle.svelte @@ -0,0 +1,112 @@ + + +
+ +
+ + +
+ + + +
+ + diff --git a/src/lib/geohash.svelte.ts b/src/lib/geohash.svelte.ts index 9f852f1..38cdd3f 100644 --- a/src/lib/geohash.svelte.ts +++ b/src/lib/geohash.svelte.ts @@ -1,12 +1,15 @@ import { OwlClient, ValidationError, ApiError } from './owlClient'; import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts'; +import { prefs } from './preferences.svelte'; class NearestStationsState { list = $state([]); currentHash = $state(''); - loading = $state(true); + loading = $state(false); error = $state(null); + private watcherId: number | null = null; + private initGeoConfig: PositionOptions = { enableHighAccuracy: false, timeout: 500, @@ -20,10 +23,35 @@ class NearestStationsState { }; constructor() { - if (typeof window !== 'undefined' && 'geolocation' in navigator) { - this.jumpstart(); - this.initWatcher(); + if (typeof window === 'undefined' || !('geolocation' in navigator)) return; + + $effect.root(() => { + $effect(() => { + if (prefs.current.NearToMe) { + this.startTracking(); + } else { + this.stopTracking(); + } + }); + }); + } + + private startTracking() { + if (this.watcherId !== null) return; // Already tracking + this.loading = true; + this.jumpstart(); + this.initWatcher(); + } + + private stopTracking() { + if (this.watcherId !== null) { + navigator.geolocation.clearWatch(this.watcherId); + this.watcherId = null; } + this.list = []; + this.currentHash = ''; + this.loading = false; + this.error = null; } private jumpstart() { @@ -35,7 +63,7 @@ class NearestStationsState { } private initWatcher() { - navigator.geolocation.watchPosition( + this.watcherId = navigator.geolocation.watchPosition( (pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude), (err) => this.handleError(err), this.geoConfig diff --git a/src/lib/owlClient.ts b/src/lib/owlClient.ts index 33d7198..7f3ef08 100644 --- a/src/lib/owlClient.ts +++ b/src/lib/owlClient.ts @@ -31,7 +31,7 @@ export function ThrowApiError(e: unknown): never { // Map ApiError 'code' values if (e instanceof ApiError) { - console.error(JSON.stringify(e), e.code, "ERRCODE") + console.error(JSON.stringify(e), e.code, 'ERRCODE'); let status = 500; if (e.code === 'AUTH') { status = 401; @@ -54,7 +54,7 @@ export function ThrowApiError(e: unknown): never { // Fallback for other error kind const genericMessage = e instanceof Error ? e.message : 'An unexpected error occurred.'; - console.log(e) + console.log(e); throw error(500, { message: genericMessage, name: 'CRITICAL_ERROR', diff --git a/src/lib/preferences.svelte.ts b/src/lib/preferences.svelte.ts index 9fe05db..cc1fb33 100644 --- a/src/lib/preferences.svelte.ts +++ b/src/lib/preferences.svelte.ts @@ -1,13 +1,15 @@ import { browser } from '$app/environment'; export interface Preferences { - locationEnabled: boolean; + NearToMe: boolean; + ShowPassingTrains: boolean; } const STORAGE_KEY = 'ob_pref'; const DEFAULT_PREFS: Preferences = { - locationEnabled: false + NearToMe: false, + ShowPassingTrains: true }; function loadPrefs(): Preferences { @@ -16,7 +18,13 @@ function loadPrefs(): Preferences { if (!stored) return DEFAULT_PREFS; try { - return { ...DEFAULT_PREFS, ...JSON.parse(stored) }; + const parsed = JSON.parse(stored); + const cleaned = {} as Preferences; + + for (const key of Object.keys(DEFAULT_PREFS) as Array) { + cleaned[key] = key in parsed ? parsed[key] : DEFAULT_PREFS[key]; + } + return cleaned; } catch { return DEFAULT_PREFS; } @@ -35,8 +43,12 @@ class PreferencesStore { }); } - toggleLocation(enabled: boolean) { - this.current.locationEnabled = enabled; + toggleNearToMe(enabled: boolean) { + this.current.NearToMe = enabled; + } + + toggleShowPassingTrains(enabled: boolean) { + this.current.ShowPassingTrains = enabled; } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 148cf2d..d6df465 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -20,7 +20,8 @@ IconDialpad, IconSettings, IconHelp, - IconDots + IconDots, + IconSpy, } from '@tabler/icons-svelte-runes'; onMount(async () => { @@ -47,13 +48,14 @@ { label: 'Home', path: '/', icon: IconHome }, { label: 'PIS', path: '/pis/', icon: IconDialpad }, { 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 }, ]; let navWidth = $state(0); let menuOpen = $state(false); - const ITEM_WIDTH = 70; - const MORE_BUTTON_WIDTH = 70; + const ITEM_WIDTH = 110; + const MORE_BUTTON_WIDTH = 110; const activePath = $derived(page.url?.pathname ?? ''); diff --git a/src/routes/preferences/+page.svelte b/src/routes/preferences/+page.svelte new file mode 100644 index 0000000..62a0c0e --- /dev/null +++ b/src/routes/preferences/+page.svelte @@ -0,0 +1,54 @@ + + +
+ +
+ +
+ prefs.toggleNearToMe(checked)} + /> + prefs.toggleShowPassingTrains(checked)} + /> +
+
+
+
+ + diff --git a/src/routes/preferences/+page.ts b/src/routes/preferences/+page.ts new file mode 100644 index 0000000..ec6fcea --- /dev/null +++ b/src/routes/preferences/+page.ts @@ -0,0 +1,5 @@ +export const load = () => { + return { + title: 'Preferences' + }; +}; diff --git a/src/routes/privacy/+page.svelte b/src/routes/privacy/+page.svelte new file mode 100644 index 0000000..448ca45 --- /dev/null +++ b/src/routes/privacy/+page.svelte @@ -0,0 +1,218 @@ + + +
+

Last updated: May 2026

+ +

+ The OwlBoard project is built to help you track trains, not track you. Here is exactly what data + is used, how it is used, and why. +

+ +
+ +
+

1. The Data Processed (And What Is Discarded)

+ +
    +
  • + Email Addresses: During sign-up, an email address 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. + The moment the verification code is sent, the email address is permanently deleted + from the system. It is not stored, it is not logged, and it cannot be looked up later. +
  • +
  • + Platform Identity: Instead of an email, an account + is identified by two things: +
      +
    1. + A unique alphanumeric "key" generated randomly for the + device upon verification. +
    2. +
    3. The domain portion of the registration email.
    4. +
    +
    + Example: If signing up as user@owlboard.info, the email is + wiped, and the account identifier becomes [Your-Secret-Key] @ owlboard.info. + This allows OwlBoard to manage access without knowing who you are. +
    +
  • +
  • + Fuzzy Location Data: If the "Near to Me" toggle is + enabled, exact GPS coordinates are calculated, but never leave your device. Your device compresses the position + into a "fuzzy location block" (a geohash) roughly 1.2km + by 0.6km in size before contacting the server to request nearby stations. OwlBoard only processes + that neighborhood block—never an exact location. +
  • +
  • + Server Logs & IP Addresses: Like any web application, + self-hosted security tools and the Web Application Firewall (WAF) temporarily process network + IP addresses. This is strictly for system health, infrastructure monitoring, and blocking malicious or + automated attacks. +
  • +
+
+ +
+ +
+

2. What Is Considered "Personal Data"?

+ +

+ Under the GDPR, "Personal Data" is anything that can directly or indirectly pinpoint a living + individual. +

+

+ Because email addresses are discarded instantly and location hardware telemetry is fuzzed + directly on the device, station boards, preferences, and account keys are completely pseudonymized. However, because network IP addresses are technically classified as personal data under European + law, transient security logs are treated with the highest level of guardrail protection. +

+
+ +
+ +
+

+ 3. Subject Access Requests (SAR) & The Right to be Forgotten +

+ +

+ Absolute sovereignty over your digital footprint should be standard, without jumping through + bureaucratic hoops. There is no need to send an email to exercise GDPR rights: +

+ +
    +
  • + Instant SAR (Right of Access): The + Account dashboard contains a dedicated section displaying + every single line of data which can be identified with the account key block. It can be viewed or copied instantly. +
  • +
  • + The Right to be Forgotten (Instant Erasure): A + prominent "Delete Account" 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. +
  • +
+

+ While the pseudonymized settings left behind are not technically personal data once + disconnected from an identity, the OwlBoard project operates on a simple philosophy. + When the delete button is pressed, the data is erased. +

+
+ +
+ +
+

4. No Third-Party Conglomerates

+ +

+ All data is hosted transparently on isolated, self-hosted server infrastructure in Somerset. OwlBoard does + not use third-party analytics trackers, tracking cookies, or advertising networks. +

+
+
+ + diff --git a/src/routes/privacy/+page.ts b/src/routes/privacy/+page.ts new file mode 100644 index 0000000..3213243 --- /dev/null +++ b/src/routes/privacy/+page.ts @@ -0,0 +1,5 @@ +export const load = () => { + return { + title: 'Privacy' + }; +};