wildlife: pokedex pages (list, groups, group, species detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
This commit is contained in:
@@ -43,6 +43,8 @@ describe('owner gate', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
||||
expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false);
|
||||
expect(isOwnerWritePath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(false);
|
||||
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
|
||||
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ownerWritePrefixes = [
|
||||
'/trackers/books',
|
||||
'/trackers/cars',
|
||||
'/trackers/planes',
|
||||
'/trackers/wildlife',
|
||||
'/trackers/music',
|
||||
'/trackers/pc'
|
||||
] as const;
|
||||
|
||||
247
src/lib/server/wildlife.ts
Normal file
247
src/lib/server/wildlife.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// Wildlife pokedex queries (/trackers/wildlife). Public, read-only; data is written only by
|
||||
// scripts/wildlife-import.ts and scripts/wildlife-photos.ts.
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { wildlifeGroups, wildlifePhotos, wildlifeSpecies } from '#lib/server/db/schema/index.js';
|
||||
import { and, asc, eq, gt, ilike, inArray, isNotNull, sql, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
SORTS,
|
||||
STATUSES,
|
||||
WHERE,
|
||||
type SpeciesCard,
|
||||
type WildSort,
|
||||
type WildWhere
|
||||
} from '#lib/wildlife/shared.js';
|
||||
|
||||
export const PAGE_SIZE = 60;
|
||||
|
||||
export type WildFilters = {
|
||||
q: string;
|
||||
group: string;
|
||||
status: string;
|
||||
where: WildWhere | '';
|
||||
photos: '' | 'yes';
|
||||
sort: WildSort;
|
||||
page: number;
|
||||
};
|
||||
|
||||
const pick = <T extends string>(value: string | null, allowed: readonly T[]): T | '' =>
|
||||
allowed.includes(value as T) ? (value as T) : '';
|
||||
|
||||
export function parseFilters(params: URLSearchParams, group = ''): WildFilters {
|
||||
const sort = params.get('sort');
|
||||
const status = params.get('status') ?? '';
|
||||
return {
|
||||
q: (params.get('q') ?? '').trim().slice(0, 80),
|
||||
group: group || (params.get('group') ?? '').replace(/[^a-z0-9-]/g, '').slice(0, 60),
|
||||
status: status === 'threatened' ? status : pick(status, STATUSES),
|
||||
where: pick(params.get('where'), Object.keys(WHERE) as WildWhere[]),
|
||||
photos: pick(params.get('photos'), ['yes'] as const),
|
||||
sort: sort && sort in SORTS ? (sort as WildSort) : 'nl',
|
||||
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
||||
};
|
||||
}
|
||||
|
||||
const cardColumns = {
|
||||
dex: wildlifeSpecies.dex,
|
||||
slug: wildlifeSpecies.slug,
|
||||
groupSlug: wildlifeGroups.slug,
|
||||
groupEmoji: wildlifeGroups.emoji,
|
||||
nameEn: wildlifeSpecies.nameEn,
|
||||
nameNl: wildlifeSpecies.nameNl,
|
||||
scientificName: wildlifeSpecies.scientificName,
|
||||
iucn: wildlifeSpecies.iucn,
|
||||
nlObs: wildlifeSpecies.nlObs,
|
||||
worldObs: wildlifeSpecies.worldObs,
|
||||
nlEstablishment: wildlifeSpecies.nlEstablishment,
|
||||
photo: wildlifeSpecies.photo,
|
||||
photoCount: wildlifeSpecies.photoCount
|
||||
};
|
||||
|
||||
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
const threatRank = sql`case ${wildlifeSpecies.iucn} when 'EX' then 7 when 'EW' then 6 when 'CR' then 5
|
||||
when 'EN' then 4 when 'VU' then 3 when 'NT' then 2 when 'LC' then 1 else 0 end`;
|
||||
|
||||
export async function searchSpecies(
|
||||
f: WildFilters
|
||||
): Promise<{ species: SpeciesCard[]; total: number }> {
|
||||
const where: SQL[] = [];
|
||||
for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6))
|
||||
where.push(ilike(wildlifeSpecies.search, `%${escapeLike(word)}%`));
|
||||
if (f.group) where.push(eq(wildlifeGroups.slug, f.group));
|
||||
if (f.status === 'threatened')
|
||||
where.push(inArray(wildlifeSpecies.iucn, ['VU', 'EN', 'CR', 'EW']));
|
||||
else if (f.status) where.push(eq(wildlifeSpecies.iucn, f.status));
|
||||
if (f.where === 'nl') where.push(gt(wildlifeSpecies.nlObs, 0));
|
||||
if (f.where === 'europe')
|
||||
where.push(and(eq(wildlifeSpecies.nlObs, 0), gt(wildlifeSpecies.europeObs, 0))!);
|
||||
if (f.where === 'world')
|
||||
where.push(and(eq(wildlifeSpecies.nlObs, 0), eq(wildlifeSpecies.europeObs, 0))!);
|
||||
if (f.photos) where.push(isNotNull(wildlifeSpecies.photo));
|
||||
const order = {
|
||||
nl: [
|
||||
sql`${wildlifeSpecies.nlObs} desc`,
|
||||
sql`${wildlifeSpecies.worldObs} desc`,
|
||||
asc(wildlifeSpecies.dex)
|
||||
],
|
||||
world: [sql`${wildlifeSpecies.worldObs} desc`, asc(wildlifeSpecies.dex)],
|
||||
dex: [asc(wildlifeSpecies.dex)],
|
||||
name: [
|
||||
sql`coalesce(${wildlifeSpecies.nameEn}, ${wildlifeSpecies.nameNl}, ${wildlifeSpecies.scientificName})`
|
||||
],
|
||||
threatened: [sql`${threatRank} desc`, sql`${wildlifeSpecies.worldObs} desc`],
|
||||
heaviest: [sql`${wildlifeSpecies.massKg} desc nulls last`, asc(wildlifeSpecies.dex)],
|
||||
longest: [sql`${wildlifeSpecies.lengthM} desc nulls last`, asc(wildlifeSpecies.dex)],
|
||||
oldest: [sql`${wildlifeSpecies.lifespanYears} desc nulls last`, asc(wildlifeSpecies.dex)]
|
||||
}[f.sort];
|
||||
const condition = where.length ? and(...where) : undefined;
|
||||
const [species, [{ total }]] = await Promise.all([
|
||||
db
|
||||
.select(cardColumns)
|
||||
.from(wildlifeSpecies)
|
||||
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
|
||||
.where(condition)
|
||||
.orderBy(...order)
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((f.page - 1) * PAGE_SIZE),
|
||||
db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(wildlifeSpecies)
|
||||
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
|
||||
.where(condition)
|
||||
]);
|
||||
return { species, total };
|
||||
}
|
||||
|
||||
type Overview = {
|
||||
groups: {
|
||||
slug: string;
|
||||
name: string;
|
||||
nameNl: string;
|
||||
emoji: string;
|
||||
speciesCount: number;
|
||||
nlCount: number;
|
||||
photo: string | null;
|
||||
}[];
|
||||
stats: { species: number; groups: number; nl: number; photos: number; threatened: number };
|
||||
};
|
||||
let overviewCache: { at: number; value: Overview } | null = null;
|
||||
|
||||
/** Groups (in display order) and headline counts; cached for 10 minutes. */
|
||||
export async function wildlifeOverview(): Promise<Overview> {
|
||||
if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value;
|
||||
const [groups, [counts]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
slug: wildlifeGroups.slug,
|
||||
name: wildlifeGroups.name,
|
||||
nameNl: wildlifeGroups.nameNl,
|
||||
emoji: wildlifeGroups.emoji,
|
||||
speciesCount: wildlifeGroups.speciesCount,
|
||||
nlCount: wildlifeGroups.nlCount,
|
||||
photo: wildlifeGroups.photo
|
||||
})
|
||||
.from(wildlifeGroups)
|
||||
.where(gt(wildlifeGroups.speciesCount, 0))
|
||||
.orderBy(asc(wildlifeGroups.sortOrder)),
|
||||
db
|
||||
.select({
|
||||
species: sql<number>`count(*)::int`,
|
||||
nl: sql<number>`count(*) filter (where ${wildlifeSpecies.nlObs} > 0)::int`,
|
||||
photos: sql<number>`coalesce(sum(${wildlifeSpecies.photoCount}), 0)::int`,
|
||||
threatened: sql<number>`count(*) filter (where ${wildlifeSpecies.iucn} in ('VU','EN','CR','EW'))::int`
|
||||
})
|
||||
.from(wildlifeSpecies)
|
||||
]);
|
||||
const value = { groups, stats: { ...counts, groups: groups.length } };
|
||||
if (value.stats.species) overviewCache = { at: Date.now(), value };
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function getGroup(slug: string) {
|
||||
const [group] = await db
|
||||
.select()
|
||||
.from(wildlifeGroups)
|
||||
.where(eq(wildlifeGroups.slug, slug))
|
||||
.limit(1);
|
||||
return group ?? null;
|
||||
}
|
||||
|
||||
export async function getSpecies(groupSlug: string, slug: string) {
|
||||
const [row] = await db
|
||||
.select({ species: wildlifeSpecies, group: wildlifeGroups })
|
||||
.from(wildlifeSpecies)
|
||||
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
|
||||
.where(and(eq(wildlifeGroups.slug, groupSlug), eq(wildlifeSpecies.slug, slug)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const s = row.species;
|
||||
const [neighbours, photos, relatives] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
dex: wildlifeSpecies.dex,
|
||||
slug: wildlifeSpecies.slug,
|
||||
name: sql<string>`coalesce(${wildlifeSpecies.nameEn}, ${wildlifeSpecies.nameNl}, ${wildlifeSpecies.scientificName})`,
|
||||
groupSlug: wildlifeGroups.slug
|
||||
})
|
||||
.from(wildlifeSpecies)
|
||||
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
|
||||
.where(
|
||||
sql`${wildlifeSpecies.dex} in (
|
||||
(select max(dex) from wildlife.species where dex < ${s.dex}),
|
||||
(select min(dex) from wildlife.species where dex > ${s.dex}))`
|
||||
),
|
||||
db
|
||||
.select({
|
||||
kind: wildlifePhotos.kind,
|
||||
photoId: wildlifePhotos.photoId,
|
||||
url: wildlifePhotos.url,
|
||||
width: wildlifePhotos.width,
|
||||
height: wildlifePhotos.height,
|
||||
author: wildlifePhotos.author,
|
||||
license: wildlifePhotos.license,
|
||||
pageUrl: wildlifePhotos.pageUrl,
|
||||
place: wildlifePhotos.place
|
||||
})
|
||||
.from(wildlifePhotos)
|
||||
.where(eq(wildlifePhotos.speciesId, s.id))
|
||||
.orderBy(asc(wildlifePhotos.position)),
|
||||
// Same family (else order), most seen first.
|
||||
s.familyName || s.orderName
|
||||
? db
|
||||
.select(cardColumns)
|
||||
.from(wildlifeSpecies)
|
||||
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
|
||||
.where(
|
||||
and(
|
||||
s.familyName
|
||||
? eq(wildlifeSpecies.familyName, s.familyName)
|
||||
: eq(wildlifeSpecies.orderName, s.orderName!),
|
||||
sql`${wildlifeSpecies.id} <> ${s.id}`
|
||||
)
|
||||
)
|
||||
.orderBy(sql`${wildlifeSpecies.nlObs} desc`, sql`${wildlifeSpecies.worldObs} desc`)
|
||||
.limit(8)
|
||||
: Promise.resolve([] as SpeciesCard[])
|
||||
]);
|
||||
return {
|
||||
...row,
|
||||
photos,
|
||||
relatives,
|
||||
prev: neighbours.find((n) => n.dex < s.dex) ?? null,
|
||||
next: neighbours.find((n) => n.dex > s.dex) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/** A random species with a photo. */
|
||||
export async function randomSpecies(group?: string) {
|
||||
const [row] = await db
|
||||
.select({ slug: wildlifeSpecies.slug, groupSlug: wildlifeGroups.slug })
|
||||
.from(wildlifeSpecies)
|
||||
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
|
||||
.where(
|
||||
and(isNotNull(wildlifeSpecies.photo), group ? eq(wildlifeGroups.slug, group) : undefined)
|
||||
)
|
||||
.orderBy(sql`random()`)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user