2 Commits

7 changed files with 190 additions and 26 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
let isNotLondon = $state(false); let isNotLondon = $state(false);
let londonZone = $state('Greenwich Mean Time'); let londonZone = $state('Greenwich Mean Time');
@@ -19,24 +19,26 @@
</script> </script>
{#if isNotLondon} {#if isNotLondon}
<div transition:slide={{duration: 300}} class="tzWarn"><p class="tzText"> <div transition:slide={{ duration: 300 }} class="tzWarn">
All times are shown in <strong>{londonZone}</strong> <p class="tzText">
</p></div> All times are shown in <strong>{londonZone}</strong>
</p>
</div>
{/if} {/if}
<style> <style>
.tzWarn { .tzWarn {
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
padding: 1rem 0 0 0; padding: 1rem 0 0 0;
} }
.tzText { .tzText {
width: 80%; width: 80%;
text-align: center; text-align: center;
margin: auto; margin: auto;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
font-size: 1.2rem; font-size: 1.2rem;
} }
</style> </style>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { quickLinks } from '$lib/quick-links.svelte';
const flipDuration = 300;
</script>
<BaseCard header={'Quick Links'}>
<div class="card-content">
{#if quickLinks.list.length === 0}
<p class="msg">Your most viewed stations will appear here</p>
{:else}
<div class="stations-flex">
{#each quickLinks.list as station (station.id)}
<div
class="btn-container"
animate:flip={{ duration: flipDuration }}
in:fade|global={{ duration: 200 }}
>
<Button href={`/board?loc=${station.id}`}
><span class="stn-name">{station.id}</span></Button
>
</div>
{/each}
</div>
{/if}
</div>
</BaseCard>
<style>
.card-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
width: 90%;
min-height: 98px;
margin: auto;
padding: 10px 0 10px 0;
}
.stations-flex {
display: flex;
flex-wrap: wrap;
gap: 0.1rem 0.5rem;
justify-content: center;
align-items: flex-start;
}
.btn-container {
display: block;
width: fit-content;
will-change: transform;
}
.msg {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-title);
}
.stn-name {
text-transform: capitalize;
}
</style>

View File

@@ -7,10 +7,16 @@ class NearestStationsState {
loading = $state(true); loading = $state(true);
error = $state<string | null>(null); error = $state<string | null>(null);
private initGeoConfig: PositionOptions = {
enableHighAccuracy: false,
timeout: 500,
maximumAge: Infinity,
}
private geoConfig: PositionOptions = { private geoConfig: PositionOptions = {
enableHighAccuracy: false, enableHighAccuracy: false,
timeout: 30000, timeout: 20000,
maximumAge: 120000 maximumAge: 30000,
}; };
constructor() { constructor() {
@@ -24,7 +30,7 @@ class NearestStationsState {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude), (pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err), (err) => this.handleError(err),
this.geoConfig this.initGeoConfig
); );
} }

View File

@@ -0,0 +1,60 @@
export interface QuickLink {
id: string;
score: number;
lastAccessed: number;
}
const RETURNED_LENGTH: number = 9;
const MAX_SCORE: number = 50;
const MAX_ENTRIES: number = RETURNED_LENGTH * 4;
class QuickLinksService {
#links = $state<QuickLink[]>([]);
constructor() {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ql');
if (saved) {
this.#links = JSON.parse(saved);
}
}
}
get list(): QuickLink[] {
return this.#links.slice(0, RETURNED_LENGTH);
}
recordVisit(id: string) {
if (id == '') return;
const existing = this.#links.find((l) => l.id === id);
if (existing) {
existing.score += 1;
existing.lastAccessed = Date.now();
} else {
this.#links.push({
id: id,
score: 1,
lastAccessed: Date.now()
});
}
// Score decay - if MAX_SCORE reached, divide all by two
if (this.#links.some((l) => l.score > MAX_SCORE)) {
this.#links.forEach((l) => {
l.score = Math.max(1, Math.floor(l.score / 2));
});
}
// Sort & Prune
const sorted = [...this.#links].sort(
(a, b) => b.score - a.score || b.lastAccessed - a.lastAccessed
);
this.#links = sorted.slice(0, MAX_ENTRIES);
localStorage.setItem('ql', JSON.stringify(this.#links));
}
}
export const quickLinks = new QuickLinksService();

View File

@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte'; import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte'; import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
import QuickLinksCard from '$lib/components/ui/cards/QuickLinksCard.svelte';
</script> </script>
<div class="card-container"> <div class="card-container">
<LocationBoardCard /> <LocationBoardCard />
<NearbyStationsCard /> <NearbyStationsCard />
<QuickLinksCard />
</div> </div>
<style> <style>

View File

@@ -1,3 +1,24 @@
<script lang="ts">
import { untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte';
let { data } = $props();
// Update 'QuickLinks'
$effect(() => {
if (data.BoardLocation) {
const id = data.BoardLocation?.c ?? data.BoardLocation?.t;
if (id) {
// Untrack, as we do not need to handle changes to quickLinks - this is WRITE_ONLY
untrack(() => {
quickLinks.recordVisit(id);
console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
});
}
}
});
</script>
<section>Live board are not yet implemented on the server</section> <section>Live board are not yet implemented on the server</section>
<style> <style>

View File

@@ -18,11 +18,13 @@ export const load: PageLoad = async ({ url }) => {
}); });
} }
if (locId) { let BoardLocation;
const location = LOCATIONS.find(locId);
if (location) { if (locId) {
title = location.n || location.t; BoardLocation = LOCATIONS.find(locId);
if (BoardLocation) {
title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
} else { } else {
error(404, { error(404, {
message: `Location (${locId.toUpperCase()}) not found`, message: `Location (${locId.toUpperCase()}) not found`,
@@ -32,6 +34,6 @@ export const load: PageLoad = async ({ url }) => {
} }
return { return {
title, title,
location BoardLocation
}; };
}; };