Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 704880dc98 | |||
| 5f940e8196 | |||
| 8c0fc74283 | |||
| ef9524aa0f | |||
| 8515cf0e9c | |||
| 602ea9c8e1 | |||
| 42b6b6568a | |||
| ba44365c71 | |||
| 3f251145be | |||
| 59b35c0886 | |||
| eccae362ec | |||
| 6696bc7162 | |||
| 2259965a28 | |||
| cc6c7c8ede | |||
| 6249e6daeb | |||
| 085a30bec8 | |||
| 8442d59b68 | |||
| fac49b57ba | |||
| 7636f0b2bc | |||
| bf0a1e1d2a | |||
|
|
0612270545 | ||
| 901d12f71d | |||
| 771f64c168 | |||
| 46703f1a6c | |||
| 0aa14cebf3 | |||
| 3903317a59 | |||
| 4738f10ad1 | |||
| bc58e97776 | |||
| b757269151 | |||
| 45c124a365 | |||
| 9e30e4246f | |||
| a04e5faebe | |||
| 113eb54cd9 | |||
| e95b7da1e3 | |||
| cc4b10f239 | |||
|
|
112c31d194 | ||
| 2bd668bf5e | |||
| 61ae72e50b | |||
| c4689adda4 | |||
| 807f8af167 | |||
| 8c9a08afa1 | |||
| b5748e5ae5 | |||
| c827deb395 | |||
| 8a29d5ad59 | |||
| 35f78ec829 | |||
| 8818dde99d | |||
| 07d2c70973 | |||
| b51fef14fd | |||
| ea7d64a556 | |||
| 6e528da403 | |||
| 85da5afdfe | |||
| 427f4ed095 | |||
| 8dde5aa302 | |||
| 5f67ad36e7 | |||
| b77c3f8cf5 | |||
| 1754ea9316 | |||
| 6a127de950 | |||
| 9d08e6af23 | |||
| 941a9f16e1 | |||
| c8205e474e | |||
| 81ee02f11a | |||
| eb80a830f5 | |||
| 988c6c31a9 | |||
| 2af279e8a9 | |||
| faeb76a7e7 | |||
| a022c45499 | |||
| 6f82d1350e | |||
| 7a039b23f4 | |||
| 8445eef3cf | |||
| 1d4cfd58c5 | |||
| 763d7881fb | |||
| 2cada618bf | |||
| 7914b0140b | |||
| 178fa8ddce | |||
| 9c8f20a618 | |||
| 9c46e281c2 | |||
| 8b2dfed239 | |||
| 2da375451a | |||
| e4de2dcec3 | |||
| 00c1e8b1e7 | |||
| 9a1b21ec39 | |||
| b1ce715ec7 | |||
| 064d41595f | |||
| fa2456b99e | |||
| 8aa689af64 | |||
| 8275f1fb22 | |||
| 0cf5d44062 | |||
| 88837af624 | |||
| 45f2bbb198 | |||
| 36af5675e9 | |||
| 7e69d1e888 | |||
| 2915135ed8 | |||
| 4cfe6bad6c | |||
| 64fb8df262 | |||
| f94e3dc8ac | |||
| 54924b39f0 | |||
| b52e9188f4 | |||
| ecd49fc410 | |||
| 4ea0ba2d48 | |||
| 0846cf7d77 | |||
| 0ee9ed0abb | |||
| 8eec207f2d | |||
| c2bc9050c2 | |||
| c2ba8febb4 | |||
| f6b34150d4 | |||
| abac077541 | |||
| c757d0882a | |||
| a10585a4d2 | |||
| 438623d857 | |||
| afb1f191df | |||
| 499dd94cbb | |||
| 2466db74cd | |||
| 0638ead87f | |||
| 753b2e5d6a | |||
| 3691a85fdf | |||
| 4b63e9ca11 | |||
| f1cd0ef849 | |||
| eca43c82d0 | |||
| 1d0b96e7bc | |||
| b8b366b542 | |||
| 6ed508702e | |||
| 46ab4a0adb | |||
| 08ab1f2716 | |||
| e039f69afd | |||
| 1ff6bb859e | |||
| 997c54133f | |||
| bdc8ac24ac | |||
| c688b874c8 | |||
| 74f64c7457 | |||
| 245137c65d |
21
.env.example
21
.env.example
@@ -1,5 +1,9 @@
|
||||
# PostgreSQL connection (server only)
|
||||
DB_URL=postgresql://postgres:postgres@localhost:5432/personal_dev
|
||||
# PostgreSQL connections (server only). Never commit real passwords.
|
||||
# DB_URL: the app role (personal_app: DML only, no DDL).
|
||||
DB_URL=postgresql://personal_app:CHANGE_ME@localhost:5432/personal_dev
|
||||
# DB_MIGRATE_URL: the schema owner (personal), used only by drizzle-kit,
|
||||
# scripts/db-migrate.ts, the seed scripts and deploy backups.
|
||||
DB_MIGRATE_URL=postgresql://personal:CHANGE_ME@localhost:5432/personal_dev
|
||||
|
||||
# Use the exact public origin, including scheme and port. Set the same value for both.
|
||||
ORIGIN=http://localhost:5173
|
||||
@@ -28,3 +32,16 @@ R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
# House tracker: shared secret for the home worker (openssl rand -hex 32), and
|
||||
# where alerts go (Discord webhook and/or ntfy topic URL). See docs/house-tracker.md.
|
||||
HOUSE_WORKER_TOKEN=
|
||||
HOUSE_DISCORD_WEBHOOK_URL=
|
||||
HOUSE_NTFY_URL=
|
||||
HOUSE_NTFY_TOKEN=
|
||||
|
||||
# Money tracker bank sync (scripts/bank-link.ts, scripts/bank-sync.ts; not read by the web app).
|
||||
# Keep these in ~/.config/money-sync.env (mode 600), never in git.
|
||||
ENABLEBANKING_APP_ID=
|
||||
ENABLEBANKING_KEY_PATH=
|
||||
ENABLEBANKING_REDIRECT_URL=https://stanrunge.dev/projects/money-tracker/bank-callback
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Test and deploy staging
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
|
||||
jobs:
|
||||
staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Install and test
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run test:unit -- --run
|
||||
|
||||
# Build once. The same SHA-tagged image stays on the deployment host for
|
||||
# one-click promotion to production; the production workflow never rebuilds it.
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
|
||||
- name: Configure deployment SSH key
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Transfer tested image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="git.stanrunge.dev/stan/personal-website:${{ github.sha }}"
|
||||
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
|
||||
|
||||
- name: Deploy staging
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -o BatchMode=yes deploy@172.17.0.1 \
|
||||
"bash -s -- '${{ github.sha }}' '/opt/personal-website-staging' 'https://staging.stanrunge.dev'" \
|
||||
< scripts/deploy-remote.sh
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Deploy production
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Always test the staging branch, regardless of which branch the button
|
||||
# appears on in Gitea's Actions UI.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: staging
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Check staging source
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run test:unit -- --run
|
||||
|
||||
- name: Configure deployment SSH key
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Promote the exact staging image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
commit=$(git rev-parse HEAD)
|
||||
ssh -o BatchMode=yes deploy@172.17.0.1 "bash -s -- '$commit'" \
|
||||
< scripts/verify-staging-image.sh
|
||||
ssh -o BatchMode=yes deploy@172.17.0.1 \
|
||||
"bash -s -- '$commit' '/opt/personal-website' 'https://stanrunge.dev'" \
|
||||
< scripts/deploy-remote.sh
|
||||
70
.gitea/workflows/deploy.yml
Normal file
70
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Deploy production
|
||||
# Two environments only: local dev (see README "Development") and prod.
|
||||
# A push/merge to main builds and tests the image, backs up prod (pg_dump via
|
||||
# postgres:17-alpine), runs Drizzle migrations, then switches the container.
|
||||
# Pushes to any other branch (e.g. db-schemas) do not match and run nothing.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rollback_tag:
|
||||
description: 'Roll back: redeploy this earlier image SHA (skips migrations/seeds)'
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Belt and braces: a manual dispatch from another branch does nothing either.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure deployment SSH key
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
||||
|
||||
# The Dockerfile runs bun install --frozen-lockfile, unit tests and the build,
|
||||
# so a failing test never produces a deployable image.
|
||||
- if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: git.stanrunge.dev/stan/website:${{ github.sha }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
|
||||
- name: Transfer image
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="git.stanrunge.dev/stan/website:${{ github.sha }}"
|
||||
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
|
||||
|
||||
- name: Back up, migrate and deploy
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -o BatchMode=yes deploy@172.17.0.1 \
|
||||
"bash -s -- '${{ github.sha }}' deploy" < scripts/deploy-remote.sh
|
||||
|
||||
- name: Roll back to an earlier image
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
TAG: ${{ inputs.rollback_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in *[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;; esac
|
||||
ssh -o BatchMode=yes deploy@172.17.0.1 "bash -s -- '$TAG' rollback" < scripts/deploy-remote.sh
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,6 +16,10 @@ Thumbs.db
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Home worker credentials and browser cookies
|
||||
/house.env
|
||||
/.house-worker-profile/
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
@@ -5,3 +5,23 @@ yarn.lock
|
||||
|
||||
# Generated pinned catalog
|
||||
data/pokemon-catalog.json
|
||||
|
||||
# Generated Pokémon locations and encounters
|
||||
data/pokemon-locations.json
|
||||
|
||||
# Generated Pokémon research and map coordinates
|
||||
data/pokemon-research.json
|
||||
data/pokemon-atlas.json
|
||||
# Attributed generated challenge snapshot
|
||||
data/cod-challenges.json
|
||||
|
||||
# Generated local image download manifest
|
||||
data/game-images.json
|
||||
data/cod-achievement-art.json
|
||||
src/lib/house-tracker/fixtures/
|
||||
|
||||
# Vendored third-party code
|
||||
static/vendor/
|
||||
|
||||
# Generated drizzle snapshots
|
||||
drizzle/*/snapshot.json
|
||||
|
||||
@@ -3,6 +3,7 @@ WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN bun run test:unit -- --run
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1.4.0
|
||||
@@ -12,11 +13,17 @@ ENV APP_VERSION=$GIT_SHA
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/data ./data
|
||||
COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema
|
||||
COPY --from=builder /app/src/lib/cs-prices ./src/lib/cs-prices
|
||||
# scripts/house-listing.ts (manual listings CLI) imports these pure helpers.
|
||||
COPY --from=builder /app/src/lib/house-tracker ./src/lib/house-tracker
|
||||
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
|
||||
# scripts/music-*.ts (Spotify poll, Last.fm and streaming-history imports) import these.
|
||||
COPY --from=builder /app/src/lib/music ./src/lib/music
|
||||
COPY package.json ./
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV BODY_SIZE_LIMIT=6M
|
||||
CMD ["bun", "build/index.js"]
|
||||
CMD ["bun", "./build"]
|
||||
|
||||
469
README.md
Executable file → Normal file
469
README.md
Executable file → Normal file
@@ -6,3 +6,472 @@ This is my personal website, including:
|
||||
- About me
|
||||
- Blog posts + comments
|
||||
- Projects like Animaldex, CatchEmAll
|
||||
|
||||
## House tracker
|
||||
|
||||
Available at `/projects/house-tracker` (admin only). Listings come from a worker
|
||||
that runs at home; setup, settings and the ban-avoidance rules are in
|
||||
[docs/house-tracker.md](docs/house-tracker.md).
|
||||
|
||||
## osu! Map Finder
|
||||
|
||||
Available at `/projects/spotify-beatmap-finder`. Built with the existing Svelte 5,
|
||||
SvelteKit 3, Tailwind 4, and adapter-bun setup. Uses server-only direct `fetch`
|
||||
calls; no Spotify/osu! SDK or additional services.
|
||||
|
||||
Set these private variables in your local `.env` or deployment environment:
|
||||
|
||||
```text
|
||||
SPOTIFY_CLIENT_ID
|
||||
SPOTIFY_CLIENT_SECRET
|
||||
OSU_CLIENT_ID
|
||||
OSU_CLIENT_SECRET
|
||||
```
|
||||
|
||||
Never prefix these with `PUBLIC_` or commit `.env`. Providers read
|
||||
`$app/env/private`; the browser receives only display metadata and results.
|
||||
The other projects on this personal site retain their own environment requirements.
|
||||
|
||||
Supported inputs: song text (`Deftones Change`), Spotify track, playlist, and album
|
||||
URLs, and `spotify:track:ID`, `spotify:playlist:ID`, `spotify:album:ID` URIs.
|
||||
URLs allow HTTP/HTTPS, `www`, locale prefixes, query parameters and trailing slashes.
|
||||
Artist, episode, show and other resources receive a validation error.
|
||||
|
||||
Playlists and albums preserve order and process up to **50 usable unique tracks**
|
||||
(`MAX_PLAYLIST_TRACKS`), scanning at most **10 pages** (`MAX_PLAYLIST_PAGES`).
|
||||
Null, unavailable, local, malformed and episode entries are skipped. Truncation is
|
||||
shown explicitly. Album tracks inherit the album artwork/name. Each match has its
|
||||
own error state; an osu! failure cannot erase other completed tracks. The enhanced
|
||||
form shows a pending state and renders the complete batch; native form submission
|
||||
also works without JavaScript. Filters require JavaScript.
|
||||
|
||||
Spotify playlist availability depends on application quota/access. Both legacy
|
||||
`tracks/track` and newer `items/item` payloads are supported. Spotify's
|
||||
[2026 development-mode changes](https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide)
|
||||
restrict playlist access; client credentials cannot unlock private/user-authorized
|
||||
playlists. Such responses produce an access error. This project intentionally does
|
||||
not add user authentication.
|
||||
|
||||
Matching is pure and testable in `src/lib/map-finder/matching.ts`: Unicode and
|
||||
punctuation normalization, diacritic removal, common annotation cleanup, title and
|
||||
artist token overlap, Unicode metadata alternatives, and version penalties. Title
|
||||
contributes 65 points and artist 35. High confidence starts at 88, medium at 72;
|
||||
weak artist evidence is capped below medium, and a different version costs 24.
|
||||
These are heuristics, not probabilities. Candidates below 40 are omitted; up to six
|
||||
are sorted and deduplicated by beatmapset ID. Low-confidence candidates are collapsed.
|
||||
Search first uses artist + title, then one cleaned-title fallback if no medium/high
|
||||
match exists. Duration is omitted because osu! map length may reflect a song cut.
|
||||
|
||||
Tokens are reused until 30 seconds before expiry with a shared refresh promise.
|
||||
Matching results cache for five minutes (maximum 500 entries), including no-match
|
||||
results, and identical pending searches are coalesced. Three concurrent osu!
|
||||
matching jobs are allowed per process. The site allows one active search per IP,
|
||||
up to six active searches globally, and 12 budget units per IP per 10 minutes:
|
||||
a single track costs one, a playlist/album six. Expired records are cleaned on
|
||||
requests; the limiter has a 10,000-record ceiling. It uses `getClientAddress()`,
|
||||
never arbitrary forwarded headers; unsupported adapters share a conservative key.
|
||||
Configure adapter-bun's trusted proxy/client-address settings (`ADDRESS_HEADER`, `XFF_DEPTH`) correctly at deployment.
|
||||
|
||||
Requests have a 12-second timeout and at most two retries for network errors,
|
||||
429s or server errors. Backoff includes jitter. `Retry-After` is honored; delays
|
||||
above four seconds return a helpful error and retain a provider cooldown rather
|
||||
than holding the request open. All caches, cooldowns, concurrency and rate limits
|
||||
are per process and reset on restart; multiple instances do not share them.
|
||||
|
||||
Local commands (npm scripts and existing tooling; no new dependencies):
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
npm run test:unit -- --run
|
||||
npm run test:integration
|
||||
npm run check
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
Finder unit tests mock upstream APIs. Browser tests cover initial layout and
|
||||
validation with and without enhancement, and do not call Spotify or osu!.
|
||||
|
||||
## Language learner
|
||||
|
||||
`/projects/language-learner` teaches Dutch and Korean (explained in English) and
|
||||
English (explained in Dutch), from A1 to C1. Korean starts with a Hangul level.
|
||||
It runs entirely in the browser: no database tables, migrations or API keys.
|
||||
|
||||
- Course content lives in `src/lib/language-learner/courses/<lang>/<level>.ts` as
|
||||
compact `target | translation | note` lines, plus markdown notes and
|
||||
multiple-choice grammar questions (`prompt | answer | wrong; wrong | explanation`).
|
||||
Exercises (choice, typing, sentence building, fill-in-the-blank, listening,
|
||||
matching) are generated from that data. `content.test.ts` validates every lesson.
|
||||
- Endless drills are generated by code in `src/lib/language-learner/drills/`:
|
||||
number words, Dutch/English clock times, de/het, plurals, Dutch and Korean
|
||||
conjugation (a rule-based Korean conjugator handling the ㅂ/ㄷ/르/ㅅ/ㅎ
|
||||
irregulars), Korean particles and counters, Hangul reading, English irregular
|
||||
verbs, comparatives and a/an.
|
||||
- Challenges: a seeded daily challenge (first score of the day counts), 60-second
|
||||
sprint, survival with three lives, mixed practice, level tests that mark a level
|
||||
as done at 80%, and spaced-repetition reviews (simplified SM-2).
|
||||
- Audio uses the browser's speech synthesis, so listening exercises appear only
|
||||
when the device has a voice for the language. Romanization of Korean is
|
||||
approximate Revised Romanization.
|
||||
- Progress, streaks and settings are stored in `localStorage`
|
||||
(`language-learner-v1`), per browser. Export/import on the overview page moves
|
||||
them between devices.
|
||||
|
||||
## Shared accounts and permissions
|
||||
|
||||
`/login`, `/register`, and `/account` are the central account pages. Better Auth
|
||||
handles email/password credentials and database-backed sessions for the entire
|
||||
site. Google and osu! buttons appear when the corresponding client ID and secret
|
||||
are configured. The header exposes account access everywhere.
|
||||
|
||||
Copy the auth settings from `.env.example`. `BETTER_AUTH_SECRET` must be at least
|
||||
32 random characters; there is no runtime fallback secret. Set `ORIGIN` and
|
||||
`BETTER_AUTH_URL` to the same public origin (for example,
|
||||
`https://dev.stanrunge.dev` behind the local proxy). Register these exact callbacks:
|
||||
|
||||
- Google: `<BETTER_AUTH_URL>/api/auth/callback/google`
|
||||
- osu!: `<BETTER_AUTH_URL>/api/auth/callback/osu`
|
||||
|
||||
osu! uses the `identify` scope and a stable numeric account ID. Its API does not
|
||||
supply an email address, so these accounts use a reserved, unverified
|
||||
`<id>@osu.invalid` internal identifier, hidden from the account UI. Credential
|
||||
registration cannot claim that namespace. Provider accounts are linked explicitly
|
||||
from `/account` while logged in; there is no automatic merging by email. Link a
|
||||
provider **before** using it to create a separate account if you want shared
|
||||
progress. Existing separate accounts are not automatically merged.
|
||||
|
||||
Every new user has role `user`. The shared server guards enforce administrator
|
||||
access for blog drafts/writes, list creation/editing, Animaldex moderation, and
|
||||
Progress edits. Legacy `ADMIN_SECRET`, `AUTH_TOKEN`, `admin_token`, and `token`
|
||||
logins no longer authorize anything. To grant yourself admin access, register,
|
||||
copy the account ID shown on `/account`, then run on the server:
|
||||
|
||||
```sh
|
||||
npm run auth:role -- <user-id> admin
|
||||
# To revoke access:
|
||||
npm run auth:role -- <user-id> user
|
||||
```
|
||||
|
||||
Role changes take effect on the next request; roles cannot be set through signup
|
||||
or profile updates. Public registrations are never made admin automatically.
|
||||
Future projects should reference the shared `user.id`, use `requireUser(event)`,
|
||||
and scope **both reads and writes** to the session user's ID. Use
|
||||
`requireAdmin(event)` for site-wide administration; never accept an owner ID from
|
||||
a form or infer permission from the presence of a cookie.
|
||||
|
||||
Email/password registration works without a mail service. Email verification and
|
||||
password recovery emails are **not configured** yet. Connect a transactional mail
|
||||
sender and Better Auth's verification/reset hooks before offering those flows.
|
||||
Better Auth's rate limiting is enabled and currently uses per-process memory;
|
||||
a multi-instance deployment needs shared rate-limit storage. Account pages and
|
||||
saved progress require a working database. Authentication forms require JavaScript.
|
||||
|
||||
## Game tracker
|
||||
|
||||
`/projects/game-tracker` is the hub for completionist progress. Catchemall lives at
|
||||
`/projects/game-tracker/pokemon` (the old `/projects/catch-em-all` URL redirects),
|
||||
with location pages under `/projects/game-tracker/pokemon/locations`. Call of Duty
|
||||
games live at `/projects/game-tracker/call-of-duty/<slug>`. All catalog data is
|
||||
owned by the site: it is checked in under `data/` and seeded into Postgres, and
|
||||
nothing calls PokéAPI or Steam at runtime.
|
||||
|
||||
### Pokémon catalog
|
||||
|
||||
`npm run catalog:build` rebuilds two files from the pinned PokéAPI CSV snapshot
|
||||
(revision recorded inside them):
|
||||
|
||||
- `data/pokemon-catalog.json`: **1,025 species, 53 game versions/expansions and
|
||||
27,267 species/game entries**, plus each species' evolution chain and the
|
||||
evolution method that applies **in each game** (Magnezone needs Mt. Coronet in
|
||||
Platinum but a Thunder Stone in Sword).
|
||||
- `data/pokemon-locations.json`: 1,104 locations and about 67,000 encounters
|
||||
(area, method, levels, rarity, form and conditions such as time of day, season
|
||||
or swarm). Max Raid dens are folded per location.
|
||||
|
||||
Each entry has one availability:
|
||||
|
||||
- `catchable`: a documented encounter (wild, gift, static, in-game trade).
|
||||
- `evolve`: a pre-evolution is obtainable in that game and the method exists
|
||||
there, e.g. Blastoise in Red is “Evolve Wartortle: Level 36+”.
|
||||
- `breed`: an obtainable relative can breed it (not in Gen 1, Let’s Go,
|
||||
Colosseum/XD or the Legends games); incense babies name their incense.
|
||||
- `event_only`: a mythical in the game with no other route.
|
||||
- `unknown`: in the game's Pokédex data, but no confirmed route.
|
||||
|
||||
`catchable`, `evolve` and `breed` can be ticked and count toward progress.
|
||||
PokéAPI has **no encounter data** for Brilliant Diamond/Shining Pearl, Legends:
|
||||
Arceus, Scarlet/Violet and their DLC, or Legends: Z-A, so those games stay
|
||||
`unknown` until another source is added. Missing pairs are not proof of absence.
|
||||
|
||||
Administrators can correct any entry from the Pokémon detail dialog and add notes
|
||||
and a picture (JPEG/PNG/WebP, 5 MB, stored in R2 under `locations/`) on any
|
||||
location page. Rows carry a `source`: seeding refreshes `pokeapi` and `derived`
|
||||
rows and never touches `manual` ones. Location notes and pictures always survive
|
||||
reseeding, and user progress is keyed by entry, so it survives too.
|
||||
|
||||
### Call of Duty catalog
|
||||
|
||||
`data/call-of-duty.json` covers Black Ops, Black Ops II, Ghosts, Advanced
|
||||
Warfare, Black Ops III, Infinite Warfare and WWII. Weapon classes, post-launch
|
||||
(DLC/update) weapons and camo rules are **hand-maintained** from the Call of Duty
|
||||
Wiki (CC BY-SA). `npm run catalog:cod` refreshes only the achievement lists from
|
||||
the public Steam Community pages. Users tick per-weapon milestones (bought or
|
||||
unlocked, max level, Gold, Gold Cheetah); class and overall camos (Diamond, Dark
|
||||
Matter, Solar, Black Sky, Chrome, Chrome Tiger) are derived in
|
||||
`src/lib/game-tracker/mastery.ts`, and post-launch weapons never gate them.
|
||||
Weapon progress is keyed by a slug of the weapon name, so renaming a weapon in
|
||||
the JSON starts its progress over; the seed keeps removed items that already have
|
||||
progress and warns about them.
|
||||
|
||||
### Database
|
||||
|
||||
The schema is managed by Drizzle (`src/lib/server/db/schema/`, migrations in
|
||||
`drizzle/`). Postgres schemas: `auth`, `money`, `house`, `ref`, `games`, `cs`,
|
||||
`animaldex`, `site`, `meta` (Drizzle-managed) and `lists` (one real table per list,
|
||||
created at runtime through the `meta.create_list` family of SQL functions, so it is
|
||||
excluded from Drizzle via `schemaFilter`). See [Development](#development) for the
|
||||
local database and migrations, and `db/reorg/README.md` for the one-time move of the
|
||||
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
|
||||
upsert reference data and run on every production deploy.
|
||||
|
||||
## Book tracker
|
||||
|
||||
`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
|
||||
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
|
||||
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
|
||||
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
|
||||
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
|
||||
position per file, autosaves progress and sessions, and stores highlights with notes.
|
||||
|
||||
Files are never public. They are streamed (with Range support) by
|
||||
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
|
||||
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
|
||||
`hooks.server.ts`).
|
||||
|
||||
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
|
||||
|
||||
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
|
||||
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
|
||||
|
||||
In production, `scripts/deploy-remote.sh` writes `~deploy/compose.books.yml` (the deploy account may only sudo-tee `/opt/personal-website/.env`), which
|
||||
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
|
||||
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
|
||||
be opened.
|
||||
|
||||
## Money
|
||||
|
||||
`/trackers/money` (formerly `/projects/money-tracker`, which now redirects) is restricted to administrator accounts. Its tables
|
||||
are created by `npm run db:migrate:money-tracker`; apply that migration to each
|
||||
environment before deploying the new route. The deployment preflight checks for
|
||||
all nine tracker tables and the projection columns.
|
||||
|
||||
`/trackers/money/documents` lists private documents (contracts, pension rules) with a
|
||||
written summary, an inline viewer and a download. Nothing about them is in git: the
|
||||
files and the summaries live on the host in `MONEY_DOCUMENTS_ROOT` (default
|
||||
`/money-documents` when it exists, otherwise `~/money-documents`) as
|
||||
`documents.json` (`[{ id, title, category, date, pages, file, summary }]`, summary in
|
||||
Markdown) plus `files/<file>`. `scripts/deploy-remote.sh` bind-mounts
|
||||
`/home/stan/money-documents` read-only at `/money-documents` when it has a
|
||||
`documents.json`. Files are only streamed to admins via
|
||||
`/trackers/money/documents/<id>/file` (401 logged out, 403 for non-admins,
|
||||
`private, no-store`). To add one, copy the file into `files/` and add a manifest entry;
|
||||
no redeploy is needed.
|
||||
|
||||
Transactions can be entered manually or imported from a CSV or tab-separated
|
||||
export with `date,account,description,amount` headers. Dates accept
|
||||
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
|
||||
such as `-€1,234.50`. Quoted fields and comma, semicolon, or tab delimiters
|
||||
are supported, and empty rows are skipped. Imported entries are stored in date
|
||||
order. `Start balance` rows are imported as ordinary transactions, so leave
|
||||
the account opening balance at zero for those accounts. Import creates
|
||||
missing accounts and adds every row in one database transaction. It does not
|
||||
deduplicate repeat uploads, so import each file once. Accounts have an editable
|
||||
opening balance for money held before the first recorded transaction.
|
||||
The current balance and history stop at today; future dated transactions feed the
|
||||
wealth projection. The projection invests spendable money above the configured
|
||||
reserve in a modeled balance, even without an existing investment account.
|
||||
An existing investment account can optionally be selected as the starting
|
||||
balance for growth. The projection treats recurring entries as monthly
|
||||
equivalents from next month and can disable them to avoid double counting entries
|
||||
already recorded as future transactions. Its reserve, hypothetical return,
|
||||
contribution rule, and end date are editable. Wish list priority runs from tier 5
|
||||
(highest) to tier 1 (lowest), with cumulative estimated costs on the overview.
|
||||
|
||||
The utility comparison uses meter readings within the configured billing period,
|
||||
the current per-unit rate, and prorated monthly advances. It excludes fixed
|
||||
charges, taxes, and past rate changes. Set the billing start to a recorded opening
|
||||
meter reading for a meaningful annual comparison.
|
||||
|
||||
Seeding runs in one transaction and is repeatable. It preserves row IDs, existing
|
||||
catches, selected games, and hand-maintained availability notes. Pokémon are keyed
|
||||
by national number, games by PokéAPI version ID, and entries by Pokémon/game pair.
|
||||
Existing games with exactly matching names are adopted to preserve their IDs.
|
||||
The old broken reset and auth-schema overwrite commands have been removed.
|
||||
|
||||
Availability is deliberately conservative:
|
||||
|
||||
- `catchable`: PokéAPI supplies an encounter record. Conditions or a particular
|
||||
form may be required. These entries contribute to the catch checklist.
|
||||
- `unknown` (`?`): a Pokédex or game-index record exists, but the acquisition method
|
||||
has not been confirmed. Membership alone never becomes “catchable”.
|
||||
- A dash: no source record; this does **not** prove the Pokémon is unavailable.
|
||||
- Manually maintained trade/transfer/event entries remain supported.
|
||||
|
||||
Encounter coverage varies by game and is especially incomplete for newer titles.
|
||||
The import does not invent evolution, breeding, gift, trade, transfer, or event
|
||||
rules from missing data. The page explains these limits. Catalog refreshes are
|
||||
explicit: update `REVISION` in `scripts/build-pokemon-catalog.py`, run
|
||||
`npm run catalog:build`, review the snapshot, and rerun the seed. Source:
|
||||
[PokéAPI data](https://github.com/PokeAPI/pokeapi/tree/master/data/v2/csv).
|
||||
|
||||
Catches and selected games are stored server-side under the authenticated user's
|
||||
ID. Repeated catch requests are idempotent, writes validate catalog IDs, and an
|
||||
explicit empty game selection survives reloads. The UI rolls back failed saves
|
||||
and prevents overlapping changes to the same catch.
|
||||
|
||||
`npm run test:unit -- --run` covers authorization, safe return paths, osu! identity
|
||||
mapping, and catalog integrity alongside existing tests. `npm run test:auth` runs
|
||||
browser/database checks against a migrated, seeded **development** database; it
|
||||
creates temporary test accounts/content and removes them afterward. It checks
|
||||
registration, login/logout, cookie replay, role escalation attempts, cross-user
|
||||
progress isolation, admin routes, game selection, and OAuth authorization startup.
|
||||
Completing Google/osu! consent still requires real provider credentials and the
|
||||
registered callback URL. The integration follows the installed Better Auth APIs;
|
||||
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
|
||||
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
|
||||
|
||||
## Development
|
||||
|
||||
There are two environments: local dev and production. Prod is the source of truth
|
||||
for data; the dev database is a disposable copy of it.
|
||||
|
||||
```sh
|
||||
cp .env.example .env # DB_URL (app role) + DB_MIGRATE_URL (owner role) -> local personal_dev
|
||||
scripts/db-refresh-dev.sh # fresh prod dump -> personal_dev (sessions cleared)
|
||||
bun run db:migrate # apply this branch's pending Drizzle migrations
|
||||
bun run dev
|
||||
```
|
||||
|
||||
`db-refresh-dev.sh` reads `PROD_DB_URL` (a role that can read everything, e.g.
|
||||
`personal`) and `DEV_ADMIN_URL` (superuser on the dev cluster) from the environment
|
||||
or `.env.dev-refresh` (git-ignored, never commit it). It refuses to overwrite the
|
||||
source database or any database named `personal`. `PG_DOCKER=1` uses the
|
||||
`postgres:17-alpine` image when local `pg_dump` 17 isn't installed.
|
||||
|
||||
Schema changes: edit `src/lib/server/db/schema/`, run `bun run db:generate --name
|
||||
<change>`, review the SQL in `drizzle/`, then `bun run db:migrate`. Never edit an
|
||||
applied migration; never use `db:push` against prod.
|
||||
|
||||
`scripts/preview.sh <branch> [port]` runs a branch against a throwaway copy of the
|
||||
dev database (migrated with that branch's migrations) and cleans everything up on
|
||||
Ctrl-C.
|
||||
|
||||
### Database roles
|
||||
|
||||
| env var | role | used for |
|
||||
| ---------------- | -------------- | --------------------------------------------------------------- |
|
||||
| `DB_URL` | `personal_app` | the app at runtime: DML only, calls the `meta.*` list functions |
|
||||
| `DB_MIGRATE_URL` | `personal` | Drizzle migrations, seeds, backups (`pg_dump`) |
|
||||
|
||||
`personal_ddl` owns the `lists` schema (only reachable through the SECURITY DEFINER
|
||||
`meta.*` functions); `personal_ro` is read-only. The deploy preflight fails if
|
||||
`DB_URL` can create objects, i.e. if it isn't the app role.
|
||||
|
||||
## Production
|
||||
|
||||
Push or merge to `main` → Gitea Actions (`.gitea/workflows/deploy.yml`) builds a
|
||||
SHA-tagged image (unit tests + production build run inside the Docker build),
|
||||
transfers it, and runs `scripts/deploy-remote.sh <sha> deploy` on the host:
|
||||
|
||||
1. checks the image and that `DB_MIGRATE_URL` is set in `/etc/personal-website/prod.env`;
|
||||
2. `pg_dump` of prod (postgres:17-alpine) into the backup directory;
|
||||
3. `bun scripts/db-migrate.ts` (Drizzle migrations) and the catalog seeds;
|
||||
4. preflight (tables, column types, app-role privileges), switch the container, health checks.
|
||||
|
||||
A failed health check restores the previous image tag automatically. Manual
|
||||
rollback: Gitea Actions → **Deploy production** → **Run workflow** with
|
||||
`rollback_tag=<previous sha>`; this redeploys that image without migrating or
|
||||
seeding. Database changes are not rolled back with the image; restore the
|
||||
pre-deploy dump if a migration has to be undone. Only `main` deploys; other
|
||||
branches never trigger the workflow. The runner needs Docker CLI/socket access
|
||||
and the `DEPLOY_KEY` secret.
|
||||
|
||||
`/etc/personal-website/prod.env` needs `DB_URL`, `DB_MIGRATE_URL`, a unique random
|
||||
`BETTER_AUTH_SECRET` (32+ characters), and `ORIGIN`/`BETTER_AUTH_URL` set to
|
||||
`https://stanrunge.dev`, plus the Google and osu! client ID/secret pairs and the five
|
||||
`R2_*` values from `.env.example`. Picture uploads (lists, Animaldex) go through the
|
||||
app server to R2 (5 MB per image; the container sets `BODY_SIZE_LIMIT=6M`). OAuth
|
||||
callback URLs:
|
||||
|
||||
```text
|
||||
https://stanrunge.dev/api/auth/callback/google
|
||||
https://stanrunge.dev/api/auth/callback/osu
|
||||
```
|
||||
|
||||
After registering, promote your account (ID shown on `/account`):
|
||||
|
||||
```sh
|
||||
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
|
||||
git.stanrunge.dev/stan/website:<deployed-sha> bun scripts/set-admin.ts <account-id> admin
|
||||
```
|
||||
|
||||
No public signup grants admin privileges.
|
||||
|
||||
# CS prices
|
||||
|
||||
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
|
||||
individual listing. The separate `cs.item_dimensions` table makes it possible to
|
||||
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
|
||||
additional imported dimensions such as collection or rarity. Group chart points
|
||||
average the available per-listing prices equally within each time bucket. Raw
|
||||
observations retain their source, currency, timestamp, kind, price in cents, and
|
||||
volume. Chart responses use up to roughly 240 time buckets per series and allow
|
||||
up to 1,000 selected series; actual response time still depends on database size
|
||||
and hardware.
|
||||
|
||||
With `DB_URL` pointed at a migrated database:
|
||||
|
||||
```sh
|
||||
npm run cs-prices:ingest -- catalog
|
||||
STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... npm run cs-prices:ingest -- history
|
||||
```
|
||||
|
||||
The catalog pass pages through Counter-Strike market listings and saves a current
|
||||
lowest-listing snapshot. The history pass visits each catalog item and stores
|
||||
Steam's available historical sale-price and volume points. It needs a Steam web
|
||||
session, may take many hours for the full catalog, and can be resumed with
|
||||
`--start=<item offset>` and bounded with `--limit=<items>`. Requests pause for two
|
||||
seconds by default (`--delay-ms=...`, minimum one second), retry temporary
|
||||
failures, and upsert observations safely. Run catalog regularly for new listings
|
||||
and snapshots; run history regularly for new sale points. Steam's market web
|
||||
endpoints are undocumented and may change or limit access. The historical
|
||||
granularity and completeness are bounded by what Steam returns; running this
|
||||
collector cannot reconstruct older high-frequency prices that Steam no longer
|
||||
exposes.
|
||||
|
||||
For another provider or a prepared archive, import JSON Lines instead:
|
||||
|
||||
```sh
|
||||
npm run cs-prices:ingest -- import ./prices.jsonl
|
||||
```
|
||||
|
||||
Each line needs `marketHashName`, `source`, `currency` (three-letter ISO code),
|
||||
`observedAt` (ISO timestamp), `priceCents`, and `kind`. Optional `name`, `volume`,
|
||||
and `dimensions` add detail. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"marketHashName": "AK-47 | Redline (Field-Tested)",
|
||||
"source": "archive",
|
||||
"currency": "USD",
|
||||
"observedAt": "2026-09-24T12:00:00Z",
|
||||
"priceCents": 1850,
|
||||
"volume": 42,
|
||||
"kind": "sale-median",
|
||||
"dimensions": { "collection": "Phoenix", "rarity": "classified" }
|
||||
}
|
||||
```
|
||||
|
||||
Imported sources and price kinds appear in the page's source and kind controls.
|
||||
|
||||
182
bun.lock
182
bun.lock
@@ -9,32 +9,44 @@
|
||||
"@aws-sdk/client-s3": "^3.1121.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1121.0",
|
||||
"@maptiler/sdk": "^4.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"ai": "^7.0.85",
|
||||
"d3-geo": "^3.1.1",
|
||||
"drizzle-orm": "^1.0.0-rc.4",
|
||||
"echarts": "^6.1.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"jose": "^6.2.10",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"postgres": "^3.4.9",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
"zod": "^4.5.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"@iconify-json/simple-icons": "^1.2.94",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/adapter-bun": "^1.0.0",
|
||||
"@sveltejs/enhanced-img": "^0.11.0",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/kit": "^3.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@typescript/native": "npm:typescript@7",
|
||||
"better-auth": "^1.7.2",
|
||||
"bits-ui": "^2.19.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.19",
|
||||
"cn": "^0.4.0",
|
||||
"drizzle-kit": "^1.0.0-rc.4",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -43,11 +55,13 @@
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.57.0",
|
||||
"shadcn-svelte": "^1.7.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "4.7.6",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
@@ -201,6 +215,8 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"@fontsource-variable/montserrat": ["@fontsource-variable/montserrat@5.3.0", "", {}, "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
@@ -281,6 +297,8 @@
|
||||
|
||||
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
||||
|
||||
"@lucide/svelte": ["@lucide/svelte@1.48.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw=="],
|
||||
|
||||
"@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="],
|
||||
|
||||
"@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
|
||||
@@ -309,7 +327,29 @@
|
||||
|
||||
"@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="],
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
|
||||
|
||||
@@ -355,66 +395,8 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg=="],
|
||||
|
||||
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
|
||||
|
||||
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
|
||||
|
||||
"@rollup/plugin-replace": ["@rollup/plugin-replace@6.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA=="],
|
||||
|
||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="],
|
||||
@@ -431,11 +413,11 @@
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
|
||||
|
||||
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.7", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-replace": "^6.0.3", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g=="],
|
||||
"@sveltejs/adapter-bun": ["@sveltejs/adapter-bun@1.0.0", "", { "dependencies": { "bun-types": "^1.3.14" }, "peerDependencies": { "@sveltejs/kit": "^3.0.0-next.0" } }, "sha512-CUfFNxrWrKk/0VqSM1DrXCAwpY3tWocaFwBohuBeyxKx07859shLqPdT9oo00lDti9PvVwhQMDS6bKQylTyv0g=="],
|
||||
|
||||
"@sveltejs/enhanced-img": ["@sveltejs/enhanced-img@0.11.0", "", { "dependencies": { "magic-string": "^0.30.5", "sharp": "^0.34.1", "svelte-parse-markup": "^0.1.5", "vite-imagetools": "^9.0.3", "zimmerframe": "^1.1.2" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0", "svelte": "^5.0.0", "vite": "^6.3.0 || >=7.0.0" } }, "sha512-TN7VzGoqwFqvA4Faj1brUPHLtcEkyFa08nrn9bfm98yVrzr0g4bOZHjlBoPYHHX+sZNGE+cxznhTVgarECPQsA=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="],
|
||||
"@sveltejs/kit": ["@sveltejs/kit@3.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "@standard-schema/spec": "^1.1.0", "cookie": "^2.0.1", "devalue": "^5.9.4", "esm-env": "^1.2.2", "magic-string": "^1.2.3", "mrmime": "^2.0.1", "sirv": "^3.0.2" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "svelte": "^5.57.1", "typescript": "^6.0.0", "vite": "^8.0.12" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JTWlq2HQlkqx6vIoxhC0R6sc4WVqbQoRyEmr5YlqKmiYbzY1U3awtSWTmjMLlS056a49AmkQR6rnNANXIE8B0Q=="],
|
||||
|
||||
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
|
||||
|
||||
@@ -477,7 +459,7 @@
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
"@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
@@ -495,7 +477,9 @@
|
||||
|
||||
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
|
||||
|
||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||
"@types/topojson-client": ["@types/topojson-client@3.1.5", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-specification": "*" } }, "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw=="],
|
||||
|
||||
"@types/topojson-specification": ["@types/topojson-specification@1.0.5", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="],
|
||||
|
||||
@@ -609,6 +593,8 @@
|
||||
|
||||
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": "cli.js" }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
@@ -619,18 +605,24 @@
|
||||
|
||||
"cmdk-sv": ["cmdk-sv@0.0.19", "", { "dependencies": { "bits-ui": "^0.21.12", "nanoid": "^5.0.7" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.1" } }, "sha512-Vm6+/up5nQbwGmNw0uYFbXTrbpF460/FhMohTskzzszMtIR/u/78haucSmJWjjp9bW0wxpHXZWa6fk+Lj1tKnA=="],
|
||||
|
||||
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||
"cn": ["cn@0.4.0", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
"cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
@@ -643,7 +635,7 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="],
|
||||
"devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-rc.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-KZCpjRyu+oYHLj/UJogfFlOkWhHVkaEI2EOT1U3NDVXUzLoTyPjqwFxwOrlQxsY6jzRyxcz4EacqboGfhEeYrA=="],
|
||||
|
||||
@@ -651,12 +643,12 @@
|
||||
|
||||
"earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
|
||||
|
||||
"echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.416", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
@@ -721,8 +713,6 @@
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
|
||||
|
||||
"gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
|
||||
@@ -733,8 +723,6 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
@@ -747,14 +735,12 @@
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
@@ -781,8 +767,6 @@
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
|
||||
|
||||
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
|
||||
@@ -857,6 +841,8 @@
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
|
||||
|
||||
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
|
||||
@@ -873,12 +859,12 @@
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
|
||||
|
||||
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||
|
||||
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],
|
||||
@@ -935,16 +921,12 @@
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": "bin/cli.mjs" }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
|
||||
|
||||
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
|
||||
|
||||
"rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="],
|
||||
|
||||
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
|
||||
@@ -955,6 +937,8 @@
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
|
||||
|
||||
"shadcn-svelte": ["shadcn-svelte@1.7.0", "", { "dependencies": { "cn": "^0.3.0", "commander": "^14.0.0", "node-fetch-native": "^1.6.4", "postcss": "^8.5.10" }, "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "shadcn-svelte": "dist/index.mjs" } }, "sha512-AK3ZsYOL8PN/RxcEBCHGvQj2TrLfaaBsbw0QohyEb3nn/yPNDRC/+9iSjnTp/uKEe+tAi0tBxB/Yhzno0N7EEg=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
@@ -973,9 +957,7 @@
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svelte": ["svelte@5.57.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg=="],
|
||||
"svelte": ["svelte@5.57.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": "bin/svelte-check" }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
|
||||
|
||||
@@ -1005,12 +987,16 @@
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
|
||||
|
||||
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
@@ -1051,6 +1037,8 @@
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"world-atlas": ["world-atlas@2.0.2", "", {}, "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
@@ -1061,16 +1049,16 @@
|
||||
|
||||
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
|
||||
|
||||
"zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@maplibre/vt-pbf/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="],
|
||||
|
||||
"@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@sveltejs/kit/magic-string": ["magic-string@1.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
@@ -1087,6 +1075,8 @@
|
||||
|
||||
"cmdk-sv/bits-ui": ["bits-ui@0.21.16", "", { "dependencies": { "@internationalized/date": "^3.5.1", "@melt-ui/svelte": "0.76.2", "nanoid": "^5.0.5" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.118" } }, "sha512-XFZ7/bK7j/K+5iktxX/ZpmoFHjYjpPzP5EOO/4bWiaFg5TG1iMcfjDhlBTQnJxD6BoVoHuqeZPHZvaTgF4Iv3Q=="],
|
||||
|
||||
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||
|
||||
"eslint-plugin-compat/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
|
||||
|
||||
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
||||
@@ -1103,7 +1093,7 @@
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.18", "", { "bin": "bin/nanoid.cjs" }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"rollup/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
"shadcn-svelte/cn": ["cn@0.3.3", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-OEiyJkSpYm2kOuNOKQ+c2wmzZRG8orrb9fnObtHTbCGNx/E9KLnGcmcocItOzREKk5KzQN5I2e3BUCj563KEpw=="],
|
||||
|
||||
"svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
@@ -1113,6 +1103,10 @@
|
||||
|
||||
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
||||
|
||||
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
"baseColor": "neutral"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
@@ -12,5 +12,9 @@
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://tw3.shadcn-svelte.com/registry/default"
|
||||
"registry": "https://shadcn-svelte.com/registry",
|
||||
"style": "vega",
|
||||
"iconLibrary": "lucide",
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle"
|
||||
}
|
||||
|
||||
5166
data/call-of-duty.json
Normal file
5166
data/call-of-duty.json
Normal file
File diff suppressed because it is too large
Load Diff
620
data/cod-achievement-art.json
Normal file
620
data/cod-achievement-art.json
Normal file
@@ -0,0 +1,620 @@
|
||||
{
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f84ba26b1844819ea9e52aef0d0570559b57a75a.jpg": "/game-art/call-of-duty/achievements/238b785e1fca8f84bbee.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e3b18cd1c090368b0386de8cc46f5a169c4038e9.jpg": "/game-art/call-of-duty/achievements/668be15f0347459b94f2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0a2812d34a7fa98e20285368bc1a763bd792f889.jpg": "/game-art/call-of-duty/achievements/5cd7a661f6d3e7e6e1bb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/584c74c05982e10c4441dd65324f277b7539eae2.jpg": "/game-art/call-of-duty/achievements/3f54bba6d8f7de2e2284.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b1b1349e22f48972109b743d520183ba70caad3c.jpg": "/game-art/call-of-duty/achievements/a9236535546a8dbc79b5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/aa159223f265ab937d7d5b6690ddcf49020a2cbc.jpg": "/game-art/call-of-duty/achievements/7535dcc4298af27198c1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f2edbcfaed969439c62f7a4e6f1eb068b413e158.jpg": "/game-art/call-of-duty/achievements/8852bd4a99d6ccc75845.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e9d07584f66650764670664404079276b4a3406b.jpg": "/game-art/call-of-duty/achievements/d49877c3e2ce9ca95bd9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5b0accacfbcfa0396cb41f268b5ad8d9a65c613.jpg": "/game-art/call-of-duty/achievements/1826f646d000287dc1f6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e8d0caf4122d896eb05024593bb3b66dadf4e88d.jpg": "/game-art/call-of-duty/achievements/652149e0966e1eae6125.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/066c88a94e5a4b27555d5c643a08859ddf915d0f.jpg": "/game-art/call-of-duty/achievements/090152a9c71b3a45e225.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/8557b021165d25955cb8c9fef04bde10d9c73a99.jpg": "/game-art/call-of-duty/achievements/19009908c84ec2cafc6d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e63f09bb9b40effe11802afce07b6e9a90571724.jpg": "/game-art/call-of-duty/achievements/e20d161a6df6dc45f756.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/77911ac60f573a18fe2b136fe366c6eed30fe108.jpg": "/game-art/call-of-duty/achievements/3805fbfc6e08a1eeab92.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/8fb4508be92820c78ff69f0e69546de0d9b76be7.jpg": "/game-art/call-of-duty/achievements/a647123583f71660a8df.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/052772410645d83c3fce7b03c9467b66ccdf0213.jpg": "/game-art/call-of-duty/achievements/2bf1bc39e772950369fa.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f6e25d8f4ebbe3166dfc3b58c34867f268f2d033.jpg": "/game-art/call-of-duty/achievements/566ea9ce791bf9abe5a3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/54d9340f91dbdb3e22ff5fc256ce30d0fa6a963c.jpg": "/game-art/call-of-duty/achievements/fdfdce2ff535cf32cd0a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/82c5e869cf17efa57d7c2a6d19903cb2aee7ff0d.jpg": "/game-art/call-of-duty/achievements/c3e15cc571084bd8e032.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/19dcec07495607501ec6b251e2b822be1b60f0fd.jpg": "/game-art/call-of-duty/achievements/3a814d01d4b72ed78b86.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/2af436c800d1b2343c79bb8cfec3517a896b2074.jpg": "/game-art/call-of-duty/achievements/ca1b094aada4c4d3e3bf.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/6e962666da615b8cb3821e60f3f104b270338e04.jpg": "/game-art/call-of-duty/achievements/e09047b22965850447f9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d013df6d049ec65340b44f297ae5e181d10e4b49.jpg": "/game-art/call-of-duty/achievements/04af8a32026bacfca67c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/5e4644a4b38f3fc47114804ec397ea4700629285.jpg": "/game-art/call-of-duty/achievements/b9c0bf5f5afcc39213c9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/75798a0d173926d2ee97d71bca3ec380927d104d.jpg": "/game-art/call-of-duty/achievements/02bc128c11c94faf1c9b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5feb12f310b6feb51db21f92cf62e53ba3f6521.jpg": "/game-art/call-of-duty/achievements/edb0787878d0b6e8e696.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/4a90e8dda5c440eb25d33a6f495edbed0140d7af.jpg": "/game-art/call-of-duty/achievements/a57766605831b6778bf0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/35e478ea8071694ca9be384f361aea9c2928c420.jpg": "/game-art/call-of-duty/achievements/350f4b45235fdc221371.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7047eecc9292d4c18462340cb7cbeb417f35b0f7.jpg": "/game-art/call-of-duty/achievements/68dc121e074880e68595.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c51d6a0a1a54e2b152d000fea518a0e6359dac4b.jpg": "/game-art/call-of-duty/achievements/5297acb4f4da5e454251.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b6bedf6b9fb9a8dc846b8a0d178bde44ab140f35.jpg": "/game-art/call-of-duty/achievements/40ebfc363b9df8683469.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7d2b7d0909fed0934b8958181b8e6e3834c31698.jpg": "/game-art/call-of-duty/achievements/5772b839a7ac1b2099a3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c531aac734c62f81aea8b4a3cd0d393b3da7e4a4.jpg": "/game-art/call-of-duty/achievements/3db466d8d7ea4e0ce404.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/a00d833009c4c2f5390f3f6750a341c3ded2e3f4.jpg": "/game-art/call-of-duty/achievements/8a1dc6e2c75c02ca2d9b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/904509f71a94d27bd02c8c59f735ce9625cc1c49.jpg": "/game-art/call-of-duty/achievements/078f7c246f6c0bdd0193.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c6bbe0d3d22db00af800f931976e8003be50ee06.jpg": "/game-art/call-of-duty/achievements/80472754bf73ca7d58e6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/21177bc1209e70be184fd480004bb2082c27e428.jpg": "/game-art/call-of-duty/achievements/bec752b67dc9ab1ca236.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/2f1b3a3deb73182554d30c5e6240bf2abb7e9137.jpg": "/game-art/call-of-duty/achievements/4ad516df8a73d5904575.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/414eea359802851cd50bf577d6be3d814988a88a.jpg": "/game-art/call-of-duty/achievements/9989fb508e8136957f1a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/1e1797ea80faffaebab8aac35df9b5f737a3a740.jpg": "/game-art/call-of-duty/achievements/013fa5da7e485ac3242c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f8d225e74c13b54a4be33d1db176424518cae516.jpg": "/game-art/call-of-duty/achievements/1e08a1136a06591bcd6f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/294382b96591ade0f7a3dd35f69aa64cf8e0d14d.jpg": "/game-art/call-of-duty/achievements/f56176dafd96a78a3c03.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/6cfdcdff1c60ba4087436650c376379668611765.jpg": "/game-art/call-of-duty/achievements/907d50d6eb676aad489f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/db3ebdb04f6d8dc9208cb41d65bf0dde71e48ee6.jpg": "/game-art/call-of-duty/achievements/1af5f1612db6eb49ec8e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3b4ddc46340c4ee914c43232708e4f0b99d1f433.jpg": "/game-art/call-of-duty/achievements/6ffbbc679ea915877be5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/93c7772577b5cbf3385f4b505017684f0d27a77e.jpg": "/game-art/call-of-duty/achievements/cb00bccd7155c5c79939.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7d430d7cb0e27f1357148e8c57c1ff53969e9789.jpg": "/game-art/call-of-duty/achievements/5d0dc7bf0c89d2cb7a4e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0fd21bfea7ae6cea7d19beacda813546b8aa2aac.jpg": "/game-art/call-of-duty/achievements/dbaad790ad126bced253.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/9dad5b5851b3b41c654b5b64bf17289c2bd25e5c.jpg": "/game-art/call-of-duty/achievements/74b743415e4823b9afbd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/fab6950999928104e43aafbed1b875bd8fa535ae.jpg": "/game-art/call-of-duty/achievements/afdea16426b4c756e4bd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5d3e15aeb110c232b25d35badd27d3723cfc79f.jpg": "/game-art/call-of-duty/achievements/34bd87a05e2d5c87308d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e3008c9f8bc996aab7ccd1c03252eb489eb2a961.jpg": "/game-art/call-of-duty/achievements/8e7cbdfd2248aa26d16e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3e370768bce2246e4a34e2b95a63894a8f3ad300.jpg": "/game-art/call-of-duty/achievements/af00a2e00f4d494e1bc7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/221f9b2e1a6562e0f2dc99518bc133b6d9e28845.jpg": "/game-art/call-of-duty/achievements/c45cafdc867922a8fe07.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0703c58b9cdbc32a607b198dd6df75aebbda4e0f.jpg": "/game-art/call-of-duty/achievements/8878c7ee8bd4313bb88c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/dfffdca7f1eec0f51c4d1ba6c7794a98d234ef36.jpg": "/game-art/call-of-duty/achievements/ef3dc83102c12eda7a11.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3924b4c5269c1db5e3fe069ef1b14bbdcdd03b7b.jpg": "/game-art/call-of-duty/achievements/e8811318dbb354abc472.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d959ef75d60450e2eeb53099cd3ac21ed8543d89.jpg": "/game-art/call-of-duty/achievements/a8f55324896c83dac16c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/aaf807e4357992e7926fa8e37d994dea52716f5d.jpg": "/game-art/call-of-duty/achievements/ef83905f63cb07adf3b4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d8f4344e51350ddbcf8594090fb5fe716a1cf87e.jpg": "/game-art/call-of-duty/achievements/18074a3c513c6266b0e2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/a2f171ee14933001a4126270eedbfbbe51f41c0e.jpg": "/game-art/call-of-duty/achievements/7767a2b17c42543b9a15.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/945ede3b335f123ae7f556d8125a0f957c04ad30.jpg": "/game-art/call-of-duty/achievements/10f6dc026886bf6d8fed.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3aa709991192b7df667d76aeade231ac5d5a491e.jpg": "/game-art/call-of-duty/achievements/586ae3efa7b170f43654.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/ae1ea1ba275ee59ba9b86bef5b2d22488f9b5c8a.jpg": "/game-art/call-of-duty/achievements/8623c2c97d51409a3fcf.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/33bf5941093863c085a3cba80d03004258b2bdc8.jpg": "/game-art/call-of-duty/achievements/4bb2e298d674f00435e7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/4b1a75db52ecb1484674cb4c8fd10ae4028d9b0e.jpg": "/game-art/call-of-duty/achievements/4558c32b182fb8e840ef.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0ad5d6255b43a6d24b4ecc7ac5d4b87289e24ab7.jpg": "/game-art/call-of-duty/achievements/8bc453728da64b88935a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/12112302162a4ca970c616a9b4d6c777d0e7e623.jpg": "/game-art/call-of-duty/achievements/12bc6d970ab690b96ee4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/02c69139d83d887e8bbad07330f7c076e4181a28.jpg": "/game-art/call-of-duty/achievements/bc3aa66672504284d00e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/f84f81208278703664dbd530e5e3c6c5c71daf13.jpg": "/game-art/call-of-duty/achievements/613c8f97eb5108156e44.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/0dd80a8bb848350d4b8a0a18dafb270652068384.jpg": "/game-art/call-of-duty/achievements/e2769289d1b1467be1b9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/0133fda3a5fa19b1165259600644d25d981bf479.jpg": "/game-art/call-of-duty/achievements/334bacf4dad73d50f310.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/ab8d4ccecae718e5314300a6716d0c3623ce0dc5.jpg": "/game-art/call-of-duty/achievements/2d642533372b4304001b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/0980082fe85ff0d25840700b6ed2e52504956d1c.jpg": "/game-art/call-of-duty/achievements/4470c1371d884636304f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/3bef8dba7289f3eb05a69fcb97e252b13a44a492.jpg": "/game-art/call-of-duty/achievements/dd9e2b94ec07c8fabd4d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/910637c7094876116abbb2e786652120fb4f65f3.jpg": "/game-art/call-of-duty/achievements/a142154c721fce62166a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/df51dbfc2880d1d6053175b6160efb42a71d0a44.jpg": "/game-art/call-of-duty/achievements/5c7e46bb2e598bd62c8f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/f43d08d5e78681254c6d1d83edea8a0a3ed6d50b.jpg": "/game-art/call-of-duty/achievements/00e8802dfb3a6e23b510.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/eb2f8a890a5a1c09082c7f31d6907ad9852b8d6b.jpg": "/game-art/call-of-duty/achievements/a6a776d25ae0dd14f3f7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/1814c8da565a05aeb72c3a260dcbb53722b7349c.jpg": "/game-art/call-of-duty/achievements/0292942faf8b9048ae7b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/69f41d5d43ccfe5b5491392eae1b1ec9e2eec557.jpg": "/game-art/call-of-duty/achievements/18bdee8e74450d4081c5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/7f6c52357f7ea9a087edf50427d002b2544c2321.jpg": "/game-art/call-of-duty/achievements/7344321d980fae59ea9b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/bdb4750ed6445a7fb6e9a588ad7c7f25595bbfe3.jpg": "/game-art/call-of-duty/achievements/64144e08856de27591ec.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/6a8c99571f5eda2d2e43696189f23ce5fea933b3.jpg": "/game-art/call-of-duty/achievements/babce0e82a2404d14780.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/774b2da404ddc3a9056c8dff17cf67aa8f8abf6c.jpg": "/game-art/call-of-duty/achievements/6635d754150bed1f4a52.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a9e6270ce3479af37f5ace927b7eac23f014ad19.jpg": "/game-art/call-of-duty/achievements/7f8eaceb05beacb38982.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/2958048adcec6bf1ff5ada6285f9525e76edaf03.jpg": "/game-art/call-of-duty/achievements/f2c28703a829c38a7ef7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/d17d6491ab01329bc3585da8b36dc354308983d3.jpg": "/game-art/call-of-duty/achievements/26bdf2f4f35b581412fb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/3bdf965dafb49cb647a31cc21baf86572df84cfa.jpg": "/game-art/call-of-duty/achievements/18e91bf129656ecfa143.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/e27810ec54b75392b3539ddddfc6ada5de42cc36.jpg": "/game-art/call-of-duty/achievements/65b1a4886f0cefe57fcd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/03ee61e8a78cbd8ec3b9dfa592d20d3433ef8759.jpg": "/game-art/call-of-duty/achievements/f1e208e8d69e884977d3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/5d8f8186eb5516caaad9138a3c7cb9b85e0059e0.jpg": "/game-art/call-of-duty/achievements/536203b9c317cef8f921.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a064792dbe470c7ebf93c07df2c0125b8199f68f.jpg": "/game-art/call-of-duty/achievements/f43afe71b1b2f4c99be6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/1dbf014f724756b8ea85f94879089ce63344b453.jpg": "/game-art/call-of-duty/achievements/0a02f70e810fd5c73ed1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c5ca4e900ec711fbbaa15322bcacca74057e92ab.jpg": "/game-art/call-of-duty/achievements/c46c3d53d7617ce2df29.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/2e406d05afd666007c927c29c7cb8df66dcf125a.jpg": "/game-art/call-of-duty/achievements/4c1ee54bbdc0de51a397.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/9dfd28b4500bdc6a148469ad3576fca079768bc8.jpg": "/game-art/call-of-duty/achievements/30101749dcc9e8f00f0e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/53173f0fc6c55779ed242f82ea899194c58e1b6b.jpg": "/game-art/call-of-duty/achievements/cf15357082a813202247.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/f6699b8c585d8e0f74d946382dc9a6b944668e1d.jpg": "/game-art/call-of-duty/achievements/4d80ad746889b6e51b11.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/6952628963694dbdb8c8b2149bd5d0b13d25a512.jpg": "/game-art/call-of-duty/achievements/f5e0217080c8e78a385b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a452e8f7a3986dfe5ee2dcfd5997db72e36b1908.jpg": "/game-art/call-of-duty/achievements/3ced3d4600e56bd1b8f5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/5ced4efe9b5745469076c965e0fa878c1579b388.jpg": "/game-art/call-of-duty/achievements/68b751f778d9075b3438.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/dc92dcfcab652a8d51315a3e9abc8a2b87db664f.jpg": "/game-art/call-of-duty/achievements/687438517f482f07c3cd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c2a4e5c077eca0eb3913fccdf1cd502444859717.jpg": "/game-art/call-of-duty/achievements/9ac358819352bb811725.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c1683a5aaa4459a4c30a64c7114581b5e5d535db.jpg": "/game-art/call-of-duty/achievements/f579808080eac5263a19.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/4c18e7babfce7b1e9a87a91ee8f3d906336f0d66.jpg": "/game-art/call-of-duty/achievements/34fdac009785f70f3a10.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/81c1462e80a491628924060da29d6b28fd04eab1.jpg": "/game-art/call-of-duty/achievements/d1df330a62288f93daee.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/d17a0f39120c0fe05b991f50c8158ccc3a0519db.jpg": "/game-art/call-of-duty/achievements/35b09deb66bebeaffe24.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/cfffa291c82382e008c2b1102864d8efb2bb54b5.jpg": "/game-art/call-of-duty/achievements/9cf4c992ca6581f3188e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/f05286d7cbdebfe28a34155a8c3a87158555f940.jpg": "/game-art/call-of-duty/achievements/a6c2e17cef39da2ae9f2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/0124448f02f1564f79b5b0db570b1f736a6e10c0.jpg": "/game-art/call-of-duty/achievements/2ec7b277532d83f8bc00.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/9cb9fed7bd89695d81b2df2a78e54e7496ff7811.jpg": "/game-art/call-of-duty/achievements/414383f0814d172be947.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/5ef50a6c4cb152dd5a599d9edbfcb8b01800b208.jpg": "/game-art/call-of-duty/achievements/9001d16125fc11453433.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/3e1149b43b4a4f9173e3a5bc8b8eb6020745430c.jpg": "/game-art/call-of-duty/achievements/ec4fa15d8342ffa088be.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b4149289f16f86509f23d4a64d3305c8949b639a.jpg": "/game-art/call-of-duty/achievements/07109eeca8b226f66582.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/4198c3c5a45727f7fbd1b56a0550b39bd2c7be41.jpg": "/game-art/call-of-duty/achievements/5e6d7390b0040d17f382.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/aa3095124501a0269a16d1ca1d43ce7a576dd097.jpg": "/game-art/call-of-duty/achievements/fe71b44a31aa3bffaba1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b5e4c73252d3bcf7adc023659eef75227d469a4f.jpg": "/game-art/call-of-duty/achievements/ee23c7268ede05849376.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8688428ee57a31db06a01f617b27fa594b2e1768.jpg": "/game-art/call-of-duty/achievements/e55f4f23ab46a034b0e5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ffd7006549012bd3446a63cfb6a5ab02ce9c4b23.jpg": "/game-art/call-of-duty/achievements/d0628ddac4b96e0acbff.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/44b401b61ef76da507f4f68ba70529b79813c434.jpg": "/game-art/call-of-duty/achievements/4bdfcb1087e540bf298b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/58b1b329fc69b4575f51890b299a613a66168b9f.jpg": "/game-art/call-of-duty/achievements/ed3aa2c7c084d6206576.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ba3c0b4a33c13a614aabcf3689a5de688fc2ce8c.jpg": "/game-art/call-of-duty/achievements/b7a6b37b1a42b24dbcc8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/014b27d6529a2962da3b0885a0f381386d315fb9.jpg": "/game-art/call-of-duty/achievements/92fc55d6e72e1a9562af.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/57137bee776f973ac60b5b78f12368a40eb668a1.jpg": "/game-art/call-of-duty/achievements/39917528f97b83a77af7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/402d492afcb268e1c782a2d9b0efaf6a888ac043.jpg": "/game-art/call-of-duty/achievements/d9369ed45d2404a7042e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/7654f81ef6a8904b079d0efc8979bfb70fe84031.jpg": "/game-art/call-of-duty/achievements/21a06303c02c87795486.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c115776df2f978c2fefeaac726905b4562b1f2bc.jpg": "/game-art/call-of-duty/achievements/aafd004c36a22f406f02.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/20e2eaed04069ab2d120e3ad59ff5962811f873b.jpg": "/game-art/call-of-duty/achievements/5718a95c950469faebf5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/9024a82b5900604aae70224d142aba561b03f32d.jpg": "/game-art/call-of-duty/achievements/94a082f60893d4cac5a3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b1255b28f31764890bd8e091fbbc8e6cc733b8ff.jpg": "/game-art/call-of-duty/achievements/ecf176d404db072b1191.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b4fa1dc3da619e91ee1a9451bee10f76fb1f2fff.jpg": "/game-art/call-of-duty/achievements/d1b71242d74e7049b1d9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/88516669f90dbf453e51ef71a80283c816ce8e39.jpg": "/game-art/call-of-duty/achievements/95151875ccbdf29fc0b3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8870a661c34b3f30442b5b739fe0efe4cf6e7c88.jpg": "/game-art/call-of-duty/achievements/acc3d4a27a712aa33d5e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ff6956ca387b85abc2f8fd9d556d3171f365bec2.jpg": "/game-art/call-of-duty/achievements/a296ebf4142d08eb8171.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/affa528c8efa5ace55e526fe1056f1c0c880b3a3.jpg": "/game-art/call-of-duty/achievements/34faa2286f859e4ef883.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/e8624092dbc511ce22738709002767687b1318ef.jpg": "/game-art/call-of-duty/achievements/5ab29e7e5d2638bfd973.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/2d084cd19f869118a54e873560b7cc72f5958ecf.jpg": "/game-art/call-of-duty/achievements/68e944579f95a6efbce8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/008cfb3cb67e60096605cca5a345940459dfaef0.jpg": "/game-art/call-of-duty/achievements/d4ffdfc1a9c180cbbec9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/23c6604893406d0c1de71cf902b83036d5195cba.jpg": "/game-art/call-of-duty/achievements/49c5e3803cd325081584.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/78c51356d1ec5d11a5859b571406cc13a0437f81.jpg": "/game-art/call-of-duty/achievements/da67236546bab6d710ad.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/bdb32d53b37dfb7ee8697f33d6d8b562f6d9bb19.jpg": "/game-art/call-of-duty/achievements/a04d785f7384b8ec08de.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/6e23e3219dac8f448d9771f0c85252c4e51b53ea.jpg": "/game-art/call-of-duty/achievements/845f6e30ece4d5830bc7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c6bd87838a42ad9105417c8206ef03befc063e1c.jpg": "/game-art/call-of-duty/achievements/c07284fe0ce0ae0cf020.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/add7001a22ffe44d0eb1a231ba8b3678bf607cb0.jpg": "/game-art/call-of-duty/achievements/fe8363fccefe3e208c56.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/60a410c18c0961922c0b6184b6cf560f9a104e9c.jpg": "/game-art/call-of-duty/achievements/04536f0a02897beda365.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8d9968d9592f55f89feae4de7eec43727232a39e.jpg": "/game-art/call-of-duty/achievements/a8de98585c8315e0d03c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/080951c14ab06932c4f5c1bd8d3b3e6c1bf39714.jpg": "/game-art/call-of-duty/achievements/be8130070b07f1d7de8d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/87d54d65d1e289e2067c9c3242826efc9386c284.jpg": "/game-art/call-of-duty/achievements/597da19410c37ba79471.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/bd13209f20a3a91977efed0be33feb57f475b0e6.jpg": "/game-art/call-of-duty/achievements/f52e922e57b3e985f75e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c512a6fdd4046539d974a46fa800c78bffb386be.jpg": "/game-art/call-of-duty/achievements/08063cb881c78706d4da.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/13dfac69945f5592f310d1c8691ce77efe65cc43.jpg": "/game-art/call-of-duty/achievements/09d0c54a2098a7c62b55.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/d52adc9ffe0a7e4afdf34377bfe630f5974ae46e.jpg": "/game-art/call-of-duty/achievements/1f03769c740daab1c248.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/74d98845ae2afd34468df4636dcfc8e7ae3b01ff.jpg": "/game-art/call-of-duty/achievements/0a97ffde899d5d8a3892.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b83aab19a451e06dcf837b7c714250d8bdc0f2e1.jpg": "/game-art/call-of-duty/achievements/93f9eff015cb69ddb6d8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/4fde690288c1729e309bb6aa8d699d3ee183efee.jpg": "/game-art/call-of-duty/achievements/6c16d165aeeee4f7f8d5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/608f862c91acbd5070827e16a74acd7e8301dc1b.jpg": "/game-art/call-of-duty/achievements/46401b6250fb1b2d8743.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/0c6091bc69bc52636473f15b8846c3121e4f9692.jpg": "/game-art/call-of-duty/achievements/1c25fab35bd9506e3ec2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ebfb4b1a4669815e2abde15a1f5bb133b05a9ab3.jpg": "/game-art/call-of-duty/achievements/b9e3bae0f6ed760e6ec2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/25c2af163b6af528fae74383bfd03d9a2a8517ae.jpg": "/game-art/call-of-duty/achievements/fc49cf28f27efa9eb0d7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8df32a41f76f24ac6e8e7b324ef4e9fa9f827fee.jpg": "/game-art/call-of-duty/achievements/31c5acd1d552c3ada03e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/938412147809b3b8d782a0c6b1354980b7a76466.jpg": "/game-art/call-of-duty/achievements/33c4b3d1c6340964894f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/02389e50db94d8103d71e3e26532659526caf88c.jpg": "/game-art/call-of-duty/achievements/f38810da50f22d46bcb8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a398ddce474468a1e86dd574eb2ea0d4376c3054.jpg": "/game-art/call-of-duty/achievements/de27cce95c4d870ba405.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bc18770b2f678ce079eb645b346bc382b1413701.jpg": "/game-art/call-of-duty/achievements/b518642efce084c12125.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/eee8e470d8814bcd7656b725fd69a2befa9b85aa.jpg": "/game-art/call-of-duty/achievements/76bb5e0379a74272a2b8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bd9bc54978cc4e496bd56b01b9310558b445ee66.jpg": "/game-art/call-of-duty/achievements/aa77b61181dd612fdc28.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/92780cf10aadd423c0220c712bda43d771c28895.jpg": "/game-art/call-of-duty/achievements/211aa8aae0f177b5e529.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a50d913586131c5361a061484ab1af89a55bf500.jpg": "/game-art/call-of-duty/achievements/55259b5c8a012ee36742.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/43116761fcecd5ee2e779fcf4a44671d059dea36.jpg": "/game-art/call-of-duty/achievements/66cab33804509ca93ec5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/53aa6769167cb791c8937d914eea6187b457206c.jpg": "/game-art/call-of-duty/achievements/774f9f97189ec253aae2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bfa0592d8e8afd7223ce09077e90eb96f18ef54b.jpg": "/game-art/call-of-duty/achievements/fa8d7a05d505c8a169cb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a24743f65dd411c593ce4aeea2c0ddca531b2f97.jpg": "/game-art/call-of-duty/achievements/c27becc53b17266aa07a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/c33f19060684bf97b90c7a2b5e4acd45282a4f41.jpg": "/game-art/call-of-duty/achievements/f8f9105abc5a19505251.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/9fae3eaaeb9592ab9abb923853648db26f060727.jpg": "/game-art/call-of-duty/achievements/d8713fdad3cce9149fbc.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/d2309eb8d02d6cba6d5c9066bc290f81507724d6.jpg": "/game-art/call-of-duty/achievements/f78b311c634d024f7212.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/58a5787ec19de789f22118238c219166b5879c2a.jpg": "/game-art/call-of-duty/achievements/fc0511b8a8f451e31b96.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a4db0a6179ec7e43ab2e9d528dd800193f3a3f2e.jpg": "/game-art/call-of-duty/achievements/a8d8e785ea66bfd50395.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7df87b2e05cecca9b64b72379999f3ec684c90e4.jpg": "/game-art/call-of-duty/achievements/5a05b2edc9a6cf26c2b7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/847f129434ca57fea22fad195dd050f7ac84b26d.jpg": "/game-art/call-of-duty/achievements/05dc7fab4c1945a38c89.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/3f2ce46fe0875b0e20bcda66dda170cfaa2bd326.jpg": "/game-art/call-of-duty/achievements/a184150a8351d169bea0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/b1f82e4fdd456c755e74831dbd9656f8513cb02b.jpg": "/game-art/call-of-duty/achievements/ea8506d6d5ecb4d83d41.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1df05bcc60677d4079a0477f358ed2969bfbc9c5.jpg": "/game-art/call-of-duty/achievements/62a4e28a78e2fbc6620b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/ba24d697d0b40477fbe3c8f910b998709e571c51.jpg": "/game-art/call-of-duty/achievements/518c2e90a89ad4e18f89.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/0273d8ae279400fdfc70a04193f38a5761aa0c6c.jpg": "/game-art/call-of-duty/achievements/6867da51392b2a7cbbcb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/b22e38d57625347eab217d729128eeb4db0b4b42.jpg": "/game-art/call-of-duty/achievements/042c2e99d89b11b11ce2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/4268c8d61c9b7ba0f3e9d30f5c1b8710eeffbe8a.jpg": "/game-art/call-of-duty/achievements/f1cb256ef89049cee39b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/d631ddab876e49701f239d48c3c726b920e4afb1.jpg": "/game-art/call-of-duty/achievements/05ddeeac3170749eabb7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/35978e3bee44c5f47960bf6402d8017f80e621eb.jpg": "/game-art/call-of-duty/achievements/a6a8081342baa4827e01.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7649a95fc52417c32bb68ba9b6e2b5afbb8ed356.jpg": "/game-art/call-of-duty/achievements/5b8bdad757dcbb5910df.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/350975aca49822ad5333b344c09fe5731ca09a50.jpg": "/game-art/call-of-duty/achievements/88eaa22dcd5346871a17.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/9a1954acd31b60a475590b67bdd5076b1a9031ed.jpg": "/game-art/call-of-duty/achievements/189eba245560bf593d4c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1366d36a636b584ef7ada54a563ce7d0bfe9ce46.jpg": "/game-art/call-of-duty/achievements/cd69584aaea92839e157.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7d4e5fbb281349fdfdac177573815bf29b11c0da.jpg": "/game-art/call-of-duty/achievements/e75c0a817729b4502215.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a3f98881e75528718bb396a93f5eda22e4bf89ce.jpg": "/game-art/call-of-duty/achievements/d465f49420763f09c12b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/527e6f6f7a6985ccd146095c86a95a07972cc5f3.jpg": "/game-art/call-of-duty/achievements/be3c201d42ccf528c417.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/e2f9814f32a4f19b0662cacf9e60c7f050aaf788.jpg": "/game-art/call-of-duty/achievements/982416111ad59fb0ee5c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/166d66a24f482cc50fe730defb60b5b594cd942e.jpg": "/game-art/call-of-duty/achievements/28b83c917f642ebf63bc.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/2dbc27cc5cc21467c3721ca8b8ec7367bdb73626.jpg": "/game-art/call-of-duty/achievements/edef7fdb97fe10818f0a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/f538ea6d78ea1645dc54c04af031ccdee6568984.jpg": "/game-art/call-of-duty/achievements/270e2a421a005b5dddfe.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1f92129f1dd043e20858106244a5fc272f686b86.jpg": "/game-art/call-of-duty/achievements/92e43364427d5e9059b6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/6d7a6e51581af0ffe24116942136f2d81912fd5f.jpg": "/game-art/call-of-duty/achievements/881a6dcdb647a5456dbe.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/bf9871d14aa2080c53c6ad7bd8b44fb698ce9858.jpg": "/game-art/call-of-duty/achievements/cbf2768728500d2aea68.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/be9985fb5b0b3ec7f6090fd6e9bcc6999ff47c39.jpg": "/game-art/call-of-duty/achievements/5e2480d30de157fa3fc9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/2a65ab1924a9aa0973853c89cbf6de1b6a3f1b56.jpg": "/game-art/call-of-duty/achievements/cb01eaaed54970697e75.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/b8d2965a03da8768a690adf601a6125275c65954.jpg": "/game-art/call-of-duty/achievements/5442fbdf2f6974acd90d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/92c128fb406db30bdb68b246b95f73e980bd0007.jpg": "/game-art/call-of-duty/achievements/7c4f4b2fc6b80acf9e3a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/dfffd590f99c950a8f42737602bf5bbcde05ef51.jpg": "/game-art/call-of-duty/achievements/c114604b3ea2245bd5ff.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c26807e278a807bfa9a9e194132debb994e700d3.jpg": "/game-art/call-of-duty/achievements/c720a215a01a41843917.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/f86efa50cdb567bf7fe1b3d732bfc90f28c2d85c.jpg": "/game-art/call-of-duty/achievements/2e69343cde4ac31be831.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/57083ea13b16f8cddc531bbceb75d6497690eb82.jpg": "/game-art/call-of-duty/achievements/1ee9ce141d49f68403b1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/68cff2f93656ee35858621a27f45df5d637d6897.jpg": "/game-art/call-of-duty/achievements/92dd45baf84e04330fea.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c5c38a113aa37738d0238d463fa76db953406524.jpg": "/game-art/call-of-duty/achievements/cb6f70177e029d0bb502.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/3d5e62983b187417073ceef554847f9d00ce394a.jpg": "/game-art/call-of-duty/achievements/ba6f3cd833455f3b90bb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/029d30e48640e9e588e7c254d42d735760f2e01f.jpg": "/game-art/call-of-duty/achievements/3a2f2380e76d6d2d4d3c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/40389e331060b203357e57dd61a3f363b9979475.jpg": "/game-art/call-of-duty/achievements/b47151eba05bf44da97f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/7ff96353c673619e2d2842385084d09868959e91.jpg": "/game-art/call-of-duty/achievements/561a880efddcee091658.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/ccb127bdaa673e31efb9d70454119e02ae2ca766.jpg": "/game-art/call-of-duty/achievements/cae6882a0941fa16921a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/087aed50312fb462e05211ad90905406b2409b5f.jpg": "/game-art/call-of-duty/achievements/4804a593f80db9e96f61.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/d72dc9e5f3b89f6a69e288de7f51fadf101ad876.jpg": "/game-art/call-of-duty/achievements/914c34dc12a4487979fa.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/6f52f66ffde12ccab6fd9dbe2a0a6e352b691b46.jpg": "/game-art/call-of-duty/achievements/06e0022dd9d28a55dd89.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/0c41ca34b961eae7baed5cd9fa22206f7196da5a.jpg": "/game-art/call-of-duty/achievements/c723c6e6e732d58c5c93.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/fbf0db3c197a2865ff8a546cd12950491b59eab7.jpg": "/game-art/call-of-duty/achievements/f6881f12ab401ed7ed80.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/91ca2c866c87108ee73388103a4e14c95e9e5d45.jpg": "/game-art/call-of-duty/achievements/2f9a0970dc33a4e8f3e7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/0246b25415a4eb83d6dae41bbefe5800e9c758b9.jpg": "/game-art/call-of-duty/achievements/90438c063f19119ea25d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a2d770c6dbc5f58106ba15255c1815e2fa6bfb65.jpg": "/game-art/call-of-duty/achievements/c85019f07f9c65e315dd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/51f85202f9407ac9c89c1a23c62717454aeed483.jpg": "/game-art/call-of-duty/achievements/b99c72128425236b1035.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/d946bb02b2001926c36d1c8b4bcf2e9da2a7df9f.jpg": "/game-art/call-of-duty/achievements/2a5be22b7dc548a2822c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/ca842a5338ed929ed9e82bfa95c3dd371d2197a4.jpg": "/game-art/call-of-duty/achievements/0bcdb9a4c85d98296f13.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/b9cc357bf7dff52609164ffc6ef9357fc29e49b7.jpg": "/game-art/call-of-duty/achievements/14804cc8bd7397a0ff89.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/be864d18461b9998bbbb0a8d3d16a6327c151222.jpg": "/game-art/call-of-duty/achievements/4adda62a9920bf5ed7db.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/eb0573e8cc9f1a3878d7ef7ec4ac80dfa1761b61.jpg": "/game-art/call-of-duty/achievements/9aea21d05fe9092fd238.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/84caa17a06d06d31adc9931bcb6db33e62c47670.jpg": "/game-art/call-of-duty/achievements/a6d5ae65a7085ade450a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/99a4e0e015ae9be3b8ca65867bbab6650e2be4a7.jpg": "/game-art/call-of-duty/achievements/5aebe7390c44348243a4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/86064965b478de57183d1b43d122f8dcb55bf933.jpg": "/game-art/call-of-duty/achievements/baf8d5dac758f4d4e27d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/647e830027c5674a640ac6e4cd15c2ed182f40ee.jpg": "/game-art/call-of-duty/achievements/917e8bb7b9071f135d07.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a5c165e92b5b46b90a7bd9e6ef1f0e8142c9a9fe.jpg": "/game-art/call-of-duty/achievements/34e5afd793ffa563ff78.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c83af569c6421ec90065f01e5f6ca3bbbfed12e3.jpg": "/game-art/call-of-duty/achievements/6504e1d1cf9bd7c5e023.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/298570669846c5b34f22843c132d7ab3282f28ff.jpg": "/game-art/call-of-duty/achievements/aa7ec52c45ac218e3b5e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/522d635afe5643642ff92e4e847cc2134924ea49.jpg": "/game-art/call-of-duty/achievements/891c8afc4c4d72e9052d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/35e201ad8332ddfbcacceec0661498f86ea7920d.jpg": "/game-art/call-of-duty/achievements/58117f46b67e5f8319bd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/81ba45eba34f972948c411f8fe449149cebdd6a7.jpg": "/game-art/call-of-duty/achievements/ea2c5010e5df6266508c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/698088f494c39ba52fa3901a40ed972197065b12.jpg": "/game-art/call-of-duty/achievements/7e8aff9916e8753b6017.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a62c206a46f6961b66eaa42b93465b4a9acb90d3.jpg": "/game-art/call-of-duty/achievements/f8e7e9067abe8bd97043.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/63b91d1c9f1057775175185dd7ed32a85d7c4024.jpg": "/game-art/call-of-duty/achievements/eb5e344cbc69c8be20c2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a445134fc4d8ebcd88c4509a2e7f4c0e4b4cf935.jpg": "/game-art/call-of-duty/achievements/73aaa55d00fc669decb6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a013f9c37c35c49595a007006016d74058191cc9.jpg": "/game-art/call-of-duty/achievements/68a76bf623fbaaffa1de.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/9b30e1a1fbb4b87626c730dffbabf4b29a12111d.jpg": "/game-art/call-of-duty/achievements/93be31b31e10c70d69da.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/2fb09ee55aa0815adf363172bdd1ecab23f724b2.jpg": "/game-art/call-of-duty/achievements/3e3fa6bfd93d450030f2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/3a2fa1c8efe6e383a891ae866aa8fdaa1841679a.jpg": "/game-art/call-of-duty/achievements/d76d9073568fdb7f1e22.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/5f0d23a405f525db66d5de98217fe178f3085a31.jpg": "/game-art/call-of-duty/achievements/05a8efb5e1e15477d51c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/20755ba1aed7213aafb9678daa6718fbd654fefe.jpg": "/game-art/call-of-duty/achievements/3e8f3d7cb413572aee1a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/050bae232fd990eb93163372db049de1e1667220.jpg": "/game-art/call-of-duty/achievements/a3fa1324f9bfa48e7ffd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/ad68409f7ee2029e76ffc99a24431817e479e15c.jpg": "/game-art/call-of-duty/achievements/0a790da83079d22ff365.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/77c17d48b3bb8f201f1c70106830ac2c93cc2bec.jpg": "/game-art/call-of-duty/achievements/1f569f12ef6bf02e2f60.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/9876a14b1eb1e169200e97ad975ba8a3f30f9dc1.jpg": "/game-art/call-of-duty/achievements/27832d705a5816832e31.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/de9c3a1e8c1e223cc764433967c2f4d6f957e56c.jpg": "/game-art/call-of-duty/achievements/db531ff792ba71a88bd2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/818648ba1376faae9caed0a37b39ee62c4cfdac1.jpg": "/game-art/call-of-duty/achievements/21351a3cca37d9104b8b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/6d61fe4bfae30b393c098e533598dcae13d54e70.jpg": "/game-art/call-of-duty/achievements/e467ca6eec81624f607b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/13e2d09ec3fd46b409918f9e78e08e016028d662.jpg": "/game-art/call-of-duty/achievements/4407c5769f18a5653760.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/a863a128014547ec4ffee3151c84a0f0217a3673.jpg": "/game-art/call-of-duty/achievements/6c1736bbf16d4782813f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/a6103cf19e593a4f72dba733a9587a8b11afdd56.jpg": "/game-art/call-of-duty/achievements/d63505f2c28cd355246c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/3c2cf01172a6ebe981d788ae2a93169befaff5e8.jpg": "/game-art/call-of-duty/achievements/81358b11c5b0cddbed0e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/2e11039befd3a7ce82b8f2a7f082bcbe7fc2bd48.jpg": "/game-art/call-of-duty/achievements/88da698e6c00ecffc4ad.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/fc28df331e97465a79a21e474913c33fd9ad7b36.jpg": "/game-art/call-of-duty/achievements/fd8dea35848fe13e55d4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/686c5d0f87a272597a0b20d9532375fc9e5b7d12.jpg": "/game-art/call-of-duty/achievements/61884f34fcc524b656f7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e52fc0cbbc3344a95f9221fdb4cfe83dc3309f8b.jpg": "/game-art/call-of-duty/achievements/6323591518b8852fecf8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bc113dc8c9b656e3c2d0126ad847a518d8e71f43.jpg": "/game-art/call-of-duty/achievements/8893e5ac9a39df8861ba.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/f58a4c733358e524682fe4bff82e2b1a1bab647b.jpg": "/game-art/call-of-duty/achievements/579c1482e528f86abea0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/4920ae39511c42ad9a0e07493ef88aa63c60b25f.jpg": "/game-art/call-of-duty/achievements/e50138224f098ff2b0e9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/2edc4b06d9a471fee962c1e88ed15af793182854.jpg": "/game-art/call-of-duty/achievements/c2c0bf56904af06f44af.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/6d36e7c98bc7caa26d3c02d4ded63a2a0b3e5f3a.jpg": "/game-art/call-of-duty/achievements/6a5efa3bd9334d5d867b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e0f85322f92ba1ff6567bbc23dfbf89dff58f650.jpg": "/game-art/call-of-duty/achievements/290296290c6b8566569a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/ad615c659c19ba7050c7d8b226d6dfdc19cfa062.jpg": "/game-art/call-of-duty/achievements/57922440c12a64977ca8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/28711f22e4d02183fa3b9ddee726798870d3294c.jpg": "/game-art/call-of-duty/achievements/7614725d8df2f9e2deaa.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/1adeb3c1d0616ed493b878064414ba475a3fc970.jpg": "/game-art/call-of-duty/achievements/68a83b461dbf8944ba1b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/5e1ff8cee3a47d7259264b3e0d672e0c7358f6e2.jpg": "/game-art/call-of-duty/achievements/e1a62db91c1bbee7d6bc.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bfc8834dad242fa7b4e54f965e52e3af764c648f.jpg": "/game-art/call-of-duty/achievements/fb9e970b872359137afb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/adc0de3da240048a1c3ef711e8935298e7048d71.jpg": "/game-art/call-of-duty/achievements/18938e8b05c43850a1d1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/37f79052f673cd66e823b8a2ac0edf46361e9086.jpg": "/game-art/call-of-duty/achievements/de047bf800f352c3ccda.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/90bd08a0ca63a5f797a632c37360fcd7417d670d.jpg": "/game-art/call-of-duty/achievements/cc9a0743649ab4c59526.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/f21d7109756d613aae0c1f5939edd05ff11f497a.jpg": "/game-art/call-of-duty/achievements/f544fe840003238312be.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/c710f5068bd6fcc2e003c9687bf35a4e9eb6ec75.jpg": "/game-art/call-of-duty/achievements/470a0f7cb6ebca39d5a2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/142953876ef39884da73128e38f1e1031da26075.jpg": "/game-art/call-of-duty/achievements/4a5a93323ab645faf0bf.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/44a46d2994af6d8908b1b1a8db56f63e93ebb9d9.jpg": "/game-art/call-of-duty/achievements/bc64976f8e558446785b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/3605bded742f799361ad78d90b6e69a1b2ed4396.jpg": "/game-art/call-of-duty/achievements/9f300fde5a055be0a2f7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/16b7df11cfbc7e897f6b166c2f6af9a572f148bf.jpg": "/game-art/call-of-duty/achievements/c6f06ebc885130e0722e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/4ab8c81fbd79007c23a981e4b44212e3d91ff1d9.jpg": "/game-art/call-of-duty/achievements/9e2b0d60184c6df69f4a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/17fad7e3884b0a6d22d9186521159c8a48f99710.jpg": "/game-art/call-of-duty/achievements/d9668edb4a10ec5afb29.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/439b0eb7928b4b7b7e4e6fb1b9f4e78454f6129c.jpg": "/game-art/call-of-duty/achievements/39a710368dfcc33592fd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/95855c51876cd96887b8044dac125939defb8756.jpg": "/game-art/call-of-duty/achievements/9c8dab60983dd5f09299.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/339a36f54f554c4bd428b34c6ab57ca302a33148.jpg": "/game-art/call-of-duty/achievements/33c148406570b01ac65e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e847bc992c94bb85aa7d877cca66d7a304202c38.jpg": "/game-art/call-of-duty/achievements/2b4d4ce264f28bf49169.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bc7b95aca3dd892e3af7bb8855609e5deffc640c.jpg": "/game-art/call-of-duty/achievements/ddf6fde35a75833626cf.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bdc263e06fd24394a7b3f556e6383b5cf2a5bd8e.jpg": "/game-art/call-of-duty/achievements/822d7187b609f4026d01.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/57f801ff4e86a62c29137529be436dd0c67e801a.jpg": "/game-art/call-of-duty/achievements/a7e8830d01f2edb54842.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/d1d328cd4b41675b205aa41b179f39cdef652162.jpg": "/game-art/call-of-duty/achievements/8e54b7d6307955986b96.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/52da6d72be62dd5a6c4e06ac0a4b6caa8e77109c.jpg": "/game-art/call-of-duty/achievements/4d410e57cd4155a21ff5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/bb758e805f9d2da1053db7db6518605b8b74acfd.jpg": "/game-art/call-of-duty/achievements/9f5847949abb34cbf970.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5b120f358bd219fa1830106aff9713e3e6888210.jpg": "/game-art/call-of-duty/achievements/ddb5a6e3ba7e9cf06f1a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/b3c22eab9f1e84f9d73392731db39fc30d0396d1.jpg": "/game-art/call-of-duty/achievements/6566800a8a3788ec7255.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/6467e3d1e0d8a819cc4e064b3451e2519c2a8c21.jpg": "/game-art/call-of-duty/achievements/d519143240b213146571.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/96bb5a6ddaed3705e0fb1b04cf889dc484e48348.jpg": "/game-art/call-of-duty/achievements/fe5d49946f714a41def4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/47cc1f734302c3acd1a15c365de915d8c6bbad73.jpg": "/game-art/call-of-duty/achievements/588e3e026859e6b20c4e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/236c9cf40603e1c35cace7ad3b923005d8313c07.jpg": "/game-art/call-of-duty/achievements/d4b4f64c747f8cce5747.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/861a3a694b818be9072467dd283c028371b2e063.jpg": "/game-art/call-of-duty/achievements/ef0937348329fb2b5b7f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e90c82ba064b78420e1ed4bad078158bfb708985.jpg": "/game-art/call-of-duty/achievements/e744091006695c8e43f0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/756b0d4a29fbc2ebbf236daf1e8a030e346041bc.jpg": "/game-art/call-of-duty/achievements/75446a54b94fe2cca1ba.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7fa120b283d4bdd452167da70156378588dd63d7.jpg": "/game-art/call-of-duty/achievements/7d76c465d77c0c4ed74d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/421be651f3b3faa73f873b1aff31f918b6d33faf.jpg": "/game-art/call-of-duty/achievements/356e1b587d1aa06e229f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5c09f121abcde97031674a6224f3cc20f50fea2f.jpg": "/game-art/call-of-duty/achievements/6b7a793e48a2806701b1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/2b42df8394c11a52ffd953d12fdae05b9fdc5b83.jpg": "/game-art/call-of-duty/achievements/50eed7e3271e385ba055.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7e6b3e6c827e209091223bcc22237a2904cf765d.jpg": "/game-art/call-of-duty/achievements/77f0a50f6e76f0895d78.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/4ca7533816c1d6901b77bfee63e5d8b8be38dc45.jpg": "/game-art/call-of-duty/achievements/10b81c1bf5661167f7e9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ce04968c413bfeca4b66ec2368a65da39a4b6f46.jpg": "/game-art/call-of-duty/achievements/3e1b28577bacd3294557.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/9b16bf7dab254eed651d2a78a41f3e8cd98fd52c.jpg": "/game-art/call-of-duty/achievements/48ec62405747e62c1221.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/9808074d5ee0e3fed290a3871963fc27f702a494.jpg": "/game-art/call-of-duty/achievements/140c3fc9803cc12de9e8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/52f205ce172e2455709b9c56d9a91ac9f83c2cc4.jpg": "/game-art/call-of-duty/achievements/f8471d3a20bca44f2c6d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/6bc4563784f085681652b5cf1938417d092ea225.jpg": "/game-art/call-of-duty/achievements/fbb12b75d252fcf4c473.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/50039d61608edf89dea7a3661a44d20fe2ef5316.jpg": "/game-art/call-of-duty/achievements/a3e82b8ab886ea64042b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/642be79721bbdd465e8cd02abc08e3dfedbf0f16.jpg": "/game-art/call-of-duty/achievements/4844d808b7dcf60ab4ea.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/f47dbd16bfda8265ffed525c4020176a536f03a2.jpg": "/game-art/call-of-duty/achievements/7fd6c2e9a3fc8d1013b7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7bad50fee59ad5bc0c3ca3bf18b1982199386110.jpg": "/game-art/call-of-duty/achievements/4249be6f37e30ef940ec.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/1c883dc3d1adc6a5ec710f76191277b769ea76e9.jpg": "/game-art/call-of-duty/achievements/f741557d449ea7cde80a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/dfb171eac5a5c121258d4e05475c5d2e24f7cd1c.jpg": "/game-art/call-of-duty/achievements/17e4f46fee28877750f3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/022fb52a500d56c8437262d50fa280c2714bb42f.jpg": "/game-art/call-of-duty/achievements/8132fe0e13988bd24fd1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5f8e24e7491983f80a7fa0ff69e0493e07d0be0d.jpg": "/game-art/call-of-duty/achievements/e42ff6122323ed9a3395.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/af2e18e12cb9755a12a35732a6f22d4544f5c3cc.jpg": "/game-art/call-of-duty/achievements/127c266e7e0fc974c8b8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/58ec162ca04995a17db5193753a6ab4d72a549fe.jpg": "/game-art/call-of-duty/achievements/90e6a8e1e520abcf4a56.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7c38971df65317d11d36d0bc83446007b7e82d3e.jpg": "/game-art/call-of-duty/achievements/d57ea860dac87b16eed1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e3d2196805f3364ccf63b11af82bf78f26fe367f.jpg": "/game-art/call-of-duty/achievements/055fa62b0ec398270f1f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/c055805d0b537d1f5592821c4957a05c56c035e0.jpg": "/game-art/call-of-duty/achievements/c141e7cd14b49ac24e14.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ff3828ee10a3dd90c39d4ba03e4c80318f41df3a.jpg": "/game-art/call-of-duty/achievements/26ce14a18ff0d3ca4630.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/19725429483357cf9923024eb3b01571cb590964.jpg": "/game-art/call-of-duty/achievements/c222d739e10080d08e71.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/374aef06c38570f275550d5ec112dadbdafe2474.jpg": "/game-art/call-of-duty/achievements/65d6c7d09869e3d44d9a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/4d1a14f525f9eee3732db407b0b00e8776b7da80.jpg": "/game-art/call-of-duty/achievements/ce5174efcfa4cd484408.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e64fa2ec9f5b716db5aadab888dfbed2897cfb57.jpg": "/game-art/call-of-duty/achievements/3649dd52dcfcbb203aed.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/c74fb5b0b7a56ebc7f963c1d30caa069820d2540.jpg": "/game-art/call-of-duty/achievements/35d06b424797d3dec59e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/35a53e8e4094957bfa43273c524cf45133da03df.jpg": "/game-art/call-of-duty/achievements/8d292c25901c52b93b82.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ffdbc4312f0f2420a218acd67ad92df364910a6b.jpg": "/game-art/call-of-duty/achievements/d8fad671953c03f693d8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/b544bd18fb12b0b37891c889a51755e59318404a.jpg": "/game-art/call-of-duty/achievements/3140fc6f7f446c716a1b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/67a2a2b17f8736eec7743f0df0c1c96a8f545272.jpg": "/game-art/call-of-duty/achievements/7ef86a67871720d7252c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/be09a9092346a464996c7df633d6f9f953b008d6.jpg": "/game-art/call-of-duty/achievements/197b69315304f7e48443.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1d4a50459abf5d2364a5d98606f6262a6fab9213.jpg": "/game-art/call-of-duty/achievements/223cbe388d22b5576646.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d6b5ee282b0916528cb140ccbef2612cf1e21d4b.jpg": "/game-art/call-of-duty/achievements/e735731d7451544af1a6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/db0d1b5f92f92cbd3d80842d57943d8d5be8d5cb.jpg": "/game-art/call-of-duty/achievements/e76bb42ca3475d9b0892.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/bfd15017d6af1bc8d41067ac2c572955c217493d.jpg": "/game-art/call-of-duty/achievements/ae01cdcbada7670a3afa.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8b244c1fea2b4fdea2623a6b02b8679306b02b56.jpg": "/game-art/call-of-duty/achievements/03b4a6ad35e8d85f2257.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/254dec333adbadd7753e20a030fba050bdd03a78.jpg": "/game-art/call-of-duty/achievements/73502027f0cfafd4b5c0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8f9d970c9cfbf6a836e459d3ac8e15f1e38930ba.jpg": "/game-art/call-of-duty/achievements/8856e5ac6156c0c7ab33.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/25ed74dddf450df12f377e5342c81713b3129d2a.jpg": "/game-art/call-of-duty/achievements/4f967cbfd21a42fbaa16.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f825b53bb58668624201e12ed4eabd033efcd585.jpg": "/game-art/call-of-duty/achievements/b10628c3d94d45f19de9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a737ecf9f6d5e3953b1bf4c50aae4cec5e9e06bb.jpg": "/game-art/call-of-duty/achievements/2e67b31daa3d79a4df8e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/9e7be918ab894a01f7728744ba615eac1820817c.jpg": "/game-art/call-of-duty/achievements/3a0fba9657b38aa35e43.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f21271c5b4fe81a8c2dacb3124dd38d5bf8f2a66.jpg": "/game-art/call-of-duty/achievements/be14e6541017097851a9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/fcc3e34ba09068858def201bcdf9ceb789eb61bc.jpg": "/game-art/call-of-duty/achievements/6c614d52d10224d2fa8e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/30de18e9078ef6b63b3299eae374845469d095d5.jpg": "/game-art/call-of-duty/achievements/46af7819bce39547f361.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c62e9ba3af62855025c2b3b17a91ef02e2528cd2.jpg": "/game-art/call-of-duty/achievements/bc5360cb1b7898b761ea.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d36b6dfff0667595586a4ddbf1d7c3788acbf38b.jpg": "/game-art/call-of-duty/achievements/acf446664a46e34baee6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/936a1c5bd72edb720616e40eab136d672d607a4a.jpg": "/game-art/call-of-duty/achievements/4c88c48b2a35e2ea664f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/86f552ab60ab431d435dc70606adfc1212a6744b.jpg": "/game-art/call-of-duty/achievements/b175cc13888dd81b6a9e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/381722b8ae8a5f47100470a2d2cb419c872da647.jpg": "/game-art/call-of-duty/achievements/6ad5ee337862d9c49188.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/41169a20ac367bd8a588ace5b5d39ad139c92b78.jpg": "/game-art/call-of-duty/achievements/4895825fc7ac017a9c81.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/6348a58f593d9f44d2cedbdca5f221d0042b679d.jpg": "/game-art/call-of-duty/achievements/cfcac47d7797bdb2a9b7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/26745f61f56cbf73a45b7ef9b1948e492148a5b4.jpg": "/game-art/call-of-duty/achievements/2ce53b47a9e930cd0f78.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/6090b4e792a1023679013b471e2aa71ecd5d4f99.jpg": "/game-art/call-of-duty/achievements/74b955b39ea1ed52fc64.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/def3fc3485640dd6edae975f87139aeef4d4fbe1.jpg": "/game-art/call-of-duty/achievements/f54072dc01e0c107a2bd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/68f16e67a78e2b087c70ba959b216b49c16c2abf.jpg": "/game-art/call-of-duty/achievements/ca18fcccac729d9d7db5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/92a8876b6145ad17c8f0404f86d01bf26a010334.jpg": "/game-art/call-of-duty/achievements/3cc75903f021d64740bb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e71deeb01ca53bb6bf6a7e6973efe9b82a0f8665.jpg": "/game-art/call-of-duty/achievements/388fce23aa91b4901180.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/474b8a9fb5b38c894cd1f1957a63b679bb771216.jpg": "/game-art/call-of-duty/achievements/e773ca386395ded5b66f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c41b5d959900613a8cc39386742f7b22291f7999.jpg": "/game-art/call-of-duty/achievements/60b0825c4351c069e7cb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d4a3b90d95dc9533703d69aade0ebed45b0b8693.jpg": "/game-art/call-of-duty/achievements/f4973928757861b05492.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/4a856c1791fd65d131bdb4fd173acc78aee409a1.jpg": "/game-art/call-of-duty/achievements/06254bba874e7fd01696.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e53cf9bba3fac118dda21382e0e22df8eb24194b.jpg": "/game-art/call-of-duty/achievements/ddf914f209f0775a6681.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e24f7b67ee2e209383ad61e5db207235e5c1b36c.jpg": "/game-art/call-of-duty/achievements/c891f4cb387f443cf069.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1937a0f8698adcadc42a0ace8b4462977a760d3d.jpg": "/game-art/call-of-duty/achievements/09de527af050eb1ea6db.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/caaa4c120cc31a3b3844078901690924622eefec.jpg": "/game-art/call-of-duty/achievements/100ea250a4b8821ed763.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8264162bd63715cce724a4c0e43200863e9db401.jpg": "/game-art/call-of-duty/achievements/245b5e76379398c97e60.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/3b7c4ba4b7813850520cb9490ad83b5053674cf3.jpg": "/game-art/call-of-duty/achievements/caf98f2b75941adb9458.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/889a2ef104e08f5c7202344b4002c92360a198d7.jpg": "/game-art/call-of-duty/achievements/47b989f34d821c69c13c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5be087b25337e1be80fc34fab49516a9bc77b6b6.jpg": "/game-art/call-of-duty/achievements/c4dfbcc832173be93b88.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8b85c34b1028b1a36a15b1a2a5163c887f127b43.jpg": "/game-art/call-of-duty/achievements/134ff04d96b5e2117659.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e3c00a88baa550f02cb701bd54a48a75584884ec.jpg": "/game-art/call-of-duty/achievements/691f0231b7479088e835.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/cb1ff523e32e0c9c4d71db0f03ba939783e2c65e.jpg": "/game-art/call-of-duty/achievements/076ad1effb6e1150ee58.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/13a1311666c200fe0d063946a86ff12eccafdacc.jpg": "/game-art/call-of-duty/achievements/b2aad82684e6f9525b74.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/24f298d0cad725a3a2a6d6c157ba8184954bd51d.jpg": "/game-art/call-of-duty/achievements/1f401a4643636b7e09ea.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/db45274296fa6e35c811ad848ae8ad626135d5e4.jpg": "/game-art/call-of-duty/achievements/6513f9d21ce06e1c34a4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a0c4ad945aba7cb59e1cd01286a298bc0319048b.jpg": "/game-art/call-of-duty/achievements/e7cf115da299db339ab8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2391734766760a40fb7938f2f70273c4d3d63525.jpg": "/game-art/call-of-duty/achievements/901df0e5c0d48e171f53.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ec08f0773c6d0e51642dde798ec750e9aff7d6e9.jpg": "/game-art/call-of-duty/achievements/54ddc40c8fce36f87df2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/de077255c5d4e52e0f6f8f00fb2e9dbca80a64d1.jpg": "/game-art/call-of-duty/achievements/c0ae7942b7a2eb803695.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e014cca3b1ca0ac9b9252f8a4541a80a07972956.jpg": "/game-art/call-of-duty/achievements/07de5ba81b60ceeb4adb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/668440cb517ae4f0bb5de178bb65482af2f9352c.jpg": "/game-art/call-of-duty/achievements/36735e3cc67ff6aefd7d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8a237750c9186100f4bf102ce1958334d8c94a5f.jpg": "/game-art/call-of-duty/achievements/394689ecddb3bb3c6ae7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1087feeae28b52a8691214007e0e4b55193c57b6.jpg": "/game-art/call-of-duty/achievements/64727141ed121cf93c42.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2ba51a64811bce2e9d987100c8de9057edefddd0.jpg": "/game-art/call-of-duty/achievements/632a1fcb4fedf10658d6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/4290a97f840765a0a8eaad905cdc3944c524b6dd.jpg": "/game-art/call-of-duty/achievements/ca5280a764455c85011e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c9c5c6ad44fd794cca4f1dc884f38f5d04bdc49c.jpg": "/game-art/call-of-duty/achievements/e0b4aa8da3809ac08f42.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b518c5c6818a1dd05389dc4d00d68ec6d1c295aa.jpg": "/game-art/call-of-duty/achievements/bd4b2888b439fa2fdb0e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e70609d0d14761efad36689c6164748b9ab906dc.jpg": "/game-art/call-of-duty/achievements/2a6bea06204ff2131803.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/98a9190243d1e999f28e52cff2f41eacf2bea14a.jpg": "/game-art/call-of-duty/achievements/29d44f556a418702ab3f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/858fa9620a0b3f18af5202262cd01014111fa142.jpg": "/game-art/call-of-duty/achievements/f0c8abc666782ed1b999.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/41e444d3d97c38c8c9745eb9812270071366d034.jpg": "/game-art/call-of-duty/achievements/5dfbb5d9c9e6761f5421.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/33c0c6c42510a5740d92052401cfa3e022041aee.jpg": "/game-art/call-of-duty/achievements/cbbf8d2b5b17d9049b8f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ad607c6979932af3f628a2f49ab7861768317271.jpg": "/game-art/call-of-duty/achievements/b4664863a39199d14aed.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b21d4dba771c1b995b823ae472ad2449ede75e96.jpg": "/game-art/call-of-duty/achievements/2595dd69394a004aa9b3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a8691d946c8df1cbe15a5b5a844ce67afe44edec.jpg": "/game-art/call-of-duty/achievements/a33c85c2eac09eee12d3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/94ec5a16bec33a09a7e6d46c59228dc2bfe39036.jpg": "/game-art/call-of-duty/achievements/6ec3b50ebd02d0735b93.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f4eecc8ddd7ee7293fa25832335188b015ffb83d.jpg": "/game-art/call-of-duty/achievements/e630036163d8caf0debb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/7f11480479347fcea37cc1cfae939aeba0310c02.jpg": "/game-art/call-of-duty/achievements/4836d8a31e08f9d59a2b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f92300994fb718a533ca129c8ff923a926ab45a3.jpg": "/game-art/call-of-duty/achievements/acec1c540085bf43a216.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2064b041848207e9152648f9b04b10c0eb4a5ea6.jpg": "/game-art/call-of-duty/achievements/add0b1b64943198d70f3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/0cd1b2512a34e0cdaccffdedc50a3922b97928f9.jpg": "/game-art/call-of-duty/achievements/57f87a62c441cc00fee3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/016736a5e36aa630ef854098bcf8b46a0ed090ce.jpg": "/game-art/call-of-duty/achievements/5ad988718dfbdf86382d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/32576cd779cc6194a221fa52d0fcd1ab42a3d3c2.jpg": "/game-art/call-of-duty/achievements/6081ccee396297fcf8f0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2eb29923c09df13e6d1459d86a81fb0578304ab4.jpg": "/game-art/call-of-duty/achievements/ccecd58a309d11d1175b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ba1e4900d687debb7029236ccc1faa64db1180eb.jpg": "/game-art/call-of-duty/achievements/061b960e95b7d94f8f43.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5cadec3374b283cb8f9dc854701bc822bdcce372.jpg": "/game-art/call-of-duty/achievements/ecdf86fbec69e35d181d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/25a6f8d781a75b9c5a2836a6143654af206e7ed2.jpg": "/game-art/call-of-duty/achievements/99ec70d1d7557298d43a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/0c3c4505562edca7e9b5c2f33461bed0d024cd9e.jpg": "/game-art/call-of-duty/achievements/f876467edbcda81649b4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8dedc819af9d9899a880116f1c5749157a41331b.jpg": "/game-art/call-of-duty/achievements/31c585a4c4713380fb47.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/880481de0edbb3b6c8106b91619047765c0438a4.jpg": "/game-art/call-of-duty/achievements/fe2f56321b012cbac9a5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b25f0928717fdc0078a4b8235d4f8090f9347b2a.jpg": "/game-art/call-of-duty/achievements/a7d36705f1683cc0f51f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/33cbc57d8d5d260b2a029f72aa840e6f777a5fcc.jpg": "/game-art/call-of-duty/achievements/5a3e56b651582cd37b0b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/00f4b4482ffe5af8e36fc29ad10184fabb796b2e.jpg": "/game-art/call-of-duty/achievements/f9484a145c44b5d0cbd1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5b6d4af29251e8c797ea6c8df0b5856a338147f7.jpg": "/game-art/call-of-duty/achievements/a14a5d22282b82e02b4e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/67cc050f25ae01778daa55c135f8935f0745ea5d.jpg": "/game-art/call-of-duty/achievements/2e87d420089b4f2be57f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/559bc3b290bc195d1285c286f1cde1c24335391b.jpg": "/game-art/call-of-duty/achievements/3c6074ea42bb0c376cb8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/782ab1a29161fcea2ee6637a6c0965e074e0bc35.jpg": "/game-art/call-of-duty/achievements/aac4dce9aa2be9c78d9b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ecf93da66531bf5795410c7401216bc46c96377f.jpg": "/game-art/call-of-duty/achievements/27bc37653fd38aff2e63.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c25d1ef82dc9ae885afc75fb7db37722039a5cfa.jpg": "/game-art/call-of-duty/achievements/ed46540b107123ccac71.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/61e6b6f00e56022f96fff4ea74393b211d87333c.jpg": "/game-art/call-of-duty/achievements/1189b4ced39f5b73873d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/68cf892bf027d8f5b337c878809441595772c462.jpg": "/game-art/call-of-duty/achievements/42184870c69db3836487.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1c86b033653580d08f213eda50989d7a7284ae57.jpg": "/game-art/call-of-duty/achievements/28d3a3ca08a7bccc03fc.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/cf50aefce11bd69bc0e7aaa776cc8cfbe32c1843.jpg": "/game-art/call-of-duty/achievements/cabc845fe34956e075a8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e1cda475ce180a6b18d5707c3e43f791890a8a0a.jpg": "/game-art/call-of-duty/achievements/594e4fdf8acf6f23526d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/7cec40e8b161cad9671f4a0e6a3c7b753312bbae.jpg": "/game-art/call-of-duty/achievements/b73876cee3831c464041.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/fed3d249602647c0793d1054b15a88b61e532fcd.jpg": "/game-art/call-of-duty/achievements/88afe330f75fb3f138a5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ca4a4b6a35ed886f3a38de19ac74cc1d79cf9b24.jpg": "/game-art/call-of-duty/achievements/73c21ba0cdda98cc9bf6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a4ea4b980d2ecaf4e2cd586d771d4a8ea67f0fb1.jpg": "/game-art/call-of-duty/achievements/0eea8fd2286728848ad9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/9d54b20f3f810ba5350c026dd5ea10fb5219eb9d.jpg": "/game-art/call-of-duty/achievements/307d65e590b40d9b2ed7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/476ebb35cfa9b4c0b1cfdc764396347cf8a956a1.jpg": "/game-art/call-of-duty/achievements/821090905722714275bc.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/4d7838ed2db245e8dc1efd9c33bfb484d0987f5b.jpg": "/game-art/call-of-duty/achievements/9949c131335becbb7bde.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6f53e953c713a7cb5ccb5f927324289297424f53.jpg": "/game-art/call-of-duty/achievements/bf2530311d97100ba635.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0da3fabafb959cabc6450cdf61b380ec186a0742.jpg": "/game-art/call-of-duty/achievements/b6595861a5baa64dd7ca.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/1d1d49d31480100db0d8d3ff4ebe60600c548c33.jpg": "/game-art/call-of-duty/achievements/a993f3d757f6e294b0ce.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/364a213998f2261a3bc4c79ebe3d0b2786fcef6d.jpg": "/game-art/call-of-duty/achievements/b1b2c8cca82ab7fef281.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e41644864b89f501bb6b4e81f58d34e6f7a692cc.jpg": "/game-art/call-of-duty/achievements/24f087406bd644483bc0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/7cb49cf9cc0bbbb0d345993b1251fe0ad5115b97.jpg": "/game-art/call-of-duty/achievements/7ea2fbd55068b35f2b52.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/503545f86c912ce5f504001f50f0187dbda22e6f.jpg": "/game-art/call-of-duty/achievements/5e04d92c3fba71824fae.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/68a5bbfd6300ee0f9ab5dcd3f1abbc1158f3a4fa.jpg": "/game-art/call-of-duty/achievements/6ecc81eb586e45d95d80.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/eaa5797d0d8aa6892b2cd89342afb9e20854cf90.jpg": "/game-art/call-of-duty/achievements/d4d590aa6e41f28ba0c9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/914889189cdea483f10e7911c0ec21c743bb7ee7.jpg": "/game-art/call-of-duty/achievements/22dc6c8473596aeb227f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/57b930b1ea0cf6e610139859dc2e6d56226a2633.jpg": "/game-art/call-of-duty/achievements/bcd92f34c188b2c94e19.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/efbe0632197664b2c0228426c8df13097631a37d.jpg": "/game-art/call-of-duty/achievements/4ae83a4491c70c5a7a73.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f4b3adcedec5e27d71b7af7e9ee0428cd1dcf400.jpg": "/game-art/call-of-duty/achievements/5dd6624c4ec7afaadf27.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c17c0c50abc6e6d9a9aa85e458f54608b099df9b.jpg": "/game-art/call-of-duty/achievements/1a88c2ae4f55f28fe1cb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/be10a6dca336bcffc3db6e100cc84e2afc39a04e.jpg": "/game-art/call-of-duty/achievements/dda424d5bf63271f6a83.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d36e209bce7aeee14bcd7421ec5989c3d6766335.jpg": "/game-art/call-of-duty/achievements/ea4c12853a21dcc53f2d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/45373ed485cfa11602295fc21cb619facf1b587a.jpg": "/game-art/call-of-duty/achievements/050347b46f0d8368c7c1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5fd86313adb4952d991ea88fd25c264b4cf3e4d0.jpg": "/game-art/call-of-duty/achievements/a50db8da0e01557fbd8e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/abfde4e6cf3c3461caf90d5830c2984fbaf4bc68.jpg": "/game-art/call-of-duty/achievements/48753b740d2a2cdc0ba2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fe8f95d54e14d3186cbcb91c422943ed386f4668.jpg": "/game-art/call-of-duty/achievements/85ab28ed3bd4013da705.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/ed8e02846abd5ed0be162106597261e0323ca74b.jpg": "/game-art/call-of-duty/achievements/4ee5e40d6a92ccf126ce.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c53a03bdce2b1ca3aa4a7c1c51b2fae47aec2417.jpg": "/game-art/call-of-duty/achievements/15f92324581bf6590bf6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/69206aa25a08794251623f894f522dd974094996.jpg": "/game-art/call-of-duty/achievements/a07c68c98dc2d336adda.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/8fd625f315d38418c48ebc2e525ee9a132562029.jpg": "/game-art/call-of-duty/achievements/38e607282e37825c2269.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/01d0cc9c538cf12c4a2604f31f9d44b0a6b81bc6.jpg": "/game-art/call-of-duty/achievements/ff3a9b02aa377eed93fe.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/7783476c4c94f89e01165466eb82d896e0247c45.jpg": "/game-art/call-of-duty/achievements/8a2840e5b9584aa1546b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/48f63f1800eccb761142db00dab66bb306409774.jpg": "/game-art/call-of-duty/achievements/4b5e9de3363f43ea5dac.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/191459e1e92933065eb06461831ec18c8748aac5.jpg": "/game-art/call-of-duty/achievements/09cbb08ecadeced19848.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/bc5c4bfac95f698f5339a3458aa43b8765802e7f.jpg": "/game-art/call-of-duty/achievements/ffb4db59b87d8527049c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d3f4ef642be6bc91a1f93bc0f079ef3b18ce8b05.jpg": "/game-art/call-of-duty/achievements/9b4b9d4026c4db6643b2.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e174be71bda7e57d4c4fe7993ca4a958f3352648.jpg": "/game-art/call-of-duty/achievements/f90d041f942f984f6c15.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c52901981e755d5553186c5b77f6d59348a50f95.jpg": "/game-art/call-of-duty/achievements/d29610fabecd57672df4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d20c0b3c00365f3ec8bc9d24682c20aa487cc8ae.jpg": "/game-art/call-of-duty/achievements/31432873709e76e4e1a3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/583e60eb79cd565e85aa1bd4c59bec3aa32e4434.jpg": "/game-art/call-of-duty/achievements/63dfa9a75717ece9f7fe.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/809188e0d91acbef64b5f99a05bdce0e76a48300.jpg": "/game-art/call-of-duty/achievements/2be108c182fe9595ccb9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/322e0ab6895d3f97a06244de57038145321ba78f.jpg": "/game-art/call-of-duty/achievements/55f264e040cb5f11dd6c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e0d98de5d187d767c9c29ed3fefa5e94fb5d2a71.jpg": "/game-art/call-of-duty/achievements/da40d7b9be06fd8c6981.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fad7498a85145e17041e7a42a2ac239c9a836940.jpg": "/game-art/call-of-duty/achievements/3cdbcc76097e36b877ae.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/cca04d8cb905c1a79dd337fb637db65753b1eb74.jpg": "/game-art/call-of-duty/achievements/0316533b014576c6f9d4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e4be11b62681fe629ce889e408cd33d7c9ce2d82.jpg": "/game-art/call-of-duty/achievements/99974045c45ca4746967.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a23d7843879e2533cd5ca05cdbb12768421f5200.jpg": "/game-art/call-of-duty/achievements/d9687e41518b77bf05ba.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/2140d0ad44a74cf73dfa8e5eecff94545dbec800.jpg": "/game-art/call-of-duty/achievements/fb7536a485a7fcd2102e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/41eb949d9e5ebded26f444accfbbf776e423d0c1.jpg": "/game-art/call-of-duty/achievements/5d97318fb384c8043818.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5e903ad5b14acac2a3ce30b241eca02318efe08c.jpg": "/game-art/call-of-duty/achievements/c7ac6468f1a9dd020ad9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/190cc9b3a7a4738b2c0c0aa5b3581c3d13d0fc67.jpg": "/game-art/call-of-duty/achievements/ee8e525ffbd4aad55d05.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f26e0102562e48114cfdc47063638c0ff13ae369.jpg": "/game-art/call-of-duty/achievements/2598397bd2d8472b6ffa.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/4779b58576c848e74e133b602c4febb942a486c0.jpg": "/game-art/call-of-duty/achievements/6c22b7ab6d07086e064f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/851be884cab04c069af4b95853b679e5783c0a2d.jpg": "/game-art/call-of-duty/achievements/8e7820b5324e90ea2e19.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e51e3ab96ef6d0c2a029724865f2db73074f879e.jpg": "/game-art/call-of-duty/achievements/fe0bca15248c5db77368.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/73263340a8746250ab3a8f1186f10c238cb98481.jpg": "/game-art/call-of-duty/achievements/51155702084575df7717.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0ce7057339b8725b96f9ecca6f9a244456e050c3.jpg": "/game-art/call-of-duty/achievements/eb67cbdf0f2bf1e2e392.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6091d5200a8aed5d2bf8079cd8e5f331b25a6d6a.jpg": "/game-art/call-of-duty/achievements/c14b826b7ceee473d291.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/565514b78a058199b82113be663edafbfcfaeaa6.jpg": "/game-art/call-of-duty/achievements/0e4f0bf6cff2c371105d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/232be3c5fc55ef830f30fe8e4505373482dba0ed.jpg": "/game-art/call-of-duty/achievements/875fa76fce401a711538.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/abe4a2fcc597ac2f024aeaeac6e4aa6f8a989951.jpg": "/game-art/call-of-duty/achievements/c9eece5fc57482ab737d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d2fca5a28ce5c89ec913731cf05742e5a8ec5afc.jpg": "/game-art/call-of-duty/achievements/0151aa252dbe2c64fa5b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/ce1781c257f210cf1e816d4704030365abc75058.jpg": "/game-art/call-of-duty/achievements/ab8c01332d03579e6fc0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f737b9b719c85a924574939fd4d188c1817b9619.jpg": "/game-art/call-of-duty/achievements/5f0fd6350175e1f60148.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fa839e5d6e9db883809176ac97e5e1e1fb364e50.jpg": "/game-art/call-of-duty/achievements/98a68729deb6696212f7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a47053b4181e3d73780e2aa08454f29883e5b4ea.jpg": "/game-art/call-of-duty/achievements/bd970842650295550c88.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e787578182bf475de654ff869d7a76204d7358c0.jpg": "/game-art/call-of-duty/achievements/5d72276ec7235d317174.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0174240168f53900f133adf792fe1435ee1b0ea1.jpg": "/game-art/call-of-duty/achievements/7cde51a094d2ba37d596.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c7abcb4db7eae619b4b94affd18b152fb039fe1f.jpg": "/game-art/call-of-duty/achievements/2776bc87b100fcf92325.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fdf433700a6079bd2af957abe935150557992f31.jpg": "/game-art/call-of-duty/achievements/59803f6ef69ae9c0b923.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6a11b9adabb47de14ec6e99d57cd08fd05c7638b.jpg": "/game-art/call-of-duty/achievements/43bf5dd8b3e0c059e922.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f7c7b9405060e89852c1fdd1b98cd99fd4556ee6.jpg": "/game-art/call-of-duty/achievements/51ad1c409df815178212.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d1cd031ca9d9664353b45bfea4fd6be7da9c0498.jpg": "/game-art/call-of-duty/achievements/14e430fa2934e973aa36.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e7e98e8582812646a25139106f785cbd75520f63.jpg": "/game-art/call-of-duty/achievements/2b8350b0eae8221c10f8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/9f05aef05e025f2b3562f02a3879a150188beaa9.jpg": "/game-art/call-of-duty/achievements/9fc62e9028597c00592e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/65010f7eb16e517591dc2b62da2a907475033cdd.jpg": "/game-art/call-of-duty/achievements/326861e9806de8f193a8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/13c39f9153e906d20d6fd4526884deb3b08a13a1.jpg": "/game-art/call-of-duty/achievements/ed1fc961b8cae53a42df.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e4b6c8ab4e761956616be5b79ec00f3df015162d.jpg": "/game-art/call-of-duty/achievements/1884707001ee9093877b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/df54112c58d577e0c021905122dcd40b2f6e7a98.jpg": "/game-art/call-of-duty/achievements/1afc6d1d8574b1961ca8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e794b4b7a3adcaf08c50323b1a019fa34eed3632.jpg": "/game-art/call-of-duty/achievements/fae378553e0ee2f50a9d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/eb1ca331fcaaa3e1ddf481375bebff75a3ee8c76.jpg": "/game-art/call-of-duty/achievements/b1771805712e9d7dca3f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f6db79f0db66c4ff5998b54f392bc5f6671e1e62.jpg": "/game-art/call-of-duty/achievements/033de94bcc4b798ef4c6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/804df5f215f8b28b76e370f636664b803e9cca52.jpg": "/game-art/call-of-duty/achievements/754dbd600cc2e348d91e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/2264003a9af6d55e694d9ebfb8c4be90678c9a18.jpg": "/game-art/call-of-duty/achievements/7a11fd527ab181f42e46.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/afdb278ecf2d8812f2eb4e2ba1916746fac6b715.jpg": "/game-art/call-of-duty/achievements/8a30f1adc1bc6d98f62a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5aa426a557d386edacc8c53a16f31538643fc52a.jpg": "/game-art/call-of-duty/achievements/ed424ec96f90c53b8412.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/1f55129c4a789aeac4909e1d61618b5ea67a6c95.jpg": "/game-art/call-of-duty/achievements/0d96a1f789220f3ee488.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e33837825d9424f56fcef9fe51e4f7cd8c7ad661.jpg": "/game-art/call-of-duty/achievements/692b9a85693cd1405c98.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/b829035e663ee531e0603a0506f2247cc13e5990.jpg": "/game-art/call-of-duty/achievements/a4dac4e7d2f8011e0a34.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6b396a5ecb13f571bac9d485356927f286048727.jpg": "/game-art/call-of-duty/achievements/b601313d7027944c14ff.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/5f5204549ec2b2350fe2abb39f7b31106f0eee9d.jpg": "/game-art/call-of-duty/achievements/5423e6e0220bf8389f93.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/6d40e8236f865b88e62f5b8f81683733b190de10.jpg": "/game-art/call-of-duty/achievements/464c02401563b468757a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/444ca3ad0ffcd78d48869b996ac0c3f28e8a3892.jpg": "/game-art/call-of-duty/achievements/f21af58534176e7cc2ae.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/9de932c337c236172603c5380673c6c02be47e56.jpg": "/game-art/call-of-duty/achievements/87efada8bf8cd21d007e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/81828d6e7db5cd1f3e05061f8cb2c2ff212bf021.jpg": "/game-art/call-of-duty/achievements/00fe9ca1edc2bf6ef2c7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/a3a798f1259d54c816478e6636dfb812faa38cd3.jpg": "/game-art/call-of-duty/achievements/017d310d77f9ce71f34d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d3fd3f4a6d1160e7fca344c7153a3c1c3790e20d.jpg": "/game-art/call-of-duty/achievements/89bc701b69649a38c36c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/97caaed86660711ef4d92f431057ed5f0ed37ab2.jpg": "/game-art/call-of-duty/achievements/e9040935e077d69a190a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/9a09587009ade2752f099e19c947c7ba6b3edbf4.jpg": "/game-art/call-of-duty/achievements/516471bdf6e23a7c3525.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d24857c1f2837e608c8dc19bdd780cf11dceb234.jpg": "/game-art/call-of-duty/achievements/f8d28a760c778fb16f3b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d4cd1139dbfda102e4b2865ca8fdd2d2f46b1e3a.jpg": "/game-art/call-of-duty/achievements/0a0f8b7255d63297ad28.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/5f51760b958da5c541c968359c4904ae65b577eb.jpg": "/game-art/call-of-duty/achievements/304f33ab3c88f3c4bb61.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/1f41f3bdd8749e0f4893ba61eed72f52d5cacb6f.jpg": "/game-art/call-of-duty/achievements/a22e8f846d1302897f3b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/14db187aa9805219b5e7665953e2ff5370eb4bf1.jpg": "/game-art/call-of-duty/achievements/34a87af7e5ed992e5f56.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/fe4b494bd12470168e58611cc5848fad5af4c556.jpg": "/game-art/call-of-duty/achievements/47f3c124d30542618467.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/cd0fd8249e28ab27c9ad41bda381164ebb24bdbe.jpg": "/game-art/call-of-duty/achievements/6e28b230d5c7f0ccfd98.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/b77eee595f7c4d33efbbd156cd05b91f2137d504.jpg": "/game-art/call-of-duty/achievements/8078c43246474ae807e8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/8c0b886c75e4c8aa9c7140f7138b1139625a14d4.jpg": "/game-art/call-of-duty/achievements/1e444cfdba5372cf8d80.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f0d225a5898fbdbf0320519a2e9269232ee0b2a1.jpg": "/game-art/call-of-duty/achievements/52cb1a51cb4f0b4a579c.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/bd1fa843cba1aeaea05401a2012de9f626cdea56.jpg": "/game-art/call-of-duty/achievements/173b27753257df49245a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/fe251e95bb30bf3e566dba7a9a2764588aca5303.jpg": "/game-art/call-of-duty/achievements/325c34e0be02eb788bce.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/4d0bb5694f2137cfa4bd6b45c50dfde39974e797.jpg": "/game-art/call-of-duty/achievements/91a62cd332f49a9dbcbb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/b11c309f8f71d56743791e9066bf507bce47089d.jpg": "/game-art/call-of-duty/achievements/7bc5d0d30105499b4787.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/c83c48ee231f23865e1564bd9dd609d159e2dc78.jpg": "/game-art/call-of-duty/achievements/786a6fbe1c805696cbeb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f46558af9e3f8d4c34e6d910ddcd06899d7668dd.jpg": "/game-art/call-of-duty/achievements/46d491e0a9b3ef87b4c3.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f1bd0dc5211c6469c2eb8914a76376b7c9587da2.jpg": "/game-art/call-of-duty/achievements/35bbd7b64edea5f0dbcc.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/e415053365c5cd2a794422e6e6e82b7447695978.jpg": "/game-art/call-of-duty/achievements/c5b97fe5c00980e17812.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/bf5061bb9b4d88a78819244cc9cb4fee608d8e4c.jpg": "/game-art/call-of-duty/achievements/e38ea5af67911aaa1672.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/ce15e88ab93d982b480395308ff8d3a51f4bfd09.jpg": "/game-art/call-of-duty/achievements/0d6e0f9af9b044891c85.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/53e20712d27d0fa30f6389683703fec90785b9f2.jpg": "/game-art/call-of-duty/achievements/fde64e9607353ca56656.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/3aab6e911ce76a3ae861515b15eab3880fbed914.jpg": "/game-art/call-of-duty/achievements/9fa641633b88a769124a.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/0ceaafe4e56dc6a5be562d6695fd556c896f1a6e.jpg": "/game-art/call-of-duty/achievements/dd4cde7eaa47be497800.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/1eeeda536f7fcca06176e03a8c8d0d673f93db81.jpg": "/game-art/call-of-duty/achievements/5e6c05ca59d43672b9e6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/062937d06c763afe5c917ce8c6ff2a3d8c1a10c7.jpg": "/game-art/call-of-duty/achievements/dcd3cfe2c0164cda451f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/ac5a08806508f32eaa1e8ee3b630e28c34fe8a8e.jpg": "/game-art/call-of-duty/achievements/26a745ed804f7c028b9e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/fafcdac24818ab3ea5fb9b062822e315636d7ec1.jpg": "/game-art/call-of-duty/achievements/96ad629fae03dc395189.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/3da38c567f9e948f20dc472fdb59f407b96dc8b2.jpg": "/game-art/call-of-duty/achievements/5324eff1e27445cda337.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6a78ffd1b01c0db1b7efc5d4be32d98eea5c4e8e.jpg": "/game-art/call-of-duty/achievements/845b6b03c0a95993479f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/c56dac0afdaf369cc301c0067d1988bb74b67618.jpg": "/game-art/call-of-duty/achievements/47d8dd228ad855fcea24.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5b2a1593fe4240b9c6a6f4dd69965e33d5f0abe1.jpg": "/game-art/call-of-duty/achievements/73a2389b03f7587fc0e5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d1f38005260fe5598668227f322d4fbfc955a590.jpg": "/game-art/call-of-duty/achievements/aadf5c23e728238bb9c5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8007c03be77aba99d7b1cc88e02a020118047bde.jpg": "/game-art/call-of-duty/achievements/1c4e1eb7189ca710164b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/3e8727718b0f19b5faec8c42fab0b862b4b63481.jpg": "/game-art/call-of-duty/achievements/413f83285d53ffcae2fd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/e7405eabbe8111af18d2edf6ff029482b2b700fb.jpg": "/game-art/call-of-duty/achievements/26b06ce878eff10b73bf.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6930db97368989cd6fd99cc8875511008f80f9ab.jpg": "/game-art/call-of-duty/achievements/6e906dc87a721d88e3bd.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/066c58519c94120a7586b048285be0e03f2eac8f.jpg": "/game-art/call-of-duty/achievements/5b39f17436deb4386859.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9f473cb021b4e2a466eaabdd43e70b0d4bcc33c6.jpg": "/game-art/call-of-duty/achievements/06adf873abc0e85ec5c0.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/4ea7184e640b125cf66a0b41f50782e20141d149.jpg": "/game-art/call-of-duty/achievements/0f588f009483c1119c89.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/0d7fb5796c7fed3697cf1e005f9cf771d8c54d83.jpg": "/game-art/call-of-duty/achievements/fcb7c46cd797564a698f.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/dd125e0c672897afd49f58d916a6b30508d5cb8f.jpg": "/game-art/call-of-duty/achievements/8bf89d4fa5608647b7fa.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6215cea00d06af99fc481233e5ae205d2fd4cecd.jpg": "/game-art/call-of-duty/achievements/0923efbeb3f152d328be.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/4ed6a97188c019b9c5ad79ec923756f29ba6e4c4.jpg": "/game-art/call-of-duty/achievements/5155ebec3a8d0596e4db.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/b10776bf82f624ffa3266e371074ac5f990a2f21.jpg": "/game-art/call-of-duty/achievements/ab9164941f8672bc8abb.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d92a57a9bdb0215bbf025f1ebfeef31b86808b74.jpg": "/game-art/call-of-duty/achievements/741578149080d68ea0cf.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/35abebfdd66507398c87c1fb106bd847f29cd103.jpg": "/game-art/call-of-duty/achievements/60315f55775a3fce1077.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/714ebbc054d4a315f36b603047be752e3f03d6e0.jpg": "/game-art/call-of-duty/achievements/df765b3d7d4bf9e8daf4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/e001bbd75e459f1a3698594ae4d906e637e85505.jpg": "/game-art/call-of-duty/achievements/6b6d3959856e3f9ca287.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/27b2f66216c7326455b475b4968a371b579ee57c.jpg": "/game-art/call-of-duty/achievements/e61c3692670ed08908c1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9bd50348aa430df867060e24d3770e48b09672dd.jpg": "/game-art/call-of-duty/achievements/c7b703f4789fdf88f2e8.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/fcf0b94a7b7a9c7bc86fc5b797308a6139b7ef54.jpg": "/game-art/call-of-duty/achievements/d4451dddcf7b12a1e957.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/642bb5bf764fe94d8b8ed64221f81ea418dd67f8.jpg": "/game-art/call-of-duty/achievements/b98dcb94df80f1203e69.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/2830777cd6e2ca860803a580a507927315639bdb.jpg": "/game-art/call-of-duty/achievements/e7684dc386168bb855ce.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/f317858e66eae25497d0038dc802403ca25a6d69.jpg": "/game-art/call-of-duty/achievements/e3d5603aaef9f9181396.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/77d79b47cc9d7a1f75250e9b568e35d6aa9c5b0d.jpg": "/game-art/call-of-duty/achievements/3606dac327d5e7c2f002.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9a2332cefa0811232da7eb65cce6babdd53e17aa.jpg": "/game-art/call-of-duty/achievements/373fb8fa42b25f2ff2f5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/dfbef312f1d2fcfeb4003204c0318041cf40bbac.jpg": "/game-art/call-of-duty/achievements/d2fa391ff5e1ba047d69.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8c653f390740da89eea0a708fea7a073f6042dc8.jpg": "/game-art/call-of-duty/achievements/6f0bcb55520c5f4ee6d6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/143591c3bd783549dec12e594ca66eae0a7ab3b9.jpg": "/game-art/call-of-duty/achievements/5bb20f6fc96f66d387da.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/a46c13da4c9b6ed4e1477231c8b36349fc808f0b.jpg": "/game-art/call-of-duty/achievements/ffaffffefe23de4af17e.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/a0f7fc7d8cf212d04fa86a806791f43ee8bb7c10.jpg": "/game-art/call-of-duty/achievements/7b4336d95ad38bdceb14.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8e0e68c0f8fc9c69b0e5fd7dc50fdd9c6b2335b5.jpg": "/game-art/call-of-duty/achievements/8ebc6e71dbc8afa226c6.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d2bcd4fcee0e57475afca3dcba7809540521ec20.jpg": "/game-art/call-of-duty/achievements/178528d63e0791dfa11d.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/146e6d1394fea265d9850d6a482d643fa55cf8bd.jpg": "/game-art/call-of-duty/achievements/c0fdcae2d2e23d569eb4.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/725b6f81036fe2e27e105e7f2d5fa569af5a0300.jpg": "/game-art/call-of-duty/achievements/9179e207bd077972ff97.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8a0ee0fc42268e7308d0cbe121944d95647baa9a.jpg": "/game-art/call-of-duty/achievements/f26378d314c0b4bf71f5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/20e57a0d1b40c952c8af700c8b5cee0cc12b4cd8.jpg": "/game-art/call-of-duty/achievements/807b6d5288ea4e99c540.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/531e5e8aab594ff1e740f96f1063186c3e0f9567.jpg": "/game-art/call-of-duty/achievements/8a6ee3f02bbce9526be7.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/adef655eb9161e85b9b75c5fe4341238df00ed1b.jpg": "/game-art/call-of-duty/achievements/c9e7867e6868bbdbd283.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5d099ed40cadcfa7996e9dc2bac903bbc5427830.jpg": "/game-art/call-of-duty/achievements/21dd1009bcc8472b26d1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/0874326e2469ef5053d713487d2774adf4a83794.jpg": "/game-art/call-of-duty/achievements/50b2a42d676828bf2407.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/14c155b68e14d342bb1807ac44d7f96cf40d5066.jpg": "/game-art/call-of-duty/achievements/954434d2dbdb5fcc4343.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/31381253ea5db5f151e6b2ed375edbd17031b9de.jpg": "/game-art/call-of-duty/achievements/4738f4e73fdec7acb4c1.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/18e378dd7b85bb39e37a3b2c94b5673b2bc192ab.jpg": "/game-art/call-of-duty/achievements/923bcc83b36645e6ef85.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/15d7872c42fcbf3a829021296824cca0a505c6d2.jpg": "/game-art/call-of-duty/achievements/8f7ce98dd2e8abf78bd9.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/1b97bf2a964c883d01b91d830d953078f731413a.jpg": "/game-art/call-of-duty/achievements/f9fe1161efe00ae8c571.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/70dab8649791478c2d9877465550534656917c0f.jpg": "/game-art/call-of-duty/achievements/048b05607127d4ee5b7b.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/89b9d0d811451df6f7c57c25183ada2ee1ddc402.jpg": "/game-art/call-of-duty/achievements/73a862019bb136eb7a86.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/244a19d76e4c44187b88a0d30f2883631e7c82f5.jpg": "/game-art/call-of-duty/achievements/0f898edbf93d8f89dace.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5b505784913a7dd3d8fd62174cdd76a0fb49c151.jpg": "/game-art/call-of-duty/achievements/8ca100618c6de814d6d5.webp",
|
||||
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/bc21875d3703b16313741bed719261fbb2d1ffdf.jpg": "/game-art/call-of-duty/achievements/f3d9e7b5a4fd35172686.webp"
|
||||
}
|
||||
1874
data/cod-art.json
Normal file
1874
data/cod-art.json
Normal file
File diff suppressed because it is too large
Load Diff
36766
data/cod-challenges.json
Normal file
36766
data/cod-challenges.json
Normal file
File diff suppressed because it is too large
Load Diff
90
data/cod-rank-art.json
Normal file
90
data/cod-rank-art.json
Normal file
@@ -0,0 +1,90 @@
|
||||
[
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 1,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-1.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804661214/9FD6B03A8AB4ABE1D52ADEB9A14D8DBB72054910/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 2,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-2.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804661581/1D2C4EAD9F7EBCE0955B6BBE0FEA78A643B817C7/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 3,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-3.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804661865/38F7010DD1543AA9A08C584D1B90DD7C9C138BA1/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 4,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-4.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804662398/E36D618159E6474536E21AFD5873BB652658C010/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 5,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-5.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804662706/45B72C58EB62C84E20A50AEDF2539069BFBF4531/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 6,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-6.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804663180/AB4D41F43A0A68C3417F5DBB1ADA54365F217A49/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 7,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-7.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804668104/BC9E371A435F8CCD160A22ED70AABB4E747E78E4/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 8,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-8.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804668443/6985C3A4733C6CC5E19EEDC53E95FBE705752C70/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 9,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-9.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804669343/BCBD2BC8411DA714684CE5E01C24F4E3DA948F5F/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": 10,
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-10.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804670653/CD1052286A32FDA566E1849D3B6F02ADB22C45CC/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
},
|
||||
{
|
||||
"game": "black-ops-2",
|
||||
"prestige": "master",
|
||||
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-11.webp",
|
||||
"url": "https://images.steamusercontent.com/ugc/2506892135804671070/305118EBAF387C239C6CBF2D8C808E1E6305D3E7/",
|
||||
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||
}
|
||||
]
|
||||
39352
data/game-images.json
Normal file
39352
data/game-images.json
Normal file
File diff suppressed because it is too large
Load Diff
5568
data/pokemon-atlas.json
Normal file
5568
data/pokemon-atlas.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
1
data/pokemon-locations.json
Normal file
1
data/pokemon-locations.json
Normal file
File diff suppressed because one or more lines are too long
3894
data/pokemon-map-art.json
Normal file
3894
data/pokemon-map-art.json
Normal file
File diff suppressed because it is too large
Load Diff
59
data/pokemon-releases.json
Normal file
59
data/pokemon-releases.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"note": "First release of each title; Red and Blue refer to the international versions. Japan versions are separate catalog entries.",
|
||||
"source": "https://bulbapedia.bulbagarden.net/wiki/Pok%C3%A9mon_games",
|
||||
"dates": {
|
||||
"red-japan": "1996-02-27",
|
||||
"green-japan": "1996-02-27",
|
||||
"blue-japan": "1996-10-15",
|
||||
"red": "1998-09-28",
|
||||
"blue": "1998-09-28",
|
||||
"yellow": "1998-09-12",
|
||||
"gold": "1999-11-21",
|
||||
"silver": "1999-11-21",
|
||||
"crystal": "2000-12-14",
|
||||
"ruby": "2002-11-21",
|
||||
"sapphire": "2002-11-21",
|
||||
"colosseum": "2003-11-21",
|
||||
"firered": "2004-01-29",
|
||||
"leafgreen": "2004-01-29",
|
||||
"emerald": "2004-09-16",
|
||||
"xd": "2005-08-04",
|
||||
"diamond": "2006-09-28",
|
||||
"pearl": "2006-09-28",
|
||||
"platinum": "2008-09-13",
|
||||
"heartgold": "2009-09-12",
|
||||
"soulsilver": "2009-09-12",
|
||||
"black": "2010-09-18",
|
||||
"white": "2010-09-18",
|
||||
"black-2": "2012-06-23",
|
||||
"white-2": "2012-06-23",
|
||||
"x": "2013-10-12",
|
||||
"y": "2013-10-12",
|
||||
"omega-ruby": "2014-11-21",
|
||||
"alpha-sapphire": "2014-11-21",
|
||||
"sun": "2016-11-18",
|
||||
"moon": "2016-11-18",
|
||||
"ultra-sun": "2017-11-17",
|
||||
"ultra-moon": "2017-11-17",
|
||||
"lets-go-pikachu": "2018-11-16",
|
||||
"lets-go-eevee": "2018-11-16",
|
||||
"sword": "2019-11-15",
|
||||
"shield": "2019-11-15",
|
||||
"the-isle-of-armor-sword": "2020-06-17",
|
||||
"the-isle-of-armor-shield": "2020-06-17",
|
||||
"the-crown-tundra-sword": "2020-10-22",
|
||||
"the-crown-tundra-shield": "2020-10-22",
|
||||
"brilliant-diamond": "2021-11-19",
|
||||
"shining-pearl": "2021-11-19",
|
||||
"legends-arceus": "2022-01-28",
|
||||
"scarlet": "2022-11-18",
|
||||
"violet": "2022-11-18",
|
||||
"the-teal-mask-scarlet": "2023-09-13",
|
||||
"the-teal-mask-violet": "2023-09-13",
|
||||
"the-indigo-disk-scarlet": "2023-12-14",
|
||||
"the-indigo-disk-violet": "2023-12-14",
|
||||
"legends-za": "2025-10-16",
|
||||
"mega-dimension": "2025-12-10",
|
||||
"champions": "2026-04-08"
|
||||
}
|
||||
}
|
||||
1
data/pokemon-research.json
Normal file
1
data/pokemon-research.json
Normal file
File diff suppressed because one or more lines are too long
643
data/tasks/daisy-import.json
Normal file
643
data/tasks/daisy-import.json
Normal file
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"source": "daisy-import",
|
||||
"areas": [
|
||||
{
|
||||
"slug": "deadlines",
|
||||
"name": "Deadlines and dates",
|
||||
"sortOrder": 10
|
||||
},
|
||||
{
|
||||
"slug": "house",
|
||||
"name": "House and moving",
|
||||
"description": "Jan–Feb 2027 with Juhee",
|
||||
"sortOrder": 20
|
||||
},
|
||||
{
|
||||
"slug": "kibi",
|
||||
"name": "Kibi",
|
||||
"sortOrder": 30
|
||||
},
|
||||
{
|
||||
"slug": "admin",
|
||||
"name": "Phone, subscriptions and admin",
|
||||
"sortOrder": 40
|
||||
},
|
||||
{
|
||||
"slug": "clients",
|
||||
"name": "Clients",
|
||||
"sortOrder": 50
|
||||
},
|
||||
{
|
||||
"slug": "website",
|
||||
"name": "Website (stanrunge.dev)",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"slug": "setup",
|
||||
"name": "Setup and security",
|
||||
"description": "Perma evolvable setup, before 2027",
|
||||
"sortOrder": 70
|
||||
},
|
||||
{
|
||||
"slug": "home",
|
||||
"name": "Home",
|
||||
"sortOrder": 80
|
||||
},
|
||||
{
|
||||
"slug": "health",
|
||||
"name": "Health and self-care",
|
||||
"sortOrder": 90
|
||||
},
|
||||
{
|
||||
"slug": "reading",
|
||||
"name": "Reading",
|
||||
"sortOrder": 100
|
||||
},
|
||||
{
|
||||
"slug": "planning",
|
||||
"name": "Planning and goals",
|
||||
"sortOrder": 110
|
||||
}
|
||||
],
|
||||
"goals": [
|
||||
{
|
||||
"title": "€10k net/month into own Revolut by 2030",
|
||||
"area": "planning",
|
||||
"targetDate": "2030-01-01",
|
||||
"metric": "Net income per month",
|
||||
"metricUnit": "EUR",
|
||||
"metricTarget": 10000
|
||||
},
|
||||
{
|
||||
"title": "Own a really nice Ford Mustang GT"
|
||||
},
|
||||
{
|
||||
"title": "PC overhaul",
|
||||
"notes": "Top AMD X3D CPU, RTX 5090.",
|
||||
"metric": "Budget",
|
||||
"metricUnit": "EUR",
|
||||
"metricTarget": 10000
|
||||
},
|
||||
{
|
||||
"title": "Organise goals into a plan",
|
||||
"area": "planning",
|
||||
"notes": "Social media, Grok Bot, IT company."
|
||||
},
|
||||
{
|
||||
"title": "Perma evolvable setup foundation ready before 2027",
|
||||
"area": "setup",
|
||||
"targetDate": "2026-12-31"
|
||||
},
|
||||
{
|
||||
"title": "Get in shape long term",
|
||||
"area": "health"
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Jan Loman printer visit",
|
||||
"status": "done",
|
||||
"notes": "Settled.",
|
||||
"due": "2026-10-24",
|
||||
"dueTime": "14:00"
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Call BackHome about Kibi's chip",
|
||||
"status": "todo",
|
||||
"due": "2026-10-26",
|
||||
"dueTime": "10:00",
|
||||
"tags": ["kibi", "call"]
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Cargill collective health insurance (OHRA, code 114)",
|
||||
"status": "todo",
|
||||
"notes": "Needs current insurer + premium to compare.",
|
||||
"due": "2026-12-31",
|
||||
"tags": ["insurance"]
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Reply to mom about insurance info",
|
||||
"status": "todo",
|
||||
"notes": "Waiting since Oct 9.",
|
||||
"tags": ["insurance"]
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Stop zorgtoeslag from Jan 2027",
|
||||
"status": "todo",
|
||||
"due": "2027-01-01",
|
||||
"reminderAt": "2026-12-07T09:00:00+01:00",
|
||||
"tags": ["admin"]
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Supermarket product list",
|
||||
"status": "todo",
|
||||
"reminderAt": "2026-10-24T11:50:00+02:00"
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Book haircut",
|
||||
"status": "todo",
|
||||
"notes": "Around Nov 14.",
|
||||
"due": "2026-11-14"
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "House overview",
|
||||
"status": "in_progress",
|
||||
"flagged": true,
|
||||
"notes": "Applied only to Graaf Hendrikstraat 16 so far."
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "React to house options",
|
||||
"status": "todo",
|
||||
"notes": "Raam €1,475; Burg. van Dijkesingel 214-B; Groenhovenweg 161; Spoorstraat €795; Noothoven van Goorstraat 1-206."
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "Tell Daisy budget, move date, must-haves, Funda/Pararius applications",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "Mijndak Midden-Holland sign-up",
|
||||
"status": "todo",
|
||||
"flagged": true,
|
||||
"notes": "€23 option."
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "Sign up: socialehuurwoning.nl, Vesteda Goudshof, VBT, Viramah, agency Jesse's mom uses",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "House tracker/scraper on the site",
|
||||
"status": "todo",
|
||||
"tags": ["website"]
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "Update address everywhere",
|
||||
"status": "todo",
|
||||
"notes": "Checklist ready."
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "UniFi network for the new place",
|
||||
"status": "todo",
|
||||
"tags": ["network"]
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Food ordered",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Chip scanner ordered",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Chip number found",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Chip owner change at BackHome",
|
||||
"status": "todo",
|
||||
"notes": "Still 'Kwiebus' under E Zwart."
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Call vet about Kibi's patient card",
|
||||
"status": "todo",
|
||||
"tags": ["call"]
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "GPS tracker",
|
||||
"status": "todo",
|
||||
"notes": "Dad may have done it already."
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Dash KIBI",
|
||||
"status": "todo",
|
||||
"flagged": true,
|
||||
"notes": "Meaning unclear, ask Stan."
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Basic-Fit cancelled",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Ohra inboedelverzekering cancelled",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Zorgtoeslag income updated for 2026",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Move number to Revolut eSIM, then iPad eSIM, cancel Odido",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Move SIM + WhatsApp from iPhone to Pixel 11 Pro XL",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Check Oasen and Delta Energie fully cancelled",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Cancel other unused subscriptions",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Merge banks into Revolut",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Quote dack €450 for vet clinic site fix",
|
||||
"status": "done",
|
||||
"tags": ["dack"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Reply to dack",
|
||||
"status": "done",
|
||||
"tags": ["dack"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Cloudflare DNS/SSL for dack",
|
||||
"status": "in_progress",
|
||||
"tags": ["dack"],
|
||||
"notes": "Grind-week goal."
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Send dack's invoice via Moneybird",
|
||||
"status": "todo",
|
||||
"tags": ["dack"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Cheap shared WordPress hosting for dack's clients on own VMs",
|
||||
"status": "todo",
|
||||
"tags": ["dack"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Karolina's Wix site, Master Purpose (NL/PL)",
|
||||
"status": "todo",
|
||||
"tags": ["karolina"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Wix Studio + Velo/CLI so an AI can edit code",
|
||||
"status": "todo",
|
||||
"tags": ["karolina"]
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "DB reorganised into schemas, staging retired",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Energy tracker (NED data) live",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Health tracker (private) + 15 RIVM vaccinations",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Book tracker, reader, 362 books live",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Reader zoom, 2-page view, scroll/paging",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Money tracker with MoneyMan",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "NED backfill",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Music listening tracker + now-playing",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Task/goal tracker to replace TickTick",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Hardware DB (current PC parts first)",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "List of every organisation holding my data and what they know",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Move to SvelteKit 3",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Juhee website data",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "AWS cleanup ($0/month)",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Google Cloud cleanup",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "stan-vm hardened (ufw, Tailscale-only SSH)",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Nightly restic backups to B2 + Storage Box",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "WhatsApp bridge on stan-vm",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "gcloud + moneybird-cli on stan-vm",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Domains Squarespace to Cloudflare",
|
||||
"status": "in_progress",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "Both transfers pending.",
|
||||
"due": "2026-10-29"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Syncthing: Introducer + Auto Accept on Arch and Windows",
|
||||
"status": "in_progress",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Backup follow-up: restic passwords into 1Password",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"due": "2026-10-24",
|
||||
"dueTime": "11:00"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Delete empty B2 bucket",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"due": "2026-10-25"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Rotate weak admin secret + DB password",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"tags": ["security"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "1Password overhaul",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "Imports, 2FA, passkeys, dedupe, delete unused accounts.",
|
||||
"tags": ["security"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Moneybird API token, then clean up Moneybird",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Authentik SSO on stan-vm",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "vash-prod memory warnings + finish security audit",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"tags": ["security"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Remove old grok-bot devices from Tailscale",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Close empty GCP billing account; decide on AWS 'Vash Software'",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "AWS part optional."
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Raise WSL RAM",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "Sees 8 of 16 GB."
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Home server rebuild with ZFS",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Analytics, monitoring, alerts across devices",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Mouse sensitivity cm/360 across devices",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Buy Logitech G840 XL mousepad",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"tags": ["buy"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Router and networking setup",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"tags": ["network"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "J5 Hexagon research, then move wachtboden",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Agent-deck + AI/Linux dev setup",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "MITM/certificate lab",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "Idea.",
|
||||
"tags": ["idea"]
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Unpack boxes; organise batteries and tools; buy thermometer",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Clean desk, laundry, clothes",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Connect vinyl player, printer, monitor, switch, osu! pad, tablet, fridge, microwave",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Hue lights + Home Assistant + Apple Home",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Fan controller",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Opa's stuff (WhatsApp, sensor install)",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Getting in shape with Doc",
|
||||
"status": "in_progress",
|
||||
"notes": "Lesson 1 done; ~2,300–2,400 kcal and 160–190 g protein a day.",
|
||||
"goal": "Get in shape long term"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Join the gym my parents use",
|
||||
"status": "todo",
|
||||
"goal": "Get in shape long term"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Brush teeth twice a day, shower, hair care",
|
||||
"status": "todo",
|
||||
"tags": ["habit"]
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Dentist checkup",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Pick hardware for Withings/Garmin syncs",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Curaçao vaccines",
|
||||
"status": "todo",
|
||||
"notes": "Via Doc."
|
||||
},
|
||||
{
|
||||
"area": "reading",
|
||||
"title": "Read 'De meeste mensen deugen' (Bregman)",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "reading",
|
||||
"title": "Re-download 'Haves and Have-Nots'",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "reading",
|
||||
"title": "Book questions: duplicates, 6 unknown files, Nick's repo 404",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "planning",
|
||||
"title": "Schedule sync (paper agenda, TickTick, Daisy)",
|
||||
"status": "todo",
|
||||
"goal": "Organise goals into a plan"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
data/tasks/ticktick-merges.json
Normal file
7
data/tasks/ticktick-merges.json
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{ "ticktickId": "6ad52191ebcd7400000000ce", "title": "Jan Loman printer visit" },
|
||||
{ "ticktickId": "690d2fd98c824268f297a676", "title": "Router and networking setup" },
|
||||
{ "ticktickId": "6844769df471d103461fa9f1", "title": "Home server rebuild with ZFS" },
|
||||
{ "ticktickId": "6ab6f32759b6090811e91a00", "title": "Dentist checkup" },
|
||||
{ "ticktickId": "68225a8dbfe81104eac49a7a", "title": "Merge banks into Revolut" }
|
||||
]
|
||||
17
db/reorg/01_roles.sql
Normal file
17
db/reorg/01_roles.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 01_roles.sql: cluster-level roles. Run as a superuser (postgres). Idempotent.
|
||||
-- In prod: needs `sudo docker exec -i <pg-container> psql -U postgres`; set passwords separately (\password), never in this file.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
CREATE ROLE personal_app LOGIN; -- app, ned-collector, house worker
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ddl') THEN
|
||||
CREATE ROLE personal_ddl NOLOGIN; -- owns schema lists; only reachable via SECURITY DEFINER fns
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
CREATE ROLE personal_ro LOGIN; -- read-only for audits/agents/pgAdmin
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER ROLE personal_ro SET default_transaction_read_only = on;
|
||||
ALTER ROLE personal_app SET search_path = '$user'; -- force schema-qualified names in app SQL
|
||||
ALTER ROLE personal_ro SET search_path = '$user';
|
||||
142
db/reorg/02_schemas_move.sql
Normal file
142
db/reorg/02_schemas_move.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- 02_schemas_move.sql: create schemas, snapshot counts, move + rename all 58 public tables.
|
||||
SET ROLE personal;
|
||||
|
||||
CREATE SCHEMA auth; CREATE SCHEMA money; CREATE SCHEMA house; CREATE SCHEMA ref;
|
||||
CREATE SCHEMA games; CREATE SCHEMA cs; CREATE SCHEMA animaldex; CREATE SCHEMA site;
|
||||
CREATE SCHEMA meta; CREATE SCHEMA hardware; CREATE SCHEMA energy; CREATE SCHEMA legacy;
|
||||
COMMENT ON SCHEMA legacy IS 'Pre-reorg list tables + reorg bookkeeping. Drop 30 days after cutover.';
|
||||
COMMENT ON SCHEMA ref IS 'Seeded third-party catalog data (Pokemon, CoD tracker definitions). Read-only in the GUI; seeders own it.';
|
||||
COMMENT ON SCHEMA games IS 'Stan''s own game progress.';
|
||||
|
||||
-- exact row counts of every public table before anything moves
|
||||
CREATE TABLE legacy.reorg_counts_before (table_name text PRIMARY KEY, n bigint NOT NULL);
|
||||
DO $$
|
||||
DECLARE t text; c bigint;
|
||||
BEGIN
|
||||
FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
||||
EXECUTE format('SELECT count(*) FROM public.%I', t) INTO c;
|
||||
INSERT INTO legacy.reorg_counts_before VALUES (t, c);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE legacy.reorg_table_map (
|
||||
old_name text PRIMARY KEY,
|
||||
old_prefix text NOT NULL, -- prefix used by its index/constraint/sequence names
|
||||
new_schema text NOT NULL,
|
||||
new_name text NOT NULL,
|
||||
UNIQUE (new_schema, new_name)
|
||||
);
|
||||
INSERT INTO legacy.reorg_table_map (old_name, old_prefix, new_schema, new_name) VALUES
|
||||
('user','user','auth','user'), ('session','session','auth','session'),
|
||||
('account','account','auth','account'), ('verification','verification','auth','verification'),
|
||||
('money_accounts','money_accounts','money','accounts'), ('money_transactions','money_transactions','money','transactions'),
|
||||
('money_recurring','money_recurring','money','recurring'), ('money_forecasts','money_forecasts','money','forecasts'),
|
||||
('money_insurances','money_insurances','money','insurances'), ('money_wishlist','money_wishlist','money','wishlist'),
|
||||
('money_projection_settings','money_projection_settings','money','projection_settings'),
|
||||
('money_utility_settings','money_utility_settings','money','utility_settings'),
|
||||
('money_utility_readings','money_utility_readings','money','utility_readings'),
|
||||
('house_listings','house_listings','house','listings'), ('house_price_history','house_price_history','house','price_history'),
|
||||
('house_searches','house_searches','house','searches'), ('house_worker_runs','house_worker_runs','house','worker_runs'),
|
||||
('house_alert_rules','house_alert_rules','house','alert_rules'),
|
||||
('pokemon','pokemon','ref','pokemon'), ('games','game','ref','pokemon_games'),
|
||||
('pokemon_game_entries','pokemon_game_entry','ref','pokemon_game_entries'),
|
||||
('pokemon_locations','pokemon_locations','ref','pokemon_locations'), ('pokemon_encounters','pokemon_encounters','ref','pokemon_encounters'),
|
||||
('pokemon_evolutions','pokemon_evolutions','ref','pokemon_evolutions'), ('pokemon_dex_numbers','pokemon_dex_numbers','ref','pokemon_dex_numbers'),
|
||||
('pokemon_moves','pokemon_moves','ref','pokemon_moves'), ('pokemon_learnsets','pokemon_learnsets','ref','pokemon_learnsets'),
|
||||
('pokemon_research','pokemon_research','ref','pokemon_research'), ('pokemon_game_research','pokemon_game_research','ref','pokemon_game_research'),
|
||||
('tracker_games','tracker_games','ref','tracker_games'), ('tracker_items','tracker_items','ref','tracker_items'),
|
||||
('tracker_challenges','tracker_challenges','ref','tracker_challenges'),
|
||||
('user_games','user_game','games','catch_em_all_games'), ('user_entries','user_entry','games','catch_em_all_entries'),
|
||||
('catch_em_all_settings','catch_em_all_settings','games','catch_em_all_settings'),
|
||||
('pokemon_journeys','pokemon_journeys','games','pokemon_journeys'), ('pokemon_seen','pokemon_seen','games','pokemon_seen'),
|
||||
('pokemon_catches','pokemon_catches','games','pokemon_catches'), ('pokemon_milestones','pokemon_milestones','games','pokemon_milestones'),
|
||||
('tracker_progress','tracker_progress','games','tracker_progress'), ('tracker_ranks','tracker_ranks','games','tracker_ranks'),
|
||||
('tracker_weapon_ranks','tracker_weapon_ranks','games','tracker_weapon_ranks'),
|
||||
('tracker_challenge_progress','tracker_challenge_progress','games','tracker_challenge_progress'),
|
||||
('osu_entries','osu_entries','games','osu_entries'),
|
||||
('cs_market_items','cs_market_items','cs','items'), ('cs_price_observations','cs_price_observations','cs','price_observations'),
|
||||
('cs_item_dimensions','cs_item_dimensions','cs','item_dimensions'), ('cs_price_streams','cs_price_streams','cs','price_streams'),
|
||||
('animals','animals','animaldex','animals'), ('sightings','sightings','animaldex','sightings'),
|
||||
('photos','photos','animaldex','sighting_photos'),
|
||||
('blog_articles','blog_articles','site','blog_articles'), ('tasks','tasks','site','tasks'),
|
||||
('topics','topics','site','topics'), ('tasks_to_topics','tasks_to_topics','site','task_topics'),
|
||||
('lists','lists','legacy','lists'), ('list_items','list_items','legacy','list_items'),
|
||||
('list_item_completions','list_item_completions','legacy','list_item_completions');
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
missing text;
|
||||
BEGIN
|
||||
SELECT string_agg(tablename, ', ') INTO missing FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename NOT IN (SELECT old_name FROM legacy.reorg_table_map);
|
||||
IF missing IS NOT NULL THEN RAISE EXCEPTION 'unmapped public tables: %', missing; END IF;
|
||||
END $$;
|
||||
|
||||
-- types + shared trigger function
|
||||
ALTER TYPE public.availability SET SCHEMA ref;
|
||||
ALTER TYPE public.blog_article_status SET SCHEMA site;
|
||||
ALTER FUNCTION public.site_touch_updated_at() SET SCHEMA meta;
|
||||
ALTER FUNCTION meta.site_touch_updated_at() RENAME TO touch_updated_at;
|
||||
|
||||
-- Duplicate indexes (hand SQL + Drizzle both created one). Keep Drizzle's `_index` / constraint-backed `_key`.
|
||||
DROP INDEX public.games_source_id_unique; -- dup of constraint games_source_id_key
|
||||
DROP INDEX public.pokemon_number_unique; -- dup of constraint pokemon_number_key
|
||||
DROP INDEX public.catch_em_all_settings_user_id_unique;-- dup of constraint ..._user_id_key
|
||||
DROP INDEX public.money_accounts_user_id_idx;
|
||||
DROP INDEX public.money_forecasts_user_date_idx;
|
||||
DROP INDEX public.money_insurances_user_id_idx;
|
||||
DROP INDEX public.money_recurring_user_id_idx;
|
||||
DROP INDEX public.money_transactions_account_id_idx;
|
||||
DROP INDEX public.money_transactions_user_date_idx;
|
||||
DROP INDEX public.money_wishlist_user_id_idx;
|
||||
DROP INDEX public.lists_slug_unique; -- dup of lists_slug_key (legacy table, tidy anyway)
|
||||
|
||||
-- Move + rename tables; rename their constraints, indexes and owned sequences to the new prefix.
|
||||
CREATE FUNCTION pg_temp.reprefix(name text, old_name text, old_prefix text, new_name text) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT CASE
|
||||
WHEN name LIKE old_name || '\_%' THEN new_name || substr(name, length(old_name) + 1)
|
||||
WHEN name LIKE old_prefix || '\_%' THEN new_name || substr(name, length(old_prefix) + 1)
|
||||
ELSE NULL END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
m record; r record; tbl regclass; newn text;
|
||||
BEGIN
|
||||
FOR m IN SELECT * FROM legacy.reorg_table_map ORDER BY old_name LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I SET SCHEMA %I', m.old_name, m.new_schema);
|
||||
IF m.old_name <> m.new_name THEN
|
||||
EXECUTE format('ALTER TABLE %I.%I RENAME TO %I', m.new_schema, m.old_name, m.new_name);
|
||||
END IF;
|
||||
tbl := format('%I.%I', m.new_schema, m.new_name)::regclass;
|
||||
-- constraints (renaming a pk/unique constraint renames its index too)
|
||||
FOR r IN SELECT conname AS n FROM pg_constraint WHERE conrelid = tbl LOOP
|
||||
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||
EXECUTE format('ALTER TABLE %s RENAME CONSTRAINT %I TO %I', tbl, r.n, newn);
|
||||
END IF;
|
||||
END LOOP;
|
||||
-- remaining plain indexes
|
||||
FOR r IN SELECT i.relname AS n FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid
|
||||
WHERE x.indrelid = tbl
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = x.indexrelid AND c.conrelid = tbl) LOOP
|
||||
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||
EXECUTE format('ALTER INDEX %I.%I RENAME TO %I', m.new_schema, r.n, newn);
|
||||
END IF;
|
||||
END LOOP;
|
||||
-- owned sequences
|
||||
FOR r IN SELECT s.relname AS n FROM pg_class s JOIN pg_depend d ON d.objid = s.oid AND d.deptype IN ('a','i')
|
||||
WHERE s.relkind = 'S' AND d.refobjid = tbl LOOP
|
||||
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||
EXECUTE format('ALTER SEQUENCE %I.%I RENAME TO %I', m.new_schema, r.n, newn);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public') THEN
|
||||
RAISE EXCEPTION 'public still has tables';
|
||||
END IF;
|
||||
END $$;
|
||||
RESET ROLE;
|
||||
32
db/reorg/03_fixes.sql
Normal file
32
db/reorg/03_fixes.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- 03_fixes.sql: missing PKs, type fixes, CHECKs, updated_at columns.
|
||||
SET ROLE personal;
|
||||
|
||||
-- missing primary keys (promote the existing unique indexes)
|
||||
ALTER TABLE cs.item_dimensions ADD CONSTRAINT item_dimensions_pkey PRIMARY KEY USING INDEX item_dimensions_unique_idx;
|
||||
ALTER TABLE cs.price_streams ADD CONSTRAINT price_streams_pkey PRIMARY KEY USING INDEX price_streams_unique_idx;
|
||||
ALTER TABLE house.price_history ADD CONSTRAINT price_history_pkey PRIMARY KEY (listing_id, observed_at);
|
||||
DROP INDEX house.price_history_listing_idx; -- now covered by the PK
|
||||
|
||||
-- include_recurring was an int used as a boolean (app code: charts.ts `!== 0`)
|
||||
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring DROP DEFAULT;
|
||||
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring TYPE boolean USING include_recurring <> 0;
|
||||
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring SET DEFAULT true;
|
||||
|
||||
-- osu numbers stored as text (0 rows in prod)
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN pp TYPE numeric USING nullif(pp, '')::numeric;
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN accuracy TYPE numeric USING nullif(replace(accuracy, '%', ''), '')::numeric;
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN rate DROP DEFAULT;
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN rate TYPE numeric(4,2) USING nullif(rate, '')::numeric;
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN rate SET DEFAULT 1.00;
|
||||
|
||||
-- CHECKs matching the values the app already enforces (src/lib/server/money-tracker.ts, money page)
|
||||
ALTER TABLE money.recurring ADD CONSTRAINT recurring_cadence_check CHECK (cadence IN ('weekly','monthly','yearly'));
|
||||
ALTER TABLE money.insurances ADD CONSTRAINT insurances_cadence_check CHECK (cadence IN ('weekly','monthly','yearly'));
|
||||
ALTER TABLE money.accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('spendable','investment'));
|
||||
ALTER TABLE money.utility_settings ADD CONSTRAINT utility_settings_kind_check CHECK (kind IN ('electricity','gas','water'));
|
||||
ALTER TABLE money.utility_readings ADD CONSTRAINT utility_readings_kind_check CHECK (kind IN ('electricity','gas','water'));
|
||||
ALTER TABLE money.projection_settings ADD CONSTRAINT projection_settings_contribution_mode_check CHECK (contribution_mode IN ('excess','fixed'));
|
||||
|
||||
-- user-editable tables without updated_at
|
||||
ALTER TABLE house.alert_rules ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||
RESET ROLE;
|
||||
224
db/reorg/04_meta.sql
Normal file
224
db/reorg/04_meta.sql
Normal file
@@ -0,0 +1,224 @@
|
||||
-- 04_meta.sql: GUI registry (meta.tables / meta.columns), runtime-DDL functions, schema `lists`.
|
||||
SET ROLE personal;
|
||||
|
||||
CREATE TABLE meta.tables (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
schema_name text NOT NULL,
|
||||
table_name text NOT NULL,
|
||||
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||
display_name text NOT NULL,
|
||||
description text,
|
||||
icon text,
|
||||
picture_url text,
|
||||
kind text NOT NULL CHECK (kind IN ('list','core','ref')),
|
||||
editable boolean NOT NULL DEFAULT true,
|
||||
has_done boolean NOT NULL DEFAULT false,
|
||||
default_sort text,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (schema_name, table_name)
|
||||
);
|
||||
COMMENT ON TABLE meta.tables IS 'One row per GUI-visible table (runtime lists + core + ref). Structure truth stays in pg_catalog; this is presentation.';
|
||||
|
||||
CREATE TABLE meta.columns (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
table_id integer NOT NULL REFERENCES meta.tables(id) ON DELETE CASCADE,
|
||||
column_name text NOT NULL,
|
||||
label text,
|
||||
ui_type text NOT NULL CHECK (ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json')),
|
||||
options jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
hidden boolean NOT NULL DEFAULT false,
|
||||
help_text text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (table_id, column_name)
|
||||
);
|
||||
CREATE INDEX columns_table_id_index ON meta.columns (table_id);
|
||||
|
||||
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON meta.tables FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON meta.columns FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||
|
||||
RESET ROLE;
|
||||
-- schema for runtime lists, owned by personal_ddl (not by the migration owner)
|
||||
CREATE SCHEMA lists AUTHORIZATION personal_ddl;
|
||||
COMMENT ON SCHEMA lists IS 'Runtime-created user lists. Never touched by Drizzle migrations (excluded via schemaFilter). Create/alter only via meta.* functions.';
|
||||
GRANT USAGE, CREATE ON SCHEMA meta TO personal_ddl;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON meta.tables, meta.columns TO personal_ddl;
|
||||
SET ROLE personal_ddl;
|
||||
|
||||
-- guess a ui_type from a column's catalog type (format_type output) and name
|
||||
CREATE FUNCTION meta.guess_ui_type(p_type text, p_col text, p_is_enum boolean DEFAULT false) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
|
||||
SELECT CASE
|
||||
WHEN p_is_enum THEN 'select'
|
||||
WHEN p_type LIKE '%[]' THEN 'multiselect'
|
||||
WHEN p_col IN ('picture_url','image_url','icon_url','image') THEN 'image'
|
||||
WHEN p_col LIKE '%\_url' OR p_col = 'url' THEN 'url'
|
||||
WHEN p_col LIKE '%\_cents' THEN 'cents'
|
||||
WHEN p_col IN ('notes','description','content','note') THEN 'longtext'
|
||||
WHEN p_type IN ('smallint','integer','bigint','real','double precision') OR p_type LIKE 'numeric%' THEN 'number'
|
||||
WHEN p_type = 'boolean' THEN 'bool'
|
||||
WHEN p_type = 'date' THEN 'date'
|
||||
WHEN p_type LIKE 'timestamp%' THEN 'datetime'
|
||||
WHEN p_type IN ('json','jsonb') THEN 'json'
|
||||
ELSE 'text' END
|
||||
$$;
|
||||
|
||||
-- reconcile meta.columns with pg_catalog for one table (or all registered tables).
|
||||
-- Uses pg_attribute (not information_schema, which hides tables the caller has no grants on).
|
||||
CREATE FUNCTION meta.sync(p_table_id integer DEFAULT NULL) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||
DECLARE k integer;
|
||||
BEGIN
|
||||
IF to_regclass('pg_temp._meta_cols') IS NULL THEN CREATE TEMP TABLE _meta_cols ON COMMIT DROP AS SELECT 1::integer AS table_id, ''::text AS col, ''::text AS typ, false AS is_enum, 0::integer AS pos, NULL::jsonb AS vals LIMIT 0; END IF;
|
||||
TRUNCATE _meta_cols;
|
||||
INSERT INTO _meta_cols
|
||||
SELECT t.id, a.attname, format_type(a.atttypid, a.atttypmod), ty.typtype = 'e', a.attnum,
|
||||
CASE WHEN ty.typtype = 'e' THEN (SELECT jsonb_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = ty.oid) END
|
||||
FROM meta.tables t
|
||||
JOIN pg_class c ON c.relname = t.table_name
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.schema_name
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
||||
JOIN pg_type ty ON ty.oid = a.atttypid
|
||||
WHERE p_table_id IS NULL OR t.id = p_table_id;
|
||||
DELETE FROM meta.columns mc
|
||||
WHERE (p_table_id IS NULL OR mc.table_id = p_table_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM _meta_cols x WHERE x.table_id = mc.table_id AND x.col = mc.column_name);
|
||||
INSERT INTO meta.columns (table_id, column_name, label, ui_type, options, position, hidden)
|
||||
SELECT x.table_id, x.col, initcap(replace(x.col, '_', ' ')), meta.guess_ui_type(x.typ, x.col, x.is_enum),
|
||||
CASE WHEN x.vals IS NOT NULL THEN jsonb_build_object('values', x.vals) ELSE '{}'::jsonb END,
|
||||
x.pos, x.col IN ('id','created_at','updated_at','user_id','sort_order')
|
||||
FROM _meta_cols x
|
||||
ON CONFLICT (table_id, column_name) DO NOTHING;
|
||||
GET DIAGNOSTICS k = ROW_COUNT;
|
||||
RETURN k;
|
||||
END $$;
|
||||
|
||||
-- allowed column types for runtime lists -> SQL type
|
||||
CREATE FUNCTION meta.sql_type(p_ui_type text) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
|
||||
SELECT CASE p_ui_type
|
||||
WHEN 'text' THEN 'text' WHEN 'longtext' THEN 'text' WHEN 'url' THEN 'text' WHEN 'image' THEN 'text'
|
||||
WHEN 'select' THEN 'text' WHEN 'number' THEN 'numeric' WHEN 'cents' THEN 'integer'
|
||||
WHEN 'date' THEN 'date' WHEN 'datetime' THEN 'timestamptz' WHEN 'bool' THEN 'boolean'
|
||||
WHEN 'multiselect' THEN 'text[]' WHEN 'json' THEN 'jsonb' END
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION meta.assert_ident(p text) RETURNS void
|
||||
LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog AS $$
|
||||
BEGIN
|
||||
IF p IS NULL OR p !~ '^[a-z][a-z0-9_]{0,62}$' THEN
|
||||
RAISE EXCEPTION 'invalid identifier: %', p USING ERRCODE = '22023';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- add one column to a runtime list. p_col: {"name","ui_type","label"?,"options"?,"default"?}
|
||||
CREATE FUNCTION meta.add_column(p_slug text, p_col jsonb) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||
DECLARE
|
||||
t meta.tables; c text := p_col->>'name'; u text := coalesce(p_col->>'ui_type', 'text');
|
||||
st text := meta.sql_type(u); vals jsonb := p_col->'options'->'values'; ddl text;
|
||||
BEGIN
|
||||
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
|
||||
PERFORM meta.assert_ident(c);
|
||||
IF c IN ('id','name','notes','picture_url','done_at','sort_order','created_at','updated_at') THEN
|
||||
RAISE EXCEPTION 'column % is a standard column', c; END IF;
|
||||
IF st IS NULL THEN RAISE EXCEPTION 'unsupported ui_type %', u; END IF;
|
||||
ddl := format('ALTER TABLE lists.%I ADD COLUMN %I %s', t.table_name, c, st);
|
||||
IF p_col ? 'default' THEN ddl := ddl || format(' DEFAULT %L', p_col->>'default'); END IF;
|
||||
IF u = 'select' AND jsonb_typeof(vals) = 'array' THEN
|
||||
ddl := ddl || format(' CONSTRAINT %I CHECK (%I IN (%s))', t.table_name || '_' || c || '_check', c,
|
||||
(SELECT string_agg(quote_literal(v), ',') FROM jsonb_array_elements_text(vals) v));
|
||||
END IF;
|
||||
EXECUTE ddl;
|
||||
INSERT INTO meta.columns (table_id, column_name, label, ui_type, options, position)
|
||||
VALUES (t.id, c, coalesce(p_col->>'label', initcap(replace(c, '_', ' '))), u,
|
||||
coalesce(p_col->'options', '{}'::jsonb),
|
||||
(SELECT coalesce(max(position), 0) + 1 FROM meta.columns WHERE table_id = t.id));
|
||||
END $$;
|
||||
|
||||
-- create a runtime list table with the standard columns + extras
|
||||
CREATE FUNCTION meta.create_list(
|
||||
p_slug text, p_table text, p_display_name text,
|
||||
p_has_done boolean DEFAULT true, p_columns jsonb DEFAULT '[]'::jsonb,
|
||||
p_picture_url text DEFAULT NULL, p_description text DEFAULT NULL, p_icon text DEFAULT NULL,
|
||||
p_sort_order integer DEFAULT 0
|
||||
) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||
DECLARE tid integer; col jsonb;
|
||||
BEGIN
|
||||
PERFORM meta.assert_ident(p_table);
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'lists' AND tablename = p_table) THEN
|
||||
RAISE EXCEPTION 'lists.% already exists', p_table; END IF;
|
||||
EXECUTE format($f$CREATE TABLE lists.%1$I (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL CHECK (btrim(name) <> ''),
|
||||
notes text,
|
||||
picture_url text,
|
||||
%2$s
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now())$f$,
|
||||
p_table, CASE WHEN p_has_done THEN 'done_at timestamptz,' ELSE '' END);
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON lists.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', p_table);
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, description, icon, picture_url, kind, editable, has_done, default_sort, sort_order)
|
||||
VALUES ('lists', p_table, p_slug, p_display_name, p_description, p_icon, p_picture_url, 'list', true, p_has_done,
|
||||
CASE WHEN p_has_done THEN 'done_at nulls first, sort_order, name' ELSE 'sort_order, name' END, p_sort_order)
|
||||
RETURNING id INTO tid;
|
||||
PERFORM meta.sync(tid);
|
||||
UPDATE meta.columns SET label = 'Done', ui_type = 'datetime' WHERE table_id = tid AND column_name = 'done_at';
|
||||
FOR col IN SELECT * FROM jsonb_array_elements(p_columns) LOOP
|
||||
PERFORM meta.add_column(p_slug, col);
|
||||
END LOOP;
|
||||
RETURN tid;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION meta.rename_column(p_slug text, p_old text, p_new text) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||
DECLARE t meta.tables;
|
||||
BEGIN
|
||||
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
|
||||
PERFORM meta.assert_ident(p_new);
|
||||
IF p_old IN ('id','name','done_at','created_at','updated_at') THEN RAISE EXCEPTION 'standard column'; END IF;
|
||||
EXECUTE format('ALTER TABLE lists.%I RENAME COLUMN %I TO %I', t.table_name, p_old, p_new);
|
||||
UPDATE meta.columns SET column_name = p_new WHERE table_id = t.id AND column_name = p_old;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION meta.drop_column(p_slug text, p_col text) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||
DECLARE t meta.tables;
|
||||
BEGIN
|
||||
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
|
||||
IF p_col IN ('id','name','notes','picture_url','done_at','sort_order','created_at','updated_at') THEN
|
||||
RAISE EXCEPTION 'standard column'; END IF;
|
||||
EXECUTE format('ALTER TABLE lists.%I DROP COLUMN %I', t.table_name, p_col);
|
||||
DELETE FROM meta.columns WHERE table_id = t.id AND column_name = p_col;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION meta.archive_list(p_slug text) RETURNS void
|
||||
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||
UPDATE meta.tables SET archived_at = now() WHERE slug = p_slug AND kind = 'list' AND archived_at IS NULL
|
||||
$$;
|
||||
|
||||
-- real DROP only after 7 days in the archive (backups cover the rest)
|
||||
CREATE FUNCTION meta.drop_list(p_slug text) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||
DECLARE t meta.tables;
|
||||
BEGIN
|
||||
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
|
||||
IF t.archived_at IS NULL OR t.archived_at > now() - interval '7 days' THEN
|
||||
RAISE EXCEPTION 'archive % first and wait 7 days', p_slug; END IF;
|
||||
EXECUTE format('DROP TABLE lists.%I', t.table_name);
|
||||
DELETE FROM meta.tables WHERE id = t.id;
|
||||
END $$;
|
||||
|
||||
RESET ROLE;
|
||||
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA meta FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION meta.touch_updated_at() TO PUBLIC; -- trigger fn, must stay callable
|
||||
176
db/reorg/05_lists.sql
Normal file
176
db/reorg/05_lists.sql
Normal file
@@ -0,0 +1,176 @@
|
||||
-- 05_lists.sql: the 22 legacy lists become real tables in schema `lists`.
|
||||
-- legacy.* stays untouched (rollback reference); typo fixes + dedupes are applied to a temp working copy.
|
||||
-- Runs as superuser (inserts into personal_ddl-owned tables); tables are created via meta.create_list.
|
||||
|
||||
CREATE TEMP TABLE src ON COMMIT DROP AS
|
||||
SELECT i.id, i.list_id, btrim(i.name) AS name, nullif(btrim(i.description), '') AS notes, i.picture_url,
|
||||
c.created_at AS done_at, i.created_at, i.updated_at
|
||||
FROM legacy.list_items i
|
||||
LEFT JOIN legacy.list_item_completions c ON c.item_id = i.id;
|
||||
|
||||
CREATE TEMP TABLE fix_log (item_id integer, list_id integer, action text, before text, after text) ON COMMIT DROP;
|
||||
|
||||
-- ---- dedupes: keep the lower id, carry over done_at / picture / notes, delete the other ----
|
||||
CREATE TEMP TABLE dedupe (keep_id integer, drop_id integer, new_name text) ON COMMIT DROP;
|
||||
INSERT INTO dedupe VALUES
|
||||
(165, 323, NULL), -- places: New York x2
|
||||
(168, 331, NULL), -- places: France x2
|
||||
(156, 271, 'Advanced Programming in the UNIX Environment'), -- books: "...UNIX® Environment" dup
|
||||
(139, 287, 'Building Knowledge Graphs: A Practitioner''s Guide'); -- books: short + full title
|
||||
UPDATE src k SET done_at = coalesce(k.done_at, d.done_at), picture_url = coalesce(k.picture_url, d.picture_url),
|
||||
notes = coalesce(k.notes, d.notes), name = coalesce(x.new_name, k.name)
|
||||
FROM dedupe x JOIN src d ON d.id = x.drop_id WHERE k.id = x.keep_id;
|
||||
INSERT INTO fix_log SELECT s.id, s.list_id, 'dedupe-drop', s.name, 'merged into ' || x.keep_id FROM src s JOIN dedupe x ON x.drop_id = s.id;
|
||||
DELETE FROM src WHERE id IN (SELECT drop_id FROM dedupe);
|
||||
|
||||
-- ---- typo / naming fixes ----
|
||||
CREATE TEMP TABLE renames (id integer PRIMARY KEY, new_name text NOT NULL) ON COMMIT DROP;
|
||||
INSERT INTO renames VALUES
|
||||
-- vinyls (kept as "Artist - Album" here; split below)
|
||||
(343, 'Pearl Jam - Ten'), (346, 'Amy Winehouse - Back to Black'),
|
||||
(27, 'Slipknot - Vol. 3: (The Subliminal Verses)'), (6, 'Them Crooked Vultures - Them Crooked Vultures'),
|
||||
(39, 'Various Artists - Katamari Damacy (OST)'),
|
||||
(40, 'Gojira - The Way of All Flesh'), (370, 'Gojira - The Link'),
|
||||
(80, 'Deftones - Private Music'), (308, 'Kendrick Lamar - DAMN.'),
|
||||
-- movies/shows
|
||||
(98, 'Kim''s Convenience'), (394, 'Azuki-chan'), (111, 'A Minecraft Movie'),
|
||||
-- books
|
||||
(150, 'Domain-Driven Design: Tackling Complexity in the Heart of Software'),
|
||||
(285, 'Molecular Biology of the Cell'), (288, 'Building Microservices'),
|
||||
(147, 'Scrum: The Art of Doing Twice the Work in Half the Time'),
|
||||
-- places
|
||||
(325, 'Curaçao'),
|
||||
-- cars
|
||||
(322, 'Mazda MX-5 Miata'),
|
||||
-- leetcode
|
||||
(217, 'Two Sum'),
|
||||
-- foods
|
||||
(221, 'Sotteok Sotteok'), (230, 'Bibim Naengmyeon'), (240, 'Doenjang Jjigae'), (241, 'Kimchi Jjigae'),
|
||||
-- papers
|
||||
(190, 'Order Matters: Sequence to Sequence for Sets'),
|
||||
(200, 'GPipe: Easy Scaling with Micro-Batch Pipeline Parallelism'),
|
||||
(203, 'Attention Is All You Need'),
|
||||
(201, 'Deep Speech 2: End-to-End Speech Recognition in English and Mandarin'),
|
||||
-- games
|
||||
(385, 'Chrono Trigger');
|
||||
DO $$ BEGIN
|
||||
IF (SELECT count(*) FROM renames r JOIN src s ON s.id = r.id) <> (SELECT count(*) FROM renames) THEN
|
||||
RAISE EXCEPTION 'rename list references missing item ids'; END IF;
|
||||
END $$;
|
||||
INSERT INTO fix_log SELECT s.id, s.list_id, 'rename', s.name, r.new_name FROM src s JOIN renames r ON r.id = s.id WHERE s.name <> r.new_name;
|
||||
UPDATE src s SET name = r.new_name FROM renames r WHERE r.id = s.id;
|
||||
|
||||
-- helper: copy standard columns for one list into lists.<t> and return the row count
|
||||
CREATE FUNCTION pg_temp.copy_std(p_list integer, p_table text, p_has_done boolean) RETURNS bigint LANGUAGE plpgsql AS $$
|
||||
DECLARE n bigint;
|
||||
BEGIN
|
||||
EXECUTE format('INSERT INTO lists.%I (id, name, notes, picture_url, %s sort_order, created_at, updated_at)
|
||||
SELECT id, name, notes, picture_url, %s row_number() OVER (ORDER BY id), created_at, updated_at
|
||||
FROM src WHERE list_id = %s', p_table,
|
||||
CASE WHEN p_has_done THEN 'done_at,' ELSE '' END,
|
||||
CASE WHEN p_has_done THEN 'done_at,' ELSE '' END, p_list);
|
||||
GET DIAGNOSTICS n = ROW_COUNT;
|
||||
EXECUTE format('SELECT setval(pg_get_serial_sequence(%L, ''id''), (SELECT max(id) FROM lists.%I))', 'lists.' || p_table, p_table);
|
||||
RETURN n;
|
||||
END $$;
|
||||
|
||||
CREATE TEMP TABLE list_map (list_id integer PRIMARY KEY, tbl text NOT NULL, has_done boolean NOT NULL, cols jsonb NOT NULL) ON COMMIT DROP;
|
||||
INSERT INTO list_map VALUES
|
||||
(1, 'vinyls', true, '[{"name":"artist","ui_type":"text"}]'),
|
||||
(2, 'watchlist', true, '[{"name":"kind","ui_type":"select","options":{"values":["movie","show","anime","documentary"]}}]'),
|
||||
(3, 'books', true, '[{"name":"author","ui_type":"text"}]'),
|
||||
(4, 'side_projects', false, '[{"name":"status","ui_type":"select","default":"idea","options":{"values":["idea","active","paused","done","dropped"]}}]'),
|
||||
(5, 'artists_to_see', true, '[]'),
|
||||
(6, 'osu_maps', true, '[{"name":"mods","ui_type":"text"},{"name":"goal","ui_type":"select","options":{"values":["pass","fc"]}}]'),
|
||||
(7, 'animals_to_spot', true, '[]'),
|
||||
(8, 'vehicles_to_spot', true, '[{"name":"kind","ui_type":"select","default":"car","options":{"values":["car","train","plane"]}}]'),
|
||||
(9, 'leetcode', true, '[{"name":"kind","ui_type":"select","default":"problem","options":{"values":["problem","topic"]}},{"name":"url","ui_type":"url"}]'),
|
||||
(10, 'restaurants', true, '[{"name":"city","ui_type":"text"}]'),
|
||||
(11, 'recipes', true, '[{"name":"cuisine","ui_type":"text"},{"name":"recipe_url","ui_type":"url"}]'),
|
||||
(12, 'places', true, '[{"name":"kind","ui_type":"select","options":{"values":["country","region","city","island"]}}]'),
|
||||
(13, 'papers', false, '[{"name":"authors","ui_type":"text"},{"name":"year","ui_type":"number"},{"name":"url","ui_type":"url"}]'),
|
||||
(14, 'piano_songs', true, '[{"name":"artist","ui_type":"text"},{"name":"reference_url","ui_type":"url"}]'),
|
||||
(15, 'games_to_complete', true, '[{"name":"platform","ui_type":"text"}]'),
|
||||
(22, 'things_to_learn', true, '[]'),
|
||||
(23, 'build_from_scratch', true, '[]'),
|
||||
(24, 'tools_to_try', true, '[{"name":"url","ui_type":"url"}]'),
|
||||
(25, 'setup_upgrades', true, '[]'),
|
||||
(26, 'bucket_list', true, '[]'),
|
||||
(27, 'docs_to_read', true, '[{"name":"rfc_number","ui_type":"number"},{"name":"topic","ui_type":"text"},{"name":"starter_pack","ui_type":"bool","default":"false"},{"name":"url","ui_type":"url"}]'),
|
||||
(28, 'banger_songs', false, '[{"name":"artist","ui_type":"text"},{"name":"kind","ui_type":"select","default":"song","options":{"values":["song","playlist"]}},{"name":"url","ui_type":"url"}]');
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM legacy.lists l WHERE NOT EXISTS (SELECT 1 FROM list_map m WHERE m.list_id = l.id))
|
||||
OR EXISTS (SELECT 1 FROM list_map m JOIN legacy.lists l ON l.id = m.list_id WHERE l.is_progress <> m.has_done AND m.list_id NOT IN (4)) THEN
|
||||
RAISE EXCEPTION 'list_map does not cover legacy.lists / has_done mismatch';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- create tables (as personal_ddl via SECURITY DEFINER) and copy standard columns
|
||||
DO $$
|
||||
DECLARE m record; l record; n bigint;
|
||||
BEGIN
|
||||
FOR m IN SELECT * FROM list_map ORDER BY list_id LOOP
|
||||
SELECT * INTO l FROM legacy.lists WHERE id = m.list_id;
|
||||
PERFORM meta.create_list(l.slug, m.tbl, l.name, m.has_done, m.cols, l.picture_url, NULL, NULL, m.list_id);
|
||||
UPDATE meta.tables SET created_at = l.created_at WHERE slug = l.slug;
|
||||
n := pg_temp.copy_std(m.list_id, m.tbl, m.has_done);
|
||||
RAISE NOTICE 'lists.%: % rows', m.tbl, n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- parsing must not bump updated_at: suppress user triggers for these UPDATEs (superuser only)
|
||||
SET LOCAL session_replication_role = replica;
|
||||
-- ---- per-list parsing into the extra columns (only where it parses cleanly) ----
|
||||
-- vinyls: "Artist - Album"
|
||||
UPDATE lists.vinyls SET artist = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3)
|
||||
WHERE strpos(name, ' - ') > 0;
|
||||
-- books: "Author - Title" only for the 3 personal-name ones (others contain ':' subtitles, never ' - ')
|
||||
UPDATE lists.books SET author = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3)
|
||||
WHERE id IN (28, 29, 306);
|
||||
-- side projects: everything starts as an idea
|
||||
UPDATE lists.side_projects SET status = 'idea';
|
||||
-- osu: trailing mods
|
||||
UPDATE lists.osu_maps SET name = 'Lonely Go', mods = 'HR' WHERE id = 310;
|
||||
UPDATE lists.osu_maps SET name = 'Imagination', mods = 'HR' WHERE id = 317;
|
||||
-- vehicles: all current entries are cars
|
||||
UPDATE lists.vehicles_to_spot SET kind = 'car';
|
||||
-- leetcode
|
||||
UPDATE lists.leetcode SET kind = CASE WHEN name IN ('Two Sum','3Sum') THEN 'problem' ELSE 'topic' END;
|
||||
UPDATE lists.leetcode SET url = 'https://leetcode.com/problems/two-sum/' WHERE name = 'Two Sum';
|
||||
UPDATE lists.leetcode SET url = 'https://leetcode.com/problems/3sum/' WHERE name = '3Sum';
|
||||
-- restaurants: "Name (City)"
|
||||
UPDATE lists.restaurants SET city = substring(name from '\(([^)]+)\)\s*$'), name = btrim(regexp_replace(name, '\s*\([^)]+\)\s*$', ''))
|
||||
WHERE name ~ '\([^)]+\)\s*$';
|
||||
-- recipes
|
||||
UPDATE lists.recipes SET cuisine = CASE WHEN name = 'Yogurt, honey & cruesli' THEN NULL ELSE 'korean' END;
|
||||
-- places
|
||||
UPDATE lists.places SET kind = CASE
|
||||
WHEN name IN ('London','Seattle','Miami','Dubai','New York') THEN 'city'
|
||||
WHEN name IN ('California','Texas') THEN 'region'
|
||||
WHEN name IN ('Curaçao','Aruba','Bonaire') THEN 'island'
|
||||
ELSE 'country' END;
|
||||
-- piano: "artist - title" + URL in notes
|
||||
UPDATE lists.piano_songs SET artist = 'ryo (supercell)', name = 'odds & ends', reference_url = notes, notes = NULL
|
||||
WHERE name = 'ryo/supercell - odds & ends';
|
||||
-- tools: notes that are a bare URL become url
|
||||
UPDATE lists.tools_to_try SET url = notes, notes = NULL WHERE notes ~ '^https?://\S+$';
|
||||
-- docs/RFCs: "RFC n – Title", notes "Topic · starter pack"
|
||||
UPDATE lists.docs_to_read SET
|
||||
rfc_number = substring(name from '^RFC (\d+)')::integer,
|
||||
topic = btrim(split_part(notes, '·', 1)),
|
||||
starter_pack = notes LIKE '%starter pack%',
|
||||
url = 'https://www.rfc-editor.org/rfc/rfc' || substring(name from '^RFC (\d+)'),
|
||||
notes = NULL
|
||||
WHERE name ~ '^RFC \d+';
|
||||
-- banger songs: "Artist - Title" + URL notes; the 2 YT mixes are playlists
|
||||
UPDATE lists.banger_songs SET url = notes, notes = NULL WHERE notes ~ '^https?://\S+$';
|
||||
UPDATE lists.banger_songs SET artist = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3), kind = 'song'
|
||||
WHERE strpos(name, ' - ') > 0;
|
||||
UPDATE lists.banger_songs SET kind = 'playlist' WHERE artist IS NULL;
|
||||
|
||||
SET LOCAL session_replication_role = origin;
|
||||
|
||||
-- persist the fix log for review (legacy schema, dropped with it)
|
||||
CREATE TABLE legacy.reorg_list_fixes AS SELECT * FROM fix_log;
|
||||
ALTER TABLE legacy.reorg_list_fixes OWNER TO personal; -- so `personal` can pg_dump everything
|
||||
148
db/reorg/06_hardware_energy_cs.sql
Normal file
148
db/reorg/06_hardware_energy_cs.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
-- 06_hardware_energy_cs.sql: bring hardware + energy + CS prices over from dev.
|
||||
-- Expects schemas import_hardware / import_energy / import_cs, staged by run.sh from dumps/dev-import-staged.dump.
|
||||
SET ROLE personal;
|
||||
|
||||
-- ---------- energy (DDL identical to ned-collector/collect.py so its IF NOT EXISTS is a no-op) ----------
|
||||
CREATE TABLE energy.gas_storage_daily (
|
||||
gas_day date NOT NULL,
|
||||
site text NOT NULL,
|
||||
point_name text,
|
||||
volume_twh numeric(12,4),
|
||||
fill_pct numeric(7,3),
|
||||
source text NOT NULL DEFAULT 'ned-json',
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (gas_day, site)
|
||||
);
|
||||
CREATE TABLE energy.ned_utilization (
|
||||
valid_from timestamptz NOT NULL, valid_to timestamptz NOT NULL,
|
||||
point text NOT NULL, type text NOT NULL, activity text NOT NULL, classification text NOT NULL, granularity text NOT NULL,
|
||||
capacity_kw numeric, volume_kwh numeric, percentage numeric, emission_kg numeric, emission_factor numeric,
|
||||
source_file text,
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (valid_from, point, type, activity, classification, granularity)
|
||||
);
|
||||
CREATE INDEX ned_utilization_type_time ON energy.ned_utilization (type, granularity, valid_from DESC);
|
||||
CREATE TABLE energy.collector_runs (
|
||||
id bigserial PRIMARY KEY,
|
||||
started_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
mode text NOT NULL, ok boolean, rows_upserted integer, errors text
|
||||
);
|
||||
CREATE VIEW energy.gas_storage_latest AS
|
||||
SELECT DISTINCT ON (site) site, gas_day, volume_twh, fill_pct, updated_at
|
||||
FROM energy.gas_storage_daily ORDER BY site, gas_day DESC;
|
||||
CREATE VIEW energy.electricity_hourly AS
|
||||
SELECT valid_from AT TIME ZONE 'Europe/Amsterdam' AS hour_local, type, classification,
|
||||
round(volume_kwh / 1000.0, 1) AS mwh, percentage, emission_factor AS kg_co2_per_kwh
|
||||
FROM energy.ned_utilization WHERE granularity = 'Hour';
|
||||
|
||||
INSERT INTO energy.gas_storage_daily SELECT * FROM import_energy.gas_storage_daily;
|
||||
INSERT INTO energy.ned_utilization SELECT * FROM import_energy.ned_utilization;
|
||||
INSERT INTO energy.collector_runs SELECT * FROM import_energy.collector_runs;
|
||||
SELECT setval('energy.collector_runs_id_seq', coalesce((SELECT max(id) FROM energy.collector_runs), 1));
|
||||
|
||||
-- ---------- hardware (prices -> integer cents) ----------
|
||||
CREATE TABLE hardware.devices (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
kind text NOT NULL CHECK (kind IN ('desktop','laptop','phone','vm','monitor')),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE hardware.components (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
device_id bigint NOT NULL REFERENCES hardware.devices(id) ON DELETE CASCADE,
|
||||
category text NOT NULL CHECK (category IN ('case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','monitor','lighting','accessory')),
|
||||
brand text,
|
||||
model text NOT NULL,
|
||||
quantity integer NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
specs jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
unit_price_cents integer CHECK (unit_price_cents >= 0),
|
||||
total_price_cents integer CHECK (total_price_cents >= 0),
|
||||
shop text,
|
||||
order_number text,
|
||||
order_date date,
|
||||
warranty_until date,
|
||||
invoice_number text,
|
||||
status text NOT NULL DEFAULT 'in_use' CHECK (status IN ('in_use','retired','sold')),
|
||||
verified boolean NOT NULL DEFAULT true,
|
||||
notes text,
|
||||
source text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX components_category_idx ON hardware.components (category);
|
||||
CREATE INDEX components_device_idx ON hardware.components (device_id);
|
||||
CREATE UNIQUE INDEX components_dedupe_uq ON hardware.components (device_id, model, order_number) NULLS NOT DISTINCT;
|
||||
CREATE INDEX components_specs_gin ON hardware.components USING gin (specs);
|
||||
COMMENT ON SCHEMA hardware IS 'Stan''s hardware inventory: devices (PCs, laptops, phones, VMs, monitors) and their components/peripherals with purchase + warranty details.';
|
||||
COMMENT ON TABLE hardware.devices IS 'A physical or virtual machine Stan owns/uses (e.g. stan-pc). Components hang off a device.';
|
||||
COMMENT ON TABLE hardware.components IS 'One line item (part or peripheral) belonging to a device, with purchase, price and warranty info.';
|
||||
COMMENT ON COLUMN hardware.components.unit_price_cents IS 'Price per unit in EUR cents incl. VAT.';
|
||||
COMMENT ON COLUMN hardware.components.total_price_cents IS 'unit price * quantity in EUR cents incl. VAT (excl. shipping).';
|
||||
COMMENT ON COLUMN hardware.components.verified IS 'false = purchase is real but unconfirmed whether it is (still) part of this setup, or some details are guessed. See notes.';
|
||||
COMMENT ON COLUMN hardware.components.source IS 'Where the data came from: user_list, gmail, manual.';
|
||||
|
||||
INSERT INTO hardware.devices (id, name, kind, notes, created_at, updated_at) OVERRIDING SYSTEM VALUE
|
||||
SELECT id, name, kind, notes, created_at, created_at FROM import_hardware.devices;
|
||||
INSERT INTO hardware.components (id, device_id, category, brand, model, quantity, specs, unit_price_cents, total_price_cents,
|
||||
shop, order_number, order_date, warranty_until, invoice_number, status, verified, notes, source, created_at, updated_at)
|
||||
OVERRIDING SYSTEM VALUE
|
||||
SELECT id, device_id, category, brand, model, quantity, specs, round(unit_price_eur * 100)::integer, round(total_price_eur * 100)::integer,
|
||||
shop, order_number, order_date, warranty_until, invoice_number, status, verified, notes, source, created_at, updated_at
|
||||
FROM import_hardware.components;
|
||||
SELECT setval(pg_get_serial_sequence('hardware.devices', 'id'), (SELECT max(id) FROM hardware.devices));
|
||||
SELECT setval(pg_get_serial_sequence('hardware.components', 'id'), (SELECT max(id) FROM hardware.components));
|
||||
DO $$ BEGIN
|
||||
IF (SELECT sum(total_price_cents) FROM hardware.components) <> (SELECT round(sum(total_price_eur) * 100) FROM import_hardware.components) THEN
|
||||
RAISE EXCEPTION 'hardware cents conversion mismatch'; END IF;
|
||||
END $$;
|
||||
|
||||
CREATE VIEW hardware.setup_overview AS
|
||||
SELECT d.name AS device, d.kind AS device_kind, c.category, c.brand, c.model, c.quantity,
|
||||
c.total_price_cents, round(c.total_price_cents / 100.0, 2) AS total_price_eur,
|
||||
c.shop, c.order_date, c.warranty_until, (c.warranty_until >= CURRENT_DATE) AS under_warranty,
|
||||
c.status, c.verified, c.specs, c.notes, c.order_number, c.invoice_number, c.source, c.id AS component_id
|
||||
FROM hardware.components c JOIN hardware.devices d ON d.id = c.device_id
|
||||
ORDER BY d.name, array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','accessory'], c.category),
|
||||
c.order_date, c.model;
|
||||
COMMENT ON VIEW hardware.setup_overview IS 'Flat view of every component per device, build order, live under_warranty flag. Filter status = ''in_use'' for the current setup.';
|
||||
|
||||
-- ---------- CS prices (prod tables are empty; copy dev) ----------
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM cs.items) OR EXISTS (SELECT 1 FROM cs.price_observations)
|
||||
OR EXISTS (SELECT 1 FROM cs.item_dimensions) OR EXISTS (SELECT 1 FROM cs.price_streams) THEN
|
||||
RAISE EXCEPTION 'cs.* not empty in target; refusing to merge dev data'; END IF;
|
||||
END $$;
|
||||
INSERT INTO cs.items (id, market_hash_name, name, icon_url, item_type, first_seen_at, last_seen_at)
|
||||
SELECT id, market_hash_name, name, icon_url, item_type, first_seen_at, last_seen_at FROM import_cs.cs_market_items;
|
||||
INSERT INTO cs.item_dimensions (item_id, key, value) SELECT item_id, key, value FROM import_cs.cs_item_dimensions;
|
||||
INSERT INTO cs.price_streams (source, currency, kind) SELECT source, currency, kind FROM import_cs.cs_price_streams;
|
||||
INSERT INTO cs.price_observations (id, item_id, source, currency, observed_at, price_cents, volume, kind, imported_at) OVERRIDING SYSTEM VALUE
|
||||
SELECT id, item_id, source, currency, observed_at, price_cents, volume, kind, imported_at FROM import_cs.cs_price_observations;
|
||||
SELECT setval(pg_get_serial_sequence('cs.items', 'id'), coalesce((SELECT max(id) FROM cs.items), 1));
|
||||
SELECT setval(pg_get_serial_sequence('cs.price_observations', 'id'), coalesce((SELECT max(id) FROM cs.price_observations), 1));
|
||||
|
||||
CREATE TABLE legacy.reorg_imports (dst text PRIMARY KEY, src text NOT NULL, n bigint NOT NULL);
|
||||
-- ---------- verify imports against the staged dev copies, then drop staging ----------
|
||||
RESET ROLE;
|
||||
DO $$
|
||||
DECLARE r record; a bigint; b bigint;
|
||||
BEGIN
|
||||
FOR r IN SELECT * FROM (VALUES
|
||||
('energy.gas_storage_daily','import_energy.gas_storage_daily'), ('energy.ned_utilization','import_energy.ned_utilization'),
|
||||
('energy.collector_runs','import_energy.collector_runs'), ('hardware.devices','import_hardware.devices'),
|
||||
('hardware.components','import_hardware.components'), ('cs.items','import_cs.cs_market_items'),
|
||||
('cs.item_dimensions','import_cs.cs_item_dimensions'), ('cs.price_streams','import_cs.cs_price_streams'),
|
||||
('cs.price_observations','import_cs.cs_price_observations')) v(dst, srct) LOOP
|
||||
EXECUTE 'SELECT count(*) FROM ' || r.dst INTO a;
|
||||
EXECUTE 'SELECT count(*) FROM ' || r.srct INTO b;
|
||||
IF a <> b THEN RAISE EXCEPTION '% has % rows, dev % has %', r.dst, a, r.srct, b; END IF;
|
||||
INSERT INTO legacy.reorg_imports VALUES (r.dst, r.srct, a);
|
||||
END LOOP;
|
||||
END $$;
|
||||
DROP SCHEMA import_energy CASCADE;
|
||||
DROP SCHEMA import_hardware CASCADE;
|
||||
DROP SCHEMA import_cs CASCADE;
|
||||
79
db/reorg/07_triggers_meta_grants.sql
Normal file
79
db/reorg/07_triggers_meta_grants.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- 07_triggers_meta_grants.sql: updated_at triggers everywhere, register core tables in meta, grants.
|
||||
SET ROLE personal;
|
||||
|
||||
-- 1) updated_at trigger on every table that has the column but no trigger (27 pre-existing + new ones)
|
||||
DO $$
|
||||
DECLARE r record; n integer := 0;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT n.nspname AS s, c.relname AS t
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
|
||||
WHERE c.relkind = 'r'
|
||||
AND n.nspname IN ('auth','money','house','ref','games','cs','animaldex','site','hardware','energy')
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg WHERE tg.tgrelid = c.oid AND NOT tg.tgisinternal
|
||||
AND tg.tgfoid = 'meta.touch_updated_at()'::regprocedure)
|
||||
LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', r.s, r.t);
|
||||
n := n + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE 'added updated_at triggers: %', n;
|
||||
END $$;
|
||||
-- the 16 old triggers were all named site_touch_updated_at; normalise the name
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN SELECT tg.tgname, tg.tgrelid::regclass AS rel FROM pg_trigger tg WHERE tg.tgname = 'site_touch_updated_at' LOOP
|
||||
EXECUTE format('ALTER TRIGGER site_touch_updated_at ON %s RENAME TO touch_updated_at', r.rel);
|
||||
END LOOP;
|
||||
END $$;
|
||||
RESET ROLE;
|
||||
|
||||
-- 2) register core + ref tables in meta (GUI visibility); auth + bookkeeping are not exposed
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
SELECT n.nspname, c.relname,
|
||||
replace(n.nspname || '-' || c.relname, '_', '-'),
|
||||
initcap(replace(c.relname, '_', ' ')),
|
||||
CASE WHEN n.nspname = 'ref' THEN 'ref' ELSE 'core' END,
|
||||
n.nspname <> 'ref', false, 1000
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind IN ('r','v')
|
||||
AND n.nspname IN ('money','house','ref','games','cs','animaldex','site','hardware','energy')
|
||||
ON CONFLICT DO NOTHING;
|
||||
UPDATE meta.tables SET editable = false WHERE kind = 'core' AND (schema_name, table_name) IN
|
||||
(SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'v');
|
||||
SELECT meta.sync();
|
||||
|
||||
-- 3) grants
|
||||
GRANT USAGE ON SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy, lists TO personal_app, personal_ro;
|
||||
-- app: full DML on its own data, read-only on ref
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy, lists TO personal_app;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA ref, meta TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy, lists TO personal_app;
|
||||
-- app may edit presentation fields of the registry directly
|
||||
GRANT UPDATE (slug, display_name, description, icon, picture_url, default_sort, sort_order) ON meta.tables TO personal_app; -- slug: URL only, table name stays
|
||||
GRANT UPDATE (label, ui_type, options, position, hidden, help_text) ON meta.columns TO personal_app;
|
||||
GRANT EXECUTE ON FUNCTION meta.create_list(text, text, text, boolean, jsonb, text, text, text, integer),
|
||||
meta.add_column(text, jsonb), meta.rename_column(text, text, text), meta.drop_column(text, text),
|
||||
meta.archive_list(text), meta.drop_list(text), meta.sync(integer) TO personal_app;
|
||||
-- ro: everything readable
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy, lists TO personal_ro;
|
||||
-- future objects
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA ref, meta GRANT SELECT ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy
|
||||
GRANT SELECT ON TABLES TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON TABLES TO personal_ro;
|
||||
-- migration owner can read the runtime lists, so deploy backups (pg_dump as `personal`) cover everything
|
||||
GRANT USAGE ON SCHEMA lists TO personal;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA lists TO personal;
|
||||
GRANT SELECT ON ALL SEQUENCES IN SCHEMA lists TO personal;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON TABLES TO personal;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON SEQUENCES TO personal;
|
||||
-- nobody creates in public anymore
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
68
db/reorg/08_verify.sql
Normal file
68
db/reorg/08_verify.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- 08_verify.sql: hard assertions; any failure aborts the whole transaction.
|
||||
DO $$
|
||||
DECLARE r record; a bigint; bad text := '';
|
||||
BEGIN
|
||||
-- every moved table keeps its exact row count
|
||||
-- expected = prod count before + rows imported from dev (cs.* only; prod cs was empty)
|
||||
FOR r IN SELECT m.*, b.n + coalesce(i.n, 0) AS n FROM legacy.reorg_table_map m
|
||||
JOIN legacy.reorg_counts_before b ON b.table_name = m.old_name
|
||||
LEFT JOIN legacy.reorg_imports i ON i.dst = m.new_schema || '.' || m.new_name LOOP
|
||||
EXECUTE format('SELECT count(*) FROM %I.%I', r.new_schema, r.new_name) INTO a;
|
||||
IF a <> r.n THEN bad := bad || format('%s.%s %s<>%s; ', r.new_schema, r.new_name, a, r.n); END IF;
|
||||
END LOOP;
|
||||
IF bad <> '' THEN RAISE EXCEPTION 'row count mismatch: %', bad; END IF;
|
||||
IF (SELECT count(*) FROM legacy.reorg_counts_before) <> 58 THEN RAISE EXCEPTION 'expected 58 source tables'; END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public') THEN RAISE EXCEPTION 'public not empty'; END IF;
|
||||
END $$;
|
||||
|
||||
-- lists: every legacy item is in exactly one lists.* table, except the 4 merged duplicates; done count preserved
|
||||
DO $$
|
||||
DECLARE t record; n bigint; total bigint := 0; done bigint := 0; d bigint;
|
||||
dropped bigint := (SELECT count(*) FROM legacy.reorg_list_fixes WHERE action = 'dedupe-drop');
|
||||
BEGIN
|
||||
FOR t IN SELECT * FROM meta.tables WHERE kind = 'list' LOOP
|
||||
EXECUTE format('SELECT count(*) FROM lists.%I', t.table_name) INTO n;
|
||||
total := total + n;
|
||||
IF t.has_done THEN EXECUTE format('SELECT count(*) FROM lists.%I WHERE done_at IS NOT NULL', t.table_name) INTO d; done := done + d; END IF;
|
||||
END LOOP;
|
||||
IF total + dropped <> (SELECT count(*) FROM legacy.list_items) THEN
|
||||
RAISE EXCEPTION 'lists total % + % dropped <> legacy %', total, dropped, (SELECT count(*) FROM legacy.list_items); END IF;
|
||||
-- expected done = legacy completions minus dedupe pairs where BOTH copies were done (they collapse to one)
|
||||
d := (SELECT count(*) FROM legacy.list_item_completions)
|
||||
- (SELECT count(*) FROM dedupe x
|
||||
WHERE EXISTS (SELECT 1 FROM legacy.list_item_completions c WHERE c.item_id = x.keep_id)
|
||||
AND EXISTS (SELECT 1 FROM legacy.list_item_completions c WHERE c.item_id = x.drop_id));
|
||||
IF done <> d THEN RAISE EXCEPTION 'done count % <> expected %', done, d; END IF;
|
||||
IF (SELECT count(*) FROM meta.tables WHERE kind = 'list') <> (SELECT count(*) FROM legacy.lists) THEN
|
||||
RAISE EXCEPTION 'list count mismatch'; END IF;
|
||||
RAISE NOTICE 'lists OK: % rows (+% merged dupes), % done', total, dropped, done;
|
||||
END $$;
|
||||
|
||||
-- every table with updated_at has the trigger
|
||||
DO $$
|
||||
DECLARE missing text;
|
||||
BEGIN
|
||||
SELECT string_agg(n.nspname || '.' || c.relname, ', ') INTO missing
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
|
||||
WHERE c.relkind = 'r' AND n.nspname NOT IN ('legacy','pg_catalog','information_schema') AND n.nspname !~ '^pg_'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg WHERE tg.tgrelid = c.oid AND tg.tgfoid = 'meta.touch_updated_at()'::regprocedure);
|
||||
IF missing IS NOT NULL THEN RAISE EXCEPTION 'missing updated_at trigger: %', missing; END IF;
|
||||
-- every table has a PK
|
||||
SELECT string_agg(n.nspname || '.' || c.relname, ', ') INTO missing
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind = 'r' AND n.nspname NOT IN ('legacy','pg_catalog','information_schema') AND n.nspname !~ '^pg_'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype = 'p');
|
||||
IF missing IS NOT NULL THEN RAISE EXCEPTION 'tables without PK: %', missing; END IF;
|
||||
END $$;
|
||||
|
||||
-- list rows keep their original timestamps
|
||||
DO $$
|
||||
DECLARE t record; n bigint; bad bigint := 0;
|
||||
BEGIN
|
||||
FOR t IN SELECT table_name FROM meta.tables WHERE kind = 'list' LOOP
|
||||
EXECUTE format('SELECT count(*) FROM lists.%I x JOIN legacy.list_items i ON i.id = x.id WHERE x.updated_at <> i.updated_at OR x.created_at <> i.created_at', t.table_name) INTO n;
|
||||
bad := bad + n;
|
||||
END LOOP;
|
||||
IF bad > 0 THEN RAISE EXCEPTION '% list rows lost their original timestamps', bad; END IF;
|
||||
END $$;
|
||||
58
db/reorg/09_drizzle_names.sql
Normal file
58
db/reorg/09_drizzle_names.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- 09_drizzle_names.sql: rename constraints/indexes to the names Drizzle generates, so future
|
||||
-- drizzle-kit migrations (which address objects by name) match the database. Pure renames, no data change.
|
||||
-- Generated from a diff of the rehearsed DB against the Drizzle baseline (2026-10-23).
|
||||
SET ROLE personal;
|
||||
ALTER TABLE cs.item_dimensions RENAME CONSTRAINT item_dimensions_item_id_cs_market_items_id_fkey TO item_dimensions_item_id_items_id_fkey;
|
||||
ALTER TABLE cs.price_observations RENAME CONSTRAINT price_observations_item_id_cs_market_items_id_fkey TO price_observations_item_id_items_id_fkey;
|
||||
ALTER TABLE games.catch_em_all_entries RENAME CONSTRAINT catch_em_all_entries_pokemon_game_entry_id_pokemon_game_entry_i TO "catch_em_all_entries_HeABXvGNiB6D_fkey";
|
||||
ALTER TABLE games.catch_em_all_games RENAME CONSTRAINT catch_em_all_games_game_id_game_id_fkey TO catch_em_all_games_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.catch_em_all_settings RENAME CONSTRAINT catch_em_all_settings_user_id_fkey TO catch_em_all_settings_user_id_user_id_fkey;
|
||||
ALTER TABLE games.osu_entries RENAME CONSTRAINT osu_entries_user_id_fkey TO osu_entries_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_game_id_fkey TO pokemon_catches_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_pokemon_id_fkey TO pokemon_catches_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_user_id_fkey TO pokemon_catches_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_user_id_game_id_party_slot_key TO pokemon_catches_user_id_game_id_party_slot_unique;
|
||||
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_game_id_fkey TO pokemon_journeys_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_user_id_fkey TO pokemon_journeys_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_user_id_game_id_key TO pokemon_journeys_user_id_game_id_unique;
|
||||
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_game_id_fkey TO pokemon_milestones_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_user_id_fkey TO pokemon_milestones_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_user_id_game_id_key_key TO pokemon_milestones_user_id_game_id_key_unique;
|
||||
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_game_id_fkey TO pokemon_seen_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_pokemon_id_fkey TO pokemon_seen_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_user_id_fkey TO pokemon_seen_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_user_id_game_id_pokemon_id_key TO pokemon_seen_user_id_game_id_pokemon_id_unique;
|
||||
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_challenge_id_fkey TO "tracker_challenge_progress_HtzyFtOyUAhk_fkey";
|
||||
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_user_id_challenge_id_key TO tracker_challenge_progress_user_id_challenge_id_unique;
|
||||
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_user_id_fkey TO tracker_challenge_progress_user_id_user_id_fkey;
|
||||
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_game_id_fkey TO tracker_ranks_game_id_tracker_games_id_fkey;
|
||||
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_user_id_fkey TO tracker_ranks_user_id_user_id_fkey;
|
||||
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_user_id_game_id_key TO tracker_ranks_user_id_game_id_unique;
|
||||
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_item_id_fkey TO tracker_weapon_ranks_item_id_tracker_items_id_fkey;
|
||||
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_user_id_fkey TO tracker_weapon_ranks_user_id_user_id_fkey;
|
||||
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_user_id_item_id_key TO tracker_weapon_ranks_user_id_item_id_unique;
|
||||
ALTER TABLE house.price_history RENAME CONSTRAINT price_history_listing_id_fkey TO price_history_listing_id_listings_id_fkey;
|
||||
ALTER TABLE meta.columns RENAME CONSTRAINT columns_table_id_fkey TO columns_table_id_tables_id_fkey;
|
||||
ALTER TABLE money.forecasts RENAME CONSTRAINT forecasts_account_id_money_accounts_id_fkey TO forecasts_account_id_accounts_id_fkey;
|
||||
ALTER TABLE money.projection_settings RENAME CONSTRAINT "projection_settings_fqvzThdinM8E_fkey" TO projection_settings_investment_account_id_accounts_id_fkey;
|
||||
ALTER TABLE money.recurring RENAME CONSTRAINT recurring_account_id_money_accounts_id_fkey TO recurring_account_id_accounts_id_fkey;
|
||||
ALTER TABLE money.transactions RENAME CONSTRAINT transactions_account_id_money_accounts_id_fkey TO transactions_account_id_accounts_id_fkey;
|
||||
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_game_id_fkey TO pokemon_dex_numbers_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_pokemon_id_fkey TO pokemon_dex_numbers_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_pokemon_id_game_id_dex_key TO pokemon_dex_numbers_pokemon_id_game_id_dex_unique;
|
||||
ALTER TABLE ref.pokemon_encounters RENAME CONSTRAINT pokemon_encounters_game_id_games_id_fkey TO pokemon_encounters_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_evolutions RENAME CONSTRAINT pokemon_evolutions_game_id_games_id_fkey TO pokemon_evolutions_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_game_entries RENAME CONSTRAINT pokemon_game_entries_game_id_game_id_fkey TO pokemon_game_entries_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_game_research RENAME CONSTRAINT pokemon_game_research_game_id_fkey TO pokemon_game_research_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_learnsets RENAME CONSTRAINT pokemon_learnsets_pokemon_id_fkey TO pokemon_learnsets_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE ref.pokemon_learnsets RENAME CONSTRAINT pokemon_learnsets_pokemon_id_version_group_key TO pokemon_learnsets_pokemon_id_version_group_unique;
|
||||
ALTER TABLE ref.pokemon_research RENAME CONSTRAINT pokemon_research_pokemon_id_fkey TO pokemon_research_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE ref.tracker_challenges RENAME CONSTRAINT tracker_challenges_game_id_fkey TO tracker_challenges_game_id_tracker_games_id_fkey;
|
||||
ALTER TABLE ref.tracker_challenges RENAME CONSTRAINT tracker_challenges_game_id_key_key TO tracker_challenges_game_id_key_unique;
|
||||
ALTER TABLE site.blog_articles RENAME CONSTRAINT blog_articles_slug_unique TO blog_articles_slug_key;
|
||||
ALTER INDEX games.osu_entries_owner_idx RENAME TO osu_entries_user_id_kind_index;
|
||||
ALTER INDEX games.pokemon_catches_owner_idx RENAME TO pokemon_catches_user_id_game_id_pokemon_id_index;
|
||||
ALTER INDEX games.pokemon_seen_user_idx RENAME TO pokemon_seen_user_id_index;
|
||||
ALTER INDEX money.utility_readings_user_kind_date_idx RENAME TO utility_readings_user_id_kind_date_index;
|
||||
ALTER INDEX ref.pokemon_dex_game_idx RENAME TO pokemon_dex_numbers_game_id_index;
|
||||
RESET ROLE;
|
||||
21
db/reorg/README.md
Normal file
21
db/reorg/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# One-time schema reorg (2026-10)
|
||||
|
||||
Hand-written, reviewed SQL that moved the old flat `public.*` tables into the
|
||||
domain schemas (`auth`, `money`, `house`, `ref`, `games`, `cs`, `animaldex`,
|
||||
`site`, `meta`, `lists`, `hardware`, `energy`, `legacy`), created the roles
|
||||
(`personal`, `personal_app`, `personal_ddl`, `personal_ro`), the `meta.*`
|
||||
registry/API and the 22 runtime lists. It is the state that the Drizzle baseline
|
||||
in `drizzle/` describes.
|
||||
|
||||
These files are kept for the record and for the cutover only. They were
|
||||
rehearsed on a copy of prod (PG17) and run in one transaction (`run.sh`,
|
||||
which refuses the real database names unless `ALLOW_REAL=yes`). After the
|
||||
cutover, record the baseline with `bun scripts/db-migrate.ts --init`; from then
|
||||
on every schema change is a normal Drizzle migration (`bun run db:generate`).
|
||||
|
||||
Passwords are never in these files; set them with `\password` in psql.
|
||||
|
||||
`09_drizzle_names.sql` runs just before `08_verify.sql` and only renames 48
|
||||
constraints and 5 indexes to Drizzle's generated names, so a catalog diff of the
|
||||
reorged DB against a DB built from the baseline is empty (columns, defaults,
|
||||
constraints, indexes, enums in the nine Drizzle schemas).
|
||||
24
db/reorg/run.sh
Executable file
24
db/reorg/run.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# run.sh <target-db> : stage the dev import, then apply 01..08 in ONE transaction as superuser.
|
||||
# Rehearsal: runs against the private PG17 cluster in ~/dev/setup/db/rehearsal (port 5441, unix socket only).
|
||||
# Prod: same files, but psql = `sudo docker exec -i <pg-container> psql -U postgres -d personal` (see PLAN.md §4c).
|
||||
set -euo pipefail
|
||||
DB=${1:?target db}
|
||||
case "$DB" in personal|personal_dev|personal_staging) [ "${ALLOW_REAL:-}" = yes ] || { echo "refusing $DB without ALLOW_REAL=yes"; exit 3; };; esac
|
||||
D=~/dev/setup/db
|
||||
export LD_LIBRARY_PATH=$D/tools/root/usr/lib/x86_64-linux-gnu
|
||||
BIN=$D/tools/root/usr/lib/postgresql/17/bin
|
||||
CONN=(-h "${PGSOCK:-$D/rehearsal/sock}" -p "${PGPORT:-5441}" -U postgres)
|
||||
STAGED=${STAGED:-$D/dumps/dev-import-staged.dump}
|
||||
|
||||
# 1) stage dev data as import_* schemas (owned by personal so 06 can read it)
|
||||
$BIN/psql "${CONN[@]}" -d "$DB" -v ON_ERROR_STOP=1 -qc "DROP SCHEMA IF EXISTS import_energy, import_hardware, import_cs CASCADE"
|
||||
$BIN/pg_restore "${CONN[@]}" -d "$DB" --no-owner --role=personal --single-transaction --exit-on-error "$STAGED"
|
||||
|
||||
# 2) the migration, all-or-nothing
|
||||
cd "$D/migration"
|
||||
$BIN/psql "${CONN[@]}" -d "$DB" -v ON_ERROR_STOP=1 --single-transaction \
|
||||
-c "SET lock_timeout = '5s'" -c "SET statement_timeout = '10min'" \
|
||||
-f 01_roles.sql -f 02_schemas_move.sql -f 03_fixes.sql -f 04_meta.sql -f 05_lists.sql \
|
||||
-f 06_hardware_energy_cs.sql -f 07_triggers_meta_grants.sql -f 09_drizzle_names.sql -f 08_verify.sql
|
||||
echo "migration committed on $DB"
|
||||
31
deploy/home-collector/README.md
Normal file
31
deploy/home-collector/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# home-collector
|
||||
|
||||
Feeds the private dashboard at `/start` (also the browser new-tab page). The app container has
|
||||
no Docker socket and must not get one, so a small stdlib-only Python script runs on the host
|
||||
every minute (systemd **user** timer `home-collector.timer`) and upserts two rows into
|
||||
`home.snapshots`:
|
||||
|
||||
| key | contents |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `host` | CPU %, load, uptime, RAM, swap, disk; every Docker container (state, health, restart count, CPU/mem via `sudo docker`); systemd timers (`stan-backup*`, `postgres-backup*`, `disk-guard-*`, `house-scrape`, `b2-cost`, `groceries-import`, `money-sync`, `ned-collector-*`) with last/next run and result; HTTP up/down + latency for each self-hosted service; latest `~/backups-cost/daily/*.json` |
|
||||
| `ntfy` | the last 40 messages (72 h) from the ntfy topics in `~/.config/disk-guard.env` (`NTFY_URL`) and `~/.config/house-scrape.env` (`HOUSE_NTFY_URL`), plus `NTFY_EXTRA`. Only a label is stored, never the topic name. |
|
||||
|
||||
The page reads both rows in the same parallel query batch as tasks and links, so a slow
|
||||
upstream never delays it; a snapshot older than 3 minutes is shown as stale.
|
||||
|
||||
## Setup
|
||||
|
||||
1. A login role that may only upsert snapshots (created once, cluster superuser):
|
||||
`CREATE ROLE home_collector LOGIN CONNECTION LIMIT 3 PASSWORD '…'; GRANT CONNECT ON DATABASE personal TO home_collector;`
|
||||
The `home` migration grants `USAGE` on the schema and `SELECT, INSERT, UPDATE` on
|
||||
`home.snapshots` when the role exists (re-run those two GRANTs by hand if the role is created later).
|
||||
2. `~/.config/home-collector.env` (mode 600):
|
||||
```
|
||||
HOME_DB_URL=postgresql://home_collector:…@127.0.0.1:5440/personal
|
||||
# optional extra topics, e.g. the backup alerts from /etc/stan-backup:
|
||||
# NTFY_EXTRA=backups=https://ntfy.sh/<topic>
|
||||
```
|
||||
3. `bash deploy/home-collector/install.sh` (copies the script to `~/.local/bin/home-collector`
|
||||
and enables the timer). Re-run after editing the script, e.g. to add a service to `SERVICES`.
|
||||
|
||||
Debug: `python3 ~/.local/bin/home-collector --print`, `journalctl --user -u home-collector -n 20`.
|
||||
343
deploy/home-collector/home-collector.py
Normal file
343
deploy/home-collector/home-collector.py
Normal file
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-side collector for the /start dashboard on stanrunge.dev.
|
||||
|
||||
Runs every minute as a systemd user timer on the server (home-collector.timer) and upserts
|
||||
two rows into home.snapshots with the limited `home_collector` role:
|
||||
host CPU, RAM, swap, disk, load, uptime, Docker containers (state, health, restarts,
|
||||
CPU/mem), systemd timer results (backups, disk-guard, scrapers) and service checks
|
||||
ntfy the most recent messages from the owner's ntfy topics
|
||||
|
||||
The app container has no Docker socket and never gets one; this script uses the host's
|
||||
passwordless `sudo docker`. ntfy topic names stay on the host: only a label is stored.
|
||||
|
||||
Config: ~/.config/home-collector.env (mode 600)
|
||||
HOME_DB_URL=postgres://home_collector:...@127.0.0.1:5440/<db>
|
||||
NTFY_EXTRA=label=https://ntfy.sh/topic,... (optional extra topics, e.g. backups)
|
||||
Topics are also read from ~/.config/disk-guard.env (NTFY_URL) and
|
||||
~/.config/house-scrape.env (HOUSE_NTFY_URL).
|
||||
Stdlib only. `--print` prints the snapshot instead of writing it; HOME_DB_URL in the
|
||||
environment overrides the env file (e.g. to fill the dev database).
|
||||
"""
|
||||
import concurrent.futures as cf
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
HOME = os.path.expanduser('~')
|
||||
CFG = os.path.join(HOME, '.config')
|
||||
TS = 'stan-ovh.tail665403.ts.net'
|
||||
|
||||
# (name, link shown on the dashboard, url checked from the host or None = link only)
|
||||
SERVICES = [
|
||||
('Website', 'https://stanrunge.dev', 'http://127.0.0.1:3000/login'),
|
||||
('Gitea', 'https://git.stanrunge.dev', 'http://127.0.0.1:3010/'),
|
||||
('Immich', 'https://photos.stanrunge.dev', 'http://127.0.0.1:2283/'),
|
||||
('Umami', 'https://analytics.stanrunge.dev', 'http://127.0.0.1:3110/'),
|
||||
('pgAdmin', 'https://pgadmin.stanrunge.dev', 'http://127.0.0.1:5050/misc/ping'),
|
||||
('Vash esports', 'https://esports.vash.software', 'http://127.0.0.1:3100/'),
|
||||
('Vash staging', 'https://esports-staging.vash.software', 'http://127.0.0.1:3101/'),
|
||||
('Dozzle logs', 'https://logs.vash.software', 'http://127.0.0.1:3111/'),
|
||||
('Beeriokart', 'https://beeriokart.stanrunge.dev', 'http://127.0.0.1:3003/'),
|
||||
('Stuhee', 'https://stuhee.stanrunge.dev', 'http://127.0.0.1:3004/'),
|
||||
('Dev site', 'https://dev.stanrunge.dev', 'http://127.0.0.1:3002/'),
|
||||
('Netdata', f'http://{TS}:19999', 'http://127.0.0.1:19999/api/v1/info'),
|
||||
('Portainer', f'https://{TS}:9443', 'https://127.0.0.1:9443/'),
|
||||
('ntfy', 'https://ntfy.sh/app', 'https://ntfy.sh/v1/health'),
|
||||
]
|
||||
|
||||
# (label, unit, scope)
|
||||
TIMERS = [
|
||||
('Backup (restic → B2)', 'stan-backup', 'system'),
|
||||
('Backup maintenance', 'stan-backup-maintain', 'system'),
|
||||
('Postgres dump', 'postgres-backup', 'system'),
|
||||
('Postgres dump prune', 'postgres-backup-prune', 'system'),
|
||||
('Disk guard check', 'disk-guard-check', 'user'),
|
||||
('Disk guard cleanup', 'disk-guard-cleanup', 'user'),
|
||||
('House scrape', 'house-scrape', 'user'),
|
||||
('B2 cost', 'b2-cost', 'user'),
|
||||
('Groceries import', 'groceries-import', 'user'),
|
||||
('Money sync', 'money-sync', 'user'),
|
||||
('Energy (NED) hourly', 'ned-collector-hourly', 'user'),
|
||||
('Energy (NED) daily', 'ned-collector-daily', 'user'),
|
||||
]
|
||||
|
||||
|
||||
def read_env(path):
|
||||
out = {}
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
k, v = line.split('=', 1)
|
||||
out[k.strip()] = v.strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def run(cmd, timeout=15):
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
return r.stdout if r.returncode == 0 else ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def cpu_times():
|
||||
with open('/proc/stat') as f:
|
||||
parts = [int(x) for x in f.readline().split()[1:]]
|
||||
idle = parts[3] + (parts[4] if len(parts) > 4 else 0)
|
||||
return idle, sum(parts[:8])
|
||||
|
||||
|
||||
def meminfo():
|
||||
m = {}
|
||||
with open('/proc/meminfo') as f:
|
||||
for line in f:
|
||||
k, v = line.split(':', 1)
|
||||
m[k] = int(v.split()[0]) * 1024
|
||||
return m
|
||||
|
||||
|
||||
def system_stats():
|
||||
i1, t1 = cpu_times()
|
||||
time.sleep(1)
|
||||
i2, t2 = cpu_times()
|
||||
cpu = 100.0 * (1 - (i2 - i1) / max(1, t2 - t1))
|
||||
m = meminfo()
|
||||
with open('/proc/uptime') as f:
|
||||
uptime = float(f.read().split()[0])
|
||||
load = list(os.getloadavg())
|
||||
disks = []
|
||||
for mount in ('/',):
|
||||
u = shutil.disk_usage(mount)
|
||||
disks.append({'mount': mount, 'total': u.total, 'used': u.used, 'free': u.free,
|
||||
'pct': round(100 * u.used / u.total, 1)})
|
||||
return {
|
||||
'hostname': socket.gethostname(),
|
||||
'cpus': os.cpu_count(),
|
||||
'cpuPct': round(cpu, 1),
|
||||
'load': [round(x, 2) for x in load],
|
||||
'uptimeS': int(uptime),
|
||||
'mem': {'total': m['MemTotal'], 'available': m['MemAvailable'],
|
||||
'used': m['MemTotal'] - m['MemAvailable']},
|
||||
'swap': {'total': m.get('SwapTotal', 0), 'used': m.get('SwapTotal', 0) - m.get('SwapFree', 0)},
|
||||
'disks': disks,
|
||||
}
|
||||
|
||||
|
||||
def containers():
|
||||
ids = run(['sudo', '-n', 'docker', 'ps', '-aq']).split()
|
||||
if not ids:
|
||||
return []
|
||||
try:
|
||||
info = json.loads(run(['sudo', '-n', 'docker', 'inspect', *ids]) or '[]')
|
||||
except ValueError:
|
||||
info = []
|
||||
stats = {}
|
||||
for line in run(['sudo', '-n', 'docker', 'stats', '--no-stream', '--format', '{{json .}}'], 20).splitlines():
|
||||
try:
|
||||
s = json.loads(line)
|
||||
stats[s['Name']] = {'cpu': s.get('CPUPerc'), 'mem': s.get('MemUsage', '').split(' / ')[0]}
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
out = []
|
||||
for c in info:
|
||||
st = c.get('State', {})
|
||||
name = c.get('Name', '').lstrip('/')
|
||||
out.append({
|
||||
'name': name,
|
||||
'image': c.get('Config', {}).get('Image', '').split('@')[0][:80],
|
||||
'state': st.get('Status'),
|
||||
'health': (st.get('Health') or {}).get('Status'),
|
||||
'restarts': c.get('RestartCount', 0),
|
||||
'exitCode': st.get('ExitCode'),
|
||||
'startedAt': st.get('StartedAt'),
|
||||
'cpu': stats.get(name, {}).get('cpu'),
|
||||
'mem': stats.get(name, {}).get('mem'),
|
||||
})
|
||||
out.sort(key=lambda c: c['name'])
|
||||
return out
|
||||
|
||||
|
||||
def unit_props(unit, scope, props):
|
||||
cmd = ['systemctl'] + (['--user'] if scope == 'user' else []) + [
|
||||
'show', unit, '-p', ','.join(props)]
|
||||
d = {}
|
||||
for line in run(cmd).splitlines():
|
||||
if '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
d[k] = v
|
||||
return d
|
||||
|
||||
|
||||
def list_timers(scope):
|
||||
cmd = ['systemctl'] + (['--user'] if scope == 'user' else []) + ['list-timers', '--all', '-o', 'json']
|
||||
try:
|
||||
return {t['unit']: t for t in json.loads(run(cmd) or '[]')}
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def sec(usec):
|
||||
try:
|
||||
n = int(usec or 0)
|
||||
return n // 1_000_000 if n > 0 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def timers():
|
||||
lists = {'user': list_timers('user'), 'system': list_timers('system')}
|
||||
out = []
|
||||
for label, unit, scope in TIMERS + [('Home collector', 'home-collector', 'user')]:
|
||||
t = lists[scope].get(unit + '.timer')
|
||||
if not t:
|
||||
continue
|
||||
s = unit_props(unit + '.service', scope, ['ActiveState', 'Result', 'ExecMainStatus'])
|
||||
out.append({
|
||||
'label': label, 'unit': unit, 'scope': scope,
|
||||
'last': sec(t.get('last')),
|
||||
'next': sec(t.get('next')),
|
||||
'running': s.get('ActiveState') in ('activating', 'active', 'deactivating'),
|
||||
'result': s.get('Result'),
|
||||
'exitStatus': int(s.get('ExecMainStatus') or 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
_insecure = ssl.create_default_context()
|
||||
_insecure.check_hostname = False
|
||||
_insecure.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def check(service):
|
||||
name, link, url = service
|
||||
res = {'name': name, 'url': link, 'ok': None, 'code': None, 'ms': None}
|
||||
if not url:
|
||||
return res
|
||||
start = time.monotonic()
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'home-collector'})
|
||||
with urllib.request.urlopen(req, timeout=5, context=_insecure) as r:
|
||||
r.read(256)
|
||||
res['code'] = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
res['code'] = e.code
|
||||
except Exception:
|
||||
res['code'] = 0
|
||||
res['ms'] = round((time.monotonic() - start) * 1000)
|
||||
res['ok'] = bool(res['code']) and res['code'] < 500
|
||||
return res
|
||||
|
||||
|
||||
def services():
|
||||
with cf.ThreadPoolExecutor(8) as ex:
|
||||
return list(ex.map(check, SERVICES))
|
||||
|
||||
|
||||
def b2_cost():
|
||||
d = os.path.join(HOME, 'backups-cost', 'daily')
|
||||
try:
|
||||
files = sorted(f for f in os.listdir(d) if f.endswith('.json'))
|
||||
if files:
|
||||
with open(os.path.join(d, files[-1])) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def ntfy_topics(cfg):
|
||||
topics = []
|
||||
for label, path, key in (('disk-guard', 'disk-guard.env', 'NTFY_URL'),
|
||||
('houses', 'house-scrape.env', 'HOUSE_NTFY_URL')):
|
||||
url = read_env(os.path.join(CFG, path)).get(key)
|
||||
if url:
|
||||
topics.append((label, url))
|
||||
for item in (cfg.get('NTFY_EXTRA') or '').split(','):
|
||||
if '=' in item:
|
||||
label, url = item.split('=', 1)
|
||||
topics.append((label.strip(), url.strip()))
|
||||
return topics
|
||||
|
||||
|
||||
def ntfy(cfg):
|
||||
msgs, seen, errors = [], set(), []
|
||||
for label, url in ntfy_topics(cfg):
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
try:
|
||||
req = urllib.request.Request(url.rstrip('/') + '/json?poll=1&since=72h',
|
||||
headers={'User-Agent': 'home-collector'})
|
||||
with urllib.request.urlopen(req, timeout=8) as r:
|
||||
for line in r.read().decode('utf-8', 'replace').splitlines():
|
||||
try:
|
||||
m = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if m.get('event') != 'message':
|
||||
continue
|
||||
msgs.append({'id': m.get('id'), 'topic': label, 'time': m.get('time'),
|
||||
'title': (m.get('title') or '')[:200],
|
||||
'message': (m.get('message') or '')[:600],
|
||||
'priority': m.get('priority', 3), 'tags': m.get('tags') or [],
|
||||
'click': m.get('click')})
|
||||
except Exception:
|
||||
errors.append(label)
|
||||
msgs.sort(key=lambda m: m['time'] or 0, reverse=True)
|
||||
return {'messages': msgs[:40], 'topics': [t for t, _ in ntfy_topics(cfg)], 'errors': errors}
|
||||
|
||||
|
||||
def write(db_url, rows):
|
||||
tag = 'j' + secrets.token_hex(8)
|
||||
sql = ['BEGIN;']
|
||||
for key, data in rows.items():
|
||||
payload = json.dumps(data, separators=(',', ':'))
|
||||
sql.append(
|
||||
f"INSERT INTO home.snapshots (key, data, collected_at) VALUES ('{key}', ${tag}${payload}${tag}$::jsonb, now()) "
|
||||
"ON CONFLICT (key) DO UPDATE SET data = excluded.data, collected_at = excluded.collected_at;")
|
||||
sql.append('COMMIT;')
|
||||
r = subprocess.run(['psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', db_url], input='\n'.join(sql),
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
# psql errors never contain the password; the URL is not echoed.
|
||||
sys.stderr.write(r.stderr[-500:])
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = read_env(os.path.join(CFG, 'home-collector.env'))
|
||||
with cf.ThreadPoolExecutor(4) as ex:
|
||||
f_sys = ex.submit(system_stats)
|
||||
f_ct = ex.submit(containers)
|
||||
f_svc = ex.submit(services)
|
||||
f_ntfy = ex.submit(ntfy, cfg)
|
||||
host = f_sys.result()
|
||||
host['containers'] = f_ct.result()
|
||||
host['services'] = f_svc.result()
|
||||
host['timers'] = timers()
|
||||
host['b2'] = b2_cost()
|
||||
notes = f_ntfy.result()
|
||||
rows = {'host': host, 'ntfy': notes}
|
||||
if '--print' in sys.argv:
|
||||
print(json.dumps(rows, indent=1))
|
||||
return
|
||||
url = os.environ.get('HOME_DB_URL') or cfg.get('HOME_DB_URL')
|
||||
if not url:
|
||||
sys.exit('HOME_DB_URL missing in ~/.config/home-collector.env')
|
||||
write(url, rows)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
11
deploy/home-collector/home-collector.service
Normal file
11
deploy/home-collector/home-collector.service
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Snapshot server stats, containers, timers and ntfy for stanrunge.dev/start
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
ConditionPathExists=%h/.config/home-collector.env
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 %h/.local/bin/home-collector
|
||||
TimeoutStartSec=50
|
||||
Nice=10
|
||||
10
deploy/home-collector/home-collector.timer
Normal file
10
deploy/home-collector/home-collector.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run home-collector every minute (stanrunge.dev/start)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=60s
|
||||
AccuracySec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
12
deploy/home-collector/install.sh
Executable file
12
deploy/home-collector/install.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install/refresh the /start collector as a systemd user timer (run as stan on the server).
|
||||
# Needs ~/.config/home-collector.env with HOME_DB_URL for the home_collector role (see README).
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
install -D -m 755 "$here/home-collector.py" "$HOME/.local/bin/home-collector"
|
||||
install -D -m 644 "$here/home-collector.service" "$HOME/.config/systemd/user/home-collector.service"
|
||||
install -D -m 644 "$here/home-collector.timer" "$HOME/.config/systemd/user/home-collector.timer"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now home-collector.timer
|
||||
systemctl --user start home-collector.service || true
|
||||
systemctl --user --no-pager status home-collector.service | head -5
|
||||
66
deploy/music-collector/README.md
Normal file
66
deploy/music-collector/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# music (self-hosted Last.fm)
|
||||
|
||||
`/trackers/music` (stats) and the "listening now" card on the homepage read the `music`
|
||||
schema in the `personal` database:
|
||||
|
||||
| table / view | contents |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `artists`, `albums` | Spotify ids when known (imports are matched by name), album art URLs (640 px + small) |
|
||||
| `tracks`, `track_artists` | `match_key` = normalised `artist\|title` (`src/lib/music/normalize.ts`) for cross-source dedupe |
|
||||
| `plays` | one row per play: `source` spotify / lastfm / youtube / local, `origin` api / export / import |
|
||||
| `plays_deduped` (view) | `plays` minus Last.fm scrobbles that Spotify also has (what every stat reads) |
|
||||
| `now_playing` | row 1, refreshed every minute |
|
||||
| `spotify_auth` | row 1, refresh token + poller state. **Not** granted to `personal_ro` |
|
||||
| `import_state` | resumable backfill progress (`lastfm:<user>`) |
|
||||
|
||||
## Spotify (live)
|
||||
|
||||
1. In the Spotify developer dashboard (the existing app, `SPOTIFY_CLIENT_ID`) add the redirect
|
||||
URIs `https://stanrunge.dev/api/spotify/callback` and
|
||||
`https://dev.stanrunge.dev/api/spotify/callback`.
|
||||
2. Logged in as the owner, open <https://stanrunge.dev/api/spotify/connect> once. Scopes:
|
||||
`user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read user-library-read playlist-read-private`.
|
||||
3. That's it: the **app itself** polls every minute (`src/lib/server/music/poller.ts`, started
|
||||
from `hooks.server.ts` `init`) with its own role and Spotify credentials. Status (last poll,
|
||||
last error) is shown to the owner on `/trackers/music`.
|
||||
|
||||
### Optional: poll from the host instead
|
||||
|
||||
Only if the poller should keep running independent of the app container. Needs its own role:
|
||||
|
||||
```sh
|
||||
sudo docker exec -i postgres-db-1 psql -U postgres -d personal <<'SQL'
|
||||
CREATE ROLE music_collector LOGIN CONNECTION LIMIT 3 PASSWORD '<pick one>';
|
||||
GRANT CONNECT ON DATABASE personal TO music_collector;
|
||||
GRANT USAGE ON SCHEMA music TO music_collector;
|
||||
GRANT SELECT, INSERT, UPDATE ON music.artists, music.albums, music.tracks, music.track_artists,
|
||||
music.plays, music.now_playing, music.import_state TO music_collector;
|
||||
GRANT SELECT, UPDATE ON music.spotify_auth TO music_collector;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO music_collector;
|
||||
SQL
|
||||
```
|
||||
|
||||
`~/.config/music-collector.env` (mode 600): `MUSIC_DB_URL=postgresql://music_collector:…@127.0.0.1:5440/personal`,
|
||||
`SPOTIFY_CLIENT_ID=…`, `SPOTIFY_CLIENT_SECRET=…`. Then `bash deploy/music-collector/install.sh --enable`
|
||||
and set `MUSIC_POLLER=off` in prod.env. (The music migration grants the same when the role
|
||||
already exists.) Debug: `journalctl --user -u music-collector -n 20`.
|
||||
|
||||
## Backfills
|
||||
|
||||
Both run from the deployed image with the app role, so no extra credentials:
|
||||
|
||||
```sh
|
||||
tag=$(sudo sed -n 's/^IMAGE_TAG=//p' /opt/personal-website/.env)
|
||||
# Last.fm (needs an API key from https://www.last.fm/api/account/create); resumable, re-run any time
|
||||
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
|
||||
-e LASTFM_API_KEY=<key> git.stanrunge.dev/stan/website:$tag bun scripts/music-import-lastfm.ts --user stanrunge
|
||||
# Spotify Extended Streaming History (unzipped export in ~/spotify-export)
|
||||
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
|
||||
-v ~/spotify-export:/data:ro git.stanrunge.dev/stan/website:$tag bun scripts/music-import-history.ts /data
|
||||
```
|
||||
|
||||
Dedupe: within one source a play within 60 s of the same `match_key` is skipped on insert;
|
||||
across sources `plays_deduped` hides a Last.fm scrobble when a Spotify play of the same
|
||||
`match_key` ended between 3 min before and track length + 3 min after the scrobble time
|
||||
(Last.fm records when a track started, Spotify when it ended). Listening time uses
|
||||
`ms_played` when known, else the track length.
|
||||
17
deploy/music-collector/install.sh
Executable file
17
deploy/music-collector/install.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the optional host-side Spotify poller as a systemd user timer (run as stan on the
|
||||
# server). Copies the units; enables the timer only with --enable (needs
|
||||
# ~/.config/music-collector.env, see README.md). Normally not needed: the app polls itself.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
install -D -m 644 "$here/music-collector.service" "$HOME/.config/systemd/user/music-collector.service"
|
||||
install -D -m 644 "$here/music-collector.timer" "$HOME/.config/systemd/user/music-collector.timer"
|
||||
systemctl --user daemon-reload
|
||||
if [ "${1:-}" = --enable ]; then
|
||||
test -f "$HOME/.config/music-collector.env" || { echo 'Missing ~/.config/music-collector.env' >&2; exit 1; }
|
||||
systemctl --user enable --now music-collector.timer
|
||||
systemctl --user start music-collector.service || true
|
||||
systemctl --user --no-pager status music-collector.service | head -5
|
||||
else
|
||||
echo 'Installed (not enabled). Enable with: bash deploy/music-collector/install.sh --enable'
|
||||
fi
|
||||
15
deploy/music-collector/music-collector.service
Normal file
15
deploy/music-collector/music-collector.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Spotify scrobbler poll (now playing + recently played -> music.*) for stanrunge.dev/trackers/music
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
ConditionPathExists=%h/.config/music-collector.env
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# A checkout of stan/website on main. Not the live dev tree.
|
||||
WorkingDirectory=%h/dev/website-music
|
||||
# MUSIC_DB_URL (music_collector role), SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET; mode 600.
|
||||
EnvironmentFile=%h/.config/music-collector.env
|
||||
ExecStart=/usr/bin/bun scripts/music-poll.ts
|
||||
TimeoutStartSec=50
|
||||
Nice=10
|
||||
10
deploy/music-collector/music-collector.timer
Normal file
10
deploy/music-collector/music-collector.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Spotify scrobbler poll every minute (optional; the app polls by itself)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=45s
|
||||
OnUnitActiveSec=60s
|
||||
AccuracySec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
13
deploy/systemd/house-scrape.service
Normal file
13
deploy/systemd/house-scrape.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=House tracker nightly scrape (Kamernet, Vesteda -> house.listings)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# A checkout of stan/website on main. Not the live dev tree.
|
||||
WorkingDirectory=%h/dev/website-houses
|
||||
# DB_URL (house_scraper role: house schema only), optional HOUSE_NTFY_URL; mode 600, never committed.
|
||||
EnvironmentFile=%h/.config/house-scrape.env
|
||||
ExecStart=/usr/bin/bun scripts/house-scrape.ts
|
||||
TimeoutStartSec=900
|
||||
10
deploy/systemd/house-scrape.timer
Normal file
10
deploy/systemd/house-scrape.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=House tracker scrape, once a night
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:40:00 Europe/Amsterdam
|
||||
RandomizedDelaySec=1200
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
13
deploy/systemd/money-sync.service
Normal file
13
deploy/systemd/money-sync.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Money tracker bank sync (Enable Banking -> money.transactions)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# A checkout of stan/website (main once merged). Not the live dev tree.
|
||||
WorkingDirectory=%h/dev/website-money
|
||||
# DB_URL (app role), ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH; mode 600, never committed.
|
||||
EnvironmentFile=%h/.config/money-sync.env
|
||||
ExecStart=/usr/bin/bun scripts/bank-sync.ts
|
||||
TimeoutStartSec=600
|
||||
10
deploy/systemd/money-sync.timer
Normal file
10
deploy/systemd/money-sync.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Money tracker bank sync, 4x per day (PSD2 allows ~4 unattended fetches/day)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06,11,16,21:20:00 Europe/Amsterdam
|
||||
RandomizedDelaySec=300
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
132
docs/game-tracker.md
Normal file
132
docs/game-tracker.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Game tracker
|
||||
|
||||
The development database has the journal migration and catalog imports applied. Production and staging need the same additive migration and seeds before deploying this code:
|
||||
|
||||
```sh
|
||||
bun run db:migrate:tracker-journal
|
||||
bun run db:seed:research
|
||||
bun run db:seed:challenges
|
||||
bun run db:seed:art
|
||||
```
|
||||
|
||||
These commands require `DB_URL`. They build on the existing Pokémon and Call of Duty catalogs. They preserve user catches, teams, ranks and challenge progress. Removed challenge definitions are retired only if nobody has saved progress for them. Deployment preflight checks the new tables and populated research catalogs.
|
||||
|
||||
## Local images and library layout
|
||||
|
||||
The tracker includes locally stored WebP pictures for **all 53 Pokémon entries, all seven COD entries and osu!**, plus the series logos. Most games use box art; expansions use their promotional artwork. Covers keep their original aspect ratio. No supplied files or remote image hotlinks are required. Replace a WebP at the same path to use your own art.
|
||||
|
||||
`data/game-images.json` records download URLs, original sources and credits. `data/pokemon-releases.json` records chronology. Game rights remain with Nintendo/The Pokémon Company, Activision, and ppy; these assets are not claimed as site-owned or openly licensed. The public `/projects/game-tracker/credits` page links the sources.
|
||||
|
||||
```sh
|
||||
# Pillow required; map rebuilding also needs beautifulsoup4.
|
||||
npm run catalog:images
|
||||
npm run catalog:map-art
|
||||
npm run check:images
|
||||
```
|
||||
|
||||
The downloader preserves transparency/aspect ratio, writes WebP atomically and reuses valid existing files. PokéAPI sprite URLs and Libretro box-art URLs are pinned to source commits. The three Scarlet/Violet maps are assembled from the original regional map tiles by `build-pokemon-map-art.py`. Map downloads are cached in `/tmp/game-map-art`.
|
||||
|
||||
The library has one column per series on desktop. Pokémon has a main journal link and only the games explicitly chosen in rules, arranged chronologically in exactly two columns; COD is chronological and osu! is a single entry. Large collections can scroll vertically so covers and names remain readable. At mobile sizes or high browser zoom, the series flow vertically. In the Pokémon journal, cover-only buttons stay visible across the header. They use one row for small selections and two rows for larger selections; the active cover is highlighted. Narrow screens can scroll the two-row cover strip horizontally.
|
||||
|
||||
## Pokémon
|
||||
|
||||
Open `/projects/game-tracker/pokemon?game=platinum`. A playthrough remembers its stage and selected location when **Save my place** is pressed. The adventure view hides encounters requiring radar, swarms, another cartridge or similar special conditions by default; these can be revealed with a checkbox. This is a convenience filter, not a full simulation of story gates or encounter eligibility.
|
||||
|
||||
The Pokédex defaults to a regional dex where the source has one. National order remains available. Map encounters show methods, areas, levels, rates and conditions. Species details include evolution information, stats, abilities, biology and searchable game-specific learnsets.
|
||||
|
||||
The companion layout keeps the current team and an animated completion ribbon above tabs for the map, Pokédex, PC and milestones. Dex and PC pages show smaller batches appropriate to the device. Desktop shows the map beside a fitted grid containing every species matching the selected area and filters, without an encounter-list scrollbar. On smaller screens, switch between the region map and area Pokémon. **Expand encounters** opens a larger grid; tapping any portrait opens encounter details and catch registration. Broad locations with hundreds of species use smaller portraits; area/floor filters and the expanded view make these easier to explore.
|
||||
|
||||
Register each individual catch with a nickname, current level, four moves, party slot, shiny flag, acquisition method, location, date and notes. The PC is sorted in National Pokédex order and shows the **first registered record** per species, even if a later record has an earlier catch date. Open species research to browse all duplicates. Assigning an occupied team slot moves the previous occupant to the PC. The six slots and caught checkmark update in one transaction; catch and checkmark mutations use the same per-user/game lock.
|
||||
|
||||
Existing caught checkmarks remain intact. A checkmark without an individual record offers **Add catch details**. Deleting a catch removes its individual details and party slot but retains the historical caught checkmark. Clear that checkmark separately after removing all records for the species. Species are separate records: after evolving a companion, record the evolved species and move it into the relevant team slot.
|
||||
|
||||
Badges, Elite Four, champions, selected bosses and collection milestones vary by game. Custom milestones cover personal goals and events outside the default list. Progress is split into story, postgame, and trades/transfers/events.
|
||||
|
||||
### Current catalog coverage
|
||||
|
||||
| Data | Records |
|
||||
| ------------------------------------- | ------: |
|
||||
| Species and species research | 1,025 |
|
||||
| Game catalog | 53 |
|
||||
| Regional dex/game/species assignments | 16,044 |
|
||||
| Moves | 937 |
|
||||
| Species/version-group learnsets | 11,331 |
|
||||
| Existing encounter rows | 67,282 |
|
||||
| Existing locations | 1,104 |
|
||||
|
||||
`data/pokemon-research.json` uses the same pinned [PokéAPI dataset](https://github.com/PokeAPI/pokeapi/tree/575291cdb197a7e3a320297be276c9de4ef8401a) as the existing catalog. Regenerate with `bun run catalog:research`, then seed. Learn methods and levels are version-specific; battle stats, move power/accuracy/PP and biology use the snapshot’s default forms and current values. Alternate forms and historical stat changes are not yet a complete catalog. Missing encounters are not evidence of unavailability, and recent titles have thinner source coverage.
|
||||
|
||||
### Atlas coverage
|
||||
|
||||
All **13 supported regions** now use local map images: Kanto (including the Sevii Islands), Johto, Hoenn, Sinnoh, Unova, Kalos, Alola, Galar (including its expansions), Hisui, Paldea, Orre, Kitakami and Blueberry Academy. The SVG schematic rendering has been removed. Maps preserve their aspect ratios and support zoom, dragging, keyboard scrolling, clickable markers and optional town labels.
|
||||
|
||||
`data/pokemon-map-art.json` stores image dimensions and marker positions. Kanto, Johto, Hoenn, Sinnoh, Unova and Kalos use the location coordinates associated with the [Serebii map images](https://www.serebii.net/pokearth/). Orre uses a Pokémon XD game screenshot. Paldea, Kitakami and the Terarium use assembled in-game map tiles. The remaining landmark positions are manually placed and labeled approximate. Images are regional references shared by titles in that region, not an exhaustive collection of every game's map revision or every interior map. The searchable location list continues to provide imported encounters outside the shown markers.
|
||||
|
||||
The original `pokemon-atlas.json` retains location and coordinate reference data from [pret](https://github.com/pret). Its build script does not control the new image backgrounds.
|
||||
|
||||
Pokémon sprites, shiny sprites and official artwork are stored as **3,075 local WebP images**, covering every one of the catalog's 1,025 species.
|
||||
|
||||
## Completion and discovery
|
||||
|
||||
The library and individual game pages show shadcn progress bars with two decimal places, so a single new entry remains visible even in a large catalog. Bars animate between values and respect reduced-motion preferences. **All progress** in Pokémon opens the full collection, regional dex, story, badge, league, boss, postgame and transfer/event breakdown.
|
||||
|
||||
Pokémon sightings are independent, per-user/per-game records. Use **seen** in the Pokédex or **Mark as seen** in an encounter dialog. Catches automatically record sightings; simply browsing the catalog never does. Historical caught checkmarks are backfilled as seen by the additive journal migration. A caught species cannot be marked unseen while its caught checkmark remains.
|
||||
|
||||
National caught/seen bars count each species once across the games selected in rules, against the full species catalog. Per-game collections include known acquisition methods (including trades/events/transfers) and exclude unconfirmed availability unless a real catch establishes it. In-game collection bars cover catching, evolution and breeding. Regional bars count distinct species across the imported regional dexes, without double-counting overlap.
|
||||
|
||||
Game completion balances collection, story, postgame and transfer/event progress equally where data exists. The story category includes a story-cleared checkpoint. **Mark story complete** records that checkpoint without inventing individual badge records; completing every default story milestone also infers it. Games completed counts cleared stories, not 100% collections. Personal milestones have a separate bar, so adding a custom goal does not change the main denominator. Changing selected games changes the scope; removing data can lower percentages.
|
||||
|
||||
COD completion equally weights cumulative player/weapon levels, weapon mastery, achievements and challenges where a denominator is known. Each recorded level counts immediately: `prestige × max level + current level`, out of `(max prestige + 1) × max level`. For example, M27 P0 L17 is 17/51, P1 L1 is 18/51, and P2 L17 is 51/51. Missing ranks contribute zero; launchers/melee without weapon levels and games without verified caps are excluded from rank denominators. Library and game totals share the same calculation; historical out-of-range records are bounded for display calculations, never rewritten. Each challenge contributes its fraction of a known target, or a completed/not-completed value when no target exists. Derived Diamond/Dark Matter tiers are shown separately and are not counted a second time in overall completion. Weapon cards, achievement groups, individual challenges and the full class breakdown show their own percentages. Black Ops II's catalog records a player cap of level 55 and prestige 10 (Prestige Master), a weapon prestige cap of 2, and attachment-rank caps by weapon class. Assault rifles cap at 17, SMGs and LMGs at 16, shotguns, pistols and most sniper rifles at 10, Ballista at 11 and Crossbow at 6. Launchers and the other melee specials have no attachment-rank progression. These caps are enforced by both the controls and server actions. osu! shows all-goal, pass, FC and accuracy completion from saved goals.
|
||||
|
||||
The BO2 cap list follows the [weapon-level unlock table](https://w.atwiki.jp/blackops2_cod/pages/47.html); the player Prestige Master threshold is documented by [Activision Support](https://support.activision.com/call-of-duty--black-ops-ii/articles/achieving-prestige-master-in-call-of-duty-black-ops-ii). The numeric player cap represents the level-55 prestige cycle; prestige 10 at level 55 displays the Master emblem.
|
||||
|
||||
Re-run `bun run db:migrate:tracker-journal` before deploying this update: it adds `pokemon_seen` and `pokemon_journeys.completed` without deleting existing data. Deployment preflight checks both.
|
||||
|
||||
## Call of Duty
|
||||
|
||||
Player and weapon levels/prestiges are separate from camo mastery. The challenges tab provides search, category filters, numeric progress where a source gives a target, completion checkboxes and linked requirements/rewards.
|
||||
|
||||
Weapon cards combine artwork, name, level, prestige and camo progress. Level and prestige **− / +** buttons save immediately, display save failures, and use atomic database increments to preserve simultaneous updates from multiple devices. Prestige + starts the next prestige at level 1. Tap the level number for direct editing. Positive prestige infers that maximum level has been reached at least once, and existing max-level checkmarks remain recognized. Black Ops II player and weapon level/prestige caps are enforced in both controls and server actions. The shared COD companion board groups weapons by class. At desktop sizes of 1280×720 and above, all 40 BO2 weapons fit on one screen; other games use 32-weapon pages. Narrow screens use a readable vertical layout. Every gun has a still image. Select a weapon name to edit camos and use full rank controls; the compact + button saves a level immediately. All ten BO2 prestige emblems and the Master emblem are served locally beside a level badge. Hide finished requires every prestige run and applicable camo milestone, not just a first-run max-level checkmark. Overall mastery statistics are available in the **Progress overview** dialog.
|
||||
|
||||
The imported snapshot has **3,674 challenges/operations**: Black Ops 1,489; Black Ops II 526; Ghosts 31; Advanced Warfare 75; Black Ops III 860; Infinite Warfare 329; WWII 364. This is a partial, attributed catalog, not an exhaustive inventory. Ghosts source rows with missing tier thresholds are excluded. Rewards without source text are left blank, and many emblem/calling-card pictures remain unavailable.
|
||||
|
||||
`scripts/build-cod-challenges.py` imports rendered, line-numbered wiki snapshots with `--snapshots <directory>`. The checked-in JSON is the deployment input; regenerating from newer research requires supplying source snapshots and reviewing ambiguous rows. Stable keys use game/category/name, so review renamed source headings before importing to avoid splitting progress.
|
||||
|
||||
208 locally served WebP weapon pictures are registered in `data/cod-art.json`, covering all seven games, with a larger shadcn dialog preview and original image credits. The base weapon galleries for Black Ops III and WWII are complete. Other games remain partial. Images include renders, game screenshots, named variants and real weapon reference photos, identified in the preview credits. All 618 cataloged achievement icons have local WebP copies. Add verified images and manifest entries, then run `db:seed:art`; the regular COD catalog seed also retains manifest images.
|
||||
|
||||
| Base weapon picture coverage | Pictures / weapons |
|
||||
| ---------------------------- | -----------------: |
|
||||
| Black Ops | 32 / 42 |
|
||||
| Black Ops II | 38 / 39 |
|
||||
| Ghosts | 20 / 40 |
|
||||
| Advanced Warfare | 34 / 35 |
|
||||
| Black Ops III | 31 / 31 |
|
||||
| Infinite Warfare | 20 / 35 |
|
||||
| WWII | 31 / 31 |
|
||||
|
||||
Additional pictures include BO2’s DLC Peacekeeper and Ghosts’ DLC Maverick-A2. BO2’s Combat Knife is the remaining entry without artwork; all guns have pictures. These counts do not imply complete DLC, camo, emblem or calling-card artwork.
|
||||
|
||||
Challenge text is adapted from **Call of Duty Wiki contributors**, with per-record source links. The adapted challenge dataset retains the source’s [CC BY-SA terms](https://www.fandom.com/licensing), per [the wiki’s copyright policy](https://callofduty.fandom.com/wiki/Call_of_Duty_Wiki:Copyrights). Game artwork retains its original owners’ rights; it is not covered by the challenge-text license. `data/cod-art.json` records each image’s source, download URL and credit. Pokémon artwork/sprites are downloaded from [PokéAPI’s sprite repository](https://github.com/PokeAPI/sprites) and served locally.
|
||||
|
||||
## osu!
|
||||
|
||||
`/projects/game-tracker/osu` contains beatmap goals, memorable scores and training sessions for osu!, taiko, catch and mania. Track pass/FC/accuracy/custom goals, score links, pp, combo, misses, mods and notes. Practice records have a skill, map BPM, playback rate and duration. The chart compares qualifying sessions, with a minimum duration filter. Effective BPM is map BPM multiplied by playback rate. These are manual journal entries; there is no automatic account/score import.
|
||||
|
||||
## Shared components and validation
|
||||
|
||||
Pokémon rules/research/catches, COD ranks/artwork, osu entries, Animaldex details, money forms, article creation and list/item creation/editing use shadcn Dialog. Destructive list/article/language reset controls use Alert Dialog. Existing custom selects use Native Select. Money/list modal fields use shared inputs, textareas and checkboxes. Native disclosure sections that explain content are retained.
|
||||
|
||||
```sh
|
||||
npm run check
|
||||
npm run build
|
||||
npm run test:unit -- --run
|
||||
npm run test:tracker
|
||||
```
|
||||
|
||||
Tracker browser tests use the migrated **local development** database, start their own server on port 4174, create temporary users and delete them afterward. They check Platinum routes and dex ordering, duplicate catch policy, team slot reassignment, ownership, rank/challenge persistence, osu training calculations, dialog keyboard behavior, mobile page widths, local image loading, picture-button selection, and a readable two-column 53-game Pokémon library. Companion checks cover immediate weapon adjustments, concurrent updates, failed-save recovery, prestige inference, all-species encounter grid fitting, expanded encounters and catch registration from the map. Progress checks cover persisted sightings, caught/seen deduplication, completed stories, partial COD challenges, osu! goal completion, rules-scoped totals and direct cover switching. Do not point `test:tracker` at a shared or production database.
|
||||
|
||||
## Deployment verification
|
||||
|
||||
`deploy-remote.sh` takes a PostgreSQL-version-matched custom-format backup and validates its archive before running the repeatable migrations and catalog seeds on each environment. The COD seed retains stable IDs and preserves stale items with either checklist progress **or weapon ranks**. No saved player/weapon levels are reset or clamped in storage. `audit-tracker-data.ts` prints only row counts and fingerprints for saved tracker records; compare before/after each deployment (newly added journal tables and backfilled sightings are expected). BO2 catalog caps, weapon counts and image coverage are checked by deployment preflight.
|
||||
|
||||
BO2 data sources: [attachment level unlocks](https://w.atwiki.jp/blackops2_cod/pages/47.html), [Peacekeeper attachments](https://callofduty.fandom.com/wiki/Peacekeeper), and [weapon prestige](https://callofduty.fandom.com/wiki/Prestige_Mode). Artwork URLs and credits are in `data/cod-art.json` and `data/cod-rank-art.json` and included in the public credits page.
|
||||
251
docs/house-tracker.md
Normal file
251
docs/house-tracker.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# House tracker
|
||||
|
||||
## Nightly scrape on stan-vm (2026-10)
|
||||
|
||||
`scripts/house-scrape.ts` runs once a night from a systemd user timer
|
||||
(`deploy/systemd/house-scrape.{service,timer}`, 03:40 Amsterdam + up to 20 min jitter) and
|
||||
writes `house.listings` directly with the limited `house_scraper` role
|
||||
(`~/.config/house-scrape.env`, mode 600). One request at a time, 6–10 s apart, honest
|
||||
User-Agent, robots.txt-allowed paths only. A 403/429/challenge stops that source for the
|
||||
night and is logged as `challenged`; nothing is retried or worked around.
|
||||
|
||||
| source | how | status |
|
||||
| ---------------------- | ------------------------------------------------------------- | ------------------------------------ |
|
||||
| Kamernet | `/huren/huurwoningen-<city>` pages, `__NEXT_DATA__` JSON | scraped (10 cities around Gouda) |
|
||||
| Vesteda | `POST /api/units/search/facet` (what their site calls), 20 km | scraped; rented units count as gone |
|
||||
| Pararius | Cloudflare 403 from the Hetzner IP | not scraped here; home worker below |
|
||||
| huurwoningen.nl | same Cloudflare 403 (Pararius group) | skipped |
|
||||
| Funda | bot-check page ("Je bent bijna op de pagina…") | skipped |
|
||||
| Huurstunt | Cloudflare challenge | skipped |
|
||||
| Mijndak Midden-Holland | OutSystems app behind a login, no public listing feed | skipped; use their own e-mail alerts |
|
||||
|
||||
Same home on two sources: `address_key` (street + number + city, normalised, see
|
||||
`src/lib/house-tracker/address.ts`) links the copy to the original via `duplicate_of`; a
|
||||
manual entry wins. The page shows one row with "also on …" links. Kamernet never shows house
|
||||
numbers, so its listings are never merged. Alerts: new non-copy listings matching an alert
|
||||
rule go to `HOUSE_NTFY_URL` (its own topic, max 5 per night plus one summary); the first run
|
||||
of a source only seeds.
|
||||
|
||||
No caps at scrape time: every listing in the region is stored (Kamernet city pages, Vesteda
|
||||
with an open rent range, Pararius without a price band). The €2,000 / 2-bedroom / 50 m²
|
||||
limits are only the page's default filters (clear a field to drop it) and the alert rule.
|
||||
|
||||
Liveability: every listing gets a Leefbaarometer 3.0 score (BZK, measurement 2024, published
|
||||
July 2026; `src/lib/house-tracker/liveability.ts`). The address is geocoded with PDOK
|
||||
Locatieserver (exact address, else 6-char postcode, else street centroid → marked approx.),
|
||||
then the 100 m grid cell and the buurt containing the point are read from the official WFS
|
||||
(`geo.leefbaarometer.nl/lbm3`, layers `clippedgridscore24` / `buurtscore24`). The cell score
|
||||
is used when there is one (and the location is not street-level), else the buurt. Stored on
|
||||
the row: `lbm_class` (1-9, zeer onvoldoende … uitstekend), `lbm_onv_class` (overlast &
|
||||
onveiligheid, 1-9 where 5 = Dutch average), buurt, level, precision, year, and all
|
||||
sub-scores in `lbm_detail`. Scored by the nightly scrape (new and changed addresses), the
|
||||
CLI and the page's add form; backfill or re-score with `scripts/house-liveability.ts
|
||||
[--force]`. For a new measurement, bump `LBM_YEAR` and run it with `--force`.
|
||||
|
||||
Manual listings: `scripts/house-listing.ts` (CLI) or the form on the page. Against prod use
|
||||
`scripts/house-db.sh <command> …`, which runs the CLI as `house_cli` (SELECT/INSERT/UPDATE/
|
||||
DELETE on house.listings, SELECT/INSERT on house.price_history, nothing else) from
|
||||
`~/.config/house-cli.env`.
|
||||
|
||||
Personal tool: collects Dutch housing listings into Postgres, shows them on
|
||||
`/trackers/houses` (owner only, 404 for everyone else; the old URL redirects) and sends an alert when a new listing
|
||||
matches a rule. Only Pararius for now; other sources plug in the same way.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
home box (residential IP) stanrunge.dev (Hetzner)
|
||||
┌──────────────────────────┐ POST sync ┌──────────────────────────────┐
|
||||
│ scripts/house-worker.ts │ ──────────────▶ │ /projects/house-tracker/api/ │
|
||||
│ polls pararius.nl │ POST details │ worker/{sync,details,report}│
|
||||
│ parses HTML │ ◀────────────── │ stores, dedups, alerts │
|
||||
└──────────────────────────┘ needDetails └──────────────┬───────────────┘
|
||||
▼
|
||||
Discord webhook / ntfy push
|
||||
```
|
||||
|
||||
The server never talks to the listing sites. Pararius sits behind Cloudflare,
|
||||
which challenges datacenter IPs and plain HTTP clients, so the worker runs at
|
||||
home where traffic looks like a normal visitor.
|
||||
|
||||
Two lanes:
|
||||
|
||||
- **fast**: one filtered search (default `huurwoningen/gouda/1300-1700`), page 1
|
||||
only. Every 3–5 min on weekdays 07:00–18:00 Amsterdam time, every 30–60 min
|
||||
otherwise. Listings near Gouda were all published in office hours on weekdays.
|
||||
Alerts are sent from the search card alone, before the detail page is fetched.
|
||||
- **slow**: full, paginated sweeps of rent and buy listings in Gouda, Rotterdam,
|
||||
Den Haag, Utrecht, Amsterdam and Eindhoven every 12 h. Unfiltered city sweeps
|
||||
also mark listings that disappeared, which gives "days listed" in the stats.
|
||||
|
||||
Safety rules in the worker: one request at a time, at least 8 s apart with
|
||||
jitter, a daily budget (1500 by default, the slow lane may only use 60%), and
|
||||
any challenge/403/429 stops everything and backs off 15 min → 30 → 60 … 6 h.
|
||||
The first poll of any search only seeds the database, so you don't get 40
|
||||
alerts for listings that were already online.
|
||||
|
||||
## Server setup
|
||||
|
||||
Add to the production env file (`/etc/personal-website/prod.env`):
|
||||
|
||||
```text
|
||||
# openssl rand -hex 32
|
||||
HOUSE_WORKER_TOKEN=
|
||||
# one or both
|
||||
HOUSE_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
HOUSE_NTFY_URL=https://ntfy.sh/some-long-random-topic
|
||||
HOUSE_NTFY_TOKEN= # only for a protected ntfy topic
|
||||
PUBLIC_MAPTILER_API_KEY= # already set for Animaldex
|
||||
```
|
||||
|
||||
The `house` schema is part of the Drizzle migrations that run on every deploy. Use
|
||||
"send test alert" on the page to check the alert channel.
|
||||
|
||||
If Cloudflare Bot Fight Mode / WAF challenges are on for stanrunge.dev, add a
|
||||
WAF skip rule for `URI Path starts with /projects/house-tracker/api/worker/`,
|
||||
otherwise the worker's POSTs get challenged too. The endpoints are protected by
|
||||
the bearer token.
|
||||
|
||||
### Development behind Cloudflare Access
|
||||
|
||||
For `https://dev.stanrunge.dev`, keep Access enabled. In Cloudflare Zero Trust,
|
||||
go to **Access controls → Service credentials → Service Tokens** and create a
|
||||
token named `house-worker`. Save its Client ID and Client Secret. In the Access
|
||||
application protecting the dev hostname, add a **Service Auth** policy with
|
||||
**Include → Service Token → house-worker**. Keep the existing browser login
|
||||
policy. See [Cloudflare's service token documentation](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/).
|
||||
|
||||
Set `HOUSE_WORKER_TOKEN` in the dev website's `.env` and restart its dev server.
|
||||
On the home machine, copy `house.env.example` to `house.env`, fill in the same
|
||||
worker token plus the Cloudflare Client ID and Client Secret, then run:
|
||||
|
||||
```sh
|
||||
chmod 600 house.env
|
||||
bun --env-file=house.env scripts/house-worker.ts --check-api
|
||||
```
|
||||
|
||||
This makes one authenticated GET to the website's worker health endpoint. It
|
||||
does not contact Pararius, sync listings or send alerts. The expected output is
|
||||
`API connection OK`. Access credentials are sent only to the website API; the
|
||||
client refuses redirects and stops immediately on an authentication failure.
|
||||
An HTTP 503 can mean the website's worker token is missing; an HTTP 404 can mean
|
||||
the running website does not yet include the health route.
|
||||
|
||||
## Home worker setup
|
||||
|
||||
Needs a machine that stays on (old laptop, Raspberry Pi, NAS) with Bun 1.4 or
|
||||
newer; older versions cannot read this repository's lockfile. Use `bun upgrade`
|
||||
if needed.
|
||||
|
||||
```sh
|
||||
git clone https://git.stanrunge.dev/stan/website ~/house-worker
|
||||
cd ~/house-worker && bun install --frozen-lockfile
|
||||
cat > house.env <<'EOF'
|
||||
HOUSE_API_URL=https://stanrunge.dev
|
||||
HOUSE_WORKER_TOKEN=same-value-as-the-server
|
||||
EOF
|
||||
chmod 600 house.env
|
||||
|
||||
# 1. check parsing without sending anything
|
||||
bun --env-file=house.env scripts/house-worker.ts --dry-run --once
|
||||
# 2. one real poll
|
||||
bun --env-file=house.env scripts/house-worker.ts --once
|
||||
```
|
||||
|
||||
Then run it as a service, `/etc/systemd/system/house-worker.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=House tracker worker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=stan
|
||||
WorkingDirectory=/home/stan/house-worker
|
||||
ExecStart=/home/stan/.bun/bin/bun --env-file=house.env scripts/house-worker.ts
|
||||
Restart=always
|
||||
RestartSec=60
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo systemctl enable --now house-worker
|
||||
journalctl -u house-worker -f
|
||||
```
|
||||
|
||||
Or with Docker: `docker run -d --restart unless-stopped --name house-worker
|
||||
--env-file house.env -v "$PWD":/app -w /app oven/bun:1.4.0 bun
|
||||
scripts/house-worker.ts`.
|
||||
|
||||
## Worker settings
|
||||
|
||||
| variable | default |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `HOUSE_FAST_SEARCHES` | `https://www.pararius.nl/huurwoningen/gouda/1300-1700` (comma separated) |
|
||||
| `HOUSE_SLOW_SEARCHES` | rent + buy for the six cities above |
|
||||
| `HOUSE_SLOW_EVERY_HOURS` | `12` |
|
||||
| `HOUSE_SLOW_MAX_PAGES` | `80` per search |
|
||||
| `HOUSE_MIN_GAP_SECONDS` | `8` between any two requests |
|
||||
| `HOUSE_DAILY_BUDGET` | `1500` requests |
|
||||
| `HOUSE_FETCH_MODE` | `fetch`, or `browser` (real Chromium) |
|
||||
|
||||
Any Pararius search URL works as a fast search: set the filters on the site
|
||||
(price, radius, bedrooms) and paste the URL. Every extra fast search is one more
|
||||
request per poll.
|
||||
|
||||
## When it gets challenged
|
||||
|
||||
You get one "worker was challenged" alert per 6 hours and the page header turns
|
||||
red. In order:
|
||||
|
||||
1. Do nothing for a day. Backoff handles short blocks.
|
||||
2. Slow down: raise `HOUSE_MIN_GAP_SECONDS`, lower `HOUSE_DAILY_BUDGET`, or drop
|
||||
slow searches.
|
||||
3. Save a page manually in your normal browser to investigate parsing offline.
|
||||
Do not switch clients, use proxies or solve challenges to bypass a block.
|
||||
|
||||
The optional `HOUSE_FETCH_MODE=browser` uses headless Chromium with a persistent
|
||||
profile (`npx playwright install chromium` once on the worker machine). It obeys
|
||||
the same stop/backoff rules and is not a workaround for a challenged fetch.
|
||||
|
||||
## Parsers
|
||||
|
||||
`src/lib/house-tracker/pararius.ts`, tested against trimmed real pages in
|
||||
`src/lib/house-tracker/fixtures/`. Listing pages expose an exact publish
|
||||
timestamp (`odp.offered_since`), coordinates, deposit, energy label and agent.
|
||||
`pararius-search-gouda.html` is a trimmed real search page saved on 2026-10-11,
|
||||
with tracking and account data removed. It covers three listings, nested base
|
||||
and total rent, action attributes and a lazy photo template. The synthetic
|
||||
fixture still covers pagination and price on request. Rental `priceEur` is the
|
||||
base/listed rent used by the search filter; a separately shown total can be
|
||||
higher (Raam: €1,475 base, €1,750 total). If Pararius changes markup, the worker
|
||||
reports `parse-error` in the worker log on the page.
|
||||
|
||||
## Adding listings by hand
|
||||
|
||||
`scripts/house-listing.ts` writes `house.listings` directly (links from chats,
|
||||
screenshots). No restart or deploy is needed for new rows.
|
||||
|
||||
```sh
|
||||
# DATABASE_URL or DB_URL must be set; locally: bun --env-file=.env run house …
|
||||
bun run house add --url https://vbtverhuurmakelaars.nl/woning/gouda-vest-7-d \
|
||||
--city Gouda --price "€ 1.422,- p.m." --area 84 --rooms 3 --bedrooms 2 --energy-label A
|
||||
echo '{"url":"…","city":"Gouda","price_eur":1350,"area_m2":81}' | bun run house add --json -
|
||||
bun run house add --street "Vest 7-D" --city Gouda --price 1422 # no link (screenshot)
|
||||
bun run house update <id|url> --status viewing --notes "za 14:00" # "" clears a field
|
||||
bun run house list [--all] [--city gouda] [--offer buy] [--json]
|
||||
bun run house remove <id|url> [--hard] # soft remove marks it gone; restore undoes it
|
||||
```
|
||||
|
||||
- `source` is the site (`funda`, `pararius`, `vbt`, `vesteda`, … or `manual`
|
||||
without a link); `source_id` comes from the URL (Funda's number, Pararius'
|
||||
worker key, otherwise the path), or a hash of the address. Adding the same
|
||||
link again updates that row and clears `removed_at`.
|
||||
- Fields follow `listingDetailSchema` (camelCase, snake_case or the flag names)
|
||||
and are lenient: "€ 1.422,- p.m." and "84 m²" are parsed. Only given fields are
|
||||
written. `city_slug` is derived from `city` in Pararius' slug style.
|
||||
- `--first-seen` sets when it was shared; price changes go to `price_history`.
|
||||
- Manual rows use `first_seen_lane = 'manual'` and never trigger alerts.
|
||||
@@ -1,12 +1,40 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
// Drizzle owns the core schemas only. Deliberately excluded:
|
||||
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
|
||||
// - legacy: pre-reorg tables kept read-only for rollback
|
||||
// - energy: owned by ned-collector
|
||||
// Migrations run as the schema owner (`personal`), never as the app role.
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/lib/server/db/schema/index.ts',
|
||||
out: './drizzle',
|
||||
schemaFilter: [
|
||||
'auth',
|
||||
'money',
|
||||
'house',
|
||||
'ref',
|
||||
'games',
|
||||
'cs',
|
||||
'animaldex',
|
||||
'site',
|
||||
'meta',
|
||||
'health',
|
||||
'books',
|
||||
'tasks',
|
||||
'groceries',
|
||||
'home',
|
||||
'cars',
|
||||
'music',
|
||||
'hardware',
|
||||
'planes',
|
||||
'wildlife',
|
||||
'trains'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
strict: true,
|
||||
dbCredentials: {
|
||||
url: process.env.DB_URL
|
||||
url: (process.env.DB_MIGRATE_URL || process.env.DB_URL)!
|
||||
}
|
||||
});
|
||||
|
||||
857
drizzle/20261023215112_baseline/migration.sql
Normal file
857
drizzle/20261023215112_baseline/migration.sql
Normal file
@@ -0,0 +1,857 @@
|
||||
CREATE SCHEMA "animaldex";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "auth";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "cs";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "games";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "house";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "meta";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "money";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "ref";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "site";
|
||||
--> statement-breakpoint
|
||||
CREATE TYPE "site"."blog_article_status" AS ENUM('draft', 'published');--> statement-breakpoint
|
||||
CREATE TYPE "ref"."availability" AS ENUM('catchable', 'trade_only', 'transfer_only', 'event_only', 'unknown', 'evolve', 'breed');--> statement-breakpoint
|
||||
CREATE TABLE "auth"."account" (
|
||||
"id" text PRIMARY KEY,
|
||||
"account_id" text NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp with time zone,
|
||||
"refresh_token_expires_at" timestamp with time zone,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."session" (
|
||||
"id" text PRIMARY KEY,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"token" text NOT NULL UNIQUE,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."user" (
|
||||
"id" text PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"role" text DEFAULT 'user' NOT NULL,
|
||||
"email" text NOT NULL UNIQUE,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."verification" (
|
||||
"id" text PRIMARY KEY,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "site"."blog_articles" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"slug" text NOT NULL UNIQUE,
|
||||
"content" text DEFAULT '' NOT NULL,
|
||||
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"status" "site"."blog_article_status" DEFAULT 'draft'::"site"."blog_article_status" NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "site"."tasks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"current_points" integer DEFAULT 0 NOT NULL,
|
||||
"total_points" integer DEFAULT 1 NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "site"."task_topics" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"task_id" integer NOT NULL,
|
||||
"topic_id" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "task_topics_task_id_topic_id_unique" UNIQUE("task_id","topic_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "site"."topics" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"emoji" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."catch_em_all_settings" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL UNIQUE,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_games" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"source_id" integer UNIQUE,
|
||||
"slug" text UNIQUE,
|
||||
"name" text NOT NULL,
|
||||
"generation" integer DEFAULT 1 NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"breeding" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"number" integer NOT NULL UNIQUE,
|
||||
"generation" integer DEFAULT 1 NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"type1" text NOT NULL,
|
||||
"type2" text,
|
||||
"hp" integer DEFAULT 0 NOT NULL,
|
||||
"attack" integer DEFAULT 0 NOT NULL,
|
||||
"defense" integer DEFAULT 0 NOT NULL,
|
||||
"special_attack" integer DEFAULT 0 NOT NULL,
|
||||
"special_defense" integer DEFAULT 0 NOT NULL,
|
||||
"speed" integer DEFAULT 0 NOT NULL,
|
||||
"evolves_from" integer,
|
||||
"evolution_chain" integer,
|
||||
"is_baby" boolean DEFAULT false NOT NULL,
|
||||
"is_legendary" boolean DEFAULT false NOT NULL,
|
||||
"is_mythical" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_encounters" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"pokemon_id" integer NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"location_id" integer NOT NULL,
|
||||
"area" text,
|
||||
"method" text NOT NULL,
|
||||
"min_level" integer,
|
||||
"max_level" integer,
|
||||
"rarity" integer,
|
||||
"form" text,
|
||||
"conditions" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"note" text,
|
||||
"source" text DEFAULT 'pokeapi' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_evolutions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"from_pokemon_id" integer NOT NULL,
|
||||
"to_pokemon_id" integer NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"method" text NOT NULL,
|
||||
"source" text DEFAULT 'pokeapi' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pokemon_evolutions_from_pokemon_id_to_pokemon_id_game_id_method_unique" UNIQUE("from_pokemon_id","to_pokemon_id","game_id","method")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_game_entries" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"pokemon_id" integer NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"availability" "ref"."availability" DEFAULT 'catchable'::"ref"."availability" NOT NULL,
|
||||
"note" text,
|
||||
"source" text DEFAULT 'pokeapi' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pokemon_game_entries_pokemon_id_game_id_unique" UNIQUE("pokemon_id","game_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_locations" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"source_id" integer UNIQUE,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"region" text,
|
||||
"description" text,
|
||||
"image_key" text,
|
||||
"image_credit" text,
|
||||
"source" text DEFAULT 'pokeapi' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."catch_em_all_entries" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"pokemon_game_entry_id" integer NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "catch_em_all_entries_pokemon_game_entry_id_user_id_unique" UNIQUE("pokemon_game_entry_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."catch_em_all_games" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "catch_em_all_games_user_id_game_id_unique" UNIQUE("user_id","game_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."tracker_games" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL UNIQUE,
|
||||
"franchise" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"short_name" text NOT NULL,
|
||||
"release_year" integer,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"rules" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."tracker_items" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"game_id" integer NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"icon" text,
|
||||
"post_launch" boolean DEFAULT false NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tracker_items_game_id_key_unique" UNIQUE("game_id","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."tracker_progress" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"item_id" integer NOT NULL,
|
||||
"milestone" text DEFAULT 'done' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tracker_progress_user_id_item_id_milestone_unique" UNIQUE("user_id","item_id","milestone")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."osu_entries" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"mode" text DEFAULT 'osu' NOT NULL,
|
||||
"beatmap_url" text,
|
||||
"score_url" text,
|
||||
"goal" text DEFAULT 'pass' NOT NULL,
|
||||
"skill" text DEFAULT 'stamina' NOT NULL,
|
||||
"bpm" integer,
|
||||
"duration" integer,
|
||||
"accuracy" numeric,
|
||||
"combo" integer,
|
||||
"misses" integer,
|
||||
"pp" numeric,
|
||||
"mods" text DEFAULT 'NM' NOT NULL,
|
||||
"rate" numeric(4,2) DEFAULT 1.00 NOT NULL,
|
||||
"achieved" boolean DEFAULT false NOT NULL,
|
||||
"played_on" date NOT NULL,
|
||||
"notes" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "osu_entries_kind_check" CHECK ("kind" IN ('score','goal','training'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."pokemon_catches" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"pokemon_id" integer NOT NULL,
|
||||
"nickname" text DEFAULT '' NOT NULL,
|
||||
"level" integer DEFAULT 1 NOT NULL,
|
||||
"moves" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"party_slot" integer,
|
||||
"shiny" boolean DEFAULT false NOT NULL,
|
||||
"nature" text,
|
||||
"ability" text,
|
||||
"ball" text,
|
||||
"location" text,
|
||||
"obtained" text DEFAULT 'caught' NOT NULL,
|
||||
"stage" text DEFAULT 'story' NOT NULL,
|
||||
"notes" text DEFAULT '' NOT NULL,
|
||||
"caught_on" date,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pokemon_catches_user_id_game_id_party_slot_unique" UNIQUE("user_id","game_id","party_slot"),
|
||||
CONSTRAINT "pokemon_catches_level_check" CHECK ("level" between 1 and 100),
|
||||
CONSTRAINT "pokemon_catches_party_slot_check" CHECK ("party_slot" between 1 and 6),
|
||||
CONSTRAINT "pokemon_catches_moves_check" CHECK (cardinality("moves") <= 4),
|
||||
CONSTRAINT "pokemon_catches_stage_check" CHECK ("stage" IN ('story','postgame','collection'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_dex_numbers" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"pokemon_id" integer NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"dex" text NOT NULL,
|
||||
"dex_name" text NOT NULL,
|
||||
"number" integer NOT NULL,
|
||||
CONSTRAINT "pokemon_dex_numbers_pokemon_id_game_id_dex_unique" UNIQUE("pokemon_id","game_id","dex")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_game_research" (
|
||||
"game_id" integer PRIMARY KEY,
|
||||
"version_group" integer NOT NULL,
|
||||
"regions" text[] NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."pokemon_journeys" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"stage" text DEFAULT 'story' NOT NULL,
|
||||
"location" text,
|
||||
"completed" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pokemon_journeys_user_id_game_id_unique" UNIQUE("user_id","game_id"),
|
||||
CONSTRAINT "pokemon_journeys_stage_check" CHECK ("stage" IN ('story','postgame','collection'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_learnsets" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"pokemon_id" integer NOT NULL,
|
||||
"version_group" integer NOT NULL,
|
||||
"moves" jsonb NOT NULL,
|
||||
CONSTRAINT "pokemon_learnsets_pokemon_id_version_group_unique" UNIQUE("pokemon_id","version_group")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."pokemon_milestones" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"stage" text DEFAULT 'story' NOT NULL,
|
||||
"done" boolean DEFAULT false NOT NULL,
|
||||
"notes" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pokemon_milestones_user_id_game_id_key_unique" UNIQUE("user_id","game_id","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_moves" (
|
||||
"id" integer PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"power" integer,
|
||||
"accuracy" integer,
|
||||
"pp" integer,
|
||||
"priority" integer NOT NULL,
|
||||
"target" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."pokemon_research" (
|
||||
"pokemon_id" integer PRIMARY KEY,
|
||||
"facts" jsonb NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."pokemon_seen" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"pokemon_id" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pokemon_seen_user_id_game_id_pokemon_id_unique" UNIQUE("user_id","game_id","pokemon_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."tracker_challenge_progress" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"challenge_id" integer NOT NULL,
|
||||
"current" integer DEFAULT 0 NOT NULL,
|
||||
"done" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tracker_challenge_progress_user_id_challenge_id_unique" UNIQUE("user_id","challenge_id"),
|
||||
CONSTRAINT "tracker_challenge_progress_current_check" CHECK ("current" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ref"."tracker_challenges" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"game_id" integer NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"requirement" text NOT NULL,
|
||||
"reward" text,
|
||||
"icon" text,
|
||||
"target" integer,
|
||||
"source_url" text NOT NULL,
|
||||
CONSTRAINT "tracker_challenges_game_id_key_unique" UNIQUE("game_id","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."tracker_ranks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"level" integer DEFAULT 1 NOT NULL,
|
||||
"prestige" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tracker_ranks_user_id_game_id_unique" UNIQUE("user_id","game_id"),
|
||||
CONSTRAINT "tracker_ranks_level_check" CHECK ("level" between 1 and 10000),
|
||||
CONSTRAINT "tracker_ranks_prestige_check" CHECK ("prestige" between 0 and 1000)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games"."tracker_weapon_ranks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"item_id" integer NOT NULL,
|
||||
"level" integer DEFAULT 1 NOT NULL,
|
||||
"prestige" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tracker_weapon_ranks_user_id_item_id_unique" UNIQUE("user_id","item_id"),
|
||||
CONSTRAINT "tracker_weapon_ranks_level_check" CHECK ("level" between 1 and 10000),
|
||||
CONSTRAINT "tracker_weapon_ranks_prestige_check" CHECK ("prestige" between 0 and 1000)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "animaldex"."animals" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"species" text NOT NULL,
|
||||
"breed" text,
|
||||
"animal_name" text,
|
||||
"description" text,
|
||||
"ai_breed_suggestion" text,
|
||||
"ai_breed_confidence" double precision,
|
||||
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"accepted_at" timestamp with time zone,
|
||||
"denied_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "animaldex"."sighting_photos" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"sighting_id" integer NOT NULL,
|
||||
"r2_key" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "animaldex"."sightings" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"animal_id" integer NOT NULL,
|
||||
"reporter_name" text,
|
||||
"seen_at" timestamp with time zone NOT NULL,
|
||||
"lat" double precision NOT NULL,
|
||||
"lng" double precision NOT NULL,
|
||||
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"accepted_at" timestamp with time zone,
|
||||
"denied_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "meta"."columns" (
|
||||
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "meta"."columns_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"table_id" integer NOT NULL,
|
||||
"column_name" text NOT NULL,
|
||||
"label" text,
|
||||
"ui_type" text NOT NULL,
|
||||
"options" jsonb DEFAULT '{}' NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"hidden" boolean DEFAULT false NOT NULL,
|
||||
"help_text" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "columns_table_id_column_name_key" UNIQUE("table_id","column_name"),
|
||||
CONSTRAINT "columns_ui_type_check" CHECK (ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "meta"."tables" (
|
||||
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "meta"."tables_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"schema_name" text NOT NULL,
|
||||
"table_name" text NOT NULL,
|
||||
"slug" text NOT NULL CONSTRAINT "tables_slug_key" UNIQUE,
|
||||
"display_name" text NOT NULL,
|
||||
"description" text,
|
||||
"icon" text,
|
||||
"picture_url" text,
|
||||
"kind" text NOT NULL,
|
||||
"editable" boolean DEFAULT true NOT NULL,
|
||||
"has_done" boolean DEFAULT false NOT NULL,
|
||||
"default_sort" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tables_schema_name_table_name_key" UNIQUE("schema_name","table_name"),
|
||||
CONSTRAINT "tables_slug_check" CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||
CONSTRAINT "tables_kind_check" CHECK (kind IN ('list','core','ref'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."accounts" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"opening_balance_cents" integer DEFAULT 0 NOT NULL,
|
||||
"kind" text DEFAULT 'spendable' NOT NULL,
|
||||
"color" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "accounts_user_id_name_unique" UNIQUE("user_id","name"),
|
||||
CONSTRAINT "accounts_kind_check" CHECK (kind IN ('spendable','investment'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."forecasts" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"date" date NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"amount_cents" integer NOT NULL,
|
||||
"account_id" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."insurances" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"provider" text,
|
||||
"coverage" text,
|
||||
"premium_cents" integer,
|
||||
"cadence" text DEFAULT 'monthly' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "insurances_cadence_check" CHECK (cadence IN ('weekly','monthly','yearly'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."projection_settings" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL UNIQUE,
|
||||
"investment_account_id" integer,
|
||||
"reserve_cents" integer DEFAULT 100000 NOT NULL,
|
||||
"annual_return_percent" numeric(5,2) DEFAULT '5.00' NOT NULL,
|
||||
"contribution_mode" text DEFAULT 'excess' NOT NULL,
|
||||
"include_recurring" boolean DEFAULT true NOT NULL,
|
||||
"fixed_monthly_cents" integer DEFAULT 0 NOT NULL,
|
||||
"end_date" date,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "projection_settings_contribution_mode_check" CHECK (contribution_mode IN ('excess','fixed'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."recurring" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"amount_cents" integer NOT NULL,
|
||||
"cadence" text NOT NULL,
|
||||
"starts_on" date,
|
||||
"ends_on" date,
|
||||
"account_id" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "recurring_cadence_check" CHECK (cadence IN ('weekly','monthly','yearly'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."transactions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"account_id" integer NOT NULL,
|
||||
"date" date NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"amount_cents" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."utility_readings" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"date" date NOT NULL,
|
||||
"reading" numeric(12,3) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "utility_readings_kind_check" CHECK (kind IN ('electricity','gas','water'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."utility_settings" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"rate_per_unit" numeric(12,4) DEFAULT '0' NOT NULL,
|
||||
"monthly_advance_cents" integer DEFAULT 0 NOT NULL,
|
||||
"billing_start" date,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "utility_settings_user_id_kind_unique" UNIQUE("user_id","kind"),
|
||||
CONSTRAINT "utility_settings_kind_check" CHECK (kind IN ('electricity','gas','water'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "money"."wishlist" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"estimated_cents" integer,
|
||||
"priority" integer DEFAULT 3 NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."item_dimensions" (
|
||||
"item_id" integer,
|
||||
"key" text,
|
||||
"value" text NOT NULL,
|
||||
CONSTRAINT "item_dimensions_pkey" PRIMARY KEY("item_id","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."items" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"market_hash_name" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"icon_url" text,
|
||||
"item_type" text,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."price_observations" (
|
||||
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "cs"."price_observations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"item_id" integer NOT NULL,
|
||||
"source" text NOT NULL,
|
||||
"currency" text NOT NULL,
|
||||
"observed_at" timestamp with time zone NOT NULL,
|
||||
"price_cents" integer NOT NULL,
|
||||
"volume" integer,
|
||||
"kind" text NOT NULL,
|
||||
"imported_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."price_streams" (
|
||||
"source" text,
|
||||
"currency" text,
|
||||
"kind" text,
|
||||
CONSTRAINT "price_streams_pkey" PRIMARY KEY("source","currency","kind")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "house"."alert_rules" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"offer" text DEFAULT 'rent' NOT NULL,
|
||||
"min_price" integer,
|
||||
"max_price" integer,
|
||||
"min_area" integer,
|
||||
"min_bedrooms" integer,
|
||||
"cities" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"property_types" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "alert_rules_offer_check" CHECK (offer IN ('rent', 'buy'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "house"."listings" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"source" text NOT NULL,
|
||||
"source_id" text NOT NULL,
|
||||
"external_id" text,
|
||||
"url" text NOT NULL,
|
||||
"offer" text NOT NULL,
|
||||
"property_type" text NOT NULL,
|
||||
"title" text,
|
||||
"street" text,
|
||||
"postal_code" text,
|
||||
"city" text,
|
||||
"city_slug" text,
|
||||
"neighbourhood" text,
|
||||
"price_eur" integer,
|
||||
"area_m2" integer,
|
||||
"rooms" integer,
|
||||
"bedrooms" integer,
|
||||
"bathrooms" integer,
|
||||
"construction_year" integer,
|
||||
"energy_label" text,
|
||||
"interior" text,
|
||||
"deposit_eur" integer,
|
||||
"available_from" text,
|
||||
"contract_type" text,
|
||||
"agent" text,
|
||||
"image_url" text,
|
||||
"description" text,
|
||||
"features" jsonb DEFAULT '{}' NOT NULL,
|
||||
"lat" double precision,
|
||||
"lng" double precision,
|
||||
"offered_at" timestamp with time zone,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"first_seen_lane" text DEFAULT 'slow' NOT NULL,
|
||||
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"detail_fetched_at" timestamp with time zone,
|
||||
"removed_at" timestamp with time zone,
|
||||
"alerted_at" timestamp with time zone,
|
||||
"status" text,
|
||||
"notes" text,
|
||||
CONSTRAINT "listings_offer_check" CHECK (offer IN ('rent', 'buy')),
|
||||
CONSTRAINT "listings_price_eur_check" CHECK (price_eur IS NULL OR price_eur >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "house"."price_history" (
|
||||
"listing_id" integer,
|
||||
"observed_at" timestamp with time zone DEFAULT now(),
|
||||
"price_eur" integer NOT NULL,
|
||||
CONSTRAINT "price_history_pkey" PRIMARY KEY("listing_id","observed_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "house"."searches" (
|
||||
"search_url" text PRIMARY KEY,
|
||||
"source" text NOT NULL,
|
||||
"lane" text NOT NULL,
|
||||
"first_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_found" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "house"."worker_runs" (
|
||||
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "house"."worker_runs_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"source" text NOT NULL,
|
||||
"lane" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"http_status" integer,
|
||||
"found" integer,
|
||||
"duration_ms" integer,
|
||||
"message" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "account_userId_idx" ON "auth"."account" ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "account_issuer_account_id_unique" ON "auth"."account" ("issuer","account_id");--> statement-breakpoint
|
||||
CREATE INDEX "session_userId_idx" ON "auth"."session" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "verification_identifier_idx" ON "auth"."verification" ("identifier");--> statement-breakpoint
|
||||
CREATE INDEX "pokemon_encounters_pokemon_id_game_id_index" ON "ref"."pokemon_encounters" ("pokemon_id","game_id");--> statement-breakpoint
|
||||
CREATE INDEX "pokemon_encounters_location_id_index" ON "ref"."pokemon_encounters" ("location_id");--> statement-breakpoint
|
||||
CREATE INDEX "pokemon_evolutions_game_id_index" ON "ref"."pokemon_evolutions" ("game_id");--> statement-breakpoint
|
||||
CREATE INDEX "pokemon_game_entries_game_id_index" ON "ref"."pokemon_game_entries" ("game_id");--> statement-breakpoint
|
||||
CREATE INDEX "catch_em_all_entries_user_id_index" ON "games"."catch_em_all_entries" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "catch_em_all_games_user_id_index" ON "games"."catch_em_all_games" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "tracker_items_game_id_index" ON "ref"."tracker_items" ("game_id");--> statement-breakpoint
|
||||
CREATE INDEX "tracker_progress_user_id_index" ON "games"."tracker_progress" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "osu_entries_user_id_kind_index" ON "games"."osu_entries" ("user_id","kind");--> statement-breakpoint
|
||||
CREATE INDEX "pokemon_catches_user_id_game_id_pokemon_id_index" ON "games"."pokemon_catches" ("user_id","game_id","pokemon_id");--> statement-breakpoint
|
||||
CREATE INDEX "pokemon_dex_numbers_game_id_index" ON "ref"."pokemon_dex_numbers" ("game_id");--> statement-breakpoint
|
||||
CREATE INDEX "pokemon_seen_user_id_index" ON "games"."pokemon_seen" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "columns_table_id_index" ON "meta"."columns" ("table_id");--> statement-breakpoint
|
||||
CREATE INDEX "accounts_user_id_index" ON "money"."accounts" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "forecasts_user_id_date_index" ON "money"."forecasts" ("user_id","date");--> statement-breakpoint
|
||||
CREATE INDEX "insurances_user_id_index" ON "money"."insurances" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "recurring_user_id_index" ON "money"."recurring" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_user_id_date_id_index" ON "money"."transactions" ("user_id","date","id");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_account_id_index" ON "money"."transactions" ("account_id");--> statement-breakpoint
|
||||
CREATE INDEX "utility_readings_user_id_kind_date_index" ON "money"."utility_readings" ("user_id","kind","date");--> statement-breakpoint
|
||||
CREATE INDEX "wishlist_user_id_index" ON "money"."wishlist" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "item_dimensions_filter_idx" ON "cs"."item_dimensions" ("key","value","item_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "items_hash_idx" ON "cs"."items" ("market_hash_name");--> statement-breakpoint
|
||||
CREATE INDEX "items_name_idx" ON "cs"."items" ("name");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "price_observations_unique_idx" ON "cs"."price_observations" ("item_id","source","currency","observed_at","kind");--> statement-breakpoint
|
||||
CREATE INDEX "price_observations_lookup_idx" ON "cs"."price_observations" ("item_id","source","currency","observed_at");--> statement-breakpoint
|
||||
CREATE INDEX "price_observations_time_idx" ON "cs"."price_observations" ("observed_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "listings_source_idx" ON "house"."listings" ("source","source_id");--> statement-breakpoint
|
||||
CREATE INDEX "listings_active_idx" ON "house"."listings" ("offer","first_seen_at" DESC) WHERE removed_at IS NULL;--> statement-breakpoint
|
||||
CREATE INDEX "listings_city_idx" ON "house"."listings" ("source","offer","city_slug");--> statement-breakpoint
|
||||
CREATE INDEX "listings_details_idx" ON "house"."listings" ("first_seen_at" DESC) WHERE detail_fetched_at IS NULL;--> statement-breakpoint
|
||||
CREATE INDEX "worker_runs_created_idx" ON "house"."worker_runs" ("created_at" DESC);--> statement-breakpoint
|
||||
ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "site"."task_topics" ADD CONSTRAINT "task_topics_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "site"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "site"."task_topics" ADD CONSTRAINT "task_topics_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "site"."topics"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."catch_em_all_settings" ADD CONSTRAINT "catch_em_all_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_location_id_pokemon_locations_id_fkey" FOREIGN KEY ("location_id") REFERENCES "ref"."pokemon_locations"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_from_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("from_pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_to_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("to_pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_game_entries" ADD CONSTRAINT "pokemon_game_entries_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_game_entries" ADD CONSTRAINT "pokemon_game_entries_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."catch_em_all_entries" ADD CONSTRAINT "catch_em_all_entries_HeABXvGNiB6D_fkey" FOREIGN KEY ("pokemon_game_entry_id") REFERENCES "ref"."pokemon_game_entries"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."catch_em_all_entries" ADD CONSTRAINT "catch_em_all_entries_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."catch_em_all_games" ADD CONSTRAINT "catch_em_all_games_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."catch_em_all_games" ADD CONSTRAINT "catch_em_all_games_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."tracker_items" ADD CONSTRAINT "tracker_items_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."tracker_progress" ADD CONSTRAINT "tracker_progress_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."tracker_progress" ADD CONSTRAINT "tracker_progress_item_id_tracker_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "ref"."tracker_items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."osu_entries" ADD CONSTRAINT "osu_entries_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_dex_numbers" ADD CONSTRAINT "pokemon_dex_numbers_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_dex_numbers" ADD CONSTRAINT "pokemon_dex_numbers_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_game_research" ADD CONSTRAINT "pokemon_game_research_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_journeys" ADD CONSTRAINT "pokemon_journeys_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_journeys" ADD CONSTRAINT "pokemon_journeys_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_learnsets" ADD CONSTRAINT "pokemon_learnsets_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_milestones" ADD CONSTRAINT "pokemon_milestones_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_milestones" ADD CONSTRAINT "pokemon_milestones_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."pokemon_research" ADD CONSTRAINT "pokemon_research_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."tracker_challenge_progress" ADD CONSTRAINT "tracker_challenge_progress_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."tracker_challenge_progress" ADD CONSTRAINT "tracker_challenge_progress_HtzyFtOyUAhk_fkey" FOREIGN KEY ("challenge_id") REFERENCES "ref"."tracker_challenges"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "ref"."tracker_challenges" ADD CONSTRAINT "tracker_challenges_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."tracker_ranks" ADD CONSTRAINT "tracker_ranks_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."tracker_ranks" ADD CONSTRAINT "tracker_ranks_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."tracker_weapon_ranks" ADD CONSTRAINT "tracker_weapon_ranks_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "games"."tracker_weapon_ranks" ADD CONSTRAINT "tracker_weapon_ranks_item_id_tracker_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "ref"."tracker_items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "animaldex"."sighting_photos" ADD CONSTRAINT "sighting_photos_sighting_id_sightings_id_fkey" FOREIGN KEY ("sighting_id") REFERENCES "animaldex"."sightings"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "animaldex"."sightings" ADD CONSTRAINT "sightings_animal_id_animals_id_fkey" FOREIGN KEY ("animal_id") REFERENCES "animaldex"."animals"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "meta"."columns" ADD CONSTRAINT "columns_table_id_tables_id_fkey" FOREIGN KEY ("table_id") REFERENCES "meta"."tables"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."forecasts" ADD CONSTRAINT "forecasts_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."forecasts" ADD CONSTRAINT "forecasts_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."insurances" ADD CONSTRAINT "insurances_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."projection_settings" ADD CONSTRAINT "projection_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."projection_settings" ADD CONSTRAINT "projection_settings_investment_account_id_accounts_id_fkey" FOREIGN KEY ("investment_account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."recurring" ADD CONSTRAINT "recurring_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."recurring" ADD CONSTRAINT "recurring_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id");--> statement-breakpoint
|
||||
ALTER TABLE "money"."utility_readings" ADD CONSTRAINT "utility_readings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."utility_settings" ADD CONSTRAINT "utility_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."wishlist" ADD CONSTRAINT "wishlist_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."item_dimensions" ADD CONSTRAINT "item_dimensions_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_observations" ADD CONSTRAINT "price_observations_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "house"."price_history" ADD CONSTRAINT "price_history_listing_id_listings_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "house"."listings"("id") ON DELETE CASCADE;
|
||||
10307
drizzle/20261023215112_baseline/snapshot.json
Normal file
10307
drizzle/20261023215112_baseline/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
148
drizzle/20261024002120_health/migration.sql
Normal file
148
drizzle/20261024002120_health/migration.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
CREATE SCHEMA "health";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."appointments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL,
|
||||
"type" text DEFAULT 'appointment' NOT NULL,
|
||||
"provider" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "appointments_type_check" CHECK ("type" IN ('appointment','checkup','condition','test','procedure','other')),
|
||||
CONSTRAINT "appointments_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."body_measurements" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL,
|
||||
"weight_kg" numeric(5,2),
|
||||
"waist_cm" numeric(5,1),
|
||||
"body_fat_pct" numeric(4,1),
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "body_measurements_any_check" CHECK ("weight_kg" IS NOT NULL OR "waist_cm" IS NOT NULL OR "body_fat_pct" IS NOT NULL),
|
||||
CONSTRAINT "body_measurements_weight_check" CHECK ("weight_kg" IS NULL OR "weight_kg" BETWEEN 20 AND 400),
|
||||
CONSTRAINT "body_measurements_waist_check" CHECK ("waist_cm" IS NULL OR "waist_cm" BETWEEN 30 AND 250),
|
||||
CONSTRAINT "body_measurements_fat_check" CHECK ("body_fat_pct" IS NULL OR "body_fat_pct" BETWEEN 1 AND 75),
|
||||
CONSTRAINT "body_measurements_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."medications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"name" text NOT NULL,
|
||||
"kind" text DEFAULT 'medication' NOT NULL,
|
||||
"dose" text,
|
||||
"frequency" text,
|
||||
"start_date" date,
|
||||
"stop_date" date,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "medications_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "medications_kind_check" CHECK ("kind" IN ('medication','supplement')),
|
||||
CONSTRAINT "medications_dates_check" CHECK ("stop_date" IS NULL OR "start_date" IS NULL OR "stop_date" >= "start_date")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."nutrition_daily" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL CONSTRAINT "nutrition_daily_date_key" UNIQUE,
|
||||
"kcal" integer,
|
||||
"protein_g" numeric(5,1),
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "nutrition_daily_kcal_check" CHECK ("kcal" IS NULL OR "kcal" BETWEEN 0 AND 20000),
|
||||
CONSTRAINT "nutrition_daily_protein_check" CHECK ("protein_g" IS NULL OR "protein_g" BETWEEN 0 AND 1000),
|
||||
CONSTRAINT "nutrition_daily_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."sleep" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL CONSTRAINT "sleep_date_key" UNIQUE,
|
||||
"bedtime" timestamp with time zone NOT NULL,
|
||||
"wake_time" timestamp with time zone NOT NULL,
|
||||
"hours" numeric(4,2) GENERATED ALWAYS AS (round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)) STORED,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sleep_order_check" CHECK ("wake_time" > "bedtime"),
|
||||
CONSTRAINT "sleep_length_check" CHECK ("wake_time" - "bedtime" <= interval '24 hours'),
|
||||
CONSTRAINT "sleep_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."vaccinations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"vaccine" text NOT NULL,
|
||||
"target" text,
|
||||
"dose_no" integer,
|
||||
"date" date NOT NULL,
|
||||
"batch_lot" text,
|
||||
"provider" text,
|
||||
"country" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "vaccinations_vaccine_check" CHECK (length(btrim("vaccine")) > 0),
|
||||
CONSTRAINT "vaccinations_dose_no_check" CHECK ("dose_no" IS NULL OR "dose_no" BETWEEN 1 AND 20),
|
||||
CONSTRAINT "vaccinations_country_check" CHECK ("country" IS NULL OR "country" ~ '^[A-Z]{2}$'),
|
||||
CONSTRAINT "vaccinations_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."workouts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL,
|
||||
"session_id" uuid,
|
||||
"exercise" text NOT NULL,
|
||||
"sets" integer,
|
||||
"reps" integer,
|
||||
"load_kg" numeric(6,2),
|
||||
"rpe" numeric(3,1),
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "workouts_exercise_check" CHECK (length(btrim("exercise")) > 0),
|
||||
CONSTRAINT "workouts_sets_check" CHECK ("sets" IS NULL OR "sets" BETWEEN 1 AND 100),
|
||||
CONSTRAINT "workouts_reps_check" CHECK ("reps" IS NULL OR "reps" BETWEEN 0 AND 1000),
|
||||
CONSTRAINT "workouts_load_check" CHECK ("load_kg" IS NULL OR "load_kg" BETWEEN 0 AND 1000),
|
||||
CONSTRAINT "workouts_rpe_check" CHECK ("rpe" IS NULL OR "rpe" BETWEEN 1 AND 10),
|
||||
CONSTRAINT "workouts_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "appointments_date_idx" ON "health"."appointments" ("date");--> statement-breakpoint
|
||||
CREATE INDEX "body_measurements_date_idx" ON "health"."body_measurements" ("date");--> statement-breakpoint
|
||||
CREATE INDEX "medications_start_date_idx" ON "health"."medications" ("start_date");--> statement-breakpoint
|
||||
CREATE INDEX "medications_stop_date_idx" ON "health"."medications" ("stop_date");--> statement-breakpoint
|
||||
CREATE INDEX "vaccinations_date_idx" ON "health"."vaccinations" ("date");--> statement-breakpoint
|
||||
CREATE INDEX "workouts_date_idx" ON "health"."workouts" ("date");--> statement-breakpoint
|
||||
CREATE INDEX "workouts_session_idx" ON "health"."workouts" ("session_id");
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- updated_at triggers, same as every other core table (07_triggers_meta_grants.sql).
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
FOREACH t IN ARRAY ARRAY['vaccinations','body_measurements','workouts','nutrition_daily','sleep','medications','appointments'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON health.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Privacy: health data is readable/writable by the app role only. Unlike the other core
|
||||
-- schemas it is deliberately NOT granted to personal_ro, and it is not registered in
|
||||
-- meta.tables (so the generic list/table GUI never shows it).
|
||||
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
|
||||
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh.
|
||||
REVOKE ALL ON SCHEMA health FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA health TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA health TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
11583
drizzle/20261024002120_health/snapshot.json
Normal file
11583
drizzle/20261024002120_health/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
65
drizzle/20261024003809_money_bank_sync/migration.sql
Normal file
65
drizzle/20261024003809_money_bank_sync/migration.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
CREATE TABLE "money"."bank_sessions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"provider" text DEFAULT 'enablebanking' NOT NULL,
|
||||
"session_id" text NOT NULL CONSTRAINT "bank_sessions_session_id_key" UNIQUE,
|
||||
"aspsp_name" text NOT NULL,
|
||||
"aspsp_country" text NOT NULL,
|
||||
"psu_type" text,
|
||||
"status" text DEFAULT 'authorized' NOT NULL,
|
||||
"valid_until" timestamp with time zone,
|
||||
"accounts" jsonb DEFAULT '[]' NOT NULL,
|
||||
"last_synced_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "bank_sessions_status_check" CHECK (status IN ('authorized','expired','revoked','error'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "scope" text DEFAULT 'personal' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "owner" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "iban" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "external_uid" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "provider" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "external_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "source" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "booking_status" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "counterparty" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "category" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "raw" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "value_date" date;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_external_uid_key" UNIQUE("external_uid");--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_account_external_id_key" UNIQUE("account_id","external_id");--> statement-breakpoint
|
||||
CREATE INDEX "bank_sessions_user_id_index" ON "money"."bank_sessions" ("user_id");--> statement-breakpoint
|
||||
ALTER TABLE "money"."bank_sessions" ADD CONSTRAINT "bank_sessions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_scope_check" CHECK (scope IN ('personal','joint'));--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_provider_check" CHECK (provider IN ('manual','enablebanking'));--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_source_check" CHECK (source IN ('manual','csv','enablebanking'));--> statement-breakpoint
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- Backfill: the shared account with Juhee is the only joint one; everything else stays personal.
|
||||
UPDATE "money"."accounts" SET "scope" = 'joint', "owner" = 'Stan & Juhee' WHERE lower("name") = 'joint';
|
||||
--> statement-breakpoint
|
||||
UPDATE "money"."accounts" SET "owner" = 'Stan' WHERE "scope" = 'personal' AND "owner" IS NULL;
|
||||
--> statement-breakpoint
|
||||
-- updated_at trigger, same as every other core table (07_triggers_meta_grants.sql).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON money.bank_sessions
|
||||
FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
|
||||
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh. Default
|
||||
-- privileges on `money` already cover new tables; this is explicit for clarity.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON money.bank_sessions TO personal_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE money.bank_sessions_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT SELECT ON money.bank_sessions TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
11543
drizzle/20261024003809_money_bank_sync/snapshot.json
Normal file
11543
drizzle/20261024003809_money_bank_sync/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3
drizzle/20261024011919_money_bank_balance/migration.sql
Normal file
3
drizzle/20261024011919_money_bank_balance/migration.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_cents" integer;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_type" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_at" timestamp with time zone;
|
||||
12076
drizzle/20261024011919_money_bank_balance/snapshot.json
Normal file
12076
drizzle/20261024011919_money_bank_balance/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
194
drizzle/20261024013208_books/migration.sql
Normal file
194
drizzle/20261024013208_books/migration.sql
Normal file
@@ -0,0 +1,194 @@
|
||||
CREATE SCHEMA "books";
|
||||
--> statement-breakpoint
|
||||
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> statement-breakpoint
|
||||
CREATE TYPE "books"."reading_status" AS ENUM('want_to_read', 'reading', 'finished', 'abandoned');--> statement-breakpoint
|
||||
CREATE TABLE "books"."files" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"path" text NOT NULL,
|
||||
"format" text NOT NULL,
|
||||
"size_bytes" bigint NOT NULL,
|
||||
"sha256" text NOT NULL,
|
||||
"md5" text,
|
||||
"source" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."notes" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"file_id" integer,
|
||||
"locator" text,
|
||||
"page" integer,
|
||||
"highlight" text,
|
||||
"note" text,
|
||||
"color" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."progress" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"file_id" integer,
|
||||
"locator" text,
|
||||
"page" integer,
|
||||
"percent" real DEFAULT 0 NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"status" "books"."reading_status" DEFAULT 'want_to_read'::"books"."reading_status" NOT NULL,
|
||||
"rating" smallint,
|
||||
"last_read_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "progress_percent_check" CHECK ("percent" between 0 and 100),
|
||||
CONSTRAINT "progress_rating_check" CHECK ("rating" is null or "rating" between 1 and 5)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."sessions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"file_id" integer,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"ended_at" timestamp with time zone,
|
||||
"start_percent" real,
|
||||
"end_percent" real,
|
||||
"start_page" integer,
|
||||
"end_page" integer,
|
||||
"pages_read" integer,
|
||||
"source" text DEFAULT 'reader' NOT NULL,
|
||||
"note" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sessions_percent_check" CHECK (("start_percent" is null or "start_percent" between 0 and 100) and ("end_percent" is null or "end_percent" between 0 and 100)),
|
||||
CONSTRAINT "sessions_end_check" CHECK ("ended_at" is null or "ended_at" >= "started_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."shelf_works" (
|
||||
"shelf_id" integer,
|
||||
"work_id" integer,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "shelf_works_pkey" PRIMARY KEY("shelf_id","work_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."shelves" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."works" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"subtitle" text,
|
||||
"authors" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"isbn10" text,
|
||||
"isbn13" text,
|
||||
"publisher" text,
|
||||
"year" integer,
|
||||
"language" text,
|
||||
"page_count" integer,
|
||||
"description" text,
|
||||
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"cover_url" text,
|
||||
"cover_path" text,
|
||||
"openlibrary_work_id" text,
|
||||
"openlibrary_edition_id" text,
|
||||
"google_books_id" text,
|
||||
"annas_md5" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"source_refs" jsonb DEFAULT '{}' NOT NULL,
|
||||
"metadata_status" "books"."metadata_status" DEFAULT 'pending'::"books"."metadata_status" NOT NULL,
|
||||
"metadata_source" text,
|
||||
"metadata_fetched_at" timestamp with time zone,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "works_year_check" CHECK ("year" is null or "year" between 0 and 2100)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "files_path_key" ON "books"."files" ("path");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "files_sha256_key" ON "books"."files" ("sha256");--> statement-breakpoint
|
||||
CREATE INDEX "files_work_id_idx" ON "books"."files" ("work_id");--> statement-breakpoint
|
||||
CREATE INDEX "notes_work_id_idx" ON "books"."notes" ("work_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "progress_work_id_key" ON "books"."progress" ("work_id");--> statement-breakpoint
|
||||
CREATE INDEX "progress_status_idx" ON "books"."progress" ("status");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_work_id_idx" ON "books"."sessions" ("work_id");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_started_at_idx" ON "books"."sessions" ("started_at");--> statement-breakpoint
|
||||
CREATE INDEX "shelf_works_work_id_idx" ON "books"."shelf_works" ("work_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "shelves_slug_key" ON "books"."shelves" ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "works_isbn13_key" ON "books"."works" ("isbn13") WHERE "isbn13" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "works_title_idx" ON "books"."works" (lower("title"));--> statement-breakpoint
|
||||
ALTER TABLE "books"."files" ADD CONSTRAINT "files_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_shelf_id_shelves_id_fkey" FOREIGN KEY ("shelf_id") REFERENCES "books"."shelves"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: updated_at triggers, grants, default shelves, GUI registration.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['works','files','shelves','progress','notes'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA books TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA books TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA books TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA books TO personal_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA books TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT ON TABLES TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO books.shelves (slug, name, sort_order) VALUES
|
||||
('to-read', 'To read', 10), ('reading', 'Reading', 20), ('done', 'Done', 30), ('reference', 'Reference', 40)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('books','works','books-works','Books','core',true,false,1000),
|
||||
('books','shelves','books-shelves','Book shelves','core',true,false,1000),
|
||||
('books','files','books-files','Book files','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
-- meta.sync() is executable by personal_app/personal_ddl, not by the migration role
|
||||
-- (personal) in prod; deploy-remote.sh runs it as the app role after migrating.
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: updated_at trigger and explicit grants (default privileges from the books migration cover new tables too).
|
||||
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.sessions FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON books.sessions TO personal_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE books.sessions_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT SELECT ON books.sessions TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
13722
drizzle/20261024013208_books/snapshot.json
Normal file
13722
drizzle/20261024013208_books/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "include_in_total" boolean DEFAULT true NOT NULL;
|
||||
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
142
drizzle/20261024022836_tasks/migration.sql
Normal file
142
drizzle/20261024022836_tasks/migration.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
CREATE SCHEMA "tasks";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks"."activity" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"task_id" integer,
|
||||
"goal_id" integer,
|
||||
"kind" text NOT NULL,
|
||||
"from_status" text,
|
||||
"to_status" text,
|
||||
"note" text,
|
||||
"value" numeric(14,2),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "activity_target_check" CHECK ("task_id" IS NOT NULL OR "goal_id" IS NOT NULL),
|
||||
CONSTRAINT "activity_kind_check" CHECK ("kind" IN ('created','status','edit','note','progress'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks"."areas" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"color" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "areas_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||
CONSTRAINT "areas_name_check" CHECK (length(btrim("name")) > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks"."goals" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"area_id" integer,
|
||||
"target_date" date,
|
||||
"metric" text,
|
||||
"metric_unit" text,
|
||||
"metric_target" numeric(14,2),
|
||||
"metric_current" numeric(14,2),
|
||||
"flagged" boolean DEFAULT false NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"source" text,
|
||||
"achieved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "goals_title_check" CHECK (length(btrim("title")) > 0),
|
||||
CONSTRAINT "goals_status_check" CHECK ("status" IN ('active','achieved','dropped'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks"."tasks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'todo' NOT NULL,
|
||||
"priority" smallint DEFAULT 0 NOT NULL,
|
||||
"flagged" boolean DEFAULT false NOT NULL,
|
||||
"due_date" date,
|
||||
"due_time" time,
|
||||
"reminder_at" timestamp with time zone,
|
||||
"recurrence" text,
|
||||
"area_id" integer,
|
||||
"goal_id" integer,
|
||||
"parent_id" integer,
|
||||
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"source" text,
|
||||
"external_id" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tasks_title_check" CHECK (length(btrim("title")) > 0),
|
||||
CONSTRAINT "tasks_status_check" CHECK ("status" IN ('todo','in_progress','done','dropped')),
|
||||
CONSTRAINT "tasks_priority_check" CHECK ("priority" BETWEEN 0 AND 3),
|
||||
CONSTRAINT "tasks_parent_check" CHECK ("parent_id" IS NULL OR "parent_id" <> "id"),
|
||||
CONSTRAINT "tasks_time_check" CHECK ("due_time" IS NULL OR "due_date" IS NOT NULL)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "activity_task_idx" ON "tasks"."activity" ("task_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "activity_goal_idx" ON "tasks"."activity" ("goal_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "areas_slug_key" ON "tasks"."areas" ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "goals_title_key" ON "tasks"."goals" (lower("title"));--> statement-breakpoint
|
||||
CREATE INDEX "tasks_area_idx" ON "tasks"."tasks" ("area_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_goal_idx" ON "tasks"."tasks" ("goal_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_parent_idx" ON "tasks"."tasks" ("parent_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_status_due_idx" ON "tasks"."tasks" ("status","due_date");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_tags_idx" ON "tasks"."tasks" USING gin ("tags");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "tasks_source_external_key" ON "tasks"."tasks" ("source","external_id") WHERE "external_id" IS NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."goals" ADD CONSTRAINT "goals_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_parent_id_tasks_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- updated_at triggers, same as every other core table.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
FOREACH t IN ARRAY ARRAY['areas','goals','tasks'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON tasks.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Privacy: like health, the task tracker is readable/writable by the app role only
|
||||
-- (no personal_ro grant). Grants only run on prod, where migrations run as `personal`.
|
||||
REVOKE ALL ON SCHEMA tasks FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA tasks TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tasks TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tasks TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Registered in meta.tables (structure only; the lists GUI only shows kind = 'list').
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('tasks','tasks','tasks-tasks','Tasks','core',true,true,1000),
|
||||
('tasks','goals','tasks-goals','Goals','core',true,false,1000),
|
||||
('tasks','areas','tasks-areas','Task areas','core',true,false,1000),
|
||||
('tasks','activity','tasks-activity','Task activity','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal file
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE "meta"."edit_log" (
|
||||
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "meta"."edit_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"user_id" text,
|
||||
"user_email" text,
|
||||
"schema_name" text NOT NULL,
|
||||
"table_name" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"pk" jsonb,
|
||||
"before" jsonb,
|
||||
"after" jsonb,
|
||||
CONSTRAINT "edit_log_action_check" CHECK (action IN ('insert','update','delete','import','add_column'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "edit_log_table_at_index" ON "meta"."edit_log" ("schema_name","table_name","at");--> statement-breakpoint
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- The app role appends to and reads the log; nobody updates or deletes through the app.
|
||||
-- personal_ro gets no access: before/after values can hold private (health) data.
|
||||
-- Grants only run on prod (as `personal`); the dev DB is owned by personal_dev.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT ON meta.edit_log TO personal_app;
|
||||
GRANT USAGE ON SEQUENCE meta.edit_log_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
REVOKE ALL ON meta.edit_log FROM personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tasks"."tasks" ADD COLUMN "start_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."tasks" ADD COLUMN "time_zone" text;
|
||||
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
112
drizzle/20261024132343_groceries/migration.sql
Normal file
112
drizzle/20261024132343_groceries/migration.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
CREATE SCHEMA "groceries";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."prices" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"product_id" integer NOT NULL,
|
||||
"store_id" integer NOT NULL,
|
||||
"price_cents" integer NOT NULL,
|
||||
"unit_price_cents" integer,
|
||||
"promo" boolean DEFAULT false NOT NULL,
|
||||
"observed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"source" text DEFAULT 'manual' NOT NULL,
|
||||
"store_product_id" text,
|
||||
"url" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "prices_price_check" CHECK ("price_cents" >= 0),
|
||||
CONSTRAINT "prices_unit_price_check" CHECK ("unit_price_cents" IS NULL OR "unit_price_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."products" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"brand" text,
|
||||
"category" text,
|
||||
"size" text,
|
||||
"unit_amount" numeric(10,3),
|
||||
"unit" text,
|
||||
"favorite" boolean DEFAULT false NOT NULL,
|
||||
"notes" text,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "products_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "products_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||
CONSTRAINT "products_unit_amount_check" CHECK ("unit_amount" IS NULL OR "unit_amount" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."stores" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"website" text,
|
||||
"color" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "stores_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||
CONSTRAINT "stores_name_check" CHECK (length(btrim("name")) > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "prices_product_store_idx" ON "groceries"."prices" ("product_id","store_id","observed_at");--> statement-breakpoint
|
||||
CREATE INDEX "prices_store_idx" ON "groceries"."prices" ("store_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "products_name_brand_key" ON "groceries"."products" (lower("name"),lower(coalesce("brand", '')));--> statement-breakpoint
|
||||
CREATE INDEX "products_category_idx" ON "groceries"."products" ("category");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "stores_slug_key" ON "groceries"."stores" ("slug");--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_product_id_products_id_fkey" FOREIGN KEY ("product_id") REFERENCES "groceries"."products"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- updated_at triggers, same as every other core table.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
FOREACH t IN ARRAY ARRAY['stores','products','prices'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Private like tasks/health: app role only, no personal_ro. Grants only run on prod,
|
||||
-- where migrations run as `personal`.
|
||||
REVOKE ALL ON SCHEMA groceries FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA groceries TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO groceries.stores (slug, name, website, color, sort_order) VALUES
|
||||
('ah', 'Albert Heijn', 'https://www.ah.nl', '#00a0e2', 10),
|
||||
('jumbo', 'Jumbo', 'https://www.jumbo.com', '#eeb717', 20),
|
||||
('lidl', 'Lidl', 'https://www.lidl.nl', '#0050aa', 30),
|
||||
('plus', 'Plus', 'https://www.plus.nl', '#e2001a', 40)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- Registered in meta.tables so the /lists database browser shows them (kind 'core').
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('groceries','products','groceries-products','Grocery products','core',true,false,1100),
|
||||
('groceries','prices','groceries-prices','Grocery prices','core',true,false,1100),
|
||||
('groceries','stores','groceries-stores','Grocery stores','core',true,false,1100)
|
||||
ON CONFLICT DO NOTHING;
|
||||
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
15861
drizzle/20261024132343_groceries/snapshot.json
Normal file
15861
drizzle/20261024132343_groceries/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11
drizzle/20261024134401_groceries_drop_manual/migration.sql
Normal file
11
drizzle/20261024134401_groceries_drop_manual/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "groceries"."prices" DROP CONSTRAINT "prices_product_id_products_id_fkey";--> statement-breakpoint
|
||||
DROP TABLE "groceries"."prices";--> statement-breakpoint
|
||||
DROP TABLE "groceries"."products";--> statement-breakpoint
|
||||
-- The hand-entered products/prices tables were never used (0 rows); replaced by store items
|
||||
-- and groups in the next migration. Unregister them from the /lists database browser.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
DELETE FROM meta.tables WHERE schema_name = 'groceries' AND table_name IN ('products','prices');
|
||||
END IF;
|
||||
END $$;
|
||||
15330
drizzle/20261024134401_groceries_drop_manual/snapshot.json
Normal file
15330
drizzle/20261024134401_groceries_drop_manual/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
138
drizzle/20261024134411_groceries_compare/migration.sql
Normal file
138
drizzle/20261024134411_groceries_compare/migration.sql
Normal file
@@ -0,0 +1,138 @@
|
||||
-- Trigram search for group/item names (trusted extension; the database owner may create it).
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."groups" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"key" text,
|
||||
"name" text NOT NULL,
|
||||
"brand" text,
|
||||
"category" text,
|
||||
"unit" text,
|
||||
"family_key" text,
|
||||
"favorite" boolean DEFAULT false NOT NULL,
|
||||
"manual" boolean DEFAULT false NOT NULL,
|
||||
"merged_into_id" integer,
|
||||
"notes" text,
|
||||
"item_count" integer DEFAULT 0 NOT NULL,
|
||||
"store_count" integer DEFAULT 0 NOT NULL,
|
||||
"store_ids" integer[] DEFAULT '{}'::integer[] NOT NULL,
|
||||
"min_unit_price_cents" integer,
|
||||
"min_price_cents" integer,
|
||||
"promo_count" integer DEFAULT 0 NOT NULL,
|
||||
"image_url" text,
|
||||
"search_text" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "groups_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "groups_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||
CONSTRAINT "groups_not_self_merged" CHECK ("merged_into_id" IS NULL OR "merged_into_id" <> "id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."import_runs" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"store_id" integer NOT NULL,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"finished_at" timestamp with time zone,
|
||||
"status" text DEFAULT 'running' NOT NULL,
|
||||
"requests" integer DEFAULT 0 NOT NULL,
|
||||
"fetched" integer DEFAULT 0 NOT NULL,
|
||||
"inserted" integer DEFAULT 0 NOT NULL,
|
||||
"price_changes" integer DEFAULT 0 NOT NULL,
|
||||
"error" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."item_prices" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"item_id" integer NOT NULL,
|
||||
"observed_on" date NOT NULL,
|
||||
"price_cents" integer,
|
||||
"promo_price_cents" integer,
|
||||
"promo_label" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."items" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"store_id" integer NOT NULL,
|
||||
"store_product_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"brand" text,
|
||||
"size_text" text,
|
||||
"unit" text,
|
||||
"unit_amount" numeric(12,4),
|
||||
"unit_source" text,
|
||||
"price_cents" integer,
|
||||
"promo_price_cents" integer,
|
||||
"promo_label" text,
|
||||
"unit_price_cents" integer,
|
||||
"promo_unit_price_cents" integer,
|
||||
"category" text,
|
||||
"category_path" text,
|
||||
"gtin" text,
|
||||
"gtin_checked_at" timestamp with time zone,
|
||||
"image_url" text,
|
||||
"url" text,
|
||||
"available" boolean DEFAULT true NOT NULL,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"group_id" integer,
|
||||
"match_key" text,
|
||||
"match_method" text,
|
||||
"match_confidence" real,
|
||||
"match_locked" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "items_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||
CONSTRAINT "items_price_check" CHECK ("price_cents" IS NULL OR "price_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "groups_key_key" ON "groceries"."groups" ("key");--> statement-breakpoint
|
||||
CREATE INDEX "groups_family_idx" ON "groceries"."groups" ("family_key");--> statement-breakpoint
|
||||
CREATE INDEX "groups_category_idx" ON "groceries"."groups" ("category");--> statement-breakpoint
|
||||
CREATE INDEX "groups_store_ids_idx" ON "groceries"."groups" USING gin ("store_ids");--> statement-breakpoint
|
||||
CREATE INDEX "groups_search_trgm_idx" ON "groceries"."groups" USING gin ("search_text" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "groups_list_idx" ON "groceries"."groups" ("store_count","name");--> statement-breakpoint
|
||||
CREATE INDEX "import_runs_store_idx" ON "groceries"."import_runs" ("store_id","started_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "item_prices_item_day_key" ON "groceries"."item_prices" ("item_id","observed_on");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "items_store_product_key" ON "groceries"."items" ("store_id","store_product_id");--> statement-breakpoint
|
||||
CREATE INDEX "items_group_idx" ON "groceries"."items" ("group_id");--> statement-breakpoint
|
||||
CREATE INDEX "items_gtin_idx" ON "groceries"."items" ("gtin");--> statement-breakpoint
|
||||
CREATE INDEX "items_name_trgm_idx" ON "groceries"."items" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."groups" ADD CONSTRAINT "groups_merged_into_id_groups_id_fkey" FOREIGN KEY ("merged_into_id") REFERENCES "groceries"."groups"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."import_runs" ADD CONSTRAINT "import_runs_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."item_prices" ADD CONSTRAINT "item_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "groceries"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "groceries"."groups"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
FOREACH t IN ARRAY ARRAY['groups','items'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Private: app role only (default privileges from the first groceries migration also apply).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('groceries','groups','groceries-groups','Grocery groups','core',true,false,1100),
|
||||
('groceries','items','groceries-items','Grocery store items','core',true,false,1100),
|
||||
('groceries','item_prices','groceries-item-prices','Grocery price history','core',false,false,1100),
|
||||
('groceries','import_runs','groceries-import-runs','Grocery import runs','core',false,false,1100)
|
||||
ON CONFLICT DO NOTHING;
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
16678
drizzle/20261024134411_groceries_compare/snapshot.json
Normal file
16678
drizzle/20261024134411_groceries_compare/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5
drizzle/20261024140745_house_scrapers/migration.sql
Normal file
5
drizzle/20261024140745_house_scrapers/migration.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "address_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "duplicate_of" integer;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "total_price_eur" integer;--> statement-breakpoint
|
||||
CREATE INDEX "listings_address_idx" ON "house"."listings" ("address_key") WHERE address_key IS NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD CONSTRAINT "listings_duplicate_of_listings_id_fkey" FOREIGN KEY ("duplicate_of") REFERENCES "house"."listings"("id") ON DELETE SET NULL;
|
||||
16755
drizzle/20261024140745_house_scrapers/snapshot.json
Normal file
16755
drizzle/20261024140745_house_scrapers/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle/20261024141609_health_scale_metrics/migration.sql
Normal file
20
drizzle/20261024141609_health_scale_metrics/migration.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "muscle_mass_kg" numeric(5,2);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "skeletal_muscle_kg" numeric(5,2);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "bone_mass_kg" numeric(4,2);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "visceral_fat" numeric(4,1);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "body_water_pct" numeric(4,1);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "protein_pct" numeric(4,1);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmr_kcal" integer;--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmi" numeric(4,1);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "measured_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "source" text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "body_measurements_date_weight_key" ON "health"."body_measurements" ("date","weight_kg");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "body_measurements_measured_at_key" ON "health"."body_measurements" ("measured_at");--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD CONSTRAINT "body_measurements_scale_check" CHECK (("muscle_mass_kg" IS NULL OR "muscle_mass_kg" BETWEEN 5 AND 250)
|
||||
AND ("skeletal_muscle_kg" IS NULL OR "skeletal_muscle_kg" BETWEEN 5 AND 200)
|
||||
AND ("bone_mass_kg" IS NULL OR "bone_mass_kg" BETWEEN 0.5 AND 20)
|
||||
AND ("visceral_fat" IS NULL OR "visceral_fat" BETWEEN 1 AND 60)
|
||||
AND ("body_water_pct" IS NULL OR "body_water_pct" BETWEEN 20 AND 85)
|
||||
AND ("protein_pct" IS NULL OR "protein_pct" BETWEEN 5 AND 40)
|
||||
AND ("bmr_kcal" IS NULL OR "bmr_kcal" BETWEEN 500 AND 6000)
|
||||
AND ("bmi" IS NULL OR "bmi" BETWEEN 10 AND 80));
|
||||
16941
drizzle/20261024141609_health_scale_metrics/snapshot.json
Normal file
16941
drizzle/20261024141609_health_scale_metrics/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
66
drizzle/20261024150925_home/migration.sql
Normal file
66
drizzle/20261024150925_home/migration.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
CREATE SCHEMA "home";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "home"."links" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"section" text DEFAULT 'links' NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "links_title_check" CHECK (length(btrim("title")) between 1 and 80),
|
||||
CONSTRAINT "links_url_check" CHECK ("url" ~ '^https?://' and length("url") <= 2000)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "home"."snapshots" (
|
||||
"key" text PRIMARY KEY,
|
||||
"data" jsonb NOT NULL,
|
||||
"collected_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "links_sort_idx" ON "home"."links" ("section","sort_order");--> statement-breakpoint
|
||||
-- Privacy: like tasks and health, the start page is the app role only (no personal_ro).
|
||||
-- Grants only run on prod, where migrations run as `personal`.
|
||||
REVOKE ALL ON SCHEMA home FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA home TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA home TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA home TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
-- The host-side collector (deploy/home-collector) may only upsert snapshots. The role is
|
||||
-- created out of band (it has a password); see deploy/home-collector/README.md.
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'home_collector') THEN
|
||||
GRANT USAGE ON SCHEMA home TO home_collector;
|
||||
GRANT SELECT, INSERT, UPDATE ON home.snapshots TO home_collector;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Starter links; edited inline on /start.
|
||||
INSERT INTO "home"."links" ("title", "url", "section", "sort_order") VALUES
|
||||
('Gmail', 'https://mail.google.com/mail/u/0/', 'daily', 10),
|
||||
('Calendar', 'https://calendar.google.com/', 'daily', 20),
|
||||
('WhatsApp', 'https://web.whatsapp.com/', 'daily', 30),
|
||||
('Discord', 'https://discord.com/app', 'daily', 40),
|
||||
('X', 'https://x.com/home', 'daily', 50),
|
||||
('YouTube', 'https://www.youtube.com/', 'daily', 60),
|
||||
('Spotify', 'https://open.spotify.com/', 'daily', 70),
|
||||
('Drive', 'https://drive.google.com/', 'daily', 80),
|
||||
('Moneybird', 'https://moneybird.com/390525278507501515', 'money', 110),
|
||||
('Revolut', 'https://app.revolut.com/', 'money', 120),
|
||||
('Knab', 'https://www.knab.nl/', 'money', 130),
|
||||
('Tikkie', 'https://www.tikkie.me/', 'money', 140),
|
||||
('Gitea', 'https://git.stanrunge.dev/', 'dev', 210),
|
||||
('GitHub', 'https://github.com/', 'dev', 220),
|
||||
('Cloudflare', 'https://dash.cloudflare.com/', 'dev', 230),
|
||||
('Tailscale', 'https://login.tailscale.com/admin/machines', 'dev', 240),
|
||||
('Backblaze B2', 'https://secure.backblaze.com/b2_buckets.htm', 'dev', 250),
|
||||
('1Password', 'https://my.1password.com/', 'dev', 260),
|
||||
('osu!', 'https://osu.ppy.sh/', 'fun', 310),
|
||||
('Twitch', 'https://www.twitch.tv/', 'fun', 320);
|
||||
17149
drizzle/20261024150925_home/snapshot.json
Normal file
17149
drizzle/20261024150925_home/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
134
drizzle/20261024153902_cars/migration.sql
Normal file
134
drizzle/20261024153902_cars/migration.sql
Normal file
@@ -0,0 +1,134 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "cars";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cars"."brands" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"country" text,
|
||||
"model_count" integer DEFAULT 0 NOT NULL,
|
||||
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||
"first_year" smallint,
|
||||
"last_year" smallint,
|
||||
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cars"."models" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"dex" integer NOT NULL,
|
||||
"brand_id" integer NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"search" text NOT NULL,
|
||||
"first_year" smallint,
|
||||
"last_year" smallint,
|
||||
"bodies" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"size_classes" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"fuels" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"drives" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"transmissions" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"engine_l_min" real,
|
||||
"engine_l_max" real,
|
||||
"cylinders_min" smallint,
|
||||
"cylinders_max" smallint,
|
||||
"turbo" boolean,
|
||||
"power_kw_min" real,
|
||||
"power_kw_max" real,
|
||||
"l100_min" real,
|
||||
"l100_max" real,
|
||||
"co2_min" smallint,
|
||||
"co2_max" smallint,
|
||||
"mpg_min" real,
|
||||
"mpg_max" real,
|
||||
"range_km_max" smallint,
|
||||
"seats" smallint,
|
||||
"doors" smallint,
|
||||
"mass_kg" smallint,
|
||||
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||
"nl_rank" integer,
|
||||
"nl_first_year" smallint,
|
||||
"nl_last_year" smallint,
|
||||
"nl_price" integer,
|
||||
"nl_price_latest" integer,
|
||||
"nl_price_latest_year" smallint,
|
||||
"nl_years" jsonb DEFAULT '[]' NOT NULL,
|
||||
"nl_names" jsonb DEFAULT '[]' NOT NULL,
|
||||
"us_years" smallint[] DEFAULT '{}'::smallint[] NOT NULL,
|
||||
"variant_count" integer DEFAULT 0 NOT NULL,
|
||||
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cars"."variants" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"model_id" integer NOT NULL,
|
||||
"source" text DEFAULT 'fueleconomy.gov' NOT NULL,
|
||||
"source_id" integer,
|
||||
"year" smallint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"engine_l" real,
|
||||
"cylinders" smallint,
|
||||
"transmission" text,
|
||||
"drive" text,
|
||||
"fuel" text,
|
||||
"size_class" text,
|
||||
"mpg_city" real,
|
||||
"mpg_hwy" real,
|
||||
"mpg_comb" real,
|
||||
"co2_gpm" smallint,
|
||||
"range_mi" smallint,
|
||||
"ev_motor" text,
|
||||
"turbo" boolean DEFAULT false NOT NULL,
|
||||
"supercharged" boolean DEFAULT false NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "brands_slug_key" ON "cars"."brands" ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "models_dex_key" ON "cars"."models" ("dex");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "models_brand_slug_key" ON "cars"."models" ("brand_id","slug");--> statement-breakpoint
|
||||
CREATE INDEX "models_nl_count_idx" ON "cars"."models" ("nl_count");--> statement-breakpoint
|
||||
CREATE INDEX "models_search_trgm_idx" ON "cars"."models" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "variants_model_year_idx" ON "cars"."variants" ("model_id","year");--> statement-breakpoint
|
||||
ALTER TABLE "cars"."models" ADD CONSTRAINT "models_brand_id_brands_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "cars"."brands"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."variants" ADD CONSTRAINT "variants_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/cars-import.ts.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['brands','models'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON cars.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA cars TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA cars TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cars TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA cars TO personal_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA cars TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT ON TABLES TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('cars','brands','cars-brands','Car brands','core',false,false,1000),
|
||||
('cars','models','cars-models','Car models','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
18350
drizzle/20261024153902_cars/snapshot.json
Normal file
18350
drizzle/20261024153902_cars/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11
drizzle/20261024154032_house_liveability/migration.sql
Normal file
11
drizzle/20261024154032_house_liveability/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_score" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_class" integer;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_onv_class" integer;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_level" text;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_precision" text;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_buurt_code" text;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_buurt_name" text;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_year" integer;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_detail" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_query" text;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "lbm_scored_at" timestamp with time zone;
|
||||
18493
drizzle/20261024154032_house_liveability/snapshot.json
Normal file
18493
drizzle/20261024154032_house_liveability/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
159
drizzle/20261024155715_music/migration.sql
Normal file
159
drizzle/20261024155715_music/migration.sql
Normal file
@@ -0,0 +1,159 @@
|
||||
CREATE SCHEMA "music";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."albums" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"spotify_id" text,
|
||||
"name" text NOT NULL,
|
||||
"artist_id" integer,
|
||||
"image_url" text,
|
||||
"image_url_small" text,
|
||||
"release_date" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."artists" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"spotify_id" text,
|
||||
"name" text NOT NULL,
|
||||
"image_url" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."import_state" (
|
||||
"key" text PRIMARY KEY,
|
||||
"data" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."now_playing" (
|
||||
"id" smallint PRIMARY KEY DEFAULT 1,
|
||||
"track_id" integer,
|
||||
"is_playing" boolean DEFAULT false NOT NULL,
|
||||
"progress_ms" integer,
|
||||
"device_name" text,
|
||||
"device_type" text,
|
||||
"context_uri" text,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "now_playing_single_row" CHECK ("id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."plays" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"track_id" integer NOT NULL,
|
||||
"played_at" timestamp with time zone NOT NULL,
|
||||
"source" text DEFAULT 'spotify' NOT NULL,
|
||||
"origin" text DEFAULT 'api' NOT NULL,
|
||||
"ms_played" integer,
|
||||
"context_type" text,
|
||||
"context_uri" text,
|
||||
"platform" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "plays_source_check" CHECK ("source" in ('spotify', 'lastfm', 'youtube', 'local')),
|
||||
CONSTRAINT "plays_origin_check" CHECK ("origin" in ('api', 'export', 'import', 'manual'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."spotify_auth" (
|
||||
"id" smallint PRIMARY KEY DEFAULT 1,
|
||||
"spotify_user_id" text NOT NULL,
|
||||
"display_name" text,
|
||||
"refresh_token" text NOT NULL,
|
||||
"access_token" text,
|
||||
"access_expires_at" timestamp with time zone,
|
||||
"scope" text,
|
||||
"recent_cursor" bigint,
|
||||
"needs_reauth" boolean DEFAULT false NOT NULL,
|
||||
"lease_until" timestamp with time zone,
|
||||
"last_poll_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "spotify_auth_single_row" CHECK ("id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."track_artists" (
|
||||
"track_id" integer,
|
||||
"artist_id" integer,
|
||||
"position" smallint DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "track_artists_pkey" PRIMARY KEY("track_id","artist_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music"."tracks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"spotify_id" text,
|
||||
"name" text NOT NULL,
|
||||
"album_id" integer,
|
||||
"artist_id" integer,
|
||||
"duration_ms" integer,
|
||||
"explicit" boolean,
|
||||
"match_key" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "albums_spotify_id_key" ON "music"."albums" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "albums_name_idx" ON "music"."albums" (lower("name"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "artists_spotify_id_key" ON "music"."artists" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "artists_name_idx" ON "music"."artists" (lower("name"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plays_dedupe_key" ON "music"."plays" ("source","track_id","played_at");--> statement-breakpoint
|
||||
CREATE INDEX "plays_played_at_idx" ON "music"."plays" ("played_at");--> statement-breakpoint
|
||||
CREATE INDEX "plays_source_played_idx" ON "music"."plays" ("source","played_at");--> statement-breakpoint
|
||||
CREATE INDEX "plays_track_played_idx" ON "music"."plays" ("track_id","played_at");--> statement-breakpoint
|
||||
CREATE INDEX "track_artists_artist_idx" ON "music"."track_artists" ("artist_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "tracks_spotify_id_key" ON "music"."tracks" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "tracks_name_idx" ON "music"."tracks" (lower("name"));--> statement-breakpoint
|
||||
CREATE INDEX "tracks_match_key_idx" ON "music"."tracks" ("match_key");--> statement-breakpoint
|
||||
ALTER TABLE "music"."albums" ADD CONSTRAINT "albums_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "music"."now_playing" ADD CONSTRAINT "now_playing_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "music"."plays" ADD CONSTRAINT "plays_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "music"."track_artists" ADD CONSTRAINT "track_artists_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "music"."track_artists" ADD CONSTRAINT "track_artists_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "music"."tracks" ADD CONSTRAINT "tracks_album_id_albums_id_fkey" FOREIGN KEY ("album_id") REFERENCES "music"."albums"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "music"."tracks" ADD CONSTRAINT "tracks_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
-- Hand-written below. Cross-source dedupe: every stat reads plays_deduped, which hides a
|
||||
-- Last.fm scrobble when Spotify (live API or the streaming-history export) has the same
|
||||
-- normalised artist+title around the same time. Last.fm stores when a track started,
|
||||
-- Spotify when it ended, so the Spotify end may fall from 3 min before the scrobble to
|
||||
-- track length + 3 min after it. Spotify rows always win; Last.fm fills the gaps.
|
||||
CREATE VIEW "music"."plays_deduped" AS
|
||||
SELECT p.* FROM "music"."plays" p
|
||||
WHERE p.source <> 'lastfm' OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "music"."tracks" lt, "music"."plays" s
|
||||
JOIN "music"."tracks" st ON st.id = s.track_id
|
||||
WHERE lt.id = p.track_id
|
||||
AND s.source = 'spotify'
|
||||
AND s.played_at >= p.played_at - interval '3 minutes'
|
||||
AND s.played_at <= p.played_at + interval '25 minutes'
|
||||
AND s.played_at <= p.played_at + make_interval(secs => coalesce(st.duration_ms, 600000) / 1000.0) + interval '3 minutes'
|
||||
AND st.match_key = lt.match_key
|
||||
);
|
||||
--> statement-breakpoint
|
||||
-- Grants only run on prod, where migrations run as `personal`. Listening history is public
|
||||
-- (personal_ro may read it); the Spotify refresh token is for the app role only.
|
||||
REVOKE ALL ON SCHEMA music FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA music TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA music TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA music
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA music
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA music TO personal_ro;
|
||||
GRANT SELECT ON music.artists, music.albums, music.tracks, music.track_artists, music.plays,
|
||||
music.plays_deduped, music.now_playing TO personal_ro;
|
||||
END IF;
|
||||
-- Optional host-side poller (deploy/music-collector). The role is created out of band
|
||||
-- (it has a password); see deploy/music-collector/README.md.
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'music_collector') THEN
|
||||
GRANT USAGE ON SCHEMA music TO music_collector;
|
||||
GRANT SELECT, INSERT, UPDATE ON music.artists, music.albums, music.tracks, music.track_artists,
|
||||
music.plays, music.now_playing, music.import_state TO music_collector;
|
||||
GRANT SELECT, UPDATE ON music.spotify_auth TO music_collector;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO music_collector;
|
||||
END IF;
|
||||
END $$;
|
||||
19833
drizzle/20261024155715_music/snapshot.json
Normal file
19833
drizzle/20261024155715_music/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1
drizzle/20261024160855_tasks_auto_handle/migration.sql
Normal file
1
drizzle/20261024160855_tasks_auto_handle/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "tasks"."tasks" ADD COLUMN "auto_handle" boolean DEFAULT false NOT NULL;
|
||||
19846
drizzle/20261024160855_tasks_auto_handle/snapshot.json
Normal file
19846
drizzle/20261024160855_tasks_auto_handle/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7
drizzle/20261024160937_osu_match_cache/migration.sql
Normal file
7
drizzle/20261024160937_osu_match_cache/migration.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "music"."osu_match_cache" (
|
||||
"key" text PRIMARY KEY,
|
||||
"result" jsonb NOT NULL,
|
||||
"searched_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN IF current_user = 'personal' THEN GRANT SELECT, INSERT, UPDATE, DELETE ON music.osu_match_cache TO personal_app; END IF; END $$;
|
||||
19901
drizzle/20261024160937_osu_match_cache/snapshot.json
Normal file
19901
drizzle/20261024160937_osu_match_cache/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
50
drizzle/20261024161853_cars_photos/migration.sql
Normal file
50
drizzle/20261024161853_cars_photos/migration.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
CREATE TABLE "cars"."photos" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"model_id" integer NOT NULL,
|
||||
"position" smallint NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"file" text NOT NULL,
|
||||
"caption" text,
|
||||
"thumb" text NOT NULL,
|
||||
"width" integer NOT NULL,
|
||||
"height" integer NOT NULL,
|
||||
"author" text,
|
||||
"license" text,
|
||||
"license_url" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "cars"."brands" ADD COLUMN "wikidata" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."brands" ADD COLUMN "wikipedia" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."brands" ADD COLUMN "logo" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."brands" ADD COLUMN "logo_file" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."brands" ADD COLUMN "logo_author" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."brands" ADD COLUMN "logo_license" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."brands" ADD COLUMN "photo" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."models" ADD COLUMN "wikidata" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."models" ADD COLUMN "wikipedia" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."models" ADD COLUMN "photo" text;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."models" ADD COLUMN "photo_count" smallint DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."models" ADD COLUMN "photos_checked_at" timestamp with time zone;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "photos_model_position_key" ON "cars"."photos" ("model_id","position");--> statement-breakpoint
|
||||
ALTER TABLE "cars"."photos" ADD CONSTRAINT "photos_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- hand-written: grants + registry for the photos table
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON cars.photos TO personal_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE cars.photos_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT SELECT ON cars.photos TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('cars','photos','cars-photos','Car photos','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
20274
drizzle/20261024161853_cars_photos/snapshot.json
Normal file
20274
drizzle/20261024161853_cars_photos/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1
drizzle/20261024172700_music_lease_holder/migration.sql
Normal file
1
drizzle/20261024172700_music_lease_holder/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "music"."spotify_auth" ADD COLUMN "lease_holder" text;
|
||||
20287
drizzle/20261024172700_music_lease_holder/snapshot.json
Normal file
20287
drizzle/20261024172700_music_lease_holder/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
290
drizzle/20261024174805_hardware/migration.sql
Normal file
290
drizzle/20261024174805_hardware/migration.sql
Normal file
@@ -0,0 +1,290 @@
|
||||
-- Hand-reconciled: the hardware schema already existed (hand-made 2026-10-22 from Stan's list +
|
||||
-- order mails, see ~/dev/hardware/schema.sql; prices later moved to cents). This brings an existing
|
||||
-- schema to the Drizzle model, and creates it from scratch on an empty database.
|
||||
CREATE SCHEMA IF NOT EXISTS "hardware";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "hardware"."devices" (
|
||||
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."devices_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"name" text NOT NULL UNIQUE,
|
||||
"label" text,
|
||||
"hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"brand" text,
|
||||
"model" text,
|
||||
"specs" jsonb DEFAULT '{}' NOT NULL,
|
||||
"price_cents" integer,
|
||||
"monthly_cents" integer,
|
||||
"budget_cents" integer,
|
||||
"shop" text,
|
||||
"purchase_date" date,
|
||||
"url" text,
|
||||
"image_url" text,
|
||||
"sort_order" integer DEFAULT 100 NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other')),
|
||||
CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold')),
|
||||
CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "hardware"."components" (
|
||||
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."components_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"device_id" bigint NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"brand" text,
|
||||
"model" text NOT NULL,
|
||||
"quantity" integer DEFAULT 1 NOT NULL,
|
||||
"specs" jsonb DEFAULT '{}' NOT NULL,
|
||||
"unit_price_cents" integer,
|
||||
"total_price_cents" integer,
|
||||
"shop" text,
|
||||
"order_number" text,
|
||||
"order_date" date,
|
||||
"warranty_until" date,
|
||||
"invoice_number" text,
|
||||
"status" text DEFAULT 'in_use' NOT NULL,
|
||||
"verified" boolean DEFAULT true NOT NULL,
|
||||
"dpi" integer,
|
||||
"measured_dpi" integer,
|
||||
"url" text,
|
||||
"image_url" text,
|
||||
"notes" text,
|
||||
"source" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT("device_id","model","order_number"),
|
||||
CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other')),
|
||||
CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold')),
|
||||
CONSTRAINT "components_quantity_check" CHECK (quantity > 0),
|
||||
CONSTRAINT "components_unit_price_cents_check" CHECK (unit_price_cents >= 0),
|
||||
CONSTRAINT "components_total_price_cents_check" CHECK (total_price_cents >= 0),
|
||||
CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "hardware"."sensitivities" (
|
||||
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."sensitivities_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"game" text NOT NULL UNIQUE,
|
||||
"cm_per_360" numeric(7,2),
|
||||
"yaw" numeric(10,6),
|
||||
"in_game_sens" numeric(10,4),
|
||||
"mouse_id" bigint,
|
||||
"fov" text,
|
||||
"notes" text,
|
||||
"sort_order" integer DEFAULT 100 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sensitivities_cm_check" CHECK (cm_per_360 is null or cm_per_360 > 0),
|
||||
CONSTRAINT "sensitivities_yaw_check" CHECK (yaw is null or yaw > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
-- Existing tables: new columns.
|
||||
ALTER TABLE "hardware"."devices"
|
||||
ADD COLUMN IF NOT EXISTS "label" text,
|
||||
ADD COLUMN IF NOT EXISTS "hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS "brand" text,
|
||||
ADD COLUMN IF NOT EXISTS "model" text,
|
||||
ADD COLUMN IF NOT EXISTS "specs" jsonb DEFAULT '{}' NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS "price_cents" integer,
|
||||
ADD COLUMN IF NOT EXISTS "monthly_cents" integer,
|
||||
ADD COLUMN IF NOT EXISTS "budget_cents" integer,
|
||||
ADD COLUMN IF NOT EXISTS "shop" text,
|
||||
ADD COLUMN IF NOT EXISTS "purchase_date" date,
|
||||
ADD COLUMN IF NOT EXISTS "url" text,
|
||||
ADD COLUMN IF NOT EXISTS "image_url" text,
|
||||
ADD COLUMN IF NOT EXISTS "sort_order" integer DEFAULT 100 NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hardware"."components"
|
||||
ADD COLUMN IF NOT EXISTS "dpi" integer,
|
||||
ADD COLUMN IF NOT EXISTS "measured_dpi" integer,
|
||||
ADD COLUMN IF NOT EXISTS "url" text,
|
||||
ADD COLUMN IF NOT EXISTS "image_url" text;
|
||||
--> statement-breakpoint
|
||||
-- Checks: widen kinds/categories/statuses (drop + re-add by name).
|
||||
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_kind_check";
|
||||
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other'));
|
||||
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_status_check";
|
||||
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold'));
|
||||
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_cents_check";
|
||||
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0));
|
||||
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_category_check";
|
||||
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other'));
|
||||
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_status_check";
|
||||
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold'));
|
||||
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_dpi_check";
|
||||
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0));
|
||||
--> statement-breakpoint
|
||||
-- Dedupe key: unique index -> constraint (same name, same NULLS NOT DISTINCT semantics);
|
||||
-- device FK -> Drizzle's name.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_dedupe_uq' AND connamespace = 'hardware'::regnamespace) THEN
|
||||
DROP INDEX IF EXISTS "hardware"."components_dedupe_uq";
|
||||
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT ("device_id","model","order_number");
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||
ALTER TABLE "hardware"."components" RENAME CONSTRAINT "components_device_id_fkey" TO "components_device_id_devices_id_fkey";
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_devices_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_device_id_devices_id_fkey" FOREIGN KEY ("device_id") REFERENCES "hardware"."devices"("id") ON DELETE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sensitivities_mouse_id_components_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||
ALTER TABLE "hardware"."sensitivities" ADD CONSTRAINT "sensitivities_mouse_id_components_id_fkey" FOREIGN KEY ("mouse_id") REFERENCES "hardware"."components"("id") ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "components_device_idx" ON "hardware"."components" ("device_id");
|
||||
CREATE INDEX IF NOT EXISTS "components_category_idx" ON "hardware"."components" ("category");
|
||||
CREATE INDEX IF NOT EXISTS "components_specs_gin" ON "hardware"."components" USING gin ("specs");
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: updated_at triggers, overview view, grants, GUI registration, seed.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['devices','components','sensitivities'] LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'touch_updated_at' AND tgrelid = format('hardware.%I', t)::regclass) THEN
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON hardware.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP VIEW IF EXISTS "hardware"."setup_overview";
|
||||
--> statement-breakpoint
|
||||
CREATE VIEW "hardware"."setup_overview" AS
|
||||
SELECT d.name AS device,
|
||||
d.label AS device_label,
|
||||
d.kind AS device_kind,
|
||||
d.status AS device_status,
|
||||
c.category,
|
||||
c.brand,
|
||||
c.model,
|
||||
c.quantity,
|
||||
c.total_price_cents,
|
||||
round(c.total_price_cents::numeric / 100.0, 2) AS total_price_eur,
|
||||
c.shop,
|
||||
c.order_date,
|
||||
c.warranty_until,
|
||||
(c.warranty_until >= current_date) AS under_warranty,
|
||||
c.status,
|
||||
c.verified,
|
||||
c.specs,
|
||||
c.dpi,
|
||||
c.measured_dpi,
|
||||
c.notes,
|
||||
c.order_number,
|
||||
c.invoice_number,
|
||||
c.source,
|
||||
c.id AS component_id
|
||||
FROM hardware.components c
|
||||
JOIN hardware.devices d ON d.id = c.device_id
|
||||
ORDER BY d.sort_order, d.name,
|
||||
array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','mouse','keyboard','headset','audio','controller','accessory','other'], c.category),
|
||||
c.order_date NULLS LAST, c.model;
|
||||
--> statement-breakpoint
|
||||
COMMENT ON VIEW "hardware"."setup_overview" IS 'Flat view of every component per device (build order), with a live under_warranty flag. status = ''in_use'' is the current setup, ''planned'' the upgrade plan.';
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA hardware TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA hardware TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA hardware TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA hardware TO personal_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA hardware TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT ON TABLES TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('hardware','devices','hardware-devices','Hardware devices','core',true,false,1000),
|
||||
('hardware','components','hardware-components','Hardware components','core',true,false,1000),
|
||||
('hardware','sensitivities','hardware-sensitivities','Game sensitivities (cm/360)','core',true,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- ===== Seed (idempotent). Facts from Stan's own list, order mails and what he told Daisy;
|
||||
-- unknowns stay null. No home address in public-facing notes.
|
||||
INSERT INTO hardware.devices (name, kind) VALUES ('stan-pc','desktop'), ('old-pc-2021','desktop')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
UPDATE hardware.devices SET
|
||||
label = 'Main PC', hostnames = '{stan-arch,stan-win11}', status = 'active', model = 'Custom build',
|
||||
shop = 'Megekko', sort_order = 10,
|
||||
notes = 'Stan''s main PC: Lian Li O11 Dynamic XL ROG White, i9-12900KF, RX 7900 XT, 10 Lian Li fans around the case + 3 NZXT Aer on the radiator. Dual-boots Arch (stan-arch) and Windows 11 + WSL2 (stan-win11). Desk peripherals and the Odyssey G9 are tracked here. Mostly bought at Megekko 2021-2022.'
|
||||
WHERE name = 'stan-pc' AND label IS NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE hardware.devices SET
|
||||
label = 'Previous PC (2021)', status = 'retired', model = 'Budget build', shop = 'Megekko',
|
||||
purchase_date = '2021-01-31', sort_order = 90
|
||||
WHERE name = 'old-pc-2021' AND label IS NULL;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO hardware.devices (name, label, hostnames, kind, status, brand, model, specs, price_cents, monthly_cents, budget_cents, shop, purchase_date, url, sort_order, notes) VALUES
|
||||
('stan-pc-next','Next PC (planned)','{}','desktop','planned',NULL,'High-end AMD + RTX 5090 build','{}',NULL,NULL,1000000,NULL,NULL,NULL,15,
|
||||
'Planned upgrade, budget up to about EUR 10k: top AMD X3D CPU, RTX 5090, fast DDR5, PCIe Gen5 SSD, high-end motherboard and a cooler that fits. Parts not picked yet.'),
|
||||
('stan-ovh','Server','{}','server','active','OVHcloud','RISE-S',
|
||||
'{"cpu":"AMD Ryzen 7 9700X","ram":"64 GB DDR5 ECC","storage":"2x 512 GB NVMe (software RAID1)","os":"Ubuntu 26.04"}',
|
||||
NULL,6174,NULL,'OVHcloud','2026-10-24','https://www.ovhcloud.com/en/bare-metal/rise/',20,
|
||||
'Dedicated server, prod since 2026-10-24 (replaced stan-vm). EUR 61.74/month excl. VAT. Runs Postgres, Gitea, the website and the bots.'),
|
||||
('stan-vm','Old VM','{}','vm','retired','Hetzner Cloud',NULL,'{"vcpu":4,"ram":"8 GB"}',NULL,2000,NULL,'Hetzner',NULL,NULL,80,
|
||||
'Old always-on Hetzner VM (about EUR 20/month, bought before the price rises). Replaced by stan-ovh on 2026-10-24; stopped, kept as a fallback until Stan cancels it.'),
|
||||
('stan-macbookpro','MacBook Pro','{}','laptop','active','Apple','MacBook Pro','{}',NULL,NULL,NULL,NULL,NULL,NULL,30,
|
||||
'Exact model/chip not recorded yet. An M5 Max MacBook Pro is planned.'),
|
||||
('stan-pixel','Phone','{}','phone','active','Google','Pixel 11 Pro XL','{"storage":"256 GB"}',NULL,NULL,NULL,'Belsimpel','2026-10-18',NULL,40,
|
||||
'Main phone since 2026-10-19, stock Android. About EUR 1,299 SIM-free (exact amount not recorded).'),
|
||||
('stan-iphone','Old phone','{}','phone','spare','Apple','iPhone 14 Pro Max','{}',NULL,NULL,NULL,NULL,NULL,NULL,45,
|
||||
'Previous phone, kept (weak battery and speed).'),
|
||||
('stan-ipad','iPad','{}','tablet','active','Apple','iPad Air 13-inch (M2)','{"screen_in":13,"chip":"Apple M2"}',NULL,NULL,NULL,NULL,NULL,NULL,50,
|
||||
'With a keyboard case; used for typing on the go and Termius SSH.'),
|
||||
('cargill-laptop','Work laptop','{}','laptop','active',NULL,NULL,'{"ram":"16 GB"}',NULL,NULL,NULL,NULL,NULL,NULL,60,
|
||||
'Cargill work laptop (not personal; Windows + WSL2). Model not recorded.')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- Peripherals get real categories instead of "accessory".
|
||||
UPDATE hardware.components SET category = 'mouse' WHERE category = 'accessory' AND model ILIKE 'MX Master%';
|
||||
--> statement-breakpoint
|
||||
UPDATE hardware.components SET category = 'keyboard' WHERE category = 'accessory' AND (model ILIKE 'Alloy Origins%' OR model ILIKE 'Pudding Keycaps%' OR model ILIKE 'UwU%');
|
||||
--> statement-breakpoint
|
||||
UPDATE hardware.components SET category = 'controller' WHERE category = 'accessory' AND brand = 'Thrustmaster';
|
||||
--> statement-breakpoint
|
||||
UPDATE hardware.components SET category = 'audio' WHERE category = 'accessory' AND model ILIKE '%mic boom arm%';
|
||||
--> statement-breakpoint
|
||||
UPDATE hardware.components SET notes = replace(notes, ', delivered 2026-03-03 to Houtenstraat 15 Gouda.', ', delivered 2026-03-03.') WHERE notes LIKE '%Houtenstraat%';
|
||||
--> statement-breakpoint
|
||||
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, notes, source)
|
||||
SELECT d.id, v.category, v.brand, v.model, v.quantity, v.specs::jsonb, v.status, true, v.notes, v.source
|
||||
FROM (VALUES
|
||||
('stan-ovh','cpu','AMD','Ryzen 7 9700X',1,'{"cores":8,"threads":16,"socket":"AM5"}','in_use',NULL,'manual'),
|
||||
('stan-ovh','ram',NULL,'64 GB DDR5 ECC',1,'{"type":"DDR5 ECC","capacity_gb":64}','in_use',NULL,'manual'),
|
||||
('stan-ovh','storage',NULL,'512 GB NVMe SSD',2,'{"interface":"NVMe","capacity_gb":512,"raid":"software RAID1"}','in_use','Mirrored (RAID1), about 440 GB usable for data.','manual'),
|
||||
('stan-pc-next','cpu','AMD','Top Ryzen X3D (model TBD)',1,'{"socket":"AM5"}','planned','Pick the fastest gaming X3D chip available when buying.','plan'),
|
||||
('stan-pc-next','gpu','NVIDIA','GeForce RTX 5090',1,'{"vram_gb":32}','planned','Board partner TBD.','plan'),
|
||||
('stan-pc-next','motherboard',NULL,'High-end AM5 motherboard (TBD)',1,'{"socket":"AM5"}','planned',NULL,'plan'),
|
||||
('stan-pc-next','ram',NULL,'Fast DDR5 kit (TBD)',1,'{"type":"DDR5"}','planned',NULL,'plan'),
|
||||
('stan-pc-next','storage',NULL,'PCIe 5.0 NVMe SSD (TBD)',1,'{"interface":"PCIe 5.0 x4 NVMe"}','planned',NULL,'plan'),
|
||||
('stan-pc-next','cpu_cooler',NULL,'Cooler for the new CPU (TBD)',1,'{}','planned','Must fit the new socket and the O11 Dynamic XL (or the new case).','plan')
|
||||
) AS v(device, category, brand, model, quantity, specs, status, notes, source)
|
||||
JOIN hardware.devices d ON d.name = v.device
|
||||
ON CONFLICT (device_id, model, order_number) DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- Games Stan plays; cm/360 left empty for him to fill. CS2's yaw (0.022 deg/count) is the
|
||||
-- Source engine constant, so its in-game value is computed from a mouse's measured DPI.
|
||||
INSERT INTO hardware.sensitivities (game, yaw, sort_order) VALUES
|
||||
('Counter-Strike 2', 0.022, 10),
|
||||
('Call of Duty', NULL, 20),
|
||||
('osu!', NULL, 30),
|
||||
('Sea of Thieves', NULL, 40)
|
||||
ON CONFLICT (game) DO NOTHING;
|
||||
21290
drizzle/20261024174805_hardware/snapshot.json
Normal file
21290
drizzle/20261024174805_hardware/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
140
drizzle/20261024193304_planes/migration.sql
Normal file
140
drizzle/20261024193304_planes/migration.sql
Normal file
@@ -0,0 +1,140 @@
|
||||
CREATE SCHEMA "planes";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "planes"."makers" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"full_name" text,
|
||||
"country" text,
|
||||
"wikidata" text,
|
||||
"wikipedia" text,
|
||||
"type_count" integer DEFAULT 0 NOT NULL,
|
||||
"first_year" smallint,
|
||||
"last_year" smallint,
|
||||
"built_total" integer DEFAULT 0 NOT NULL,
|
||||
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"logo" text,
|
||||
"logo_file" text,
|
||||
"logo_author" text,
|
||||
"logo_license" text,
|
||||
"photo" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "planes"."photos" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"type_id" integer NOT NULL,
|
||||
"position" smallint NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"file" text NOT NULL,
|
||||
"caption" text,
|
||||
"thumb" text NOT NULL,
|
||||
"width" integer NOT NULL,
|
||||
"height" integer NOT NULL,
|
||||
"author" text,
|
||||
"license" text,
|
||||
"license_url" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "planes"."types" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"dex" integer NOT NULL,
|
||||
"maker_id" integer NOT NULL,
|
||||
"family_id" integer,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"full_name" text NOT NULL,
|
||||
"search" text NOT NULL,
|
||||
"wikidata" text NOT NULL,
|
||||
"wikipedia" text,
|
||||
"kind" text,
|
||||
"description" text,
|
||||
"summary" text,
|
||||
"role" text,
|
||||
"roles" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"role_label" text,
|
||||
"country" text,
|
||||
"first_flight" text,
|
||||
"first_flight_year" smallint,
|
||||
"introduced_year" smallint,
|
||||
"retired_year" smallint,
|
||||
"status" text,
|
||||
"in_service" boolean,
|
||||
"number_built" integer,
|
||||
"makers" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"engine_type" text,
|
||||
"engine_count" smallint,
|
||||
"engines" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"crew" smallint,
|
||||
"passengers" smallint,
|
||||
"length_m" real,
|
||||
"wingspan_m" real,
|
||||
"height_m" real,
|
||||
"rotor_m" real,
|
||||
"mtow_kg" integer,
|
||||
"max_speed_kmh" smallint,
|
||||
"cruise_kmh" smallint,
|
||||
"range_km" integer,
|
||||
"ceiling_m" integer,
|
||||
"wing" text,
|
||||
"gear" text,
|
||||
"icao" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"recognize" text,
|
||||
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"photo" text,
|
||||
"photo_count" smallint DEFAULT 0 NOT NULL,
|
||||
"photos_checked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "makers_slug_key" ON "planes"."makers" ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "photos_type_position_key" ON "planes"."photos" ("type_id","position");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "types_dex_key" ON "planes"."types" ("dex");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "types_wikidata_key" ON "planes"."types" ("wikidata");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "types_maker_slug_key" ON "planes"."types" ("maker_id","slug");--> statement-breakpoint
|
||||
CREATE INDEX "types_number_built_idx" ON "planes"."types" ("number_built");--> statement-breakpoint
|
||||
CREATE INDEX "types_first_flight_year_idx" ON "planes"."types" ("first_flight_year");--> statement-breakpoint
|
||||
CREATE INDEX "types_search_trgm_idx" ON "planes"."types" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||
ALTER TABLE "planes"."photos" ADD CONSTRAINT "photos_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "planes"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "planes"."types" ADD CONSTRAINT "types_maker_id_makers_id_fkey" FOREIGN KEY ("maker_id") REFERENCES "planes"."makers"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "planes"."types" ADD CONSTRAINT "types_family_id_types_id_fkey" FOREIGN KEY ("family_id") REFERENCES "planes"."types"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/planes-import.ts and scripts/planes-photos.ts.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['makers','types'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON planes.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA planes TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA planes TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA planes TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA planes GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA planes GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA planes TO personal_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA planes TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA planes GRANT SELECT ON TABLES TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('planes','makers','planes-makers','Plane makers','core',false,false,1000),
|
||||
('planes','types','planes-types','Plane types','core',false,false,1000),
|
||||
('planes','photos','planes-photos','Plane photos','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
22615
drizzle/20261024193304_planes/snapshot.json
Normal file
22615
drizzle/20261024193304_planes/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
122
drizzle/20261024215408_wildlife/migration.sql
Normal file
122
drizzle/20261024215408_wildlife/migration.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
CREATE SCHEMA "wildlife";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "wildlife"."groups" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"name_nl" text NOT NULL,
|
||||
"emoji" text NOT NULL,
|
||||
"sort_order" smallint DEFAULT 0 NOT NULL,
|
||||
"species_count" integer DEFAULT 0 NOT NULL,
|
||||
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||
"photo" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "wildlife"."photos" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"species_id" integer NOT NULL,
|
||||
"position" smallint NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"photo_id" integer NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"width" integer,
|
||||
"height" integer,
|
||||
"author" text,
|
||||
"license" text NOT NULL,
|
||||
"page_url" text,
|
||||
"place" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "wildlife"."species" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"dex" integer NOT NULL,
|
||||
"group_id" integer NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"scientific_name" text NOT NULL,
|
||||
"name_en" text,
|
||||
"name_nl" text,
|
||||
"search" text NOT NULL,
|
||||
"inat_id" integer NOT NULL,
|
||||
"gbif_key" integer,
|
||||
"wikidata" text,
|
||||
"wikipedia_en" text,
|
||||
"wikipedia_nl" text,
|
||||
"class_name" text,
|
||||
"order_name" text,
|
||||
"family_name" text,
|
||||
"iucn" text,
|
||||
"nl_status" text,
|
||||
"nl_establishment" text,
|
||||
"extinct" boolean DEFAULT false NOT NULL,
|
||||
"nl_obs" integer DEFAULT 0 NOT NULL,
|
||||
"europe_obs" integer DEFAULT 0 NOT NULL,
|
||||
"world_obs" integer DEFAULT 0 NOT NULL,
|
||||
"gbif_nl" integer,
|
||||
"scope" text NOT NULL,
|
||||
"summary" text,
|
||||
"summary_nl" text,
|
||||
"facts" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"mass_kg" real,
|
||||
"length_m" real,
|
||||
"lifespan_years" real,
|
||||
"named_year" smallint,
|
||||
"named_by" text,
|
||||
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"photo" text,
|
||||
"photo_count" smallint DEFAULT 0 NOT NULL,
|
||||
"photos_checked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "groups_slug_key" ON "wildlife"."groups" ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "photos_species_position_key" ON "wildlife"."photos" ("species_id","position");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "photos_species_photo_key" ON "wildlife"."photos" ("species_id","photo_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "species_dex_key" ON "wildlife"."species" ("dex");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "species_inat_key" ON "wildlife"."species" ("inat_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "species_slug_key" ON "wildlife"."species" ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "species_group_idx" ON "wildlife"."species" ("group_id");--> statement-breakpoint
|
||||
CREATE INDEX "species_nl_obs_idx" ON "wildlife"."species" ("nl_obs");--> statement-breakpoint
|
||||
CREATE INDEX "species_search_trgm_idx" ON "wildlife"."species" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||
ALTER TABLE "wildlife"."photos" ADD CONSTRAINT "photos_species_id_species_id_fkey" FOREIGN KEY ("species_id") REFERENCES "wildlife"."species"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "wildlife"."species" ADD CONSTRAINT "species_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "wildlife"."groups"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/wildlife-import.ts and scripts/wildlife-photos.ts.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['groups','species'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON wildlife.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA wildlife TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA wildlife TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA wildlife TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA wildlife TO personal_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA wildlife TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT SELECT ON TABLES TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('wildlife','groups','wildlife-groups','Wildlife groups','core',false,false,1000),
|
||||
('wildlife','species','wildlife-species','Wildlife species','core',false,false,1000),
|
||||
('wildlife','photos','wildlife-photos','Wildlife photos','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user