spotters: most-active-in sort with country/region selector, collection tracking (seen marks, progress, filters), refresh scripts score activity and report new NL entries
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -1,14 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Weekly-ish refresh of the car pokedex (/trackers/cars) on stan-vm.
|
# Weekly-ish refresh of the car pokedex (/trackers/cars) on stan-ovh.
|
||||||
#
|
#
|
||||||
# ssh stan-vm 'cd ~/dev/website-cars && git fetch -q && git checkout -q --detach origin/main \
|
# ssh stan@37.187.157.191 'cd ~/dev/website-cars && git fetch -q && git checkout -q --detach origin/main \
|
||||||
# && bun install --frozen-lockfile >/dev/null && scripts/cars-refresh.sh'
|
# && bun install --frozen-lockfile >/dev/null && scripts/cars-refresh.sh'
|
||||||
#
|
#
|
||||||
# 1. bun scripts/cars-import.ts --report-new rebuilds brands/models/variants from RDW +
|
# 1. bun scripts/cars-import.ts --report-new rebuilds brands/models/variants from RDW +
|
||||||
# fueleconomy.gov and notes which brands/models did not exist before.
|
# fueleconomy.gov and notes which brands/models did not exist before.
|
||||||
# 2. bun scripts/cars-photos.ts --missing finds Wikimedia Commons photos for models
|
# 2. bun scripts/cars-photos.ts --missing finds Wikimedia Commons photos for models
|
||||||
# that have none yet (new ones included).
|
# that have none yet (new ones included).
|
||||||
# stdout: one JSON report {generatedAt, totals, newBrands, newModels[] (with photo)}.
|
# 3. bun scripts/spotters-activity.ts cars refreshes the per-country activity scores
|
||||||
|
# ("most active in NL" sort; see that script).
|
||||||
|
# stdout: one JSON report {generatedAt, totals, newBrands, newModels[] (with photo)}, plus `nl` per new
|
||||||
|
# entry and `newInNl` (new entries that are active in the Netherlands, see
|
||||||
|
# scripts/spotters-report-nl.ts). --report-new is accepted (the report is always printed).
|
||||||
# Progress goes to stderr. DB_URL is read from the running app container and never printed.
|
# Progress goes to stderr. DB_URL is read from the running app container and never printed.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
@@ -19,8 +23,12 @@ if [ -z "${DB_URL:-}" ]; then
|
|||||||
export DB_URL
|
export DB_URL
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
args=()
|
||||||
|
for a in "$@"; do [ "$a" = "--report-new" ] || args+=("$a"); done
|
||||||
|
|
||||||
report=$(mktemp /tmp/cars-report.XXXXXX.json)
|
report=$(mktemp /tmp/cars-report.XXXXXX.json)
|
||||||
trap 'rm -f "$report"' EXIT
|
trap 'rm -f "$report"' EXIT
|
||||||
|
|
||||||
bun scripts/cars-import.ts --report-new "$@" >"$report"
|
bun scripts/cars-import.ts --report-new "${args[@]}" >"$report"
|
||||||
bun scripts/cars-photos.ts --missing --enrich "$report" --fetch
|
bun scripts/spotters-activity.ts cars >&2
|
||||||
|
bun scripts/cars-photos.ts --missing --enrich "$report" --fetch | bun scripts/spotters-report-nl.ts cars
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
# are stable (types are matched by Wikidata id).
|
# are stable (types are matched by Wikidata id).
|
||||||
# 2. bun scripts/planes-photos.ts --missing finds Wikimedia Commons photos for types that
|
# 2. bun scripts/planes-photos.ts --missing finds Wikimedia Commons photos for types that
|
||||||
# have none yet (new ones included) and maker logos that are missing.
|
# have none yet (new ones included) and maker logos that are missing.
|
||||||
# stdout: one JSON report {generatedAt, totals, newMakers, newTypes[] (with photo)}.
|
# 3. bun scripts/spotters-activity.ts planes refreshes the per-country activity scores
|
||||||
|
# ("most active in NL" sort; OpenSky registrations, see that script).
|
||||||
|
# stdout: one JSON report {generatedAt, totals, newMakers, newTypes[] (with photo)}, plus `nl` per new
|
||||||
|
# entry and `newInNl` (new entries that are active in the Netherlands, see
|
||||||
|
# scripts/spotters-report-nl.ts). --report-new is accepted (the report is always printed).
|
||||||
# Progress goes to stderr. DB_URL is read from the running app container and never printed.
|
# Progress goes to stderr. DB_URL is read from the running app container and never printed.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
@@ -20,8 +24,12 @@ if [ -z "${DB_URL:-}" ]; then
|
|||||||
export DB_URL
|
export DB_URL
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
args=()
|
||||||
|
for a in "$@"; do [ "$a" = "--report-new" ] || args+=("$a"); done
|
||||||
|
|
||||||
report=$(mktemp /tmp/planes-report.XXXXXX.json)
|
report=$(mktemp /tmp/planes-report.XXXXXX.json)
|
||||||
trap 'rm -f "$report"' EXIT
|
trap 'rm -f "$report"' EXIT
|
||||||
|
|
||||||
bun scripts/planes-import.ts --report-new "$@" >"$report"
|
bun scripts/planes-import.ts --report-new "${args[@]}" >"$report"
|
||||||
bun scripts/planes-photos.ts --missing --enrich "$report" --fetch
|
bun scripts/spotters-activity.ts planes >&2
|
||||||
|
bun scripts/planes-photos.ts --missing --enrich "$report" --fetch | bun scripts/spotters-report-nl.ts planes
|
||||||
|
|||||||
397
scripts/spotters-activity.ts
Normal file
397
scripts/spotters-activity.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
// Precomputes "how active is this entry in country X" for the four spotting pokedexes
|
||||||
|
// (/trackers/cars, planes, trains, wildlife) into <schema>.activity (item, area, score),
|
||||||
|
// so the dex can sort by most active in NL / Germany / Europe ... with one indexed join.
|
||||||
|
//
|
||||||
|
// DB_URL=... bun scripts/spotters-activity.ts <cars|planes|trains|wildlife|all> [--dry-run]
|
||||||
|
//
|
||||||
|
// Scores are only comparable within one area (higher = more active there):
|
||||||
|
// - cars: NL = cars registered in NL (RDW). US = EPA-tested model years and variants.
|
||||||
|
// The brand's home country = overall popularity (NL regs + 100 x US variants).
|
||||||
|
// Other countries have no registration data (gap).
|
||||||
|
// - planes: aircraft registered per country with that type (OpenSky aircraft database,
|
||||||
|
// matched by maker + model name, then by ICAO type code), plus 25 per
|
||||||
|
// operator from that country on Wikidata (P137 -> P17; air forces, airlines).
|
||||||
|
// Variants roll up into their family.
|
||||||
|
// - trains: 100 if an operator from that country runs it (+25 per extra operator), +300
|
||||||
|
// if still in service (+100 unknown), 20 for the country of origin only, small
|
||||||
|
// tie-break for number built.
|
||||||
|
// - wildlife: research-grade iNaturalist observations per country (same query as the NL
|
||||||
|
// count in scripts/wildlife-import.ts); Europe / world use the stored counts.
|
||||||
|
// Regions (src/lib/spotting/areas.ts) are the sum of their countries; `world` = all.
|
||||||
|
// External calls: OpenSky CSV (1 download), Wikidata SPARQL (~10 queries) and the
|
||||||
|
// iNaturalist API (~1 call/s, ~450 calls). Progress on stderr.
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { REGIONS, COUNTRIES } from '../src/lib/spotting/areas';
|
||||||
|
import { countryCode } from './spotters-countries';
|
||||||
|
import { chunks, log, sparql } from './planes-wiki';
|
||||||
|
import { inat } from './wildlife-inat';
|
||||||
|
|
||||||
|
type Scores = Map<number, Map<string, number>>;
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const which = args.find((a) => !a.startsWith('--')) ?? 'all';
|
||||||
|
const dryRun = args.includes('--dry-run');
|
||||||
|
const flag = (name: string) => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i >= 0 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = process.env.DB_URL;
|
||||||
|
if (!url) throw new Error('Set DB_URL');
|
||||||
|
const sql = postgres(url, { max: 2, connect_timeout: 10, onnotice: () => {} });
|
||||||
|
|
||||||
|
function add(scores: Scores, id: number, area: string | null, value: number) {
|
||||||
|
if (!area || !(value > 0)) return;
|
||||||
|
let m = scores.get(id);
|
||||||
|
if (!m) scores.set(id, (m = new Map()));
|
||||||
|
m.set(area, (m.get(area) ?? 0) + value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds region rows (sum of countries) unless already present, then replaces the table. */
|
||||||
|
async function write(schema: string, column: string, scores: Scores, keepRegions = false) {
|
||||||
|
const rows: { id: number; area: string; score: number }[] = [];
|
||||||
|
for (const [id, m] of scores) {
|
||||||
|
const out = new Map(m);
|
||||||
|
for (const r of REGIONS) {
|
||||||
|
if (keepRegions && m.has(r.code)) continue;
|
||||||
|
let sum = 0;
|
||||||
|
for (const [area, v] of m)
|
||||||
|
if (/^[a-z]{2}$/.test(area) && (r.code === 'world' || r.countries.includes(area))) sum += v;
|
||||||
|
if (sum > 0) out.set(r.code, sum);
|
||||||
|
}
|
||||||
|
for (const [area, score] of out) rows.push({ id, area, score: Math.round(score * 100) / 100 });
|
||||||
|
}
|
||||||
|
const areas = new Map<string, number>();
|
||||||
|
for (const r of rows) areas.set(r.area, (areas.get(r.area) ?? 0) + 1);
|
||||||
|
log(
|
||||||
|
`${schema}: ${rows.length} activity rows for ${scores.size} entries; nl ${areas.get('nl') ?? 0}, de ${areas.get('de') ?? 0}, us ${areas.get('us') ?? 0}, europe ${areas.get('europe') ?? 0}, ${areas.size} areas`
|
||||||
|
);
|
||||||
|
if (dryRun) return;
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
await tx.unsafe(`delete from ${schema}.activity`);
|
||||||
|
for (const c of chunks(rows, 5000))
|
||||||
|
await tx.unsafe(
|
||||||
|
`insert into ${schema}.activity (${column}, area, score)
|
||||||
|
select * from unnest($1::int[], $2::text[], $3::real[])`,
|
||||||
|
[c.map((r) => r.id), c.map((r) => r.area), c.map((r) => r.score)] as never
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ cars
|
||||||
|
|
||||||
|
async function cars() {
|
||||||
|
const models = await sql<
|
||||||
|
{ id: number; nl: number; variants: number; years: number; country: string | null }[]
|
||||||
|
>`select m.id, m.nl_count as nl, m.variant_count as variants,
|
||||||
|
coalesce(cardinality(m.us_years), 0) as years, b.country
|
||||||
|
from cars.models m join cars.brands b on b.id = m.brand_id`;
|
||||||
|
const scores: Scores = new Map();
|
||||||
|
for (const m of models) {
|
||||||
|
add(scores, m.id, 'nl', m.nl);
|
||||||
|
add(scores, m.id, 'us', m.years * 10 + m.variants);
|
||||||
|
const home = countryCode(m.country);
|
||||||
|
if (home && home !== 'nl' && home !== 'us')
|
||||||
|
add(scores, m.id, home, 1 + m.nl + 100 * m.variants);
|
||||||
|
}
|
||||||
|
await write('cars', 'model_id', scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ trains
|
||||||
|
|
||||||
|
async function trains() {
|
||||||
|
const types = await sql<
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
country: string | null;
|
||||||
|
built: number | null;
|
||||||
|
in_service: boolean | null;
|
||||||
|
retired: number | null;
|
||||||
|
ops: (string | null)[];
|
||||||
|
}[]
|
||||||
|
>`select t.id, t.country, t.number_built as built, t.in_service, t.retired_year as retired,
|
||||||
|
coalesce(array_agg(o.country) filter (where o.id is not null), '{}') as ops
|
||||||
|
from trains.types t
|
||||||
|
left join trains.type_operators x on x.type_id = t.id
|
||||||
|
left join trains.operators o on o.id = x.operator_id
|
||||||
|
group by t.id`;
|
||||||
|
const scores: Scores = new Map();
|
||||||
|
for (const t of types) {
|
||||||
|
// Running there now matters most ("does it run on Dutch rails?"): in service +300,
|
||||||
|
// unknown status +100, retired +0 (+50 if retired after 2000).
|
||||||
|
const tie = Math.min(t.built ?? 0, 1000) / 100;
|
||||||
|
const status =
|
||||||
|
t.in_service === true ? 300 : t.in_service == null ? 100 : (t.retired ?? 0) >= 2000 ? 50 : 0;
|
||||||
|
const opCountries = new Map<string, number>();
|
||||||
|
for (const name of t.ops) {
|
||||||
|
const cc = countryCode(name);
|
||||||
|
if (cc) opCountries.set(cc, (opCountries.get(cc) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const [cc, n] of opCountries)
|
||||||
|
add(scores, t.id, cc, 100 + 25 * (Math.min(n, 4) - 1) + status + tie);
|
||||||
|
const origin = countryCode(t.country);
|
||||||
|
if (origin && !opCountries.has(origin)) add(scores, t.id, origin, 20 + tie);
|
||||||
|
}
|
||||||
|
await write('trains', 'type_id', scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ planes
|
||||||
|
|
||||||
|
/** Minimal CSV reader for OpenSky's single-quoted dump ('' escapes a quote). */
|
||||||
|
function* csvRows(text: string, quote = "'") {
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = '';
|
||||||
|
let inQ = false;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (inQ) {
|
||||||
|
if (ch === quote) {
|
||||||
|
if (text[i + 1] === quote) {
|
||||||
|
field += quote;
|
||||||
|
i++;
|
||||||
|
} else inQ = false;
|
||||||
|
} else field += ch;
|
||||||
|
} else if (ch === quote) inQ = true;
|
||||||
|
else if (ch === ',') {
|
||||||
|
row.push(field);
|
||||||
|
field = '';
|
||||||
|
} else if (ch === '\n') {
|
||||||
|
row.push(field);
|
||||||
|
yield row;
|
||||||
|
row = [];
|
||||||
|
field = '';
|
||||||
|
} else if (ch !== '\r') field += ch;
|
||||||
|
}
|
||||||
|
if (field || row.length) yield [...row, field];
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const GENERIC = /\b(family|series|aircraft|airliner|helicopter|classic|next generation)\b/g;
|
||||||
|
|
||||||
|
async function planes() {
|
||||||
|
const csvPath = flag('--opensky') ?? '/tmp/spotters/opensky.csv';
|
||||||
|
if (!existsSync(csvPath)) {
|
||||||
|
log(`planes: downloading the OpenSky aircraft database to ${csvPath}`);
|
||||||
|
const res = await fetch(
|
||||||
|
'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2025-08.csv'
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`OpenSky download: HTTP ${res.status}`);
|
||||||
|
await Bun.write(csvPath, await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
const types = await sql<
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
family: number | null;
|
||||||
|
name: string;
|
||||||
|
maker: string;
|
||||||
|
makers: string[];
|
||||||
|
icao: string[];
|
||||||
|
wikidata: string;
|
||||||
|
}[]
|
||||||
|
>`select t.id, t.family_id as family, t.name, m.name as maker, t.makers, t.icao, t.wikidata
|
||||||
|
from planes.types t join planes.makers m on m.id = t.maker_id`;
|
||||||
|
|
||||||
|
// Candidate keys per maker word: the type's name, without generic words, and its
|
||||||
|
// leading code part ("PA-28 Cherokee" -> "pa 28", "F-16 Fighting Falcon" -> "f 16").
|
||||||
|
type Cand = { id: number; key: string; exact: boolean; family: number | null };
|
||||||
|
const byMakerWord = new Map<string, Cand[]>();
|
||||||
|
for (const t of types) {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const n = norm(t.name);
|
||||||
|
keys.add(n);
|
||||||
|
keys.add(n.replace(GENERIC, ' ').replace(/\s+/g, ' ').trim());
|
||||||
|
const toks = n.split(' ');
|
||||||
|
const codeEnd = toks.findIndex((w, i) => i > 0 && /^[a-z]{3,}$/.test(w));
|
||||||
|
if (codeEnd > 0 && toks.slice(0, codeEnd).some((w) => /\d/.test(w)))
|
||||||
|
keys.add(toks.slice(0, codeEnd).join(' '));
|
||||||
|
const words = new Set([t.maker, ...t.makers].map((m) => norm(m).split(' ')[0]).filter(Boolean));
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key.length < 2 || (!/\d/.test(key) && key.length < 4)) continue;
|
||||||
|
for (const w of words) {
|
||||||
|
if (!byMakerWord.has(w)) byMakerWord.set(w, []);
|
||||||
|
byMakerWord.get(w)!.push({ id: t.id, key, exact: key === n, family: t.family });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const matchModel = (manufacturer: string, model: string): number | null => {
|
||||||
|
const cands = byMakerWord.get(norm(manufacturer).split(' ')[0]);
|
||||||
|
if (!cands) return null;
|
||||||
|
const m = norm(model);
|
||||||
|
let best: Cand | null = null;
|
||||||
|
for (const c of cands) {
|
||||||
|
if (!(m === c.key || (m.startsWith(c.key) && !/\d/.test(m[c.key.length] ?? '')))) continue;
|
||||||
|
if (
|
||||||
|
!best ||
|
||||||
|
c.key.length > best.key.length ||
|
||||||
|
(c.key.length === best.key.length && best.family !== null && c.family === null)
|
||||||
|
)
|
||||||
|
best = c;
|
||||||
|
}
|
||||||
|
return best?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
log('planes: reading OpenSky aircraft');
|
||||||
|
const text = await Bun.file(csvPath).text();
|
||||||
|
const rows = csvRows(text);
|
||||||
|
const head = rows.next().value as string[];
|
||||||
|
const col = (n: string) => head.indexOf(n);
|
||||||
|
const [iCountry, iMan, iModel, iType] = [
|
||||||
|
col('country'),
|
||||||
|
col('manufacturerName'),
|
||||||
|
col('model'),
|
||||||
|
col('typecode')
|
||||||
|
];
|
||||||
|
const direct = new Map<string, Map<string, number>>(); // "id" -> country -> n
|
||||||
|
const byCode = new Map<string, Map<string, number>>(); // typecode -> country -> n (unmatched)
|
||||||
|
const codeVotes = new Map<string, Map<number, number>>(); // typecode -> type id -> votes
|
||||||
|
let total = 0;
|
||||||
|
let matched = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.length < head.length) continue;
|
||||||
|
const cc = countryCode(r[iCountry]);
|
||||||
|
if (!cc) continue;
|
||||||
|
total++;
|
||||||
|
const code = r[iType]?.trim().toUpperCase();
|
||||||
|
const id = r[iMan] && r[iModel] ? matchModel(r[iMan], r[iModel]) : null;
|
||||||
|
if (id) {
|
||||||
|
matched++;
|
||||||
|
const key = String(id);
|
||||||
|
if (!direct.has(key)) direct.set(key, new Map());
|
||||||
|
direct.get(key)!.set(cc, (direct.get(key)!.get(cc) ?? 0) + 1);
|
||||||
|
if (code) {
|
||||||
|
if (!codeVotes.has(code)) codeVotes.set(code, new Map());
|
||||||
|
codeVotes.get(code)!.set(id, (codeVotes.get(code)!.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
} else if (code) {
|
||||||
|
if (!byCode.has(code)) byCode.set(code, new Map());
|
||||||
|
byCode.get(code)!.set(cc, (byCode.get(code)!.get(cc) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unmatched aircraft follow their ICAO type code: the type's own ICAO list first, else
|
||||||
|
// the type most matched aircraft with that code went to (needs a clear majority).
|
||||||
|
const icaoOwner = new Map<string, number>();
|
||||||
|
for (const t of types) for (const c of t.icao) if (!icaoOwner.has(c)) icaoOwner.set(c, t.id);
|
||||||
|
let viaCode = 0;
|
||||||
|
for (const [code, counts] of byCode) {
|
||||||
|
let owner = icaoOwner.get(code) ?? null;
|
||||||
|
const votes = codeVotes.get(code);
|
||||||
|
if (!owner && votes) {
|
||||||
|
const sorted = [...votes].sort((a, b) => b[1] - a[1]);
|
||||||
|
const sum = sorted.reduce((s, [, n]) => s + n, 0);
|
||||||
|
if (sorted[0][1] >= 3 && sorted[0][1] / sum >= 0.6) owner = sorted[0][0];
|
||||||
|
}
|
||||||
|
if (!owner) continue;
|
||||||
|
const key = String(owner);
|
||||||
|
if (!direct.has(key)) direct.set(key, new Map());
|
||||||
|
for (const [cc, n] of counts) {
|
||||||
|
direct.get(key)!.set(cc, (direct.get(key)!.get(cc) ?? 0) + n);
|
||||||
|
viaCode += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(
|
||||||
|
`planes: ${total} aircraft with a country, ${matched} matched by model, ${viaCode} by type code`
|
||||||
|
);
|
||||||
|
|
||||||
|
const scores: Scores = new Map();
|
||||||
|
// Roll registrations up the family tree (737 MAX 8 -> 737 MAX -> 737).
|
||||||
|
const parent = new Map(types.map((t) => [t.id, t.family]));
|
||||||
|
for (const [key, m] of direct) {
|
||||||
|
let id: number | null = Number(key);
|
||||||
|
for (let depth = 0; id && depth < 6; depth++, id = parent.get(id) ?? null)
|
||||||
|
for (const [cc, n] of m) add(scores, id, cc, n);
|
||||||
|
}
|
||||||
|
// Wikidata operators (P137) and their countries.
|
||||||
|
const byQid = new Map(types.map((t) => [t.wikidata, t.id]));
|
||||||
|
let ops = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const c of chunks(
|
||||||
|
[...byQid.keys()].filter((q): q is string => !!q),
|
||||||
|
100
|
||||||
|
)) {
|
||||||
|
// A failing chunk (query timeout) only loses its operator bonus; registrations stay.
|
||||||
|
const res = await sparql<{ item: string; iso: string; n: string }>(`
|
||||||
|
SELECT ?item ?iso (COUNT(DISTINCT ?op) AS ?n) WHERE {
|
||||||
|
VALUES ?item { ${c.map((q) => `wd:${q}`).join(' ')} }
|
||||||
|
?item wdt:P137 ?op . ?op wdt:P17 ?country . ?country wdt:P297 ?iso .
|
||||||
|
} GROUP BY ?item ?iso`).catch((e: unknown) => {
|
||||||
|
failed++;
|
||||||
|
log(`planes: operator chunk failed (${String(e).slice(0, 80)})`);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
for (const r of res) {
|
||||||
|
const id = byQid.get(r.item.split('/').at(-1)!);
|
||||||
|
if (!id) continue;
|
||||||
|
add(scores, id, r.iso.toLowerCase(), 25 * Math.min(Number(r.n) || 1, 6));
|
||||||
|
ops++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(`planes: ${ops} type x country operator links from Wikidata (${failed} chunks failed)`);
|
||||||
|
await write('planes', 'type_id', scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ wildlife
|
||||||
|
|
||||||
|
async function wildlife() {
|
||||||
|
const species = await sql<{ id: number; inat: number; nl: number; eu: number; world: number }[]>`
|
||||||
|
select id, inat_id as inat, nl_obs as nl, europe_obs as eu, world_obs as world from wildlife.species`;
|
||||||
|
const scores: Scores = new Map();
|
||||||
|
for (const s of species) {
|
||||||
|
add(scores, s.id, 'nl', s.nl);
|
||||||
|
add(scores, s.id, 'europe', s.eu);
|
||||||
|
add(scores, s.id, 'world', s.world);
|
||||||
|
}
|
||||||
|
const byInat = new Map(species.map((s) => [s.inat, s.id]));
|
||||||
|
const countries = [...new Set([...COUNTRIES, 'lu'])].filter((c) => c !== 'nl');
|
||||||
|
const dn = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||||
|
for (const cc of countries) {
|
||||||
|
const name =
|
||||||
|
{
|
||||||
|
us: 'United States',
|
||||||
|
gb: 'United Kingdom',
|
||||||
|
cz: 'Czech Republic',
|
||||||
|
tr: 'Turkey',
|
||||||
|
kr: 'South Korea'
|
||||||
|
}[cc] ?? dn.of(cc.toUpperCase())!;
|
||||||
|
const found = await inat<{
|
||||||
|
results: { id: number; name: string; admin_level: number | null }[];
|
||||||
|
}>(`/places/autocomplete?q=${encodeURIComponent(name)}&per_page=20`);
|
||||||
|
const place =
|
||||||
|
found.results.find((p) => p.admin_level === 0 && norm(p.name) === norm(name)) ??
|
||||||
|
found.results.find((p) => p.admin_level === 0);
|
||||||
|
if (!place) {
|
||||||
|
log(`wildlife: no iNaturalist place for ${cc} (${name})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n = 0;
|
||||||
|
for (const batch of chunks([...byInat.keys()], 400)) {
|
||||||
|
const res = await inat(
|
||||||
|
`/observations/species_counts?place_id=${place.id}&taxon_id=${batch.join(',')}&captive=false&quality_grade=research&per_page=500`
|
||||||
|
);
|
||||||
|
for (const r of res.results) {
|
||||||
|
const id = byInat.get(r.taxon.id);
|
||||||
|
if (id && r.count > 0) {
|
||||||
|
add(scores, id, cc, r.count);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(`wildlife: ${cc} (${place.name}, place ${place.id}): ${n} species`);
|
||||||
|
}
|
||||||
|
await write('wildlife', 'species_id', scores, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs: Record<string, () => Promise<void>> = { cars, trains, planes, wildlife };
|
||||||
|
try {
|
||||||
|
for (const name of which === 'all' ? Object.keys(jobs) : which.split(',')) {
|
||||||
|
if (!jobs[name]) throw new Error(`unknown tracker ${name}`);
|
||||||
|
await jobs[name]();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
172
scripts/spotters-countries.ts
Normal file
172
scripts/spotters-countries.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// Country name (as found in Wikidata labels, OpenSky, the train/plane/car data) -> lowercase
|
||||||
|
// ISO 3166-1 alpha-2 code, for scripts/spotters-activity.ts. Historic states map to their
|
||||||
|
// main successor (Deutsches Reich -> de, Soviet Union -> ru, Yugoslavia -> rs).
|
||||||
|
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
'united states of america': 'us',
|
||||||
|
usa: 'us',
|
||||||
|
'u.s.': 'us',
|
||||||
|
'united states': 'us',
|
||||||
|
"people's republic of china": 'cn',
|
||||||
|
'republic of china': 'tw',
|
||||||
|
'taiwan (republic of china)': 'tw',
|
||||||
|
'taiwan under japanese rule': 'tw',
|
||||||
|
manchukuo: 'cn',
|
||||||
|
'hong kong': 'hk',
|
||||||
|
macau: 'mo',
|
||||||
|
macao: 'mo',
|
||||||
|
'russian federation': 'ru',
|
||||||
|
'soviet union': 'ru',
|
||||||
|
ussr: 'ru',
|
||||||
|
'soviet union (russia since 1991)': 'ru',
|
||||||
|
'russian empire': 'ru',
|
||||||
|
'russian sfsr': 'ru',
|
||||||
|
'deutsches reich': 'de',
|
||||||
|
'german reich': 'de',
|
||||||
|
'german empire': 'de',
|
||||||
|
'nazi germany': 'de',
|
||||||
|
'east germany': 'de',
|
||||||
|
'west germany': 'de',
|
||||||
|
'weimar republic': 'de',
|
||||||
|
'weimarer republik': 'de',
|
||||||
|
'deutsches kaiserreich': 'de',
|
||||||
|
'allied-occupied germany': 'de',
|
||||||
|
'grand duchy of baden': 'de',
|
||||||
|
'territory of the saar basin': 'de',
|
||||||
|
'saar protectorate': 'de',
|
||||||
|
'kingdom of bavaria': 'de',
|
||||||
|
'kingdom of prussia': 'de',
|
||||||
|
'kingdom of saxony': 'de',
|
||||||
|
'kingdom of württemberg': 'de',
|
||||||
|
'german reich (nazi period) deutsches reich': 'de',
|
||||||
|
germa: 'de',
|
||||||
|
czechoslovakia: 'cz',
|
||||||
|
'first czechoslovak republic': 'cz',
|
||||||
|
'czech republic': 'cz',
|
||||||
|
'protectorate of bohemia and moravia': 'cz',
|
||||||
|
'czechoslovak socialist republic': 'cz',
|
||||||
|
'korea under japanese rule': 'kr',
|
||||||
|
'korean empire': 'kr',
|
||||||
|
'south korea': 'kr',
|
||||||
|
'north korea': 'kp',
|
||||||
|
'republic of korea': 'kr',
|
||||||
|
"democratic people's republic of korea": 'kp',
|
||||||
|
'irish free state': 'ie',
|
||||||
|
'republic of ireland': 'ie',
|
||||||
|
yugoslavia: 'rs',
|
||||||
|
'kingdom of yugoslavia': 'rs',
|
||||||
|
'socialist federal republic of yugoslavia': 'rs',
|
||||||
|
'federal republic of yugoslavia': 'rs',
|
||||||
|
'serbia and montenegro': 'rs',
|
||||||
|
'empire of japan': 'jp',
|
||||||
|
'kingdom of italy': 'it',
|
||||||
|
'austria–hungary': 'at',
|
||||||
|
'austria-hungary': 'at',
|
||||||
|
cisleithania: 'at',
|
||||||
|
'first republic of austria': 'at',
|
||||||
|
'austrian empire': 'at',
|
||||||
|
'colony of natal': 'za',
|
||||||
|
'south african republic': 'za',
|
||||||
|
'orange free state': 'za',
|
||||||
|
'cape colony': 'za',
|
||||||
|
'union of south africa': 'za',
|
||||||
|
'dutch east indies': 'id',
|
||||||
|
'kingdom of the netherlands': 'nl',
|
||||||
|
netherlands: 'nl',
|
||||||
|
holland: 'nl',
|
||||||
|
england: 'gb',
|
||||||
|
scotland: 'gb',
|
||||||
|
wales: 'gb',
|
||||||
|
'northern ireland': 'gb',
|
||||||
|
'great britain': 'gb',
|
||||||
|
'united kingdom of great britain and ireland': 'gb',
|
||||||
|
'united kingdom': 'gb',
|
||||||
|
uk: 'gb',
|
||||||
|
tanganyika: 'tz',
|
||||||
|
'mandatory palestine': 'il',
|
||||||
|
'second polish republic': 'pl',
|
||||||
|
"polish people's republic": 'pl',
|
||||||
|
'polska rzeczpospolita ludowa': 'pl',
|
||||||
|
'second spanish republic': 'es',
|
||||||
|
'francoist spain': 'es',
|
||||||
|
'kingdom of romania': 'ro',
|
||||||
|
"ukrainian people's republic": 'ua',
|
||||||
|
'ukrainian ssr': 'ua',
|
||||||
|
turkey: 'tr',
|
||||||
|
türkiye: 'tr',
|
||||||
|
'ottoman empire': 'tr',
|
||||||
|
'kingdom of hungary': 'hu',
|
||||||
|
'kingdom of greece': 'gr',
|
||||||
|
'kingdom of bulgaria': 'bg',
|
||||||
|
'british raj': 'in',
|
||||||
|
'dominion of india': 'in',
|
||||||
|
ceylon: 'lk',
|
||||||
|
burma: 'mm',
|
||||||
|
'french third republic': 'fr',
|
||||||
|
'vichy france': 'fr',
|
||||||
|
'french fourth republic': 'fr',
|
||||||
|
'kingdom of egypt': 'eg',
|
||||||
|
rhodesia: 'zw',
|
||||||
|
'southern rhodesia': 'zw',
|
||||||
|
'northern rhodesia': 'zm',
|
||||||
|
'belgian congo': 'cd',
|
||||||
|
zaire: 'cd',
|
||||||
|
swaziland: 'sz',
|
||||||
|
eswatini: 'sz',
|
||||||
|
persia: 'ir',
|
||||||
|
iran: 'ir',
|
||||||
|
syria: 'sy',
|
||||||
|
vietnam: 'vn',
|
||||||
|
laos: 'la',
|
||||||
|
bolivia: 'bo',
|
||||||
|
venezuela: 've',
|
||||||
|
tanzania: 'tz',
|
||||||
|
moldova: 'md',
|
||||||
|
macedonia: 'mk',
|
||||||
|
'north macedonia': 'mk',
|
||||||
|
'bosnia and herzegovina': 'ba',
|
||||||
|
'ivory coast': 'ci',
|
||||||
|
"côte d'ivoire": 'ci',
|
||||||
|
'cape verde': 'cv',
|
||||||
|
'east timor': 'tl',
|
||||||
|
kosovo: 'xk',
|
||||||
|
'isle of man': 'im',
|
||||||
|
jersey: 'je',
|
||||||
|
guernsey: 'gg',
|
||||||
|
'state of palestine': 'ps',
|
||||||
|
brunei: 'bn',
|
||||||
|
'republic of the congo': 'cg',
|
||||||
|
'democratic republic of the congo': 'cd',
|
||||||
|
'the gambia': 'gm',
|
||||||
|
'the bahamas': 'bs',
|
||||||
|
'federated states of micronesia': 'fm',
|
||||||
|
'vatican city': 'va',
|
||||||
|
czechia: 'cz'
|
||||||
|
};
|
||||||
|
|
||||||
|
let byName: Map<string, string> | null = null;
|
||||||
|
|
||||||
|
function table() {
|
||||||
|
if (byName) return byName;
|
||||||
|
byName = new Map();
|
||||||
|
const dn = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||||
|
for (let a = 65; a <= 90; a++)
|
||||||
|
for (let b = 65; b <= 90; b++) {
|
||||||
|
const code = String.fromCharCode(a, b);
|
||||||
|
try {
|
||||||
|
const name = dn.of(code);
|
||||||
|
if (name && name !== code) byName.set(name.toLowerCase(), code.toLowerCase());
|
||||||
|
} catch {
|
||||||
|
/* not a region */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(ALIASES)) byName.set(k, v);
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Country label -> ISO2 (lowercase), or null when unknown / multinational. */
|
||||||
|
export function countryCode(name: string | null | undefined): string | null {
|
||||||
|
if (!name) return null;
|
||||||
|
const key = name.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
return table().get(key) ?? table().get(key.replace(/^the /, '')) ?? null;
|
||||||
|
}
|
||||||
56
scripts/spotters-report-nl.ts
Normal file
56
scripts/spotters-report-nl.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Adds Netherlands relevance to a refresh report (stdin JSON -> stdout JSON), so a "new
|
||||||
|
// entries" ping can be limited to things that actually show up in NL.
|
||||||
|
//
|
||||||
|
// bun scripts/cars-import.ts --report-new | bun scripts/spotters-report-nl.ts cars
|
||||||
|
//
|
||||||
|
// Every new item (newModels / newTypes / newSpecies, matched by the /trackers/... url) gets
|
||||||
|
// `nl` (its activity score in NL from <schema>.activity, 0 when none) and the report gets
|
||||||
|
// `newInNl`: the same items with nl > 0, most active first. Needs DB_URL. Run after
|
||||||
|
// scripts/spotters-activity.ts so new entries already have scores.
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
const tracker = process.argv[2];
|
||||||
|
const CFG = {
|
||||||
|
cars: {
|
||||||
|
key: 'newModels',
|
||||||
|
sql: "select a.score from cars.activity a join cars.models m on m.id = a.model_id join cars.brands b on b.id = m.brand_id where a.area = 'nl' and b.slug = $1 and m.slug = $2"
|
||||||
|
},
|
||||||
|
planes: {
|
||||||
|
key: 'newTypes',
|
||||||
|
sql: "select a.score from planes.activity a join planes.types t on t.id = a.type_id join planes.makers m on m.id = t.maker_id where a.area = 'nl' and m.slug = $1 and t.slug = $2"
|
||||||
|
},
|
||||||
|
trains: {
|
||||||
|
key: 'newTypes',
|
||||||
|
sql: "select a.score from trains.activity a join trains.types t on t.id = a.type_id join trains.makers m on m.id = t.maker_id where a.area = 'nl' and m.slug = $1 and t.slug = $2"
|
||||||
|
},
|
||||||
|
wildlife: {
|
||||||
|
key: 'newSpecies',
|
||||||
|
sql: "select a.score from wildlife.activity a join wildlife.species s on s.id = a.species_id join wildlife.groups g on g.id = s.group_id where a.area = 'nl' and g.slug = $1 and s.slug = $2"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
if (!(tracker in CFG)) {
|
||||||
|
console.error(
|
||||||
|
'usage: bun scripts/spotters-report-nl.ts <cars|planes|trains|wildlife> < report.json'
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const cfg = CFG[tracker as keyof typeof CFG];
|
||||||
|
const input = await new Response(Bun.stdin.stream()).text();
|
||||||
|
if (!input.trim()) process.exit(0);
|
||||||
|
const report = JSON.parse(input) as Record<string, unknown>;
|
||||||
|
const items = (report[cfg.key] ?? []) as { url?: string; nl?: number }[];
|
||||||
|
const sql = postgres(process.env.DB_URL!, { max: 1 });
|
||||||
|
try {
|
||||||
|
for (const item of items) {
|
||||||
|
const path = item.url ? new URL(item.url, 'https://x').pathname : '';
|
||||||
|
const [a, b] = path.replace(/^\/trackers\/[a-z]+\//, '').split('/');
|
||||||
|
const [row] = a && b ? await sql.unsafe<{ score: number }[]>(cfg.sql, [a, b]) : [];
|
||||||
|
item.nl = row ? Math.round(row.score * 10) / 10 : 0;
|
||||||
|
}
|
||||||
|
report.newInNl = items
|
||||||
|
.filter((x) => (x.nl ?? 0) > 0)
|
||||||
|
.toSorted((x, y) => (y.nl ?? 0) - (x.nl ?? 0));
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(report, null, 1));
|
||||||
@@ -9,7 +9,11 @@
|
|||||||
# are stable (types are matched by Wikidata id).
|
# are stable (types are matched by Wikidata id).
|
||||||
# 2. bun scripts/trains-photos.ts --missing finds Wikimedia Commons photos for types that
|
# 2. bun scripts/trains-photos.ts --missing finds Wikimedia Commons photos for types that
|
||||||
# have none yet (new ones included) and maker and operator logos that are missing.
|
# have none yet (new ones included) and maker and operator logos that are missing.
|
||||||
# stdout: one JSON report {generatedAt, totals, newMakers, newTypes[] (with photo)}.
|
# 3. bun scripts/spotters-activity.ts trains refreshes the per-country activity scores
|
||||||
|
# ("most active in NL" sort; see that script).
|
||||||
|
# stdout: one JSON report {generatedAt, totals, newMakers, newTypes[] (with photo)}, plus `nl` per new
|
||||||
|
# entry and `newInNl` (new entries that are active in the Netherlands, see
|
||||||
|
# scripts/spotters-report-nl.ts). --report-new is accepted (the report is always printed).
|
||||||
# Progress goes to stderr. DB_URL is read from the running app container and never printed.
|
# Progress goes to stderr. DB_URL is read from the running app container and never printed.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
@@ -20,8 +24,12 @@ if [ -z "${DB_URL:-}" ]; then
|
|||||||
export DB_URL
|
export DB_URL
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
args=()
|
||||||
|
for a in "$@"; do [ "$a" = "--report-new" ] || args+=("$a"); done
|
||||||
|
|
||||||
report=$(mktemp /tmp/trains-report.XXXXXX.json)
|
report=$(mktemp /tmp/trains-report.XXXXXX.json)
|
||||||
trap 'rm -f "$report"' EXIT
|
trap 'rm -f "$report"' EXIT
|
||||||
|
|
||||||
bun scripts/trains-import.ts --report-new "$@" >"$report"
|
bun scripts/trains-import.ts --report-new "${args[@]}" >"$report"
|
||||||
bun scripts/trains-photos.ts --missing --enrich "$report" --fetch
|
bun scripts/spotters-activity.ts trains >&2
|
||||||
|
bun scripts/trains-photos.ts --missing --enrich "$report" --fetch | bun scripts/spotters-report-nl.ts trains
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
# that entered the lists (existing ones are kept, Dex numbers are stable).
|
# that entered the lists (existing ones are kept, Dex numbers are stable).
|
||||||
# 2. bun scripts/wildlife-photos.ts --missing CC-licensed iNaturalist photos for species
|
# 2. bun scripts/wildlife-photos.ts --missing CC-licensed iNaturalist photos for species
|
||||||
# without photos (new ones included).
|
# without photos (new ones included).
|
||||||
# --report-new: stdout is one JSON report {generatedAt, totals, newSpecies[] (with photo)}.
|
# 3. bun scripts/spotters-activity.ts wildlife per-country activity scores (iNaturalist
|
||||||
|
# research-grade observations per country) for the "most active in …" sort.
|
||||||
|
# --report-new: stdout is one JSON report {generatedAt, totals, newSpecies[] (with photo)},
|
||||||
|
# plus `nl` per new species and `newInNl` (see scripts/spotters-report-nl.ts).
|
||||||
# Without it, progress only. Progress goes to stderr. DB_URL is read from the running app
|
# Without it, progress only. Progress goes to stderr. DB_URL is read from the running app
|
||||||
# container and never printed.
|
# container and never printed.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -26,8 +29,10 @@ if [ "${1:-}" = "--report-new" ]; then
|
|||||||
report=$(mktemp /tmp/wildlife-report.XXXXXX.json)
|
report=$(mktemp /tmp/wildlife-report.XXXXXX.json)
|
||||||
trap 'rm -f "$report"' EXIT
|
trap 'rm -f "$report"' EXIT
|
||||||
bun scripts/wildlife-import.ts --report-new "$@" >"$report"
|
bun scripts/wildlife-import.ts --report-new "$@" >"$report"
|
||||||
bun scripts/wildlife-photos.ts --missing --enrich "$report"
|
bun scripts/spotters-activity.ts wildlife >&2
|
||||||
|
bun scripts/wildlife-photos.ts --missing --enrich "$report" | bun scripts/spotters-report-nl.ts wildlife
|
||||||
else
|
else
|
||||||
bun scripts/wildlife-import.ts "$@" >&2
|
bun scripts/wildlife-import.ts "$@" >&2
|
||||||
bun scripts/wildlife-photos.ts --missing >&2
|
bun scripts/wildlife-photos.ts --missing >&2
|
||||||
|
bun scripts/spotters-activity.ts wildlife >&2
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href="/trackers/cars/{brand.slug}" class={['tile', { compact }]} title={brand.name}>
|
<a href="/trackers/cars/{brand.slug}" class={['tile', { compact }]} title={brand.name}>
|
||||||
|
{#if brand.seen}<span class="got" title="{brand.seen} collected">✓ {brand.seen}</span>{/if}
|
||||||
<span class="mark"><BrandBadge name={brand.name} logo={brand.logo} size="xl" /></span>
|
<span class="mark"><BrandBadge name={brand.name} logo={brand.logo} size="xl" /></span>
|
||||||
<span class="name">{brand.name}</span>
|
<span class="name">{brand.name}</span>
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
@@ -18,7 +19,20 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.got {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(22 163 74);
|
||||||
|
}
|
||||||
.tile {
|
.tile {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const BODIES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export const DRIVES = ['fwd', 'rwd', 'awd', '4wd'] as const;
|
export const DRIVES = ['fwd', 'rwd', 'awd', '4wd'] as const;
|
||||||
export const SORTS = {
|
export const SORTS = {
|
||||||
|
active: 'most active in…',
|
||||||
popular: 'most common in NL',
|
popular: 'most common in NL',
|
||||||
dex: 'dex #',
|
dex: 'dex #',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
@@ -152,6 +153,8 @@ export const years = (first: number | null, last: number | null, current: number
|
|||||||
: `${first}–${last >= current ? 'now' : last}`;
|
: `${first}–${last >= current ? 'now' : last}`;
|
||||||
|
|
||||||
export type CarCard = {
|
export type CarCard = {
|
||||||
|
id: number;
|
||||||
|
seen: boolean;
|
||||||
dex: number;
|
dex: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -190,6 +193,8 @@ export type CarPhoto = {
|
|||||||
|
|
||||||
/** A brand in the logo grid (/trackers/cars/brands and the strip on /trackers/cars). */
|
/** A brand in the logo grid (/trackers/cars/brands and the strip on /trackers/cars). */
|
||||||
export type BrandTileData = {
|
export type BrandTileData = {
|
||||||
|
/** Entries Stan has collected in this group (optional). */
|
||||||
|
seen?: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
models: number;
|
models: number;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href="/trackers/planes/{maker.slug}" class={['tile', { compact }]} title={maker.name}>
|
<a href="/trackers/planes/{maker.slug}" class={['tile', { compact }]} title={maker.name}>
|
||||||
|
{#if maker.seen}<span class="got" title="{maker.seen} collected">✓ {maker.seen}</span>{/if}
|
||||||
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
|
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
|
||||||
<span class="name">{maker.name}</span>
|
<span class="name">{maker.name}</span>
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
@@ -18,7 +19,20 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.got {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(22 163 74);
|
||||||
|
}
|
||||||
.tile {
|
.tile {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const ERAS = {
|
|||||||
} as const;
|
} as const;
|
||||||
export type PlaneEra = keyof typeof ERAS;
|
export type PlaneEra = keyof typeof ERAS;
|
||||||
export const SORTS = {
|
export const SORTS = {
|
||||||
|
active: 'most active in…',
|
||||||
built: 'most built',
|
built: 'most built',
|
||||||
dex: 'dex #',
|
dex: 'dex #',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
@@ -148,6 +149,8 @@ export const fmtKg = (kg: number) =>
|
|||||||
export const fmtM = (m: number) => `${m >= 100 ? Math.round(m) : Math.round(m * 10) / 10} m`;
|
export const fmtM = (m: number) => `${m >= 100 ? Math.round(m) : Math.round(m * 10) / 10} m`;
|
||||||
|
|
||||||
export type PlaneCard = {
|
export type PlaneCard = {
|
||||||
|
id: number;
|
||||||
|
seen: boolean;
|
||||||
dex: number;
|
dex: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -170,6 +173,8 @@ export type PlaneCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type MakerTileData = {
|
export type MakerTileData = {
|
||||||
|
/** Entries Stan has collected in this group (optional). */
|
||||||
|
seen?: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
types: number;
|
types: number;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { db } from '#lib/server/db/index.js';
|
|||||||
import { carBrands, carModels, carPhotos, carVariants } from '#lib/server/db/schema/index.js';
|
import { carBrands, carModels, carPhotos, carVariants } from '#lib/server/db/schema/index.js';
|
||||||
import { and, arrayContains, asc, desc, eq, gt, gte, ilike, lte, sql, type SQL } from 'drizzle-orm';
|
import { and, arrayContains, asc, desc, eq, gt, gte, ilike, lte, sql, type SQL } from 'drizzle-orm';
|
||||||
import { BODIES, DRIVES, FUELS, SORTS, type CarCard, type CarSort } from '#lib/cars/shared.js';
|
import { BODIES, DRIVES, FUELS, SORTS, type CarCard, type CarSort } from '#lib/cars/shared.js';
|
||||||
|
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
|
||||||
|
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
|
||||||
|
|
||||||
export const PAGE_SIZE = 60;
|
export const PAGE_SIZE = 60;
|
||||||
|
|
||||||
@@ -17,6 +19,9 @@ export type CarFilters = {
|
|||||||
from: number | null;
|
from: number | null;
|
||||||
to: number | null;
|
to: number | null;
|
||||||
sort: CarSort;
|
sort: CarSort;
|
||||||
|
/** Area for the "most active" sort (?in=, default NL). */
|
||||||
|
area: string;
|
||||||
|
got: Got;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,12 +43,16 @@ export function parseFilters(params: URLSearchParams): CarFilters {
|
|||||||
market: pick(params.get('market'), ['nl', 'us'] as const),
|
market: pick(params.get('market'), ['nl', 'us'] as const),
|
||||||
from: year(params.get('from')),
|
from: year(params.get('from')),
|
||||||
to: year(params.get('to')),
|
to: year(params.get('to')),
|
||||||
sort: sort && sort in SORTS ? (sort as CarSort) : 'popular',
|
sort: sort && sort in SORTS ? (sort as CarSort) : 'active',
|
||||||
|
area: parseArea(params.get('in')),
|
||||||
|
got: parseGot(params.get('got')),
|
||||||
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardColumns = {
|
const cardColumns = {
|
||||||
|
id: carModels.id,
|
||||||
|
seen: seenSql('cars', carModels.id),
|
||||||
dex: carModels.dex,
|
dex: carModels.dex,
|
||||||
slug: carModels.slug,
|
slug: carModels.slug,
|
||||||
name: carModels.name,
|
name: carModels.name,
|
||||||
@@ -74,7 +83,15 @@ export async function searchCars(f: CarFilters): Promise<{ cars: CarCard[]; tota
|
|||||||
if (f.market === 'us') where.push(gt(carModels.variantCount, 0));
|
if (f.market === 'us') where.push(gt(carModels.variantCount, 0));
|
||||||
if (f.from) where.push(gte(carModels.lastYear, f.from));
|
if (f.from) where.push(gte(carModels.lastYear, f.from));
|
||||||
if (f.to) where.push(lte(carModels.firstYear, f.to));
|
if (f.to) where.push(lte(carModels.firstYear, f.to));
|
||||||
|
const got = gotSql('cars', carModels.id, f.got);
|
||||||
|
if (got) where.push(got);
|
||||||
const order = {
|
const order = {
|
||||||
|
active: [
|
||||||
|
sql`${activitySql('cars', carModels.id, f.area)} desc`,
|
||||||
|
desc(carModels.nlCount),
|
||||||
|
desc(carModels.variantCount),
|
||||||
|
asc(carModels.dex)
|
||||||
|
],
|
||||||
dex: [asc(carModels.dex)],
|
dex: [asc(carModels.dex)],
|
||||||
popular: [desc(carModels.nlCount), asc(carModels.dex)],
|
popular: [desc(carModels.nlCount), asc(carModels.dex)],
|
||||||
name: [asc(carModels.name), asc(carBrands.name)],
|
name: [asc(carModels.name), asc(carBrands.name)],
|
||||||
@@ -104,6 +121,7 @@ export async function searchCars(f: CarFilters): Promise<{ cars: CarCard[]; tota
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type BrandTile = {
|
export type BrandTile = {
|
||||||
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
models: number;
|
models: number;
|
||||||
@@ -125,6 +143,7 @@ export async function carOverview(): Promise<Overview> {
|
|||||||
const [brands, [counts]] = await Promise.all([
|
const [brands, [counts]] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
|
id: carBrands.id,
|
||||||
slug: carBrands.slug,
|
slug: carBrands.slug,
|
||||||
name: carBrands.name,
|
name: carBrands.name,
|
||||||
models: carBrands.modelCount,
|
models: carBrands.modelCount,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type PlaneEra,
|
type PlaneEra,
|
||||||
type PlaneSort
|
type PlaneSort
|
||||||
} from '#lib/planes/shared.js';
|
} from '#lib/planes/shared.js';
|
||||||
|
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
|
||||||
|
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
|
||||||
|
|
||||||
export const PAGE_SIZE = 60;
|
export const PAGE_SIZE = 60;
|
||||||
|
|
||||||
@@ -23,6 +25,9 @@ export type PlaneFilters = {
|
|||||||
era: PlaneEra | '';
|
era: PlaneEra | '';
|
||||||
service: '' | 'yes' | 'no';
|
service: '' | 'yes' | 'no';
|
||||||
sort: PlaneSort;
|
sort: PlaneSort;
|
||||||
|
/** Area for the "most active" sort (?in=, default NL). */
|
||||||
|
area: string;
|
||||||
|
got: Got;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,12 +43,16 @@ export function parseFilters(params: URLSearchParams): PlaneFilters {
|
|||||||
engine: pick(params.get('engine'), ENGINES),
|
engine: pick(params.get('engine'), ENGINES),
|
||||||
era: pick(params.get('era'), Object.keys(ERAS) as PlaneEra[]),
|
era: pick(params.get('era'), Object.keys(ERAS) as PlaneEra[]),
|
||||||
service: pick(params.get('service'), ['yes', 'no'] as const),
|
service: pick(params.get('service'), ['yes', 'no'] as const),
|
||||||
sort: sort && sort in SORTS ? (sort as PlaneSort) : 'built',
|
sort: sort && sort in SORTS ? (sort as PlaneSort) : 'active',
|
||||||
|
area: parseArea(params.get('in')),
|
||||||
|
got: parseGot(params.get('got')),
|
||||||
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardColumns = {
|
const cardColumns = {
|
||||||
|
id: planeTypes.id,
|
||||||
|
seen: seenSql('planes', planeTypes.id),
|
||||||
dex: planeTypes.dex,
|
dex: planeTypes.dex,
|
||||||
slug: planeTypes.slug,
|
slug: planeTypes.slug,
|
||||||
name: planeTypes.name,
|
name: planeTypes.name,
|
||||||
@@ -89,7 +98,14 @@ export async function searchPlanes(
|
|||||||
}
|
}
|
||||||
if (f.service === 'yes') where.push(eq(planeTypes.inService, true));
|
if (f.service === 'yes') where.push(eq(planeTypes.inService, true));
|
||||||
if (f.service === 'no') where.push(eq(planeTypes.inService, false));
|
if (f.service === 'no') where.push(eq(planeTypes.inService, false));
|
||||||
|
const got = gotSql('planes', planeTypes.id, f.got);
|
||||||
|
if (got) where.push(got);
|
||||||
const order = {
|
const order = {
|
||||||
|
active: [
|
||||||
|
sql`${activitySql('planes', planeTypes.id, f.area)} desc`,
|
||||||
|
sql`${planeTypes.numberBuilt} desc nulls last`,
|
||||||
|
asc(planeTypes.dex)
|
||||||
|
],
|
||||||
built: [sql`${planeTypes.numberBuilt} desc nulls last`, asc(planeTypes.dex)],
|
built: [sql`${planeTypes.numberBuilt} desc nulls last`, asc(planeTypes.dex)],
|
||||||
dex: [asc(planeTypes.dex)],
|
dex: [asc(planeTypes.dex)],
|
||||||
name: [asc(planeTypes.fullName)],
|
name: [asc(planeTypes.fullName)],
|
||||||
@@ -120,6 +136,7 @@ export async function searchPlanes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type MakerTile = {
|
export type MakerTile = {
|
||||||
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
types: number;
|
types: number;
|
||||||
@@ -141,6 +158,7 @@ export async function planeOverview(): Promise<Overview> {
|
|||||||
const [makers, [counts]] = await Promise.all([
|
const [makers, [counts]] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
|
id: planeMakers.id,
|
||||||
slug: planeMakers.slug,
|
slug: planeMakers.slug,
|
||||||
name: planeMakers.name,
|
name: planeMakers.name,
|
||||||
types: planeMakers.typeCount,
|
types: planeMakers.typeCount,
|
||||||
|
|||||||
164
src/lib/server/spotting.ts
Normal file
164
src/lib/server/spotting.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Shared server helpers for the four spotting pokedexes (cars, planes, trains, wildlife):
|
||||||
|
// Stan's collection (<schema>.seen) and the per-country activity areas (<schema>.activity,
|
||||||
|
// filled by scripts/spotters-activity.ts). Only the owner writes `seen`; everyone can read.
|
||||||
|
import { client } from '#lib/server/db/index.js';
|
||||||
|
import { isRegion } from '#lib/spotting/areas.js';
|
||||||
|
|
||||||
|
export type Tracker = 'cars' | 'planes' | 'trains' | 'wildlife';
|
||||||
|
|
||||||
|
/** Constant identifiers only (never user input), so building SQL text from them is safe. */
|
||||||
|
const T: Record<Tracker, { items: string; col: string; group: string }> = {
|
||||||
|
cars: { items: 'cars.models', col: 'model_id', group: 'brand_id' },
|
||||||
|
planes: { items: 'planes.types', col: 'type_id', group: 'maker_id' },
|
||||||
|
trains: { items: 'trains.types', col: 'type_id', group: 'maker_id' },
|
||||||
|
wildlife: { items: 'wildlife.species', col: 'species_id', group: 'group_id' }
|
||||||
|
};
|
||||||
|
export const isTracker = (t: string): t is Tracker => t in T;
|
||||||
|
|
||||||
|
export type SeenEntry = { seenOn: string | null; note: string | null; at: string };
|
||||||
|
|
||||||
|
/** Overall progress: seen / total. */
|
||||||
|
export async function progress(tracker: Tracker): Promise<{ seen: number; total: number }> {
|
||||||
|
const { items, col } = T[tracker];
|
||||||
|
const schema = tracker;
|
||||||
|
const [r] = await client.unsafe<{ seen: number; total: number }[]>(
|
||||||
|
`select (select count(*)::int from ${schema}.seen s join ${items} i on i.id = s.${col}) as seen,
|
||||||
|
(select count(*)::int from ${items}) as total`
|
||||||
|
);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seen count per group (brand / maker / animal group id). */
|
||||||
|
export async function seenByGroup(tracker: Tracker): Promise<Map<number, number>> {
|
||||||
|
const { items, col, group } = T[tracker];
|
||||||
|
const rows = await client.unsafe<{ g: number; n: number }[]>(
|
||||||
|
`select i.${group} as g, count(*)::int as n from ${tracker}.seen s
|
||||||
|
join ${items} i on i.id = s.${col} group by 1`
|
||||||
|
);
|
||||||
|
return new Map(rows.map((r) => [r.g, r.n]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seen / total for one group. */
|
||||||
|
export async function groupProgress(tracker: Tracker, groupId: number) {
|
||||||
|
const { items, col, group } = T[tracker];
|
||||||
|
const [r] = await client.unsafe<{ seen: number; total: number }[]>(
|
||||||
|
`select count(s.${col})::int as seen, count(*)::int as total
|
||||||
|
from ${items} i left join ${tracker}.seen s on s.${col} = i.id where i.${group} = $1`,
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seen / total for an arbitrary set of item ids (e.g. a train operator's fleet). */
|
||||||
|
export async function idsProgress(tracker: Tracker, ids: number[]) {
|
||||||
|
if (!ids.length) return { seen: 0, total: 0 };
|
||||||
|
const { col } = T[tracker];
|
||||||
|
const [r] = await client.unsafe<{ seen: number }[]>(
|
||||||
|
`select count(*)::int as seen from ${tracker}.seen where ${col} = any($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
return { seen: r.seen, total: new Set(ids).size };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSeen(tracker: Tracker, id: number): Promise<SeenEntry | null> {
|
||||||
|
const { col } = T[tracker];
|
||||||
|
const [r] = await client.unsafe<{ seen_on: string | null; note: string | null; at: Date }[]>(
|
||||||
|
`select seen_on::text, note, created_at as at from ${tracker}.seen where ${col} = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return r ? { seenOn: r.seen_on, note: r.note, at: r.at.toISOString() } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks (or unmarks) an item as seen. Returns false when the item does not exist. */
|
||||||
|
export async function setSeen(
|
||||||
|
tracker: Tracker,
|
||||||
|
id: number,
|
||||||
|
seen: boolean,
|
||||||
|
seenOn: string | null = null,
|
||||||
|
note: string | null = null
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { items, col } = T[tracker];
|
||||||
|
const [exists] = await client.unsafe(`select 1 from ${items} where id = $1`, [id]);
|
||||||
|
if (!exists) return false;
|
||||||
|
if (!seen) {
|
||||||
|
await client.unsafe(`delete from ${tracker}.seen where ${col} = $1`, [id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await client.unsafe(
|
||||||
|
`insert into ${tracker}.seen (${col}, seen_on, note) values ($1, $2::date, $3)
|
||||||
|
on conflict (${col}) do update set seen_on = excluded.seen_on, note = excluded.note`,
|
||||||
|
[id, seenOn, note]
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaCache = new Map<Tracker, { at: number; value: Set<string> }>();
|
||||||
|
/** Areas that have any activity rows (cached 10 min), to grey out empty choices. */
|
||||||
|
export async function areasWithData(tracker: Tracker): Promise<string[]> {
|
||||||
|
const hit = areaCache.get(tracker);
|
||||||
|
if (hit && Date.now() - hit.at < 600_000) return [...hit.value];
|
||||||
|
const rows = await client.unsafe<{ area: string }[]>(
|
||||||
|
`select distinct area from ${tracker}.activity`
|
||||||
|
);
|
||||||
|
const value = new Set(rows.map((r) => r.area));
|
||||||
|
if (value.size) areaCache.set(tracker, { at: Date.now(), value });
|
||||||
|
return [...value];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates a POSTed seen change. */
|
||||||
|
export function parseSeenBody(body: unknown) {
|
||||||
|
const b = (body ?? {}) as Record<string, unknown>;
|
||||||
|
const id = Number(b.id);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null;
|
||||||
|
const seen = b.seen !== false;
|
||||||
|
const seenOn =
|
||||||
|
typeof b.seenOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.seenOn) ? b.seenOn : null;
|
||||||
|
const note = typeof b.note === 'string' && b.note.trim() ? b.note.trim().slice(0, 500) : null;
|
||||||
|
return { id, seen, seenOn, note };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isRegion };
|
||||||
|
|
||||||
|
// ---- SQL fragments for the drizzle list queries ----
|
||||||
|
import { sql, type AnyColumn, type SQL } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/** `exists(seen)` for a card column. */
|
||||||
|
export const seenSql = (tracker: Tracker, idCol: AnyColumn) =>
|
||||||
|
sql<boolean>`exists(select 1 from ${sql.raw(`${tracker}.seen`)} s where s.${sql.raw(T[tracker].col)} = ${idCol})`;
|
||||||
|
|
||||||
|
/** Activity score of an item in an area (0 when unknown), for the "most active" sort. */
|
||||||
|
export const activitySql = (tracker: Tracker, idCol: AnyColumn, area: string) =>
|
||||||
|
sql<number>`coalesce((select a.score from ${sql.raw(`${tracker}.activity`)} a where a.${sql.raw(T[tracker].col)} = ${idCol} and a.area = ${area}), 0)`;
|
||||||
|
|
||||||
|
/** ?got=yes|no filter. */
|
||||||
|
export function gotSql(tracker: Tracker, idCol: AnyColumn, got: string): SQL | null {
|
||||||
|
if (got === 'yes') return seenSql(tracker, idCol);
|
||||||
|
if (got === 'no') return sql`not ${seenSql(tracker, idCol)}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupActCache = new Map<string, { at: number; value: Map<number, number> }>();
|
||||||
|
/** Summed activity of each group's entries in an area (cached 10 min): ranks brand/maker tiles. */
|
||||||
|
export async function groupActivity(tracker: Tracker, area: string): Promise<Map<number, number>> {
|
||||||
|
const k = `${tracker}:${area}`;
|
||||||
|
const hit = groupActCache.get(k);
|
||||||
|
if (hit && Date.now() - hit.at < 600_000) return hit.value;
|
||||||
|
const { items, col, group } = T[tracker];
|
||||||
|
const rows = await client.unsafe<{ g: number; s: number }[]>(
|
||||||
|
`select i.${group} as g, sum(a.score)::float8 as s from ${tracker}.activity a
|
||||||
|
join ${items} i on i.id = a.${col} where a.area = $1 group by 1`,
|
||||||
|
[area]
|
||||||
|
);
|
||||||
|
const value = new Map(rows.map((r) => [r.g, r.s]));
|
||||||
|
groupActCache.set(k, { at: Date.now(), value });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orders tiles by area activity (then their existing order). */
|
||||||
|
export function rankByActivity<T extends { id: number }>(tiles: T[], act: Map<number, number>) {
|
||||||
|
if (!act.size) return tiles;
|
||||||
|
return tiles
|
||||||
|
.map((t, i) => ({ t, i, s: act.get(t.id) ?? 0 }))
|
||||||
|
.sort((a, b) => b.s - a.s || a.i - b.i)
|
||||||
|
.map((x) => x.t);
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
type TrainEra,
|
type TrainEra,
|
||||||
type TrainSort
|
type TrainSort
|
||||||
} from '#lib/trains/shared.js';
|
} from '#lib/trains/shared.js';
|
||||||
|
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
|
||||||
|
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
|
||||||
|
|
||||||
export const PAGE_SIZE = 60;
|
export const PAGE_SIZE = 60;
|
||||||
|
|
||||||
@@ -31,6 +33,9 @@ export type TrainFilters = {
|
|||||||
era: TrainEra | '';
|
era: TrainEra | '';
|
||||||
service: '' | 'yes' | 'no';
|
service: '' | 'yes' | 'no';
|
||||||
sort: TrainSort;
|
sort: TrainSort;
|
||||||
|
/** Area for the "most active" sort (?in=, default NL). */
|
||||||
|
area: string;
|
||||||
|
got: Got;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,12 +52,16 @@ export function parseFilters(params: URLSearchParams): TrainFilters {
|
|||||||
country: (params.get('country') ?? '').trim().slice(0, 80),
|
country: (params.get('country') ?? '').trim().slice(0, 80),
|
||||||
era: pick(params.get('era'), Object.keys(ERAS) as TrainEra[]),
|
era: pick(params.get('era'), Object.keys(ERAS) as TrainEra[]),
|
||||||
service: pick(params.get('service'), ['yes', 'no'] as const),
|
service: pick(params.get('service'), ['yes', 'no'] as const),
|
||||||
sort: sort && sort in SORTS ? (sort as TrainSort) : 'built',
|
sort: sort && sort in SORTS ? (sort as TrainSort) : 'active',
|
||||||
|
area: parseArea(params.get('in')),
|
||||||
|
got: parseGot(params.get('got')),
|
||||||
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardColumns = {
|
const cardColumns = {
|
||||||
|
id: trainTypes.id,
|
||||||
|
seen: seenSql('trains', trainTypes.id),
|
||||||
dex: trainTypes.dex,
|
dex: trainTypes.dex,
|
||||||
slug: trainTypes.slug,
|
slug: trainTypes.slug,
|
||||||
name: trainTypes.name,
|
name: trainTypes.name,
|
||||||
@@ -73,8 +82,13 @@ const cardColumns = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||||
const order = (sort: TrainSort) =>
|
const order = (sort: TrainSort, area = 'nl') =>
|
||||||
({
|
({
|
||||||
|
active: [
|
||||||
|
sql`${activitySql('trains', trainTypes.id, area)} desc`,
|
||||||
|
sql`${trainTypes.numberBuilt} desc nulls last`,
|
||||||
|
asc(trainTypes.dex)
|
||||||
|
],
|
||||||
built: [sql`${trainTypes.numberBuilt} desc nulls last`, asc(trainTypes.dex)],
|
built: [sql`${trainTypes.numberBuilt} desc nulls last`, asc(trainTypes.dex)],
|
||||||
dex: [asc(trainTypes.dex)],
|
dex: [asc(trainTypes.dex)],
|
||||||
name: [asc(trainTypes.fullName)],
|
name: [asc(trainTypes.fullName)],
|
||||||
@@ -107,6 +121,8 @@ export async function searchTrains(
|
|||||||
}
|
}
|
||||||
if (f.service === 'yes') where.push(eq(trainTypes.inService, true));
|
if (f.service === 'yes') where.push(eq(trainTypes.inService, true));
|
||||||
if (f.service === 'no') where.push(eq(trainTypes.inService, false));
|
if (f.service === 'no') where.push(eq(trainTypes.inService, false));
|
||||||
|
const got = gotSql('trains', trainTypes.id, f.got);
|
||||||
|
if (got) where.push(got);
|
||||||
const condition = where.length ? and(...where) : undefined;
|
const condition = where.length ? and(...where) : undefined;
|
||||||
const [trains, [{ total }]] = await Promise.all([
|
const [trains, [{ total }]] = await Promise.all([
|
||||||
db
|
db
|
||||||
@@ -114,7 +130,7 @@ export async function searchTrains(
|
|||||||
.from(trainTypes)
|
.from(trainTypes)
|
||||||
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
|
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
|
||||||
.where(condition)
|
.where(condition)
|
||||||
.orderBy(...order(f.sort))
|
.orderBy(...order(f.sort, f.area))
|
||||||
.limit(PAGE_SIZE)
|
.limit(PAGE_SIZE)
|
||||||
.offset((f.page - 1) * PAGE_SIZE),
|
.offset((f.page - 1) * PAGE_SIZE),
|
||||||
db
|
db
|
||||||
@@ -127,6 +143,7 @@ export async function searchTrains(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type MakerTile = {
|
export type MakerTile = {
|
||||||
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
types: number;
|
types: number;
|
||||||
@@ -165,6 +182,7 @@ export async function trainOverview(): Promise<Overview> {
|
|||||||
const [makers, operators, countries, [counts]] = await Promise.all([
|
const [makers, operators, countries, [counts]] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
|
id: trainMakers.id,
|
||||||
slug: trainMakers.slug,
|
slug: trainMakers.slug,
|
||||||
name: trainMakers.name,
|
name: trainMakers.name,
|
||||||
types: trainMakers.typeCount,
|
types: trainMakers.typeCount,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
type WildSort,
|
type WildSort,
|
||||||
type WildWhere
|
type WildWhere
|
||||||
} from '#lib/wildlife/shared.js';
|
} from '#lib/wildlife/shared.js';
|
||||||
|
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
|
||||||
|
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
|
||||||
|
|
||||||
export const PAGE_SIZE = 60;
|
export const PAGE_SIZE = 60;
|
||||||
|
|
||||||
@@ -21,6 +23,9 @@ export type WildFilters = {
|
|||||||
where: WildWhere | '';
|
where: WildWhere | '';
|
||||||
photos: '' | 'yes';
|
photos: '' | 'yes';
|
||||||
sort: WildSort;
|
sort: WildSort;
|
||||||
|
/** Area for the "most active" sort (?in=, default NL). */
|
||||||
|
area: string;
|
||||||
|
got: Got;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,12 +41,16 @@ export function parseFilters(params: URLSearchParams, group = ''): WildFilters {
|
|||||||
status: status === 'threatened' ? status : pick(status, STATUSES),
|
status: status === 'threatened' ? status : pick(status, STATUSES),
|
||||||
where: pick(params.get('where'), Object.keys(WHERE) as WildWhere[]),
|
where: pick(params.get('where'), Object.keys(WHERE) as WildWhere[]),
|
||||||
photos: pick(params.get('photos'), ['yes'] as const),
|
photos: pick(params.get('photos'), ['yes'] as const),
|
||||||
sort: sort && sort in SORTS ? (sort as WildSort) : 'nl',
|
sort: sort && sort in SORTS ? (sort as WildSort) : 'active',
|
||||||
|
area: parseArea(params.get('in')),
|
||||||
|
got: parseGot(params.get('got')),
|
||||||
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardColumns = {
|
const cardColumns = {
|
||||||
|
id: wildlifeSpecies.id,
|
||||||
|
seen: seenSql('wildlife', wildlifeSpecies.id),
|
||||||
dex: wildlifeSpecies.dex,
|
dex: wildlifeSpecies.dex,
|
||||||
slug: wildlifeSpecies.slug,
|
slug: wildlifeSpecies.slug,
|
||||||
groupSlug: wildlifeGroups.slug,
|
groupSlug: wildlifeGroups.slug,
|
||||||
@@ -77,7 +86,15 @@ export async function searchSpecies(
|
|||||||
if (f.where === 'world')
|
if (f.where === 'world')
|
||||||
where.push(and(eq(wildlifeSpecies.nlObs, 0), eq(wildlifeSpecies.europeObs, 0))!);
|
where.push(and(eq(wildlifeSpecies.nlObs, 0), eq(wildlifeSpecies.europeObs, 0))!);
|
||||||
if (f.photos) where.push(isNotNull(wildlifeSpecies.photo));
|
if (f.photos) where.push(isNotNull(wildlifeSpecies.photo));
|
||||||
|
const got = gotSql('wildlife', wildlifeSpecies.id, f.got);
|
||||||
|
if (got) where.push(got);
|
||||||
const order = {
|
const order = {
|
||||||
|
active: [
|
||||||
|
sql`${activitySql('wildlife', wildlifeSpecies.id, f.area)} desc`,
|
||||||
|
sql`${wildlifeSpecies.nlObs} desc`,
|
||||||
|
sql`${wildlifeSpecies.worldObs} desc`,
|
||||||
|
asc(wildlifeSpecies.dex)
|
||||||
|
],
|
||||||
nl: [
|
nl: [
|
||||||
sql`${wildlifeSpecies.nlObs} desc`,
|
sql`${wildlifeSpecies.nlObs} desc`,
|
||||||
sql`${wildlifeSpecies.worldObs} desc`,
|
sql`${wildlifeSpecies.worldObs} desc`,
|
||||||
@@ -114,6 +131,7 @@ export async function searchSpecies(
|
|||||||
|
|
||||||
type Overview = {
|
type Overview = {
|
||||||
groups: {
|
groups: {
|
||||||
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
nameNl: string;
|
nameNl: string;
|
||||||
@@ -132,6 +150,7 @@ export async function wildlifeOverview(): Promise<Overview> {
|
|||||||
const [groups, [counts]] = await Promise.all([
|
const [groups, [counts]] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
|
id: wildlifeGroups.id,
|
||||||
slug: wildlifeGroups.slug,
|
slug: wildlifeGroups.slug,
|
||||||
name: wildlifeGroups.name,
|
name: wildlifeGroups.name,
|
||||||
nameNl: wildlifeGroups.nameNl,
|
nameNl: wildlifeGroups.nameNl,
|
||||||
|
|||||||
31
src/lib/spotting/AreaSelect.svelte
Normal file
31
src/lib/spotting/AreaSelect.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// "most active in …" selector (?in=): countries and regions with precomputed scores.
|
||||||
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
|
import { AREA_OPTIONS, areaLabel } from './areas.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
available = [],
|
||||||
|
onchange
|
||||||
|
}: { value: string; available?: string[]; onchange?: () => void } = $props();
|
||||||
|
const has = $derived(new Set(available));
|
||||||
|
const known = $derived(
|
||||||
|
new Set([...AREA_OPTIONS.countries, ...AREA_OPTIONS.regions].map((o) => o.code))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NativeSelect name="in" aria-label="Most active in country or region" {value} {onchange}>
|
||||||
|
{#if !known.has(value)}<option {value}>📍 {areaLabel(value)}</option>{/if}
|
||||||
|
<optgroup label="Country">
|
||||||
|
{#each AREA_OPTIONS.countries as o (o.code)}
|
||||||
|
<option value={o.code} disabled={available.length > 0 && !has.has(o.code)}
|
||||||
|
>📍 {o.label}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Region">
|
||||||
|
{#each AREA_OPTIONS.regions as o (o.code)}
|
||||||
|
<option value={o.code} disabled={available.length > 0 && !has.has(o.code)}>{o.label}</option>
|
||||||
|
{/each}
|
||||||
|
</optgroup>
|
||||||
|
</NativeSelect>
|
||||||
154
src/lib/spotting/SeenPanel.svelte
Normal file
154
src/lib/spotting/SeenPanel.svelte
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Detail-page collection panel: the owner marks an entry as seen with an optional date
|
||||||
|
// and note; visitors see Stan's sighting read-only.
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { isSeen, saveSeen, type SpotTracker } from './seen.svelte.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
tracker,
|
||||||
|
id,
|
||||||
|
entry,
|
||||||
|
label
|
||||||
|
}: {
|
||||||
|
tracker: SpotTracker;
|
||||||
|
id: number;
|
||||||
|
entry: { seenOn: string | null; note: string | null } | null;
|
||||||
|
label: string;
|
||||||
|
} = $props();
|
||||||
|
const owner = $derived(!!page.data.isOwner);
|
||||||
|
const on = $derived(isSeen(tracker, id, !!entry));
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10);
|
||||||
|
let seenOn = $state('');
|
||||||
|
let note = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let failed = $state(false);
|
||||||
|
$effect.pre(() => {
|
||||||
|
seenOn = entry?.seenOn ?? '';
|
||||||
|
note = entry?.note ?? '';
|
||||||
|
});
|
||||||
|
async function save(seen: boolean) {
|
||||||
|
busy = true;
|
||||||
|
failed = false;
|
||||||
|
const ok = await saveSeen(tracker, id, seen, {
|
||||||
|
seenOn: seen ? seenOn || (entry ? null : today()) : null,
|
||||||
|
note: seen ? note : null
|
||||||
|
});
|
||||||
|
failed = !ok;
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if owner}
|
||||||
|
<section class={['panel', { on }]} aria-label="My collection">
|
||||||
|
<div class="head">
|
||||||
|
<strong>{on ? '✅ Collected' : '⬜ Not collected yet'}</strong>
|
||||||
|
<button type="button" class="btn primary" disabled={busy} onclick={() => save(!on)}>
|
||||||
|
{on ? 'Unmark' : `Mark ${label} as seen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if on}
|
||||||
|
<form
|
||||||
|
class="fields"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<span>seen on</span>
|
||||||
|
<input type="date" bind:value={seenOn} max={today()} />
|
||||||
|
</label>
|
||||||
|
<label class="grow">
|
||||||
|
<span>note</span>
|
||||||
|
<input type="text" bind:value={note} maxlength="500" placeholder="where, who with…" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn" disabled={busy}>save</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if failed}<p class="err">Couldn't save. Are you still logged in?</p>{/if}
|
||||||
|
</section>
|
||||||
|
{:else if on && entry}
|
||||||
|
<section class="panel on" aria-label="Stan's collection">
|
||||||
|
<strong>✅ Stan has seen this one</strong>{#if entry.seenOn}
|
||||||
|
<span class="muted"> on {entry.seenOn}</span>{/if}{#if entry.note}
|
||||||
|
<p class="muted mt-1">“{entry.note}”</p>{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
padding: 0.75rem 0.95rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.panel.on {
|
||||||
|
border-color: rgb(34 197 94 / 0.6);
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
.fields label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.fields .grow {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
}
|
||||||
|
.fields input {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--site-bg, transparent);
|
||||||
|
color: var(--site-fg, inherit);
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.3rem 0.85rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--site-surface);
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
}
|
||||||
|
.btn.primary {
|
||||||
|
background: rgb(22 163 74);
|
||||||
|
border-color: rgb(22 163 74);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.on .btn.primary {
|
||||||
|
background: var(--site-surface);
|
||||||
|
border-color: var(--site-border);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: rgb(239 68 68);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
src/lib/spotting/SpotItem.svelte
Normal file
88
src/lib/spotting/SpotItem.svelte
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Wraps a dex card: a ✓ badge when Stan has seen it, and for the owner a toggle button
|
||||||
|
// (kept outside the card's <a> so it stays valid, focusable HTML).
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { isSeen, saveSeen, type SpotTracker } from './seen.svelte.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
tracker,
|
||||||
|
id,
|
||||||
|
seen = false,
|
||||||
|
label,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
tracker: SpotTracker;
|
||||||
|
id: number;
|
||||||
|
seen?: boolean;
|
||||||
|
label: string;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
const owner = $derived(!!page.data.isOwner);
|
||||||
|
const on = $derived(isSeen(tracker, id, seen));
|
||||||
|
let busy = $state(false);
|
||||||
|
async function toggle() {
|
||||||
|
busy = true;
|
||||||
|
await saveSeen(tracker, id, !on);
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={['spot', { on }]}>
|
||||||
|
{@render children()}
|
||||||
|
{#if owner}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mark"
|
||||||
|
aria-pressed={on}
|
||||||
|
disabled={busy}
|
||||||
|
onclick={toggle}
|
||||||
|
title={on ? `Collected. Click to unmark ${label}` : `Mark ${label} as seen`}
|
||||||
|
>{on ? '✓' : '+'}<span class="sr-only">{on ? 'collected' : 'mark as seen'}</span></button
|
||||||
|
>
|
||||||
|
{:else if on}
|
||||||
|
<span class="mark" title="Stan has seen this one">✓<span class="sr-only">collected</span></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spot {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.spot.on > :global(a) {
|
||||||
|
box-shadow: 0 0 0 2px rgb(34 197 94 / 0.75);
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
border: 1.5px solid rgb(255 255 255 / 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
button.mark:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
background: rgb(0 0 0 / 0.75);
|
||||||
|
}
|
||||||
|
.on .mark {
|
||||||
|
background: rgb(22 163 74);
|
||||||
|
border-color: rgb(187 247 208);
|
||||||
|
}
|
||||||
|
button.mark:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
src/lib/spotting/SpotProgress.svelte
Normal file
98
src/lib/spotting/SpotProgress.svelte
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// "✅ 12 / 3,694 collected" with a bar; read-only for visitors.
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
||||||
|
import { GOT } from './areas.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
seen,
|
||||||
|
total,
|
||||||
|
label = 'collected',
|
||||||
|
href = null,
|
||||||
|
got = null
|
||||||
|
}: {
|
||||||
|
seen: number;
|
||||||
|
total: number;
|
||||||
|
label?: string;
|
||||||
|
href?: string | null;
|
||||||
|
/** When set, shows all / collected / still-to-go chips that set ?got= on this page. */
|
||||||
|
got?: string | null;
|
||||||
|
} = $props();
|
||||||
|
const gotHref = (value: string) => {
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- throwaway copy for a link
|
||||||
|
const params = new URLSearchParams(page.url.searchParams.toString());
|
||||||
|
if (value) params.set('got', value);
|
||||||
|
else params.delete('got');
|
||||||
|
const qs = params.toString();
|
||||||
|
return `${page.url.pathname}${qs ? `?${qs}` : ''}`;
|
||||||
|
};
|
||||||
|
const pct = $derived(total ? Math.round((seen / total) * 1000) / 10 : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="progress">
|
||||||
|
<div class="row">
|
||||||
|
<span
|
||||||
|
>✅ <strong class="tabular-nums">{seen.toLocaleString('en-GB')}</strong>
|
||||||
|
<span class="muted">/ {total.toLocaleString('en-GB')} {label}</span></span
|
||||||
|
>
|
||||||
|
<span class="muted tabular-nums"
|
||||||
|
>{pct}%{#if href}
|
||||||
|
· <a {href} class="underline">to go →</a>{/if}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={seen} max={total} label="{seen} of {total} {label}" small />
|
||||||
|
{#if got !== null}
|
||||||
|
<div class="chips">
|
||||||
|
{#each Object.entries(GOT) as [key, text] (key)}
|
||||||
|
<a
|
||||||
|
href={gotHref(key)}
|
||||||
|
class={['chip', { active: got === key }]}
|
||||||
|
aria-current={got === key ? 'true' : undefined}
|
||||||
|
data-sveltekit-noscroll
|
||||||
|
data-sveltekit-replacestate>{text}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
}
|
||||||
|
.chip.active {
|
||||||
|
border-color: var(--site-muted);
|
||||||
|
background: var(--site-hover);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
277
src/lib/spotting/areas.ts
Normal file
277
src/lib/spotting/areas.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// Countries and regions for the spotting pokedexes' "most active in ..." sort
|
||||||
|
// (/trackers/cars, planes, trains, wildlife). Codes are lowercase ISO 3166-1 alpha-2
|
||||||
|
// ("nl") or a region code ("europe"). Scores per area are precomputed into each
|
||||||
|
// tracker's `activity` table by scripts/spotters-activity.ts; a region's score is the sum
|
||||||
|
// of its countries' scores (wildlife: iNaturalist's own Europe / world counts).
|
||||||
|
|
||||||
|
export const DEFAULT_AREA = 'nl';
|
||||||
|
|
||||||
|
export type Region = { code: string; name: string; emoji: string; countries: readonly string[] };
|
||||||
|
|
||||||
|
const EUROPE = [
|
||||||
|
'nl',
|
||||||
|
'be',
|
||||||
|
'lu',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
'gb',
|
||||||
|
'ie',
|
||||||
|
'es',
|
||||||
|
'pt',
|
||||||
|
'it',
|
||||||
|
'ch',
|
||||||
|
'at',
|
||||||
|
'dk',
|
||||||
|
'se',
|
||||||
|
'no',
|
||||||
|
'fi',
|
||||||
|
'is',
|
||||||
|
'pl',
|
||||||
|
'cz',
|
||||||
|
'sk',
|
||||||
|
'hu',
|
||||||
|
'si',
|
||||||
|
'hr',
|
||||||
|
'ba',
|
||||||
|
'rs',
|
||||||
|
'me',
|
||||||
|
'mk',
|
||||||
|
'al',
|
||||||
|
'gr',
|
||||||
|
'bg',
|
||||||
|
'ro',
|
||||||
|
'md',
|
||||||
|
'ua',
|
||||||
|
'by',
|
||||||
|
'lt',
|
||||||
|
'lv',
|
||||||
|
'ee',
|
||||||
|
'ru',
|
||||||
|
'mt',
|
||||||
|
'cy',
|
||||||
|
'li',
|
||||||
|
'mc',
|
||||||
|
'sm',
|
||||||
|
'ad',
|
||||||
|
'im',
|
||||||
|
'je',
|
||||||
|
'gg',
|
||||||
|
'xk',
|
||||||
|
'tr',
|
||||||
|
'ge',
|
||||||
|
'am',
|
||||||
|
'az'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const REGIONS: readonly Region[] = [
|
||||||
|
{ code: 'benelux', name: 'Benelux', emoji: '🧇', countries: ['nl', 'be', 'lu'] },
|
||||||
|
{ code: 'europe', name: 'Europe', emoji: '🇪🇺', countries: EUROPE },
|
||||||
|
{
|
||||||
|
code: 'north-america',
|
||||||
|
name: 'North America',
|
||||||
|
emoji: '🌎',
|
||||||
|
countries: ['us', 'ca', 'mx', 'cu', 'gt', 'cr', 'pa', 'jm', 'do', 'hn', 'sv', 'ni', 'bs', 'pr']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'south-america',
|
||||||
|
name: 'South America',
|
||||||
|
emoji: '🌎',
|
||||||
|
countries: ['br', 'ar', 'cl', 'co', 'pe', 've', 'ec', 'bo', 'py', 'uy', 'gy', 'sr']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'asia',
|
||||||
|
name: 'Asia',
|
||||||
|
emoji: '🌏',
|
||||||
|
countries: [
|
||||||
|
'jp',
|
||||||
|
'cn',
|
||||||
|
'kr',
|
||||||
|
'kp',
|
||||||
|
'tw',
|
||||||
|
'hk',
|
||||||
|
'in',
|
||||||
|
'id',
|
||||||
|
'th',
|
||||||
|
'vn',
|
||||||
|
'my',
|
||||||
|
'sg',
|
||||||
|
'ph',
|
||||||
|
'pk',
|
||||||
|
'bd',
|
||||||
|
'lk',
|
||||||
|
'mm',
|
||||||
|
'kh',
|
||||||
|
'la',
|
||||||
|
'np',
|
||||||
|
'mn',
|
||||||
|
'kz',
|
||||||
|
'uz',
|
||||||
|
'tm',
|
||||||
|
'kg',
|
||||||
|
'tj',
|
||||||
|
'ir',
|
||||||
|
'iq',
|
||||||
|
'sa',
|
||||||
|
'ae',
|
||||||
|
'il',
|
||||||
|
'jo',
|
||||||
|
'sy',
|
||||||
|
'lb',
|
||||||
|
'qa',
|
||||||
|
'kw',
|
||||||
|
'om',
|
||||||
|
'bh',
|
||||||
|
'ye',
|
||||||
|
'af',
|
||||||
|
'mo',
|
||||||
|
'bt',
|
||||||
|
'bn'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'africa',
|
||||||
|
name: 'Africa',
|
||||||
|
emoji: '🌍',
|
||||||
|
countries: [
|
||||||
|
'za',
|
||||||
|
'eg',
|
||||||
|
'ma',
|
||||||
|
'dz',
|
||||||
|
'tn',
|
||||||
|
'ly',
|
||||||
|
'ng',
|
||||||
|
'ke',
|
||||||
|
'tz',
|
||||||
|
'ug',
|
||||||
|
'et',
|
||||||
|
'gh',
|
||||||
|
'zw',
|
||||||
|
'zm',
|
||||||
|
'mz',
|
||||||
|
'na',
|
||||||
|
'bw',
|
||||||
|
'ao',
|
||||||
|
'cd',
|
||||||
|
'cg',
|
||||||
|
'cm',
|
||||||
|
'sn',
|
||||||
|
'ci',
|
||||||
|
'sd',
|
||||||
|
'mg',
|
||||||
|
'mw',
|
||||||
|
'rw',
|
||||||
|
'sz',
|
||||||
|
'ls',
|
||||||
|
'er',
|
||||||
|
'sl',
|
||||||
|
'ga',
|
||||||
|
'ml',
|
||||||
|
'ne',
|
||||||
|
'bf',
|
||||||
|
'td',
|
||||||
|
'so',
|
||||||
|
'mu'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'oceania',
|
||||||
|
name: 'Oceania',
|
||||||
|
emoji: '🦘',
|
||||||
|
countries: ['au', 'nz', 'pg', 'fj', 'nc', 'pf', 'sb', 'vu', 'ws', 'to']
|
||||||
|
},
|
||||||
|
{ code: 'world', name: 'the whole world', emoji: '🌐', countries: [] }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Countries offered in the selector (any ISO code with data also works via ?in=xx). */
|
||||||
|
export const COUNTRIES = [
|
||||||
|
'nl',
|
||||||
|
'be',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
'gb',
|
||||||
|
'ie',
|
||||||
|
'es',
|
||||||
|
'pt',
|
||||||
|
'it',
|
||||||
|
'ch',
|
||||||
|
'at',
|
||||||
|
'dk',
|
||||||
|
'se',
|
||||||
|
'no',
|
||||||
|
'fi',
|
||||||
|
'pl',
|
||||||
|
'cz',
|
||||||
|
'hu',
|
||||||
|
'gr',
|
||||||
|
'tr',
|
||||||
|
'ru',
|
||||||
|
'ua',
|
||||||
|
'us',
|
||||||
|
'ca',
|
||||||
|
'mx',
|
||||||
|
'br',
|
||||||
|
'ar',
|
||||||
|
'jp',
|
||||||
|
'cn',
|
||||||
|
'kr',
|
||||||
|
'tw',
|
||||||
|
'in',
|
||||||
|
'id',
|
||||||
|
'th',
|
||||||
|
'au',
|
||||||
|
'nz',
|
||||||
|
'za'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const regionByCode = new Map(REGIONS.map((r) => [r.code, r]));
|
||||||
|
export const isRegion = (code: string) => regionByCode.has(code);
|
||||||
|
export const regionOf = (code: string) => regionByCode.get(code) ?? null;
|
||||||
|
|
||||||
|
let names: Intl.DisplayNames | null = null;
|
||||||
|
/** "nl" -> "the Netherlands", "europe" -> "Europe". */
|
||||||
|
export function areaName(code: string): string {
|
||||||
|
const region = regionByCode.get(code);
|
||||||
|
if (region) return region.name;
|
||||||
|
if (code === 'nl') return 'the Netherlands';
|
||||||
|
if (code === 'us') return 'the USA';
|
||||||
|
if (code === 'gb') return 'the UK';
|
||||||
|
try {
|
||||||
|
names ??= new Intl.DisplayNames(['en'], { type: 'region' });
|
||||||
|
return names.of(code.toUpperCase()) ?? code.toUpperCase();
|
||||||
|
} catch {
|
||||||
|
return code.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short label for chips and selects: "🇳🇱 Netherlands", "🇪🇺 Europe". */
|
||||||
|
export function areaLabel(code: string): string {
|
||||||
|
const region = regionByCode.get(code);
|
||||||
|
if (region) return `${region.emoji} ${code === 'world' ? 'World' : region.name}`;
|
||||||
|
return `${flagOf(code)} ${areaName(code).replace(/^the /, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flagOf(code: string): string {
|
||||||
|
if (!/^[a-z]{2}$/.test(code)) return '🏳️';
|
||||||
|
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ?in= value -> a valid area code (default NL). */
|
||||||
|
export function parseArea(value: string | null | undefined): string {
|
||||||
|
const v = (value ?? '').trim().toLowerCase();
|
||||||
|
if (regionByCode.has(v)) return v;
|
||||||
|
if (/^[a-z]{2}$/.test(v)) return v;
|
||||||
|
return DEFAULT_AREA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selector options, grouped: countries then regions. */
|
||||||
|
export const AREA_OPTIONS = {
|
||||||
|
countries: COUNTRIES.map((code) => ({ code, label: areaLabel(code) })).sort((a, b) =>
|
||||||
|
a.code === 'nl' ? -1 : b.code === 'nl' ? 1 : a.label.slice(5).localeCompare(b.label.slice(5))
|
||||||
|
),
|
||||||
|
regions: REGIONS.map((r) => ({ code: r.code, label: areaLabel(r.code) }))
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Collection filter (?got=). */
|
||||||
|
export const GOT = { '': 'all', yes: '✅ collected', no: '⬜ still to go' } as const;
|
||||||
|
export type Got = keyof typeof GOT;
|
||||||
|
export const parseGot = (v: string | null): Got => (v === 'yes' || v === 'no' ? v : '');
|
||||||
50
src/lib/spotting/seen.svelte.ts
Normal file
50
src/lib/spotting/seen.svelte.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Client-side collection state for the spotting pokedexes: optimistic overrides on top of
|
||||||
|
// what the server sent, so a toggle updates every card/panel for that entry at once.
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
|
export type SpotTracker = 'cars' | 'planes' | 'trains' | 'wildlife';
|
||||||
|
|
||||||
|
const overrides = $state<Record<string, boolean>>({});
|
||||||
|
const key = (tracker: SpotTracker, id: number) => `${tracker}:${id}`;
|
||||||
|
|
||||||
|
export function isSeen(tracker: SpotTracker, id: number, fromServer: boolean): boolean {
|
||||||
|
const k = key(tracker, id);
|
||||||
|
return k in overrides ? overrides[k] : fromServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner-only POST; reverts the optimistic state when the server refuses. */
|
||||||
|
export async function saveSeen(
|
||||||
|
tracker: SpotTracker,
|
||||||
|
id: number,
|
||||||
|
seen: boolean,
|
||||||
|
extra: { seenOn?: string | null; note?: string | null } = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const k = key(tracker, id);
|
||||||
|
const before = k in overrides ? overrides[k] : undefined;
|
||||||
|
overrides[k] = seen;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/trackers/${tracker}/seen`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, seen, ...extra })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
await invalidateAll();
|
||||||
|
delete overrides[k];
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
if (before === undefined) delete overrides[k];
|
||||||
|
else overrides[k] = before;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasSeen = { id: number; seen: boolean };
|
||||||
|
/** How many of these entries are collected (respecting optimistic toggles). */
|
||||||
|
export const countSeen = (tracker: SpotTracker, items: HasSeen[]) =>
|
||||||
|
items.reduce((n, x) => n + (isSeen(tracker, x.id, x.seen) ? 1 : 0), 0);
|
||||||
|
/** Client-side ?got= filter for group pages that list every entry at once. */
|
||||||
|
export function filterGot<T extends HasSeen>(tracker: SpotTracker, items: T[], got: string): T[] {
|
||||||
|
if (got !== 'yes' && got !== 'no') return items;
|
||||||
|
return items.filter((x) => isSeen(tracker, x.id, x.seen) === (got === 'yes'));
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
class={['tile', { compact }]}
|
class={['tile', { compact }]}
|
||||||
title={maker.name}
|
title={maker.name}
|
||||||
>
|
>
|
||||||
|
{#if maker.seen}<span class="got" title="{maker.seen} collected">✓ {maker.seen}</span>{/if}
|
||||||
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
|
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
|
||||||
<span class="name">{maker.name}</span>
|
<span class="name">{maker.name}</span>
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
@@ -22,7 +23,20 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.got {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(22 163 74);
|
||||||
|
}
|
||||||
.tile {
|
.tile {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const ERAS = {
|
|||||||
} as const;
|
} as const;
|
||||||
export type TrainEra = keyof typeof ERAS;
|
export type TrainEra = keyof typeof ERAS;
|
||||||
export const SORTS = {
|
export const SORTS = {
|
||||||
|
active: 'most active in…',
|
||||||
built: 'most built',
|
built: 'most built',
|
||||||
dex: 'dex #',
|
dex: 'dex #',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
@@ -205,6 +206,8 @@ export const fmtM = (m: number) => `${m >= 100 ? Math.round(m) : Math.round(m *
|
|||||||
export const fmtT = (t: number) => `${t >= 100 ? Math.round(t) : Math.round(t * 10) / 10} t`;
|
export const fmtT = (t: number) => `${t >= 100 ? Math.round(t) : Math.round(t * 10) / 10} t`;
|
||||||
|
|
||||||
export type TrainCard = {
|
export type TrainCard = {
|
||||||
|
id: number;
|
||||||
|
seen: boolean;
|
||||||
dex: number;
|
dex: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -225,6 +228,8 @@ export type TrainCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type MakerTileData = {
|
export type MakerTileData = {
|
||||||
|
/** Entries Stan has collected in this group (optional). */
|
||||||
|
seen?: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
types: number;
|
types: number;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
title="{group.name} · {group.nameNl}"
|
title="{group.name} · {group.nameNl}"
|
||||||
style:--hue={groupHue(group.slug)}
|
style:--hue={groupHue(group.slug)}
|
||||||
>
|
>
|
||||||
|
{#if group.seen}<span class="got" title="{group.seen} collected">✓ {group.seen}</span>{/if}
|
||||||
{#if group.photo}
|
{#if group.photo}
|
||||||
<img
|
<img
|
||||||
src={photoAt(group.photo, compact ? 'small' : 'medium')}
|
src={photoAt(group.photo, compact ? 'small' : 'medium')}
|
||||||
@@ -31,6 +32,18 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.got {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(22 163 74);
|
||||||
|
}
|
||||||
.tile {
|
.tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
WHERE,
|
WHERE,
|
||||||
type SpeciesCard as Card
|
type SpeciesCard as Card
|
||||||
} from './shared.js';
|
} from './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 { Input } from '#lib/components/ui/input/index.js';
|
||||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
import Search from 'lucide-svelte/icons/search';
|
import Search from 'lucide-svelte/icons/search';
|
||||||
@@ -22,7 +26,10 @@
|
|||||||
total,
|
total,
|
||||||
pageSize,
|
pageSize,
|
||||||
groups = null,
|
groups = null,
|
||||||
quick = []
|
quick = [],
|
||||||
|
areas = [],
|
||||||
|
collected = null,
|
||||||
|
collectedLabel = 'species collected'
|
||||||
}: {
|
}: {
|
||||||
base: string;
|
base: string;
|
||||||
filters: WildFilters;
|
filters: WildFilters;
|
||||||
@@ -31,11 +38,22 @@
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
groups?: { slug: string; name: string; emoji: string; speciesCount: number }[] | null;
|
groups?: { slug: string; name: string; emoji: string; speciesCount: number }[] | null;
|
||||||
quick?: { label: string; params: Record<string, string> }[];
|
quick?: { label: string; params: Record<string, string> }[];
|
||||||
|
areas?: string[];
|
||||||
|
collected?: { seen: number; total: number } | null;
|
||||||
|
collectedLabel?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const pages = $derived(Math.max(1, Math.ceil(total / pageSize)));
|
const pages = $derived(Math.max(1, Math.ceil(total / pageSize)));
|
||||||
const filtering = $derived(
|
const filtering = $derived(
|
||||||
!!(f.q || (groups && f.group) || f.status || f.where || f.photos || f.sort !== 'nl')
|
!!(
|
||||||
|
f.q ||
|
||||||
|
(groups && f.group) ||
|
||||||
|
f.status ||
|
||||||
|
f.where ||
|
||||||
|
f.photos ||
|
||||||
|
f.got ||
|
||||||
|
f.sort !== 'active'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let form: HTMLFormElement | undefined = $state();
|
let form: HTMLFormElement | undefined = $state();
|
||||||
@@ -61,6 +79,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if collected}
|
||||||
|
<div class="mb-5">
|
||||||
|
<SpotProgress
|
||||||
|
seen={collected.seen}
|
||||||
|
total={collected.total}
|
||||||
|
label={collectedLabel}
|
||||||
|
href={withParams({ got: 'no' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
bind:this={form}
|
bind:this={form}
|
||||||
method="GET"
|
method="GET"
|
||||||
@@ -104,6 +133,10 @@
|
|||||||
<NativeSelect name="sort" aria-label="Sort by" value={f.sort} onchange={submit}>
|
<NativeSelect name="sort" aria-label="Sort by" value={f.sort} onchange={submit}>
|
||||||
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option>{/each}
|
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option>{/each}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
|
{#if f.sort === 'active'}<AreaSelect value={f.area} available={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>
|
<noscript><button type="submit" class="pill">search</button></noscript>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -126,7 +159,16 @@
|
|||||||
{#if species.length}
|
{#if species.length}
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{#each species as s, i (s.dex)}
|
{#each species as s, i (s.dex)}
|
||||||
<li><SpeciesCard species={s} eager={i < 4} /></li>
|
<li>
|
||||||
|
<SpotItem
|
||||||
|
tracker="wildlife"
|
||||||
|
id={s.id}
|
||||||
|
seen={s.seen}
|
||||||
|
label={s.nameEn ?? s.nameNl ?? s.scientificName}
|
||||||
|
>
|
||||||
|
<SpeciesCard species={s} eager={i < 4} />
|
||||||
|
</SpotItem>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const WHERE = {
|
|||||||
export type WildWhere = keyof typeof WHERE;
|
export type WildWhere = keyof typeof WHERE;
|
||||||
|
|
||||||
export const SORTS = {
|
export const SORTS = {
|
||||||
|
active: 'most active in…',
|
||||||
nl: 'most seen in NL',
|
nl: 'most seen in NL',
|
||||||
world: 'most seen worldwide',
|
world: 'most seen worldwide',
|
||||||
dex: 'dex #',
|
dex: 'dex #',
|
||||||
@@ -65,6 +66,8 @@ export const SORTS = {
|
|||||||
export type WildSort = keyof typeof SORTS;
|
export type WildSort = keyof typeof SORTS;
|
||||||
|
|
||||||
export type SpeciesCard = {
|
export type SpeciesCard = {
|
||||||
|
id: number;
|
||||||
|
seen: boolean;
|
||||||
dex: number;
|
dex: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
groupSlug: string;
|
groupSlug: string;
|
||||||
@@ -80,6 +83,8 @@ export type SpeciesCard = {
|
|||||||
photoCount: number;
|
photoCount: number;
|
||||||
};
|
};
|
||||||
export type GroupTileData = {
|
export type GroupTileData = {
|
||||||
|
/** Entries Stan has collected in this group (optional). */
|
||||||
|
seen?: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
nameNl: string;
|
nameNl: string;
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import { carOverview, PAGE_SIZE, parseFilters, searchCars } from '#lib/server/cars.js';
|
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';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const filters = parseFilters(url.searchParams);
|
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 {
|
return {
|
||||||
filters,
|
filters,
|
||||||
cars,
|
cars,
|
||||||
total,
|
total,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
|
collected,
|
||||||
|
areas,
|
||||||
brands: overview.brands.map(({ slug, name, models }) => ({ slug, name, models })),
|
brands: overview.brands.map(({ slug, name, models }) => ({ slug, name, models })),
|
||||||
topBrands: overview.byNl
|
topBrands: rankByActivity(overview.byNl, act)
|
||||||
.slice(0, 16)
|
.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,
|
stats: overview.stats,
|
||||||
currentYear: new Date().getFullYear(),
|
currentYear: new Date().getFullYear(),
|
||||||
seo: {
|
seo: {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
LOGO_NOTE,
|
LOGO_NOTE,
|
||||||
SORTS
|
SORTS
|
||||||
} from '#lib/cars/shared.js';
|
} 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 { Input } from '#lib/components/ui/input/index.js';
|
||||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
import Search from 'lucide-svelte/icons/search';
|
import Search from 'lucide-svelte/icons/search';
|
||||||
@@ -26,7 +30,7 @@
|
|||||||
const f = $derived(data.filters);
|
const f = $derived(data.filters);
|
||||||
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
||||||
const filtering = $derived(
|
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();
|
let form: HTMLFormElement | undefined = $state();
|
||||||
@@ -51,13 +55,14 @@
|
|||||||
return `/trackers/cars${qs ? `?${qs}` : ''}`;
|
return `/trackers/cars${qs ? `?${qs}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quick = [
|
const quick: { label: string; params: Record<string, string> }[] = [
|
||||||
{ label: '📖 dex order', params: { sort: 'dex' } },
|
{ label: '📖 dex order', params: { sort: 'dex' } },
|
||||||
{ label: '⚡ electric', params: { fuel: 'electric', sort: 'popular' } },
|
{ label: '⚡ electric', params: { fuel: 'electric', sort: 'popular' } },
|
||||||
{ label: '🍃 hybrids', params: { fuel: 'hybrid', sort: 'popular' } },
|
{ label: '🍃 hybrids', params: { fuel: 'hybrid', sort: 'popular' } },
|
||||||
{ label: '🏎️ coupés', params: { body: 'coupe', sort: 'popular' } },
|
{ label: '🏎️ coupés', params: { body: 'coupe', sort: 'popular' } },
|
||||||
{ label: '💪 most powerful', params: { sort: 'power' } },
|
{ label: '💪 most powerful', params: { sort: 'power' } },
|
||||||
{ label: '🐎 Mustang', params: { q: 'mustang' } }
|
{ label: '🐎 Mustang', params: { q: 'mustang' } },
|
||||||
|
{ label: '⬜ still to go', params: { got: 'no' } }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -94,6 +99,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</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}
|
{#if !filtering && f.page === 1}
|
||||||
<section class="mb-8" aria-labelledby="brands-heading">
|
<section class="mb-8" aria-labelledby="brands-heading">
|
||||||
<div class="mb-3 flex items-baseline justify-between gap-3">
|
<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 Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</NativeSelect>
|
</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>
|
<noscript><button type="submit" class="pill">search</button></noscript>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -206,7 +228,11 @@
|
|||||||
{#if data.cars.length}
|
{#if data.cars.length}
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{#each data.cars as car, i (car.dex)}
|
{#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}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -10,11 +10,18 @@
|
|||||||
thumbSrcset,
|
thumbSrcset,
|
||||||
years
|
years
|
||||||
} from '#lib/cars/shared.js';
|
} 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 ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
const b = $derived(data.brand);
|
const b = $derived(data.brand);
|
||||||
|
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||||
|
const shown = $derived(filterGot('cars', data.models, got));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
@@ -57,9 +64,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<ul class="grid">
|
||||||
{#each data.models as car, i (car.dex)}
|
{#each shown 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}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getModel } from '#lib/server/cars.js';
|
import { getModel } from '#lib/server/cars.js';
|
||||||
import type { carModels, carVariants } from '#lib/server/db/schema/index.js';
|
import type { carModels, carVariants } from '#lib/server/db/schema/index.js';
|
||||||
|
import { getSeen } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
type Model = typeof carModels.$inferSelect;
|
type Model = typeof carModels.$inferSelect;
|
||||||
@@ -32,7 +33,9 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
const found = await getModel(params.brand, params.model);
|
const found = await getModel(params.brand, params.model);
|
||||||
if (!found) error(404, 'No such car in the pokedex');
|
if (!found) error(404, 'No such car in the pokedex');
|
||||||
const { model, brand, variants, photos, prev, next } = found;
|
const { model, brand, variants, photos, prev, next } = found;
|
||||||
|
const seenEntry = await getSeen('cars', model.id);
|
||||||
return {
|
return {
|
||||||
|
spot: { id: model.id, entry: seenEntry },
|
||||||
car: publicCar(model),
|
car: publicCar(model),
|
||||||
brand: { slug: brand.slug, name: brand.name, country: brand.country, logo: brand.logo },
|
brand: { slug: brand.slug, name: brand.name, country: brand.country, logo: brand.logo },
|
||||||
variants: variants.map(publicVariant),
|
variants: variants.map(publicVariant),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
import Shuffle from 'lucide-svelte/icons/shuffle';
|
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||||
|
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -174,6 +175,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
<SeenPanel tracker="cars" id={data.spot.id} entry={data.spot.entry} label="this one" />
|
||||||
|
|
||||||
{#if data.photos.length}
|
{#if data.photos.length}
|
||||||
<Gallery photos={data.photos} name="{data.brand.name} {car.name}" wikipedia={car.wikipedia} />
|
<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 { carOverview } from '#lib/server/cars.js';
|
||||||
|
import { seenByGroup } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const { byNl, stats } = await carOverview();
|
const [{ byNl, stats }, seen] = await Promise.all([carOverview(), seenByGroup('cars')]);
|
||||||
return {
|
return {
|
||||||
brands: byNl.map(({ slug, name, models, nlCount, logo }) => ({
|
brands: byNl.map(({ id, slug, name, models, nlCount, logo }) => ({
|
||||||
|
seen: seen.get(id) ?? 0,
|
||||||
slug,
|
slug,
|
||||||
name,
|
name,
|
||||||
models,
|
models,
|
||||||
|
|||||||
14
src/routes/trackers/cars/seen/+server.ts
Normal file
14
src/routes/trackers/cars/seen/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
@@ -1,18 +1,41 @@
|
|||||||
import { PAGE_SIZE, parseFilters, planeOverview, searchPlanes } from '#lib/server/planes.js';
|
import { PAGE_SIZE, parseFilters, planeOverview, searchPlanes } from '#lib/server/planes.js';
|
||||||
|
import {
|
||||||
|
areasWithData,
|
||||||
|
groupActivity,
|
||||||
|
progress,
|
||||||
|
rankByActivity,
|
||||||
|
seenByGroup
|
||||||
|
} from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const filters = parseFilters(url.searchParams);
|
const filters = parseFilters(url.searchParams);
|
||||||
const [{ planes, total }, overview] = await Promise.all([searchPlanes(filters), planeOverview()]);
|
const [{ planes, total }, overview, collected, seenMakers, areas, act] = await Promise.all([
|
||||||
|
searchPlanes(filters),
|
||||||
|
planeOverview(),
|
||||||
|
progress('planes'),
|
||||||
|
seenByGroup('planes'),
|
||||||
|
areasWithData('planes'),
|
||||||
|
groupActivity('planes', filters.area)
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
planes,
|
planes,
|
||||||
total,
|
total,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
|
collected,
|
||||||
|
areas,
|
||||||
makers: overview.makers.map(({ slug, name, types }) => ({ slug, name, types })),
|
makers: overview.makers.map(({ slug, name, types }) => ({ slug, name, types })),
|
||||||
topMakers: overview.byBuilt
|
topMakers: rankByActivity(overview.byBuilt, act)
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
|
.map(({ id, slug, name, types, built, logo }) => ({
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
types,
|
||||||
|
built,
|
||||||
|
logo,
|
||||||
|
seen: seenMakers.get(id) ?? 0
|
||||||
|
})),
|
||||||
stats: overview.stats,
|
stats: overview.stats,
|
||||||
seo: {
|
seo: {
|
||||||
title: 'Plane pokedex',
|
title: 'Plane pokedex',
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
roleLabel,
|
roleLabel,
|
||||||
SORTS
|
SORTS
|
||||||
} from '#lib/planes/shared.js';
|
} from '#lib/planes/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 { Input } from '#lib/components/ui/input/index.js';
|
||||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
import Search from 'lucide-svelte/icons/search';
|
import Search from 'lucide-svelte/icons/search';
|
||||||
@@ -25,7 +29,9 @@
|
|||||||
|
|
||||||
const f = $derived(data.filters);
|
const f = $derived(data.filters);
|
||||||
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
||||||
const filtering = $derived(!!(f.q || f.maker || f.role || f.engine || f.era || f.service));
|
const filtering = $derived(
|
||||||
|
!!(f.q || f.maker || f.role || f.engine || f.era || f.service || f.got)
|
||||||
|
);
|
||||||
|
|
||||||
let form: HTMLFormElement | undefined = $state();
|
let form: HTMLFormElement | undefined = $state();
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
@@ -49,7 +55,7 @@
|
|||||||
return `/trackers/planes${qs ? `?${qs}` : ''}`;
|
return `/trackers/planes${qs ? `?${qs}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quick = [
|
const quick: { label: string; params: Record<string, string> }[] = [
|
||||||
{ label: '📖 dex order', params: { sort: 'dex' } },
|
{ label: '📖 dex order', params: { sort: 'dex' } },
|
||||||
{ label: '🛫 airliners', params: { role: 'airliner', sort: 'built' } },
|
{ label: '🛫 airliners', params: { role: 'airliner', sort: 'built' } },
|
||||||
{ label: '🚁 helicopters', params: { role: 'helicopter', sort: 'built' } },
|
{ label: '🚁 helicopters', params: { role: 'helicopter', sort: 'built' } },
|
||||||
@@ -57,7 +63,8 @@
|
|||||||
{ label: '🎖️ WW2 warbirds', params: { role: 'military', era: 'ww2', sort: 'built' } },
|
{ label: '🎖️ WW2 warbirds', params: { role: 'military', era: 'ww2', sort: 'built' } },
|
||||||
{ label: '🚀 fastest', params: { sort: 'fastest' } },
|
{ label: '🚀 fastest', params: { sort: 'fastest' } },
|
||||||
{ label: '🐘 heaviest', params: { sort: 'biggest' } },
|
{ label: '🐘 heaviest', params: { sort: 'biggest' } },
|
||||||
{ label: '✈️ 737', params: { q: '737' } }
|
{ label: '✈️ 737', params: { q: '737' } },
|
||||||
|
{ label: '⬜ still to go', params: { got: 'no' } }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -94,6 +101,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<SpotProgress
|
||||||
|
seen={data.collected.seen}
|
||||||
|
total={data.collected.total}
|
||||||
|
label="types collected"
|
||||||
|
href={withParams({ got: 'no' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !filtering && f.page === 1}
|
{#if !filtering && f.page === 1}
|
||||||
<section class="mb-8" aria-labelledby="makers-heading">
|
<section class="mb-8" aria-labelledby="makers-heading">
|
||||||
<div class="mb-3 flex items-baseline justify-between gap-3">
|
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||||
@@ -159,6 +175,14 @@
|
|||||||
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</NativeSelect>
|
</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>
|
<noscript><button type="submit" class="pill">search</button></noscript>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -181,7 +205,11 @@
|
|||||||
{#if data.planes.length}
|
{#if data.planes.length}
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{#each data.planes as plane, i (plane.dex)}
|
{#each data.planes as plane, i (plane.dex)}
|
||||||
<li><PlaneCard {plane} eager={i < 4} /></li>
|
<li>
|
||||||
|
<SpotItem tracker="planes" id={plane.id} seen={plane.seen} label={plane.fullName}>
|
||||||
|
<PlaneCard {plane} eager={i < 4} />
|
||||||
|
</SpotItem>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -11,10 +11,17 @@
|
|||||||
thumbSrcset
|
thumbSrcset
|
||||||
} from '#lib/planes/shared.js';
|
} from '#lib/planes/shared.js';
|
||||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
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 type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
const b = $derived(data.maker);
|
const b = $derived(data.maker);
|
||||||
|
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||||
|
const shown = $derived(filterGot('planes', data.types, got));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
@@ -61,9 +68,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<SpotProgress
|
||||||
|
seen={countSeen('planes', data.types)}
|
||||||
|
total={data.types.length}
|
||||||
|
label="{b.name} types collected"
|
||||||
|
{got}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{#each data.types as plane, i (plane.dex)}
|
{#each shown as plane, i (plane.dex)}
|
||||||
<li><PlaneCard {plane} eager={i < 4} /></li>
|
<li>
|
||||||
|
<SpotItem tracker="planes" id={plane.id} seen={plane.seen} label={plane.fullName}>
|
||||||
|
<PlaneCard {plane} eager={i < 4} />
|
||||||
|
</SpotItem>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getType } from '#lib/server/planes.js';
|
import { getType } from '#lib/server/planes.js';
|
||||||
import type { planeTypes } from '#lib/server/db/schema/index.js';
|
import type { planeTypes } from '#lib/server/db/schema/index.js';
|
||||||
|
import { getSeen } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
type Type = typeof planeTypes.$inferSelect;
|
type Type = typeof planeTypes.$inferSelect;
|
||||||
@@ -28,12 +29,14 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
const found = await getType(params.maker, params.type);
|
const found = await getType(params.maker, params.type);
|
||||||
if (!found) error(404, 'No such aircraft in the pokedex');
|
if (!found) error(404, 'No such aircraft in the pokedex');
|
||||||
const { type, maker, photos, parent, variants, prev, next } = found;
|
const { type, maker, photos, parent, variants, prev, next } = found;
|
||||||
|
const seenEntry = await getSeen('planes', type.id);
|
||||||
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
|
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
|
||||||
const rest: Partial<T> = { ...row };
|
const rest: Partial<T> = { ...row };
|
||||||
delete rest.id;
|
delete rest.id;
|
||||||
return rest as Omit<T, 'id'>;
|
return rest as Omit<T, 'id'>;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
spot: { id: type.id, entry: seenEntry },
|
||||||
plane: publicType(type),
|
plane: publicType(type),
|
||||||
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
|
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
import Shuffle from 'lucide-svelte/icons/shuffle';
|
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||||
|
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -215,6 +216,7 @@
|
|||||||
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
|
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<SeenPanel tracker="planes" id={data.spot.id} entry={data.spot.entry} label="this one" />
|
||||||
|
|
||||||
{#if data.photos.length}
|
{#if data.photos.length}
|
||||||
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />
|
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { planeOverview } from '#lib/server/planes.js';
|
import { planeOverview } from '#lib/server/planes.js';
|
||||||
|
import { seenByGroup } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const { byBuilt, stats } = await planeOverview();
|
const [{ byBuilt, stats }, seen] = await Promise.all([planeOverview(), seenByGroup('planes')]);
|
||||||
return {
|
return {
|
||||||
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
|
makers: byBuilt.map(({ id, slug, name, types, built, logo }) => ({
|
||||||
|
seen: seen.get(id) ?? 0,
|
||||||
slug,
|
slug,
|
||||||
name,
|
name,
|
||||||
types,
|
types,
|
||||||
|
|||||||
14
src/routes/trackers/planes/seen/+server.ts
Normal file
14
src/routes/trackers/planes/seen/+server.ts
Normal 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('planes', body.id, body.seen, body.seenOn, body.note);
|
||||||
|
if (!ok) error(404, 'No such entry');
|
||||||
|
return json({ ok: true, ...body });
|
||||||
|
};
|
||||||
@@ -1,24 +1,70 @@
|
|||||||
import { PAGE_SIZE, parseFilters, searchTrains, trainOverview } from '#lib/server/trains.js';
|
import { PAGE_SIZE, parseFilters, searchTrains, trainOverview } from '#lib/server/trains.js';
|
||||||
|
import {
|
||||||
|
areasWithData,
|
||||||
|
groupActivity,
|
||||||
|
progress,
|
||||||
|
rankByActivity,
|
||||||
|
seenByGroup
|
||||||
|
} from '#lib/server/spotting.js';
|
||||||
|
import { regionOf } from '#lib/spotting/areas.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
const names = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||||
|
/** English country names (as stored on operators) inside an area. */
|
||||||
|
const areaCountries = (area: string) =>
|
||||||
|
new Set(
|
||||||
|
(regionOf(area)?.countries ?? [area]).map((c) => {
|
||||||
|
try {
|
||||||
|
return names.of(c.toUpperCase()) ?? c;
|
||||||
|
} catch {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const filters = parseFilters(url.searchParams);
|
const filters = parseFilters(url.searchParams);
|
||||||
const [{ trains, total }, overview] = await Promise.all([searchTrains(filters), trainOverview()]);
|
const [{ trains, total }, overview, collected, seenMakers, areas, act] = await Promise.all([
|
||||||
|
searchTrains(filters),
|
||||||
|
trainOverview(),
|
||||||
|
progress('trains'),
|
||||||
|
seenByGroup('trains'),
|
||||||
|
areasWithData('trains'),
|
||||||
|
groupActivity('trains', filters.area)
|
||||||
|
]);
|
||||||
|
const home = areaCountries(filters.area);
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
trains,
|
trains,
|
||||||
total,
|
total,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
|
collected,
|
||||||
|
areas,
|
||||||
makers: overview.makers
|
makers: overview.makers
|
||||||
.filter((m) => m.types >= 2 || m.slug === filters.maker)
|
.filter((m) => m.types >= 2 || m.slug === filters.maker)
|
||||||
.map(({ slug, name, types }) => ({ slug, name, types })),
|
.map(({ slug, name, types }) => ({ slug, name, types })),
|
||||||
countries: overview.countries
|
countries: overview.countries
|
||||||
.filter((c) => c.types >= 3 || c.name === filters.country)
|
.filter((c) => c.types >= 3 || c.name === filters.country)
|
||||||
.map(({ name, types }) => ({ name, types })),
|
.map(({ name, types }) => ({ name, types })),
|
||||||
topMakers: overview.byBuilt
|
topMakers: rankByActivity(overview.byBuilt, act)
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
|
.map(({ id, slug, name, types, built, logo }) => ({
|
||||||
topOperators: overview.operators.slice(0, 16).map(({ slug, name, types, logo }) => ({
|
slug,
|
||||||
|
name,
|
||||||
|
types,
|
||||||
|
built,
|
||||||
|
logo,
|
||||||
|
seen: seenMakers.get(id) ?? 0
|
||||||
|
})),
|
||||||
|
// Operators from the chosen area first (the world region keeps the plain fleet-size order).
|
||||||
|
topOperators: (filters.area === 'world'
|
||||||
|
? overview.operators
|
||||||
|
: [...overview.operators].sort(
|
||||||
|
(a, b) => Number(home.has(b.country ?? '')) - Number(home.has(a.country ?? ''))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.slice(0, 16)
|
||||||
|
.map(({ slug, name, types, logo }) => ({
|
||||||
slug,
|
slug,
|
||||||
name,
|
name,
|
||||||
types,
|
types,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
TRACTIONS,
|
TRACTIONS,
|
||||||
tractionLabel
|
tractionLabel
|
||||||
} from '#lib/trains/shared.js';
|
} from '#lib/trains/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 { Input } from '#lib/components/ui/input/index.js';
|
||||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
import Search from 'lucide-svelte/icons/search';
|
import Search from 'lucide-svelte/icons/search';
|
||||||
@@ -27,7 +31,7 @@
|
|||||||
const f = $derived(data.filters);
|
const f = $derived(data.filters);
|
||||||
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
||||||
const filtering = $derived(
|
const filtering = $derived(
|
||||||
!!(f.q || f.maker || f.category || f.traction || f.country || f.era || f.service)
|
!!(f.q || f.maker || f.category || f.traction || f.country || f.era || f.service || f.got)
|
||||||
);
|
);
|
||||||
|
|
||||||
let form: HTMLFormElement | undefined = $state();
|
let form: HTMLFormElement | undefined = $state();
|
||||||
@@ -61,7 +65,8 @@
|
|||||||
{ label: '🚇 metros', params: { category: 'metro', sort: 'built' } },
|
{ label: '🚇 metros', params: { category: 'metro', sort: 'built' } },
|
||||||
{ label: '🚋 trams', params: { category: 'tram', sort: 'built' } },
|
{ label: '🚋 trams', params: { category: 'tram', sort: 'built' } },
|
||||||
{ label: '💪 most powerful', params: { sort: 'power' } },
|
{ label: '💪 most powerful', params: { sort: 'power' } },
|
||||||
{ label: '🟡 VIRM', params: { q: 'virm' } }
|
{ label: '🟡 VIRM', params: { q: 'virm' } },
|
||||||
|
{ label: '⬜ still to go', params: { got: 'no' } }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -107,6 +112,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<SpotProgress
|
||||||
|
seen={data.collected.seen}
|
||||||
|
total={data.collected.total}
|
||||||
|
label="types collected"
|
||||||
|
href={withParams({ got: 'no' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !filtering && f.page === 1}
|
{#if !filtering && f.page === 1}
|
||||||
<section class="mb-8" aria-labelledby="makers-heading">
|
<section class="mb-8" aria-labelledby="makers-heading">
|
||||||
<div class="mb-3 flex items-baseline justify-between gap-3">
|
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||||
@@ -194,6 +208,14 @@
|
|||||||
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</NativeSelect>
|
</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>
|
<noscript><button type="submit" class="pill">search</button></noscript>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -216,7 +238,11 @@
|
|||||||
{#if data.trains.length}
|
{#if data.trains.length}
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{#each data.trains as train, i (train.dex)}
|
{#each data.trains as train, i (train.dex)}
|
||||||
<li><TrainCard {train} eager={i < 4} /></li>
|
<li>
|
||||||
|
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
||||||
|
<TrainCard {train} eager={i < 4} />
|
||||||
|
</SpotItem>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -11,10 +11,17 @@
|
|||||||
thumbSrcset
|
thumbSrcset
|
||||||
} from '#lib/trains/shared.js';
|
} from '#lib/trains/shared.js';
|
||||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
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 type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
const b = $derived(data.maker);
|
const b = $derived(data.maker);
|
||||||
|
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||||
|
const shown = $derived(filterGot('trains', data.types, got));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
@@ -61,9 +68,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<SpotProgress
|
||||||
|
seen={countSeen('trains', data.types)}
|
||||||
|
total={data.types.length}
|
||||||
|
label="{b.name} types collected"
|
||||||
|
{got}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{#each data.types as train, i (train.dex)}
|
{#each shown as train, i (train.dex)}
|
||||||
<li><TrainCard {train} eager={i < 4} /></li>
|
<li>
|
||||||
|
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
||||||
|
<TrainCard {train} eager={i < 4} />
|
||||||
|
</SpotItem>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getType } from '#lib/server/trains.js';
|
import { getType } from '#lib/server/trains.js';
|
||||||
import type { trainTypes } from '#lib/server/db/schema/index.js';
|
import type { trainTypes } from '#lib/server/db/schema/index.js';
|
||||||
|
import { getSeen } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
type Type = typeof trainTypes.$inferSelect;
|
type Type = typeof trainTypes.$inferSelect;
|
||||||
@@ -28,12 +29,14 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
const found = await getType(params.maker, params.type);
|
const found = await getType(params.maker, params.type);
|
||||||
if (!found) error(404, 'No such train in the pokedex');
|
if (!found) error(404, 'No such train in the pokedex');
|
||||||
const { type, maker, photos, parent, variants, operators, prev, next } = found;
|
const { type, maker, photos, parent, variants, operators, prev, next } = found;
|
||||||
|
const seenEntry = await getSeen('trains', type.id);
|
||||||
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
|
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
|
||||||
const rest: Partial<T> = { ...row };
|
const rest: Partial<T> = { ...row };
|
||||||
delete rest.id;
|
delete rest.id;
|
||||||
return rest as Omit<T, 'id'>;
|
return rest as Omit<T, 'id'>;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
spot: { id: type.id, entry: seenEntry },
|
||||||
train: publicType(type),
|
train: publicType(type),
|
||||||
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
|
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
import Shuffle from 'lucide-svelte/icons/shuffle';
|
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||||
|
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -222,6 +223,7 @@
|
|||||||
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
|
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<SeenPanel tracker="trains" id={data.spot.id} entry={data.spot.entry} label="this one" />
|
||||||
|
|
||||||
{#if data.photos.length}
|
{#if data.photos.length}
|
||||||
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />
|
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />
|
||||||
|
|||||||
@@ -9,13 +9,19 @@
|
|||||||
type TrainCard as Card
|
type TrainCard as Card
|
||||||
} from '#lib/trains/shared.js';
|
} from '#lib/trains/shared.js';
|
||||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
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 type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||||
const groups = $derived(
|
const groups = $derived(
|
||||||
CATEGORIES.map((cat) => ({
|
CATEGORIES.map((cat) => ({
|
||||||
cat,
|
cat,
|
||||||
types: data.types.filter((t: Card) => t.category === cat)
|
types: filterGot('trains', data.types, got).filter((t: Card) => t.category === cat)
|
||||||
})).filter((g) => g.types.length)
|
})).filter((g) => g.types.length)
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -42,6 +48,14 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="mb-8">
|
||||||
|
<SpotProgress
|
||||||
|
seen={countSeen('trains', data.types)}
|
||||||
|
total={data.types.length}
|
||||||
|
label="types collected"
|
||||||
|
{got}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#each groups as g, gi (g.cat)}
|
{#each groups as g, gi (g.cat)}
|
||||||
<section class="mb-10" id={g.cat.replace(/ /g, '-')}>
|
<section class="mb-10" id={g.cat.replace(/ /g, '-')}>
|
||||||
@@ -52,7 +66,11 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{#each g.types as train, i (train.dex)}
|
{#each g.types as train, i (train.dex)}
|
||||||
<li><TrainCard {train} eager={gi === 0 && i < 4} /></li>
|
<li>
|
||||||
|
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
||||||
|
<TrainCard {train} eager={gi === 0 && i < 4} />
|
||||||
|
</SpotItem>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { trainOverview } from '#lib/server/trains.js';
|
import { trainOverview } from '#lib/server/trains.js';
|
||||||
|
import { seenByGroup } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const { byBuilt, stats } = await trainOverview();
|
const [{ byBuilt, stats }, seen] = await Promise.all([trainOverview(), seenByGroup('trains')]);
|
||||||
return {
|
return {
|
||||||
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
|
makers: byBuilt.map(({ id, slug, name, types, built, logo }) => ({
|
||||||
|
seen: seen.get(id) ?? 0,
|
||||||
slug,
|
slug,
|
||||||
name,
|
name,
|
||||||
types,
|
types,
|
||||||
|
|||||||
@@ -10,10 +10,17 @@
|
|||||||
thumbSrcset
|
thumbSrcset
|
||||||
} from '#lib/trains/shared.js';
|
} from '#lib/trains/shared.js';
|
||||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
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 type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
const b = $derived(data.operator);
|
const b = $derived(data.operator);
|
||||||
|
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||||
|
const shown = $derived(filterGot('trains', data.types, got));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
@@ -58,9 +65,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<SpotProgress
|
||||||
|
seen={countSeen('trains', data.types)}
|
||||||
|
total={data.types.length}
|
||||||
|
label="{b.name} types collected"
|
||||||
|
{got}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{#each data.types as train, i (train.dex)}
|
{#each shown as train, i (train.dex)}
|
||||||
<li><TrainCard {train} eager={i < 4} /></li>
|
<li>
|
||||||
|
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
||||||
|
<TrainCard {train} eager={i < 4} />
|
||||||
|
</SpotItem>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||||
|
|||||||
14
src/routes/trackers/trains/seen/+server.ts
Normal file
14
src/routes/trackers/trains/seen/+server.ts
Normal 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('trains', body.id, body.seen, body.seenOn, body.note);
|
||||||
|
if (!ok) error(404, 'No such entry');
|
||||||
|
return json({ ok: true, ...body });
|
||||||
|
};
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
import { PAGE_SIZE, parseFilters, searchSpecies, wildlifeOverview } from '#lib/server/wildlife.js';
|
import { PAGE_SIZE, parseFilters, searchSpecies, wildlifeOverview } from '#lib/server/wildlife.js';
|
||||||
|
import { areasWithData, progress, seenByGroup } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const filters = parseFilters(url.searchParams);
|
const filters = parseFilters(url.searchParams);
|
||||||
const [{ species, total }, overview] = await Promise.all([
|
const [{ species, total }, overview, collected, seenGroups, areas] = await Promise.all([
|
||||||
searchSpecies(filters),
|
searchSpecies(filters),
|
||||||
wildlifeOverview()
|
wildlifeOverview(),
|
||||||
|
progress('wildlife'),
|
||||||
|
seenByGroup('wildlife'),
|
||||||
|
areasWithData('wildlife')
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
species,
|
species,
|
||||||
total,
|
total,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
groups: overview.groups,
|
collected,
|
||||||
|
areas,
|
||||||
|
groups: overview.groups.map((g) => ({ ...g, seen: seenGroups.get(g.id) ?? 0 })),
|
||||||
stats: overview.stats,
|
stats: overview.stats,
|
||||||
seo: {
|
seo: {
|
||||||
title: 'Wildlife pokedex',
|
title: 'Wildlife pokedex',
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
const f = $derived(data.filters);
|
const f = $derived(data.filters);
|
||||||
const filtering = $derived(!!(f.q || f.group || f.status || f.where || f.sort !== 'nl'));
|
const filtering = $derived(
|
||||||
|
!!(f.q || f.group || f.status || f.where || f.got || f.sort !== 'active')
|
||||||
|
);
|
||||||
|
|
||||||
const quick: { label: string; params: Record<string, string> }[] = [
|
const quick: { label: string; params: Record<string, string> }[] = [
|
||||||
{ label: '📖 dex order', params: { sort: 'dex' } },
|
{ label: '📖 dex order', params: { sort: 'dex' } },
|
||||||
@@ -79,6 +81,8 @@
|
|||||||
pageSize={data.pageSize}
|
pageSize={data.pageSize}
|
||||||
groups={data.groups}
|
groups={data.groups}
|
||||||
{quick}
|
{quick}
|
||||||
|
areas={data.areas}
|
||||||
|
collected={data.collected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">
|
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getGroup, PAGE_SIZE, parseFilters, searchSpecies } from '#lib/server/wildlife.js';
|
import { getGroup, PAGE_SIZE, parseFilters, searchSpecies } from '#lib/server/wildlife.js';
|
||||||
|
import { areasWithData, groupProgress } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, url }) => {
|
export const load: PageServerLoad = async ({ params, url }) => {
|
||||||
const group = await getGroup(params.group);
|
const group = await getGroup(params.group);
|
||||||
if (!group) error(404, 'No such group in the wildlife pokedex');
|
if (!group) error(404, 'No such group in the wildlife pokedex');
|
||||||
const filters = parseFilters(url.searchParams, group.slug);
|
const filters = parseFilters(url.searchParams, group.slug);
|
||||||
const { species, total } = await searchSpecies(filters);
|
const [{ species, total }, collected, areas] = await Promise.all([
|
||||||
|
searchSpecies(filters),
|
||||||
|
groupProgress('wildlife', group.id),
|
||||||
|
areasWithData('wildlife')
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
group: {
|
group: {
|
||||||
slug: group.slug,
|
slug: group.slug,
|
||||||
@@ -18,6 +23,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
|||||||
photo: group.photo
|
photo: group.photo
|
||||||
},
|
},
|
||||||
filters,
|
filters,
|
||||||
|
collected,
|
||||||
|
areas,
|
||||||
species,
|
species,
|
||||||
total,
|
total,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
total={data.total}
|
total={data.total}
|
||||||
pageSize={data.pageSize}
|
pageSize={data.pageSize}
|
||||||
{quick}
|
{quick}
|
||||||
|
areas={data.areas}
|
||||||
|
collected={data.collected}
|
||||||
|
collectedLabel="{g.name.toLowerCase()} collected"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getSpecies } from '#lib/server/wildlife.js';
|
import { getSpecies } from '#lib/server/wildlife.js';
|
||||||
import { displayName, photoAt } from '#lib/wildlife/shared.js';
|
import { displayName, photoAt } from '#lib/wildlife/shared.js';
|
||||||
|
import { getSeen } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
const found = await getSpecies(params.group, params.species);
|
const found = await getSpecies(params.group, params.species);
|
||||||
if (!found) error(404, 'No such animal in the wildlife pokedex');
|
if (!found) error(404, 'No such animal in the wildlife pokedex');
|
||||||
const { species: s, group, photos, relatives, prev, next } = found;
|
const { species: s, group, photos, relatives, prev, next } = found;
|
||||||
|
const seenEntry = await getSeen('wildlife', s.id);
|
||||||
const name = displayName(s);
|
const name = displayName(s);
|
||||||
const alt = [s.nameNl && s.nameNl !== name ? s.nameNl : null, s.scientificName]
|
const alt = [s.nameNl && s.nameNl !== name ? s.nameNl : null, s.scientificName]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
return {
|
return {
|
||||||
|
spot: { id: s.id, entry: seenEntry },
|
||||||
species: {
|
species: {
|
||||||
dex: s.dex,
|
dex: s.dex,
|
||||||
slug: s.slug,
|
slug: s.slug,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
import Shuffle from 'lucide-svelte/icons/shuffle';
|
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||||
|
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -165,6 +166,7 @@
|
|||||||
{#if s.extinct}<span class="chip">🪦 extinct</span>{/if}
|
{#if s.extinct}<span class="chip">🪦 extinct</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<SeenPanel tracker="wildlife" id={data.spot.id} entry={data.spot.entry} label="this one" />
|
||||||
|
|
||||||
{#if data.photos.length}
|
{#if data.photos.length}
|
||||||
<Gallery photos={data.photos} {name} />
|
<Gallery photos={data.photos} {name} />
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { wildlifeOverview } from '#lib/server/wildlife.js';
|
import { wildlifeOverview } from '#lib/server/wildlife.js';
|
||||||
|
import { seenByGroup } from '#lib/server/spotting.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const { groups, stats } = await wildlifeOverview();
|
const [{ groups, stats }, seen] = await Promise.all([
|
||||||
|
wildlifeOverview(),
|
||||||
|
seenByGroup('wildlife')
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
groups,
|
groups: groups.map((g) => ({ ...g, seen: seen.get(g.id) ?? 0 })),
|
||||||
stats,
|
stats,
|
||||||
seo: {
|
seo: {
|
||||||
title: 'Wildlife groups',
|
title: 'Wildlife groups',
|
||||||
|
|||||||
14
src/routes/trackers/wildlife/seen/+server.ts
Normal file
14
src/routes/trackers/wildlife/seen/+server.ts
Normal 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('wildlife', body.id, body.seen, body.seenOn, body.note);
|
||||||
|
if (!ok) error(404, 'No such entry');
|
||||||
|
return json({ ok: true, ...body });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user