spotters: most-active-in sort with country/region selector, collection tracking (seen marks, progress, filters), refresh scripts score activity and report new NL entries
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
stan committed 2026-10-25 13:24:37 +00:00
1 parent 3f294df337
commit 253cd69424
58 files changed
+2151 -72

No files matched your search

+26 -3
View File
@@ -1,18 +1,41 @@
import { carOverview, PAGE_SIZE, parseFilters, searchCars } from '#lib/server/cars.js';
import {
areasWithData,
groupActivity,
progress,
rankByActivity,
seenByGroup
} from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ cars, total }, overview] = await Promise.all([searchCars(filters), carOverview()]);
const [{ cars, total }, overview, collected, seenBrands, areas, act] = await Promise.all([
searchCars(filters),
carOverview(),
progress('cars'),
seenByGroup('cars'),
areasWithData('cars'),
groupActivity('cars', filters.area)
]);
return {
filters,
cars,
total,
pageSize: PAGE_SIZE,
collected,
areas,
brands: overview.brands.map(({ slug, name, models }) => ({ slug, name, models })),
topBrands: overview.byNl
topBrands: rankByActivity(overview.byNl, act)
.slice(0, 16)
.map(({ slug, name, models, nlCount, logo }) => ({ slug, name, models, nlCount, logo })),
.map(({ id, slug, name, models, nlCount, logo }) => ({
slug,
name,
models,
nlCount,
logo,
seen: seenBrands.get(id) ?? 0
})),
stats: overview.stats,
currentYear: new Date().getFullYear(),
seo: {
+30 -4
View File
@@ -14,6 +14,10 @@
LOGO_NOTE,
SORTS
} from '#lib/cars/shared.js';
import AreaSelect from '#lib/spotting/AreaSelect.svelte';
import { GOT } from '#lib/spotting/areas.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Search from 'lucide-svelte/icons/search';
@@ -26,7 +30,7 @@
const f = $derived(data.filters);
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
const filtering = $derived(
!!(f.q || f.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to)
!!(f.q || f.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to || f.got)
);
let form: HTMLFormElement | undefined = $state();
@@ -51,13 +55,14 @@
return `/trackers/cars${qs ? `?${qs}` : ''}`;
}
const quick = [
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '📖 dex order', params: { sort: 'dex' } },
{ label: '⚡ electric', params: { fuel: 'electric', sort: 'popular' } },
{ label: '🍃 hybrids', params: { fuel: 'hybrid', sort: 'popular' } },
{ label: '🏎️ coupés', params: { body: 'coupe', sort: 'popular' } },
{ label: '💪 most powerful', params: { sort: 'power' } },
{ label: '🐎 Mustang', params: { q: 'mustang' } }
{ label: '🐎 Mustang', params: { q: 'mustang' } },
{ label: '⬜ still to go', params: { got: 'no' } }
];
</script>
@@ -94,6 +99,15 @@
</div>
</dl>
<div class="mb-8">
<SpotProgress
seen={data.collected.seen}
total={data.collected.total}
label="models collected"
href={withParams({ got: 'no' })}
/>
</div>
{#if !filtering && f.page === 1}
<section class="mb-8" aria-labelledby="brands-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
@@ -184,6 +198,14 @@
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
>{/each}
</NativeSelect>
{#if f.sort === 'active'}<AreaSelect
value={f.area}
available={data.areas}
onchange={submit}
/>{/if}
<NativeSelect name="got" aria-label="Collection" value={f.got} onchange={submit}>
{#each Object.entries(GOT) as [key, label] (key)}<option value={key}>{label}</option>{/each}
</NativeSelect>
<noscript><button type="submit" class="pill">search</button></noscript>
</div>
</form>
@@ -206,7 +228,11 @@
{#if data.cars.length}
<ul class="grid">
{#each data.cars as car, i (car.dex)}
<li><CarCard {car} current={data.currentYear} eager={i < 4} /></li>
<li>
<SpotItem tracker="cars" id={car.id} seen={car.seen} label={car.name}>
<CarCard {car} current={data.currentYear} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
{:else}
+21 -2
View File
@@ -10,11 +10,18 @@
thumbSrcset,
years
} from '#lib/cars/shared.js';
import { page } from '$app/state';
import { parseGot } from '#lib/spotting/areas.js';
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const b = $derived(data.brand);
const got = $derived(parseGot(page.url.searchParams.get('got')));
const shown = $derived(filterGot('cars', data.models, got));
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -57,9 +64,21 @@
</div>
</header>
<div class="mb-6">
<SpotProgress
seen={countSeen('cars', data.models)}
total={data.models.length}
label="{b.name} models collected"
{got}
/>
</div>
<ul class="grid">
{#each data.models as car, i (car.dex)}
<li><CarCard {car} current={data.currentYear} eager={i < 4} /></li>
{#each shown as car, i (car.dex)}
<li>
<SpotItem tracker="cars" id={car.id} seen={car.seen} label={car.name}>
<CarCard {car} current={data.currentYear} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
<p class="mt-10 text-xs text-[var(--site-muted)]">
@@ -1,6 +1,7 @@
import { error } from '@sveltejs/kit';
import { getModel } from '#lib/server/cars.js';
import type { carModels, carVariants } from '#lib/server/db/schema/index.js';
import { getSeen } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
type Model = typeof carModels.$inferSelect;
@@ -32,7 +33,9 @@ export const load: PageServerLoad = async ({ params }) => {
const found = await getModel(params.brand, params.model);
if (!found) error(404, 'No such car in the pokedex');
const { model, brand, variants, photos, prev, next } = found;
const seenEntry = await getSeen('cars', model.id);
return {
spot: { id: model.id, entry: seenEntry },
car: publicCar(model),
brand: { slug: brand.slug, name: brand.name, country: brand.country, logo: brand.logo },
variants: variants.map(publicVariant),
@@ -19,6 +19,7 @@
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Shuffle from 'lucide-svelte/icons/shuffle';
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -174,6 +175,7 @@
</div>
{/if}
</header>
<SeenPanel tracker="cars" id={data.spot.id} entry={data.spot.entry} label="this one" />
{#if data.photos.length}
<Gallery photos={data.photos} name="{data.brand.name} {car.name}" wikipedia={car.wikipedia} />
@@ -1,10 +1,12 @@
import { carOverview } from '#lib/server/cars.js';
import { seenByGroup } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { byNl, stats } = await carOverview();
const [{ byNl, stats }, seen] = await Promise.all([carOverview(), seenByGroup('cars')]);
return {
brands: byNl.map(({ slug, name, models, nlCount, logo }) => ({
brands: byNl.map(({ id, slug, name, models, nlCount, logo }) => ({
seen: seen.get(id) ?? 0,
slug,
name,
models,
+14
View File
@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { isOwner } from '#lib/server/owner.js';
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
import type { RequestHandler } from './$types';
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
export const POST: RequestHandler = async (event) => {
if (!isOwner(event)) error(404, 'Not Found');
const body = parseSeenBody(await event.request.json().catch(() => null));
if (!body) error(400, 'Bad request');
const ok = await setSeen('cars', body.id, body.seen, body.seenOn, body.note);
if (!ok) error(404, 'No such entry');
return json({ ok: true, ...body });
};