Commit: 2fef7a8
Parent: 91350ce

Add GPS: consent dialog, position tracking, coords stored with detections

Mårten Åsberg committed on 2026-05-24 at 11:45
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
src/lib/birdnet.svelte.ts +2 -1
diff --git a/src/lib/birdnet.svelte.ts b/src/lib/birdnet.svelte.ts
index 70d24bb..eb266cf 100644
@@ -1,6 +1,7 @@
import BirdNetWorker from "./birdnet.worker.ts?worker";
import type { Prediction, WorkerInMessage, WorkerOutMessage } from "./types";
import { addDetection } from "./history";
import { gps } from "./gps.svelte";
class BirdNetService {
workerReady = $state(false);
@@ -18,7 +19,7 @@ class BirdNetService {
this.workerReady = true;
} else if (msg.type === "results") {
this.predictions = msg.predictions;
for (const p of msg.predictions) addDetection(p);
for (const p of msg.predictions) addDetection(p, gps.coords);
}
},
);
src/lib/components/GpsDialog.svelte +136 -0
diff --git a/src/lib/components/GpsDialog.svelte b/src/lib/components/GpsDialog.svelte
new file mode 100644
index 0000000..7159724
@@ -0,0 +1,136 @@
<script lang="ts">
import { gps } from "$lib/gps.svelte";
let requesting = $state(false);
function handleGrant() {
requesting = true;
gps.grant();
}
</script>
{#if gps.consent === null}
<dialog
{@attach (node) => {
node.showModal();
return () => {
if (node.open) node.close();
};
}}
>
<div class="panel">
<div class="title-bar">Record bird locations?</div>
<p>
BirdGO can tag each detection with your GPS position. Your location
never leaves this device.
</p>
<div class="actions">
<button class="primary" onclick={handleGrant} disabled={requesting}>
{requesting ? "Requesting…" : "Enable GPS"}
</button>
<button class="secondary" onclick={() => gps.deny()}>
Skip for now
</button>
</div>
</div>
</dialog>
{/if}
<style>
dialog {
border: none;
background: transparent;
padding: 0;
max-width: 26rem;
width: calc(100% - 2rem);
}
dialog::backdrop {
background: rgba(26, 8, 8, 0.65);
}
.panel {
background: var(--bg-paper);
border: 2px solid var(--bg-near-black);
border-radius: 8px;
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
inset: 5px;
border-radius: 4px;
border: 1.5px dashed var(--accent-amber);
pointer-events: none;
z-index: 1;
}
}
.title-bar {
background: var(--brand-red);
color: var(--bg-cream);
padding: 0.6rem 1.25rem;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
position: relative;
&::after {
content: "";
position: absolute;
inset: 4px;
border: 1.5px dashed var(--bg-cream);
pointer-events: none;
}
}
p {
padding: 1rem 1.25rem;
font-size: 0.95rem;
line-height: 1.5;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0 1.25rem 1.25rem;
}
.primary {
background: var(--brand-red);
color: var(--bg-cream);
border: none;
border-radius: 8px;
padding: 0.65rem 1rem;
font-size: 1rem;
font-weight: 700;
font-family: inherit;
box-shadow: 0 3px 0 var(--bg-near-black);
cursor: pointer;
transition: box-shadow 0.1s, transform 0.1s;
&:active:not(:disabled) {
transform: translateY(3px);
box-shadow: none;
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.secondary {
background: transparent;
color: var(--bg-near-black);
border: 2px solid var(--bg-near-black);
border-radius: 8px;
padding: 0.65rem 1rem;
font-size: 1rem;
font-family: inherit;
cursor: pointer;
}
</style>
src/lib/components/HistoryEntry.svelte +87 -0
diff --git a/src/lib/components/HistoryEntry.svelte b/src/lib/components/HistoryEntry.svelte
new file mode 100644
index 0000000..6fbf170
@@ -0,0 +1,87 @@
<script lang="ts">
import type { HistoryEntry } from "$lib/types";
let { entry, name }: { entry: HistoryEntry; name?: string } = $props();
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<li class:has-name={!!name}>
{#if name}
<span class="name">{name}</span>
{/if}
<div class="meta">
<span class="confidence">{(entry.confidence * 100).toFixed(0)}%</span>
<span class="when">
<span>{formatDate(entry.detectedAt)}</span>
<span>{formatTime(entry.detectedAt)}</span>
</span>
</div>
</li>
<style>
li {
background: var(--bg-paper);
border: 2px solid var(--bg-near-black);
border-radius: 8px;
padding: 0.75rem 1rem;
position: relative;
display: flex;
justify-content: flex-end;
align-items: center;
&.has-name {
justify-content: space-between;
}
&::before {
content: "";
position: absolute;
inset: 5px;
border-radius: 4px;
border: 1.5px dashed var(--accent-amber);
pointer-events: none;
}
.name {
font-weight: 700;
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
}
.confidence {
color: var(--brand-red);
font-weight: 600;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.when {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.05rem;
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
opacity: 0.6;
}
}
</style>
src/lib/gps.svelte.ts +68 -0
diff --git a/src/lib/gps.svelte.ts b/src/lib/gps.svelte.ts
new file mode 100644
index 0000000..f381935
@@ -0,0 +1,68 @@
import type { Coords } from "./types";
const CONSENT_KEY = "birdgo-gps-consent";
class GpsService {
consent = $state<"granted" | "denied" | null>(null);
position = $state<GeolocationCoordinates | null>(null);
#watchId: number | null = null;
constructor() {
const stored = localStorage.getItem(CONSENT_KEY);
if (stored === "granted") {
this.consent = "granted";
this.#startWatching();
} else if (stored === "denied") {
this.consent = "denied";
}
// null/missing → first visit, dialog will show
}
grant(): void {
if (!navigator.geolocation) {
this.deny();
return;
}
navigator.geolocation.getCurrentPosition(
() => {
this.consent = "granted";
localStorage.setItem(CONSENT_KEY, "granted");
this.#startWatching();
},
() => this.deny(),
);
}
deny(): void {
this.consent = "denied";
this.#stopWatching();
localStorage.setItem(CONSENT_KEY, "denied");
}
get coords(): Coords | undefined {
if (!this.position) return undefined;
return { lat: this.position.latitude, lng: this.position.longitude };
}
#startWatching(): void {
if (!navigator.geolocation || this.#watchId !== null) return;
this.#watchId = navigator.geolocation.watchPosition(
(pos) => {
this.position = pos.coords;
},
() => {},
{ enableHighAccuracy: true },
);
}
#stopWatching(): void {
if (this.#watchId !== null) {
navigator.geolocation.clearWatch(this.#watchId);
this.#watchId = null;
}
this.position = null;
}
}
export const gps = new GpsService();
src/lib/history.ts +6 -2
diff --git a/src/lib/history.ts b/src/lib/history.ts
index b2d02f1..29c9193 100644
@@ -1,5 +1,5 @@
import { writable } from "svelte/store";
import type { HistoryEntry, Prediction } from "./types";
import type { Coords, HistoryEntry, Prediction } from "./types";
const STORAGE_KEY = "birdgo-history";
@@ -31,7 +31,10 @@ export function endSession() {
sessionSeen = new Set();
}
export function addDetection(p: Pick<Prediction, "labelIndex" | "confidence">) {
export function addDetection(
p: Pick<Prediction, "labelIndex" | "confidence">,
coords?: Coords,
) {
if (p.confidence < 0.7) return;
if (sessionSeen.has(p.labelIndex)) return;
sessionSeen.add(p.labelIndex);
@@ -40,6 +43,7 @@ export function addDetection(p: Pick<Prediction, "labelIndex" | "confidence">) {
labelIndex: p.labelIndex,
confidence: p.confidence,
detectedAt: new Date().toISOString(),
coords,
},
...entries,
]);
src/lib/types.ts +6 -0
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 1c40f4e..fb8a14d 100644
@@ -8,10 +8,16 @@ export interface Prediction {
confidence: number;
}
export interface Coords {
lat: number;
lng: number;
}
export interface HistoryEntry {
labelIndex: number;
confidence: number;
detectedAt: string; // ISO 8601
coords?: Coords;
}
export type WorkerInMessage =
src/routes/+layout.svelte +18 -0
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index afc502d..233ac22 100644
@@ -2,6 +2,7 @@
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import favicon from "$lib/assets/favicon.svg";
import GpsDialog from "$lib/components/GpsDialog.svelte";
let { children } = $props();
</script>
@@ -11,6 +12,8 @@
<meta name="theme-color" content="#3a1810" />
</svelte:head>
<GpsDialog />
<main>
{@render children()}
</main>
@@ -34,6 +37,21 @@
</li>
<li>
<a
href={resolve("/guide")}
aria-label="Field Guide"
aria-current={$page.url.pathname.startsWith("/guide") ? "page" : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="5" y="2" width="14" height="20" rx="2" fill="currentColor" />
<rect x="5" y="2" width="3.5" height="20" fill="var(--accent-amber)" />
<rect x="11" y="7" width="6" height="1.5" fill="var(--bg-deep)" />
<rect x="11" y="11" width="6" height="1.5" fill="var(--bg-deep)" />
<rect x="11" y="15" width="4" height="1.5" fill="var(--bg-deep)" />
</svg>
</a>
</li>
<li>
<a
href={resolve("/history")}
aria-label="History"
aria-current={$page.url.pathname === "/history" ? "page" : undefined}
src/routes/guide/+page.svelte +148 -0
diff --git a/src/routes/guide/+page.svelte b/src/routes/guide/+page.svelte
new file mode 100644
index 0000000..3b53cbc
@@ -0,0 +1,148 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { historyStore } from "$lib/history";
import { labelsStore } from "$lib/labels";
import PatchBanner from "$lib/components/PatchBanner.svelte";
let search = $state("");
const detectedSet = $derived(new Set($historyStore.map((e) => e.labelIndex)));
const allBirds = $derived($labelsStore.map((label, index) => ({ label, index })));
const filtered = $derived(
search.trim() === ""
? allBirds
: allBirds.filter(
({ label }) =>
label.commonName.toLowerCase().includes(search.toLowerCase()) ||
label.scientificName.toLowerCase().includes(search.toLowerCase()),
),
);
</script>
<PatchBanner text="Field Guide" />
<div class="search-wrap">
<input
type="search"
bind:value={search}
placeholder="Search {$labelsStore.length} species…"
aria-label="Search species"
/>
</div>
{#if $labelsStore.length === 0}
<p class="loading">Loading guide…</p>
{:else}
<div class="grid">
{#each filtered as { label, index } (index)}
{@const detected = detectedSet.has(index)}
{#if detected}
<a href={resolve(`/guide/${index}`)} class="card">
<span class="num">#{String(index + 1).padStart(4, "0")}</span>
<span class="name">{label.commonName}</span>
</a>
{:else}
<div class="card unseen">
<span class="num">#{String(index + 1).padStart(4, "0")}</span>
<span class="name">{label.commonName}</span>
</div>
{/if}
{/each}
</div>
{/if}
<style>
.search-wrap {
padding: 0.75rem 1rem 0;
}
input[type="search"] {
width: 100%;
background: var(--bg-paper);
border: 2px solid var(--bg-near-black);
border-radius: 8px;
padding: 0.6rem 0.75rem;
color: var(--bg-near-black);
font-size: 1rem;
font-family: inherit;
outline: none;
&::placeholder {
color: var(--accent-rust);
opacity: 0.7;
}
&:focus {
outline: 2px solid var(--brand-red);
outline-offset: -2px;
}
}
.loading {
padding: 3rem 1.5rem;
text-align: center;
color: var(--accent-rust);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
padding: 0.75rem 1rem;
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 1rem);
}
.card {
background: var(--bg-paper);
border: 2px solid var(--bg-near-black);
border-radius: 8px;
padding: 0.6rem 0.75rem;
position: relative;
display: flex;
flex-direction: column;
gap: 0.2rem;
text-decoration: none;
color: var(--bg-near-black);
content-visibility: auto;
contain-intrinsic-block-size: 5rem;
&::before {
content: "";
position: absolute;
inset: 4px;
border-radius: 4px;
border: 1.5px dashed var(--accent-amber);
pointer-events: none;
}
&.unseen {
opacity: 0.35;
pointer-events: none;
}
&:not(.unseen):hover {
background: var(--bg-cream);
}
.num {
font-size: 0.6rem;
font-variant-numeric: tabular-nums;
color: var(--accent-rust);
font-weight: 700;
letter-spacing: 0.05em;
}
.name {
font-size: 0.8rem;
font-weight: 700;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
</style>
src/routes/guide/[labelIndex]/+page.svelte +71 -0
diff --git a/src/routes/guide/[labelIndex]/+page.svelte b/src/routes/guide/[labelIndex]/+page.svelte
new file mode 100644
index 0000000..5c8c393
@@ -0,0 +1,71 @@
<script lang="ts">
import { historyStore } from "$lib/history";
import { labelsStore } from "$lib/labels";
import PatchBanner from "$lib/components/PatchBanner.svelte";
import HistoryEntry from "$lib/components/HistoryEntry.svelte";
let { data } = $props();
const label = $derived($labelsStore[data.labelIndex]);
const detections = $derived(
$historyStore.filter((e) => e.labelIndex === data.labelIndex),
);
</script>
<PatchBanner text={label?.commonName ?? "Unknown Bird"} />
<div class="species-info">
{#if label}
<p class="scientific">{label.scientificName}</p>
{/if}
<p class="stat">
{detections.length}
{detections.length === 1 ? "detection" : "detections"}
</p>
</div>
{#if detections.length > 0}
<ol>
{#each detections as entry (entry.detectedAt)}
<HistoryEntry {entry} />
{/each}
</ol>
{:else}
<p class="empty">Not detected yet. Go listen!</p>
{/if}
<style>
.species-info {
padding: 1.25rem 1rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.scientific {
font-style: italic;
font-size: 0.9rem;
opacity: 0.7;
}
.stat {
font-weight: 700;
color: var(--brand-red);
}
.empty {
padding: 3rem 1.5rem;
text-align: center;
color: var(--accent-rust);
}
ol {
list-style: none;
padding: 1rem;
padding-top: 0.75rem;
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 1rem);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>
src/routes/guide/[labelIndex]/+page.ts +5 -0
diff --git a/src/routes/guide/[labelIndex]/+page.ts b/src/routes/guide/[labelIndex]/+page.ts
new file mode 100644
index 0000000..5411290
@@ -0,0 +1,5 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = ({ params }) => ({
labelIndex: parseInt(params.labelIndex, 10),
});
src/routes/history/+page.svelte +5 -74
diff --git a/src/routes/history/+page.svelte b/src/routes/history/+page.svelte
index 068a8fe..10cc2f8 100644
@@ -2,21 +2,7 @@
import { historyStore } from "$lib/history";
import { labelsStore } from "$lib/labels";
import PatchBanner from "$lib/components/PatchBanner.svelte";
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
import HistoryEntry from "$lib/components/HistoryEntry.svelte";
</script>
<PatchBanner text="Field Log" />
@@ -26,16 +12,10 @@
{:else}
<ol>
{#each $historyStore as entry (entry.detectedAt + entry.labelIndex)}
<li>
<span class="name">{$labelsStore[entry.labelIndex]?.commonName ?? "Unknown"}</span>
<div class="meta">
<span class="confidence">{(entry.confidence * 100).toFixed(0)}%</span>
<span class="when">
<span>{formatDate(entry.detectedAt)}</span>
<span>{formatTime(entry.detectedAt)}</span>
</span>
</div>
</li>
<HistoryEntry
{entry}
name={$labelsStore[entry.labelIndex]?.commonName ?? "Unknown"}
/>
{/each}
</ol>
{/if}
@@ -45,7 +25,6 @@
padding: 3rem 1.5rem;
text-align: center;
color: var(--accent-rust);
font-size: 1rem;
}
ol {
@@ -56,53 +35,5 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
li {
background: var(--bg-paper);
border: 2px solid var(--bg-near-black);
border-radius: 8px;
padding: 0.75rem 1rem;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
&::before {
content: "";
position: absolute;
inset: 5px;
border-radius: 4px;
border: 1.5px dashed var(--accent-amber);
pointer-events: none;
}
.name {
font-weight: 700;
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
}
.confidence {
color: var(--brand-red);
font-weight: 600;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.when {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.05rem;
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
opacity: 0.6;
}
}
}
</style>