尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
Centralized, searchable schedules for San Francisco public swimming pools. This app scrapes official pool schedule PDFs from SF Rec & Park, uses an LLM to extract structured schedules, and provides a clean UI to browse by program, day, time, and pool.
• Next.js (App Router), React 19, TypeScript
• Tailwind CSS v4 (via @tailwindcss/postcss and @import "tailwindcss")
• Vercel AI SDK (ai) with Google Generative AI provider (@ai-sdk/google)
• Zod for strict schema validation
• GitHub Actions for automated weekly schedule updates
• Node.js 22+
• npm
• A Google Generative AI API key查看完整 README ↗Centralized, searchable schedules for San Francisco public swimming pools. This app scrapes official pool schedule PDFs from SF Rec & Park, uses an LLM to extract structured schedules, and provides a clean UI to browse by program, day, time, and pool.
@tailwindcss/postcss and @import "tailwindcss")ai) with Google Generative AI provider (@ai-sdk/google)Install dependencies:
npm install
Create .env.local in the project root and add:
GOOGLE_GENERATIVE_AI_API_KEY=your_key_here
Run the dev server:
npm run dev
Generate schedules:
npm run build-schedules
This will scrape PDF URLs, download PDFs, extract schedules, and write public/data/all_schedules.json. View at /schedules.
data/pools.json — Source of truth for static pool metadata (id, name, shortName, address, pageUrl). Does not change frequently.public/data/discovered_pool_schedules.json — Scraped PDF URLs (poolId → pdfUrl mapping). Regenerated on each scrape.data/pdf-manifest.json — Tracks downloaded PDFs by hash to detect changes.data/extracted/<poolId>.json — Cached LLM extractions per PDF, keyed by PDF hash in data/extracted/_manifest.json. Committed, so the weekly run only sends a PDF to the model when it has actually changed.public/data/all_schedules.json — Aggregated schedule data for the UI.data/changelog/ — Change history between schedule updates.scrape → download-pdfs → process-all-pdfs
↓ ↓ ↓
validates checks hash preserves data
pool count downloads if fails on large
& URLs content changed changes
pools.json — fails if pool count or page URLs change unexpectedly.The pipeline computes a changelog comparing old vs new schedules:
Set FAIL_ON_LARGE_CHANGES=false locally to bypass this check during development.
npm run dev — start Next.js dev servernpm run build — build for productionnpm run start — start production buildnpm run lint — run ESLintnpm run test — run testsnpm run scrape — scrape pool pages, validate against pools.json, discover PDF URLsnpm run download-pdfs — download changed PDFs into data/pdfs/npm run process-all-pdfs — extract schedules from PDFs, preserve unchanged, write changelognpm run build-schedules — full pipeline: scrape → download → processnpm run scrape-alerts — scrape pool alerts from SF Rec & Parknpm run scrape-holidays — fetch the city holiday list from sf.gov into public/data/city_holidays.json (-- --force to fetch when the year is already saved); the "Update City Holidays" workflow runs it daily from December through Februarynpm run analyze-programs — analyze raw vs canonical program namespools.json (address, URLs)GitHub Actions runs weekly to:
public/data/ and data/changelog/If large changes are detected, the build fails and a notification is sent for manual review.
# Required: Google AI API key for schedule extraction
GOOGLE_GENERATIVE_AI_API_KEY=your_key_here
# Optional: Disable build failure on large changes (for local dev)
FAIL_ON_LARGE_CHANGES=false
# Optional: Force re-extraction even if cache exists
REFRESH_EXTRACT=1
# For CI notifications (GitHub Actions secrets)
PUSHOVER_USER_KEY=...
PUSHOVER_API_TOKEN=...
The animated pool header is a two-pass WebGL pipeline run by p5.js in src/components/HeaderAnimation.tsx.
Pass 1 — simulation (src/components/water-sim.frag). A real water heightfield is simulated with the discrete 2D wave equation over a small float texture:
next = (2·h − hPrev + WAVE_SPEED · laplacian(h)) · DAMPING
Each texel stores the current height in the red channel and the previous frame's height in green; two framebuffers ping-pong so one frame's output is the next frame's input. Everything watery — rings expanding, waves interfering, reflections off the pool edges (clamped texture sampling acts as walls) — emerges from this one equation. Nothing else animates the ripples.
Disturbances are injected into the field each frame:
mix) rather than adding, so holding the pointer in place can't stack the dent infinitely deep.SCROLL_COOLDOWN_MS) so scrubbing the scrollbar can't pump the pool into chaos. Per-column hash jitter roughens the line so it doesn't read as a ruler-straight artifact.The texture is sized in CSS pixels (SIM_TEXEL_CSS_PX, ~3px per texel, square on screen) so ripples have the same on-screen wavelength and speed on every device, from phone to ultrawide.
Pass 2 — display (src/components/header-shader.frag). Renders the tiles, "SF POOLS" text, and lighting. It samples the heightfield (height + gradient via central differences) and combines it with a cheap analytic ambient swell (four drifting sines). The combined wave gradient drives everything, which is what makes the effects feel physically coherent:
x/(1+x) curves so extreme waves can't blow out to white or blackpow(dot(N, L), 64) sparkle on slopes tilted toward the light, scaled by local ripple energy so glints fade with the wave rather than snapping off| Knob | Where | Effect |
|---|---|---|
WAVE_SPEED | water-sim.frag | Ring travel speed. Must stay < 0.5 (CFL stability limit) |
DAMPING | water-sim.frag | How long waves live; toward 1.0 = longer sloshing |
SIM_SUBSTEPS | HeaderAnimation.tsx | Sim steps per frame; overall simulation speed |
SIM_TEXEL_CSS_PX | HeaderAnimation.tsx | Master scale: bigger = chunkier, softer water everywhere |
SIM_IMPULSE_RADIUS | HeaderAnimation.tsx | Size of the pointer's dent, in sim texels |
impulseAmp formula | HeaderAnimation.tsx | How hard moves/clicks press into the water |
SCROLL_AMP_PER_PX, SCROLL_AMP_MAX | HeaderAnimation.tsx | Scroll slosh strength per unit of scroll acceleration, and its cap |
SCROLL_COOLDOWN_MS | HeaderAnimation.tsx | Min gap between scroll sloshes; higher = calmer under scrubbing |
SIM_GRAD_SCALE | header-shader.frag | How strongly ripples refract tiles / bend caustics / glint |
SIM_HEIGHT_SCALE | header-shader.frag | How strongly crests brighten and troughs darken |
refraction 0.035 | header-shader.frag main() | Overall "looking through water" distortion |
caustic coupling 0.6 | header-shader.frag main() | How much waves warp the caustic pattern |
dispersion exponents 1.25 / 0.8 | header-shader.frag main() | Chromatic fringing around caustic highlights |
| glint constants | header-shader.frag main() | Sparkle tightness (64), threshold (0.05), brightness (0.4) |
Two structural constraints worth knowing before tweaking: sim texels must stay square on screen (the JS derives texture height from the clamped width — don't size the two axes independently) or waves propagate anisotropically; and windowResized deliberately ignores height-only resizes because iOS fires them as browser chrome collapses during scrolling — recreating the framebuffers there would blank the water mid-slosh.
MIT