Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s

This commit is contained in:
2026-09-25 22:43:04 +00:00
parent f6b34150d4
commit c2ba8febb4
4076 changed files with 97237 additions and 1453 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

View File

@@ -1,4 +1,11 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { Input } from '$lib/components/ui/input/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let moveSearch = $state('');
let moveMethod = $state('all');
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import {
@@ -71,7 +78,7 @@
{@const species = current.pokemon}
<div class="flex flex-wrap items-center gap-5">
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/{species.number}.png"
src={pokemonImage(species.number, 'artwork')}
alt=""
width="120"
height="120"
@@ -88,6 +95,114 @@
</div>
</div>
<section class="mt-6 rounded-xl border p-4">
<h3 class="font-semibold">Base stats</h3>
<div class="mt-3 grid gap-2">
{#each current.stats as stat (stat.name)}<div
class="grid grid-cols-[5rem_2rem_1fr] items-center gap-3 text-xs"
>
<span>{stat.name}</span><span class="font-semibold tabular-nums">{stat.value}</span>
<div class="h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-emerald-500/70"
style:width={`${(stat.value / 255) * 100}%`}
></div>
</div>
</div>{/each}
</div>
<p class="mt-3 text-xs text-muted-foreground">
Total: {current.stats.reduce((sum, s) => sum + s.value, 0)} · default form, current catalog values
</p>
</section>
{#if current.facts}
<section class="mt-5">
<h3 class="font-semibold">Species research</h3>
<dl class="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-muted-foreground">Abilities</dt>
<dd>
{current.facts.abilities
.map((a) => `${a.name}${a.hidden ? ' (hidden)' : ''}`)
.join(', ')}
</dd>
</div>
<div>
<dt class="text-muted-foreground">Height / weight</dt>
<dd>{current.facts.height / 10} m · {current.facts.weight / 10} kg</dd>
</div>
<div>
<dt class="text-muted-foreground">Egg groups</dt>
<dd>{current.facts.eggGroups.join(', ')}</dd>
</div>
<div>
<dt class="text-muted-foreground">Growth rate</dt>
<dd>{current.facts.growthRate}</dd>
</div>
<div>
<dt class="text-muted-foreground">Catch rate / base friendship</dt>
<dd>{current.facts.captureRate} / {current.facts.baseHappiness ?? '—'}</dd>
</div>
<div>
<dt class="text-muted-foreground">EV yield</dt>
<dd>
{current.facts.evYield
.map((v, i) => (v ? `${v} ${current.stats[i]?.name}` : ''))
.filter(Boolean)
.join(', ') || 'None'}
</dd>
</div>
</dl>
</section>
{/if}
<section class="mt-6">
<h3 class="font-semibold">Moves in {current.entry?.game ?? 'this game'}</h3>
{#if current.learnset.length}
<div class="mt-3 grid grid-cols-2 gap-3">
<Input
type="search"
aria-label="Search moves"
placeholder="Search moves…"
bind:value={moveSearch}
/><NativeSelect aria-label="Learn method" class="w-full" bind:value={moveMethod}
><option value="all">All methods</option
>{#each [...new Set(current.learnset.map((m) => m.method))] as method (method)}<option
value={method}>{method}</option
>{/each}</NativeSelect
>
</div>
<div class="mt-3 max-h-72 overflow-auto">
<Table.Root
><Table.Header
><Table.Row
><Table.Head>Move</Table.Head><Table.Head>Learn</Table.Head><Table.Head
>Power</Table.Head
><Table.Head>Accuracy</Table.Head><Table.Head>PP</Table.Head></Table.Row
></Table.Header
><Table.Body
>{#each current.learnset.filter((m) => (moveMethod === 'all' || moveMethod === m.method) && `${m.name} ${m.type}`
.toLowerCase()
.includes(moveSearch.toLowerCase())) as move, i (`${move.id}-${move.method}-${move.level}-${i}`)}<Table.Row
><Table.Cell
><span class="font-medium">{move.name}</span><span
class="block text-xs text-muted-foreground">{move.type} · {move.category}</span
></Table.Cell
><Table.Cell>{move.method}{move.level ? ` · Lv. ${move.level}` : ''}</Table.Cell
><Table.Cell>{move.power ?? '—'}</Table.Cell><Table.Cell
>{move.accuracy === null ? '—' : `${move.accuracy}%`}</Table.Cell
><Table.Cell>{move.pp ?? '—'}</Table.Cell></Table.Row
>{/each}</Table.Body
></Table.Root
>
</div>
<p class="mt-2 text-xs text-muted-foreground">
Learning methods are game-specific. Move power, accuracy and PP use the current catalog
snapshot.
</p>
{:else}<p class="mt-2 text-sm text-muted-foreground">
No learnset is recorded for this game.
</p>{/if}
</section>
{#if current.entry}
<section class="mt-6">
<h3 class="font-semibold">in {current.entry.game}</h3>
@@ -191,7 +306,7 @@
<div class="mt-3 grid gap-3 sm:grid-cols-[12rem_minmax(0,1fr)]">
<label class="block"
>availability
<select
<NativeSelect
name="availability"
value={current.entry.availability}
class="mt-1 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
@@ -199,7 +314,7 @@
{#each availabilityValues as value (value)}<option {value}
>{availabilityLabels[value]}</option
>{/each}
</select>
</NativeSelect>
</label>
<label class="block"
>note

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import {
buttonVariants,
type ButtonVariant,
type ButtonSize
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'default',
size = 'default',
...restProps
}: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants({ variant, size }), '', className)}
{...restProps}
/>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import {
buttonVariants,
type ButtonVariant,
type ButtonSize
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'outline',
size = 'default',
...restProps
}: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant, size }), '', className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import AlertDialogPortal from './alert-dialog-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
size = 'default',
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
size?: 'default' | 'sm';
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-6 rounded-xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
className
)}
{...restProps}
/>
</AlertDialogPortal>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn(
'flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn(
'font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,40 @@
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Media from './alert-dialog-media.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Portal from './alert-dialog-portal.svelte';
import Title from './alert-dialog-title.svelte';
import Trigger from './alert-dialog-trigger.svelte';
import Root from './alert-dialog.svelte';
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Media,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Media as AlertDialogMedia
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View File

@@ -0,0 +1,13 @@
import Content from './collapsible-content.svelte';
import Trigger from './collapsible-trigger.svelte';
import Root from './collapsible.svelte';
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger
};

View File

@@ -0,0 +1,12 @@
import OptGroup from './native-select-opt-group.svelte';
import Option from './native-select-option.svelte';
import Root from './native-select.svelte';
export {
Root,
Option,
OptGroup,
Root as NativeSelect,
Option as NativeSelectOption,
OptGroup as NativeSelectOptGroup
};

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import type { WithElementRef } from '$lib/utils.js';
import type { HTMLOptgroupAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLOptgroupAttributes> = $props();
</script>
<optgroup bind:this={ref} data-slot="native-select-opt-group" {...restProps}>
{@render children?.()}
</optgroup>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLOptionAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOptionAttributes> = $props();
</script>
<option
bind:this={ref}
data-slot="native-select-option"
class={cn('bg-[Canvas] text-[CanvasText]', className)}
{...restProps}
>
{@render children?.()}
</option>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLSelectAttributes } from 'svelte/elements';
type NativeSelectProps = Omit<WithElementRef<HTMLSelectAttributes>, 'size'> & {
size?: 'sm' | 'default';
};
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size = 'default',
children,
...restProps
}: NativeSelectProps = $props();
</script>
<div
class={cn('group/native-select relative w-fit has-[select:disabled]:opacity-50', className)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
bind:value
bind:this={ref}
data-slot="native-select"
data-size={size}
class="border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent py-1 pr-8 pl-2.5 text-sm shadow-xs transition-[color,box-shadow] select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=sm]:h-8 outline-none disabled:pointer-events-none disabled:cursor-not-allowed"
{...restProps}
>
{@render children?.()}
</select>
<ChevronDownIcon
class="text-muted-foreground top-1/2 right-2.5 size-4 -translate-y-1/2 pointer-events-none absolute select-none"
aria-hidden
data-slot="native-select-icon"
/>
</div>

View File

@@ -0,0 +1,7 @@
import Root from './slider.svelte';
export {
Root,
//
Root as Slider
};

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
className
)}
{...restProps}
>
{#snippet children({ thumbItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'bg-muted rounded-full data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5 relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb.index}
class="border-primary ring-ring/50 size-4 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { enhance } from '$app/forms';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { stages, obtainedMethods, type Stage } from './journal';
import type { PokemonCatch } from '$lib/server/db/schema/tracker-journal';
let {
open = $bindable(false),
species,
gameId,
record = null,
stage = 'story',
location = '',
signedIn,
ondelete,
ondetails
}: {
open?: boolean;
species: { id: number; number: number; name: string };
gameId: number;
record?: PokemonCatch | null;
stage?: Stage;
location?: string;
signedIn: boolean;
ondelete?: () => void;
ondetails?: () => void;
} = $props();
let message = $state('');
let saving = $state(false);
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header
><Dialog.Title>{record ? 'Meet' : 'Register'} {record?.nickname || species.name}</Dialog.Title
><Dialog.Description
>#{species.number} · {species.name}. Keep this Pokémon’s story, moves and party position
together.</Dialog.Description
></Dialog.Header
>
{#if ondetails}<Button variant="link" class="justify-self-start px-0" onclick={ondetails}
>All catches, moves and species research →</Button
>{/if}
{#if message}<p role="alert" class="text-destructive">{message}</p>{/if}
<form
method="POST"
action="?/saveCatch"
class="space-y-5"
use:enhance={() => {
saving = true;
message = '';
return async ({ result, update }) => {
await update({ reset: false });
saving = false;
if (result.type === 'success') open = false;
else
message =
result.type === 'failure'
? String(result.data?.message ?? 'Could not save.')
: 'Could not save. Try again.';
};
}}
>
<input type="hidden" name="gameId" value={gameId} /><input
type="hidden"
name="pokemonId"
value={species.id}
/>{#if record}<input type="hidden" name="id" value={record.id} />{/if}
<div class="flex items-center gap-4 rounded-xl bg-muted/40 p-3">
<img
src={pokemonImage(species.number, record?.shiny ? 'shiny' : 'sprite')}
alt={species.name}
width="80"
height="80"
/>
<div class="grid flex-1 grid-cols-[1fr_5rem] gap-3">
<label
>Nickname<Input
name="nickname"
value={record?.nickname ?? ''}
maxlength={50}
placeholder={species.name}
/></label
><label
>Level<Input
type="number"
name="level"
min={1}
max={100}
required
value={record?.level ?? 5}
/></label
>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<label
>Party position<NativeSelect
name="partySlot"
value={record?.partySlot ?? ''}
class="mt-1 w-full"
><option value="">In the PC</option>{#each [1, 2, 3, 4, 5, 6] as slot (slot)}<option
value={slot}>Team slot {slot}</option
>{/each}</NativeSelect
><span class="text-xs text-muted-foreground"
>An occupied slot moves its current Pokémon to the PC.</span
></label
><label
>Obtained by<NativeSelect
name="obtained"
value={record?.obtained ?? 'caught'}
class="mt-1 w-full"
>{#each obtainedMethods as method (method)}<option value={method}>{method}</option
>{/each}</NativeSelect
></label
>
</div>
<fieldset>
<legend class="mb-2 font-medium">Current moves</legend>
<div class="grid grid-cols-2 gap-3">
{#each [0, 1, 2, 3] as slot (slot)}<Input
name="moves"
aria-label={`Move ${slot + 1}`}
placeholder={`Move ${slot + 1}`}
value={record?.moves[slot] ?? ''}
maxlength={60}
/>{/each}
</div>
</fieldset>
<div class="grid gap-4 sm:grid-cols-2">
<label>Nature<Input name="nature" value={record?.nature ?? ''} maxlength={150} /></label
><label>Ability<Input name="ability" value={record?.ability ?? ''} maxlength={150} /></label
><label>Poké Ball<Input name="ball" value={record?.ball ?? ''} maxlength={150} /></label
><label
>Caught on<Input
type="date"
name="caughtOn"
value={record?.caughtOn ?? new Date().toISOString().slice(0, 10)}
/></label
><label
>Location<Input
name="location"
value={record?.location ?? location}
maxlength={150}
/></label
><label
>Collection stage<NativeSelect name="stage" value={record?.stage ?? stage} class="w-full"
>{#each stages as s (s.value)}<option value={s.value}>{s.label}</option
>{/each}</NativeSelect
></label
>
</div>
<label class="flex items-center gap-2"
><Checkbox name="shiny" value="on" checked={record?.shiny ?? false} />Shiny Pokémon</label
>
<label class="block"
>Notes<Textarea
name="notes"
value={record?.notes ?? ''}
maxlength={2000}
placeholder="A lucky encounter, a trade partner, a plan for evolution…"
/></label
>
<Dialog.Footer
><Button variant="outline" onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
disabled={!signedIn || saving}
>{saving ? 'Saving…' : record ? 'Save Pokémon' : 'Add to my collection'}</Button
></Dialog.Footer
>
</form>
{#if record && ondelete}<Button
variant="ghost"
class="justify-self-start text-destructive"
onclick={ondelete}>Remove this catch record</Button
>{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { pokemonImage } from './game-art';
let {
groups,
caughtNumbers,
onselect
}: {
groups: [
number,
{
name: string;
type1: string;
type2: string | null;
minLevel: number | null;
maxLevel: number | null;
}[]
][];
caughtNumbers: Set<number>;
onselect: (number: number) => void;
} = $props();
let width = $state(600);
let height = $state(400);
// Fit every species, including broad locations with hundreds of records, into one overview.
const columns = $derived(
Math.max(
1,
Math.min(
groups.length,
Math.ceil(Math.sqrt(((groups.length * width) / Math.max(1, height)) * 0.7))
)
)
);
const rows = $derived(Math.max(1, Math.ceil(groups.length / columns)));
const cellWidth = $derived(width / columns);
const cellHeight = $derived(height / rows);
const showName = $derived(cellWidth >= 58 && cellHeight >= 42);
const showLevel = $derived(cellWidth >= 65 && cellHeight >= 64);
const spriteSize = $derived(
Math.max(
12,
Math.min(48, cellWidth - 8, cellHeight - (showName ? 14 : 0) - (showLevel ? 12 : 0) - 8)
)
);
</script>
<div class="h-full min-h-0 w-full" bind:clientWidth={width} bind:clientHeight={height}>
<div
class="encounter-grid grid gap-1"
role="group"
aria-label="All Pokémon in this area"
style:grid-template-columns={`repeat(${columns}, minmax(0,1fr))`}
style:grid-template-rows={`repeat(${rows}, minmax(0,1fr))`}
style:height={`min(100%, ${rows * 108}px)`}
>
{#each groups as [number, encounters] (number)}
{@const p = encounters[0]}
{@const levels = encounters
.flatMap((e) => [e.minLevel, e.maxLevel])
.filter((n): n is number => n !== null)}
{@const min = levels.length ? Math.min(...levels) : null}
{@const max = levels.length ? Math.max(...levels) : null}
<button
class="relative flex min-h-0 min-w-0 flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-0.5 hover:bg-muted focus-visible:z-10 focus-visible:outline-2"
class:border-emerald-500={caughtNumbers.has(number)}
aria-label={`${p.name} ${p.type1}${p.type2 ? ` / ${p.type2}` : ''} encounters${caughtNumbers.has(number) ? ', caught' : ''}`}
title={`${p.name} · Lv. ${min ?? '?'}${max !== min ? `–${max}` : ''}${caughtNumbers.has(number) ? ' · caught' : ''}`}
onclick={() => onselect(number)}
>
{#if caughtNumbers.has(number)}<span
class="absolute top-0 right-1 text-[10px] text-emerald-600"
aria-hidden="true">✓</span
>{/if}
<img
src={pokemonImage(number)}
alt=""
width="48"
height="48"
style:width={`${spriteSize}px`}
style:height={`${spriteSize}px`}
class="shrink-0 object-contain"
/>
{#if showName}<span class="block w-full truncate text-center text-[10px] font-medium"
>{p.name}</span
>{/if}
{#if showLevel}<span class="text-[10px] text-muted-foreground"
>Lv. {min ?? '?'}{max !== min ? `–${max}` : ''}</span
>{/if}
</button>
{/each}
</div>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { base } from '$app/paths';
let {
slug,
name,
class: className = ''
}: { slug: string; name: string; class?: string } = $props();
</script>
<span
class={cn(
'inline-flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md',
slug === 'call-of-duty' ? 'bg-zinc-900 p-2' : '',
className
)}
>
<img
src={`${base}/game-logos/${slug}.webp`}
alt={`${name} artwork`}
class={`max-h-full max-w-full object-contain ${slug === 'osu' ? 'rounded-full bg-[#ff66aa]' : ''}`}
loading="lazy"
/>
</span>

View File

@@ -0,0 +1,732 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import { untrack } from 'svelte';
import { deserialize, enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { SvelteSet } from 'svelte/reactivity';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import MapIcon from 'lucide-svelte/icons/map';
import BookOpen from 'lucide-svelte/icons/book-open';
import Archive from 'lucide-svelte/icons/archive';
import Trophy from 'lucide-svelte/icons/trophy';
import ProgressMeter from './ProgressMeter.svelte';
import PokemonProgress from './PokemonProgress.svelte';
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
import PokemonMap from './PokemonMap.svelte';
import CatchEditor from './CatchEditor.svelte';
import { stages, firstCatches, type Stage } from './journal';
import { gameRegions, initialLocation, milestonesFor } from './pokemon-world';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability
} from '$lib/availability';
import type { PageData } from '../../routes/projects/game-tracker/pokemon/$types';
import type { PokemonCatch } from '$lib/server/db/schema/tracker-journal';
let { data }: { data: PageData } = $props();
const game = $derived(data.activeGame!);
const gameProgress = $derived(data.progress.games.find((g) => g.id === game.id));
const seenIds = new SvelteSet(untrack(() => data.seenIds));
$effect(() => {
for (const id of data.seenIds) seenIds.add(id);
});
const seenNumbers = $derived(
new Set(data.pokemon.filter((p) => seenIds.has(p.id)).map((p) => p.number))
);
const pendingSeen = new SvelteSet<number>();
async function setSeen(number: number, seen: boolean) {
const species = speciesByNumber.get(number);
if (!species || pendingSeen.has(number)) return;
pendingSeen.add(number);
message = '';
const body = new FormData();
body.set('gameId', String(game.id));
body.set('pokemonId', String(species.id));
body.set('seen', seen ? 'on' : 'off');
try {
const result = deserialize(await (await fetch('?/setSeen', { method: 'POST', body })).text());
if (result.type !== 'success')
throw new Error(
result.type === 'failure'
? String(result.data?.message ?? 'Could not save sighting.')
: 'Could not save sighting.'
);
if (seen) seenIds.add(species.id);
else seenIds.delete(species.id);
pendingSeen.delete(number);
void invalidateAll().catch(() => {
message = 'Sighting saved. Reload to refresh the progress totals.';
});
} catch (error) {
message = error instanceof Error ? error.message : 'Could not save sighting.';
} finally {
pendingSeen.delete(number);
}
}
const regions = $derived(gameRegions(game.slug ?? '', data.regions));
let stage = $state<Stage>(untrack(() => data.journey?.stage ?? 'story'));
let location = $state(
untrack(
() =>
data.journey?.location ??
initialLocation(
data.activeGame?.slug ?? '',
gameRegions(data.activeGame?.slug ?? '', data.regions)[0] ?? ''
)
)
);
let tab = $state('adventure');
let search = $state('');
let dex = $state(untrack(() => data.dexNumbers[0]?.dex ?? 'national'));
let status = $state('all');
let pageNumber = $state(1);
let pcPage = $state(1);
let viewportWidth = $state(1280);
const dexPageSize = $derived(viewportWidth < 640 ? 18 : viewportWidth < 1024 ? 36 : 60);
const pcPageSize = $derived(viewportWidth < 640 ? 12 : 30);
let message = $state('');
let saving = $state(false);
const pending = new SvelteSet<number>();
const caught = $derived(new Set(data.caughtIds));
const dexes = $derived([...new Map(data.dexNumbers.map((d) => [d.dex, d.dexName])).entries()]);
const regionalNumbers = $derived(
new Map(data.dexNumbers.filter((d) => d.dex === dex).map((d) => [d.pokemonId, d.number]))
);
const regionalOrder = $derived(
new Map(
data.pokemon.flatMap((p) => {
const d = data.dexNumbers.find((d) => d.pokemonId === p.id);
return d ? [[p.number, d.number] as const] : [];
})
)
);
const speciesByNumber = $derived(new Map(data.pokemon.map((p) => [p.number, p])));
const first = $derived(firstCatches(data.catches.map((c) => c.catch)));
const caughtNumbers = $derived(
new Set(
data.pokemon
.filter((p) => p.entries.some(([id, g]) => g === game.id && caught.has(id)))
.map((p) => p.number)
)
);
const party = $derived(
data.catches
.filter((c) => c.catch.partySlot !== null)
.sort((a, b) => a.catch.partySlot! - b.catch.partySlot!)
);
const inGame = $derived(data.pokemon.filter((p) => p.entries.some(([, g]) => g === game.id)));
const owned = $derived(inGame.filter((p) => caughtNumbers.has(p.number)));
const filtered = $derived(
inGame
.filter((p) => {
if (dex !== 'national' && !regionalNumbers.has(p.id)) return false;
if (
(status === 'caught' && !caughtNumbers.has(p.number)) ||
(status === 'missing' && caughtNumbers.has(p.number))
)
return false;
return `${p.name} ${p.number} ${p.type1} ${p.type2 ?? ''}`
.toLowerCase()
.includes(search.toLowerCase().trim());
})
.sort((a, b) =>
dex === 'national'
? a.number - b.number
: (regionalNumbers.get(a.id) ?? 0) - (regionalNumbers.get(b.id) ?? 0)
)
);
const pageCount = $derived(Math.max(1, Math.ceil(filtered.length / dexPageSize)));
const pcSpecies = $derived(
owned.filter((p) =>
`${p.name} ${p.number} ${first.get(p.id)?.nickname ?? ''}`
.toLowerCase()
.includes(search.toLowerCase().trim())
)
);
const visiblePc = $derived(pcSpecies.slice((pcPage - 1) * pcPageSize, pcPage * pcPageSize));
$effect(() => {
if (pageNumber > pageCount) pageNumber = pageCount;
});
$effect(() => {
if (pcPage > Math.max(1, Math.ceil(pcSpecies.length / pcPageSize))) pcPage = 1;
});
const milestones = $derived([
...milestonesFor(game.slug ?? '', regions[0] ?? '').map((m) => ({
...m,
done: data.milestones.find((r) => r.key === m.key)?.done ?? false
})),
...data.milestones.filter((m) => m.key.startsWith('custom:'))
]);
const stageMilestones = $derived(milestones.filter((m) => m.stage === stage));
let milestoneOpen = $state(false);
let milestoneMessage = $state('');
let detailsNumber = $state<number | null>(null);
let catchNumber = $state<number | null>(null);
let catchOpen = $state(false);
let catchRecord = $state<PokemonCatch | null>(null);
let deleteOpen = $state(false);
const catchSpecies = $derived(catchNumber === null ? null : speciesByNumber.get(catchNumber));
function editCatch(number: number, record: PokemonCatch | null = null) {
catchNumber = number;
catchRecord = record;
catchOpen = true;
}
function openSpecies(number: number) {
detailsNumber = number;
}
async function toggle(entryId: number, checked: boolean) {
if (pending.has(entryId)) return;
pending.add(entryId);
message = '';
const body = new FormData();
body.set('entryId', String(entryId));
if (checked) body.set('caught', 'on');
try {
const r = deserialize(
await (await fetch('?/togglePokemonCaught', { method: 'POST', body })).text()
);
if (r.type !== 'success')
throw new Error(
r.type === 'failure' ? String(r.data?.message ?? 'Could not save.') : 'Could not save.'
);
await invalidateAll();
} catch (e) {
message = e instanceof Error ? e.message : 'Could not save.';
} finally {
pending.delete(entryId);
}
}
async function removeCatch() {
if (!catchRecord) return;
saving = true;
const body = new FormData();
body.set('id', String(catchRecord.id));
try {
const result = deserialize(
await (await fetch('?/deleteCatch', { method: 'POST', body })).text()
);
if (result.type !== 'success') throw new Error('Could not remove catch.');
await invalidateAll();
catchOpen = false;
deleteOpen = false;
} catch (e) {
message = e instanceof Error ? e.message : 'Could not remove catch.';
} finally {
saving = false;
}
}
</script>
<svelte:window bind:innerWidth={viewportWidth} />
<PokemonProgress progress={data.progress} gameId={game.id} />
{#if message}<p role="alert" class="mt-4 text-destructive">{message}</p>{/if}
<div class="mt-2 grid items-center gap-2 lg:grid-cols-[minmax(280px,.8fr)_minmax(0,2fr)]">
<div>
<form
method="POST"
action="?/saveJourney"
class="flex flex-wrap items-end gap-2"
use:enhance={() => {
saving = true;
message = '';
return async ({ result, update }) => {
await update({ reset: false });
saving = false;
message =
result.type === 'success'
? 'Adventure saved.'
: result.type === 'failure'
? String(result.data?.message ?? 'Could not save.')
: 'Could not save.';
};
}}
>
<input type="hidden" name="gameId" value={game.id} /><input
type="hidden"
name="location"
value={location}
/>
<label class="min-w-40 max-w-xs flex-1 text-xs font-medium"
>My playthrough<NativeSelect class="mt-1 w-full" name="stage" bind:value={stage}
>{#each stages as s (s.value)}<option value={s.value}>{s.label}</option
>{/each}</NativeSelect
></label
>
<Button type="submit" size="sm" variant="outline" disabled={!data.user || saving}
>Save my place</Button
>
</form>
<form
method="POST"
action="?/setCompleted"
use:enhance
class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"
>
<input type="hidden" name="gameId" value={game.id} />
<input type="hidden" name="completed" value={data.journey?.completed ? 'off' : 'on'} />
<Button
type="submit"
variant="ghost"
size="xs"
class="h-5 text-[10px]"
disabled={!data.user ||
!hydrated ||
(!!gameProgress?.story.total && gameProgress.story.value === gameProgress.story.total)}
>{gameProgress?.completed ? '✓ Story complete' : 'Mark story complete'}</Button
>
{#if gameProgress}<span
>{gameProgress.story.value} / {gameProgress.story.total} story milestones</span
>{/if}
</form>
</div>
<section class="rounded-xl border bg-card p-2" aria-label="Current team">
<div class="mb-1 flex items-center justify-between px-1 text-xs">
<h2 class="font-semibold">Current team</h2>
<span class="text-xs text-muted-foreground">{party.length} / 6 in your team</span>
</div>
<div class={`grid grid-cols-6 gap-1 ${party.length ? '' : 'max-sm:hidden'}`}>
{#each [1, 2, 3, 4, 5, 6] as slot (slot)}
{@const member = party.find((p) => p.catch.partySlot === slot)}
{#if member}<button
class="flex min-w-0 items-center gap-1 rounded-lg border bg-muted/30 p-1 text-left transition hover:bg-muted max-sm:flex-col max-sm:text-center"
onclick={() => editCatch(member.number, member.catch)}
><img
src={pokemonImage(member.number, member.catch.shiny ? 'shiny' : 'sprite')}
alt={member.name}
width="40"
height="40"
class="size-10 shrink-0"
/><span class="min-w-0 max-w-full flex-1"
><span class="block truncate text-[11px] font-semibold"
>{member.catch.nickname || member.name}</span
><span class="block text-[11px] text-muted-foreground"
>Lv. {member.catch.level}{member.catch.shiny ? ' · ✦' : ''}</span
></span
></button
>
{:else}<button
class="flex min-h-12 items-center justify-center gap-1 rounded-lg border border-dashed text-muted-foreground hover:bg-muted/30 max-sm:flex-col"
onclick={() => (tab = 'pc')}
aria-label={`Choose a Pokémon for team slot ${slot}`}
><span class="text-xl">+</span><span class="mt-1 text-xs">Slot {slot}</span></button
>{/if}
{/each}
</div>
</section>
</div>
<Tabs.Root bind:value={tab} class="mt-3">
<Tabs.List
class="sticky top-0 z-20 mb-3 flex h-auto w-full flex-wrap justify-start gap-1 border-b bg-background/95 p-1"
><Tabs.Trigger
disabled={!hydrated}
value="adventure"
class="px-3 py-2.5"
aria-label="Explore the map"
><MapIcon class="mr-1.5 size-4" aria-hidden="true" /><span class="sm:hidden">Map</span><span
class="hidden sm:inline">Explore the map</span
></Tabs.Trigger
><Tabs.Trigger disabled={!hydrated} value="dex" class="px-3 py-2.5"
><BookOpen class="mr-1.5 size-4" aria-hidden="true" />Pokédex</Tabs.Trigger
><Tabs.Trigger disabled={!hydrated} value="pc" class="px-3 py-2.5"
><Archive class="mr-1.5 size-4" aria-hidden="true" />My PC · {owned.length}</Tabs.Trigger
><Tabs.Trigger
disabled={!hydrated}
value="milestones"
class="px-3 py-2.5"
aria-label="Badges & milestones"
><Trophy class="mr-1.5 size-4" aria-hidden="true" /><span class="sm:hidden">Badges</span><span
class="hidden sm:inline">Badges & milestones</span
></Tabs.Trigger
></Tabs.List
>
<Tabs.Content value="adventure">
<PokemonMap
{stage}
{regionalOrder}
gameId={game.id}
{regions}
locations={data.locations}
bind:location
{caughtNumbers}
{seenNumbers}
{pendingSeen}
onseen={setSeen}
onpokemon={openSpecies}
oncatch={(number) => editCatch(number)}
canRegister={!!data.user && hydrated}
/>
</Tabs.Content>
<Tabs.Content value="dex">
{#if gameProgress}<div class="mb-3 grid grid-cols-2 gap-4">
<ProgressMeter
label={dex === 'national' ? 'Game collection' : 'Regional dex caught'}
progress={dex === 'national' ? gameProgress.collection : gameProgress.regional}
compact
/><ProgressMeter
label={dex === 'national' ? 'Seen in this game' : 'Regional dex seen'}
progress={dex === 'national' ? gameProgress.seen : gameProgress.regionalSeen}
color="sky"
compact
/>
</div>{/if}
<div class="mb-5 grid gap-3 sm:grid-cols-[1fr_1fr_10rem]">
<Input
aria-label="Search Pokémon"
type="search"
placeholder="Name, number or type…"
bind:value={search}
oninput={() => (pageNumber = 1)}
/><NativeSelect
aria-label="Pokédex order"
class="w-full"
bind:value={dex}
onchange={() => (pageNumber = 1)}
>{#each dexes as [key, name] (key)}<option value={key}>Regional · {name}</option
>{/each}<option value="national">National Pokédex</option></NativeSelect
><NativeSelect
aria-label="Catch filter"
class="w-full"
bind:value={status}
onchange={() => (pageNumber = 1)}
><option value="all">All Pokémon</option><option value="missing">Still to catch</option
><option value="caught">Caught</option></NativeSelect
>
</div>
<p class="mb-4 text-sm text-muted-foreground">
Showing {Math.min(dexPageSize, filtered.length - (pageNumber - 1) * dexPageSize)} of {filtered.length}
Pokémon · {dex === 'national' ? 'National order' : 'Regional order'}{!dexes.length
? ' · No regional Pokédex is recorded for this title.'
: ''}
</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">
{#each filtered.slice((pageNumber - 1) * dexPageSize, pageNumber * dexPageSize) as p (p.id)}
{@const entry = p.entries.find(([, g]) => g === game.id)!}{@const code =
availabilityValues[entry[2]]}{@const isCaught = caught.has(entry[0])}
<article
class={`overflow-hidden rounded-xl border bg-card ${isCaught ? 'border-emerald-600/40' : ''}`}
>
<button
class="w-full p-2 text-left hover:bg-muted/40"
onclick={() => openSpecies(p.number)}
><div class="flex items-center justify-between text-xs text-muted-foreground">
<span>#{dex === 'national' ? p.number : regionalNumbers.get(p.id)}</span
>{#if isCaught}<span class="text-emerald-600">✓ caught</span>{/if}
</div>
<img
src={pokemonImage(p.number)}
alt=""
width="96"
height="96"
loading="lazy"
class="mx-auto size-12"
/>
<p class="truncate text-sm font-semibold">{p.name}</p>
<p class="mt-1 text-[11px] text-muted-foreground">
{p.type1}{p.type2 ? ` / ${p.type2}` : ''}
</p>
<p class="mt-1 truncate text-[10px] text-muted-foreground" title={entry[3]}>
{availabilityLabels[code]}
</p></button
>
<div class="flex items-center justify-between gap-2 border-t px-3 py-2">
<label class="flex items-center gap-1 text-[10px]"
><Checkbox
aria-label={`Seen ${p.name} in ${game.name}`}
checked={isCaught || seenIds.has(p.id)}
disabled={!hydrated || !data.user || isCaught || pendingSeen.has(p.number)}
onCheckedChange={(v) => setSeen(p.number, v)}
/>seen</label
>
<label class="flex items-center gap-2 text-xs"
><Checkbox
aria-label={`Caught ${p.name} in ${game.name}`}
checked={isCaught}
disabled={!data.user ||
pending.has(entry[0]) ||
(!isCaught && !obtainableAvailability.has(code))}
onCheckedChange={(v) => toggle(entry[0], v)}
/>caught</label
><Button
variant="ghost"
size="xs"
disabled={!hydrated || !data.user}
onclick={() => editCatch(p.number)}>+ log</Button
>
</div>
</article>
{:else}<p class="col-span-full py-10 text-center text-muted-foreground">
No Pokémon match these filters.
</p>{/each}
</div>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" disabled={pageNumber === 1} onclick={() => pageNumber--}
>Previous</Button
><span class="text-sm text-muted-foreground">{pageNumber} / {pageCount}</span><Button
variant="outline"
disabled={pageNumber === pageCount}
onclick={() => pageNumber++}>Next</Button
>
</div>
</Tabs.Content>
<Tabs.Content value="pc">
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold">Your living collection</h2>
<p class="mt-1 text-sm text-muted-foreground">
National order · first registered catch per species · {data.catches.length} individual records
</p>
</div>
<Input
class="max-w-xs"
aria-label="Search your PC"
placeholder="Species or nickname…"
bind:value={search}
oninput={() => (pcPage = 1)}
/>
</div>
<div class="mb-5">
{#if gameProgress}<ProgressMeter
label="PC collection"
progress={gameProgress.collection}
/>{/if}
</div>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
{#each visiblePc as p (p.id)}{@const record = first.get(p.id)}{@const copies =
data.catches.filter((c) => c.catch.pokemonId === p.id).length}
<button
class="relative rounded-xl border bg-card p-2 text-center hover:bg-muted/60"
onclick={() => (record ? editCatch(p.number, record) : openSpecies(p.number))}
><span class="absolute top-2 left-2 text-[10px] text-muted-foreground">#{p.number}</span
>{#if copies > 1}<span class="absolute top-2 right-2 text-[10px]">×{copies}</span
>{/if}<img
src={pokemonImage(p.number, record?.shiny ? 'shiny' : 'sprite')}
alt={p.name}
width="56"
height="56"
loading="lazy"
class="mx-auto"
/><span class="block truncate text-xs font-semibold">{record?.nickname || p.name}</span
><span class="text-[10px] text-muted-foreground"
>{record
? `Lv. ${record.level}${record.partySlot ? ' · team' : ''}`
: 'Add catch details'}</span
></button
>
{:else}<div class="col-span-full rounded-xl border border-dashed px-5 py-12 text-center">
<h3 class="font-semibold">A new adventure starts with one companion.</h3>
<p class="mt-2 text-sm text-muted-foreground">
Register your starter in the Pokédex, or explore a route to find your next catch.
</p>
<Button class="mt-4" variant="outline" onclick={() => (tab = 'dex')}
>Find my starter</Button
>
</div>{/each}
</div>
{#if pcSpecies.length > pcPageSize}<div class="mt-5 flex justify-between">
<Button variant="outline" disabled={pcPage === 1} onclick={() => pcPage--}
>Previous box</Button
><span>Box {pcPage} / {Math.ceil(pcSpecies.length / pcPageSize)}</span><Button
variant="outline"
disabled={pcPage * pcPageSize >= pcSpecies.length}
onclick={() => pcPage++}>Next box</Button
>
</div>{/if}
</Tabs.Content>
<Tabs.Content value="milestones">
{#if gameProgress}<div class="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<ProgressMeter
label="Story milestones"
progress={gameProgress.story}
color="amber"
/><ProgressMeter
label="Badges & trials"
progress={gameProgress.badges}
color="amber"
/><ProgressMeter
label="Postgame"
progress={gameProgress.postgame}
color="violet"
/><ProgressMeter label="Transfers & events" progress={gameProgress.transfers} color="sky" />
</div>{/if}
<div class="mb-5 flex items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold">Milestones along the way</h2>
<p class="mt-1 text-sm text-muted-foreground">
{stageMilestones.filter((m) => m.done).length} / {stageMilestones.length} · {stages.find(
(s) => s.value === stage
)?.label}
</p>
</div>
<Button
variant="outline"
disabled={!hydrated || !data.user}
onclick={() => (milestoneOpen = true)}>+ Custom milestone</Button
>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each stageMilestones as m (m.key)}
<form
method="POST"
action="?/saveMilestone"
use:enhance
class="flex items-center gap-3 rounded-xl border bg-card p-4"
>
<input type="hidden" name="gameId" value={game.id} /><input
type="hidden"
name="key"
value={m.key}
/><input type="hidden" name="title" value={m.title} /><input
type="hidden"
name="category"
value={m.category}
/><input type="hidden" name="stage" value={m.stage} /><input
type="hidden"
name="done"
value={m.done ? '' : 'on'}
/><Button
type="submit"
variant={m.done ? 'secondary' : 'outline'}
size="icon"
disabled={!hydrated || !data.user}
aria-label={`${m.done ? 'Undo' : 'Complete'} ${m.title}`}
>{m.done ? '✓' : m.category === 'badge' ? '◇' : '○'}</Button
>
<div>
<p class="text-sm font-semibold">{m.title}</p>
<p class="mt-1 text-xs text-muted-foreground">{m.category}</p>
</div>
</form>
{/each}
</div>
</Tabs.Content>
</Tabs.Root>
<Dialog.Root
open={detailsNumber !== null}
onOpenChange={(v) => {
if (!v) detailsNumber = null;
}}
><Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"
><Dialog.Header
><Dialog.Title>Pokédex field notes</Dialog.Title><Dialog.Description
>Encounters, evolution, stats and your own catches in {game.name}.</Dialog.Description
></Dialog.Header
>
{#if detailsNumber !== null}
{@const p = speciesByNumber.get(detailsNumber)}
<div class="rounded-xl border p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="font-semibold">My {p?.name} collection</h3>
<Button
size="sm"
disabled={!hydrated || !data.user}
onclick={() => editCatch(detailsNumber!)}>Register a catch</Button
>
</div>
<div class="mt-3 space-y-2">
{#each data.catches.filter((c) => c.number === detailsNumber) as c (c.catch.id)}<button
class="flex w-full items-center justify-between rounded-lg bg-muted/40 p-3 text-left"
onclick={() => editCatch(c.number, c.catch)}
><span
><span class="font-semibold">{c.catch.nickname || c.name}</span><span
class="mt-1 block text-xs text-muted-foreground"
>{c.catch.moves.join(' · ') || 'No moves recorded'}</span
></span
><span class="text-xs"
>Lv. {c.catch.level}{c.catch.partySlot ? ` · slot ${c.catch.partySlot}` : ''}</span
></button
>{:else}<p class="text-sm text-muted-foreground">
No individual catches registered yet.
</p>{/each}
</div>
</div>
<PokemonDetails
number={detailsNumber}
gameId={game.id}
isAdmin={data.isAdmin}
onsaved={() => invalidateAll()}
/>
{/if}
</Dialog.Content></Dialog.Root
>
{#if catchSpecies}{#key `${catchSpecies.id}-${catchRecord?.id ?? 'new'}`}<CatchEditor
bind:open={catchOpen}
species={catchSpecies}
gameId={game.id}
record={catchRecord}
{stage}
location={data.locations.find((p) => p.slug === location)?.name ?? location}
signedIn={!!data.user}
ondelete={() => (deleteOpen = true)}
ondetails={() => {
catchOpen = false;
detailsNumber = catchNumber;
}}
/>{/key}{/if}
<AlertDialog.Root bind:open={deleteOpen}
><AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Remove this catch record?</AlertDialog.Title><AlertDialog.Description
>This removes its nickname, moves and party slot. Other catches and the historical caught
checkmark remain saved.</AlertDialog.Description
></AlertDialog.Header
><AlertDialog.Footer
><AlertDialog.Cancel>Keep Pokémon</AlertDialog.Cancel><Button
variant="destructive"
onclick={removeCatch}
disabled={saving}>Remove record</Button
></AlertDialog.Footer
></AlertDialog.Content
></AlertDialog.Root
>
<Dialog.Root bind:open={milestoneOpen}
><Dialog.Content
><Dialog.Header
><Dialog.Title>Set a new milestone</Dialog.Title><Dialog.Description
>A battle, a ribbon, a rare encounter, or your own challenge.</Dialog.Description
></Dialog.Header
>
{#if milestoneMessage}<p role="alert" class="text-destructive">{milestoneMessage}</p>{/if}
<form
method="POST"
action="?/saveMilestone"
class="space-y-4"
use:enhance={() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') {
milestoneOpen = false;
milestoneMessage = '';
} else
milestoneMessage =
result.type === 'failure'
? String(result.data?.message ?? 'Could not save milestone.')
: 'Could not save milestone.';
}}
>
<input type="hidden" name="gameId" value={game.id} /><input
type="hidden"
name="stage"
value={stage}
/><input type="hidden" name="category" value="event" /><label class="block"
>Milestone<Input name="title" required maxlength={200} /></label
><Button type="submit">Add milestone</Button>
</form></Dialog.Content
></Dialog.Root
>

View File

@@ -0,0 +1,534 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { untrack } from 'svelte';
import { base } from '$app/paths';
import { Slider } from '$lib/components/ui/slider/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import EncounterGrid from './EncounterGrid.svelte';
import ProgressMeter from './ProgressMeter.svelte';
import { fitViewport } from './fit-viewport';
import { type AtlasNode } from './pokemon-world';
import mapArtwork from '../../../data/pokemon-map-art.json';
const regionMaps: Record<string, (typeof mapArtwork)['sinnoh']> = mapArtwork;
type Place = { id: number; slug: string; name: string; region: string | null; count: number };
type Encounter = {
pokemonId: number;
number: number;
name: string;
type1: string;
type2: string | null;
area: string | null;
method: string;
minLevel: number | null;
maxLevel: number | null;
rarity: number | null;
conditions: string[];
form: string | null;
note: string | null;
};
let {
gameId,
regions,
locations,
location = $bindable(''),
caughtNumbers,
seenNumbers,
pendingSeen,
onseen,
onpokemon,
oncatch,
canRegister,
stage,
regionalOrder
}: {
stage: string;
regionalOrder: Map<number, number>;
gameId: number;
regions: string[];
locations: Place[];
location?: string;
caughtNumbers: Set<number>;
seenNumbers: Set<number>;
pendingSeen: Set<number>;
onseen: (number: number, seen: boolean) => void;
onpokemon: (number: number) => void;
oncatch: (number: number) => void;
canRegister: boolean;
} = $props();
let region = $state(
untrack(() => {
const saved = locations.find((p) => p.slug === location)?.region?.toLowerCase();
return saved && regions.includes(saved) ? saved : (regions[0] ?? '');
})
);
let zoom = $state(1);
let filter = $state('');
let method = $state('all');
let area = $state('all');
let mobileView = $state('encounters');
let overviewOpen = $state(false);
let encounterNumber = $state<number | null>(null);
const detailRows = $derived(groups.find(([n]) => n === encounterNumber)?.[1] ?? []);
const areas = $derived([...new Set(encounters.flatMap((e) => (e.area ? [e.area] : [])))]);
let encounters = $state<Encounter[]>([]);
let loading = $state(true);
let loadError = $state('');
let retry = $state(0);
let includeSpecial = $derived(stage !== 'story');
let viewport: HTMLDivElement;
let viewportWidth = $state(640);
let viewportHeight = $state(560);
let showNames = $state(false);
const mapWidth = $derived(
map ? Math.min(viewportWidth, (viewportHeight * map.width) / map.height) * zoom : viewportWidth
);
let dragging: { x: number; y: number; left: number; top: number } | null = null;
const map = $derived(regionMaps[region]);
const places = $derived(
locations
.filter((p) => p.region?.toLowerCase() === region || (regions.length === 1 && p.count > 0))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
);
const selected = $derived(
locations.find(
(p) => p.slug === location && (p.region?.toLowerCase() === region || p.count > 0)
)
);
const selectedLocationId = $derived(selected?.id);
const node = $derived(map?.nodes.find((n) => n.slug === location));
const methods = $derived([...new Set(encounters.map((e) => e.method))]);
const special = (e: Encounter) =>
e.conditions.some((c) => /pok.radar|swarm|slot 2|national|radio/i.test(c));
const groups = $derived(
[
...Map.groupBy(
encounters.filter(
(e) =>
(area === 'all' || e.area === area) &&
(method === 'all' || e.method === method) &&
(includeSpecial || !special(e))
),
(e) => e.number
)
].sort(([a], [b]) => (regionalOrder.get(a) ?? a + 10000) - (regionalOrder.get(b) ?? b + 10000))
);
const hiddenSpecial = $derived(encounters.filter(special).length);
const placeMap = $derived(new Map(places.map((p) => [p.slug, p])));
const choices = $derived(
[
...new Map(
[
...places.map((p) => ({ slug: p.slug, name: p.name, count: p.count })),
...(map?.nodes ?? [])
.filter((n) => !placeMap.has(n.slug))
.map((n) => ({ slug: n.slug, name: n.name, count: 0 }))
].map((p) => [p.slug, p])
).values()
].filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()))
);
$effect(() => {
if (!regions.includes(region)) region = regions[0] ?? '';
});
$effect(() => {
const id = selectedLocationId;
const game = gameId;
void retry;
encounters = [];
method = 'all';
area = 'all';
encounterNumber = null;
loadError = '';
loading = !!id;
if (!id) return;
const controller = new AbortController();
fetch(`${base}/projects/game-tracker/pokemon/api/encounters?game=${game}&location=${id}`, {
signal: controller.signal
})
.then((r) => {
if (!r.ok) throw new Error('Could not load this location.');
return r.json();
})
.then((rows: Encounter[]) => {
if (!controller.signal.aborted) encounters = rows;
})
.catch((e) => {
if (!controller.signal.aborted) loadError = e.message;
})
.finally(() => {
if (!controller.signal.aborted) loading = false;
});
return () => controller.abort();
});
function choose(point: AtlasNode) {
location = point.slug;
mobileView = 'encounters';
}
</script>
<Tabs.Root bind:value={mobileView} class="mb-2 lg:hidden"
><Tabs.List class="w-full"
><Tabs.Trigger value="encounters" class="flex-1">Area Pokémon</Tabs.Trigger><Tabs.Trigger
value="map"
class="flex-1">Region map</Tabs.Trigger
></Tabs.List
></Tabs.Root
>
<div class="grid gap-3 lg:grid-cols-[minmax(0,.85fr)_minmax(0,1.15fr)]">
<section
class={`min-w-0 overflow-hidden rounded-xl border bg-card ${mobileView === 'map' ? '' : 'max-lg:hidden'}`}
>
<div class="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
<div>
<p class="text-xs font-semibold tracking-[.18em] text-muted-foreground uppercase">
Field atlas
</p>
<h2 class="text-xl font-semibold">{map?.name ?? 'Region map'}</h2>
</div>
<div class="flex items-center gap-2">
{#if regions.length > 1}<NativeSelect
aria-label="Map region"
bind:value={region}
onchange={() => {
location = '';
zoom = 1;
}}
>{#each regions as r (r)}<option value={r}>{regionMaps[r]?.name ?? r}</option
>{/each}</NativeSelect
>{/if}
<Button
variant="outline"
size="icon"
aria-label="Zoom out"
disabled={zoom <= 1}
onclick={() => (zoom = Math.max(1, zoom - 0.5))}>−</Button
>
<div class="hidden w-24 sm:block">
<Slider
type="single"
min={1}
max={4}
step={0.5}
bind:value={zoom}
aria-label="Map zoom"
/>
</div>
<Button variant="ghost" size="sm" onclick={() => (zoom = 1)} aria-label="Reset map zoom"
>{Math.round(zoom * 100)}%</Button
>
<Button
variant="outline"
size="icon"
aria-label="Zoom in"
disabled={zoom >= 4}
onclick={() => (zoom = Math.min(4, zoom + 0.5))}>+</Button
>
</div>
</div>
<!-- Scrolling and the location picker offer keyboard alternatives to dragging. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex (scrollable map viewport must be keyboard focusable) -->
<div
bind:this={viewport}
bind:clientWidth={viewportWidth}
bind:clientHeight={viewportHeight}
class="atlas-viewport h-80 overflow-auto"
use:fitViewport={{ minimum: 180, bottom: 64 }}
role="region"
aria-label={`${map?.name} interactive map`}
tabindex="0"
onpointerdown={(e) => {
if (e.target instanceof Element && e.target.closest('button')) return;
dragging = {
x: e.clientX,
y: e.clientY,
left: viewport.scrollLeft,
top: viewport.scrollTop
};
viewport.setPointerCapture(e.pointerId);
}}
onpointermove={(e) => {
if (dragging) {
viewport.scrollLeft = dragging.left - (e.clientX - dragging.x);
viewport.scrollTop = dragging.top - (e.clientY - dragging.y);
}
}}
onpointerup={() => (dragging = null)}
onpointercancel={() => (dragging = null)}
>
{#if map}
<div
class="atlas-canvas relative mx-auto"
style:width={`${mapWidth}px`}
style:height={`${(mapWidth * map.height) / map.width}px`}
>
<img
src={`${base}${map.image}`}
alt={`${map.name} region map from the Pokémon games`}
width={map.width}
height={map.height}
draggable="false"
class="absolute inset-0 h-full w-full select-none"
style:image-rendering={map.pixelated ? 'pixelated' : 'auto'}
/>
{#each map.nodes as point (point.slug)}
<button
type="button"
class="map-pin absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-sm transition-colors focus-visible:z-30 focus-visible:outline-2 focus-visible:outline-offset-4"
class:selected={location === point.slug}
class:town={point.kind === 'town'}
style:left={`${(point.x / map.width) * 100}%`}
style:top={`${(point.y / map.height) * 100}%`}
title={`${point.name} · ${placeMap.get(point.slug)?.count ?? 0} recorded species`}
aria-label={`Explore ${point.name}`}
aria-pressed={location === point.slug}
onclick={() => choose(point)}
>
<span
class="pin-label pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-background/95 px-1.5 py-0.5 text-[10px] font-semibold text-foreground shadow-sm"
class:visible-label={(showNames && point.kind === 'town') ||
location === point.slug}>{point.name}</span
>
</button>
{/each}
<div
class="hidden pointer-events-none absolute bottom-4 left-4 rounded-lg border bg-background/90 px-3 py-2 text-xs text-muted-foreground"
>
<span class="mr-2 text-amber-500">●</span>selected location · drag to explore
</div>
</div>
{:else}<p class="p-8 text-muted-foreground">
No regional map is recorded for this title yet.
</p>{/if}
</div>
<div
class="flex flex-wrap justify-between gap-2 border-t px-4 py-3 text-xs text-muted-foreground"
>
<label class="flex items-center gap-2"><Checkbox bind:checked={showNames} />Town names</label
><span>{map?.precision}</span>{#if map}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={map.source} target="_blank" rel="noreferrer" class="underline">map source ↗</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}
</div>
</section>
<section
class={`min-w-0 rounded-xl border bg-card p-3 ${mobileView === 'encounters' ? '' : 'max-lg:hidden'}`}
aria-label="Area encounters"
>
<div class="mb-2 flex items-center justify-between gap-2">
<div>
<h2 class="text-base font-semibold">
{selected?.name ?? node?.name ?? 'Choose a location'}
</h2>
<p class="text-[11px] text-muted-foreground">
{groups.length} Pokémon · {groups.filter(([n]) => caughtNumbers.has(n)).length} caught
</p>
</div>
<Button
variant="outline"
size="sm"
onclick={() => (overviewOpen = true)}
disabled={!groups.length}>Expand encounters</Button
>
</div>
<div class="grid grid-cols-2 gap-2">
<Input
type="search"
aria-label="Find a location"
placeholder="Find a route…"
bind:value={filter}
/>
<NativeSelect class="w-full" aria-label="Encounter location" bind:value={location}
><option value="">Choose a location</option>{#each choices as p (p.slug)}<option
value={p.slug}>{p.name}</option
>{/each}</NativeSelect
>
{#if areas.length > 1}<NativeSelect aria-label="Encounter area" bind:value={area}
><option value="all">All areas / floors</option>{#each areas as a (a)}<option value={a}
>{a}</option
>{/each}</NativeSelect
>{/if}
{#if methods.length > 1}<NativeSelect aria-label="Encounter method" bind:value={method}
><option value="all">All encounter methods</option>{#each methods as m (m)}<option
value={m}>{m}</option
>{/each}</NativeSelect
>{/if}
</div>
<div class="mt-2 grid grid-cols-2 gap-4">
<ProgressMeter
label="Area caught"
progress={{
value: groups.filter(([n]) => caughtNumbers.has(n)).length,
total: groups.length
}}
compact
/>
<ProgressMeter
label="Area seen"
progress={{
value: groups.filter(([n]) => caughtNumbers.has(n) || seenNumbers.has(n)).length,
total: groups.length
}}
color="sky"
compact
/>
</div>
{#if hiddenSpecial}<label class="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground"
><Checkbox bind:checked={includeSpecial} />Include special conditions (radar, swarms, other
cartridges)</label
>{/if}
<div class="mt-2 h-64" use:fitViewport={{ bottom: 48, minimum: 120 }} aria-live="polite">
{#if loading}<p class="py-8 text-sm text-muted-foreground">Searching the tall grass…</p>
{:else if loadError}<p role="alert">{loadError}</p>
<Button variant="outline" onclick={() => retry++}>Try again</Button>
{:else if groups.length}<EncounterGrid
{groups}
{caughtNumbers}
onselect={(number) => (encounterNumber = number)}
/>
{:else}<div class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
{location
? 'No encounters are recorded here for these filters. Check the location guide for gifts, special events or missing data.'
: 'Pick a marker or choose a location to explore encounters.'}
</div>{/if}
</div>
{#if selected || node}<a
class="mt-2 inline-block text-[10px] text-muted-foreground underline"
href={`https://bulbapedia.bulbagarden.net/wiki/${encodeURIComponent((selected?.name ?? node?.name ?? '').startsWith('Route') ? `${map?.name ?? ''} ${selected?.name ?? node?.name}` : (selected?.name ?? node?.name ?? ''))}`}
target="_blank"
rel="noreferrer">Location guide on Bulbapedia ↗</a
>{/if}
</section>
</div>
<Dialog.Root bind:open={overviewOpen}
><Dialog.Content class="flex h-[90dvh] max-w-[96vw] flex-col gap-3 p-4 sm:max-w-[96vw]"
><Dialog.Header
><Dialog.Title>{selected?.name ?? node?.name} · all {groups.length} Pokémon</Dialog.Title
><Dialog.Description
>Every species matching your area and method filters. Tap a portrait for levels, encounter
conditions and catch registration.</Dialog.Description
></Dialog.Header
>
<div class="min-h-0 flex-1">
<EncounterGrid {groups} {caughtNumbers} onselect={(number) => (encounterNumber = number)} />
</div></Dialog.Content
></Dialog.Root
>
<Dialog.Root
open={encounterNumber !== null}
onOpenChange={(open) => {
if (!open) encounterNumber = null;
}}
>
<Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header
><Dialog.Title
>{detailRows[0]?.name ?? 'Pokémon'} · {selected?.name ?? node?.name}</Dialog.Title
><Dialog.Description>All recorded encounters matching your filters.</Dialog.Description
></Dialog.Header
>
{#if encounterNumber !== null}<div class="flex items-center gap-3">
<img src={pokemonImage(encounterNumber)} alt="" width="64" height="64" />
<p class="text-sm">
{detailRows[0]?.type1}{detailRows[0]?.type2
? ` / ${detailRows[0].type2}`
: ''}{caughtNumbers.has(encounterNumber) ? ' · ✓ caught' : ''}
</p>
</div>{/if}
<div class="space-y-2">
{#each detailRows as row, i (i)}<div class="rounded-lg border p-3 text-xs">
<p class="font-medium">
{row.method}{row.area ? ` · ${row.area}` : ''} · Lv. {row.minLevel ??
'?'}{row.maxLevel !== row.minLevel ? `–${row.maxLevel}` : ''}{row.rarity !== null
? ` · ${row.rarity}%`
: ''}
</p>
{#if row.conditions.length || row.form || row.note}<p class="mt-1 text-muted-foreground">
{[row.form, ...row.conditions, row.note].filter(Boolean).join(' · ')}
</p>{/if}
</div>{/each}
</div>
<Dialog.Footer
><Button
variant="secondary"
disabled={!canRegister ||
(encounterNumber !== null &&
(caughtNumbers.has(encounterNumber) || pendingSeen.has(encounterNumber)))}
onclick={() => {
if (encounterNumber !== null) onseen(encounterNumber, !seenNumbers.has(encounterNumber));
}}
>{encounterNumber !== null &&
(seenNumbers.has(encounterNumber) || caughtNumbers.has(encounterNumber))
? '✓ Seen · undo'
: 'Mark as seen'}</Button
><Button
variant="outline"
onclick={() => {
const n = encounterNumber!;
encounterNumber = null;
overviewOpen = false;
onpokemon(n);
}}>Species research</Button
><Button
disabled={!canRegister}
onclick={() => {
const n = encounterNumber!;
encounterNumber = null;
overviewOpen = false;
oncatch(n);
}}>Register a catch</Button
></Dialog.Footer
>
</Dialog.Content>
</Dialog.Root>
<style>
.atlas-viewport {
background: linear-gradient(
135deg,
color-mix(in oklch, var(--background), #549da3 12%),
color-mix(in oklch, var(--background), #8fa965 10%)
);
cursor: grab;
}
.atlas-viewport:active {
cursor: grabbing;
}
.map-pin {
width: 10px;
height: 10px;
background: #6ca68e;
border-color: var(--background);
}
.map-pin.town {
width: 14px;
height: 14px;
background: #ebc481;
}
.map-pin.selected {
background: #f59e0b;
border-color: #fff;
z-index: 20;
box-shadow: 0 0 0 5px #f59e0b40;
}
.pin-label {
visibility: hidden;
}
@media (max-width: 639px) {
.map-pin .pin-label.visible-label {
visibility: hidden;
}
.map-pin.selected .pin-label {
visibility: visible;
}
}
.visible-label,
.map-pin:hover .pin-label,
.map-pin:focus-visible .pin-label {
visibility: visible;
}
</style>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
import ProgressMeter from './ProgressMeter.svelte';
import type { pokemonProgress } from './progress';
let { progress, gameId }: { progress: ReturnType<typeof pokemonProgress>; gameId: number } =
$props();
let open = $state(false);
const current = $derived(progress.games.find((g) => g.id === gameId));
</script>
<section class="mt-2 rounded-lg border bg-card px-3 py-2.5" aria-label="Pokémon completion">
<div class="mb-2 flex items-center justify-between gap-3">
<h2 class="text-xs font-semibold">Quick progress</h2>
<Button variant="ghost" size="xs" class="h-6 gap-1 text-[10px]" onclick={() => (open = true)}
><ChartNoAxesCombined class="size-3.5" aria-hidden="true" />Full breakdown</Button
>
</div>
<div class="grid grid-cols-2 gap-x-5 gap-y-2 sm:grid-cols-4">
{#if current}<ProgressMeter
label={`${current.name} completion`}
progress={current.overall}
color="amber"
compact
hint="Collection, story, postgame and transfers/events contribute equally where available."
/>{/if}
<ProgressMeter
label="National collection"
progress={progress.caught}
compact
hint={`${progress.caught.value} / ${progress.caught.total} unique species caught across selected games.`}
/>
<ProgressMeter
label="Species seen at least once"
progress={progress.seen}
color="sky"
compact
hint={`${progress.seen.value} / ${progress.seen.total} species. Catches count as seen.`}
/>
<ProgressMeter
label="Games completed"
progress={progress.completed}
color="violet"
compact
hint={`${progress.completed.value} / ${progress.completed.total} stories cleared.`}
/>
</div>
</section>
<Dialog.Root bind:open
><Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header
><Dialog.Title>Your Pokémon progress</Dialog.Title><Dialog.Description
>Every catch, discovery and milestone adds up. Collection totals exclude unconfirmed
availability unless you have recorded a catch.</Dialog.Description
></Dialog.Header
>
<div class="grid gap-4 sm:grid-cols-3">
<ProgressMeter label="National collection" progress={progress.caught} />
<ProgressMeter label="Species seen at least once" progress={progress.seen} color="sky" />
<ProgressMeter label="Games completed" progress={progress.completed} color="violet" />
<ProgressMeter
label="All adventures"
progress={progress.overall}
color="amber"
detail="Equal weight per selected game"
/>
<ProgressMeter label="All species / game pairs" progress={progress.collection} />
</div>
{#if current}
<h3 class="border-t pt-4 font-semibold">{current.name} · your adventure</h3>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<ProgressMeter label="Game collection" progress={current.collection} />
<ProgressMeter
label="Obtainable in-game"
progress={current.inGame}
hint="Catch, evolve and breed; trades, events and transfers have their own collection stage."
/>
<ProgressMeter label="Seen in this game" progress={current.seen} color="sky" />
{#if current.regional.total}<ProgressMeter
label="Regional dex caught"
progress={current.regional}
/><ProgressMeter
label="Regional dex seen"
progress={current.regionalSeen}
color="sky"
/>{/if}
{#each [{ label: 'Story milestones', progress: current.story }, { label: 'Badges & trials', progress: current.badges }, { label: 'League battles', progress: current.league }, { label: 'Boss battles', progress: current.bosses }, { label: 'Postgame milestones', progress: current.postgame }, { label: 'Trades, transfers & events', progress: current.transfers }, { label: 'Personal milestones', progress: current.custom }] as metric (metric.label)}
{#if metric.progress.total}<ProgressMeter
label={metric.label}
progress={metric.progress}
color="amber"
/>{/if}
{/each}
</div>
{/if}
<p class="text-xs text-muted-foreground">
Game completion averages collection, story, postgame and transfer/event milestones. Games
completed counts stories you mark cleared, or games with every default story milestone
finished. These percentages describe the imported catalog and your selected games.
</p>
</Dialog.Content></Dialog.Root
>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { Progress } from '$lib/components/ui/progress/index.js';
import { fraction, type ProgressCount } from './progress';
let {
label,
progress,
detail,
hint = '',
color = 'emerald',
compact = false
}: {
label: string;
progress: ProgressCount;
detail?: string;
hint?: string;
color?: 'emerald' | 'sky' | 'amber' | 'violet' | 'pink';
compact?: boolean;
} = $props();
const percent = $derived(fraction(progress) * 100);
// Keep tiny catalog gains visible without rounding unfinished progress up to 100%.
const display = $derived(
percent === 100
? '100'
: percent === 0
? '0'
: percent < 0.01
? '<0.01'
: Math.min(99.99, percent).toFixed(2)
);
const colors = {
emerald: '[&_[data-slot=progress-indicator]]:bg-emerald-500',
sky: '[&_[data-slot=progress-indicator]]:bg-sky-500',
amber: '[&_[data-slot=progress-indicator]]:bg-amber-500',
violet: '[&_[data-slot=progress-indicator]]:bg-violet-500',
pink: '[&_[data-slot=progress-indicator]]:bg-pink-500'
};
</script>
<div class="min-w-0 space-y-1" title={hint}>
<div class="flex items-baseline justify-between gap-2">
<span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'}
>{label}</span
>
<span class={`shrink-0 font-semibold tabular-nums ${compact ? 'text-xs' : 'text-sm'}`}
>{display}%{percent === 100 ? ' ✓' : ''}</span
>
</div>
<Progress
value={percent}
max={100}
aria-label={label}
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
class={`h-1.5 ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
/>
{#if !compact}<p class="text-[10px] text-muted-foreground">
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
</p>{/if}
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { base } from '$app/paths';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import artwork from '../../../data/cod-art.json';
let { game, name, icon }: { game: string; name: string; icon: string | null } = $props();
const source = $derived(
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
);
const src = $derived(source ? `${base}${source.path}` : icon);
</script>
{#if src}
<Dialog.Root>
<Dialog.Trigger
class="shrink-0 overflow-hidden rounded-md border focus-visible:outline-2"
aria-label={`View ${name} artwork`}
><img
{src}
alt=""
loading="lazy"
width="112"
height="64"
class="h-16 w-28 object-contain"
/></Dialog.Trigger
>
<Dialog.Content class="sm:max-w-3xl"
><Dialog.Header
><Dialog.Title>{name}</Dialog.Title><Dialog.Description
>{source?.credit ?? 'Weapon artwork'}</Dialog.Description
></Dialog.Header
><img
{src}
alt={`${name} weapon reference for ${game.replaceAll('-', ' ')}`}
class="max-h-[65dvh] w-full object-contain"
/>{#if source}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={source.source}
target="_blank"
rel="noreferrer"
class="text-xs text-muted-foreground underline">Image source ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}</Dialog.Content
>
</Dialog.Root>
{/if}

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { deserialize } from '$app/forms';
import { Button } from '$lib/components/ui/button/index.js';
import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus';
let {
itemId,
name,
level,
prestige,
levelCap = null,
prestigeCap = 1000,
prestigeRequiresMaxLevel = false,
compact = false,
disabled,
onupdate,
onedit
}: {
itemId: number;
name: string;
level: number;
prestige: number;
levelCap?: number | null;
prestigeCap?: number;
prestigeRequiresMaxLevel?: boolean;
compact?: boolean;
disabled: boolean;
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
onedit: () => void;
} = $props();
let saving = $state(false);
let error = $state('');
let saved = $state(false);
async function adjust(field: 'level' | 'prestige', delta: number) {
if (saving) return;
saving = true;
error = '';
saved = false;
const body = new FormData();
body.set('itemId', String(itemId));
body.set('field', field);
body.set('delta', String(delta));
try {
const result = deserialize(
await (await fetch('?/adjustWeaponRank', { method: 'POST', body })).text()
);
if (result.type !== 'success')
throw new Error(
result.type === 'failure'
? String(result.data?.message ?? 'Could not save.')
: 'Could not save.'
);
const rank = result.data?.rank as { itemId: number; level: number; prestige: number };
onupdate(rank);
saved = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Could not save. Try again.';
} finally {
saving = false;
}
}
</script>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-3">
<div
class="flex items-center gap-1 rounded-lg border bg-background p-1"
aria-label={`${name} level controls`}
>
<Button
variant="ghost"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Level down ${name}`}
disabled={disabled || saving || level <= 1}
onclick={() => adjust('level', -1)}
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
<Button
variant="ghost"
class={compact
? 'h-8 min-w-11 flex-col gap-0 px-1 tabular-nums'
: 'h-10 min-w-16 flex-col gap-0 px-2 tabular-nums'}
aria-label={`Edit ${name} level and prestige`}
disabled={disabled || saving}
onclick={onedit}
><span
class={compact ? 'text-[8px] text-muted-foreground' : 'text-[10px] text-muted-foreground'}
>LEVEL</span
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}>{level}{#if levelCap}<span class="text-muted-foreground">/{levelCap}</span>{/if}</span
></Button
>
<Button
variant="secondary"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Level up ${name}`}
disabled={disabled || saving || level >= (levelCap ?? 10000)}
onclick={() => adjust('level', 1)}
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
</div>
<div
class="flex items-center gap-1 rounded-lg border bg-background p-1"
aria-label={`${name} prestige controls`}
>
<Button
variant="ghost"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Prestige down ${name}`}
disabled={disabled || saving || prestige <= 0}
onclick={() => adjust('prestige', -1)}
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
<span class="min-w-12 text-center tabular-nums"
><span
class={compact
? 'block text-[8px] text-muted-foreground'
: 'block text-[10px] text-muted-foreground'}>PRESTIGE</span
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}>{prestige}{#if prestigeCap < 1000}<span class="text-muted-foreground">/{prestigeCap}</span>{/if}</span
></span
>
<Button
variant="secondary"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Prestige up ${name}`}
disabled={disabled || saving || prestige >= prestigeCap || (prestigeRequiresMaxLevel && level < (levelCap ?? 10000))}
onclick={() => adjust('prestige', 1)}
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
</div>
</div>
<p class="min-h-3 text-[9px] text-muted-foreground" role="status">
{saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')}
</p>
</div>

View File

@@ -0,0 +1,28 @@
/** Size a companion panel from its real page position, allowing room for its footer. */
export function fitViewport(node: HTMLElement, { minimum = 140, maximum = 560, bottom = 48 } = {}) {
let frame = 0;
const measure = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (!node.getClientRects().length) return;
const top = node.getBoundingClientRect().top + window.scrollY;
const height = Math.round(
Math.max(minimum, Math.min(maximum, window.innerHeight - top - bottom))
);
if (node.style.height !== `${height}px`) node.style.height = `${height}px`;
});
};
const observer = new ResizeObserver(measure);
// Ancestors capture team, filters, tabs and responsive header changes above the panel.
for (let parent = node.parentElement; parent; parent = parent.parentElement)
observer.observe(parent);
window.addEventListener('resize', measure);
measure();
return {
destroy() {
observer.disconnect();
window.removeEventListener('resize', measure);
cancelAnimationFrame(frame);
}
};
}

View File

@@ -0,0 +1,15 @@
import releases from '../../../data/pokemon-releases.json';
import { base } from '$app/paths';
const dates: Record<string, string> = releases.dates;
export function pokemonRelease(slug: string | null) {
return dates[slug ?? ''] ?? '';
}
export function chronologicalPokemon<T extends { slug: string | null; id: number }>(games: T[]) {
return [...games].sort(
(a, b) => pokemonRelease(a.slug).localeCompare(pokemonRelease(b.slug)) || a.id - b.id
);
}
export function pokemonImage(number: number, kind: 'sprite' | 'shiny' | 'artwork' = 'sprite') {
return `${base}/game-art/pokemon/${kind}/${number}.webp`;
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { allowedOsuUrl, boundedInteger, firstCatches, validDate } from './journal';
import { gameRegions, milestonesFor, regionMaps } from './pokemon-world';
describe('journal input and collection rules', () => {
it('keeps the first registered catch even when a duplicate has an earlier caught date', () => {
const original = { id: 2, pokemonId: 387, nickname: 'Sprout', caughtOn: '2026-09-25' };
const duplicate = { id: 8, pokemonId: 387, nickname: 'Other', caughtOn: '2020-01-01' };
const input = [duplicate, original, { ...original, id: 9, pokemonId: 399 }];
expect(firstCatches(input).get(387)).toBe(original);
expect(firstCatches(input).size).toBe(2);
expect(input[0]).toBe(duplicate);
});
it('rejects malformed or impossible numeric values', () => {
for (const value of [null, '', '-1', '1e2', '1.5', ' 2', 'NaN', '101'])
expect(boundedInteger(value, 1, 100)).toBeNull();
expect(boundedInteger('100', 1, 100)).toBe(100);
expect(boundedInteger('0', 0, 100)).toBe(0);
});
it('accepts actual calendar dates including leap days', () => {
expect(validDate('2024-02-29')).toBe(true);
for (const date of [
'2026-02-29',
'2026-04-31',
'0000-01-01',
'2026-13-01',
'2026-09-25T00:00:00Z'
])
expect(validDate(date)).toBe(false);
});
it('accepts osu links and rejects lookalike hosts, credentials and unrelated routes', () => {
expect(allowedOsuUrl('https://osu.ppy.sh/beatmapsets/123#osu/456', 'beatmap')).toBe(true);
expect(allowedOsuUrl('https://osu.ppy.sh/scores/osu/123', 'score')).toBe(true);
for (const url of [
'javascript:alert(1)',
'http://osu.ppy.sh/beatmaps/1',
'https://osu.ppy.sh.evil.test/beatmaps/1',
'https://user@osu.ppy.sh/beatmaps/1',
'https://osu.ppy.sh:123/beatmaps/1',
'https://osu.ppy.sh/users/1'
])
expect(allowedOsuUrl(url, 'beatmap')).toBe(false);
});
});
describe('atlas and milestone integrity', () => {
it('has unique locations and finite map coordinates in every region', () => {
for (const map of Object.values(regionMaps)) {
expect(new Set(map.nodes.map((n) => n.slug)).size).toBe(map.nodes.length);
expect(map.source).toMatch(/^https:\/\//);
for (const node of map.nodes)
expect(Number.isFinite(node.x) && Number.isFinite(node.y)).toBe(true);
}
expect(regionMaps.sinnoh.nodes.some((n) => n.slug === 'sinnoh-route-201')).toBe(true);
});
it('uses the right region and version-specific leaders', () => {
expect(gameRegions('legends-arceus', ['sinnoh'])).toEqual(['hisui']);
expect(gameRegions('heartgold', ['kanto', 'johto'])).toEqual(['johto', 'kanto']);
expect(gameRegions('unknown', ['unknown'])).toEqual([]);
expect(
milestonesFor('platinum', 'sinnoh').filter((m) => m.category === 'badge')[2].title
).toContain('Fantina');
expect(
milestonesFor('diamond', 'sinnoh').filter((m) => m.category === 'badge')[2].title
).toContain('Maylene');
expect(milestonesFor('emerald', 'hoenn').some((m) => m.title === 'Champion · Wallace')).toBe(
true
);
expect(
milestonesFor('ultra-sun', 'alola').some((m) => m.title === 'Elite Four · Molayne')
).toBe(true);
expect(
milestonesFor('heartgold', 'johto').filter(
(m) => m.stage === 'postgame' && m.category === 'badge'
)
).toHaveLength(8);
});
});

View File

@@ -0,0 +1,67 @@
export const stages = [
{
value: 'story',
label: 'The adventure',
description: 'Catch as you go, earn badges and become Champion.'
},
{
value: 'postgame',
label: 'Beyond the credits',
description: 'Explore the postgame and finish what you can in this game.'
},
{
value: 'collection',
label: 'Complete the collection',
description: 'Trades, transfers and event Pokémon.'
}
] as const;
export type Stage = (typeof stages)[number]['value'];
export const obtainedMethods = [
'caught',
'starter',
'gift',
'evolved',
'bred',
'trade',
'transfer',
'event'
] as const;
export const isStage = (value: string): value is Stage => stages.some((s) => s.value === value);
export function boundedInteger(value: FormDataEntryValue | null, min: number, max: number) {
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const number = Number(value);
return Number.isSafeInteger(number) && number >= min && number <= max ? number : null;
}
export function validDate(value: string) {
return (
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
Number(value.slice(0, 4)) > 0 &&
!Number.isNaN(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value
);
}
export function firstCatches<T extends { id: number; pokemonId: number }>(catches: T[]) {
const first = new Map<number, T>();
for (const caught of [...catches].sort((a, b) => a.id - b.id))
if (!first.has(caught.pokemonId)) first.set(caught.pokemonId, caught);
return first;
}
export function allowedOsuUrl(value: string, kind: 'beatmap' | 'score') {
if (!value) return true;
try {
const url = new URL(value);
return (
url.protocol === 'https:' &&
url.hostname === 'osu.ppy.sh' &&
!url.username &&
!url.password &&
!url.port &&
(kind === 'beatmap'
? /^\/(beatmaps|b|beatmapsets)\/\d+\/?$/.test(url.pathname)
: /^\/scores\/(?:osu\/|taiko\/|fruits\/|mania\/)?\d+\/?$/.test(url.pathname))
);
} catch {
return false;
}
}

View File

@@ -30,6 +30,13 @@ export type MasteryRules = {
tiers: Tier[];
classes: string[];
steamApps: { id: number; label: string }[];
rankCaps?: {
player: { level: number; prestige: number };
weaponPrestige: number;
weaponLevelsByClass: Record<string, number>;
weaponLevelOverrides?: Record<string, number>;
sourceUrl?: string;
};
};
export type Weapon = { id: number; name: string; category: string; postLaunch: boolean };

View File

@@ -0,0 +1,305 @@
import atlas from '../../../data/pokemon-atlas.json';
export type AtlasNode = {
slug: string;
name: string;
x: number;
y: number;
kind: string;
cells: number[][];
};
export type RegionMap = {
name: string;
width: number;
height: number;
nodes: AtlasNode[];
source: string;
precision: string;
};
export const regionMaps: Record<string, RegionMap> = atlas.regions;
export function gameRegions(slug: string, regions: string[]) {
if (['gold', 'silver', 'crystal', 'heartgold', 'soulsilver'].includes(slug))
return ['johto', 'kanto'];
if (slug === 'legends-arceus') return ['hisui'];
if (slug.includes('teal-mask')) return ['kitakami'];
if (slug.includes('indigo-disk')) return ['blueberry'];
if (['colosseum', 'xd'].includes(slug)) return ['orre'];
return regions.filter((r) => r in regionMaps);
}
export function initialLocation(slug: string, region: string) {
if (['diamond', 'pearl'].includes(slug)) return 'lake-verity';
return (
(
{
sinnoh: 'sinnoh-route-201',
kanto: 'pallet-town',
johto: 'new-bark-town',
hoenn: 'hoenn-route-101',
unova: slug.endsWith('-2') ? 'aspertia-city' : 'nuvema-town',
kalos: 'aquacorde-town',
alola: 'alola-route-1',
galar: 'postwick',
hisui: 'obsidian-fieldlands',
paldea: 'cabo-poco',
orre: 'phenac-city',
kitakami: 'mossui-town',
blueberry: 'central-plaza'
} as Record<string, string>
)[region] ?? ''
);
}
export type Milestone = {
key: string;
title: string;
category: string;
stage: 'story' | 'postgame' | 'collection';
};
export function milestonesFor(slug: string, region: string): Milestone[] {
const rows: Milestone[] = [];
const add = (title: string, category: string, stage: Milestone['stage'] = 'story') =>
rows.push({
key: `${category}:${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
title,
category,
stage
});
const badges: Record<string, string[]> = {
kanto: [
'Boulder · Brock',
'Cascade · Misty',
'Thunder · Lt. Surge',
'Rainbow · Erika',
'Soul · Koga',
'Marsh · Sabrina',
'Volcano · Blaine',
'Earth · Giovanni'
],
johto: [
'Zephyr · Falkner',
'Hive · Bugsy',
'Plain · Whitney',
'Fog · Morty',
'Storm · Chuck',
'Mineral · Jasmine',
'Glacier · Pryce',
'Rising · Clair'
],
hoenn: [
'Stone · Roxanne',
'Knuckle · Brawly',
'Dynamo · Wattson',
'Heat · Flannery',
'Balance · Norman',
'Feather · Winona',
'Mind · Tate & Liza',
`Rain · ${slug === 'emerald' ? 'Juan' : 'Wallace'}`
],
sinnoh:
slug === 'platinum'
? [
'Coal · Roark',
'Forest · Gardenia',
'Relic · Fantina',
'Cobble · Maylene',
'Fen · Crasher Wake',
'Mine · Byron',
'Icicle · Candice',
'Beacon · Volkner'
]
: [
'Coal · Roark',
'Forest · Gardenia',
'Cobble · Maylene',
'Fen · Crasher Wake',
'Relic · Fantina',
'Mine · Byron',
'Icicle · Candice',
'Beacon · Volkner'
],
unova: slug.endsWith('-2')
? [
'Basic · Cheren',
'Toxic · Roxie',
'Insect · Burgh',
'Bolt · Elesa',
'Quake · Clay',
'Jet · Skyla',
'Legend · Drayden',
'Wave · Marlon'
]
: [
'Trio · Cilan / Chili / Cress',
'Basic · Lenora',
'Insect · Burgh',
'Bolt · Elesa',
'Quake · Clay',
'Jet · Skyla',
'Freeze · Brycen',
`Legend · ${slug === 'white' ? 'Iris' : 'Drayden'}`
],
kalos: [
'Bug · Viola',
'Cliff · Grant',
'Rumble · Korrina',
'Plant · Ramos',
'Voltage · Clemont',
'Fairy · Valerie',
'Psychic · Olympia',
'Iceberg · Wulfric'
],
galar: [
'Grass · Milo',
'Water · Nessa',
'Fire · Kabu',
slug === 'shield' ? 'Ghost · Allister' : 'Fighting · Bea',
'Fairy · Opal',
slug === 'shield' ? 'Ice · Melony' : 'Rock · Gordie',
'Dark · Piers',
'Dragon · Raihan'
],
paldea: [
'Bug · Katy',
'Grass · Brassius',
'Electric · Iono',
'Water · Kofu',
'Normal · Larry',
'Ghost · Ryme',
'Psychic · Tulip',
'Ice · Grusha'
]
};
if (
/legends-za|mega-dimension|champions|isle-of-armor|crown-tundra|teal-mask|indigo-disk/.test(
slug
)
) {
if (slug.includes('isle-of-armor'))
['Master dojo trials', 'Tower of Darkness / Waters', 'Final battle with Mustard'].forEach(
(t) => add(t, 'boss')
);
if (slug.includes('crown-tundra'))
[
'Calyrex expedition',
'Legendary giants expedition',
'Legendary birds expedition',
'Galarian Star Tournament'
].forEach((t) => add(t, 'event'));
if (slug.includes('teal-mask'))
['Festival of Masks', 'Loyal Three battles', 'Ogerpon', 'Final battle with Kieran'].forEach(
(t) => add(t, 'boss')
);
if (slug.includes('indigo-disk'))
['Crispin', 'Amarys', 'Lacey', 'Drayton', 'Kieran', 'Terapagos'].forEach((t) =>
add(t, 'boss')
);
} else {
(badges[region] ?? []).forEach((t) => add(t, 'badge'));
const leagues: Record<string, string[]> = {
kanto: ['Lorelei', 'Bruno', 'Agatha', 'Lance'],
johto: ['Will', 'Koga', 'Bruno', 'Karen'],
hoenn: ['Sidney', 'Phoebe', 'Glacia', 'Drake'],
sinnoh: ['Aaron', 'Bertha', 'Flint', 'Lucian'],
unova: ['Shauntal', 'Grimsley', 'Caitlin', 'Marshal'],
kalos: ['Malva', 'Siebold', 'Wikstrom', 'Drasna'],
alola: [slug.startsWith('ultra-') ? 'Molayne' : 'Hala', 'Olivia', 'Acerola', 'Kahili'],
paldea: ['Rika', 'Poppy', 'Larry', 'Hassel']
};
(leagues[region] ?? []).forEach((t) => add(`Elite Four · ${t}`, 'league'));
const champion: Record<string, string> = {
kanto: slug.startsWith('lets-go') ? 'Trace' : 'Blue',
johto: 'Lance',
hoenn: slug === 'emerald' ? 'Wallace' : 'Steven',
sinnoh: 'Cynthia',
unova: slug.endsWith('-2') ? 'Iris' : 'Alder',
kalos: 'Diantha',
galar: 'Leon',
paldea: 'Geeta'
};
if (champion[region])
add(
`Champion · ${champion[region]}`,
'league',
region === 'unova' && !slug.endsWith('-2') ? 'postgame' : 'story'
);
if (region === 'sinnoh') {
[
'Commander Mars · Valley Windworks',
'Commander Jupiter · Eterna',
'Team Galactic · Lake battles',
'Cyrus · Galactic HQ',
slug === 'platinum' ? 'Cyrus · Distortion World' : 'Cyrus · Spear Pillar'
].forEach((t) => add(t, 'boss'));
['National Pokédex', 'Battle Zone', 'Stark Mountain · Heatran', 'Battle Tower'].forEach((t) =>
add(t, 'event', 'postgame')
);
}
if (region === 'johto') {
['Brock', 'Misty', 'Lt. Surge', 'Erika', 'Janine', 'Sabrina', 'Blaine', 'Blue'].forEach((t) =>
add(`Kanto badge · ${t}`, 'badge', 'postgame')
);
add('Red · Mt. Silver', 'boss', 'postgame');
}
if (region === 'kanto') {
add('Team Rocket · Silph Co.', 'boss');
add('Cerulean Cave · Mewtwo', 'event', 'postgame');
}
if (region === 'hoenn') {
add('Team Aqua / Magma finale', 'boss');
add('Legendary weather Pokémon', 'event');
if (['omega-ruby', 'alpha-sapphire'].includes(slug))
add('Delta Episode', 'event', 'postgame');
}
if (region === 'unova') ['N', 'Ghetsis'].forEach((t) => add(`Team Plasma · ${t}`, 'boss'));
if (region === 'kalos') add('Lysandre · Team Flare finale', 'boss');
if (region === 'alola') {
[
'Verdant Cavern',
'Brooklet Hill',
'Wela Volcano Park',
'Lush Jungle',
'Hokulani Observatory',
'Thrifty Megamart',
'Vast Poni Canyon'
].forEach((t) => add(`Trial · ${t}`, 'badge'));
['Hala', 'Olivia', 'Nanu', 'Hapu'].forEach((t) => add(`Grand trial · ${t}`, 'boss'));
add(slug.startsWith('ultra-') ? 'Ultra Necrozma' : 'Lusamine', 'boss');
add(slug.startsWith('ultra-') ? 'Title match · Hau' : 'Title match · Kukui', 'league');
}
if (region === 'galar')
['Champion Cup semifinals', 'Champion Cup finals', 'Rose', 'Eternatus'].forEach((t) =>
add(t, 'boss')
);
if (region === 'hisui') {
['Kleavor', 'Lilligant', 'Arcanine', 'Electrode', 'Avalugg'].forEach((t) =>
add(`Noble · ${t}`, 'boss')
);
add('Dialga & Palkia', 'boss');
add('Volo & Giratina', 'boss', 'postgame');
add('Arceus', 'boss', 'postgame');
}
if (region === 'paldea') {
[
'Klawf',
'Bombirdier',
'Orthworm',
'Great Tusk / Iron Treads',
'Dondozo & Tatsugiri'
].forEach((t) => add(`Titan · ${t}`, 'boss'));
['Giacomo', 'Mela', 'Atticus', 'Ortega', 'Eri', 'Cassiopeia'].forEach((t) =>
add(`Team Star · ${t}`, 'boss')
);
['Arven', 'Nemona', 'The Way Home'].forEach((t) => add(t, 'boss'));
add('Academy Ace Tournament', 'event', 'postgame');
}
if (region === 'orre')
(slug === 'xd'
? ['Lovrina', 'Snattle', 'Gorigan', 'Greevil']
: ['Miror B.', 'Dakim', 'Venus', 'Ein', 'Nascour', 'Evice']
).forEach((t) => add(`Cipher · ${t}`, 'boss'));
}
add('Finish my in-game collection', 'collection', 'postgame');
['Trade evolutions', 'Version exclusives', 'Transfers from other games', 'Event Pokémon'].forEach(
(t) => add(t, 'collection', 'collection')
);
return rows;
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { combinedProgress, fraction, challengeProgress, pokemonProgress } from './progress';
import { milestonesFor } from './pokemon-world';
const input: Parameters<typeof pokemonProgress>[0] = {
games: [{ id: 1, name: 'Platinum', slug: 'platinum', regions: ['sinnoh'] }],
entries: [
{ id: 1, gameId: 1, pokemonId: 387, availability: 'catchable' },
{ id: 2, gameId: 1, pokemonId: 399, availability: 'catchable' },
{ id: 3, gameId: 1, pokemonId: 1, availability: 'transfer_only' },
{ id: 4, gameId: 1, pokemonId: 99, availability: 'unknown' },
{ id: 5, gameId: 2, pokemonId: 387, availability: 'catchable' }
],
caughtIds: [],
seen: [],
journeys: [],
milestones: [],
dex: [
{ gameId: 1, pokemonId: 387 },
{ gameId: 1, pokemonId: 399 },
{ gameId: 1, pokemonId: 387 }
],
speciesTotal: 1025
};
describe('tracker completion calculations', () => {
it('balances categories, omits empty catalogs and clamps fractions', () => {
expect(
fraction(
combinedProgress([
{ value: 1, total: 2 },
{ value: 100, total: 100 },
{ value: 0, total: 0 }
])
)
).toBe(0.75);
expect(fraction({ value: 3, total: 2 })).toBe(1);
expect(fraction({ value: 0, total: 0 })).toBe(0);
expect(fraction(combinedProgress([]))).toBe(0);
});
it('credits partial challenge targets and explicit unlocks without overcounting', () => {
const result = challengeProgress(
[
{ id: 1, target: 100 },
{ id: 2, target: null },
{ id: 3, target: 5 }
],
[
{ challengeId: 1, current: 25, done: false },
{ challengeId: 2, current: 0, done: true },
{ challengeId: 3, current: 99, done: false }
]
);
expect(result).toEqual({ value: 2.25, total: 3 });
});
it('counts a species once globally, once per game, and caught implies seen', () => {
const result = pokemonProgress({
...input,
games: [...input.games, { id: 2, name: 'Diamond', slug: 'diamond', regions: ['sinnoh'] }],
caughtIds: [1, 5],
seen: [
{ gameId: 1, pokemonId: 387 },
{ gameId: 1, pokemonId: 399 }
]
});
expect(result.caught.value).toBe(1);
expect(result.seen.value).toBe(2);
expect(result.collection.value).toBe(2);
expect(result.games[0].regional).toEqual({ value: 1, total: 2 });
});
it('includes confirmed trade/transfer targets, excludes unknowns and scopes selected games', () => {
const result = pokemonProgress({
...input,
caughtIds: [1, 5],
seen: [{ gameId: 2, pokemonId: 999 }]
});
expect(result.games[0].collection).toEqual({ value: 1, total: 3 });
expect(result.games[0].inGame).toEqual({ value: 1, total: 2 });
expect(result.seen.value).toBe(1);
expect(pokemonProgress({ ...input, games: [] }).completed).toEqual({ value: 0, total: 0 });
});
it('allows real catches to confirm unknown availability', () => {
expect(pokemonProgress({ ...input, caughtIds: [4] }).games[0].collection).toEqual({
value: 1,
total: 4
});
});
it('credits story completion without pretending all collection tasks are complete', () => {
const before = pokemonProgress(input);
const after = pokemonProgress({ ...input, journeys: [{ gameId: 1, completed: true }] });
expect(after.completed.value).toBe(1);
expect(fraction(after.games[0].overall)).toBeGreaterThan(fraction(before.games[0].overall));
expect(fraction(after.games[0].overall)).toBeLessThan(1);
expect(after.games[0].badges.value).toBe(0);
});
it('infers a cleared game from all default story milestones, not arbitrary custom ones', () => {
const milestones = milestonesFor('platinum', 'sinnoh')
.filter((m) => m.stage === 'story')
.map((m) => ({ ...m, gameId: 1, done: true }));
expect(pokemonProgress({ ...input, milestones }).completed.value).toBe(1);
expect(
pokemonProgress({
...input,
milestones: [{ gameId: 1, key: 'custom:win', stage: 'story', category: 'boss', done: true }]
}).completed.value
).toBe(0);
});
});

View File

@@ -0,0 +1,125 @@
import { obtainableAvailability, type Availability } from '$lib/availability';
import { gameRegions, milestonesFor } from './pokemon-world';
export type ProgressCount = { value: number; total: number };
export const fraction = ({ value, total }: ProgressCount) =>
total > 0 ? Math.max(0, Math.min(1, value / total)) : 0;
export function combinedProgress(parts: ProgressCount[]): ProgressCount {
const available = parts.filter((p) => p.total > 0);
return { value: available.reduce((n, p) => n + fraction(p), 0), total: available.length };
}
export function sumProgress(parts: ProgressCount[]): ProgressCount {
return parts.reduce((a, p) => ({ value: a.value + p.value, total: a.total + p.total }), {
value: 0,
total: 0
});
}
export function challengeProgress(
challenges: { id: number; target: number | null }[],
progress: { challengeId: number; current: number; done: boolean }[]
) {
const saved = new Map(progress.map((p) => [p.challengeId, p]));
return {
value: challenges.reduce((n, c) => {
const p = saved.get(c.id);
return n + (p?.done ? 1 : fraction({ value: p?.current ?? 0, total: c.target ?? 0 }));
}, 0),
total: challenges.length
};
}
export function pokemonProgress(input: {
games: { id: number; name: string; slug: string | null; regions: string[] }[];
entries: { id: number; gameId: number; pokemonId: number; availability: Availability }[];
caughtIds: number[];
seen: { gameId: number; pokemonId: number }[];
journeys: { gameId: number; completed: boolean }[];
milestones: { gameId: number; key: string; stage: string; category: string; done: boolean }[];
dex: { gameId: number; pokemonId: number }[];
speciesTotal: number;
}) {
const caught = new Set(input.caughtIds);
const entries = Map.groupBy(input.entries, (e) => e.gameId);
const sightings = Map.groupBy(input.seen, (e) => e.gameId);
const milestones = Map.groupBy(input.milestones, (m) => m.gameId);
const dexes = Map.groupBy(input.dex, (d) => d.gameId);
const allCaught = new Set<number>();
const allSeen = new Set<number>();
const games = input.games.map((game) => {
const list = entries.get(game.id) ?? [];
const caughtSpecies = new Set(list.filter((e) => caught.has(e.id)).map((e) => e.pokemonId));
const seenSpecies = new Set([
...caughtSpecies,
...(sightings.get(game.id) ?? []).map((s) => s.pokemonId)
]);
for (const id of caughtSpecies) allCaught.add(id);
for (const id of seenSpecies) allSeen.add(id);
const collect = (ids: number[], source = caughtSpecies): ProgressCount => {
const unique = new Set(ids);
return { value: [...unique].filter((id) => source.has(id)).length, total: unique.size };
};
const collection = collect(
list
.filter((e) => e.availability !== 'unknown' || caughtSpecies.has(e.pokemonId))
.map((e) => e.pokemonId)
);
const inGame = collect(
list.filter((e) => obtainableAvailability.has(e.availability)).map((e) => e.pokemonId)
);
const seen = collect(
list.map((e) => e.pokemonId),
seenSpecies
);
const regionalIds = (dexes.get(game.id) ?? []).map((d) => d.pokemonId);
const regional = collect(regionalIds);
const regionalSeen = collect(regionalIds, seenSpecies);
const saved = new Map((milestones.get(game.id) ?? []).map((m) => [m.key, m]));
const defaults = milestonesFor(
game.slug ?? '',
gameRegions(game.slug ?? '', game.regions)[0] ?? ''
);
const count = (rows: { key: string }[]): ProgressCount => ({
value: rows.filter((m) => saved.get(m.key)?.done).length,
total: rows.length
});
const story = count(defaults.filter((m) => m.stage === 'story'));
const postgame = count(defaults.filter((m) => m.stage === 'postgame'));
const transfers = count(defaults.filter((m) => m.stage === 'collection'));
const badges = count(defaults.filter((m) => m.category === 'badge'));
const league = count(defaults.filter((m) => m.category === 'league'));
const bosses = count(defaults.filter((m) => m.category === 'boss'));
const custom = count([...saved.values()].filter((m) => m.key.startsWith('custom:')));
const completed =
input.journeys.some((j) => j.gameId === game.id && j.completed) ||
(story.total > 0 && story.value === story.total);
const adventure = { value: story.value + Number(completed), total: story.total + 1 };
return {
id: game.id,
name: game.name,
slug: game.slug,
collection,
inGame,
seen,
regional,
regionalSeen,
story,
adventure,
postgame,
transfers,
badges,
league,
bosses,
custom,
completed,
overall: combinedProgress([collection, adventure, postgame, transfers])
};
});
return {
games,
caught: { value: allCaught.size, total: input.speciesTotal },
seen: { value: allSeen.size, total: input.speciesTotal },
completed: { value: games.filter((g) => g.completed).length, total: games.length },
collection: sumProgress(games.map((g) => g.collection)),
overall: combinedProgress(games.map((g) => g.overall))
};
}

View File

@@ -0,0 +1,43 @@
import type { MasteryRules } from './mastery';
export type RankLimits = {
maxLevel: number | null;
maxPrestige: number;
canPrestigeOnlyAtMaxLevel: boolean;
rankable: boolean;
};
const capsFor = (rules: MasteryRules) => rules.rankCaps;
export function playerRankLimits(rules: MasteryRules): RankLimits {
const caps = capsFor(rules)?.player;
return {
maxLevel: caps?.level ?? null,
maxPrestige: caps?.prestige ?? 1000,
canPrestigeOnlyAtMaxLevel: false,
rankable: true
};
}
export function weaponRankLimits(
rules: MasteryRules,
weapon: Pick<{ category: string; name: string }, 'category' | 'name'>
): RankLimits {
const caps = capsFor(rules);
if (!caps) {
return {
maxLevel: null,
maxPrestige: 1000,
canPrestigeOnlyAtMaxLevel: false,
rankable: true
};
}
const maxLevel =
caps.weaponLevelOverrides?.[weapon.name] ?? caps.weaponLevelsByClass[weapon.category] ?? null;
return {
maxLevel,
maxPrestige: caps.weaponPrestige,
canPrestigeOnlyAtMaxLevel: true,
rankable: maxLevel !== null
};
}

View File

@@ -39,12 +39,17 @@ const publicPages: Record<string, { title: string; description: string }> = {
'/projects/game-tracker': {
title: 'Game Tracker',
description:
'Completionist progress for every Pokémon in every game and Call of Duty achievements, weapons and camos.'
'Pokémon adventures and collections, Call of Duty ranks and unlocks, and osu! scores and training.'
},
'/projects/game-tracker/pokemon': {
title: 'Catchemall',
title: 'Pokémon journal',
description:
'Track which Pokémon you have caught across 53 games, with where to find them and how to evolve or breed them.'
'Explore Pokémon encounters and regional Pokédexes, record catches and teams, and track badges across your playthroughs.'
},
'/projects/game-tracker/osu': {
title: 'osu! training',
description:
'Keep memorable scores, beatmap goals and a training journal for speed, stamina and consistency.'
},
'/projects/game-tracker/pokemon/locations': {
title: 'Pokémon locations',

View File

@@ -5,6 +5,7 @@ export * from './blog';
export * from './progress';
export * from './catch-em-all';
export * from './game-tracker';
export * from './tracker-journal';
export * from './animaldex';
export * from './lists';
export * from './money-tracker';

View File

@@ -0,0 +1,299 @@
import {
boolean,
check,
date,
index,
integer,
jsonb,
pgTable,
text,
unique
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { user } from './auth';
import { games, pokemon } from './catch-em-all';
import { trackerGames, trackerItems } from './game-tracker';
import { id, timestamps } from './helpers';
export const pokemonResearch = pgTable('pokemon_research', {
pokemonId: integer('pokemon_id')
.primaryKey()
.references(() => pokemon.id, { onDelete: 'cascade' }),
facts: jsonb('facts')
.$type<{
height: number;
weight: number;
baseExperience: number | null;
captureRate: number;
baseHappiness: number | null;
growthRate: string;
genderRate: number;
hatchCounter: number | null;
eggGroups: string[];
abilities: { name: string; hidden: boolean }[];
evYield: number[];
}>()
.notNull()
});
export const pokemonDexNumbers = pgTable(
'pokemon_dex_numbers',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
dex: text('dex').notNull(),
dexName: text('dex_name').notNull(),
number: integer('number').notNull()
},
(t) => [unique().on(t.pokemonId, t.gameId, t.dex), index().on(t.gameId)]
);
export const pokemonMoves = pgTable('pokemon_moves', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
type: text('type').notNull(),
category: text('category').notNull(),
power: integer('power'),
accuracy: integer('accuracy'),
pp: integer('pp'),
priority: integer('priority').notNull(),
target: text('target').notNull()
});
export const pokemonLearnsets = pgTable(
'pokemon_learnsets',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
versionGroup: integer('version_group').notNull(),
moves: jsonb('moves').$type<[moveId: number, method: string, level: number][]>().notNull()
},
(t) => [unique().on(t.pokemonId, t.versionGroup)]
);
export const pokemonGameResearch = pgTable('pokemon_game_research', {
gameId: integer('game_id')
.primaryKey()
.references(() => games.id, { onDelete: 'cascade' }),
versionGroup: integer('version_group').notNull(),
regions: text('regions').array().notNull()
});
export const pokemonJourneys = pgTable(
'pokemon_journeys',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
stage: text('stage', { enum: ['story', 'postgame', 'collection'] })
.notNull()
.default('story'),
location: text('location'),
completed: boolean('completed').notNull().default(false),
...timestamps
},
(t) => [unique().on(t.userId, t.gameId)]
);
export const pokemonSeen = pgTable(
'pokemon_seen',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
...timestamps
},
(t) => [unique().on(t.userId, t.gameId, t.pokemonId), index().on(t.userId)]
);
export const pokemonCatches = pgTable(
'pokemon_catches',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
nickname: text('nickname').notNull().default(''),
level: integer('level').notNull().default(1),
moves: text('moves').array().notNull().default([]),
partySlot: integer('party_slot'),
shiny: boolean('shiny').notNull().default(false),
nature: text('nature'),
ability: text('ability'),
ball: text('ball'),
location: text('location'),
obtained: text('obtained').notNull().default('caught'),
stage: text('stage', { enum: ['story', 'postgame', 'collection'] })
.notNull()
.default('story'),
notes: text('notes').notNull().default(''),
caughtOn: date('caught_on'),
...timestamps
},
(t) => [
index().on(t.userId, t.gameId, t.pokemonId),
unique().on(t.userId, t.gameId, t.partySlot),
check('pokemon_catch_level', sql`${t.level} between 1 and 100`),
check('pokemon_party_slot', sql`${t.partySlot} between 1 and 6`),
check('pokemon_four_moves', sql`cardinality(${t.moves}) <= 4`)
]
);
export const pokemonMilestones = pgTable(
'pokemon_milestones',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
title: text('title').notNull(),
category: text('category').notNull(),
stage: text('stage').notNull().default('story'),
done: boolean('done').notNull().default(false),
notes: text('notes').notNull().default(''),
...timestamps
},
(t) => [unique().on(t.userId, t.gameId, t.key)]
);
export const trackerRanks = pgTable(
'tracker_ranks',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => trackerGames.id, { onDelete: 'cascade' }),
level: integer('level').notNull().default(1),
prestige: integer('prestige').notNull().default(0),
...timestamps
},
(t) => [
unique().on(t.userId, t.gameId),
check(
'tracker_rank_range',
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
)
]
);
export const trackerWeaponRanks = pgTable(
'tracker_weapon_ranks',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
itemId: integer('item_id')
.notNull()
.references(() => trackerItems.id, { onDelete: 'cascade' }),
level: integer('level').notNull().default(1),
prestige: integer('prestige').notNull().default(0),
...timestamps
},
(t) => [
unique().on(t.userId, t.itemId),
check(
'tracker_weapon_rank_range',
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
)
]
);
export const trackerChallenges = pgTable(
'tracker_challenges',
{
id: id(),
gameId: integer('game_id')
.notNull()
.references(() => trackerGames.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
name: text('name').notNull(),
category: text('category').notNull(),
requirement: text('requirement').notNull(),
reward: text('reward'),
icon: text('icon'),
target: integer('target'),
sourceUrl: text('source_url').notNull()
},
(t) => [unique().on(t.gameId, t.key)]
);
export const trackerChallengeProgress = pgTable(
'tracker_challenge_progress',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
challengeId: integer('challenge_id')
.notNull()
.references(() => trackerChallenges.id, { onDelete: 'cascade' }),
current: integer('current').notNull().default(0),
done: boolean('done').notNull().default(false),
...timestamps
},
(t) => [unique().on(t.userId, t.challengeId)]
);
export const osuEntries = pgTable(
'osu_entries',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
kind: text('kind', { enum: ['score', 'goal', 'training'] }).notNull(),
title: text('title').notNull(),
mode: text('mode').notNull().default('osu'),
beatmapUrl: text('beatmap_url'),
scoreUrl: text('score_url'),
goal: text('goal').notNull().default('pass'),
skill: text('skill').notNull().default('stamina'),
bpm: integer('bpm'),
duration: integer('duration'),
accuracy: text('accuracy'),
combo: integer('combo'),
misses: integer('misses'),
pp: text('pp'),
mods: text('mods').notNull().default('NM'),
rate: text('rate').notNull().default('1.00'),
achieved: boolean('achieved').notNull().default(false),
playedOn: date('played_on').notNull(),
notes: text('notes').notNull().default(''),
...timestamps
},
(t) => [index().on(t.userId, t.kind)]
);
export type PokemonCatch = typeof pokemonCatches.$inferSelect;

View File

@@ -1,5 +1,6 @@
import { error } from '@sveltejs/kit';
import { and, asc, eq, inArray } from 'drizzle-orm';
import { pokemonResearch, pokemonGameResearch, pokemonLearnsets, pokemonMoves } from './db/schema';
import { db } from '$lib/server/db';
import {
games,
@@ -104,8 +105,50 @@ export async function pokemonDetail(number: number, gameId: number | null) {
)
: [];
const [research] = await db
.select()
.from(pokemonResearch)
.where(eq(pokemonResearch.pokemonId, species.id));
const [gameResearch] = gameId
? await db.select().from(pokemonGameResearch).where(eq(pokemonGameResearch.gameId, gameId))
: [];
const [learnset] = gameResearch
? await db
.select()
.from(pokemonLearnsets)
.where(
and(
eq(pokemonLearnsets.pokemonId, species.id),
eq(pokemonLearnsets.versionGroup, gameResearch.versionGroup)
)
)
: [];
const moveIds = [...new Set((learnset?.moves ?? []).map((m) => m[0]))];
const moves = moveIds.length
? await db.select().from(pokemonMoves).where(inArray(pokemonMoves.id, moveIds))
: [];
const moveMap = new Map(moves.map((m) => [m.id, m]));
const numbers = new Map(members.map((member) => [member.id, member.number]));
return {
facts: research?.facts ?? null,
stats: [
{ name: 'HP', value: species.hp },
{ name: 'Attack', value: species.attack },
{ name: 'Defense', value: species.defense },
{ name: 'Sp. Atk', value: species.specialAttack },
{ name: 'Sp. Def', value: species.specialDefense },
{ name: 'Speed', value: species.speed }
],
learnset: (learnset?.moves ?? [])
.flatMap(([id, method, level]) => {
const move = moveMap.get(id);
return move ? [{ ...move, method, level }] : [];
})
.sort(
(a, b) =>
a.method.localeCompare(b.method) || a.level - b.level || a.name.localeCompare(b.name)
),
pokemon: {
number: species.number,
name: species.name,

View File

@@ -0,0 +1,278 @@
import { and, asc, eq, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions } from '@sveltejs/kit';
import { db } from './db';
import {
games,
pokemon,
pokemonCatches,
pokemonJourneys,
pokemonMilestones,
pokemonSeen,
pokemonGameEntries,
userEntries
} from './db/schema';
import { boundedInteger, isStage, obtainedMethods, validDate } from '$lib/game-tracker/journal';
// Serialize mutations for one user's game, including competing party assignments.
async function lockGame(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
userId: string,
gameId: number
) {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${userId}), ${gameId})`);
}
export const pokemonJournalActions = {
setSeen: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to record a sighting.' });
const form = await request.formData();
const gameId = boundedInteger(form.get('gameId'), 1, 2147483647);
const pokemonId = boundedInteger(form.get('pokemonId'), 1, 2147483647);
const seen = form.get('seen');
if (!gameId || !pokemonId || !['on', 'off'].includes(String(seen)))
return fail(400, { message: 'Invalid sighting.' });
const [entry] = await db
.select({ id: pokemonGameEntries.id })
.from(pokemonGameEntries)
.where(
and(eq(pokemonGameEntries.gameId, gameId), eq(pokemonGameEntries.pokemonId, pokemonId))
);
if (!entry) return fail(404, { message: 'Species not found in this game.' });
return db.transaction(async (tx) => {
await lockGame(tx, locals.user!.id, gameId);
if (seen === 'on')
await tx
.insert(pokemonSeen)
.values({ userId: locals.user!.id, gameId, pokemonId })
.onConflictDoNothing();
else {
const [caught] = await tx
.select({ id: userEntries.id })
.from(userEntries)
.where(
and(
eq(userEntries.userId, locals.user!.id),
eq(userEntries.pokemonGameEntryId, entry.id)
)
);
if (caught) return fail(400, { message: 'Caught Pokémon also count as seen.' });
await tx
.delete(pokemonSeen)
.where(
and(
eq(pokemonSeen.userId, locals.user!.id),
eq(pokemonSeen.gameId, gameId),
eq(pokemonSeen.pokemonId, pokemonId)
)
);
}
return { saved: true };
});
},
setCompleted: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to record a completed game.' });
const form = await request.formData();
const gameId = boundedInteger(form.get('gameId'), 1, 2147483647);
if (!gameId || !['on', 'off'].includes(String(form.get('completed'))))
return fail(400, { message: 'Invalid completion state.' });
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
return fail(404, { message: 'Game not found.' });
const completed = form.get('completed') === 'on';
await db
.insert(pokemonJourneys)
.values({ userId: locals.user.id, gameId, completed })
.onConflictDoUpdate({
target: [pokemonJourneys.userId, pokemonJourneys.gameId],
set: { completed, updatedAt: new Date() }
});
return { saved: true };
},
saveCatch: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to register a catch.' });
const data = await request.formData();
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
const pokemonId = boundedInteger(data.get('pokemonId'), 1, 2147483647);
const id = data.get('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
const level = boundedInteger(data.get('level'), 1, 100);
const partySlot = data.get('partySlot') ? boundedInteger(data.get('partySlot'), 1, 6) : null;
const text = (key: string) => String(data.get(key) ?? '').trim();
const stage = text('stage');
const obtained = text('obtained');
const moves = data
.getAll('moves')
.map((v) => String(v).trim())
.filter(Boolean);
if (
!gameId ||
!pokemonId ||
!level ||
(data.get('id') && !id) ||
(data.get('partySlot') && !partySlot) ||
!isStage(stage) ||
!(obtainedMethods as readonly string[]).includes(obtained) ||
moves.length > 4 ||
moves.some((m) => m.length > 60) ||
new Set(moves).size !== moves.length ||
text('nickname').length > 50 ||
text('notes').length > 2000 ||
['location', 'nature', 'ability', 'ball'].some((k) => text(k).length > 150) ||
(text('caughtOn') && !validDate(text('caughtOn')))
)
return fail(400, {
message:
'Check the catch details: level 1–100, up to four different moves and a valid date.'
});
const [entry] = await db
.select()
.from(pokemonGameEntries)
.where(
and(eq(pokemonGameEntries.gameId, gameId), eq(pokemonGameEntries.pokemonId, pokemonId))
);
if (!entry) return fail(400, { message: 'This species has no catalog entry for this game.' });
const userId = locals.user.id;
const values = {
userId,
gameId,
pokemonId,
level,
partySlot,
stage,
obtained,
moves,
nickname: text('nickname'),
notes: text('notes'),
shiny: data.get('shiny') === 'on',
location: text('location') || null,
nature: text('nature') || null,
ability: text('ability') || null,
ball: text('ball') || null,
caughtOn: text('caughtOn') || null
};
return db.transaction(async (tx) => {
await lockGame(tx, userId, gameId);
if (id) {
const [owned] = await tx
.select({ id: pokemonCatches.id })
.from(pokemonCatches)
.where(
and(
eq(pokemonCatches.id, id),
eq(pokemonCatches.userId, userId),
eq(pokemonCatches.gameId, gameId),
eq(pokemonCatches.pokemonId, pokemonId)
)
);
if (!owned) return fail(404, { message: 'Catch not found.' });
}
if (partySlot)
await tx
.update(pokemonCatches)
.set({ partySlot: null })
.where(
and(
eq(pokemonCatches.userId, userId),
eq(pokemonCatches.gameId, gameId),
eq(pokemonCatches.partySlot, partySlot)
)
);
if (id)
await tx
.update(pokemonCatches)
.set(values)
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, userId)));
else await tx.insert(pokemonCatches).values(values);
await tx
.insert(userEntries)
.values({ userId, pokemonGameEntryId: entry.id })
.onConflictDoNothing();
await tx.insert(pokemonSeen).values({ userId, gameId, pokemonId }).onConflictDoNothing();
return { saved: true };
});
},
deleteCatch: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to edit your catches.' });
const data = await request.formData();
const id = boundedInteger(data.get('id'), 1, 2147483647);
if (!id) return fail(400, { message: 'Invalid catch.' });
const [owned] = await db
.select()
.from(pokemonCatches)
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, locals.user.id)));
if (!owned) return fail(404, { message: 'Catch not found.' });
await db.transaction(async (tx) => {
await lockGame(tx, locals.user!.id, owned.gameId);
await tx
.delete(pokemonCatches)
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, locals.user!.id)));
// The historical caught checkmark deliberately survives deleting a journal record.
});
return { saved: true };
},
saveJourney: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to save your adventure.' });
const data = await request.formData();
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
const stage = String(data.get('stage') ?? '');
const location = String(data.get('location') ?? '').trim();
if (!gameId || !isStage(stage) || location.length > 150)
return fail(400, { message: 'Invalid journey.' });
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
return fail(404, { message: 'Game not found.' });
await db
.insert(pokemonJourneys)
.values({ userId: locals.user.id, gameId, stage, location: location || null })
.onConflictDoUpdate({
target: [pokemonJourneys.userId, pokemonJourneys.gameId],
set: { stage, location: location || null, updatedAt: new Date() }
});
return { saved: true };
},
saveMilestone: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to track milestones.' });
const data = await request.formData();
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
const text = (k: string) => String(data.get(k) ?? '').trim();
const key = text('key') || `custom:${crypto.randomUUID()}`;
if (
!gameId ||
!isStage(text('stage')) ||
!text('title') ||
text('title').length > 200 ||
text('notes').length > 2000 ||
key.length > 200 ||
!['badge', 'league', 'boss', 'event', 'collection'].includes(text('category'))
)
return fail(400, { message: 'Check the milestone details.' });
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
return fail(404, { message: 'Game not found.' });
const values = {
userId: locals.user.id,
gameId,
key,
title: text('title'),
category: text('category'),
stage: text('stage'),
notes: text('notes'),
done: data.get('done') === 'on'
};
await db
.insert(pokemonMilestones)
.values(values)
.onConflictDoUpdate({
target: [pokemonMilestones.userId, pokemonMilestones.gameId, pokemonMilestones.key],
set: values
});
return { saved: true };
}
} satisfies Actions;
export async function catchesFor(userId: string | undefined, gameId: number) {
if (!userId) return [];
return db
.select({ catch: pokemonCatches, number: pokemon.number, name: pokemon.name })
.from(pokemonCatches)
.innerJoin(pokemon, eq(pokemon.id, pokemonCatches.pokemonId))
.where(and(eq(pokemonCatches.userId, userId), eq(pokemonCatches.gameId, gameId)))
.orderBy(asc(pokemon.number), asc(pokemonCatches.id));
}

View File

@@ -0,0 +1,95 @@
import { eq, count } from 'drizzle-orm';
import { db } from './db';
import { pokemonProgress } from '$lib/game-tracker/progress';
import {
games,
pokemon,
pokemonGameEntries,
pokemonGameResearch,
pokemonDexNumbers,
pokemonJourneys,
pokemonMilestones,
pokemonSeen,
userEntries,
userGames,
catchEmAllSettings
} from './db/schema';
export async function loadPokemonProgress(userId?: string) {
const [
gameRows,
entries,
research,
dex,
caught,
seen,
journeys,
milestones,
selected,
settings,
totals
] = await Promise.all([
db.select({ id: games.id, name: games.name, slug: games.slug }).from(games),
db
.select({
id: pokemonGameEntries.id,
gameId: pokemonGameEntries.gameId,
pokemonId: pokemonGameEntries.pokemonId,
availability: pokemonGameEntries.availability
})
.from(pokemonGameEntries),
db
.select({ gameId: pokemonGameResearch.gameId, regions: pokemonGameResearch.regions })
.from(pokemonGameResearch),
db
.select({ gameId: pokemonDexNumbers.gameId, pokemonId: pokemonDexNumbers.pokemonId })
.from(pokemonDexNumbers),
userId
? db
.select({ id: userEntries.pokemonGameEntryId })
.from(userEntries)
.where(eq(userEntries.userId, userId))
: [],
userId
? db
.select({ gameId: pokemonSeen.gameId, pokemonId: pokemonSeen.pokemonId })
.from(pokemonSeen)
.where(eq(pokemonSeen.userId, userId))
: [],
userId
? db
.select({ gameId: pokemonJourneys.gameId, completed: pokemonJourneys.completed })
.from(pokemonJourneys)
.where(eq(pokemonJourneys.userId, userId))
: [],
userId ? db.select().from(pokemonMilestones).where(eq(pokemonMilestones.userId, userId)) : [],
userId
? db.select({ gameId: userGames.gameId }).from(userGames).where(eq(userGames.userId, userId))
: [],
userId
? db
.select({ id: catchEmAllSettings.id })
.from(catchEmAllSettings)
.where(eq(catchEmAllSettings.userId, userId))
: [],
db.select({ count: count() }).from(pokemon)
]);
const configured = settings.length > 0 || selected.length > 0;
const ids = new Set(selected.map((g) => g.gameId));
const regions = new Map(research.map((r) => [r.gameId, r.regions]));
return {
...pokemonProgress({
games: gameRows
.filter((g) => !configured || ids.has(g.id))
.map((g) => ({ ...g, regions: regions.get(g.id) ?? [] })),
entries,
dex,
caughtIds: caught.map((c) => c.id),
seen,
journeys,
milestones,
speciesTotal: totals[0].count
}),
configured
};
}

View File

@@ -223,6 +223,8 @@
>
<div class="my-1 h-px bg-border"></div>
{#each projects as project (project.name)}
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={project.external ? project.href : resolve(project.href)}
rel={project.external ? 'external' : undefined}
@@ -241,6 +243,7 @@
>
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/each}
</div>
{/if}
@@ -343,7 +346,10 @@
</div>
</header>
<main id="main" class="min-w-0 flex-1">{@render children()}</main>
<footer class="mt-16 border-t border-[var(--site-border)]">
<footer
class="mt-16 border-t border-[var(--site-border)]"
class:tracker-footer={page.route.id === '/projects/game-tracker'}
>
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
<span
@@ -374,3 +380,21 @@
</div>
</footer>
</div>
<style>
@media (min-width: 1024px) and (min-height: 650px) {
.tracker-footer {
margin-top: 0;
}
.tracker-footer > div {
padding-top: 10px;
padding-bottom: 10px;
}
.tracker-footer nav {
display: none;
}
.tracker-footer > div > div {
font-size: 12px;
}
}
</style>

View File

@@ -1,4 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { calendarDate, relativeTime } from '$lib/relative-time';
@@ -27,19 +33,25 @@
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">blog</h1>
{#if data.isAdmin}<details class="relative">
<summary class="cursor-pointer text-base underline underline-offset-4">new article</summary>
<form
method="POST"
use:enhance
class="absolute top-full right-0 z-10 mt-3 w-72 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
{#if data.isAdmin}<Dialog.Root>
<Dialog.Trigger disabled={!hydrated} class="underline underline-offset-4"
>new article</Dialog.Trigger
>
<label class="block"
>title<Input name="title" required maxlength={256} class="mt-2" /></label
<Dialog.Content
><Dialog.Header
><Dialog.Title>new article</Dialog.Title><Dialog.Description
>Give your draft a title, then continue writing.</Dialog.Description
></Dialog.Header
>
<Button type="submit">create draft</Button>
</form>
</details>{/if}
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
<form method="POST" use:enhance class="space-y-4">
<label class="block"
>title<Input name="title" required maxlength={256} class="mt-2" /></label
>
<Button type="submit">create draft</Button>
</form>
</Dialog.Content></Dialog.Root
>{/if}
</div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
<div class="mb-8 space-y-4">

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
@@ -108,16 +109,36 @@
>
</div>
</form>
<details class="mt-12 border-t border-[var(--site-border)] pt-5">
<summary class="cursor-pointer text-base text-[var(--site-muted)]">delete article</summary>
<form method="POST" action="?/delete" class="mt-3 space-y-3">
<p class="text-base">
This permanently deletes the article. Type <strong>{data.blogArticle.title}</strong> to confirm.
</p>
<label class="block text-base"
>article title<Input name="confirmTitle" required autocomplete="off" class="mt-2" /></label
>
<Button type="submit" variant="destructive">delete article</Button>
</form>
</details>
<div class="mt-12 border-t border-[var(--site-border)] pt-5">
<AlertDialog.Root>
<AlertDialog.Trigger class="text-base text-muted-foreground"
>delete article</AlertDialog.Trigger
><AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Delete article?</AlertDialog.Title><AlertDialog.Description
>This permanently removes the article.</AlertDialog.Description
></AlertDialog.Header
>
<form method="POST" action="?/delete" class="mt-3 space-y-3">
<p class="text-base">
This permanently deletes the article. Type <strong>{data.blogArticle.title}</strong> to confirm.
</p>
<label class="block text-base"
>article title<Input
name="confirmTitle"
required
autocomplete="off"
class="mt-2"
/></label
>
<AlertDialog.Footer
><AlertDialog.Cancel>Keep article</AlertDialog.Cancel><Button
type="submit"
variant="destructive">delete article</Button
></AlertDialog.Footer
>
</form>
</AlertDialog.Content></AlertDialog.Root
>
</div>
</div>

View File

@@ -1,4 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
@@ -45,30 +52,44 @@
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">lists</h1>
{#if data.isAdmin}<details class="relative">
<summary class="cursor-pointer text-base underline underline-offset-4">new list</summary>
<form
method="POST"
action="?/newList"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
{#if data.isAdmin}<Dialog.Root>
<Dialog.Trigger disabled={!hydrated} class="underline underline-offset-4"
>new list</Dialog.Trigger
>
<label class="block"
>name<Input name="name" required maxlength={200} class="mt-2" /></label
<Dialog.Content
><Dialog.Header
><Dialog.Title>new list</Dialog.Title><Dialog.Description
>Create a collection or track progress through a list.</Dialog.Description
></Dialog.Header
>
<label class="block"
>type<select
name="type"
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
><option value="normal">normal</option><option value="progress">progress</option
></select
></label
{#if form?.message}<p role="alert" class="text-destructive">
{form.message}
</p>{/if}{#if uploadError}<p role="alert" class="text-destructive">
{uploadError}
</p>{/if}
<form
method="POST"
action="?/newList"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="space-y-4"
>
<ListPictureDropzone label="List picture (optional)" />
<Button type="submit" disabled={uploading}>create list</Button>
</form>
</details>{/if}
<label class="block"
>name<Input name="name" required maxlength={200} class="mt-2" /></label
>
<label class="block"
>type<NativeSelect
name="type"
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
><option value="normal">normal</option><option value="progress">progress</option
></NativeSelect
></label
>
<ListPictureDropzone label="List picture (optional)" />
<Button type="submit" disabled={uploading}>create list</Button>
</form>
</Dialog.Content></Dialog.Root
>{/if}
</div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="mb-5 text-red-500">{uploadError}</p>{/if}

View File

@@ -1,4 +1,13 @@
<script lang="ts">
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
let addOpen = $state(false);
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Check from 'lucide-svelte/icons/check';
@@ -28,7 +37,10 @@
update: () => Promise<void>;
}) => {
await update();
if (result.type === 'success') editingItem = null;
if (result.type === 'success') {
editingItem = null;
addOpen = false;
}
};
uploadError = '';
const picture = formData.get('picture');
@@ -65,12 +77,6 @@
};
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape') editingItem = null;
}}
/>
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/lists')}
@@ -94,11 +100,11 @@
</p>
</div>
</div>
{#if data.isAdmin}<button
{#if data.isAdmin}<Button
type="button"
onclick={() => (managing = !managing)}
aria-pressed={managing}
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</button
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</Button
>{/if}
</div>
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
@@ -123,9 +129,11 @@
{/if}
{/if}
{#if data.isAdmin && managing}
<details class="border-b border-[var(--site-border)] pb-5">
<summary class="cursor-pointer text-base text-[var(--site-muted)]">list settings</summary>
<div class="mt-5 space-y-5">
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
<Collapsible.Trigger class="cursor-pointer text-base text-[var(--site-muted)]"
>list settings</Collapsible.Trigger
>
<Collapsible.Content class="mt-5 space-y-5">
<form
method="POST"
action="?/updateList"
@@ -134,65 +142,78 @@
class="grid gap-3 sm:grid-cols-2 sm:items-end"
>
<label
>List name<input
>List name<Input
name="name"
value={data.list.name}
required
maxlength="200"
maxlength={200}
class="block w-full rounded border p-2"
/></label
>
<label
>Slug<input
>Slug<Input
name="slug"
value={data.list.slug}
required
maxlength="120"
maxlength={120}
pattern="[a-z0-9]+(-[a-z0-9]+)*"
class="block w-full rounded border p-2"
/></label
>
<label
>Type<select name="type" class="block w-full rounded border p-2">
>Type<NativeSelect name="type" class="block w-full rounded border p-2">
<option value="normal" selected={!data.list.isProgress}>Normal</option>
<option value="progress" selected={data.list.isProgress}>Progress</option>
</select></label
</NativeSelect></label
>
<div class="sm:col-span-2">
<ListPictureDropzone label="List picture (optional)" />
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
><Checkbox name="removePicture" value="on" /> Remove current picture</label
>{/if}
</div>
<button disabled={uploading} class="rounded border p-2 disabled:opacity-50"
>Save list</button
<Button type="submit" disabled={uploading} class="rounded border p-2 disabled:opacity-50"
>Save list</Button
>
</form>
<details class="border-t border-[var(--site-border)] pt-4">
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
<p class="text-sm">
This deletes the list, its items, and everyone’s saved progress. Type <strong
>{data.list.name}</strong
> to confirm.
</p>
<label class="block"
>List name<input
name="confirmName"
required
autocomplete="off"
class="mt-1 block w-full rounded border p-2"
/></label
<AlertDialog.Root>
<AlertDialog.Trigger class="font-medium text-destructive"
>Delete this list</AlertDialog.Trigger
><AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Delete this list?</AlertDialog.Title><AlertDialog.Description
>Deleting this list also removes its items and saved progress.</AlertDialog.Description
></AlertDialog.Header
>
<button class="rounded bg-red-700 px-4 py-2 text-white">Delete list</button>
</form>
</details>
</div>
</details>
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
<p class="text-sm">
This deletes the list, its items, and everyone’s saved progress. Type <strong
>{data.list.name}</strong
> to confirm.
</p>
<label class="block"
>List name<Input
name="confirmName"
required
autocomplete="off"
class="mt-1 block w-full rounded border p-2"
/></label
>
<AlertDialog.Footer
><AlertDialog.Cancel>Keep list</AlertDialog.Cancel><Button
type="submit"
variant="destructive">Delete list</Button
></AlertDialog.Footer
>
</form>
</AlertDialog.Content></AlertDialog.Root
>
</Collapsible.Content>
</Collapsible.Root>
{/if}
<label class="block">
<span class="sr-only">Search list items</span>
<input
<Input
type="search"
bind:value={search}
placeholder="Search list items"
@@ -223,19 +244,20 @@
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
<input type="hidden" name="itemId" value={item.id} />
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
<button
<Button
type="submit"
class="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--site-border)] text-base"
aria-label={`${completed.has(item.id) ? 'Uncheck' : 'Check'} ${item.name}`}
>
{#if completed.has(item.id)}<Check size={16} aria-hidden="true" />{/if}
</button>
</Button>
</form>
{/if}
{#if data.isAdmin && managing}<button
{#if data.isAdmin && managing}<Button
type="button"
onclick={() => (editingItem = item)}
class="shrink-0 rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-surface)]"
aria-label={`Edit ${item.name}`}>Edit</button
aria-label={`Edit ${item.name}`}>Edit</Button
>{/if}
</div>
</li>
@@ -243,20 +265,21 @@
{search ? 'No matching items' : 'no items yet'}
</li>{/each}
</ul>
{#if data.isAdmin && managing && editingItem}
<div class="fixed inset-0 z-40 bg-black/60"></div>
<div class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-8">
<div
role="dialog"
aria-modal="true"
aria-label={`Edit ${editingItem.name}`}
class="mx-auto max-w-xl rounded-lg bg-[var(--site-bg)] p-5 shadow-xl"
<Dialog.Root
open={!!(data.isAdmin && managing && editingItem)}
onOpenChange={(open) => {
if (!open) editingItem = null;
}}
>
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<Dialog.Header
><Dialog.Title>Edit item</Dialog.Title><Dialog.Description
>Update the name, picture and description.</Dialog.Description
></Dialog.Header
>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="text-xl font-semibold">Edit item</h2>
<button type="button" onclick={() => (editingItem = null)} class="underline">Close</button
>
</div>
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="text-destructive">{uploadError}</p>{/if}
{#if editingItem}
<form
method="POST"
action="?/updateItem"
@@ -266,30 +289,32 @@
>
<input type="hidden" name="itemId" value={editingItem.id} />
<label
>Name<input
>Name<Input
name="name"
value={editingItem.name}
required
maxlength="500"
maxlength={500}
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone label="Replace picture (optional)" />
{#if editingItem.pictureUrl}<label class="text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
><Checkbox name="removePicture" value="on" /> Remove current picture</label
>{/if}
<label
>Description (optional)<textarea
>Description (optional)<Textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2">{editingItem.description ?? ''}</textarea
></label
maxlength={5000}
rows={3}
class="block w-full rounded border p-2"
value={editingItem.description ?? ''}
/></label
>
<button
<Button
type="submit"
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Save item</button
>Save item</Button
>
</form>
<form
@@ -298,51 +323,66 @@
use:enhance={() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') editingItem = null;
if (result.type === 'success') {
editingItem = null;
addOpen = false;
}
}}
class="mt-5 border-t border-[var(--site-border)] pt-4"
>
<input type="hidden" name="itemId" value={editingItem.id} />
<button class="text-sm text-red-700 underline" aria-label={`Remove ${editingItem.name}`}
>Remove item</button
<Button
type="submit"
class="text-sm text-red-700 underline"
aria-label={`Remove ${editingItem.name}`}>Remove item</Button
>
</form>
</div>
</div>
{/if}
{/if}</Dialog.Content
></Dialog.Root
>
{#if data.isAdmin && managing}
<details class="border-t border-[var(--site-border)] pt-5">
<summary class="cursor-pointer font-medium">add item</summary>
<form
method="POST"
action="?/addItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="mt-5 grid gap-3"
>
<label
>Name<input
name="name"
required
maxlength="500"
class="block w-full rounded border p-2"
/></label
<Dialog.Root bind:open={addOpen}
><Dialog.Trigger class="underline underline-offset-4">add item</Dialog.Trigger><Dialog.Content
><Dialog.Header
><Dialog.Title>Add item</Dialog.Title><Dialog.Description
>Add a new entry to this list.</Dialog.Description
></Dialog.Header
>
<ListPictureDropzone />
<label
>Description (optional)<textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2"></textarea></label
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="text-destructive">{uploadError}</p>{/if}
<form
method="POST"
action="?/addItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="mt-5 grid gap-3"
>
<button
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Add item</button
>
</form>
</details>
<label
>Name<Input
name="name"
required
maxlength={500}
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone />
<label
>Description (optional)<Textarea
name="description"
maxlength={5000}
rows={3}
class="block w-full rounded border p-2"
/></label
>
<Button
type="submit"
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Add item</Button
>
</form>
</Dialog.Content></Dialog.Root
>
<noscript><p>Enable JavaScript to upload item pictures.</p></noscript>
{/if}
</section>

View File

@@ -7,7 +7,7 @@
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import PawPrint from 'lucide-svelte/icons/paw-print';
import X from 'lucide-svelte/icons/x';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
interface TrailPoint {
@@ -35,7 +35,6 @@
}
let mapContainer: HTMLDivElement;
let detailsDialog: HTMLDialogElement;
let map: MaptilerMap | null = null;
let destroyed = false;
let htmlMarkers: MaptilerMarker[] = [];
@@ -79,18 +78,6 @@
openAnimal(animalId);
}
$effect(() => {
if (page.state.animal) {
if (!detailsDialog.open) detailsDialog.showModal();
} else if (detailsDialog.open) {
detailsDialog.close();
}
});
function onDialogClose() {
if (page.state.animal) history.back();
}
onMount(() => {
(async () => {
try {
@@ -417,34 +404,28 @@
{/if}
</section>
<dialog
bind:this={detailsDialog}
onclose={onDialogClose}
onclick={(event) => {
if (event.target === detailsDialog) detailsDialog.close();
<Dialog.Root
open={!!page.state.animal}
onOpenChange={(open) => {
if (!open && page.state.animal) history.back();
}}
aria-label={page.state.animal
? page.state.animal.animalName || page.state.animal.species
: 'Animal details'}
class="m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-5xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-bg)] p-0 text-[var(--site-text)] backdrop:bg-black/70"
>
{#if page.state.animal}
<div class="sticky top-0 z-10 flex justify-end gap-4 bg-[var(--site-bg)] px-5 py-3 sm:px-8">
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-5xl">
<Dialog.Header>
<Dialog.Title
>{page.state.animal?.animalName ||
page.state.animal?.species ||
'Animal details'}</Dialog.Title
>
<Dialog.Description>Sightings, photos and the story so far.</Dialog.Description>
</Dialog.Header>
{#if page.state.animal}
<a
href={resolve('/projects/animaldex/[id=integer]', { id: String(page.state.animal.id) })}
data-sveltekit-reload
class="text-base underline underline-offset-4">open page</a
class="underline underline-offset-4">open page</a
>
<button
type="button"
onclick={() => detailsDialog.close()}
aria-label="Close animal details"
class="flex h-8 w-8 items-center justify-center rounded hover:bg-[var(--site-surface)]"
><X size={20} aria-hidden="true" /></button
>
</div>
<div class="px-5 pb-8 sm:px-8">
<AnimalDetails animal={page.state.animal} />
</div>
{/if}
</dialog>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
@@ -275,7 +276,7 @@
<Badge variant="secondary">{selected.length} / 1000</Badge>
</div>
<label class="field"
><span>Group by</span><select
><span>Group by</span><NativeSelect
value={dimension}
onchange={(event) => changeDimension(event.currentTarget.value)}
>
@@ -283,7 +284,7 @@
{#each data.dimensions as option (option.key)}<option value={option.key}
>{labels[option.key] ?? option.key.replaceAll('_', ' ')}</option
>{/each}
</select></label
</NativeSelect></label
>
<label class="field"
><span
@@ -347,21 +348,21 @@
</div>
<div class="controls">
<label
>Source<select
>Source<NativeSelect
value={sourceChoice}
onchange={(event) => changeSource(event.currentTarget.value)}
>{#each sourceOptions as option (option)}<option value={option}
>{option.replace('|', ' · ')}</option
>{/each}</select
>{/each}</NativeSelect
></label
><label
>Range<select bind:value={range}
>Range<NativeSelect bind:value={range}
><option value="1h">1 hour</option><option value="24h">24 hours</option><option
value="7d">7 days</option
><option value="30d">30 days</option><option value="1y">1 year</option><option
value="5y">5 years</option
><option value="all">All history</option><option value="custom">Custom</option
></select
></NativeSelect
></label
>
</div>
@@ -504,7 +505,7 @@
font-weight: 600;
margin-bottom: 0.45rem;
}
select {
:global([data-slot='native-select-wrapper']) {
width: 100%;
border: 1px solid var(--site-border);
color: var(--site-text);
@@ -587,7 +588,7 @@
display: flex;
gap: 0.5rem;
}
.controls select {
.controls :global([data-slot='native-select-wrapper']) {
min-width: 140px;
}
.custom-range {

View File

@@ -1,80 +1,88 @@
import { and, asc, countDistinct, eq, inArray, sql } from 'drizzle-orm';
import { asc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import {
catchEmAllSettings,
pokemonGameEntries,
trackerGames,
trackerItems,
trackerProgress,
userEntries,
userGames
trackerWeaponRanks,
trackerRanks,
trackerChallenges,
trackerChallengeProgress,
osuEntries
} from '$lib/server/db/schema';
import { loadPokemonProgress } from '$lib/server/tracker-progress';
import { challengeProgress, combinedProgress, sumProgress } from '$lib/game-tracker/progress';
import { summarizeGame } from '$lib/game-tracker/mastery';
import { chronologicalPokemon, pokemonRelease } from '$lib/game-tracker/game-art';
import type { PageServerLoad } from './$types';
const obtainable = inArray(pokemonGameEntries.availability, ['catchable', 'evolve', 'breed']);
async function pokemonSummary(userId: string | undefined) {
let gameIds: number[] | null = null;
if (userId) {
const [settings] = await db
.select({ id: catchEmAllSettings.id })
.from(catchEmAllSettings)
.where(eq(catchEmAllSettings.userId, userId));
const selected = await db
.select({ id: userGames.gameId })
.from(userGames)
.where(eq(userGames.userId, userId));
if (settings || selected.length) gameIds = selected.map((row) => row.id);
}
const scope =
gameIds === null ? obtainable : and(obtainable, inArray(pokemonGameEntries.gameId, gameIds));
const [totals] = await db
.select({
entries: sql<number>`count(*)`.mapWith(Number),
species: countDistinct(pokemonGameEntries.pokemonId),
games: countDistinct(pokemonGameEntries.gameId)
})
.from(pokemonGameEntries)
.where(scope);
const [caught] = userId
? await db
.select({
entries: sql<number>`count(*)`.mapWith(Number),
species: countDistinct(pokemonGameEntries.pokemonId)
})
.from(userEntries)
.innerJoin(pokemonGameEntries, eq(pokemonGameEntries.id, userEntries.pokemonGameEntryId))
.where(and(eq(userEntries.userId, userId), scope))
: [{ entries: 0, species: 0 }];
return { totals, caught };
const progress = await loadPokemonProgress(userId);
return {
...progress,
games: chronologicalPokemon(progress.configured ? progress.games : []).map((g) => ({
...g,
releaseYear: pokemonRelease(g.slug).slice(0, 4)
}))
};
}
async function callOfDutySummary(userId: string | undefined) {
const [gamesList, items, progress] = await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.sortOrder)),
db
.select({
id: trackerItems.id,
gameId: trackerItems.gameId,
kind: trackerItems.kind,
name: trackerItems.name,
category: trackerItems.category,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems),
userId
? db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.where(eq(trackerProgress.userId, userId))
: Promise.resolve([])
]);
const [gamesList, items, progress, ranks, playerRanks, challenges, challengeRows] =
await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.releaseYear), asc(trackerGames.sortOrder)),
db
.select({
id: trackerItems.id,
gameId: trackerItems.gameId,
kind: trackerItems.kind,
name: trackerItems.name,
category: trackerItems.category,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems),
userId
? db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.where(eq(trackerProgress.userId, userId))
: Promise.resolve([]),
userId
? db
.select({ itemId: trackerWeaponRanks.itemId, prestige: trackerWeaponRanks.prestige })
.from(trackerWeaponRanks)
.where(eq(trackerWeaponRanks.userId, userId))
: Promise.resolve([]),
userId
? db
.select({ gameId: trackerRanks.gameId })
.from(trackerRanks)
.where(eq(trackerRanks.userId, userId))
: Promise.resolve([]),
db
.select({
id: trackerChallenges.id,
gameId: trackerChallenges.gameId,
target: trackerChallenges.target
})
.from(trackerChallenges),
userId
? db
.select({
challengeId: trackerChallengeProgress.challengeId,
current: trackerChallengeProgress.current,
done: trackerChallengeProgress.done
})
.from(trackerChallengeProgress)
.where(eq(trackerChallengeProgress.userId, userId))
: []
]);
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
for (const rank of ranks) if (rank.prestige > 0) done.add(`${rank.itemId}:maxLevel`);
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
@@ -84,7 +92,28 @@ async function callOfDutySummary(userId: string | undefined) {
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
(id, milestone) => done.has(`${id}:${milestone}`)
);
const weaponProgress = sumProgress(summary.milestones);
const challengesProgress = challengeProgress(
challenges.filter((c) => c.gameId === game.id),
challengeRows
);
const ranksProgress = {
value:
ranks.filter((rank) =>
list.some((item) => item.kind === 'weapon' && item.id === rank.itemId)
).length + Number(playerRanks.some((rank) => rank.gameId === game.id)),
total: list.filter((item) => item.kind === 'weapon').length + 1
};
return {
overall: combinedProgress([
weaponProgress,
ranksProgress,
summary.achievements,
challengesProgress
]),
weaponProgress,
challengesProgress,
slug: game.slug,
name: game.shortName,
releaseYear: game.releaseYear,
@@ -108,9 +137,23 @@ async function callOfDutySummary(userId: string | undefined) {
}
export const load: PageServerLoad = async ({ locals }) => {
const [pokemon, callOfDuty] = await Promise.all([
const [pokemon, callOfDuty, osu] = await Promise.all([
pokemonSummary(locals.user?.id),
callOfDutySummary(locals.user?.id)
callOfDutySummary(locals.user?.id),
locals.user
? db
.select({ kind: osuEntries.kind, achieved: osuEntries.achieved })
.from(osuEntries)
.where(eq(osuEntries.userId, locals.user.id))
: []
]);
return { pokemon, callOfDuty };
return {
pokemon,
callOfDuty,
codOverall: combinedProgress(callOfDuty.map((g) => g.overall)),
osuGoals: {
value: osu.filter((e) => e.kind === 'goal' && e.achieved).length,
total: osu.filter((e) => e.kind === 'goal').length
}
};
};

View File

@@ -1,107 +1,211 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function percent(value: number, total: number) {
return total > 0 ? Math.round((value / total) * 100) : 0;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">game tracker</h1>
<p class="mt-2 text-[var(--site-muted)]">
completionist progress: every Pokémon in every game, and every achievement, weapon and camo in
Call of Duty.
</p>
{#if !data.user}<p class="mt-6">
<div class="tracker-library mx-auto flex w-full max-w-[1600px] flex-col px-4 py-5 sm:px-6">
<div class="mb-5 flex flex-wrap items-end justify-between gap-2">
<div>
<p class="text-xs tracking-[.18em] text-muted-foreground uppercase">Your next session</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Game tracker</h1>
</div>
{#if !data.user}<a
class="text-sm text-muted-foreground underline"
href={resolve('/login?returnTo=/projects/game-tracker')}>Sign in to save progress</a
>{:else}<a
class="text-xs text-muted-foreground underline"
href={resolve('/projects/game-tracker/credits')}>Artwork credits</a
>{/if}
</div>
<div
class="mb-4 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 lg:grid-cols-4"
aria-label="Your completion dashboard"
>
<ProgressMeter
label="National collection"
progress={data.pokemon.caught}
detail={`${data.pokemon.caught.value} unique species caught`}
hint="Unique species caught across the Pokémon games in your rules, out of the full species catalog."
/>
<ProgressMeter
label="Species seen at least once"
progress={data.pokemon.seen}
color="sky"
detail={`${data.pokemon.seen.value} species discovered`}
/>
<ProgressMeter
label="Pokémon games completed"
progress={data.pokemon.completed}
color="amber"
hint="Games marked story complete, or with all default story milestones checked."
/>
<ProgressMeter
label="Call of Duty overall"
progress={data.codOverall}
color="violet"
detail="Mastery · achievements · challenges"
/>
</div>
<div class="series-grid grid min-h-0 flex-1 gap-4">
<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="pokemon-series"
>
<a
href={resolve('/login?returnTo=/projects/game-tracker')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
<section class="mt-10">
<h2 class="text-2xl font-semibold">Pokémon</h2>
<Card.Root class="mt-4 py-0 transition-colors hover:bg-muted/50"
><Card.Content class="p-0"
><a href={resolve('/projects/game-tracker/pokemon')} class="block p-5">
<div class="grid grid-cols-[minmax(0,1fr)_8rem_auto] items-center gap-3">
<p class="text-xl font-semibold">catchemall</p>
<div class="w-32">
<ProgressBar
value={data.pokemon.caught.entries}
max={data.pokemon.totals.entries}
label="Pokémon caught"
small
href={resolve('/projects/game-tracker/pokemon')}
class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-amber-500/10 p-3 transition-colors hover:bg-amber-500/20"
>
<GameLogo slug="pokemon" name="Pokémon" class="h-12 w-24" />
<div class="min-w-0 flex-1">
<h2 id="pokemon-series" class="font-semibold">Pokémon</h2>
<p class="text-xs text-muted-foreground">Open your adventure journal →</p>
<p class="mt-1 text-xs text-muted-foreground">
{data.pokemon.caught.value} / {data.pokemon.caught.total} species
</p>
</div>
</a>
<div class="mb-3 space-y-2 rounded-lg bg-muted/20 p-2">
<ProgressMeter
label="Across every adventure"
progress={data.pokemon.overall}
color="amber"
compact
hint="Equal weight per game; collection and story stages each contribute."
/>
<ProgressMeter
label="Every game’s collection"
progress={data.pokemon.collection}
compact
hint="Each confirmed species/game pairing counts once, including trades and events."
/>
</div>
{#if data.pokemon.games.length}
<div class="collection-grid grid grid-cols-2 gap-2">
{#each data.pokemon.games as game (game.id)}
<a
class="game-row flex min-h-0 min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors hover:border-amber-500/50 hover:bg-amber-500/5"
href={resolve(`/projects/game-tracker/pokemon?game=${game.slug ?? game.id}`)}
title={`${game.name} · ${game.releaseYear}`}
>
<GameLogo
slug={`pokemon/${game.slug}`}
name={game.name}
class="game-cover h-10 w-10"
/>
<div class="min-w-0 flex-1">
<p class="game-name text-xs leading-tight font-medium">{game.name}</p>
<p class="game-year mt-0.5 text-[10px] text-muted-foreground">
{game.releaseYear}{game.completed ? ' · ✓ story' : ''}
</p>
<ProgressMeter
label="Game progress"
progress={game.overall}
color="amber"
compact
/>
</div>
</a>
{/each}
</div>
{:else}<a
class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground hover:text-foreground"
href={resolve('/projects/game-tracker/pokemon')}
>Choose your games in “set rules” to build your collection here.</a
>{/if}
</section>
<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="cod-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-zinc-500/10 p-3">
<GameLogo slug="call-of-duty" name="Call of Duty" class="h-12 w-24" />
<div>
<h2 id="cod-series" class="font-semibold">Call of Duty</h2>
<p class="text-xs text-muted-foreground">
{data.callOfDuty.length} campaigns of progress
</p>
</div>
</div>
<div class="mb-3 px-1">
<ProgressMeter label="COD completion" progress={data.codOverall} color="violet" compact />
</div>
<div class="cod-rows grid min-h-0 gap-1.5">
{#each data.callOfDuty as game (game.slug)}
<a
href={resolve(`/projects/game-tracker/call-of-duty/${game.slug}`)}
class="game-row flex min-h-0 items-center gap-3 rounded-lg border p-2 transition-colors hover:border-primary/40 hover:bg-muted/50"
>
<GameLogo slug={`cod/${game.slug}`} name={game.name} class="h-11 w-24" />
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight font-medium">{game.name}</p>
<p class="mt-1 text-[11px] text-muted-foreground">
{game.releaseYear}<span class="ml-2"
>{game.achievements.value}/{game.achievements.total} achievements</span
>
</p>
<ProgressMeter
label={`${game.name} progress`}
progress={game.overall}
color="violet"
compact
/>
</div>
<p class="text-xl font-semibold tabular-nums">
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
</p>
</a>
{/each}
</div>
</section>
<section
class="series-column flex min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="osu-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-pink-500/10 p-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12" />
<div>
<h2 id="osu-series" class="font-semibold">osu!</h2>
<p class="text-xs text-muted-foreground">One more attempt</p>
</div>
</div>
<a
href={resolve('/projects/game-tracker/osu')}
class="rounded-xl border p-4 transition-colors hover:border-pink-500/50 hover:bg-pink-500/5"
><div class="flex items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-14 w-14" />
<div>
<p class="font-semibold">osu!</p>
<p class="text-xs text-muted-foreground">2007</p>
</div>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.caught.entries} of {data.pokemon.totals.entries} obtainable entries ·
{data.pokemon.caught.species} of {data.pokemon.totals.species} species · {data.pokemon
.totals.games} games
</p>
</a></Card.Content
></Card.Root
>
<Button variant="link" href={resolve('/projects/game-tracker/pokemon/locations')} class="mt-3"
>browse Pokémon locations</Button
>
</section>
<section class="mt-12">
<h2 class="text-2xl font-semibold">Call of Duty</h2>
<ul class="mt-4 grid gap-4 md:grid-cols-2">
{#each data.callOfDuty as game (game.slug)}
<li>
<Card.Root class="h-full py-0 transition-colors hover:bg-muted/50"
><Card.Content class="p-0"
><a
href={resolve('/projects/game-tracker/call-of-duty/[slug]', { slug: game.slug })}
class="block h-full p-5"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">{game.name}</p>
<p class="text-[var(--site-muted)]">{game.releaseYear}</p>
</div>
<dl class="mt-4 grid gap-3">
{#each [{ key: 'achievements', label: 'achievements', ...game.achievements }, ...game.milestones] as row (row.key)}
<div
class="grid grid-cols-[minmax(0,1fr)_8rem_auto] items-center gap-3 text-sm text-[var(--site-muted)]"
>
<dt class="truncate">{row.label}</dt>
<div class="w-32">
<ProgressBar value={row.value} max={row.total} label={row.label} small />
</div>
<dd class="min-w-12 text-right tabular-nums">{row.value}/{row.total}</dd>
</div>
{/each}
</dl>
{#if game.tiers.length}
<p class="mt-4 text-[var(--site-muted)]">
{game.tiers
.map((tier) =>
tier.scope === 'class'
? `${tier.label} ${tier.value}/${tier.total} classes`
: `${tier.label} ${tier.achieved ? '✓' : `${percent(tier.value, tier.total)}%`}`
)
.join(' · ')}
</p>
{/if}
</a></Card.Content
></Card.Root
>
</li>
{:else}
<li class="text-[var(--site-muted)]">the Call of Duty catalog has not been loaded yet.</li>
{/each}
</ul>
</section>
</div>
<p class="mt-4 text-sm">Your training room →</p>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
Scores, dream FCs, stamina and speed. Build your next personal best.
</p>
<div class="mt-4">
<ProgressMeter label="osu! goals achieved" progress={data.osuGoals} color="pink" />
</div></a
>
</section>
</div>
</div>
<style>
.series-grid {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 0.8fr);
align-items: start;
}
.game-row {
min-height: 64px;
}
@media (max-width: 1100px) {
.series-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 639px) {
.series-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -1,8 +1,20 @@
import { error, fail } from '@sveltejs/kit';
import { and, asc, eq } from 'drizzle-orm';
import achievementArt from '../../../../../../data/cod-achievement-art.json';
const achievementImages: Record<string, string> = achievementArt;
import { and, asc, eq, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { trackerGames, trackerItems, trackerProgress } from '$lib/server/db/schema';
import {
trackerGames,
trackerItems,
trackerProgress,
trackerRanks,
trackerWeaponRanks,
trackerChallenges,
trackerChallengeProgress
} from '$lib/server/db/schema';
import { boundedInteger } from '$lib/game-tracker/journal';
import { appliesTo } from '$lib/game-tracker/mastery';
import { playerRankLimits, weaponRankLimits } from '$lib/game-tracker/rank-limits';
import type { Actions, PageServerLoad } from './$types';
async function findGame(slug: string) {
@@ -36,9 +48,61 @@ export const load: PageServerLoad = async ({ params, locals }) => {
.innerJoin(trackerItems, eq(trackerItems.id, trackerProgress.itemId))
.where(and(eq(trackerProgress.userId, locals.user.id), eq(trackerItems.gameId, game.id)))
: [];
const [ranks, weaponRanks, challenges, challengeProgress] = await Promise.all([
locals.user
? db
.select()
.from(trackerRanks)
.where(and(eq(trackerRanks.userId, locals.user.id), eq(trackerRanks.gameId, game.id)))
: [],
locals.user
? db
.select({
itemId: trackerWeaponRanks.itemId,
level: trackerWeaponRanks.level,
prestige: trackerWeaponRanks.prestige
})
.from(trackerWeaponRanks)
.innerJoin(trackerItems, eq(trackerItems.id, trackerWeaponRanks.itemId))
.where(
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerItems.gameId, game.id))
)
: [],
db
.select()
.from(trackerChallenges)
.where(eq(trackerChallenges.gameId, game.id))
.orderBy(asc(trackerChallenges.category), asc(trackerChallenges.id)),
locals.user
? db
.select({
challengeId: trackerChallengeProgress.challengeId,
current: trackerChallengeProgress.current,
done: trackerChallengeProgress.done
})
.from(trackerChallengeProgress)
.innerJoin(
trackerChallenges,
eq(trackerChallenges.id, trackerChallengeProgress.challengeId)
)
.where(
and(
eq(trackerChallengeProgress.userId, locals.user.id),
eq(trackerChallenges.gameId, game.id)
)
)
: []
]);
return {
rank: ranks[0] ?? null,
weaponRanks,
challenges,
challengeProgress,
game: { slug: game.slug, name: game.name, shortName: game.shortName, rules: game.rules },
items,
items: items.map((item) => ({
...item,
icon: item.icon ? (achievementImages[item.icon] ?? item.icon) : null
})),
done: progress.map((row) => `${row.itemId}:${row.milestone}`),
seo: {
title: `${game.shortName} · Game Tracker`,
@@ -48,6 +112,151 @@ export const load: PageServerLoad = async ({ params, locals }) => {
};
export const actions = {
adjustWeaponRank: async ({ request, locals, params }) => {
if (!locals.user) return fail(401, { message: 'Sign in to save weapon progress.' });
const game = await findGame(params.slug);
const form = await request.formData();
const itemId = boundedInteger(form.get('itemId'), 1, 2147483647);
const field = form.get('field');
const delta = form.get('delta') === '1' ? 1 : form.get('delta') === '-1' ? -1 : null;
if (!itemId || !['level', 'prestige'].includes(String(field)) || delta === null)
return fail(400, { message: 'Invalid weapon adjustment.' });
const [item] = await db
.select({ id: trackerItems.id, name: trackerItems.name, category: trackerItems.category })
.from(trackerItems)
.where(
and(
eq(trackerItems.id, itemId),
eq(trackerItems.gameId, game.id),
eq(trackerItems.kind, 'weapon')
)
);
if (!item) return fail(404, { message: 'Weapon not found in this game.' });
const limits = weaponRankLimits(game.rules, item);
if (!limits.rankable)
return fail(400, {
message: 'This weapon does not have a weapon-rank progression in this game.'
});
const maxLevel = limits.maxLevel ?? 10000;
if (field === 'prestige' && delta > 0 && limits.canPrestigeOnlyAtMaxLevel) {
const [current] = await db
.select({ level: trackerWeaponRanks.level, prestige: trackerWeaponRanks.prestige })
.from(trackerWeaponRanks)
.where(
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerWeaponRanks.itemId, itemId))
);
if (!current || current.level < maxLevel || current.prestige >= limits.maxPrestige)
return fail(400, { message: `Reach weapon level ${maxLevel} before increasing prestige.` });
}
// Arithmetic happens in Postgres, so simultaneous taps from different devices cannot lose a level.
const [rank] = await db
.insert(trackerWeaponRanks)
.values({
userId: locals.user.id,
itemId,
level: field === 'level' ? Math.max(1, Math.min(maxLevel, 1 + delta)) : 1,
prestige: field === 'prestige' ? Math.max(0, delta) : 0
})
.onConflictDoUpdate({
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
set: {
...(field === 'level'
? {
level: sql`greatest(1, least(${maxLevel}, ${trackerWeaponRanks.level} + ${delta}))`
}
: {
prestige: sql`greatest(0, least(${limits.maxPrestige}, ${trackerWeaponRanks.prestige} + ${delta}))`,
...(delta > 0 ? { level: 1 } : {})
}),
updatedAt: new Date()
}
})
.returning({
itemId: trackerWeaponRanks.itemId,
level: trackerWeaponRanks.level,
prestige: trackerWeaponRanks.prestige
});
return { rank };
},
saveRank: async ({ request, locals, params }) => {
if (!locals.user) return fail(401, { message: 'Sign in to save your rank.' });
const game = await findGame(params.slug);
const data = await request.formData();
const level = boundedInteger(data.get('level'), 1, 10000);
const prestige = boundedInteger(data.get('prestige'), 0, 1000);
const itemId = data.get('itemId') ? boundedInteger(data.get('itemId'), 1, 2147483647) : null;
if (level === null || prestige === null || (data.get('itemId') && !itemId))
return fail(400, { message: 'Enter a valid level and prestige.' });
if (itemId) {
const [item] = await db
.select()
.from(trackerItems)
.where(
and(
eq(trackerItems.id, itemId),
eq(trackerItems.gameId, game.id),
eq(trackerItems.kind, 'weapon')
)
);
if (!item) return fail(404, { message: 'Weapon not found in this game.' });
const limits = weaponRankLimits(game.rules, item);
if (
!limits.rankable ||
(limits.maxLevel !== null && level > limits.maxLevel) ||
prestige > limits.maxPrestige
)
return fail(400, { message: 'Enter a rank within this weapon’s level and prestige caps.' });
await db
.insert(trackerWeaponRanks)
.values({ userId: locals.user.id, itemId, level, prestige })
.onConflictDoUpdate({
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
set: { level, prestige, updatedAt: new Date() }
});
} else {
const limits = playerRankLimits(game.rules);
if ((limits.maxLevel !== null && level > limits.maxLevel) || prestige > limits.maxPrestige)
return fail(400, { message: 'Enter a rank within this player level and prestige caps.' });
await db
.insert(trackerRanks)
.values({ userId: locals.user.id, gameId: game.id, level, prestige })
.onConflictDoUpdate({
target: [trackerRanks.userId, trackerRanks.gameId],
set: { level, prestige, updatedAt: new Date() }
});
}
return { saved: true };
},
saveChallenge: async ({ request, locals, params }) => {
if (!locals.user) return fail(401, { message: 'Sign in to track challenges.' });
const game = await findGame(params.slug);
const data = await request.formData();
const challengeId = boundedInteger(data.get('challengeId'), 1, 2147483647);
const current = boundedInteger(data.get('current'), 0, 2147483647);
if (!challengeId || current === null)
return fail(400, { message: 'Invalid challenge progress.' });
const [challenge] = await db
.select()
.from(trackerChallenges)
.where(and(eq(trackerChallenges.id, challengeId), eq(trackerChallenges.gameId, game.id)));
if (!challenge) return fail(404, { message: 'Challenge not found.' });
if (challenge.target !== null && current > challenge.target)
return fail(400, { message: 'Progress cannot exceed the target.' });
const values = {
userId: locals.user.id,
challengeId,
current,
done: data.get('done') === 'on' || (challenge.target !== null && current >= challenge.target)
};
await db
.insert(trackerChallengeProgress)
.values(values)
.onConflictDoUpdate({
target: [trackerChallengeProgress.userId, trackerChallengeProgress.challengeId],
set: values
});
return { saved: true };
},
toggle: async ({ request, locals, params }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const game = await findGame(params.slug);

View File

@@ -1,14 +1,39 @@
<script lang="ts">
import WeaponRankControls from '$lib/game-tracker/WeaponRankControls.svelte';
import WeaponArtwork from '$lib/game-tracker/WeaponArtwork.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import Crosshair from 'lucide-svelte/icons/crosshair';
import Trophy from 'lucide-svelte/icons/trophy';
import Target from 'lucide-svelte/icons/target';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
import { combinedProgress, sumProgress, challengeProgress } from '$lib/game-tracker/progress';
import { playerRankLimits, weaponRankLimits } from '$lib/game-tracker/rank-limits';
let rankOpen = $state(false);
let rankWeapon = $state<number | null>(null);
let challengeSearch = $state('');
let challengeCategory = $state('all');
let rankSaving = $state(false);
let challengePage = $state(1);
let weaponSearch = $state('');
let weaponClass = $state('all');
let overviewOpen = $state(false);
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { deserialize, enhance } from '$app/forms';
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let { data, form }: PageProps = $props();
// rebuilt whenever the page data changes, so the server render already shows progress
const done = $derived(new SvelteSet(data.done));
const pending = new SvelteSet<string>();
@@ -16,7 +41,10 @@
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
const isDone = (id: number, milestone: string) => done.has(`${id}:${milestone}`);
const weaponRanks = $derived(new SvelteMap(data.weaponRanks.map((r) => [r.itemId, r])));
const isDone = (id: number, milestone: string) =>
done.has(`${id}:${milestone}`) ||
(milestone === 'maxLevel' && (weaponRanks.get(id)?.prestige ?? 0) > 0);
async function toggle(itemId: number, milestone: string, checked: boolean) {
const key = `${itemId}:${milestone}`;
@@ -55,6 +83,24 @@
isDone
)
);
const masteryProgress = $derived(sumProgress(summary.milestones));
const unlockProgress = $derived(challengeProgress(data.challenges, data.challengeProgress));
const rankProgress = $derived({
value: weaponRanks.size + Number(!!data.rank),
total: weapons.length + 1
});
const overall = $derived(
combinedProgress([masteryProgress, rankProgress, summary.achievements, unlockProgress])
);
const classesProgress = $derived(
rules.classes.map((category) => {
const members = weapons.filter((w) => w.category === category);
const tasks = members.flatMap((w) =>
rules.milestones.filter((m) => appliesTo(m, w)).map((m) => isDone(w.id, m.key))
);
return { category, value: tasks.filter(Boolean).length, total: tasks.length };
})
);
type Bar = {
label: string;
description?: string;
@@ -70,39 +116,74 @@
label: tier.scope === 'class' ? `${tier.label} classes` : tier.label
}))
]);
const classes = $derived(
rules.classes
.map((name) => {
const list = weapons.filter((weapon) => weapon.category === name);
const milestones = rules.milestones.filter((milestone) =>
list.some((weapon) => appliesTo(milestone, weapon))
);
const tiers = rules.tiers
.filter((tier) => tier.scope === 'class')
.map((tier) => ({ tier, count: summary.mastery.classTiers.get(tier.key)?.get(name) }))
.filter((entry) => entry.count);
return { name, list, milestones, tiers };
})
.filter((entry) => entry.list.length)
const weaponRows = $derived(
weapons.filter(
(w) =>
(weaponClass === 'all' || w.category === weaponClass) &&
w.name.toLowerCase().includes(weaponSearch.toLowerCase().trim()) &&
(!hideDone ||
rules.milestones.filter((m) => appliesTo(m, w)).some((m) => !isDone(w.id, m.key)))
)
);
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
const tabs = ['weapons', 'achievements'] as const;
const tabs = ['weapons', 'achievements', 'challenges'] as const;
let tab = $state<(typeof tabs)[number]>('weapons');
let hideDone = $state(false);
function percent(value: number, total: number) {
return total > 0 ? Math.round((value / total) * 100) : 0;
}
const rank = $derived(rankWeapon ? weaponRanks.get(rankWeapon) : data.rank);
const selectedWeapon = $derived(data.items.find((w) => w.id === rankWeapon));
const playerLimits = $derived(playerRankLimits(rules));
const rankLimits = $derived(
rankWeapon && selectedWeapon ? weaponRankLimits(rules, selectedWeapon) : playerLimits
);
const challengeCategories = $derived([...new Set(data.challenges.map((c) => c.category))]);
const challengeRows = $derived(
data.challenges.filter(
(c) =>
(challengeCategory === 'all' || c.category === challengeCategory) &&
`${c.name} ${c.requirement} ${c.reward ?? ''}`
.toLowerCase()
.includes(challengeSearch.toLowerCase()) &&
(!hideDone || !data.challengeProgress.find((p) => p.challengeId === c.id)?.done)
)
);
$effect(() => {
if (challengePage > Math.max(1, Math.ceil(challengeRows.length / 36))) challengePage = 1;
});
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.game.shortName}</h1>
<p class="mt-2 text-[var(--site-muted)]">
{weapons.length} weapons · {achievements.length} achievements
<div class="mt-3 flex flex-wrap items-center gap-3">
<GameLogo slug={`cod/${data.game.slug}`} name={data.game.shortName} class="h-12 w-24" />
<div class="flex-1">
<h1 class="text-2xl font-semibold tracking-tight">{data.game.shortName}</h1>
<p class="mt-2 text-muted-foreground">
Level {data.rank?.level ?? '—'}{playerLimits.maxLevel ? ` / ${playerLimits.maxLevel}` : ''} ·
Prestige {data.rank?.prestige ?? '—'}{data.game.rules.rankCaps
? ` / ${playerLimits.maxPrestige}`
: ''}{data.rank?.level === playerLimits.maxLevel &&
data.rank?.prestige === playerLimits.maxPrestige &&
data.game.rules.rankCaps
? ' · Prestige Master'
: ''}
</p>
</div>
<Button
variant="outline"
disabled={!hydrated || !data.user}
onclick={() => {
rankWeapon = null;
rankOpen = true;
}}>Update my rank</Button
>
</div>
<p class="mt-2 text-xs text-[var(--site-muted)]">
{weapons.length} weapons · {achievements.length} achievements · {data.challenges.length} challenges
& unlocks
</p>
{#if !data.user}<p class="mt-6">
<a
@@ -110,124 +191,170 @@
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
{#if form?.message}<p role="alert" class="mt-4 text-destructive">{form.message}</p>{/if}
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
<div
class="mt-8 grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
class="mt-3 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 sm:grid-cols-5"
aria-label="Game completion"
>
{#each bars as bar (bar.label)}
<div class="min-w-0" title={bar.description}>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">{bar.label}</p>
<p class="text-xl font-semibold tabular-nums">
{bar.achieved ? '✓' : `${percent(bar.value, bar.total)}%`}
</p>
</div>
<div class="mt-3">
<ProgressBar value={bar.value} max={bar.total} label={bar.label} small />
</div>
<p class="mt-2 text-[var(--site-muted)]">{bar.value} of {bar.total}</p>
</div>
{/each}
<ProgressMeter
label="Game completion"
progress={overall}
color="violet"
compact
hint="Recorded ranks, weapon mastery, achievements and challenges contribute equally. Partial challenge targets count."
/>
<ProgressMeter label="Weapon mastery" progress={masteryProgress} color="amber" compact />
<ProgressMeter
label="Player & weapon ranks"
progress={rankProgress}
color="pink"
compact
hint="Percentage of this game’s player and weapon ranks that you have entered."
/>
<ProgressMeter label="Achievements" progress={summary.achievements} color="sky" compact />
<ProgressMeter label="Challenges & unlocks" progress={unlockProgress} compact />
</div>
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
<div
class="sticky top-0 z-20 mt-4 flex flex-wrap items-center justify-between gap-3 border-y bg-background/95 py-2"
>
<Tabs.Root bind:value={tab}
><Tabs.List
>{#each tabs as name (name)}<Tabs.Trigger value={name}>{name}</Tabs.Trigger
>{#each tabs as name (name)}<Tabs.Trigger disabled={!hydrated} value={name}>
{#if name === 'weapons'}<Crosshair class="mr-1.5 size-4" aria-hidden="true" />Weapons
{:else if name === 'achievements'}<Trophy
class="mr-1.5 size-4"
aria-hidden="true"
/>Achievements
{:else}<Target class="mr-1.5 size-4" aria-hidden="true" />Challenges{/if}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" bind:checked={hideDone} class="h-5 w-5 accent-[var(--site-text)]" />
hide finished
<Button variant="ghost" size="sm" onclick={() => (overviewOpen = true)}
>Progress overview</Button
>
<label class="flex cursor-pointer items-center gap-2 text-xs">
<Checkbox bind:checked={hideDone} />
Hide finished
</label>
</div>
{#if tab === 'weapons'}
{#if rules.tiers.length || rules.milestones.length}
<p class="mt-4 max-w-3xl text-[var(--site-muted)]">
{[...rules.milestones, ...rules.tiers]
.filter((rule) => rule.description)
.map((rule) => `${rule.label}: ${rule.description}`)
.join('. ')}. DLC weapons don’t count toward class or overall camos.
</p>
{/if}
{#each classes as group (group.name)}
{@const rows = group.list.filter(
(weapon) =>
!hideDone ||
group.milestones.some(
(milestone) => appliesTo(milestone, weapon) && !isDone(weapon.id, milestone.key)
)
)}
<section class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-2xl font-semibold">{group.name}</h2>
<p class="text-[var(--site-muted)]">
{#each group.tiers as { tier, count } (tier.key)}
<span class={count?.achieved ? 'ml-4 text-[var(--site-text)]' : 'ml-4'}
>{tier.label}
{count?.achieved ? '✓' : `${count?.value}/${count?.total}`}</span
<div class="my-3 flex flex-wrap items-center gap-2">
<Input
class="min-w-40 flex-1"
aria-label="Find a weapon"
type="search"
placeholder="Find a weapon…"
bind:value={weaponSearch}
/>
<NativeSelect aria-label="Weapon class" bind:value={weaponClass}
><option value="all">All weapon classes</option>{#each rules.classes as name (name)}<option
value={name}>{name}</option
>{/each}</NativeSelect
>
</div>
<div
class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
aria-label="Weapon progress"
>
{#each weaponRows as weapon (weapon.id)}
{@const current = weaponRanks.get(weapon.id)}
{@const limits = weaponRankLimits(rules, weapon)}
{@const tiers = summary.mastery.weaponTiers(weapon)}
{@const allMilestones = rules.milestones.filter((m) => appliesTo(m, weapon))}
<article
class="weapon-card min-w-0 rounded-lg border bg-card p-2.5"
aria-label={weapon.name}
>
<div class="flex min-w-0 items-center gap-2">
<WeaponArtwork game={data.game.slug} name={weapon.name} icon={weapon.icon} />
<div class="min-w-0 flex-1">
<h2 class="truncate text-sm font-semibold" title={weapon.name}>{weapon.name}</h2>
<p class="truncate text-[10px] text-muted-foreground" title={weapon.category}>
{weapon.category}{weapon.postLaunch ? ' · DLC' : ''}
</p>
</div>
</div>
<div class="mt-2">
{#if limits.rankable}
<WeaponRankControls
itemId={weapon.id}
name={weapon.name}
level={current?.level ?? 1}
prestige={current?.prestige ?? 0}
levelCap={limits.maxLevel}
prestigeCap={limits.maxPrestige}
prestigeRequiresMaxLevel={limits.canPrestigeOnlyAtMaxLevel}
compact
disabled={!hydrated || !data.user}
onupdate={(r) => weaponRanks.set(r.itemId, r)}
onedit={() => {
rankWeapon = weapon.id;
rankOpen = true;
}}
/>
{:else}<p class="py-1 text-[10px] text-muted-foreground">No weapon level rank</p>{/if}
</div>
<div class="mt-2 flex items-center gap-2">
<ProgressMeter
label={`${weapon.name} mastery`}
progress={{
value: allMilestones.filter((m) => isDone(weapon.id, m.key)).length,
total: allMilestones.length
}}
color="amber"
compact
/>
{#if tiers.length}<span
class="shrink-0 text-[10px] text-amber-600"
title="Mastery tier"
>
{/each}
</p>
</div>
{#if rows.length}
<table class="mt-3 w-full border-collapse">
<thead>
<tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]">
<th scope="col" class="py-2 font-medium">weapon</th>
{#each group.milestones as milestone (milestone.key)}<th
scope="col"
class="w-20 py-2 text-center font-medium sm:w-28"
title={milestone.description}>{milestone.label}</th
{tiers.map((t) => t.label).join(' · ')}
</span>{/if}
</div>
{#if allMilestones.length}<Collapsible.Root class="mt-2 border-t pt-1.5">
<Collapsible.Trigger
class="group flex w-full items-center justify-between rounded py-1 text-left text-[11px] text-muted-foreground hover:text-foreground"
>
<span
>Camos & milestones <span class="ml-1 tabular-nums"
>{allMilestones.filter((m) => isDone(weapon.id, m.key))
.length}/{allMilestones.length}</span
></span
>
<ChevronDown
class="size-3.5 transition-transform group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</Collapsible.Trigger>
<Collapsible.Content class="mt-1 grid grid-cols-2 gap-x-2">
{#each allMilestones as milestone (milestone.key)}<label
class="flex min-h-7 items-center gap-1.5 text-[10px]"
><Checkbox
class="size-3.5"
checked={isDone(weapon.id, milestone.key)}
disabled={!hydrated ||
!data.user ||
pending.has(`${weapon.id}:${milestone.key}`)}
aria-label={`${weapon.name}: ${milestone.label}`}
title={milestone.description}
onCheckedChange={(checked) => toggle(weapon.id, milestone.key, checked)}
/>{milestone.label}</label
>{/each}
</tr>
</thead>
<tbody>
{#each rows as weapon (weapon.id)}
{@const tiers = summary.mastery.weaponTiers(weapon)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
{weapon.name}
{#if weapon.postLaunch}<span class="ml-1 text-sm text-[var(--site-muted)]"
>DLC</span
>{/if}
{#if tiers.length}<span class="block text-sm text-[var(--site-muted)]"
>{tiers.map((tier) => tier.label).join(' · ')}</span
>{/if}
</th>
{#each group.milestones as milestone (milestone.key)}
<td class="py-2 text-center">
{#if appliesTo(milestone, weapon)}
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={isDone(weapon.id, milestone.key)}
disabled={!hydrated ||
!data.user ||
pending.has(`${weapon.id}:${milestone.key}`)}
aria-label={`${weapon.name}: ${milestone.label}`}
title={data.user
? milestone.description
: 'Sign in to save your progress'}
onchange={(event) =>
toggle(weapon.id, milestone.key, event.currentTarget.checked)}
/>
{:else}<span class="text-[var(--site-muted)]">—</span>{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="mt-3 text-[var(--site-muted)]">all done.</p>
{/if}
</section>
{/each}
{:else}
</Collapsible.Content>
</Collapsible.Root>{/if}
</article>
{:else}<p class="col-span-full py-8 text-center text-muted-foreground">
No weapons match these filters.
</p>{/each}
</div>
<p class="mt-3 text-xs text-muted-foreground">
Showing {weaponRows.length} of {weapons.length} weapons · Changes save immediately. Prestige + starts
the next prestige at level 1.
</p>
{:else if tab === 'achievements'}
{#each categories as [category, list] (category)}
{@const rows = list.filter((item) => !hideDone || !isDone(item.id, 'done'))}
<section class="mt-10">
@@ -237,6 +364,17 @@
{list.filter((item) => isDone(item.id, 'done')).length} of {list.length}
</p>
</div>
<div class="mt-3">
<ProgressMeter
label={`${category} achievements`}
progress={{
value: list.filter((item) => isDone(item.id, 'done')).length,
total: list.length
}}
color="sky"
compact
/>
</div>
<ul class="mt-3">
{#each rows as item (item.id)}
<li class="border-t border-[var(--site-border)]">
@@ -256,13 +394,12 @@
>{item.description}</span
>{/if}
</span>
<input
type="checkbox"
<Checkbox
class="h-5 w-5 shrink-0 cursor-pointer accent-[var(--site-text)]"
checked={isDone(item.id, 'done')}
disabled={!hydrated || !data.user || pending.has(`${item.id}:done`)}
aria-label={`Unlocked ${item.name}`}
onchange={(event) => toggle(item.id, 'done', event.currentTarget.checked)}
onCheckedChange={(checked) => toggle(item.id, 'done', checked)}
/>
</label>
</li>
@@ -274,5 +411,230 @@
</ul>
</section>
{/each}
{:else}
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<Input
type="search"
aria-label="Search challenges"
placeholder="Search challenges, emblems and calling cards…"
bind:value={challengeSearch}
oninput={() => (challengePage = 1)}
/><NativeSelect
class="w-full"
aria-label="Challenge category"
bind:value={challengeCategory}
onchange={() => (challengePage = 1)}
><option value="all">All categories</option
>{#each challengeCategories as category (category)}<option value={category}
>{category}</option
>{/each}</NativeSelect
>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-2">
{#each challengeRows.slice((challengePage - 1) * 36, challengePage * 36) as challenge (challenge.id)}
{@const progress = data.challengeProgress.find((p) => p.challengeId === challenge.id)}
<article class="rounded-xl border bg-card p-5">
<div class="flex gap-3">
{#if challenge.icon}<img
src={challenge.icon}
alt=""
width="80"
height="48"
loading="lazy"
class="h-12 w-20 object-contain"
/>{/if}
<div>
<p class="text-xs text-muted-foreground">{challenge.category}</p>
<h2 class="font-semibold">{challenge.name}</h2>
</div>
</div>
<p class="mt-3 text-sm text-muted-foreground">{challenge.requirement}</p>
{#if challenge.reward}<p class="mt-2 text-sm">Unlock: {challenge.reward}</p>{/if}
<div class="mt-3">
<ProgressMeter
label={`${challenge.name} progress`}
progress={{
value: progress?.done ? (challenge.target ?? 1) : (progress?.current ?? 0),
total: challenge.target ?? 1
}}
detail={progress?.done
? 'Unlocked ✓'
: challenge.target
? `${progress?.current ?? 0} / ${challenge.target}`
: 'Not unlocked yet'}
/>
</div>
<form
method="POST"
action="?/saveChallenge"
use:enhance
class="mt-4 flex flex-wrap items-end gap-3"
>
<input
type="hidden"
name="challengeId"
value={challenge.id}
/>{#if challenge.target}<label class="flex-1 text-xs"
>Progress / {challenge.target}<Input
type="number"
name="current"
min={0}
max={challenge.target}
value={progress?.current ?? 0}
required
/></label
>{:else}<input type="hidden" name="current" value="0" />{/if}<label
class="mb-2 flex items-center gap-2 text-sm"
><Checkbox name="done" value="on" checked={progress?.done ?? false} />Unlocked</label
><Button type="submit" size="sm" variant="outline" disabled={!hydrated || !data.user}
>Save</Button
>
</form>
<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={challenge.sourceUrl}
target="_blank"
rel="noreferrer"
class="mt-3 inline-block text-xs text-muted-foreground underline"
>Source & requirements ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</article>
{:else}<p class="py-8 text-muted-foreground">
{data.challenges.length
? 'No matching challenges.'
: 'No multiplayer challenge catalog has been imported for this game yet.'}
</p>{/each}
</div>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" disabled={challengePage === 1} onclick={() => challengePage--}
>Previous</Button
><span class="text-sm text-muted-foreground"
>{challengeRows.length} challenges · {challengePage} / {Math.max(
1,
Math.ceil(challengeRows.length / 36)
)}</span
><Button
variant="outline"
disabled={challengePage * 36 >= challengeRows.length}
onclick={() => challengePage++}>Next</Button
>
</div>
<p class="mt-5 text-xs text-muted-foreground">
Requirements adapted from <a
href="https://callofduty.fandom.com/wiki/Challenges"
class="underline">Call of Duty Wiki contributors</a
>
under <a href="https://www.fandom.com/licensing" class="underline">CC BY-SA</a>. Each card
links to its source. This catalog includes confirmed source rows; coverage is not exhaustive.
</p>
{/if}
</div>
<Dialog.Root bind:open={rankOpen}
><Dialog.Content
><Dialog.Header
><Dialog.Title>{selectedWeapon?.name ?? 'Player'} level & prestige</Dialog.Title
><Dialog.Description
>Record your current rank in {data.game.shortName}. Weapon prestige also records that max
level has been reached.</Dialog.Description
></Dialog.Header
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
<form
method="POST"
action="?/saveRank"
class="space-y-4"
use:enhance={() => {
rankSaving = true;
return async ({ result, update }) => {
await update();
rankSaving = false;
if (result.type === 'success') rankOpen = false;
};
}}
>
{#if rankWeapon}<input type="hidden" name="itemId" value={rankWeapon} />{/if}
<div class="grid grid-cols-2 gap-4">
<label
>Current level<Input
type="number"
name="level"
min={1}
max={rankLimits.maxLevel ?? 10000}
value={rank?.level ?? 1}
required
/></label
><label
>Prestige<Input
type="number"
name="prestige"
min={0}
max={rankLimits.maxPrestige}
value={rank?.prestige ?? 0}
required
/></label
>
</div>
<Dialog.Footer
><Button variant="outline" onclick={() => (rankOpen = false)}>Cancel</Button><Button
type="submit"
disabled={rankSaving || !data.user}>{rankSaving ? 'Saving…' : 'Save rank'}</Button
></Dialog.Footer
>
</form></Dialog.Content
></Dialog.Root
>
<Dialog.Root bind:open={overviewOpen}
><Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-3xl"
><Dialog.Header
><Dialog.Title>Progress overview</Dialog.Title><Dialog.Description
>Mastery, camos and achievements in {data.game.shortName}.</Dialog.Description
></Dialog.Header
>
<div
class="grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
>
{#each bars as bar (bar.label)}
<ProgressMeter label={bar.label} progress={bar} color="amber" hint={bar.description} />
{/each}
</div>
<h3 class="font-semibold">Weapon classes</h3>
<div class="grid gap-4 sm:grid-cols-2">
{#each classesProgress as group (group.category)}<ProgressMeter
label={group.category}
progress={group}
color="amber"
/>{/each}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<ProgressMeter
label="Ranks entered"
progress={rankProgress}
color="pink"
detail="Your player rank plus a saved rank for each weapon."
/>
<ProgressMeter
label="Challenges & unlocks"
progress={unlockProgress}
detail={`${data.challengeProgress.filter((p) => p.done).length} unlocked · partial targets count too`}
/><ProgressMeter
label="Weapons prestiged"
progress={{
value: weapons.filter((w) => (weaponRanks.get(w.id)?.prestige ?? 0) > 0).length,
total: weapons.length
}}
color="violet"
/>
</div>
<p class="text-xs text-muted-foreground">
{[...rules.milestones, ...rules.tiers]
.filter((r) => r.description)
.map((r) => `${r.label}: ${r.description}`)
.join('. ')}. DLC weapons do not gate class or overall camos.
</p>
</Dialog.Content></Dialog.Root
>

View File

@@ -0,0 +1,15 @@
import images from '../../../../../data/game-images.json';
import maps from '../../../../../data/pokemon-map-art.json';
export function load() {
const sources = new Map<string, { source: string; credit: string; count: number }>();
for (const image of images) {
const existing = sources.get(image.source);
if (existing) existing.count++;
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
}
return {
sources: [...sources.values()],
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
total: images.length
};
}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<!-- Download manifest links point to reviewed external image sources. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<a href={resolve('/projects/game-tracker')} class="text-sm text-muted-foreground hover:underline"
>← Game tracker</a
>
<h1 class="mt-6 text-3xl font-semibold">Artwork credits</h1>
<p class="mt-3 text-muted-foreground">
Game covers, logos, sprites, maps and screenshots belong to their respective owners. The tracker
stores WebP copies for consistent, reliable display. Map imagery is shared across games in the
same region; later-region landmark positions are approximate.
</p>
<h2 class="mt-8 text-xl font-semibold">Regional maps</h2>
<ul class="mt-4 space-y-3">
{#each data.maps as map (map.name)}<li>
<a href={map.source} target="_blank" rel="noreferrer" class="font-medium underline"
>{map.name}</a
>
<p class="text-xs text-muted-foreground">{map.credit}</p>
</li>{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Covers, sprites and other artwork</h2>
<p class="mt-2 text-sm text-muted-foreground">
{data.total.toLocaleString()} downloaded image records. Box art is archived by Libretro and Serebii;
Pokémon sprites and official artwork are archived by PokéAPI. COD covers and achievement icons come
from Steam.
</p>
<ul class="mt-4 space-y-3">
{#each data.sources as item (item.source)}<li>
<a href={item.source} target="_blank" rel="noreferrer" class="text-sm underline"
>{item.credit}</a
><span class="ml-2 text-xs text-muted-foreground">{item.count} images</span>
</li>{/each}
</ul>
</div>

View File

@@ -0,0 +1,133 @@
import { and, desc, eq } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { osuEntries } from '$lib/server/db/schema';
import { allowedOsuUrl, boundedInteger, validDate } from '$lib/game-tracker/journal';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => ({
entries: locals.user
? await db
.select()
.from(osuEntries)
.where(eq(osuEntries.userId, locals.user.id))
.orderBy(desc(osuEntries.playedOn), desc(osuEntries.id))
: [],
seo: {
title: 'osu! training · Game Tracker',
description:
'A personal journal for memorable scores, beatmap goals and speed, stamina, aim and reading practice.'
}
});
export const actions = {
save: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to save training.' });
const data = await request.formData();
const t = (k: string) => String(data.get(k) ?? '').trim();
const id = t('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
const kind = t('kind');
const mode = t('mode');
const goal = t('goal') || 'pass';
const skill = t('skill') || 'stamina';
if (
(t('id') && !id) ||
!['score', 'goal', 'training'].includes(kind) ||
!['osu', 'taiko', 'fruits', 'mania'].includes(mode) ||
!['pass', 'fc', 'accuracy', 'rank', 'custom'].includes(goal) ||
!['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'].includes(
skill
) ||
!t('title') ||
t('title').length > 250 ||
t('notes').length > 5000 ||
t('mods').length > 80 ||
!validDate(t('playedOn'))
)
return fail(400, { message: 'Check the title, date and training details.' });
if (!allowedOsuUrl(t('beatmapUrl'), 'beatmap') || !allowedOsuUrl(t('scoreUrl'), 'score'))
return fail(400, { message: 'Use an https://osu.ppy.sh beatmap or score link.' });
const optionalInt = (key: string, min: number, max: number) =>
t(key) ? boundedInteger(data.get(key), min, max) : null;
const bpm = optionalInt('bpm', 1, 2000),
duration = optionalInt('duration', 1, 86400),
combo = optionalInt('combo', 0, 1000000),
misses = optionalInt('misses', 0, 1000000);
const decimal = (key: string, min: number, max: number) =>
!t(key) ||
(/^\d+(\.\d{1,4})?$/.test(t(key)) && Number(t(key)) >= min && Number(t(key)) <= max);
if (
(t('bpm') && bpm === null) ||
(t('duration') && duration === null) ||
(t('combo') && combo === null) ||
(t('misses') && misses === null) ||
!decimal('accuracy', 0, 100) ||
!decimal('pp', 0, 100000) ||
!t('rate') ||
!decimal('rate', 0.1, 4) ||
(kind === 'training' && (!bpm || !duration))
)
return fail(400, {
message:
'Training needs BPM and duration. Check numeric fields, accuracy (0–100) and rate (0.1–4).'
});
const values = {
userId: locals.user.id,
kind: kind as 'score' | 'goal' | 'training',
mode,
goal,
skill,
title: t('title'),
notes: t('notes'),
bpm,
duration,
combo,
misses,
accuracy: t('accuracy') || null,
pp: t('pp') || null,
rate: t('rate'),
mods: t('mods') || 'NM',
beatmapUrl: t('beatmapUrl') || null,
scoreUrl: t('scoreUrl') || null,
playedOn: t('playedOn'),
achieved: data.get('achieved') === 'on'
};
if (id) {
const updated = await db
.update(osuEntries)
.set(values)
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
.returning({ id: osuEntries.id });
if (!updated.length) return fail(404, { message: 'Entry not found.' });
} else await db.insert(osuEntries).values(values);
return { saved: true };
},
toggleGoal: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to update goals.' });
const data = await request.formData();
const id = boundedInteger(data.get('id'), 1, 2147483647);
if (!id) return fail(400, { message: 'Invalid goal.' });
const updated = await db
.update(osuEntries)
.set({ achieved: data.get('achieved') === 'on' })
.where(
and(
eq(osuEntries.id, id),
eq(osuEntries.userId, locals.user.id),
eq(osuEntries.kind, 'goal')
)
)
.returning({ id: osuEntries.id });
return updated.length ? { saved: true } : fail(404, { message: 'Goal not found.' });
},
delete: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to remove entries.' });
const data = await request.formData();
const id = boundedInteger(data.get('id'), 1, 2147483647);
if (!id) return fail(400, { message: 'Invalid entry.' });
const removed = await db
.delete(osuEntries)
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
.returning({ id: osuEntries.id });
return removed.length ? { saved: true } : fail(404, { message: 'Entry not found.' });
}
} satisfies Actions;

View File

@@ -0,0 +1,603 @@
<script lang="ts">
import { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
type Entry = (typeof data.entries)[number];
let tab = $state('goals');
let open = $state(false);
let deleteOpen = $state(false);
let editing = $state<Entry | null>(null);
let kind = $state<'score' | 'goal' | 'training'>('goal');
let saving = $state(false);
let search = $state('');
let modeFilter = $state('all');
let skillFilter = $state('stamina');
let minDuration = $state(30);
const skills = ['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'];
const modeName: Record<string, string> = {
osu: 'osu!',
taiko: 'osu!taiko',
fruits: 'osu!catch',
mania: 'osu!mania'
};
const scoped = $derived(
data.entries.filter(
(e) =>
(modeFilter === 'all' || e.mode === modeFilter) &&
`${e.title} ${e.notes} ${e.mods}`.toLowerCase().includes(search.toLowerCase())
)
);
const scores = $derived(scoped.filter((e) => e.kind === 'score'));
const goals = $derived(scoped.filter((e) => e.kind === 'goal'));
const sessions = $derived(scoped.filter((e) => e.kind === 'training'));
const allGoals = $derived(data.entries.filter((e) => e.kind === 'goal'));
const eligible = $derived(
sessions.filter((s) => s.skill === skillFilter && (s.duration ?? 0) >= minDuration)
);
const best = $derived(eligible.reduce((n, e) => Math.max(n, (e.bpm ?? 0) * Number(e.rate)), 0));
const chart = $derived(
[...eligible].sort((a, b) => a.playedOn.localeCompare(b.playedOn) || a.id - b.id).slice(-30)
);
const maxBpm = $derived(Math.max(1, ...chart.map((e) => (e.bpm ?? 0) * Number(e.rate))));
const minBpm = $derived(
Math.max(0, Math.min(...chart.map((e) => (e.bpm ?? 0) * Number(e.rate))) - 20)
);
function create(next: typeof kind, entry: Entry | null = null) {
kind = next;
editing = entry;
open = true;
}
function bpm(entry: Entry) {
return Math.round((entry.bpm ?? 0) * Number(entry.rate) * 10) / 10;
}
</script>
<div class="mx-auto max-w-6xl px-3 py-4 sm:px-5">
<a
href={resolve('/projects/game-tracker')}
class="text-sm text-muted-foreground hover:text-foreground">← game tracker</a
>
<div class="mt-3 flex flex-wrap items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12 rounded-full bg-pink-500/10 text-pink-500" />
<div class="flex-1">
<p class="text-xs font-semibold tracking-[.2em] text-pink-500 uppercase">One more try</p>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">osu! training</h1>
<p class="mt-1 text-xs text-muted-foreground">
Chase the FC. Build your stamina. Remember the scores that mattered.
</p>
</div>
<Button
disabled={!hydrated || !data.user}
onclick={() => create(tab === 'scores' ? 'score' : tab === 'training' ? 'training' : 'goal')}
>+ {tab === 'scores'
? 'Record a score'
: tab === 'training'
? 'Log practice'
: 'Set a goal'}</Button
>
</div>
{#if !data.user}<p class="mt-6">
<a class="underline" href={resolve('/login?returnTo=/projects/game-tracker/osu')}>Sign in</a> to
start your training journal.
</p>{/if}
{#if form?.message && !open && !deleteOpen}<p class="mt-4 text-destructive" role="alert">
{form.message}
</p>{/if}
<div class="my-3 grid grid-cols-3 gap-2">
<div class="rounded-xl border px-3 py-2">
<p class="text-xl font-semibold tabular-nums">
{data.entries.filter((e) => e.kind === 'score').length}
</p>
<p class="mt-1 text-xs text-muted-foreground">memorable scores</p>
</div>
<div class="rounded-xl border px-3 py-2">
<p class="text-xl font-semibold tabular-nums">
{data.entries.filter((e) => e.kind === 'goal' && e.achieved).length}<span
class="text-base text-muted-foreground"
>
/ {data.entries.filter((e) => e.kind === 'goal').length}</span
>
</p>
<p class="mt-1 text-xs text-muted-foreground">goals achieved</p>
</div>
<div class="rounded-xl border px-3 py-2">
<p class="text-xl font-semibold tabular-nums">
{Math.round(
data.entries
.filter((e) => e.kind === 'training')
.reduce((n, e) => n + (e.duration ?? 0), 0) / 60
)}
</p>
<p class="mt-1 text-xs text-muted-foreground">minutes of logged practice</p>
</div>
</div>
<div class="mb-3 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 sm:grid-cols-4">
<ProgressMeter
label="All map goals"
progress={{ value: allGoals.filter((g) => g.achieved).length, total: allGoals.length }}
color="pink"
compact
/>
{#each [{ kind: 'pass', label: 'Pass goals' }, { kind: 'fc', label: 'Full combo goals' }, { kind: 'accuracy', label: 'Accuracy goals' }] as group (group.kind)}
<ProgressMeter
label={group.label}
progress={{
value: allGoals.filter((g) => g.goal === group.kind && g.achieved).length,
total: allGoals.filter((g) => g.goal === group.kind).length
}}
color="pink"
compact
/>
{/each}
</div>
<div class="mb-5 flex gap-3">
<Input
type="search"
bind:value={search}
aria-label="Search osu journal"
placeholder="Search maps, scores or notes…"
/><NativeSelect aria-label="Game mode" bind:value={modeFilter}
><option value="all">All modes</option
>{#each Object.entries(modeName) as [value, label] (value)}<option {value}>{label}</option
>{/each}</NativeSelect
>
</div>
<Tabs.Root bind:value={tab}
><Tabs.List
><Tabs.Trigger disabled={!hydrated} value="goals">Map goals</Tabs.Trigger><Tabs.Trigger
disabled={!hydrated}
value="scores">Score showcase</Tabs.Trigger
><Tabs.Trigger disabled={!hydrated} value="training">Training log</Tabs.Trigger></Tabs.List
>
<Tabs.Content value="goals" class="mt-5"
><div class="grid gap-4 sm:grid-cols-2">
{#each goals as goal (goal.id)}<article class="rounded-xl border bg-card p-5">
<div class="flex items-center justify-between">
<Badge variant={goal.achieved ? 'secondary' : 'outline'}
>{goal.achieved ? '✓ achieved' : goal.goal.toUpperCase()}</Badge
><span class="text-xs text-muted-foreground">{modeName[goal.mode]} · {goal.mods}</span
>
</div>
<h2 class="mt-3 text-lg font-semibold">{goal.title}</h2>
{#if goal.notes}<p class="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">
{goal.notes}
</p>{/if}
<div class="mt-4 flex items-center gap-3">
{#if goal.beatmapUrl}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={goal.beatmapUrl} target="_blank" rel="noreferrer" class="text-sm underline"
>Open beatmap ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}<Button
size="sm"
variant="ghost"
onclick={() => create('goal', goal)}>Edit</Button
>
<form method="POST" action="?/toggleGoal" use:enhance class="ml-auto">
<input type="hidden" name="id" value={goal.id} /><input
type="hidden"
name="achieved"
value={goal.achieved ? '' : 'on'}
/><Button type="submit" variant={goal.achieved ? 'outline' : 'default'} size="sm"
>{goal.achieved ? 'Reopen' : 'Achieved!'}</Button
>
</form>
</div>
</article>{:else}<div
class="col-span-full rounded-xl border border-dashed p-12 text-center"
>
<p class="text-xl font-semibold">What’s your next breakthrough?</p>
<p class="mt-2 text-sm text-muted-foreground">
A first pass, a clean FC, 99% accuracy, or a challenge of your own.
</p>
<Button
class="mt-5"
variant="outline"
disabled={!hydrated || !data.user}
onclick={() => create('goal')}>Set a goal</Button
>
</div>{/each}
</div></Tabs.Content
>
<Tabs.Content value="scores" class="mt-5"
><div class="grid gap-4 sm:grid-cols-2">
{#each scores as score (score.id)}<article class="rounded-xl border bg-card p-5">
<div class="flex items-center justify-between">
<Badge variant="secondary">{score.mods}</Badge><span
class="text-xs text-muted-foreground"
>{score.playedOn} · {modeName[score.mode]}</span
>
</div>
<h2 class="mt-3 text-lg font-semibold">{score.title}</h2>
<div class="mt-4 flex flex-wrap gap-5">
{#if score.accuracy}<p class="text-2xl font-semibold">
{score.accuracy}<span class="text-sm text-muted-foreground">%</span>
</p>{/if}{#if score.pp}<p class="text-2xl font-semibold text-pink-500">
{score.pp}<span class="text-sm"> pp</span>
</p>{/if}{#if score.combo !== null}<p class="text-2xl font-semibold">
{score.combo}<span class="text-sm text-muted-foreground">x</span>
</p>{/if}
</div>
<p class="mt-2 text-sm text-muted-foreground">
{score.misses !== null ? `${score.misses} misses · ` : ''}{score.rate}× rate
</p>
{#if score.notes}<p class="mt-3 whitespace-pre-wrap text-sm text-muted-foreground">
{score.notes}
</p>{/if}
<div class="mt-4 flex gap-4">
{#if score.scoreUrl}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={score.scoreUrl} target="_blank" rel="noreferrer" class="text-sm underline"
>View score ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}{#if score.beatmapUrl}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={score.beatmapUrl}
target="_blank"
rel="noreferrer"
class="text-sm underline">Beatmap ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}<Button
class="ml-auto"
size="sm"
variant="ghost"
onclick={() => create('score', score)}>Edit</Button
>
</div>
</article>{:else}<div
class="col-span-full rounded-xl border border-dashed p-12 text-center"
>
<p class="text-xl font-semibold">Some scores deserve a place on the wall.</p>
<p class="mt-2 text-sm text-muted-foreground">
Keep the play, your accuracy, mods, pp, and why it mattered.
</p>
<Button
class="mt-5"
variant="outline"
disabled={!hydrated || !data.user}
onclick={() => create('score')}>Record a score</Button
>
</div>{/each}
</div></Tabs.Content
>
<Tabs.Content value="training" class="mt-5">
<section class="rounded-xl border p-5">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold tracking-widest text-muted-foreground uppercase">
Sustained pace
</p>
<p class="mt-2 text-3xl font-semibold">
{best || '—'} <span class="text-base text-muted-foreground">effective BPM</span>
</p>
</div>
<div class="flex gap-3">
<label class="text-xs"
>Skill<NativeSelect bind:value={skillFilter} class="mt-1"
>{#each skills as skill (skill)}<option value={skill}>{skill}</option
>{/each}</NativeSelect
></label
><label class="text-xs"
>Minimum seconds<Input
type="number"
min={1}
max={86400}
bind:value={minDuration}
class="mt-1 w-28"
/></label
>
</div>
</div>
<p class="mt-3 text-xs text-muted-foreground">
Best recorded pace for the selected mode and skill, sustained for at least {minDuration} seconds.
Effective BPM = map BPM × playback rate.
</p>
{#if chart.length > 1}<svg
viewBox="0 0 640 150"
role="img"
aria-label={`Last ${chart.length} qualifying ${skillFilter} sessions, ${minBpm} to ${maxBpm} effective BPM`}
class="mt-5 h-40 w-full overflow-visible"
><line x1="10" x2="630" y1="135" y2="135" stroke="currentColor" opacity=".2" /><polyline
points={chart
.map(
(e, i) =>
`${10 + (i / (chart.length - 1)) * 620},${130 - ((bpm(e) - minBpm) / (maxBpm - minBpm || 1)) * 110}`
)
.join(' ')}
fill="none"
stroke="#ec4899"
stroke-width="2"
/>{#each chart as e, i (e.id)}<circle
cx={10 + (i / (chart.length - 1)) * 620}
cy={130 - ((bpm(e) - minBpm) / (maxBpm - minBpm || 1)) * 110}
r="4"
fill="#ec4899"
><title>{e.playedOn} · {bpm(e)} BPM · {e.duration}s · {e.title}</title></circle
>{/each}</svg
>{:else}<p class="mt-5 rounded-lg bg-muted/40 p-4 text-sm text-muted-foreground">
Log two qualifying sessions to see your pace over time.
</p>{/if}
</section>
<div class="mt-5 overflow-x-auto">
<Table.Root
><Table.Header
><Table.Row
><Table.Head>Session</Table.Head><Table.Head>Skill</Table.Head><Table.Head
>BPM</Table.Head
><Table.Head>Duration</Table.Head><Table.Head>Accuracy</Table.Head><Table.Head
></Table.Head></Table.Row
></Table.Header
><Table.Body
>{#each sessions as session (session.id)}<Table.Row
><Table.Cell
><span class="font-medium">{session.title}</span><span
class="block text-xs text-muted-foreground"
>{session.playedOn} · {modeName[session.mode]}</span
></Table.Cell
><Table.Cell>{session.skill}</Table.Cell><Table.Cell
>{bpm(session)}<span class="block text-xs text-muted-foreground"
>{session.bpm} × {session.rate}</span
></Table.Cell
><Table.Cell>{session.duration}s</Table.Cell><Table.Cell
>{session.accuracy ? `${session.accuracy}%` : '—'}</Table.Cell
><Table.Cell
><Button size="sm" variant="ghost" onclick={() => create('training', session)}
>Edit</Button
></Table.Cell
></Table.Row
>{:else}<Table.Row
><Table.Cell colspan={6} class="py-10 text-center text-muted-foreground"
>Log a practice session to start building your training history.</Table.Cell
></Table.Row
>{/each}</Table.Body
></Table.Root
>
</div>
</Tabs.Content>
</Tabs.Root>
</div>
<Dialog.Root bind:open
><Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-2xl"
><Dialog.Header
><Dialog.Title
>{editing ? 'Edit' : kind === 'score' ? 'Record' : kind === 'goal' ? 'Set' : 'Log'}
{kind === 'training' ? 'practice session' : kind}</Dialog.Title
><Dialog.Description
>{kind === 'goal'
? 'Give yourself something worth chasing.'
: kind === 'score'
? 'Save the numbers and the story behind this play.'
: 'Track the pace you can sustain and what you want to work on next.'}</Dialog.Description
></Dialog.Header
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
{#key `${kind}-${editing?.id ?? 'new'}`}
<form
method="POST"
action="?/save"
class="space-y-4"
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
await update({ reset: false });
saving = false;
if (result.type === 'success') open = false;
};
}}
>
<input type="hidden" name="kind" value={kind} />{#if editing}<input
type="hidden"
name="id"
value={editing.id}
/>{/if}
<label class="block"
>{kind === 'training' ? 'Session name' : 'Map / achievement'}<Input
name="title"
required
maxlength={250}
value={editing?.title ?? ''}
placeholder={kind === 'training'
? '180 BPM stream practice'
: 'Artist — Title [Difficulty]'}
/></label
>
<div class="grid grid-cols-2 gap-4">
<label
>Mode<NativeSelect name="mode" value={editing?.mode ?? 'osu'} class="w-full"
>{#each Object.entries(modeName) as [value, label] (value)}<option {value}
>{label}</option
>{/each}</NativeSelect
></label
><label
>Date<Input
type="date"
name="playedOn"
required
value={editing?.playedOn ?? new Date().toISOString().slice(0, 10)}
/></label
>
</div>
<label class="block"
>Beatmap link (optional)<Input
type="url"
name="beatmapUrl"
placeholder="https://osu.ppy.sh/beatmaps/…"
value={editing?.beatmapUrl ?? ''}
/></label
>
{#if kind === 'score'}<label class="block"
>Score link (optional)<Input
type="url"
name="scoreUrl"
placeholder="https://osu.ppy.sh/scores/…"
value={editing?.scoreUrl ?? ''}
/></label
>{/if}
{#if kind === 'goal'}<div class="flex items-end gap-4">
<label class="flex-1"
>Goal<NativeSelect class="w-full" name="goal" value={editing?.goal ?? 'pass'}
><option value="pass">Pass</option><option value="fc">Full combo</option><option
value="accuracy">Accuracy target</option
><option value="rank">Rank / pp target</option><option value="custom"
>Personal challenge</option
></NativeSelect
></label
><label class="mb-2 flex items-center gap-2"
><Checkbox
name="achieved"
value="on"
checked={editing?.achieved ?? false}
/>Achieved</label
>
</div>{/if}
{#if kind === 'training'}<label class="block"
>Skill<NativeSelect name="skill" value={editing?.skill ?? 'stamina'} class="w-full"
>{#each skills as skill (skill)}<option value={skill}>{skill}</option
>{/each}</NativeSelect
></label
>{/if}
<div class="grid grid-cols-2 gap-4">
<label
>Mods<Input
name="mods"
value={editing?.mods ?? 'NM'}
maxlength={80}
placeholder="NM, HD, DT…"
/></label
><label
>Playback rate<Input
type="number"
name="rate"
min={0.1}
max={4}
step="0.01"
required
value={Number(editing?.rate ?? 1)}
/></label
><label
>Map BPM{kind !== 'training' ? ' (optional)' : ''}<Input
type="number"
name="bpm"
min={1}
max={2000}
required={kind === 'training'}
value={editing?.bpm ?? undefined}
/></label
><label
>Duration (seconds)<Input
type="number"
name="duration"
min={1}
max={86400}
required={kind === 'training'}
value={editing?.duration ?? undefined}
/></label
><label
>Accuracy %<Input
type="number"
name="accuracy"
min={0}
max={100}
step="0.01"
value={editing?.accuracy ? Number(editing.accuracy) : undefined}
/></label
>{#if kind !== 'training'}<label
>pp<Input
type="number"
name="pp"
min={0}
max={100000}
step="0.01"
value={editing?.pp ? Number(editing.pp) : undefined}
/></label
><label
>Max combo<Input
type="number"
name="combo"
min={0}
max={1000000}
value={editing?.combo ?? undefined}
/></label
><label
>Misses<Input
type="number"
name="misses"
min={0}
max={1000000}
value={editing?.misses ?? undefined}
/></label
>{/if}
</div>
<p class="text-xs text-muted-foreground">
Enter the actual playback rate, including DT, HT or custom rate changes. BPM is the map’s
original tempo.
</p>
<label class="block"
>Notes / target<Textarea
name="notes"
maxlength={5000}
value={editing?.notes ?? ''}
placeholder="What felt good? What needs work? What is the next target?"
/></label
>
<Dialog.Footer
><Button variant="outline" onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save entry'}</Button
></Dialog.Footer
>
</form>{/key}
{#if editing}<Button
variant="ghost"
class="justify-self-start text-destructive"
onclick={() => (deleteOpen = true)}>Delete entry</Button
>{/if}
</Dialog.Content></Dialog.Root
>
<AlertDialog.Root bind:open={deleteOpen}
><AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Delete this entry?</AlertDialog.Title><AlertDialog.Description
>This removes “{editing?.title}” from your journal.</AlertDialog.Description
></AlertDialog.Header
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
<form
method="POST"
action="?/delete"
use:enhance={() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') {
deleteOpen = false;
open = false;
}
}}
>
<input type="hidden" name="id" value={editing?.id ?? ''} /><AlertDialog.Footer
><AlertDialog.Cancel>Keep entry</AlertDialog.Cancel><Button
type="submit"
variant="destructive">Delete entry</Button
></AlertDialog.Footer
>
</form></AlertDialog.Content
></AlertDialog.Root
>

View File

@@ -1,3 +1,16 @@
import { pokemonJournalActions, catchesFor } from '$lib/server/pokemon-journal';
import { loadPokemonProgress } from '$lib/server/tracker-progress';
import {
pokemonCatches,
pokemonDexNumbers,
pokemonJourneys,
pokemonMilestones,
pokemonGameResearch,
pokemonLocations,
pokemonEncounters,
pokemonSeen
} from '$lib/server/db/schema';
import { sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import {
games,
@@ -14,13 +27,15 @@ import { requireAdmin } from '$lib/server/authorization';
import { fail } from '@sveltejs/kit';
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ locals, url }) => {
const [pokemonRows, entryRows, gameRows] = await Promise.all([
db
.select({
id: pokemonTable.id,
number: pokemonTable.number,
name: pokemonTable.name
name: pokemonTable.name,
type1: pokemonTable.type1,
type2: pokemonTable.type2
})
.from(pokemonTable)
.orderBy(asc(pokemonTable.number)),
@@ -34,7 +49,7 @@ export const load: PageServerLoad = async ({ locals }) => {
})
.from(pokemonGameEntries),
db
.select({ id: games.id, name: games.name, generation: games.generation })
.select({ id: games.id, name: games.name, slug: games.slug, generation: games.generation })
.from(games)
.orderBy(asc(games.generation), asc(games.id))
]);
@@ -69,8 +84,83 @@ export const load: PageServerLoad = async ({ locals }) => {
selectedGameIds = gameRows.map((r) => r.id);
}
const visible =
gamesConfigured || selectedGameIds.length
? gameRows.filter((g) => selectedGameIds.includes(g.id))
: gameRows;
const requested = url.searchParams.get('game');
const activeGame =
visible.find((g) => g.slug === requested || String(g.id) === requested) ??
visible.find((g) => g.slug === 'platinum') ??
visible[0] ??
null;
const gameId = activeGame?.id ?? -1;
const [
dexNumbers,
catches,
journeyRows,
milestones,
researchRows,
locations,
progress,
seenRows
] = await Promise.all([
db.select().from(pokemonDexNumbers).where(eq(pokemonDexNumbers.gameId, gameId)),
catchesFor(locals.user?.id, gameId),
locals.user
? db
.select()
.from(pokemonJourneys)
.where(
and(eq(pokemonJourneys.userId, locals.user.id), eq(pokemonJourneys.gameId, gameId))
)
: [],
locals.user
? db
.select()
.from(pokemonMilestones)
.where(
and(eq(pokemonMilestones.userId, locals.user.id), eq(pokemonMilestones.gameId, gameId))
)
: [],
db.select().from(pokemonGameResearch).where(eq(pokemonGameResearch.gameId, gameId)),
db
.select({
id: pokemonLocations.id,
slug: pokemonLocations.slug,
name: pokemonLocations.name,
region: pokemonLocations.region,
count: sql<number>`count(distinct ${pokemonEncounters.pokemonId})`.mapWith(Number)
})
.from(pokemonLocations)
.leftJoin(
pokemonEncounters,
and(
eq(pokemonEncounters.locationId, pokemonLocations.id),
eq(pokemonEncounters.gameId, gameId)
)
)
.groupBy(pokemonLocations.id),
loadPokemonProgress(locals.user?.id),
locals.user
? db
.select({ pokemonId: pokemonSeen.pokemonId })
.from(pokemonSeen)
.where(and(eq(pokemonSeen.userId, locals.user.id), eq(pokemonSeen.gameId, gameId)))
: []
]);
return {
progress,
seenIds: seenRows.map((r) => r.pokemonId),
pokemon,
activeGame,
dexNumbers,
catches,
journey: journeyRows[0] ?? null,
milestones,
regions: researchRows[0]?.regions ?? [],
locations,
games: gameRows,
caughtIds,
selectedGameIds,
@@ -84,6 +174,7 @@ function entryId(data: FormData) {
}
export const actions = {
...pokemonJournalActions,
togglePokemonCaught: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
@@ -91,32 +182,58 @@ export const actions = {
const id = entryId(data);
if (!id) return fail(400, { message: 'Invalid entry' });
// only obtainable entries can be toggled — the client disables the others
// but we can't trust that
// Verify the entry and allow historical checkmarks to be cleared even
// when source availability has since changed.
const [entry] = await db
.select({ availability: pokemonGameEntries.availability })
.select({
availability: pokemonGameEntries.availability,
gameId: pokemonGameEntries.gameId,
pokemonId: pokemonGameEntries.pokemonId
})
.from(pokemonGameEntries)
.where(eq(pokemonGameEntries.id, id));
if (!entry) return fail(404, { message: 'Unknown entry' });
if (!obtainableAvailability.has(entry.availability)) {
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
}
if (data.has('caught') && data.get('caught') !== 'on')
return fail(400, { message: 'Invalid caught state' });
const caught = data.get('caught') === 'on';
if (caught && !obtainableAvailability.has(entry.availability))
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
if (caught) {
await db
.insert(userEntries)
.values({ pokemonGameEntryId: id, userId: locals.user.id })
.onConflictDoNothing();
} else {
await db
.delete(userEntries)
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, locals.user.id)));
}
return { saved: true };
const userId = locals.user.id;
return db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${userId}), ${entry.gameId})`);
if (caught) {
await tx
.insert(userEntries)
.values({ pokemonGameEntryId: id, userId })
.onConflictDoNothing();
await tx
.insert(pokemonSeen)
.values({ userId, gameId: entry.gameId, pokemonId: entry.pokemonId })
.onConflictDoNothing();
} else {
const linked = await tx
.select({ id: pokemonCatches.id })
.from(pokemonCatches)
.where(
and(
eq(pokemonCatches.userId, userId),
eq(pokemonCatches.gameId, entry.gameId),
eq(pokemonCatches.pokemonId, entry.pokemonId)
)
)
.limit(1);
if (linked.length)
return fail(400, {
message: 'Remove the registered catches before clearing the caught checkmark.'
});
await tx
.delete(userEntries)
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, userId)));
}
return { saved: true };
});
},
setGames: async ({ request, locals }) => {

View File

@@ -1,474 +1,192 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize, enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability,
type Availability
} from '$lib/availability';
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
import { summarizeProgress } from '$lib/catch-em-all/progress';
type Entry = { id: number; availability: Availability; note: string | null };
type Game = { id: number; name: string };
let { data, form }: PageProps = $props();
// rebuilt whenever the page data changes, so the server render already shows progress
const caught = $derived(new SvelteSet(data.caughtIds));
const pending = new SvelteSet<number>();
let saveError = $state('');
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
async function toggleCaught(entryId: number, checked: boolean) {
if (pending.has(entryId)) return;
pending.add(entryId);
saveError = '';
if (checked) caught.add(entryId);
else caught.delete(entryId);
const body = new FormData();
body.set('entryId', String(entryId));
if (checked) body.set('caught', 'on');
let failed: boolean;
try {
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
const result = deserialize(await response.text());
failed = result.type !== 'success';
} catch {
failed = true;
}
pending.delete(entryId);
if (failed) {
saveError = 'Could not save your catch. Please try again, or log in again.';
if (checked) caught.delete(entryId);
else caught.add(entryId);
}
}
const entryMap = $derived(
new Map(
data.pokemon.map((pokemon) => [
pokemon.id,
new Map(
pokemon.entries.map(([id, gameId, code, note]) => [
gameId,
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
])
)
])
onMount(() => {
hydrated = true;
});
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { chronologicalPokemon } from '$lib/game-tracker/game-art';
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
import PokemonJournal from '$lib/game-tracker/PokemonJournal.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let rulesOpen = $state(false);
let stripWidth = $state(1000);
const coverRows = $derived(visibleGames.length > Math.floor((stripWidth + 4) / 68) ? 2 : 1);
let saving = $state(false);
const draft = new SvelteSet<number>();
const visibleGames = $derived(
chronologicalPokemon(
data.gamesConfigured
? data.games.filter((g) => data.selectedGameIds.includes(g.id))
: data.games
)
);
const selectedGameIds = $derived(new Set(data.selectedGameIds));
const visibleGames = $derived(
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
);
let viewGameId = $state<number | null>(null);
$effect(() => {
if (!visibleGames.some((game) => game.id === viewGameId))
viewGameId = visibleGames[0]?.id ?? null;
});
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
const progress = $derived(summarizeProgress(data.pokemon, visibleGameIds, caught));
const progressBars = $derived([
{
label: 'seen at least once',
value: progress.seenPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
},
{
label: 'caught in every game it’s in',
value: progress.completedPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
},
{
label: 'games completed',
value: progress.completedGames,
total: progress.catchableGames,
unit: 'games'
}
]);
function percent(value: number, total: number) {
return total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
}
let search = $state('');
let showUnobtainable = $state(false);
let pageNumber = $state(1);
const pageSize = 25;
const filteredPokemon = $derived(
data.pokemon.filter((pokemon) => {
const availability =
viewGameId === null ? undefined : entryMap.get(pokemon.id)?.get(viewGameId)?.availability;
if (
!showUnobtainable &&
viewGameId !== null &&
!(availability && obtainableAvailability.has(availability))
)
return false;
return `${pokemon.number} ${pokemon.name}`
.toLowerCase()
.includes(search.trim().toLowerCase());
})
);
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
$effect(() => {
if (pageNumber > pageCount) pageNumber = pageCount;
});
const visiblePokemon = $derived(
filteredPokemon.slice((pageNumber - 1) * pageSize, pageNumber * pageSize)
);
const gamesByGeneration = $derived(
[...Map.groupBy(data.games, (game) => game.generation)].sort(([a], [b]) => a - b)
);
let rulesDialog: HTMLDialogElement;
const draft = new SvelteSet<number>();
const generations = $derived([...Map.groupBy(data.games, (g) => g.generation)]);
function openRules() {
draft.clear();
for (const id of data.gamesConfigured ? selectedGameIds : data.games.map((game) => game.id))
draft.add(id);
rulesDialog.showModal();
}
function tooltip(entry: Entry | undefined, game: Game): string | null {
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
if (obtainableAvailability.has(entry.availability)) return null;
const reason = {
trade_only: 'Only obtainable by trading in this game',
transfer_only: 'Only obtainable by transferring from another game',
event_only: 'Only obtainable through an event distribution',
unknown: 'Availability has not been confirmed'
}[entry.availability as 'trade_only' | 'transfer_only' | 'event_only' | 'unknown'];
return entry.note ? `${reason} (${entry.note})` : reason;
}
let detailsDialog: HTMLDialogElement;
let detailNumber = $state<number | null>(null);
function openDetails(number: number) {
detailNumber = number;
detailsDialog.showModal();
for (const game of data.games)
if (!data.gamesConfigured || data.selectedGameIds.includes(game.id)) draft.add(game.id);
rulesOpen = true;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
<div
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 lg:grid-cols-[auto_minmax(0,1fr)_auto]"
>
<div class="mt-3 flex flex-wrap items-end justify-between gap-5">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.length} Pokémon · {data.games.length} games
</p>
</div>
<Button variant="outline" type="button" onclick={openRules}>set rules</Button>
</div>
{#if !data.user}<p class="mt-6">
<a
href={resolve('/login?returnTo=/projects/game-tracker/pokemon')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
{#if !data.pokemon.length}<p class="mt-8 text-[var(--site-muted)]">
the Pokémon catalog has not been loaded yet.
</p>{/if}
<div class="mt-8 border-y border-[var(--site-border)] py-6">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">overall progress</p>
<p class="text-2xl font-semibold tabular-nums">
{percent(progress.caughtEntries, progress.catchableEntries)}%
</p>
</div>
<div class="mt-3">
<ProgressBar
value={progress.caughtEntries}
max={progress.catchableEntries}
label="Overall catch progress"
/>
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{progress.caughtEntries} of {progress.catchableEntries} obtainable entries caught
</p>
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
{#each progressBars as item (item.label)}
<div class="min-w-0">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">{item.label}</p>
<p class="text-xl font-semibold tabular-nums">{percent(item.value, item.total)}%</p>
</div>
<div class="mt-3">
<ProgressBar value={item.value} max={item.total} label={item.label} small />
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{item.value} of {item.total}
{item.unit}
</p>
</div>
{/each}
</div>
<p class="mt-6 text-base text-[var(--site-muted)]">
counts use your selected games. Pokémon you can evolve or breed there count as obtainable.
</p>
</div>
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label class="block"
>search Pokémon
<Input
type="search"
bind:value={search}
oninput={() => (pageNumber = 1)}
placeholder="name or number"
class="mt-2 h-11"
/>
</label>
<label class="block"
>game
<select
bind:value={viewGameId}
onchange={() => (pageNumber = 1)}
disabled={!visibleGames.length}
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
>
{#each visibleGames as game (game.id)}<option value={game.id}>{game.name}</option>{/each}
</select>
</label>
</div>
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={showUnobtainable}
onchange={(event) => {
showUnobtainable = event.currentTarget.checked;
pageNumber = 1;
}}
class="h-5 w-5 accent-[var(--site-text)]"
/>
show Pokémon you can’t get in this game
</label>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="underline underline-offset-4">browse locations</a
>
</div>
{#if viewGame}
<div class="mt-8">
<table class="w-full table-fixed border-collapse">
<colgroup><col /><col class="w-24 sm:w-36" /></colgroup>
<thead
><tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]"
><th scope="col" class="py-3 font-medium">Pokémon</th><th
scope="col"
class="py-3 text-center font-medium">caught</th
></tr
></thead
<div class="flex items-center gap-3">
{#if data.activeGame}<GameLogo
slug={`pokemon/${data.activeGame.slug}`}
name={data.activeGame.name}
class="h-12 w-12"
/>{/if}
<div>
<a
href={resolve('/projects/game-tracker')}
class="text-xs text-muted-foreground hover:underline">← Game tracker</a
>
<tbody>
{#each visiblePokemon as pokemon (pokemon.id)}
{@const entry = entryMap.get(pokemon.id)?.get(viewGame.id)}
{@const reason = tooltip(entry, viewGame)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
<button
type="button"
onclick={() => openDetails(pokemon.number)}
class="flex w-full min-w-0 items-center gap-3 text-left hover:text-[var(--site-muted)]"
>
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
alt=""
loading="lazy"
width="40"
height="40"
class="h-10 w-10 shrink-0"
/>
<span class="min-w-0"
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
>{pokemon.name}
{#if entry && reason === null}<span
class="block truncate text-sm text-[var(--site-muted)]"
>{availabilityLabels[entry.availability]}{entry.note
? ` · ${entry.note}`
: ''}</span
>{/if}</span
>
</button>
</th>
<td class="py-2 text-center">
{#if entry && reason === null}
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={caught.has(entry.id)}
disabled={!hydrated || !data.user || pending.has(entry.id)}
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
/>
{:else}
<span
title={reason ?? undefined}
aria-label={`${pokemon.name} in ${viewGame.name}: ${reason}`}
class="text-[var(--site-muted)]"
>{!entry ? '—' : entry.availability === 'unknown' ? '?' : '·'}</span
>
{/if}
</td>
</tr>
{:else}
<tr><td colspan="2" class="py-8 text-[var(--site-muted)]">no Pokémon found</td></tr>
{/each}
</tbody>
</table>
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<p class="text-[var(--site-muted)]">
{filteredPokemon.length ? (pageNumber - 1) * pageSize + 1 : 0}–{Math.min(
pageNumber * pageSize,
filteredPokemon.length
)} of {filteredPokemon.length}
</p>
<div class="flex items-center gap-4">
<Button
variant="outline"
size="sm"
type="button"
disabled={pageNumber === 1}
onclick={() => (pageNumber -= 1)}>previous</Button
<h1 class="text-xl font-semibold">
Pokémon journal <span class="text-sm font-normal text-muted-foreground"
>· {data.activeGame?.name ?? 'Choose a game'}</span
>
<span class="text-[var(--site-muted)]">{pageNumber}/{pageCount}</span>
<Button
variant="outline"
size="sm"
type="button"
disabled={pageNumber === pageCount}
onclick={() => (pageNumber += 1)}>next</Button
>
</div>
</h1>
</div>
</div>
{:else if data.pokemon.length}
<p class="mt-8 text-[var(--site-muted)]">no games selected. use “set rules” to choose one.</p>
{/if}
<div
class={`game-covers order-3 col-span-2 overflow-x-auto pb-1 ${visibleGames.length > 30 ? 'lg:col-span-3' : 'lg:order-none lg:col-span-1'}`}
bind:clientWidth={stripWidth}
role="group"
aria-label="Choose Pokémon game"
>
<div
class="grid gap-1"
style:grid-template-columns={`repeat(${Math.max(1, Math.ceil(visibleGames.length / coverRows))}, minmax(40px, 1fr))`}
style:max-width={`${Math.ceil(visibleGames.length / coverRows) * 64}px`}
>
{#each visibleGames as game (game.id)}
<Button
variant="ghost"
disabled={!hydrated}
aria-pressed={data.activeGame?.id === game.id}
aria-label={`Play ${game.name}`}
title={game.name}
class={`game-cover-button h-12 min-w-0 rounded-md border p-1 ${data.activeGame?.id === game.id ? 'border-amber-500 bg-amber-500/15 ring-1 ring-amber-500' : 'border-transparent opacity-65 hover:opacity-100'}`}
onclick={() =>
goto(resolve(`/projects/game-tracker/pokemon?game=${game.slug ?? game.id}`), {
noScroll: true,
keepFocus: true
})}
>
<GameLogo slug={`pokemon/${game.slug}`} name={game.name} class="h-10 w-full" />
</Button>
{/each}
</div>
</div>
<Button
class="justify-self-end lg:col-start-3"
variant="ghost"
size="sm"
disabled={!hydrated}
onclick={openRules}>set rules</Button
>
</div>
<details class="mt-10 border-t border-[var(--site-border)] pt-5 text-[var(--site-muted)]">
<summary class="cursor-pointer">about availability</summary>
<p class="mt-3 max-w-3xl">
the catalog is owned by this site and built from a pinned PokéAPI snapshot. Checkboxes appear
for Pokémon you can catch (wild, gift, static or in-game trade), evolve from something you can
get in that game, or breed. “·” marks event-only mythicals, “?” means the Pokémon is in the
game but no route is confirmed, and “—” means there is no record. Neither proves it is
unobtainable. Newer games (Brilliant Diamond and later) have no encounter data yet.
</p>
</details>
{#if !data.user}<p class="mt-4 text-sm">
<a href={resolve('/login?returnTo=/projects/game-tracker/pokemon')} class="underline"
>Sign in</a
> to save catches, teams and adventures.
</p>{/if}
{#if form?.message && !rulesOpen}<p role="alert" class="mt-4 text-destructive">
{form.message}
</p>{/if}
{#if data.activeGame}{#key data.activeGame.id}<PokemonJournal {data} />{/key}{:else}<p
class="mt-8 text-muted-foreground"
>
No games selected. Use “set rules” to choose your next adventure.
</p>{/if}
<p class="mt-4 text-[10px] leading-relaxed text-muted-foreground">
Species, regional Pokédexes, moves and encounters from <a
href="https://pokeapi.co/"
class="underline">PokéAPI</a
>. Encounter coverage varies by game; missing records do not prove that a Pokémon is
unavailable. Stats and move battle values describe the catalog’s default forms and current
snapshot. Maps identify their source and coverage.
</p>
</div>
<dialog
bind:this={detailsDialog}
onclose={() => (detailNumber = null)}
class="m-auto max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
>
<div class="flex justify-end">
<button type="button" onclick={() => detailsDialog.close()} class="text-[var(--site-muted)]"
>close</button
<Dialog.Root bind:open={rulesOpen}>
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header
><Dialog.Title>Set rules</Dialog.Title><Dialog.Description
>Choose the games in your collection. Your saved catches stay with each game.</Dialog.Description
></Dialog.Header
>
</div>
{#if detailNumber !== null}
<PokemonDetails
number={detailNumber}
gameId={viewGameId}
isAdmin={data.isAdmin}
onsaved={() => invalidateAll()}
/>
{/if}
</dialog>
<dialog
bind:this={rulesDialog}
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
>
<h2 class="text-2xl font-semibold">set rules</h2>
<p class="mt-2 text-[var(--site-muted)]">choose the games that count toward your progress.</p>
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
<div class="mt-5 flex gap-5">
<button
type="button"
onclick={() => {
draft.clear();
for (const game of data.games) draft.add(game.id);
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onclick={() => {
for (const game of data.games) draft.add(game.id);
}}>Select all</Button
><Button variant="ghost" size="sm" onclick={() => draft.clear()}>Clear selection</Button>
</div>
<form
method="POST"
action="?/setGames"
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
await update();
saving = false;
if (result.type === 'success') rulesOpen = false;
};
}}
class="underline underline-offset-4">all</button
>
<button type="button" onclick={() => draft.clear()} class="underline underline-offset-4"
>none</button
>
</div>
<form
method="POST"
action="?/setGames"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') rulesDialog.close();
};
}}
>
<div class="mt-5 grid max-h-[50vh] gap-5 overflow-y-auto pr-2 sm:grid-cols-2">
{#each gamesByGeneration as [generation, games] (generation)}
<fieldset>
<legend class="mb-2 font-semibold"
><label class="flex items-center gap-2"
><input
type="checkbox"
checked={games.every((game) => draft.has(game.id))}
onchange={(event) => {
for (const game of games) {
if (event.currentTarget.checked) draft.add(game.id);
else draft.delete(game.id);
}
}}
/>generation {generation}</label
></legend
>
{#each games as game (game.id)}<label class="flex items-center gap-2 py-1 pl-5"
><input
type="checkbox"
name="gameIds"
value={game.id}
checked={draft.has(game.id)}
onchange={(event) => {
if (event.currentTarget.checked) draft.add(game.id);
else draft.delete(game.id);
}}
/>{game.name}</label
>{/each}
</fieldset>
{/each}
</div>
<div class="mt-6 flex justify-end gap-5">
<button type="button" onclick={() => rulesDialog.close()}>cancel</button>
<button
disabled={!data.user}
title={data.user ? undefined : 'Sign in to save rules'}
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
>save</button
<div class="grid max-h-[55dvh] gap-6 overflow-auto pr-2 sm:grid-cols-2">
{#each generations as [generation, games] (generation)}
<fieldset>
<legend class="mb-3"
><label class="flex items-center gap-2 font-semibold"
><Checkbox
checked={games.every((g) => draft.has(g.id))}
indeterminate={games.some((g) => draft.has(g.id)) &&
!games.every((g) => draft.has(g.id))}
onCheckedChange={(checked) => {
for (const g of games)
if (checked) draft.add(g.id);
else draft.delete(g.id);
}}
/>Generation {generation}</label
></legend
>
<div class="space-y-2">
{#each games as g (g.id)}<label class="flex items-center gap-3 rounded-lg border p-2"
><Checkbox
name="gameIds"
value={String(g.id)}
checked={draft.has(g.id)}
onCheckedChange={(checked) => (checked ? draft.add(g.id) : draft.delete(g.id))}
/><GameLogo slug={`pokemon/${g.slug}`} name={g.name} class="h-8 w-16" /><span
class="text-sm">{g.name}</span
></label
>{/each}
</div>
</fieldset>{/each}
</div>
<Dialog.Footer class="mt-6"
><Button variant="outline" onclick={() => (rulesOpen = false)}>Cancel</Button><Button
type="submit"
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save rules'}</Button
></Dialog.Footer
>
</div>
</form>
</dialog>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import { and, asc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { pokemon, pokemonEncounters } from '$lib/server/db/schema';
export async function GET({ url }) {
const gameId = Number(url.searchParams.get('game'));
const locationId = Number(url.searchParams.get('location'));
if (![gameId, locationId].every((n) => Number.isSafeInteger(n) && n > 0 && n <= 2147483647))
error(400, 'Invalid game or location');
const rows = await db
.select({
pokemonId: pokemon.id,
number: pokemon.number,
name: pokemon.name,
type1: pokemon.type1,
type2: pokemon.type2,
area: pokemonEncounters.area,
method: pokemonEncounters.method,
minLevel: pokemonEncounters.minLevel,
maxLevel: pokemonEncounters.maxLevel,
rarity: pokemonEncounters.rarity,
conditions: pokemonEncounters.conditions,
form: pokemonEncounters.form,
note: pokemonEncounters.note
})
.from(pokemonEncounters)
.innerJoin(pokemon, eq(pokemon.id, pokemonEncounters.pokemonId))
.where(and(eq(pokemonEncounters.gameId, gameId), eq(pokemonEncounters.locationId, locationId)))
.orderBy(asc(pokemon.number), asc(pokemonEncounters.method), asc(pokemonEncounters.minLevel));
return json(rows, { headers: { 'Cache-Control': 'public, max-age=3600' } });
}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { resolve } from '$app/paths';
import { Input } from '$lib/components/ui/input/index.js';
import * as Card from '$lib/components/ui/card/index.js';
@@ -39,13 +40,13 @@
</label>
<label class="block"
>region
<select
<NativeSelect
bind:value={region}
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
>
<option value="">all regions</option>
{#each regions as name (name)}<option value={name}>{name}</option>{/each}
</select>
</NativeSelect>
</label>
</div>
<label class="mt-5 flex cursor-pointer items-center gap-3">

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import { page } from '$app/state';
@@ -124,12 +126,12 @@
{#if games.length}
<label class="block"
>game
<select
<NativeSelect
bind:value={gameId}
class="mt-2 block rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>
{#each games as [id, name] (id)}<option value={id}>{name}</option>{/each}
</select>
</NativeSelect>
</label>
{/if}
</div>
@@ -139,7 +141,7 @@
{#each list as encounter, index (index)}
<li class="flex items-center gap-3 border-t border-[var(--site-border)] py-2">
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{encounter.number}.png"
src={pokemonImage(encounter.number)}
alt=""
loading="lazy"
width="40"

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let resetTarget = $state<{ code: LangCode; name: string } | null>(null);
import { resolve } from '$app/paths';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import Flame from 'lucide-svelte/icons/flame';
@@ -40,7 +42,7 @@
}
function reset(code: LangCode, name: string) {
if (confirm(`Delete all ${name} progress? This can't be undone.`)) progress.reset(code);
resetTarget = { code, name };
}
</script>
@@ -247,3 +249,25 @@
{/if}
</div>
</section>
<AlertDialog.Root
open={!!resetTarget}
onOpenChange={(open) => {
if (!open) resetTarget = null;
}}
>
<AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Reset {resetTarget?.name}?</AlertDialog.Title><AlertDialog.Description
>This deletes all progress for this language and cannot be undone.</AlertDialog.Description
></AlertDialog.Header
><AlertDialog.Footer
><AlertDialog.Cancel>Cancel</AlertDialog.Cancel><AlertDialog.Action
onclick={() => {
if (resetTarget) progress.reset(resetTarget.code);
resetTarget = null;
}}>Reset progress</AlertDialog.Action
></AlertDialog.Footer
></AlertDialog.Content
>
</AlertDialog.Root>

File diff suppressed because it is too large Load Diff

View File

@@ -486,21 +486,6 @@
border-left: 1px solid var(--site-border);
}
}
.money-dialog {
width: min(440px, calc(100% - 28px));
max-height: min(90vh, 850px);
overflow-y: auto;
margin: auto;
padding: 24px;
border: 1px solid var(--site-border);
border-radius: 10px;
background: var(--site-surface);
color: var(--site-text);
box-shadow: 0 18px 60px #0006;
}
.money-dialog::backdrop {
background: #0009;
}
.money-dialog .dialog-header {
display: flex;
align-items: start;

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
@@ -52,7 +53,7 @@
{#if data.editable}
<form method="POST" action="?/addTopic" class="flex flex-wrap items-end gap-2">
<div class="min-w-48 flex-1 space-y-2">
<Label for="topic-id">Add existing topic</Label><select
<Label for="topic-id">Add existing topic</Label><NativeSelect
id="topic-id"
name="topic-id"
required
@@ -60,7 +61,7 @@
><option value="">Choose a topic</option
>{#each data.topics as topic (topic.id)}<option value={topic.id}
>{topic.name}</option
>{/each}</select
>{/each}</NativeSelect
>
</div>
<Button type="submit" variant="outline">Add</Button>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import Artwork from '$lib/map-finder/Artwork.svelte';
@@ -90,11 +91,14 @@
</p>{/if}
<label class="mb-6 block text-sm"
>Show
<select bind:value={filter} class="ml-2 rounded border border-border bg-background p-2">
<NativeSelect
bind:value={filter}
class="ml-2 rounded border border-border bg-background p-2"
>
{#each filters as option (option.key)}<option value={option.key}
>{option.label}</option
>{/each}
</select>
</NativeSelect>
</label>
{/if}
{#if !results.length}<p class="text-muted-foreground">

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import './world-opinions.css';
import CountryPanel from '$lib/world-opinions/CountryPanel.svelte';
import CultureMap from '$lib/world-opinions/CultureMap.svelte';
@@ -52,14 +53,14 @@
<section id="country" class="scroll-mt-20 pt-10" aria-label="Country">
<label class="mb-3 block max-w-xs text-sm">
<span class="sr-only">Country</span>
<select
<NativeSelect
bind:value={countryId}
class="w-full rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-2"
>
{#each sorted as country (country.id)}
<option value={country.id}>{country.name}</option>
{/each}
</select>
</NativeSelect>
</label>
<CountryPanel id={countryId} />
</section>

View File

@@ -13,6 +13,7 @@ const staticPaths = [
'/lists',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',
'/projects/game-tracker/osu',
'/projects/game-tracker/pokemon/locations',
'/projects/animaldex',
'/projects/derby',