diff --git a/bun.lock b/bun.lock
index 619fe7d..185de8b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,7 +6,7 @@
"name": "weather",
"devDependencies": {
"@playwright/test": "^1.60.0",
- "@screenly/edge-apps": "^1.2.1",
+ "@screenly/edge-apps": "^1.5.0",
"@types/bun": "^1.3.14",
"@types/jsdom": "^28.0.3",
"bun-types": "^1.3.14",
@@ -44,7 +44,7 @@
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
- "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
+ "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
@@ -122,53 +122,57 @@
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
- "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
- "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
- "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
+ "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
- "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
- "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
- "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
- "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
- "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
- "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
- "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
- "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
- "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
- "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
- "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
- "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
- "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
- "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
- "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
- "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
- "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
- "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
- "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
- "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
+ "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
- "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -416,7 +420,7 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
- "@screenly/edge-apps": ["@screenly/edge-apps@1.2.1", "", { "dependencies": { "@eslint/js": "^10.0.1", "@photostructure/tz-lookup": "^11.5.0", "@sentry/browser": "^10.59.0", "@tailwindcss/vite": "^4.2.1", "country-locale-map": "^1.9.12", "dayjs": "^1.11.19", "eslint": "^10.0.3", "jiti": "^2.6.1", "jsdom": "^28.1.0", "offline-geocode-city": "^1.0.2", "panic-overlay": "^1.0.51", "sharp": "^0.34.5", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.1.2", "yaml": "^2.8.2" }, "peerDependencies": { "@playwright/test": "^1.58.0" }, "optionalPeers": ["@playwright/test"], "bin": { "edge-apps-scripts": "bin/edge-apps-scripts.js" } }, "sha512-9CTA8eaYtVlrACHWU9uhml3Yf7hIgc6mELmXhqDdHKDC2fmrgma3sAOpvr0sBjTJr37QRvpeqUjlZ9BJlwT0nQ=="],
+ "@screenly/edge-apps": ["@screenly/edge-apps@1.5.0", "", { "dependencies": { "@eslint/js": "^10.0.1", "@photostructure/tz-lookup": "^11.5.0", "@sentry/browser": "^10.59.0", "@tailwindcss/vite": "^4.2.1", "country-locale-map": "^1.9.12", "dayjs": "^1.11.19", "eslint": "^10.0.3", "jiti": "^2.6.1", "jsdom": "^28.1.0", "offline-geocode-city": "^1.0.2", "panic-overlay": "^1.0.51", "sharp": "^0.35.3", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.1.2", "yaml": "^2.8.2" }, "peerDependencies": { "@playwright/test": "^1.58.0" }, "optionalPeers": ["@playwright/test"], "bin": { "edge-apps-scripts": "bin/edge-apps-scripts.js" } }, "sha512-yEO2KA61p6FHTYQUuw1ffyAcDDoxhiCrj8iQ2evAw9OCid2yYQ5Z2zJbAWebGfhxSo2NzfUEcC9O7rZd+VS7OA=="],
"@sentry/browser": ["@sentry/browser@10.60.0", "", { "dependencies": { "@sentry/browser-utils": "10.60.0", "@sentry/core": "10.60.0", "@sentry/feedback": "10.60.0", "@sentry/replay": "10.60.0", "@sentry/replay-canvas": "10.60.0" } }, "sha512-20vzPKGrmupJPCaWd+soCOLkZRwKZxt0AVF4XGPNoGp7D7wVPRlHf+FWwVMx+rRRkahKupFefM2D9YoiUiBeag=="],
@@ -878,7 +882,7 @@
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
- "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=="],
+ "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" } }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -978,6 +982,8 @@
"@parcel/source-map/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
+ "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
+
"@screenly/edge-apps/jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="],
"@screenly/edge-apps/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
diff --git a/index.html b/index.html
index 0b706be..e72aedb 100644
--- a/index.html
+++ b/index.html
@@ -11,64 +11,68 @@
reference-width="1920"
reference-height="1080"
orientation="auto"
+ padding="0"
>
-
-
+
+
-
-
- --
- --°
-
+
+
+ --
+ --°
+
+
+
+ --°
+ --°
+
+
-
-
-
-
-
-
-
Something went wrong
-
- The weather data could not be loaded. Please check your
- configuration or contact your administrator.
+
+
-
+
+
+
+
+ Weather unavailable
+
+
+
-
-
-
![]()
+
-
+
+
+
diff --git a/package.json b/package.json
index c48ad6d..5006581 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"dependencies": {},
"prettier": "./.prettierrc.json",
"devDependencies": {
- "@screenly/edge-apps": "^1.2.1",
+ "@screenly/edge-apps": "^1.5.0",
"@playwright/test": "^1.60.0",
"@types/bun": "^1.3.14",
"@types/jsdom": "^28.0.3",
diff --git a/screenshots/1080x1920.webp b/screenshots/1080x1920.webp
new file mode 100644
index 0000000..c4ee36c
Binary files /dev/null and b/screenshots/1080x1920.webp differ
diff --git a/screenshots/1280x720.webp b/screenshots/1280x720.webp
new file mode 100644
index 0000000..f7e2bbd
Binary files /dev/null and b/screenshots/1280x720.webp differ
diff --git a/screenshots/1920x1080.webp b/screenshots/1920x1080.webp
new file mode 100644
index 0000000..f6b154b
Binary files /dev/null and b/screenshots/1920x1080.webp differ
diff --git a/screenshots/2160x3840.webp b/screenshots/2160x3840.webp
new file mode 100644
index 0000000..9aaebf7
Binary files /dev/null and b/screenshots/2160x3840.webp differ
diff --git a/screenshots/2160x4096.webp b/screenshots/2160x4096.webp
new file mode 100644
index 0000000..55c6aed
Binary files /dev/null and b/screenshots/2160x4096.webp differ
diff --git a/screenshots/3840x2160.webp b/screenshots/3840x2160.webp
new file mode 100644
index 0000000..64117c5
Binary files /dev/null and b/screenshots/3840x2160.webp differ
diff --git a/screenshots/4096x2160.webp b/screenshots/4096x2160.webp
new file mode 100644
index 0000000..09031bf
Binary files /dev/null and b/screenshots/4096x2160.webp differ
diff --git a/screenshots/480x800.webp b/screenshots/480x800.webp
new file mode 100644
index 0000000..755715f
Binary files /dev/null and b/screenshots/480x800.webp differ
diff --git a/screenshots/720x1280.webp b/screenshots/720x1280.webp
new file mode 100644
index 0000000..08cc80e
Binary files /dev/null and b/screenshots/720x1280.webp differ
diff --git a/screenshots/800x480.webp b/screenshots/800x480.webp
new file mode 100644
index 0000000..a519915
Binary files /dev/null and b/screenshots/800x480.webp differ
diff --git a/screenshots/no-api-key-2160x3840.webp b/screenshots/no-api-key-2160x3840.webp
new file mode 100644
index 0000000..d50ad37
Binary files /dev/null and b/screenshots/no-api-key-2160x3840.webp differ
diff --git a/screenshots/no-api-key-3840x2160.webp b/screenshots/no-api-key-3840x2160.webp
new file mode 100644
index 0000000..b201947
Binary files /dev/null and b/screenshots/no-api-key-3840x2160.webp differ
diff --git a/src/background.test.ts b/src/background.test.ts
new file mode 100644
index 0000000..7516dd0
--- /dev/null
+++ b/src/background.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from 'bun:test'
+import { getBackgroundForWeatherId } from './background'
+
+describe('getBackgroundForWeatherId', () => {
+ test('treats thunderstorm, drizzle and rain as rainy', () => {
+ expect(getBackgroundForWeatherId(200)).toBe('rainy')
+ expect(getBackgroundForWeatherId(300)).toBe('rainy')
+ expect(getBackgroundForWeatherId(500)).toBe('rainy')
+ expect(getBackgroundForWeatherId(599)).toBe('rainy')
+ })
+
+ test('treats the 6xx range as snow', () => {
+ expect(getBackgroundForWeatherId(600)).toBe('snow')
+ expect(getBackgroundForWeatherId(699)).toBe('snow')
+ })
+
+ test('treats a clear sky as sunny', () => {
+ expect(getBackgroundForWeatherId(800)).toBe('sunny')
+ })
+
+ test('falls back to cloudy for atmosphere, clouds and anything unknown', () => {
+ expect(getBackgroundForWeatherId(701)).toBe('cloudy')
+ expect(getBackgroundForWeatherId(804)).toBe('cloudy')
+ expect(getBackgroundForWeatherId(0)).toBe('cloudy')
+ })
+})
diff --git a/src/background.ts b/src/background.ts
index ae9aa8c..dd247fc 100644
--- a/src/background.ts
+++ b/src/background.ts
@@ -1,40 +1,24 @@
-import bgSunny from '../static/images/bg-sunny.webp'
-import bgCloudy from '../static/images/bg-cloudy.webp'
-import bgRainy from '../static/images/bg-rainy.webp'
-import bgSnow from '../static/images/bg-snow.webp'
-
// OpenWeatherMap weather condition codes:
// https://openweathermap.org/weather-conditions
//
-// 2xx: Thunderstorm
-// 3xx: Drizzle
-// 5xx: Rain
-// 6xx: Snow
-// 7xx: Atmosphere (mist, fog, etc.)
-// 800: Clear
-// 80x: Clouds
-function getBackgroundForWeatherId(weatherId: number): string {
- if (weatherId >= 200 && weatherId < 600) {
- return bgRainy
- }
-
- if (weatherId >= 600 && weatherId < 700) {
- return bgSnow
- }
+// 2xx: Thunderstorm, 3xx: Drizzle, 5xx: Rain, 6xx: Snow,
+// 7xx: Atmosphere (mist, fog), 800: Clear, 80x: Clouds
+export const BACKGROUNDS = ['sunny', 'cloudy', 'rainy', 'snow'] as const
- if (weatherId >= 700 && weatherId < 800) {
- return bgCloudy
- }
+export type Background = (typeof BACKGROUNDS)[number]
- if (weatherId === 800) {
- return bgSunny
- }
+export function getBackgroundForWeatherId(weatherId: number): Background {
+ if (weatherId >= 200 && weatherId < 600) return 'rainy'
+ if (weatherId >= 600 && weatherId < 700) return 'snow'
+ if (weatherId === 800) return 'sunny'
- // 80x: Clouds
- return bgCloudy
+ return 'cloudy'
}
export function updateBackground(weatherId: number): void {
- const bg = getBackgroundForWeatherId(weatherId)
- document.body.style.backgroundImage = `url('${bg}')`
+ const background = getBackgroundForWeatherId(weatherId)
+
+ BACKGROUNDS.forEach((candidate) =>
+ document.body.classList.toggle(`bg-${candidate}`, candidate === background),
+ )
}
diff --git a/src/css/style.css b/src/css/style.css
index c069184..a6c8cfa 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -1,341 +1,428 @@
-/* Import Screenly Design System */
@import '@screenly/edge-apps/styles';
-/* App-specific styles */
+body {
+ --app-accent: var(--theme-color-primary, #972eff);
+ --app-ink: #fff;
+ --app-ink-dim: rgb(255 255 255 / 72%);
+ --app-ink-faint: rgb(255 255 255 / 55%);
+ --app-rule: rgb(255 255 255 / 22%);
+ --app-shadow: 0 0.125rem 1rem rgb(0 0 0 / 45%);
+
+ color: var(--app-ink);
+ background-color: #08080d;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: cover;
+}
-* {
- box-sizing: border-box;
+body.bg-sunny {
+ background-image: url('/static/images/bg-sunny.webp');
}
-body {
- margin: 0;
- padding: 0;
- overflow: hidden;
- color: white;
- font-family: 'Inter', system-ui, sans-serif;
- background: url('/static/images/bg-sunny.webp') no-repeat center center;
- background-size: cover;
- text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+body.bg-cloudy {
+ background-image: url('/static/images/bg-cloudy.webp');
}
+body.bg-rainy {
+ background-image: url('/static/images/bg-rainy.webp');
+}
+
+body.bg-snow {
+ background-image: url('/static/images/bg-snow.webp');
+}
+
+/* Keeps the overlay readable over any of the condition photographs */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: var(--z-index-backdrop);
- pointer-events: none;
- background: rgba(0, 0, 0, 0.05);
- backdrop-filter: blur(7px);
+ background:
+ linear-gradient(
+ to bottom,
+ rgb(4 4 8 / 62%) 0%,
+ rgb(4 4 8 / 34%) 42%,
+ rgb(4 4 8 / 82%) 100%
+ ),
+ linear-gradient(to right, rgb(4 4 8 / 55%) 0%, transparent 62%);
}
auto-scaler {
- position: relative;
z-index: var(--z-index-content);
}
-#app {
+.weather {
+ display: flex;
+ flex-direction: column;
width: 100%;
height: 100%;
+ padding: 4rem 5rem 3.5rem;
}
-/* Current Weather Section */
-.current-weather {
- position: absolute;
- left: 320px;
- top: 230px;
+.weather-header {
display: flex;
- flex-direction: column;
- gap: 0.5rem;
+ flex: none;
+ align-items: center;
+ justify-content: space-between;
+ gap: 2rem;
}
-.city {
+.weather-date {
+ margin: 0;
+ color: var(--app-ink-dim);
font-size: 2rem;
- font-weight: 400;
- letter-spacing: -0.04em;
-}
-
-.temperature-display {
- font-size: 7.5rem;
- font-weight: 400;
- line-height: 1;
+ font-weight: 500;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ text-shadow: var(--app-shadow);
}
-/* Status Pill */
-.status-pill {
- display: inline-flex;
+.weather-brand {
+ display: flex;
align-items: center;
- justify-content: space-between;
- padding: 0.25rem 1rem;
- height: 2.4375rem;
- border-radius: 1rem;
- border: 1.68px solid rgba(255, 255, 255, 0.06);
- background: linear-gradient(
- 172deg,
- rgba(255, 255, 255, 0.06) 0%,
- rgba(255, 255, 255, 0.01) 100%
- );
- box-shadow:
- 0 13.438px 40.315px 0 rgba(0, 0, 0, 0.15),
- 0 1.68px 0 0 rgba(255, 255, 255, 0.1) inset,
- 0 -1.68px 0 0 rgba(255, 255, 255, 0.03) inset;
- width: fit-content;
- gap: 2rem;
+ gap: 2.5rem;
}
-.weather-description {
- font-size: 1rem;
+.weather-clock {
+ color: var(--app-ink);
+ font-size: 2.25rem;
font-weight: 500;
- letter-spacing: -0.04em;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ text-shadow: var(--app-shadow);
}
-.hi-lo {
- display: flex;
- align-items: center;
- gap: 0.625rem;
+.weather-logo {
+ width: auto;
+ max-height: 3.5rem;
+}
+
+.weather-logo.is-hidden {
+ display: none;
}
-.hi,
-.lo {
+.middle {
display: flex;
+ flex: 1;
align-items: center;
- gap: 0.25rem;
- font-size: 1rem;
- font-weight: 500;
- letter-spacing: -0.04em;
-}
-
-/* Forecast Card */
-.forecast-card {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- top: 598px;
- width: 1280px;
- height: 269px;
- border-radius: 1rem;
- border: 1.054px solid rgba(255, 255, 255, 0.1);
- background: linear-gradient(
- 106deg,
- rgba(255, 255, 255, 0.09) 3%,
- rgba(255, 255, 255, 0.01) 100%
- );
- backdrop-filter: blur(31.63px);
- box-shadow:
- 0 1px 0 0 rgba(255, 255, 255, 0.15) inset,
- 0 -1px 0 0 rgba(255, 255, 255, 0.05) inset;
-}
-
-.forecast-header {
+ gap: 5rem;
+ min-height: 0;
+}
+
+.now {
display: flex;
- align-items: center;
+ flex: 1 1 52%;
+ flex-direction: column;
+ justify-content: center;
gap: 0.5rem;
- padding: 1.3rem 1.5rem 0;
+ min-height: 0;
}
-.forecast-header-icon {
- width: 1.05rem;
- height: 1.05rem;
+.trend {
+ display: flex;
+ flex: 0 1 38%;
+ flex-direction: column;
+ gap: 1rem;
+ align-self: center;
}
-.forecast-header span {
- font-size: 1.05rem;
- font-weight: 400;
- text-transform: uppercase;
- letter-spacing: 0.037rem;
- color: rgba(255, 255, 255, 0.7);
+.trend.is-hidden {
+ display: none;
}
-.forecast-divider {
- margin: 0.75rem 1.5rem 0;
- height: 1px;
- background: #d9d9d9;
- opacity: 0.2;
+.trend-label {
+ margin: 0;
+ color: var(--app-ink-faint);
+ font-size: 1.5rem;
+ font-weight: 600;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ text-shadow: var(--app-shadow);
}
-.forecast-items {
+.trend-plot {
display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 1rem 1.5rem 0;
+ align-items: stretch;
+ gap: 1.75rem;
}
-.forecast-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 1.5rem;
- flex: 0 0 auto;
+.trend-chart {
+ flex: 1;
+ width: 100%;
+ aspect-ratio: 100 / 40;
+ color: var(--app-ink);
+ filter: drop-shadow(0 0.125rem 0.75rem rgb(0 0 0 / 45%));
}
-.forecast-item:not(:first-child) {
- opacity: 0.8;
+.trend-now {
+ stroke: var(--app-ink-faint);
+ stroke-width: 0.4;
+ stroke-dasharray: 1.2 1.4;
+ stroke-linecap: round;
}
-.forecast-item-temp {
- font-size: 1.625rem;
+.trend-tick {
+ fill: var(--app-ink-faint);
+ font-size: 3.2px;
font-weight: 500;
+ letter-spacing: 0.04em;
}
-.forecast-item-icon {
- width: 4.5rem;
- height: 4.5rem;
- object-fit: contain;
+.trend-scale {
+ display: flex;
+ flex: none;
+ flex-direction: column;
+ justify-content: space-between;
+ margin: 0;
+ padding: 1.5rem 0 3rem;
+ font-size: 2rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ text-shadow: var(--app-shadow);
}
-.forecast-item-time {
- font-size: 1.3rem;
- font-weight: 500;
- text-align: center;
+.trend-scale-low {
+ color: var(--app-ink-faint);
+}
+
+.trend-dot {
+ fill: var(--app-ink);
+ stroke: rgb(4 4 8 / 55%);
+ stroke-width: 0.6;
+}
+
+.now-place {
+ margin: 0;
+ overflow: hidden;
white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--app-ink-dim);
+ font-size: 3rem;
+ font-weight: 500;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ text-shadow: var(--app-shadow);
}
-.forecast-item:not(:first-child) .forecast-item-time {
- color: #e9e9e9;
+.now-place.is-long {
+ font-size: 2.25rem;
}
-/* Error screen */
+.now-place.is-very-long {
+ font-size: 1.75rem;
+}
-#error-screen {
- position: absolute;
- inset: 0;
+.now-temp {
+ margin: 0;
+ font-size: 18rem;
+ font-weight: 600;
+ line-height: 0.9;
+ letter-spacing: -0.03em;
+ font-variant-numeric: tabular-nums;
+ text-shadow: var(--app-shadow);
+}
+
+.now-condition {
display: flex;
- align-items: center;
- justify-content: center;
- padding: 3rem;
- z-index: 10;
-}
-
-.error-card {
- background: linear-gradient(
- 106deg,
- rgba(255, 255, 255, 0.09) 3%,
- rgba(255, 255, 255, 0.01) 100%
- );
- border: 1.054px solid rgba(255, 255, 255, 0.1);
- border-radius: 1rem;
+ align-items: baseline;
+ gap: 2.5rem;
+ margin: 0;
+ font-size: 3rem;
+ font-weight: 500;
+ text-shadow: var(--app-shadow);
+}
+
+.now-description::first-letter {
+ text-transform: uppercase;
+}
+
+.now-range {
display: flex;
- flex-direction: row;
- overflow: hidden;
- max-width: 60rem;
- width: 100%;
- backdrop-filter: blur(31.63px);
- box-shadow:
- 0 1px 0 0 rgba(255, 255, 255, 0.15) inset,
- 0 -1px 0 0 rgba(255, 255, 255, 0.05) inset;
+ gap: 1.5rem;
+ color: var(--app-ink-faint);
+ font-variant-numeric: tabular-nums;
+}
+
+.now-high::before {
+ content: '↑ ';
+}
+
+.now-low::before {
+ content: '↓ ';
}
-.error-card-left {
- background: rgba(255, 255, 255, 0.08);
- border-right: 0.0625rem solid rgba(255, 255, 255, 0.1);
- padding: 2.5rem 2rem;
+.forecast {
display: flex;
+ flex: none;
flex-direction: column;
- justify-content: flex-start;
- gap: 1rem;
- flex: 0 0 20rem;
+ gap: 1.75rem;
+ padding-top: 2rem;
+ border-top: 0.0625rem solid var(--app-rule);
}
-.error-card-left h2 {
- font-size: 2rem;
- font-weight: 700;
- line-height: 1.2;
- letter-spacing: -0.03em;
- color: rgba(255, 255, 255, 0.95);
+.forecast.is-hidden {
+ display: none;
+}
+
+.forecast-label {
margin: 0;
- text-shadow: none;
+ color: var(--app-ink-faint);
+ font-size: 1.5rem;
+ font-weight: 600;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ text-shadow: var(--app-shadow);
}
-.error-card-left p {
- font-size: 1.25rem;
- color: rgba(255, 255, 255, 0.85);
- line-height: 1.6;
+.forecast-items {
+ display: flex;
+ justify-content: space-between;
+ gap: 1.5rem;
margin: 0;
- text-shadow: none;
+ padding: 0;
+ list-style: none;
}
-.error-card-right {
- flex: 1;
- padding: 2.5rem 2rem;
+.forecast-item {
display: flex;
+ flex: 1;
flex-direction: column;
- justify-content: flex-start;
+ align-items: center;
gap: 0.75rem;
+ text-shadow: var(--app-shadow);
}
-.error-detail-label {
- font-size: 0.75rem;
- font-weight: 600;
- color: rgba(255, 255, 255, 0.5);
+.forecast-item-time {
+ color: var(--app-ink-faint);
+ font-size: 1.75rem;
+ font-weight: 500;
+ letter-spacing: 0.06em;
text-transform: uppercase;
- letter-spacing: 0.08em;
- text-shadow: none;
+ white-space: nowrap;
+}
+
+.forecast-item-icon {
+ width: 4.5rem;
+ height: 4.5rem;
+}
+
+.forecast-item-temp {
+ font-size: 2.75rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.weather-error {
+ display: none;
+ flex: 1;
+ flex-direction: column;
+ justify-content: center;
+ gap: 1.5rem;
+ text-shadow: var(--app-shadow);
+}
+
+.weather-error h2 {
+ margin: 0;
+ font-size: 5rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
}
-.error-detail-message {
- font-size: 1.25rem;
- color: rgba(255, 255, 255, 0.85);
- line-height: 1.6;
- word-break: break-word;
+.weather-error p {
+ max-width: 46ch;
margin: 0;
- text-shadow: none;
+ color: var(--app-ink-dim);
+ font-size: 2.25rem;
+ line-height: 1.35;
+}
+
+.weather.has-error .now,
+.weather.has-error .forecast {
+ display: none;
+}
+
+.weather.has-error .weather-error {
+ display: flex;
}
-/* Portrait mode */
@media (orientation: portrait) {
- .current-weather {
- left: 60px;
- top: 200px;
+ .weather {
+ padding: 5rem 4rem 4.5rem;
}
- .city {
+ .weather-date {
+ font-size: 2.25rem;
+ }
+
+ .weather-clock {
font-size: 2.5rem;
}
- .temperature-display {
- font-size: 9rem;
+ .middle {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 3rem;
}
- .status-pill {
- font-size: 1.5rem;
+ .now {
+ flex: none;
+ gap: 1rem;
}
- .forecast-card {
- top: auto;
- bottom: 60px;
- width: calc(100% - 120px);
- height: auto;
- padding-bottom: 1.5rem;
+ .trend {
+ flex: none;
+ width: 100%;
}
- .forecast-item-temp {
- font-size: 2rem;
+ .trend-chart {
+ aspect-ratio: 100 / 34;
}
- .forecast-item-icon {
- width: 5rem;
- height: 5rem;
+ .trend-now {
+ stroke: var(--app-ink-faint);
+ stroke-width: 0.4;
+ stroke-dasharray: 1.2 1.4;
+ stroke-linecap: round;
}
- .forecast-item-time {
- font-size: 1.5rem;
+ .trend-tick {
+ font-size: 2.9px;
+ }
+
+ .now-place {
+ font-size: 3.25rem;
+ }
+
+ .now-place.is-long {
+ font-size: 2.4rem;
}
- .forecast-item-time-period {
- display: none;
+ .now-place.is-very-long {
+ font-size: 1.85rem;
}
- #error-screen {
- padding: 1.5rem 2rem;
+ .now-temp {
+ font-size: 14rem;
}
- .error-card {
+ .now-condition {
flex-direction: column;
- max-width: 100%;
+ align-items: flex-start;
+ gap: 1rem;
+ font-size: 3.25rem;
}
- .error-card-left {
- flex: none;
- border-right: none;
- border-bottom: 0.0625rem solid rgba(255, 255, 255, 0.1);
+ .forecast-items {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 2.5rem 1rem;
+ }
+
+ .forecast-item-time {
+ font-size: 1.9rem;
+ }
+
+ .forecast-item-temp {
+ font-size: 3rem;
}
}
diff --git a/src/graph.test.ts b/src/graph.test.ts
new file mode 100644
index 0000000..e8f50d0
--- /dev/null
+++ b/src/graph.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, test } from 'bun:test'
+import { buildGraphShape } from './graph'
+
+const HOUR = 60 * 60 * 1000
+const START = Date.UTC(2026, 0, 1, 9)
+const readings = [
+ { temperature: 10, timestamp: START },
+ { temperature: 16, timestamp: START + 3 * HOUR },
+ { temperature: 21, timestamp: START + 6 * HOUR },
+ { temperature: 14, timestamp: START + 9 * HOUR },
+]
+
+describe('buildGraphShape', () => {
+ test('needs at least two readings to draw a line', () => {
+ expect(buildGraphShape([])).toBeNull()
+ expect(buildGraphShape([{ temperature: 10, timestamp: START }])).toBeNull()
+ })
+
+ test('spreads points evenly across the full width', () => {
+ const shape = buildGraphShape(readings)!
+ const xs = shape.points.map((p) => p.x)
+ expect(xs[0]).toBeGreaterThan(0)
+ expect(xs.at(-1)).toBeLessThan(100)
+ const gaps = xs.slice(1).map((x, index) => x - xs[index])
+ gaps.forEach((gap) => expect(gap).toBeCloseTo(gaps[0], 6))
+ })
+
+ test('puts the warmest reading above the coldest', () => {
+ const shape = buildGraphShape(readings)!
+ expect(shape.high.temperature).toBe(21)
+ expect(shape.low.temperature).toBe(10)
+ expect(shape.high.y).toBeLessThan(shape.low.y)
+ })
+
+ test('closes the area path back along the baseline', () => {
+ const shape = buildGraphShape(readings)!
+ expect(shape.area.startsWith(shape.line)).toBe(true)
+ expect(shape.line.startsWith('M')).toBe(true)
+ expect(shape.area.endsWith('40 Z')).toBe(true)
+ })
+
+ test('draws a flat run down the middle rather than dividing by zero', () => {
+ const flat = buildGraphShape([
+ { temperature: 12, timestamp: START },
+ { temperature: 12, timestamp: START + 3 * HOUR },
+ ])!
+ expect(flat.points.every((p) => Number.isFinite(p.y))).toBe(true)
+ expect(flat.points[0].y).toBe(flat.points[1].y)
+ })
+})
+
+describe('smoothing', () => {
+ test('draws curves rather than straight segments', () => {
+ const shape = buildGraphShape(readings)!
+ expect(shape.line).toContain('C')
+ expect(shape.line).not.toContain('L')
+ })
+
+ test('still passes through every reading', () => {
+ const shape = buildGraphShape(readings)!
+ shape.points.forEach((point) => {
+ expect(shape.line).toContain(`${Math.round(point.x * 100) / 100} `)
+ })
+ })
+})
+
+describe('ticks', () => {
+ test('marks every second reading, so six-hourly on a three-hour grid', () => {
+ const shape = buildGraphShape(readings)!
+ expect(shape.ticks).toHaveLength(2)
+ expect(shape.ticks.map((t) => t.timestamp)).toEqual([
+ START,
+ START + 6 * HOUR,
+ ])
+ })
+
+ test('never repeats a reading in the tick list', () => {
+ const shape = buildGraphShape(readings)!
+ const stamps = shape.ticks.map((t) => t.timestamp)
+ expect(new Set(stamps).size).toBe(stamps.length)
+ })
+})
diff --git a/src/graph.ts b/src/graph.ts
new file mode 100644
index 0000000..8641612
--- /dev/null
+++ b/src/graph.ts
@@ -0,0 +1,118 @@
+/**
+ * Geometry for the temperature curve. Kept free of the DOM so the shape of the
+ * line can be tested without a browser.
+ */
+export interface GraphPoint {
+ x: number
+ y: number
+ temperature: number
+ timestamp: number
+}
+
+export interface GraphShape {
+ points: GraphPoint[]
+ line: string
+ area: string
+ high: GraphPoint
+ low: GraphPoint
+ ticks: GraphPoint[]
+}
+
+export interface GraphInput {
+ temperature: number
+ timestamp: number
+}
+
+const VIEWBOX_WIDTH = 100
+const VIEWBOX_HEIGHT = 40
+// Keeps the curve clear of the labels sitting above and below it
+const TOP_PADDING = 9
+const BOTTOM_PADDING = 7
+// Keeps the end caps and the outermost dots clear of the viewBox edge
+const SIDE_PADDING = 4
+
+/**
+ * Maps readings onto a 100x40 viewBox. A flat run would divide by zero, so it
+ * is drawn along the middle instead.
+ */
+export function buildGraphShape(readings: GraphInput[]): GraphShape | null {
+ if (readings.length < 2) return null
+
+ const temperatures = readings.map((reading) => reading.temperature)
+ const min = Math.min(...temperatures)
+ const max = Math.max(...temperatures)
+ const span = max - min
+ const usableHeight = VIEWBOX_HEIGHT - TOP_PADDING - BOTTOM_PADDING
+
+ const points: GraphPoint[] = readings.map((reading, index) => ({
+ x:
+ SIDE_PADDING +
+ (index / (readings.length - 1)) * (VIEWBOX_WIDTH - SIDE_PADDING * 2),
+ y:
+ span === 0
+ ? TOP_PADDING + usableHeight / 2
+ : TOP_PADDING +
+ usableHeight -
+ ((reading.temperature - min) / span) * usableHeight,
+ temperature: reading.temperature,
+ timestamp: reading.timestamp,
+ }))
+
+ const line = smoothPath(points)
+
+ const lastX = points[points.length - 1].x
+ const area = `${line} L${round(lastX)} ${VIEWBOX_HEIGHT} L${SIDE_PADDING} ${VIEWBOX_HEIGHT} Z`
+
+ return {
+ points,
+ line,
+ area,
+ high: points.reduce((a, b) => (b.temperature > a.temperature ? b : a)),
+ low: points.reduce((a, b) => (b.temperature < a.temperature ? b : a)),
+ ticks: pickTicks(points),
+ }
+}
+
+/**
+ * Readings arrive on a fixed three-hour grid, so taking every other one gives
+ * six-hourly marks on whole clock hours, the way a weather app labels a day.
+ */
+const TICK_STRIDE = 2
+
+function pickTicks(points: GraphPoint[]): GraphPoint[] {
+ return points.filter((_, index) => index % TICK_STRIDE === 0)
+}
+
+/**
+ * Catmull-Rom through every reading, converted to cubic beziers. Weather moves
+ * gradually, so a curve reads truer than straight segments between samples.
+ */
+function smoothPath(points: GraphPoint[]): string {
+ const segments = [`M${round(points[0].x)} ${round(points[0].y)}`]
+
+ for (let i = 0; i < points.length - 1; i++) {
+ const previous = points[i - 1] ?? points[i]
+ const current = points[i]
+ const next = points[i + 1]
+ const after = points[i + 2] ?? next
+
+ const cp1x = current.x + (next.x - previous.x) / 6
+ const cp1y = current.y + (next.y - previous.y) / 6
+ const cp2x = next.x - (after.x - current.x) / 6
+ const cp2y = next.y - (after.y - current.y) / 6
+
+ segments.push(
+ `C${round(cp1x)} ${round(cp1y)}, ${round(cp2x)} ${round(cp2y)}, ${round(next.x)} ${round(next.y)}`,
+ )
+ }
+
+ return segments.join(' ')
+}
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100
+}
+
+export const GRAPH_HEIGHT = VIEWBOX_HEIGHT
+
+export const GRAPH_VIEWBOX = `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`
diff --git a/src/main.ts b/src/main.ts
index e31513f..7ab7b11 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,8 @@
import './css/style.css'
import {
+ formatLocalizedDate,
+ formatTime,
getMetadata,
getTimeZone,
getLocale,
@@ -8,9 +10,12 @@ import {
getSetting,
getCityInfo,
resolveMeasurementUnit,
+ setupBrandingLogo,
setupErrorHandling,
+ setupTheme,
type MeasurementUnit,
} from '@screenly/edge-apps'
+// Side-effect import: registers
as a custom element
import '@screenly/edge-apps/components'
import {
getCurrentWeather,
@@ -19,19 +24,22 @@ import {
} from './weather'
import type { ForecastItem } from './weather'
import { updateBackground } from './background'
-import sunIcon from '../static/images/sun.svg'
+import { getPlaceSizeClass } from './place'
+import { buildGraphShape } from './graph'
+import { drawTrend } from './trend'
+
+const TICK_MS = 30 * 1000
type ErrorReporter = (error: unknown) => void
function showError(error: unknown): void {
const message = error instanceof Error ? error.message : String(error)
- const contentEl = document.querySelector('main.content')
- const errorScreen = document.getElementById('error-screen')
- const errorMessage = document.getElementById('error-message')
+ const errorMessage = document.querySelector('[data-error-message]')
- if (contentEl) contentEl.style.display = 'none'
- if (errorScreen) errorScreen.style.display = 'flex'
- if (errorMessage) errorMessage.textContent = message
+ document.getElementById('weather')?.classList.add('has-error')
+ if (errorMessage) {
+ errorMessage.textContent = message
+ }
}
function createErrorReporter(displayErrors: boolean): ErrorReporter {
@@ -51,7 +59,14 @@ let tempHighEl: Element | null
let tempLowEl: Element | null
let forecastItemsEl: Element | null
let forecastCardEl: Element | null
-let forecastHeaderIconEl: HTMLImageElement | null
+let trendEl: Element | null
+let trendChartEl: SVGSVGElement | null
+let trendHighEl: Element | null
+let trendLowEl: Element | null
+
+let dateEl: Element | null
+let clockEl: Element | null
+let logoEl: HTMLImageElement | null
// State
let timezone: string = 'UTC'
@@ -73,16 +88,8 @@ function getCoordinates(): [number, number] {
return getMetadata().coordinates
}
-function showForecastCard() {
- if (forecastCardEl) {
- ;(forecastCardEl as HTMLElement).style.display = ''
- }
-}
-
-function hideForecastCard() {
- if (forecastCardEl) {
- ;(forecastCardEl as HTMLElement).style.display = 'none'
- }
+function setForecastVisible(visible: boolean) {
+ forecastCardEl?.classList.toggle('is-hidden', !visible)
}
function renderForecastItems(items: ForecastItem[]) {
@@ -93,8 +100,7 @@ function renderForecastItems(items: ForecastItem[]) {
) as HTMLTemplateElement
if (!template) return
- // Clear existing items
- forecastItemsEl.innerHTML = ''
+ forecastItemsEl.replaceChildren()
for (const item of items) {
const clone = template.content.cloneNode(true) as DocumentFragment
@@ -124,6 +130,59 @@ function renderForecastItems(items: ForecastItem[]) {
}
}
+function renderHeader(): void {
+ const now = new Date()
+
+ if (dateEl) {
+ dateEl.textContent = formatLocalizedDate(now, locale, {
+ timeZone: timezone,
+ weekday: 'long',
+ })
+ }
+
+ if (!clockEl) return
+
+ const time = formatTime(now, locale, timezone)
+ const period = time.dayPeriod ? ` ${time.dayPeriod}` : ''
+ clockEl.textContent = `${time.hour}:${time.minute}${period}`
+}
+
+async function setupLogo(): Promise {
+ if (!logoEl) return
+
+ logoEl.onerror = () => {
+ console.error('Failed to load branding logo')
+ logoEl?.classList.add('is-hidden')
+ }
+ logoEl.src = await setupBrandingLogo()
+}
+
+function renderTrend(items: ForecastItem[]): void {
+ const shape = buildGraphShape(
+ items.map((item) => ({
+ temperature: item.temperature,
+ timestamp: item.timestamp,
+ })),
+ )
+
+ trendEl?.classList.toggle('is-hidden', !shape)
+ if (!shape || !trendChartEl) return
+
+ drawTrend(trendChartEl, shape, formatHour)
+
+ if (trendHighEl) {
+ trendHighEl.textContent = `${Math.round(shape.high.temperature)}°`
+ }
+ if (trendLowEl) {
+ trendLowEl.textContent = `${Math.round(shape.low.temperature)}°`
+ }
+}
+
+function formatHour(timestamp: number): string {
+ const time = formatTime(new Date(timestamp), locale, timezone)
+ return time.dayPeriod ? `${time.hour} ${time.dayPeriod}` : `${time.hour}:00`
+}
+
async function updateWeatherDisplay(
latitude: number,
longitude: number,
@@ -133,7 +192,7 @@ async function updateWeatherDisplay(
const weather = await getCurrentWeather(latitude, longitude, tz, unit)
if (!weather) {
- hideForecastCard()
+ setForecastVisible(false)
return
}
@@ -164,17 +223,20 @@ async function updateWeatherDisplay(
weather,
)
+ setForecastVisible(forecast.length > 0)
+ renderTrend(forecast)
if (forecast.length > 0) {
- showForecastCard()
renderForecastItems(forecast)
- } else {
- hideForecastCard()
}
}
document.addEventListener('DOMContentLoaded', async () => {
setupErrorHandling()
+ setupTheme()
+ dateEl = document.querySelector('[data-date]')
+ clockEl = document.querySelector('[data-clock]')
+ logoEl = document.querySelector('[data-logo]')
locationEl = document.querySelector('[data-location]')
temperatureEl = document.querySelector('[data-temperature]')
weatherDescriptionEl = document.querySelector('[data-weather-description]')
@@ -182,12 +244,10 @@ document.addEventListener('DOMContentLoaded', async () => {
tempLowEl = document.querySelector('[data-temp-low]')
forecastItemsEl = document.querySelector('[data-forecast-items]')
forecastCardEl = document.querySelector('[data-forecast-card]')
- forecastHeaderIconEl = document.querySelector('[data-forecast-header-icon]')
-
- if (forecastHeaderIconEl) {
- forecastHeaderIconEl.src = sunIcon
- }
-
+ trendEl = document.querySelector('[data-trend]')
+ trendChartEl = document.querySelector('[data-trend-chart]')
+ trendHighEl = document.querySelector('[data-trend-high]')
+ trendLowEl = document.querySelector('[data-trend-low]')
const displayErrors = getSetting('display_errors') === 'true'
const reportError = createErrorReporter(displayErrors)
@@ -202,9 +262,13 @@ document.addEventListener('DOMContentLoaded', async () => {
timezone = await getTimeZone()
locale = await getLocale()
+ renderHeader()
+ await setupLogo()
+
const { cityName, countryCode } = await getCityInfo(latitude, longitude)
if (locationEl) {
locationEl.textContent = cityName
+ locationEl.className = `now-place ${getPlaceSizeClass(cityName)}`.trim()
}
// Get measurement unit from settings, or auto-detect based on location
@@ -212,6 +276,8 @@ document.addEventListener('DOMContentLoaded', async () => {
await updateWeatherDisplay(latitude, longitude, timezone, measurementUnit)
+ setInterval(renderHeader, TICK_MS)
+
// Refresh weather every 15 minutes
setInterval(
() => {
diff --git a/src/place.test.ts b/src/place.test.ts
new file mode 100644
index 0000000..e4fbb74
--- /dev/null
+++ b/src/place.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from 'bun:test'
+import { getPlaceSizeClass } from './place'
+
+describe('getPlaceSizeClass', () => {
+ test('leaves an ordinary city at the full size', () => {
+ expect(getPlaceSizeClass('Berlin, DE')).toBe('')
+ expect(getPlaceSizeClass('a'.repeat(18))).toBe('')
+ })
+
+ test('steps down for a long name', () => {
+ expect(getPlaceSizeClass('Mountain View, US')).toBe('')
+ expect(getPlaceSizeClass('a'.repeat(19))).toBe('is-long')
+ expect(getPlaceSizeClass('a'.repeat(28))).toBe('is-long')
+ })
+
+ test('steps down again for a very long name', () => {
+ expect(getPlaceSizeClass('a'.repeat(29))).toBe('is-very-long')
+ expect(getPlaceSizeClass('Llanfairpwllgwyngyllgogerychwyrndrobwll')).toBe(
+ 'is-very-long',
+ )
+ })
+
+ test('ignores surrounding whitespace', () => {
+ expect(getPlaceSizeClass(' Berlin, DE ')).toBe('')
+ })
+})
diff --git a/src/place.ts b/src/place.ts
new file mode 100644
index 0000000..d22b59a
--- /dev/null
+++ b/src/place.ts
@@ -0,0 +1,15 @@
+/**
+ * City names vary wildly in length, so the place line steps down a size rather
+ * than wrapping or truncating. Thresholds are character counts, which track
+ * rendered width closely enough on the fixed reference canvas.
+ */
+const LONG_PLACE_CHARS = 18
+const VERY_LONG_PLACE_CHARS = 28
+
+export function getPlaceSizeClass(place: string): string {
+ const length = place.trim().length
+ if (length > VERY_LONG_PLACE_CHARS) return 'is-very-long'
+ if (length > LONG_PLACE_CHARS) return 'is-long'
+
+ return ''
+}
diff --git a/src/trend.ts b/src/trend.ts
new file mode 100644
index 0000000..8e320b3
--- /dev/null
+++ b/src/trend.ts
@@ -0,0 +1,114 @@
+import { GRAPH_HEIGHT, type GraphShape } from './graph'
+
+const SVG_NS = 'http://www.w3.org/2000/svg'
+
+function svgEl(
+ name: K,
+ attributes: Record,
+): SVGElementTagNameMap[K] {
+ const element = document.createElementNS(SVG_NS, name)
+ Object.entries(attributes).forEach(([key, value]) =>
+ element.setAttribute(key, value),
+ )
+ return element
+}
+
+function buildFill(): SVGDefsElement {
+ const gradient = svgEl('linearGradient', {
+ id: 'trend-fill',
+ x1: '0',
+ y1: '0',
+ x2: '0',
+ y2: '1',
+ })
+ gradient.append(
+ svgEl('stop', {
+ offset: '0',
+ 'stop-color': 'currentColor',
+ 'stop-opacity': '0.2',
+ }),
+ svgEl('stop', {
+ offset: '1',
+ 'stop-color': 'currentColor',
+ 'stop-opacity': '0',
+ }),
+ )
+
+ const defs = svgEl('defs', {})
+ defs.append(gradient)
+ return defs
+}
+
+function buildTicks(
+ shape: GraphShape,
+ formatHour: (timestamp: number) => string,
+): SVGTextElement[] {
+ return shape.ticks.map((tick, index) => {
+ const anchor =
+ index === 0
+ ? 'start'
+ : index === shape.ticks.length - 1
+ ? 'end'
+ : 'middle'
+ const label = svgEl('text', {
+ x: String(tick.x),
+ y: String(GRAPH_HEIGHT - 0.5),
+ class: 'trend-tick',
+ 'text-anchor': anchor,
+ })
+ label.textContent = formatHour(tick.timestamp)
+ return label
+ })
+}
+
+/**
+ * Paints the temperature curve: the filled area, the line itself, a dotted rule
+ * at the present moment, dots on the day's extremes and the clock marks.
+ */
+export function drawTrend(
+ chart: SVGSVGElement,
+ shape: GraphShape,
+ formatHour: (timestamp: number) => string,
+): void {
+ const area = svgEl('path', { d: shape.area, fill: 'url(#trend-fill)' })
+ const line = svgEl('path', {
+ d: shape.line,
+ fill: 'none',
+ stroke: 'currentColor',
+ 'stroke-width': '0.9',
+ 'stroke-linecap': 'round',
+ 'stroke-linejoin': 'round',
+ })
+
+ const now = shape.points[0]
+ const nowRule = svgEl('line', {
+ x1: String(now.x),
+ y1: '2',
+ x2: String(now.x),
+ y2: String(GRAPH_HEIGHT - 5),
+ class: 'trend-now',
+ })
+
+ const dots = (
+ [
+ [shape.high, 'high'],
+ [shape.low, 'low'],
+ ] as const
+ ).map(([point, name]) =>
+ svgEl('circle', {
+ cx: String(point.x),
+ cy: String(point.y),
+ r: '1.2',
+ class: `trend-dot trend-dot-${name}`,
+ }),
+ )
+
+ chart.replaceChildren(
+ buildFill(),
+ area,
+ line,
+ nowRule,
+ ...dots,
+ ...buildTicks(shape, formatHour),
+ )
+}
diff --git a/src/weather.ts b/src/weather.ts
index 85ba2b1..7149272 100644
--- a/src/weather.ts
+++ b/src/weather.ts
@@ -26,6 +26,7 @@ export interface ForecastItem {
iconAlt: string
timeLabel: string
timePeriod?: string
+ timestamp: number
displayTemp: string
}
@@ -108,6 +109,7 @@ export async function getHourlyForecast(
iconAlt: description,
timeLabel,
timePeriod,
+ timestamp: item.dt * 1000,
displayTemp: `${temperature}°`,
}
},
@@ -121,6 +123,7 @@ export async function getHourlyForecast(
iconAlt: currentWeather.iconAlt,
timeLabel: 'NOW',
timePeriod: undefined,
+ timestamp: Date.now(),
displayTemp: `${currentWeather.temperature}°`,
}
return [nowItem, ...forecastItems]