when-am-i / commit
feat: Playwright screenshot tooling + checked-in screenshots
README.md | 17 + package-lock.json | 504 ++++++++++++++++++++- package.json | 4 + screenshots/app/01-landing.png | Bin 0 -> 340620 bytes screenshots/app/02-boston-1700.png | Bin 0 -> 1666312 bytes screenshots/app/03-boston-1800.png | Bin 0 -> 1664908 bytes screenshots/calendars/boston/01-gregorian.png | Bin 0 -> 1660225 bytes .../boston/02-algonquian-lunar-calendar.png | Bin 0 -> 1661597 bytes scripts/generate-screenshots.mts | 56 +++ scripts/lib/server.mts | 55 +++ scripts/screenshot-calendar.mts | 114 +++++ 11 files changed, 747 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a53e2be..6a80449 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,20 @@ npm test # Vitest unit tests npm run test:watch # Vitest in watch mode ``` +### Screenshots + +Screenshots are generated with Playwright, which forces deterministic app state +via URL params (`?lat=&lng=&zoom=&year=&month=&day=&era=`). The scripts reuse a +running dev server or start one automatically. + +```bash +npm run screenshot # app states → screenshots/app/ +npm run screenshot:calendar -- src/data/calendars/boston.json # one calendar → screenshots/calendars/<file>/ +``` + +`screenshot:calendar` pins a point inside the region and a representative date +in each era, capturing the answer panel and shaded map per era. + ## Reporting issues The app footer links to the [source repository](https://gitlab.com/PavelSafronov/what-year-is-it) @@ -125,6 +139,9 @@ See `src/data/calendars/SCHEMA.json` for the schema and field documentation. - Add or edit files in `src/data/calendars/` via pull request. - Use [geojson.io](https://geojson.io/) or similar tools to draw and export region polygons. - Include sources/references for each era. +- Run `npm run screenshot:calendar -- src/data/calendars/<your-file>.json` and + attach the images to your PR, so the region, eras, and conversions can be + reviewed visually. - The build process will validate and compile all data for use in the app. ## License diff --git a/package-lock.json b/package-lock.json index 5875ac2..c024883 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,8 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.6.0", + "playwright": "^1.61.0", + "tsx": "^4.22.4", "typescript": "^5.0.0", "typescript-eslint": "^8.61.1", "vite": "^5.0.0", @@ -649,7 +651,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -684,7 +685,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -719,7 +719,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -3468,6 +3467,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -3790,6 +3836,458 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index b3dafae..a7c8918 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "screenshot": "tsx scripts/generate-screenshots.mts", + "screenshot:calendar": "tsx scripts/screenshot-calendar.mts", "gen:favicon": "sips -z 64 64 calendar-source.png --out src/assets/favicon.png", "copy:leaflet-icons": "mkdir -p public && cp node_modules/leaflet/dist/images/marker-icon.png public/ && cp node_modules/leaflet/dist/images/marker-icon-2x.png public/ && cp node_modules/leaflet/dist/images/marker-shadow.png public/", "postinstall": "npm run copy:leaflet-icons" @@ -33,6 +35,8 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.6.0", + "playwright": "^1.61.0", + "tsx": "^4.22.4", "typescript": "^5.0.0", "typescript-eslint": "^8.61.1", "vite": "^5.0.0", diff --git a/screenshots/app/01-landing.png b/screenshots/app/01-landing.png new file mode 100644 index 0000000..cd22e0c Binary files /dev/null and b/screenshots/app/01-landing.png differ diff --git a/screenshots/app/02-boston-1700.png b/screenshots/app/02-boston-1700.png new file mode 100644 index 0000000..83a4c59 Binary files /dev/null and b/screenshots/app/02-boston-1700.png differ diff --git a/screenshots/app/03-boston-1800.png b/screenshots/app/03-boston-1800.png new file mode 100644 index 0000000..3ce776f Binary files /dev/null and b/screenshots/app/03-boston-1800.png differ diff --git a/screenshots/calendars/boston/01-gregorian.png b/screenshots/calendars/boston/01-gregorian.png new file mode 100644 index 0000000..81d39b6 Binary files /dev/null and b/screenshots/calendars/boston/01-gregorian.png differ diff --git a/screenshots/calendars/boston/02-algonquian-lunar-calendar.png b/screenshots/calendars/boston/02-algonquian-lunar-calendar.png new file mode 100644 index 0000000..f841f78 Binary files /dev/null and b/screenshots/calendars/boston/02-algonquian-lunar-calendar.png differ diff --git a/scripts/generate-screenshots.mts b/scripts/generate-screenshots.mts new file mode 100644 index 0000000..575af46 --- /dev/null +++ b/scripts/generate-screenshots.mts @@ -0,0 +1,56 @@ +// Generate documentation screenshots of key app states. +// +// Forces deterministic state via URL params (the same hook the app reads), so +// shots are reproducible. Output: screenshots/app/. Run from the repo root: +// npm run screenshot + +import { chromium } from 'playwright'; +import { mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { startDevServer, BASE_URL, REPO_ROOT } from './lib/server.mts'; + +const OUT_DIR = path.join(REPO_ROOT, 'screenshots', 'app'); + +const STATES = [ + { name: '01-landing', query: '', desc: 'Landing (default 1700, no pin)' }, + { + name: '02-boston-1700', + query: '?lat=42.30&lng=-71.06&zoom=10&year=1700&month=6&day=15', + desc: 'Boston, 1700 — Algonquian (descriptive-only)', + }, + { + name: '03-boston-1800', + query: '?lat=42.30&lng=-71.06&zoom=10&year=1800&month=6&day=15', + desc: 'Boston, 1800 — Gregorian', + }, +]; + +async function run() { + console.log('\n📸 Generating app screenshots…\n'); + const server = await startDevServer(); + try { + await rm(OUT_DIR, { recursive: true, force: true }); + await mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1100, height: 920 }, deviceScaleFactor: 2 }); + + for (const state of STATES) { + await page.goto(`${BASE_URL}${state.query}`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(800); // let map tiles settle + const file = path.join(OUT_DIR, `${state.name}.png`); + await page.screenshot({ path: file }); + console.log(` ✅ ${state.name} — ${state.desc}`); + } + + await browser.close(); + console.log('\n✨ Done — screenshots/app/\n'); + } finally { + server.stop(); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/lib/server.mts b/scripts/lib/server.mts new file mode 100644 index 0000000..2d8af7d --- /dev/null +++ b/scripts/lib/server.mts @@ -0,0 +1,55 @@ +// Dev-server lifecycle for the screenshot scripts: reuse a running Vite server +// on 5173 if there is one, otherwise start one and stop it when finished. + +import { spawn, type ChildProcess } from 'node:child_process'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(__dirname, '../..'); +export const BASE_URL = 'http://127.0.0.1:5173/what-year-is-it/'; + +function isUp(url: string): Promise<boolean> { + return new Promise((resolve) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(true); + }); + req.on('error', () => resolve(false)); + req.setTimeout(1000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export interface DevServer { + stop: () => void; +} + +export async function startDevServer(): Promise<DevServer> { + if (await isUp(BASE_URL)) { + console.log(' ℹ️ Reusing the dev server already running on :5173'); + return { stop: () => {} }; + } + + console.log(' 🚀 Starting Vite dev server…'); + const proc: ChildProcess = spawn('npx', ['vite', '--port', '5173', '--strictPort'], { + cwd: REPO_ROOT, + stdio: 'ignore', + }); + + const start = Date.now(); + while (Date.now() - start < 30_000) { + if (await isUp(BASE_URL)) { + console.log(' ✅ Dev server ready'); + return { stop: () => proc.kill() }; + } + await wait(500); + } + proc.kill(); + throw new Error('Dev server did not start within 30s'); +} diff --git a/scripts/screenshot-calendar.mts b/scripts/screenshot-calendar.mts new file mode 100644 index 0000000..0749105 --- /dev/null +++ b/scripts/screenshot-calendar.mts @@ -0,0 +1,114 @@ +// Screenshot one calendar data file across its eras, for inclusion in a +// calendar PR (so reviewers can see geometry/era/conversion issues visually). +// +// npm run screenshot:calendar -- src/data/calendars/boston.json +// +// For each era it pins a point inside the region and a representative date in +// that era, then captures the answer panel + shaded map. Output: +// screenshots/calendars/<file>/ + +import { chromium } from 'playwright'; +import { readFile, mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { startDevServer, BASE_URL, REPO_ROOT } from './lib/server.mts'; + +interface Era { + name: string; + start: string | null; + end: string | null; + calendar: string; +} +interface CalendarRegion { + region: { name: string; geo: { type: string; coordinates: unknown } }; + eras: Era[]; +} + +// Flatten any Polygon/MultiPolygon to its [lng, lat] positions. +function positions(geo: { type: string; coordinates: unknown }): [number, number][] { + const coords = geo.coordinates as number[][][] | number[][][][]; + return geo.type === 'Polygon' + ? (coords as number[][][]).flat() as [number, number][] + : (coords as number[][][][]).flat(2) as [number, number][]; +} + +function bboxCenterAndZoom(geo: { type: string; coordinates: unknown }) { + const pts = positions(geo); + const lngs = pts.map((p) => p[0]); + const lats = pts.map((p) => p[1]); + const minLng = Math.min(...lngs), maxLng = Math.max(...lngs); + const minLat = Math.min(...lats), maxLat = Math.max(...lats); + const center = { lng: (minLng + maxLng) / 2, lat: (minLat + maxLat) / 2 }; + const span = Math.max(maxLng - minLng, maxLat - minLat, 0.01); + const zoom = Math.max(2, Math.min(14, Math.floor(Math.log2(360 / span)) - 1)); + return { center, zoom }; +} + +function yearOf(value: string | null): number | null { + if (!value) return null; + const m = /^(-?\d+)/.exec(value); + return m ? parseInt(m[1], 10) : null; +} + +// A representative astronomical year inside an era's range. +function representativeYear(era: Era): number { + const start = yearOf(era.start); + const end = yearOf(era.end); + if (start !== null && end !== null) return Math.round((start + end) / 2); + if (start !== null) return start + 25; + if (end !== null) return end - 25; + return 1700; +} + +function slug(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} + +async function run() { + const fileArg = process.argv[2]; + if (!fileArg) { + console.error('usage: npm run screenshot:calendar -- <path/to/calendar.json>'); + process.exit(1); + } + + const absPath = path.resolve(fileArg); + const data = JSON.parse(await readFile(absPath, 'utf-8')) as CalendarRegion; + const { center, zoom } = bboxCenterAndZoom(data.region.geo); + + const outDir = path.join(REPO_ROOT, 'screenshots', 'calendars', path.basename(fileArg, '.json')); + console.log(`\n📸 Screenshotting ${data.region.name} (${data.eras.length} eras)…\n`); + + const server = await startDevServer(); + try { + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1100, height: 920 }, deviceScaleFactor: 2 }); + + for (let i = 0; i < data.eras.length; i++) { + const era = data.eras[i]; + const astroYear = representativeYear(era); + const year = astroYear > 0 ? astroYear : 1 - astroYear; + const eraParam = astroYear > 0 ? 'CE' : 'BCE'; + const query = + `?lat=${center.lat}&lng=${center.lng}&zoom=${zoom}` + + `&year=${year}&month=7&day=1&era=${eraParam}`; + + await page.goto(`${BASE_URL}${query}`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(800); + const name = `${String(i + 1).padStart(2, '0')}-${slug(era.calendar)}.png`; + await page.screenshot({ path: path.join(outDir, name) }); + console.log(` ✅ ${name} — ${era.name} (${year} ${eraParam})`); + } + + await browser.close(); + console.log(`\n✨ Done — ${path.relative(REPO_ROOT, outDir)}/\n`); + } finally { + server.stop(); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +});