diff --git a/.claude/skills/halo-app-design/SKILL.md b/.claude/skills/halo-app-design/SKILL.md index 9c017a8f..e664c96c 100644 --- a/.claude/skills/halo-app-design/SKILL.md +++ b/.claude/skills/halo-app-design/SKILL.md @@ -52,4 +52,4 @@ talking. Optional warm/cool weather hues alongside the accent when data demands. | Device | wall-mounted touch panel — no hover, big targets/numbers | | Density | data-dense (clock, energy charts, Hue cards) | | Motion | drawer unfold (150ms), colon pulse each second, breathing lit bulbs, energy-flow stroke scroll, eased counters | -| Icons | `lucide-react`, inline per-icon import, `currentColor` (migrated off Material Icons) | +| Icons | `lucide-react`, inline per-icon import, `currentColor` — **migration in progress**: 5 files done, ~12 still render Material Icons through `components/Icon.tsx` (and `index.html` still loads the two Material Icons stylesheets). New code uses lucide; convert the Material call sites you touch. | diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..453184ff --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +# EditorConfig — https://editorconfig.org +# The cross-language source of truth for indentation, line width, and newline +# hygiene. Prettier reads it (indent_style, indent_size, max_line_length, +# end_of_line); editors read it natively; rustfmt ignores it but already matches. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 +# Prettier's default is 80. Line width is the one knob most repos override; set +# it here once and every editor + Prettier follows. +max_line_length = 100 + +# Rust (rustfmt) indents with 4 spaces. +[*.rs] +indent_size = 4 + +[Makefile] +indent_style = tab + +# Markdown keeps trailing whitespace (two trailing spaces = a hard line break). +[*.md] +trim_trailing_whitespace = false + +# YAML inherits spaces from [*] above — never set it to tabs (illegal in YAML). diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 2840bbc4..8238b8b1 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -12,6 +12,6 @@ fi if echo "$STAGED" | grep -q "^backend/"; then echo "Running backend lint..." - (cd "$ROOT/backend" && cargo fmt --check) - (cd "$ROOT/backend" && cargo clippy -- -D warnings) + (cd "$ROOT/backend" && cargo fmt --all -- --check) + (cd "$ROOT/backend" && cargo clippy --workspace --all-targets -- -D warnings) fi diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 1addf2b0..9ab2fb4d 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -38,8 +38,15 @@ updates: - "minor" - "patch" - # github-actions only honours `default-days` — the semver-* keys silently - # break parsing for this ecosystem, so they're omitted. + # docker + github-actions only honour `default-days` — the semver-* keys + # silently break parsing for these ecosystems, so they're omitted. + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a4528647..469292d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,14 +17,21 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: "24" - - run: corepack enable - - run: yarn install --immutable - - run: yarn playwright install chromium - - run: yarn format - - run: yarn lint - - run: yarn test - - run: yarn build + # The pinned major lives in one place; a hardcoded literal here would + # silently drift from the file and the Dockerfile's node stage. + node-version-file: frontend/.node-version + # Yarn is vendored (.yarn/releases/*.cjs) and invoked via node — no + # corepack (node 25+ dropped the bundle). + - name: Frontend checks + run: | + yarn() { node .yarn/releases/yarn-*.cjs "$@"; } + yarn install --immutable + yarn playwright install chromium + yarn format + yarn lint + yarn typecheck + yarn test + yarn build backend: name: "Backend" @@ -40,7 +47,7 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: backend - - run: cargo fmt --check - - run: cargo clippy -- -D warnings - - run: cargo test - - run: cargo build + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace + - run: cargo build --workspace --release diff --git a/.github/workflows/cve-scan.yaml b/.github/workflows/cve-scan.yaml new file mode 100644 index 00000000..62d8af27 --- /dev/null +++ b/.github/workflows/cve-scan.yaml @@ -0,0 +1,47 @@ +# Weekly trivy scan of the published image → SARIF to the Security tab. +# Report-only: never fails the run. +name: CVE scan (GHCR image) + +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + workflow_dispatch: + +env: + REGISTRY: ghcr.io + +jobs: + scan: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + security-events: write + + steps: + - name: Log in to the Container registry + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Trivy scan + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 + with: + image-ref: ${{ env.REGISTRY }}/${{ github.repository }}:main + format: sarif + output: trivy.sarif + severity: CRITICAL,HIGH + exit-code: "0" + # Drop CVEs with no upstream fix — the scratch Rust image carries + # almost no userland; self-heals once a fix ships (the CVE resurfaces). + ignore-unfixed: true + env: + TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db + + - name: Upload SARIF to Security tab + uses: github/codeql-action/upload-sarif@c54b30b7df092240050e69945842bc67aee0f0f4 # v4.37.3 + if: always() + with: + sarif_file: trivy.sarif diff --git a/CLAUDE.md b/CLAUDE.md index af62835a..2f6e0fad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,9 @@ siblings [chat](../chat), [scribe](../scribe), [ocular](../ocular), ``` backend/ actix-web 4 — Hue/FMI/SolisCloud clients, SQLite (WAL), polling loops -frontend/ Vite + React 19 + Emotion + TanStack Router (file-based) +frontend/ Vite + React 19 + Emotion; views are switched by App.tsx state, + not a router +justfile task runner — `just dev` runs backend (bacon) + frontend together scripts/ refresh-pv-forecast.sh (cron-driven PV upsert) + misc documentation/ design notes .claude/skills/ halo-app-design skill (visual language, brand) @@ -32,11 +34,25 @@ Per-area instructions in `backend/CLAUDE.md` and `frontend/CLAUDE.md`. disabled). - **Hue events.** `GET /api/hue/events` is an SSE stream off a broadcast channel — live bridge-state pushes, not polling. +- **Installable + touch-first.** The SPA ships a web manifest and a full icon set + (`frontend/scripts/gen-icons.sh` regenerates the PNGs from `favicon.svg` — + rerun and commit after editing it). The layout assumes `viewport-fit=cover`: + `#root` is the scroll container and pads off `env(safe-area-inset-*)`, and + height is `100vh` when installed (`html.standalone`, set in `main.tsx`) vs + `100dvh` in a tab — iOS resolves `100dvh` short at cold start in a PWA. +- **Stale-tab guard.** Frontend and backend ship in one image, so a deploy + replaces both. `vite.config.ts` emits `version.json` holding the baked + `VITE_HALO_IMAGE_TAG`; `useDeployReload` polls it every 60 s and reloads when + it differs. Keyed off the image tag, not a semver — a `:main` rebuild never + bumps a semver. ## Working on this repo -- Backend `:3000` (`PORT`); needs a `.env` with at least `HUE_BRIDGE_ADDRESS` / - `HUE_BRIDGE_USER` (pair via `POST /api/hue/pair`). Run: `cargo run -p halo-backend`. +- `just dev` runs both halves together (backend headless under bacon + Vite) and + tears them down on one Ctrl-C. `just --list` for the rest. +- Backend `:3000` (`PORT`); needs `backend/.env` with at least + `HUE_BRIDGE_ADDRESS` / `HUE_BRIDGE_USER` (pair via `POST /api/hue/pair`). + Alone: `cd backend && bacon` (or `cargo run`). - Frontend dev `:5173` (`yarn dev`); Vite proxies `/api`, `/hue/events`, `/status` to `:3000`. - Key env: `SOLIS_KEY_ID/SECRET/STATION_ID`, `TOMORROW_IO_API_KEY` (fallback @@ -47,7 +63,9 @@ Per-area instructions in `backend/CLAUDE.md` and `frontend/CLAUDE.md`. - Authentication / per-user data - Shared/team dashboards, admin UI -- Mobile/native app (web only, touch-friendly) +- A native app — but the web UI is touch-first and installable (see + "Installable + touch-first"); phones are a supported target, not an + afterthought - Non-Hue smart-home integrations If a feature crosses into those areas, raise it before implementing. diff --git a/Dockerfile b/Dockerfile index 46c7f17c..a255397e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,11 @@ WORKDIR /app COPY frontend/package.json frontend/yarn.lock frontend/.yarnrc.yml* ./ # .yarnrc.yml pins yarnPath to a committed release; copy it in before install COPY frontend/.yarn/ ./.yarn/ -RUN corepack enable && yarn install --immutable --network-timeout 1000000 +# Invoke the vendored yarn through node — no corepack (node 25+ dropped the +# bundle), so this stage stays independent of the node version. +RUN node .yarn/releases/yarn-*.cjs install --immutable --network-timeout 1000000 COPY frontend/ . -RUN yarn build +RUN node .yarn/releases/yarn-*.cjs build # --- Stage 2: Build backend dependencies (native, cross-compiled) --- FROM --platform=$BUILDPLATFORM rust:1-alpine AS backend-deps diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..a35c6b48 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,76 @@ +# Security + +halo is a self-hosted, single-household wall dashboard. It runs on a home Pi and +is reachable only from the LAN — it is **not** a public or multi-tenant service. +The threat model is: everything on the LAN is trusted, so the controls that +matter are keeping the upstream credentials (Hue bridge user, SolisCloud API +key/secret, weather API key) out of the image and out of the browser, and never +exposing the app beyond the LAN. + +## Trust boundaries + +- **Network is the boundary — there is no app auth.** Every route is public by + design: no login, no session, no forward-auth, no `DEV_AUTH`. Anyone who can + reach the port can read every reading and toggle every light. This is + deliberate for a wall panel, and the only thing keeping it safe is reachability: + halo sits behind Traefik on a subdomain whose DNS record — public though it is + (`public_dns: True` in `../raspi`) — resolves to the **LAN IP**, with no port + forward. Its route is deliberately **not** in Traefik's `_gated_hosts`, so + there is no oauth2-proxy in front of it either. Making halo reachable from the + internet therefore requires adding auth first; nothing in the app would stop + it otherwise. + +- **No per-user isolation, by design.** There is no user model. `user_settings` + is a single global row (`id=1`) shared by every viewer; anything written there + is visible to everyone on the LAN. + +- **CORS is `Cors::permissive()`.** Acceptable only because the app is LAN-only + and holds nothing a cross-origin read could escalate — there are no cookies, + no session, and no auth header to steal, so a hostile page can at most do what + any LAN client can already do directly. Tighten this to an origin allowlist if + halo ever gains auth or leaves the LAN. + +- **No CSP / security response headers are set.** Same rationale; the SPA is + served from `STATIC_DIR` with no user-supplied HTML anywhere in the response + path. Worth adding if the trust model changes. + +- **Outbound fetches.** The backend calls fixed, config-pinned upstreams over + verified TLS: the Hue bridge (LAN), FMI (WFS/WMS/download), tomorrow.io, + SolisCloud, and the spot-price API. Base URLs come from env, not from request + input, so no request can steer an outbound fetch at an arbitrary host. halo is + deliberately **not** in `../raspi`'s `network_restrict.RESTRICTED` — unlike the + LAN-only siblings it needs egress for those upstreams. + +- **`POST /api/pv/forecast` is an unauthenticated write.** The cron-driven + `scripts/refresh-pv-forecast.sh` upserts ~66 rows through it. Any LAN client + can also write those rows; the blast radius is a wrong PV forecast until the + next 3-hourly refresh overwrites it. + +## Secrets + +All secrets are injected at runtime via env, never baked into the image or +committed: `HUE_BRIDGE_USER`, `SOLIS_KEY_ID`, `SOLIS_KEY_SECRET`, +`SOLIS_STATION_ID`, `TOMORROW_IO_API_KEY`. On the Pi they come from +`/etc/secrets/halo.env` (written by `../raspi`'s `tasks/secrets.py`). `.env`, +`.env.pv`, and `*.db*` are gitignored. The container runs as UID 1000 on +`scratch`. + +## Accepted risks + +- **Unauthenticated light control and history writes from the LAN.** Accepted: + the panel exists to be tapped without a login, and the LAN is the trust + boundary. Revisit if halo is ever exposed beyond the LAN, or if guest/IoT + devices share the same segment. +- **Permissive CORS + no CSP.** Accepted for the same reason (see above). + Revisit alongside any auth work. + +## Out of scope + +Authentication, per-user data, shared/team dashboards, and rate-limiting against +a hostile LAN client are deliberately not built — see the root `CLAUDE.md` +"Out of scope". Don't add them piecemeal; they change the trust model above. + +## Reporting + +This is a personal project. Flag an issue privately to the maintainer rather +than opening a public issue with exploit detail. diff --git a/backend/bacon.toml b/backend/bacon.toml index f4bac8a1..aeb0c951 100644 --- a/backend/bacon.toml +++ b/backend/bacon.toml @@ -1,9 +1,19 @@ +# Run `bacon` (TUI) for local work, or `bacon --headless -j run` (what the root +# `just dev` recipe invokes) to stream logs into the composed dev output. Both +# auto-reload on source + .env changes; the binary loads backend/.env via +# dotenvy, and watching .env restarts when that changes too. default_job = "run" +[jobs.check] +command = ["cargo", "check", "--color", "always"] +need_stdout = false + [jobs.run] -command = ["cargo", "run"] +command = ["cargo", "run", "--color", "always"] watch = ["src", ".env"] default_watch = true need_stdout = true +allow_warnings = true +background = true on_change_strategy = "kill_then_restart" apply_gitignore = false diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index af8c0f57..cace053c 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -10,4 +10,15 @@ Individual scripts: - `yarn format` / `yarn format:fix` - `yarn typecheck` -Use yarn (not npm). +Use yarn (not npm). It's vendored at `.yarn/releases/` and pinned by `yarnPath` +— CI and Docker call it as `node .yarn/releases/yarn-*.cjs`, no corepack. + +## Icons + +- **UI icons:** `lucide-react`, imported per-icon. `components/Icon.tsx` is the + legacy Material Icons wrapper — don't add call sites, convert the ones you + touch. +- **App icons:** `scripts/gen-icons.sh` regenerates `public/icon-*.png` + + `apple-touch-icon.png` from `public/favicon.svg` (needs librsvg + ImageMagick). + Rerun and commit the PNGs after editing the SVG — the build ships no + rasterizer. diff --git a/frontend/index.html b/frontend/index.html index 21f536de..d476b2a3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,13 +3,24 @@ - + + + + + + + + + diff --git a/frontend/package.json b/frontend/package.json index 323ae4e8..f4e8eb69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "vite_react_template", + "name": "halo-frontend", "private": true, "version": "0.0.0", "type": "module", diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png index 12c66638..c8dce888 100644 Binary files a/frontend/public/apple-touch-icon.png and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 00000000..8c4b1ae0 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 00000000..e8b50af3 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon-maskable-512.png b/frontend/public/icon-maskable-512.png new file mode 100644 index 00000000..ceb2601b Binary files /dev/null and b/frontend/public/icon-maskable-512.png differ diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 00000000..cce5921e --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "halo", + "short_name": "halo", + "description": "koti — valot, lämpötilat, sää ja aurinkoenergia", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f0f0f0", + "theme_color": "#f0f0f0", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { + "src": "/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/frontend/scripts/gen-icons.sh b/frontend/scripts/gen-icons.sh new file mode 100755 index 00000000..8040f1c9 --- /dev/null +++ b/frontend/scripts/gen-icons.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Regenerate the PWA / home-screen icon PNGs from public/favicon.svg. +# +# Icons are FULL-BLEED SQUARES — iOS and Android round home-screen / PWA icons +# themselves, so we must NOT bake rounded corners into the source. favicon.svg is +# a plain square (no rx); the OS applies its own mask. The maskable icon is +# DERIVED here (glyph shrunk into the ~80% safe zone) — no separate source SVG. +# +# Requires librsvg (`brew install librsvg`) + ImageMagick. Rerun after editing +# favicon.svg and commit the PNGs (the build ships no rasterizer). +set -euo pipefail +cd "$(dirname "$0")/../public" +# halo's ring strokes #525252 — it reads on the light body, not on the dark one, +# so the icon bg is --halo-body light. -b fills the corners opaque so none round. +BG="#f0f0f0" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# any-purpose icons + browser favicon fallback (full-bleed square) +rsvg-convert -w 192 -h 192 -b "$BG" favicon.svg -o icon-192.png +rsvg-convert -w 512 -h 512 -b "$BG" favicon.svg -o icon-512.png + +# apple-touch-icon: opaque, no alpha (Apple guidance), square +rsvg-convert -w 180 -h 180 -b "$BG" favicon.svg -o "$tmp/ati.png" +magick "$tmp/ati.png" -background "$BG" -flatten -alpha off -type TrueColor PNG24:apple-touch-icon.png + +# maskable: glyph shrunk into the ~80% safe zone on a full-bleed bg so Android's +# adaptive mask can't clip it +rsvg-convert -w 410 -h 410 -b "$BG" favicon.svg -o "$tmp/glyph.png" +magick -size 512x512 "xc:$BG" "$tmp/glyph.png" -gravity center -composite \ + -alpha off -type TrueColor PNG24:icon-maskable-512.png + +echo "icons regenerated (full-bleed squares) in $(pwd)" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9e7e75c6..ec0cfcd1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,31 +67,20 @@ const applyEvent = (data: Response, event: HueLiveEvent): Response => { return { ...data, sensors: data.sensors.map((s) => - s.deviceId === event.deviceId - ? { ...s, motionEnabled: event.enabled } - : s, + s.deviceId === event.deviceId ? { ...s, motionEnabled: event.enabled } : s, ), }; case "connectivity": return { ...data, sensors: data.sensors.map((s) => - s.deviceId === event.deviceId - ? { ...s, connected: event.connected } - : s, + s.deviceId === event.deviceId ? { ...s, connected: event.connected } : s, ), }; } }; -type View = - | "temperature" - | "energy" - | "lights" - | "motion" - | "radar" - | "history" - | "settings"; +type View = "temperature" | "energy" | "lights" | "motion" | "radar" | "history" | "settings"; const VIEWS: { id: View; icon: string }[] = [ { id: "temperature", icon: "thermostat" }, @@ -163,9 +152,7 @@ const App = () => { }, [sensors]); const outsideTemp = Math.min( - ...outside - .map((s) => s.temperature) - .filter((t): t is number => t !== undefined), + ...outside.map((s) => s.temperature).filter((t): t is number => t !== undefined), ); const theme = useTheme(); @@ -248,9 +235,7 @@ const App = () => { cursor: "pointer", backgroundColor: "transparent", border: "none", - color: active - ? theme.colors.activity.on - : theme.colors.text.muted, + color: active ? theme.colors.activity.on : theme.colors.text.muted, padding: "6px", transition: "color 0.15s ease", }} @@ -360,9 +345,7 @@ const App = () => { )} - {view === "history" && ( - - )} + {view === "history" && } {view === "settings" && (
{ const isDarkTheme = useMediaQuery("(prefers-color-scheme: dark)"); const theme = isDarkTheme ? darkTheme : lightTheme; + useDeployReload(); + return ( in main.tsx. + "html, body": { + height: ["100svh", "100dvh"], + }, + "html.standalone, html.standalone body": { + height: "100vh", + }, html: { fontFamily: theme.fonts.body, }, body: { padding: 0, margin: 0, + // Body owns the viewport; #root is the scroll container. Keeps a + // phantom page scrollbar from appearing behind the fullscreen view. + overflow: "hidden", display: "flex", flexDirection: "column", - justifyContent: "center", alignItems: "center", backgroundColor: theme.colors.body, color: theme.colors.text.main, @@ -34,11 +51,28 @@ const Root = () => { "*": { boxSizing: "border-box", }, + // Disables iOS double-tap-to-zoom (it misfires on quick repeated taps + // — brightness steppers, view tabs) while keeping pinch and scroll. + 'button, [role="button"], summary, label': { + touchAction: "manipulation", + }, "#root": { width: "100%", + flex: 1, + minHeight: 0, + overflowY: "auto", + // The top chrome (wordmark, fullscreen button) is absolute against + // this box, so it needs to be the positioned ancestor. + position: "relative", display: "flex", flexDirection: "column", alignItems: "center", + // black-translucent overlays the status bar; landscape notches eat + // the sides. env() is 0 unless the viewport is viewport-fit=cover. + paddingTop: "env(safe-area-inset-top)", + paddingBottom: "env(safe-area-inset-bottom)", + paddingLeft: "env(safe-area-inset-left)", + paddingRight: "env(safe-area-inset-right)", }, }} /> diff --git a/frontend/src/__tests__/App.browser.test.tsx b/frontend/src/__tests__/App.browser.test.tsx index ff6d164a..b4c7e133 100644 --- a/frontend/src/__tests__/App.browser.test.tsx +++ b/frontend/src/__tests__/App.browser.test.tsx @@ -6,8 +6,6 @@ describe("App", () => { test("renders without crashing", async () => { const screen = await render(); - await expect - .element(screen.getByRole("button", { name: "fullscreen" })) - .toBeVisible(); + await expect.element(screen.getByRole("button", { name: "fullscreen" })).toBeVisible(); }); }); diff --git a/frontend/src/components/Box.tsx b/frontend/src/components/Box.tsx index 44007aaf..36f0fb75 100644 --- a/frontend/src/components/Box.tsx +++ b/frontend/src/components/Box.tsx @@ -56,11 +56,7 @@ const Box: React.FC = ({ }} onClick={expandable ? () => setCollapsed(!collapsed) : undefined} > - {background && ( -
- {background} -
- )} + {background &&
{background}
} ; -const BoxHeader: React.FC = ({ - children, - hairline, - transparent, - borderColor, -}) => { +const BoxHeader: React.FC = ({ children, hairline, transparent, borderColor }) => { const theme = useTheme(); return ( @@ -106,13 +97,9 @@ const BoxHeader: React.FC = ({ overflow: "hidden", display: "flex", flexDirection: "column", - backgroundColor: transparent - ? "transparent" - : theme.colors.background.main, + backgroundColor: transparent ? "transparent" : theme.colors.background.main, padding: "1.5em", - borderBottom: hairline - ? `1px ${borderColor ?? theme.colors.border} solid` - : "none", + borderBottom: hairline ? `1px ${borderColor ?? theme.colors.border} solid` : "none", }} > {children} @@ -164,11 +151,7 @@ type BoxFooterProps = { borderColor?: string; } & React.HTMLAttributes; -const BoxFooter: React.FC = ({ - collapsed, - transparent, - borderColor, -}) => { +const BoxFooter: React.FC = ({ collapsed, transparent, borderColor }) => { const theme = useTheme(); return ( @@ -179,12 +162,8 @@ const BoxFooter: React.FC = ({ display: "flex", justifyContent: "center", height: "25px", - backgroundColor: transparent - ? "transparent" - : theme.colors.background.main, - borderTop: collapsed - ? "none" - : `1px ${borderColor ?? theme.colors.border} solid`, + backgroundColor: transparent ? "transparent" : theme.colors.background.main, + borderTop: collapsed ? "none" : `1px ${borderColor ?? theme.colors.border} solid`, color: theme.colors.text.light, }} > diff --git a/frontend/src/components/Energy/DailyHistory.tsx b/frontend/src/components/Energy/DailyHistory.tsx index 8acb5cf7..03eb3fa0 100644 --- a/frontend/src/components/Energy/DailyHistory.tsx +++ b/frontend/src/components/Energy/DailyHistory.tsx @@ -152,8 +152,7 @@ const DailyHistory: React.FC<{ className?: string }> = ({ className }) => { }, }, tooltip: { - itemSort: (a, b) => - Math.abs(b.parsed.y ?? 0) - Math.abs(a.parsed.y ?? 0), + itemSort: (a, b) => Math.abs(b.parsed.y ?? 0) - Math.abs(a.parsed.y ?? 0), callbacks: { label: (ctx) => { const v = ctx.parsed.y; @@ -250,9 +249,7 @@ const DailyHistory: React.FC<{ className?: string }> = ({ className }) => { backgroundColor: active ? theme.colors.activity.onSoft : theme.colors.background.main, - color: active - ? theme.colors.activity.on - : theme.colors.text.main, + color: active ? theme.colors.activity.on : theme.colors.text.main, ...theme.typography.body2, transition: "all 0.15s", }} diff --git a/frontend/src/components/Energy/Flow.tsx b/frontend/src/components/Energy/Flow.tsx index 00d64b90..161394d2 100644 --- a/frontend/src/components/Energy/Flow.tsx +++ b/frontend/src/components/Energy/Flow.tsx @@ -138,8 +138,7 @@ const Flow: React.FC<{ className?: string }> = ({ className }) => { const nodeSize = 42; const nodeTitleFontSize = 24; const socCircumference = 2 * Math.PI * nodeSize; - const socArc = - (Math.max(0, Math.min(100, soc ?? 0)) / 100) * socCircumference; + const socArc = (Math.max(0, Math.min(100, soc ?? 0)) / 100) * socCircumference; return (
= ({ className }) => { stroke={theme.colors.activity.on} strokeWidth={strokeWidth} /> - + = ({ className }) => { stroke={theme.colors.grid} strokeWidth={strokeWidth} /> - + = ({ className }) => { stroke={theme.colors.home} strokeWidth={strokeWidth} /> - + = ({ className }) => { // Header totals stay lifetime (from the API); the chart shows a recent window // so it stays legible on a narrow phone as months accumulate. const allPoints = data?.points ?? []; - const points = - range.months === Infinity ? allPoints : allPoints.slice(-range.months); - const labels = points.map((p) => - format(new Date(p.bucketStart), "LLL yy", { locale: fi }), - ); + const points = range.months === Infinity ? allPoints : allPoints.slice(-range.months); + const labels = points.map((p) => format(new Date(p.bucketStart), "LLL yy", { locale: fi })); const tickColor = theme.colors.text.muted; // One bar = total reserve income, split at the fee threshold: the grey base is @@ -176,18 +173,13 @@ const Reserve: React.FC<{ className?: string }> = ({ className }) => { flexWrap: "wrap", }} > -
- reservimarkkina -
+
reservimarkkina
{data && ( 0 - ? theme.colors.connected - : theme.colors.text.main, + color: data.totalPayout > 0 ? theme.colors.connected : theme.colors.text.main, }} > {eur(data.totalPayout)} @@ -217,9 +209,7 @@ const Reserve: React.FC<{ className?: string }> = ({ className }) => { backgroundColor: active ? theme.colors.activity.onSoft : theme.colors.background.main, - color: active - ? theme.colors.activity.on - : theme.colors.text.main, + color: active ? theme.colors.activity.on : theme.colors.text.main, ...theme.typography.body2, transition: "all 0.15s", }} @@ -238,21 +228,14 @@ const Reserve: React.FC<{ className?: string }> = ({ className }) => { ) : points.length === 0 ? ( Ei reservidataa ) : ( - - type="bar" - data={chartData} - options={options} - /> + type="bar" data={chartData} options={options} /> )}
); }; -const Centered: React.FC<{ color: string; children: React.ReactNode }> = ({ - color, - children, -}) => ( +const Centered: React.FC<{ color: string; children: React.ReactNode }> = ({ color, children }) => (
{ if (price <= PRICE_HI) { - const t = Math.min( - 1, - Math.max(0, (price - PRICE_LO) / (PRICE_HI - PRICE_LO)), - ); + const t = Math.min(1, Math.max(0, (price - PRICE_LO) / (PRICE_HI - PRICE_LO))); return 120 * (1 - t); // 120 green → 0 red } const k = Math.min(1, (price - PRICE_HI) / (PRICE_MAX - PRICE_HI)); @@ -85,17 +76,7 @@ const drawLoupe = ( const ctx = dst.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, dst.width, dst.height); - ctx.drawImage( - src, - srcX * dpr, - srcY * dpr, - srcW * dpr, - srcH * dpr, - 0, - 0, - dst.width, - dst.height, - ); + ctx.drawImage(src, srcX * dpr, srcY * dpr, srcW * dpr, srcH * dpr, 0, 0, dst.width, dst.height); // Crosshair on the exact column under the fingertip (kept honest near the // edges, where the source patch is clamped and the finger is off-centre). @@ -118,17 +99,7 @@ const PriceChart: React.FC<{ onSelect: (hour: string, price: number) => void; onRelease: () => void; loupe: LoupeBox | null; -}> = ({ - containerRef, - points, - yMin, - yMax, - now, - selectedHour, - onSelect, - onRelease, - loupe, -}) => { +}> = ({ containerRef, points, yMin, yMax, now, selectedHour, onSelect, onRelease, loupe }) => { const theme = useTheme(); const loupeRef = useRef(null); const prices = points.map((p) => p.price); @@ -413,9 +384,7 @@ const SpotPrice: React.FC<{ className?: string }> = ({ className }) => { // Same object identity for an unchanged selection so a steady hover doesn't // churn the charts on every mousemove. setSelected((prev) => - prev && prev.day === day && prev.hour === hour - ? prev - : { day, hour, price }, + prev && prev.day === day && prev.hour === hour ? prev : { day, hour, price }, ); }, []); // On release (finger lifts / cursor leaves): one revert timer, replacing any @@ -450,9 +419,7 @@ const SpotPrice: React.FC<{ className?: string }> = ({ className }) => { const points = day === "today" ? data.today : data.tomorrow; if (!points.length) continue; const canvas = - (day === "today" ? todayRef : tomorrowRef).current?.querySelector( - "canvas", - ) ?? null; + (day === "today" ? todayRef : tomorrowRef).current?.querySelector("canvas") ?? null; if (!canvas) continue; const rect = canvas.getBoundingClientRect(); const dx = Math.max(rect.left - t.clientX, 0, t.clientX - rect.right); @@ -467,19 +434,13 @@ const SpotPrice: React.FC<{ className?: string }> = ({ className }) => { const xCss = t.clientX - best.rect.left; const idx = Math.max( 0, - Math.min( - best.points.length - 1, - Math.round(chart.scales.x.getValueForPixel(xCss) ?? 0), - ), + Math.min(best.points.length - 1, Math.round(chart.scales.x.getValueForPixel(xCss) ?? 0)), ); const p = best.points[idx]; select(best.day, p.hour.slice(11, 13), p.price); const touchX = Math.max(0, Math.min(best.rect.width, xCss)); - const left = Math.max( - 0, - Math.min(best.rect.width - LOUPE_W, touchX - LOUPE_W / 2), - ); + const left = Math.max(0, Math.min(best.rect.width - LOUPE_W, touchX - LOUPE_W / 2)); const top = t.clientY - best.rect.top - LOUPE_H - LOUPE_GAP; setLoupe({ day: best.day, left, top, touchX }); }; @@ -489,9 +450,7 @@ const SpotPrice: React.FC<{ className?: string }> = ({ className }) => { }; // The colour-scale gradient is hidden by default to save space; tapping the // header reveals it (no button/placeholder), remembered across reloads. - const [showScale, setShowScale] = useState( - () => localStorage.getItem(SCALE_KEY) === "1", - ); + const [showScale, setShowScale] = useState(() => localStorage.getItem(SCALE_KEY) === "1"); const toggleScale = () => setShowScale((v) => { localStorage.setItem(SCALE_KEY, v ? "0" : "1"); @@ -539,9 +498,7 @@ const SpotPrice: React.FC<{ className?: string }> = ({ className }) => { // Shared y-range across both days so bar heights are comparable (an expensive // day towers over a cheap one) and consistent with the absolute colour scale. - const allPrices = data - ? [...data.today, ...data.tomorrow].map((p) => p.price) - : []; + const allPrices = data ? [...data.today, ...data.tomorrow].map((p) => p.price) : []; const yMax = allPrices.length ? Math.max(0, ...allPrices) * 1.08 : 1; const yMin = allPrices.length ? Math.min(0, ...allPrices) : 0; @@ -576,9 +533,7 @@ const SpotPrice: React.FC<{ className?: string }> = ({ className }) => { minWidth: 0, }} > - - pörssisähkö - + pörssisähkö {data && selected && ( = ({ className }) => { Hintatietoja ei saatavilla
) : !data ? ( -
- Ladataan... -
+
Ladataan...
) : ( <> {showScale && } diff --git a/frontend/src/components/Energy/Summary.tsx b/frontend/src/components/Energy/Summary.tsx index dbf2989a..3d42bd10 100644 --- a/frontend/src/components/Energy/Summary.tsx +++ b/frontend/src/components/Energy/Summary.tsx @@ -25,16 +25,12 @@ const integrate = (readings: SolisReading[]): Aggregates => { let dischargeKwh = 0; const today = new Date(); - const todays = readings.filter((r) => - isSameDay(new Date(r.recordedAt), today), - ); + const todays = readings.filter((r) => isSameDay(new Date(r.recordedAt), today)); for (let i = 1; i < todays.length; i++) { const a = todays[i - 1]; const b = todays[i]; - const dtH = - (new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()) / - 3_600_000; + const dtH = (new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()) / 3_600_000; if (dtH <= 0 || dtH > 1) continue; // skip large gaps (>1h) const ag = a.gridPower ?? 0; @@ -81,8 +77,7 @@ const Summary: React.FC<{ className?: string }> = ({ className }) => { const pvToHome = Math.max(0, pvTotal - pvToGrid - pvToBattery); const homeKwh = pvToHome + dischargeKwh + importKwh; - const pct = (v: number) => - pvTotal > 0 ? Math.round((v / pvTotal) * 100) : 0; + const pct = (v: number) => (pvTotal > 0 ? Math.round((v / pvTotal) * 100) : 0); const homePct = pct(pvToHome); const batteryPct = pct(pvToBattery); const gridPct = pvTotal > 0 ? 100 - homePct - batteryPct : 0; @@ -215,9 +210,7 @@ const Summary: React.FC<{ className?: string }> = ({ className }) => { gap: 2, }} > -
- tuotto -
+
tuotto
- - pv → {label} - + pv → {label} {value} = ({ - weatherSymbol, - isNight, - ...props -}) => createElement(getFmiWeatherIcon(weatherSymbol, isNight), props); +const WeatherIcon: React.FC = ({ weatherSymbol, isNight, ...props }) => + createElement(getFmiWeatherIcon(weatherSymbol, isNight), props); type WeatherBoxProps = { className?: string; @@ -61,8 +53,7 @@ const WeatherBox: React.FC = ({ className }) => { const { data: pvForecast } = useSWR( api("/api/pv/forecast"), - (url: string) => - fetch(url).then((res) => (res.ok ? res.json() : undefined)), + (url: string) => fetch(url).then((res) => (res.ok ? res.json() : undefined)), { refreshInterval: 3600000, refreshWhenHidden: true, @@ -148,23 +139,16 @@ const WeatherBox: React.FC = ({ className }) => { const segments = getFmiTemperatureSegments(hourly); - const realIsNight = - now < new Date(current.sunrise) || now > new Date(current.sunset); + const realIsNight = now < new Date(current.sunrise) || now > new Date(current.sunset); // In demo mode the test controls can override the rendered weather so every // animation kind is reachable; symbol -1 ("live") falls back to the real data. const override = testCase && testCase.symbol >= 0; const weatherSymbol = override ? testCase.symbol : current.weatherSymbol; const isNight = override ? testCase.isNight : realIsNight; const precipitation = - override && testCase.precip !== undefined - ? testCase.precip - : current.precipitation1h; + override && testCase.precip !== undefined ? testCase.precip : current.precipitation1h; const title = getFmiWeatherDescription(weatherSymbol); - const borderColor = weatherBorderColor( - weatherSymbol, - isNight, - theme.mode === "dark", - ); + const borderColor = weatherBorderColor(weatherSymbol, isNight, theme.mode === "dark"); return ( = ({ className }) => {
} > - {demo && ( - - )} + {demo && }
= ({ className }) => { {demo ? "" : (location?.displayName ?? "")} {isStale && ( - + cloud_off )} @@ -294,9 +270,7 @@ const WeatherBox: React.FC = ({ className }) => { ) : ( water_drop )} - - {today.precipitation.toFixed(1)} mm - + {today.precipitation.toFixed(1)} mm
)}
= ({ className }) => { }} > air - - {current.windSpeed.toFixed(1)} m/s - - + {current.windSpeed.toFixed(1)} m/s +
diff --git a/frontend/src/components/History.tsx b/frontend/src/components/History.tsx index da7bc895..c5bd5435 100644 --- a/frontend/src/components/History.tsx +++ b/frontend/src/components/History.tsx @@ -94,8 +94,7 @@ const History: React.FC = ({ className }) => { useEffect(() => { const controller = new AbortController(); const params = new URLSearchParams({ hours: String(range.hours) }); - if (range.maxPoints !== undefined) - params.set("max_points", String(range.maxPoints)); + if (range.maxPoints !== undefined) params.set("max_points", String(range.maxPoints)); fetch(api(`/api/history/sensors?${params}`), { signal: controller.signal }) .then((r) => r.json()) .then((data: SensorReading[]) => { @@ -247,9 +246,7 @@ const History: React.FC = ({ className }) => { backgroundColor: active ? theme.colors.activity.onSoft : theme.colors.background.main, - color: active - ? theme.colors.activity.on - : theme.colors.text.main, + color: active ? theme.colors.activity.on : theme.colors.text.main, ...theme.typography.body2, transition: "all 0.15s", }} diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index e38efcc3..7e380ad9 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -17,10 +17,7 @@ const Icon: React.FC = ({ return ( - Math.min(hi, Math.max(lo, v)); +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); type LightGroupsProps = { groups: Group[]; @@ -59,9 +58,7 @@ const LightGroup: FC = ({ group }) => { const [isOn, setIsOn] = useState(group.state.on); const [prevOn, setPrevOn] = useState(group.state.on); const [brightness, setBrightness] = useState(group.state.brightness ?? 100); - const [prevBrightness, setPrevBrightness] = useState( - group.state.brightness ?? 100, - ); + const [prevBrightness, setPrevBrightness] = useState(group.state.brightness ?? 100); const [dimming, setDimming] = useState(false); // Sync from props (live SSE updates). @@ -185,11 +182,9 @@ const LightGroup: FC = ({ group }) => { skipClickRef.current = false; return; } - fetch(api(`/api/hue/toggleGroup/${group.id}`), { method: "POST" }).then( - (res) => { - if (res.status === 200) setIsOn((prev) => !prev); - }, - ); + fetch(api(`/api/hue/toggleGroup/${group.id}`), { method: "POST" }).then((res) => { + if (res.status === 200) setIsOn((prev) => !prev); + }); }, [group.id]); const theme = useTheme(); @@ -249,9 +244,7 @@ const LightGroup: FC = ({ group }) => { justifyContent: "center", alignItems: "center", borderRadius: "100%", - backgroundColor: isOn - ? "rgba(247, 143, 8, 0.18)" - : "rgba(0, 0, 0, 0.06)", + backgroundColor: isOn ? "rgba(247, 143, 8, 0.18)" : "rgba(0, 0, 0, 0.06)", height: 42, width: 42, transition: "background 0.4s ease", diff --git a/frontend/src/components/LocationForm.tsx b/frontend/src/components/LocationForm.tsx index dd4d93a1..d4e5c7a5 100644 --- a/frontend/src/components/LocationForm.tsx +++ b/frontend/src/components/LocationForm.tsx @@ -1,12 +1,7 @@ import { useTheme } from "@emotion/react"; import { useCallback, useEffect, useState } from "react"; -import { - getCityName, - type NominatimResult, - reverseGeocode, - searchLocation, -} from "../geo"; +import { getCityName, type NominatimResult, reverseGeocode, searchLocation } from "../geo"; import useGeoLocation from "../hooks/useGeoLocation"; import useLocationSettings from "../hooks/useLocationSettings"; import useScreenshotMode from "../hooks/useScreenshotMode"; @@ -74,10 +69,7 @@ const LocationForm: React.FC = ({ className }) => { const loading = searching || geo.loading; return ( -
+
= ({ className }) => {
{geo.error && ( -
- {geo.error} -
+
{geo.error}
)} {results.length > 0 && ( @@ -171,10 +161,7 @@ const LocationForm: React.FC = ({ className }) => { css={{ padding: "0.6em 0.75em", border: "none", - borderBottom: - i < results.length - 1 - ? `1px solid ${theme.colors.border}` - : "none", + borderBottom: i < results.length - 1 ? `1px solid ${theme.colors.border}` : "none", backgroundColor: theme.colors.background.light, color: theme.colors.text.main, textAlign: "left", diff --git a/frontend/src/components/Motion.tsx b/frontend/src/components/Motion.tsx index 16e2d3aa..8fc40c13 100644 --- a/frontend/src/components/Motion.tsx +++ b/frontend/src/components/Motion.tsx @@ -68,9 +68,7 @@ const Motion: FC = ({ className, sensors = [], error }) => {
toggle(s.deviceId)} - aria-label={ - enabled ? "Poista liiketunnistin käytöstä" : "Ota käyttöön" - } + aria-label={enabled ? "Poista liiketunnistin käytöstä" : "Ota käyttöön"} css={{ display: "table-row", opacity: enabled ? 1 : 0.5, @@ -95,12 +93,8 @@ const Motion: FC = ({ className, sensors = [], error }) => { width: 8, height: 8, borderRadius: "50%", - backgroundColor: active - ? theme.colors.activity.on - : theme.colors.text.muted, - animation: active - ? `${motionPulse} 1.8s ease-out infinite` - : "none", + backgroundColor: active ? theme.colors.activity.on : theme.colors.text.muted, + animation: active ? `${motionPulse} 1.8s ease-out infinite` : "none", }} /> @@ -128,20 +122,14 @@ const Motion: FC = ({ className, sensors = [], error }) => { display: "table-cell", padding: "8px 4px", verticalAlign: "middle", - color: active - ? theme.colors.activity.on - : theme.colors.text.muted, + color: active ? theme.colors.activity.on : theme.colors.text.muted, fontWeight: active ? 600 : 400, textAlign: "right", whiteSpace: "nowrap", transition: "color 0.3s ease", }} > - {!enabled - ? "pois käytöstä" - : s.motion - ? "liikettä" - : "ei liikettä"} + {!enabled ? "pois käytöstä" : s.motion ? "liikettä" : "ei liikettä"} = ({ label, lastSeen }) => { color: theme.colors.text.light, }} > - päivitetty{" "} - {formatDistanceToNow(lastSeen, { locale: fi, addSuffix: true })} + päivitetty {formatDistanceToNow(lastSeen, { locale: fi, addSuffix: true })}
)}
diff --git a/frontend/src/components/RainMap.tsx b/frontend/src/components/RainMap.tsx index 46bece83..5a0dbf49 100644 --- a/frontend/src/components/RainMap.tsx +++ b/frontend/src/components/RainMap.tsx @@ -65,10 +65,7 @@ const precipColor = (v: number): [number, number, number, number] => { }; /** Paint one forecast grid into a canvas and wrap it as a geo-placed overlay. */ -const buildForecastOverlay = ( - fc: PrecipForecast, - frame: ForecastFrame, -): L.ImageOverlay => { +const buildForecastOverlay = (fc: PrecipForecast, frame: ForecastFrame): L.ImageOverlay => { const { cols, rows, bbox } = fc; const canvas = document.createElement("canvas"); canvas.width = cols; @@ -106,9 +103,7 @@ const RainMap = ({ className }: { className?: string }) => { ); const { data: fcData, error: fcError } = useSWR( location - ? api( - `/api/radar/forecast?lat=${location.lat}&lon=${location.lon}&hours=${FORECAST_HOURS}`, - ) + ? api(`/api/radar/forecast?lat=${location.lat}&lon=${location.lon}&hours=${FORECAST_HOURS}`) : null, jsonFetcher, // The FMI GRIB fetch can fail transiently (502); retry a few times rather @@ -128,12 +123,8 @@ const RainMap = ({ className }: { className?: string }) => { // Frame metadata (kind + time), derived so labels/slider re-render in step // with the layers built in the effect below from the same two sources. const timeline = useMemo(() => { - const obs = (obsData?.times ?? []).map( - (time) => ({ kind: "observed", time }) as const, - ); - const fc = (fcData?.frames ?? []).map( - (f) => ({ kind: "forecast", time: f.time }) as const, - ); + const obs = (obsData?.times ?? []).map((time) => ({ kind: "observed", time }) as const); + const fc = (fcData?.frames ?? []).map((f) => ({ kind: "forecast", time: f.time }) as const); return [...obs, ...fc]; }, [obsData, fcData]); @@ -176,15 +167,12 @@ const RainMap = ({ className }: { className?: string }) => { if (!map || !ready) return; if (baseRef.current) map.removeLayer(baseRef.current); const variant = theme.mode === "dark" ? "dark_all" : "light_all"; - const base = L.tileLayer( - `https://{s}.basemaps.cartocdn.com/${variant}/{z}/{x}/{y}{r}.png`, - { - subdomains: "abcd", - maxZoom: 19, - attribution: - '© OpenStreetMap © CARTO', - }, - ); + const base = L.tileLayer(`https://{s}.basemaps.cartocdn.com/${variant}/{z}/{x}/{y}{r}.png`, { + subdomains: "abcd", + maxZoom: 19, + attribution: + '© OpenStreetMap © CARTO', + }); base.addTo(map); base.bringToBack(); baseRef.current = base; @@ -244,19 +232,14 @@ const RainMap = ({ className }: { className?: string }) => { // --- show only the active frame --- useEffect(() => { - framesRef.current.forEach((f, i) => - f.layer.setOpacity(i === index ? f.baseOpacity : 0), - ); + framesRef.current.forEach((f, i) => f.layer.setOpacity(i === index ? f.baseOpacity : 0)); }, [index, timeline]); // --- playback loop --- useEffect(() => { if (!playing || timeline.length === 0) return; const delay = index >= lastIndex ? HOLD_MS : PLAY_MS; - const timer = setTimeout( - () => setIndex((i) => (i + 1) % timeline.length), - delay, - ); + const timer = setTimeout(() => setIndex((i) => (i + 1) % timeline.length), delay); return () => clearTimeout(timer); }, [playing, index, lastIndex, timeline.length]); @@ -354,16 +337,13 @@ const RainMap = ({ className }: { className?: string }) => { relColor = theme.colors.cool; } const activeForecastDry = - active?.kind === "forecast" && - (fcData?.frames[index - observedCount]?.max ?? 0) === 0; + active?.kind === "forecast" && (fcData?.frames[index - observedCount]?.max ?? 0) === 0; const legendGradient = PRECIP_STOPS.map( - (s, i) => - `rgb(${s.c[0]},${s.c[1]},${s.c[2]}) ${(i / (PRECIP_STOPS.length - 1)) * 100}%`, + (s, i) => `rgb(${s.c[0]},${s.c[1]},${s.c[2]}) ${(i / (PRECIP_STOPS.length - 1)) * 100}%`, ).join(", "); - const overlayPanel = - theme.mode === "dark" ? "rgba(37,37,37,0.9)" : "rgba(255,255,255,0.9)"; + const overlayPanel = theme.mode === "dark" ? "rgba(37,37,37,0.9)" : "rgba(255,255,255,0.9)"; if (!location) { return ( @@ -397,10 +377,7 @@ const RainMap = ({ className }: { className?: string }) => { [mq[0]]: { height: "70vh", minHeight: 360 }, }} > -
+
{/* precipitation legend (top-centre, clear of the left zoom + right recenter) */} {fcData && ( @@ -576,9 +553,7 @@ const RainMap = ({ className }: { className?: string }) => { > {active ? format(new Date(active.time), "HH:mm") : "––:––"}
-
- {relText} -
+
{relText}
diff --git a/frontend/src/components/SolisBox.tsx b/frontend/src/components/SolisBox.tsx index 87c92fa4..68542695 100644 --- a/frontend/src/components/SolisBox.tsx +++ b/frontend/src/components/SolisBox.tsx @@ -16,15 +16,11 @@ type SolisBoxProps = { const SolisBox: React.FC = ({ className }) => { const theme = useTheme(); - const { data, error } = useSWR( - api("/api/solis"), - jsonFetcher, - { - refreshInterval: 300_000, - refreshWhenHidden: true, - shouldRetryOnError: false, - }, - ); + const { data, error } = useSWR(api("/api/solis"), jsonFetcher, { + refreshInterval: 300_000, + refreshWhenHidden: true, + shouldRetryOnError: false, + }); // Not configured: hide entirely. if (error?.status === 503) return null; @@ -79,20 +75,11 @@ const SolisBox: React.FC = ({ className }) => { value={`${Math.max(0, data.power - data.grid_power).toFixed(1)} ${data.power_unit}`} /> )} - - + + {data.battery_soc !== null && ( <> - + {data.battery_power !== null && data.battery_power_unit && ( = ({ className }) => { {`akku ${Math.round(data.battery_soc)} %`}
)} - {batteryLabel && batteryLabel !== "lepotila" && ( - {batteryLabel} - )} + {batteryLabel && batteryLabel !== "lepotila" && {batteryLabel}} diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/Sparkline.tsx index eea5f414..cf68ae76 100644 --- a/frontend/src/components/Sparkline.tsx +++ b/frontend/src/components/Sparkline.tsx @@ -7,12 +7,7 @@ type SparklineProps = { className?: string; }; -const Sparkline: FC = ({ - points, - color, - height = 50, - className, -}) => { +const Sparkline: FC = ({ points, color, height = 50, className }) => { if (points.length < 2) return null; const minY = Math.min(...points); @@ -23,11 +18,7 @@ const Sparkline: FC = ({ const padY = 2; const coords = points.map( - (y, i) => - [ - i * stepX, - height - padY - ((y - minY) / range) * (height - 2 * padY), - ] as const, + (y, i) => [i * stepX, height - padY - ((y - minY) / range) * (height - 2 * padY)] as const, ); const linePath = coords .map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`) diff --git a/frontend/src/components/TemperatureBox.tsx b/frontend/src/components/TemperatureBox.tsx index 7b070d02..1f71b027 100644 --- a/frontend/src/components/TemperatureBox.tsx +++ b/frontend/src/components/TemperatureBox.tsx @@ -21,8 +21,7 @@ type TemperatureBoxProps = { temperatureOverride?: number; }; -const isBatteryLow = (sensor: Sensor) => - sensor.battery !== undefined && sensor.battery < 2; +const isBatteryLow = (sensor: Sensor) => sensor.battery !== undefined && sensor.battery < 2; const TREND_PILL_THRESHOLD = 0.3; @@ -51,8 +50,7 @@ const TemperatureBox: React.FC = ({ const lowestBatterySensor = sensors .filter(isBatteryLow) .reduce( - (acc, s) => - acc === null || (s.battery ?? 100) < (acc.battery ?? 100) ? s : acc, + (acc, s) => (acc === null || (s.battery ?? 100) < (acc.battery ?? 100) ? s : acc), null, ); @@ -128,9 +126,7 @@ const TemperatureBox: React.FC = ({ > {showBatteryPill && } - {r.temperature !== undefined - ? `${Math.round(r.temperature)}°` - : ""} + {r.temperature !== undefined ? `${Math.round(r.temperature)}°` : ""} } @@ -166,11 +162,7 @@ const TemperatureBox: React.FC = ({ )} - + )} @@ -278,10 +270,9 @@ const TemperatureBox: React.FC = ({ }, }} > - {summary.diff1h !== null && - Math.abs(summary.diff1h) >= TREND_PILL_THRESHOLD && ( - - )} + {summary.diff1h !== null && Math.abs(summary.diff1h) >= TREND_PILL_THRESHOLD && ( + + )} = ({ - className, -}) => { - const { data } = useSWR( - api("/api/weather/tomorrow"), - fetcher, - { - refreshInterval: 3600000, - refreshWhenHidden: true, - }, - ); +const TomorrowWeatherBox: React.FC = ({ className }) => { + const { data } = useSWR(api("/api/weather/tomorrow"), fetcher, { + refreshInterval: 3600000, + refreshWhenHidden: true, + }); const theme = useTheme(); - const weather = data?.data.timelines.find((t) => t.timestep === "current") - ?.intervals[0]; - const daily = - data?.data.timelines.find((t) => t.timestep === "1d")?.intervals ?? []; + const weather = data?.data.timelines.find((t) => t.timestep === "current")?.intervals[0]; + const daily = data?.data.timelines.find((t) => t.timestep === "1d")?.intervals ?? []; const today = daily[0]; const hourly = data?.data.timelines.find((t) => t.timestep === "1h"); const alerts: { code: number; message: string }[] = []; @@ -186,8 +178,7 @@ const TomorrowWeatherBox: React.FC = ({ marginLeft: "25px", }} > - {(today.values.rainAccumulation > 0 || - today.values.snowAccumulation > 0) && ( + {(today.values.rainAccumulation > 0 || today.values.snowAccumulation > 0) && (
= ({ alignItems: "center", }} > - {today.values.rainAccumulation > 0 ? ( - water_drop - ) : ( - ac_unit - )} + {today.values.rainAccumulation > 0 ? water_drop : ac_unit} - {( - today.values.rainAccumulation || - today.values.snowAccumulation - ).toFixed(1)}{" "} - mm ({today.values.precipitationProbabilityAvg.toFixed()} + {(today.values.rainAccumulation || today.values.snowAccumulation).toFixed(1)} mm ( + {today.values.precipitationProbabilityAvg.toFixed()} %)
@@ -218,13 +202,8 @@ const TomorrowWeatherBox: React.FC = ({ }} > air - - {weather.values.windSpeed.toFixed(1)} m/s - - + {weather.values.windSpeed.toFixed(1)} m/s + diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index e7fee752..94d88357 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -34,12 +34,7 @@ const Tooltip: React.FC = ({ className, children, content }) => { const dismiss = useDismiss(context); const role = useRole(context, { role: "tooltip" }); - const { getReferenceProps, getFloatingProps } = useInteractions([ - hover, - focus, - dismiss, - role, - ]); + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); const refProps = getReferenceProps(); return ( diff --git a/frontend/src/components/TrendIndicator.tsx b/frontend/src/components/TrendIndicator.tsx index ad5d82d0..b049c2f2 100644 --- a/frontend/src/components/TrendIndicator.tsx +++ b/frontend/src/components/TrendIndicator.tsx @@ -8,10 +8,7 @@ type TrendIndicatorProps = { size?: number; }; -const TrendIndicator: React.FC = ({ - trend, - size = 24, -}) => { +const TrendIndicator: React.FC = ({ trend, size = 24 }) => { const theme = useTheme(); if (trend === "stable") return null; diff --git a/frontend/src/components/WeatherAsciiBackground.tsx b/frontend/src/components/WeatherAsciiBackground.tsx index f3bbf317..658c6eed 100644 --- a/frontend/src/components/WeatherAsciiBackground.tsx +++ b/frontend/src/components/WeatherAsciiBackground.tsx @@ -114,11 +114,7 @@ function fbm(x: number, y: number, seed: number): number { // precip is mm/h: 0 = none, ~6+ = heavy downpour. It scales how dense and fast // rain/snow fall so a drizzle and a downpour read differently. -function buildConfig( - kind: WeatherKind, - isDark: boolean, - precip: number, -): KindConfig { +function buildConfig(kind: WeatherKind, isDark: boolean, precip: number): KindConfig { const rain = isDark ? "#7f93d8" : "#5fa9d6"; const snow = isDark ? "#b4c6ec" : "#8fb6dd"; const star = isDark ? "#a6a6a6" : "#8f8f8f"; @@ -316,9 +312,7 @@ const WeatherAsciiBackground: React.FC = ({ if (!ctx) return; const cfg = buildConfig(kind, isDark, precipitation); - const reduceMotion = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const phase = moonPhase(); // The header/footer are transparent when this canvas is the box background, // so the canvas paints the card surface itself — a dim sky for clouds/fog, @@ -364,9 +358,7 @@ const WeatherAsciiBackground: React.FC = ({ rows = Math.max(2, Math.ceil(height / cellH) + 1); } else { const target = Math.round(((width * height) / 1000) * cfg.density); - particles = Array.from({ length: target }, () => - makeParticle(cfg, width, height), - ); + particles = Array.from({ length: target }, () => makeParticle(cfg, width, height)); if (cfg.accumulate) { pileCols = Math.max(1, Math.ceil(width / groundW) + 1); pile = new Float32Array(pileCols); @@ -469,8 +461,7 @@ const WeatherAsciiBackground: React.FC = ({ let v = (1 - r) * 0.95 + (granule - 0.5) * 0.6; if (v <= 0) continue; if (v > 1) v = 1; - const g = - ramp[Math.min(ramp.length - 1, Math.floor(v * ramp.length))]; + const g = ramp[Math.min(ramp.length - 1, Math.floor(v * ramp.length))]; if (g === " ") continue; ctx.globalAlpha = cfg.alpha * (0.5 + v * 0.5); ctx.fillText(g, cx + gx, cy + gy); @@ -525,10 +516,7 @@ const WeatherAsciiBackground: React.FC = ({ // ground contact: snow settles into a pile, rain splashes let landed = false; if (cfg.accumulate && pileCols > 0) { - const col = Math.min( - pileCols - 1, - Math.max(0, Math.floor(p.x / groundW)), - ); + const col = Math.min(pileCols - 1, Math.max(0, Math.floor(p.x / groundW))); const landingY = height - pile[col] * pileUnit; if (p.y >= landingY) { if (pile[col] < MAX_PILE) pile[col] += 1; @@ -552,8 +540,7 @@ const WeatherAsciiBackground: React.FC = ({ if (p.x > width + cfg.fontSize) p.x = -cfg.fontSize; ctx.globalAlpha = cfg.alpha; } else { - const tw = - 0.35 + 0.65 * Math.abs(Math.sin(t * p.twinkleSpeed + p.phase)); + const tw = 0.35 + 0.65 * Math.abs(Math.sin(t * p.twinkleSpeed + p.phase)); ctx.globalAlpha = cfg.alpha * tw; } ctx.fillText(p.glyph, p.x, p.y); @@ -566,11 +553,7 @@ const WeatherAsciiBackground: React.FC = ({ const stacks = Math.floor(pile[c]); for (let k = 0; k < stacks; k++) { ctx.globalAlpha = cfg.alpha * 0.9; - ctx.fillText( - "*", - c * groundW, - height - k * pileUnit - pileUnit / 2, - ); + ctx.fillText("*", c * groundW, height - k * pileUnit - pileUnit / 2); } pile[c] = Math.max(0, pile[c] - MELT * dt); } @@ -628,11 +611,7 @@ const WeatherAsciiBackground: React.FC = ({ ctx.globalAlpha = Math.max(0, shooting.life) * (1 - i / 7); ctx.fillText(i === 0 ? "✦" : i < 3 ? "—" : "·", tx, ty); } - if ( - shooting.life <= 0 || - shooting.x < -20 || - shooting.x > width + 20 - ) { + if (shooting.life <= 0 || shooting.x < -20 || shooting.x > width + 20) { shooting = null; } } diff --git a/frontend/src/components/WeatherChart.tsx b/frontend/src/components/WeatherChart.tsx index 99b96d81..d7313213 100644 --- a/frontend/src/components/WeatherChart.tsx +++ b/frontend/src/components/WeatherChart.tsx @@ -43,27 +43,20 @@ const fitsInsideBar = (ctx: Context, charPx: number): boolean => { if (value == null) return true; const scale = ctx.chart.scales.yPv; if (!scale) return true; - const barHeight = Math.abs( - scale.getPixelForValue(value) - scale.getPixelForValue(0), - ); + const barHeight = Math.abs(scale.getPixelForValue(value) - scale.getPixelForValue(0)); const text = `${value.toFixed(0)} kWh`; return barHeight >= text.length * charPx + 4; }; -const WeatherChart: React.FC = ({ - data: unfilteredData, - days, -}) => { +const WeatherChart: React.FC = ({ data: unfilteredData, days }) => { const theme = useTheme(); const isMobile = useMediaQuery("(max-width: 600px)"); const labelFontSize = isMobile ? 8 : 10; const labelCharPx = isMobile ? 5 : 6.5; - const data = - days === undefined ? unfilteredData : unfilteredData.slice(0, days); + const data = days === undefined ? unfilteredData : unfilteredData.slice(0, days); - const avgTemp = - data.map((d) => d.temp).reduce((a, b) => a + b, 0) / data.length; + const avgTemp = data.map((d) => d.temp).reduce((a, b) => a + b, 0) / data.length; const weatherLineColor = avgTemp < 5 ? theme.colors.cool : theme.colors.warm; @@ -99,12 +92,9 @@ const WeatherChart: React.FC = ({ rotation: -90, clip: false, font: { size: labelFontSize, weight: "bold" }, - formatter: (v: number | null) => - v == null ? "" : `${v.toFixed(0)} kWh`, - anchor: (ctx: Context) => - fitsInsideBar(ctx, labelCharPx) ? "center" : "end", - align: (ctx: Context) => - fitsInsideBar(ctx, labelCharPx) ? "center" : "top", + formatter: (v: number | null) => (v == null ? "" : `${v.toFixed(0)} kWh`), + anchor: (ctx: Context) => (fitsInsideBar(ctx, labelCharPx) ? "center" : "end"), + align: (ctx: Context) => (fitsInsideBar(ctx, labelCharPx) ? "center" : "top"), color: (ctx: Context) => fitsInsideBar(ctx, labelCharPx) ? "#ffffff" @@ -173,14 +163,7 @@ const WeatherChart: React.FC = ({ }, }; - return ( - - ); + return ; }; export default WeatherChart; diff --git a/frontend/src/components/WeatherTestControls.tsx b/frontend/src/components/WeatherTestControls.tsx index ac41a300..c082f74a 100644 --- a/frontend/src/components/WeatherTestControls.tsx +++ b/frontend/src/components/WeatherTestControls.tsx @@ -30,10 +30,7 @@ type WeatherTestControlsProps = { onSelect: (testCase: WeatherTestCase) => void; }; -const WeatherTestControls: React.FC = ({ - activeId, - onSelect, -}) => { +const WeatherTestControls: React.FC = ({ activeId, onSelect }) => { const theme = useTheme(); return ( @@ -71,12 +68,8 @@ const WeatherTestControls: React.FC = ({ fontFamily: theme.fonts.heading, fontSize: 11, lineHeight: 1.2, - color: active - ? theme.colors.activity.on - : theme.colors.text.muted, - backgroundColor: active - ? theme.colors.activity.onSoft - : "transparent", + color: active ? theme.colors.activity.on : theme.colors.text.muted, + backgroundColor: active ? theme.colors.activity.onSoft : "transparent", "&:hover": { color: theme.colors.text.main }, }} > diff --git a/frontend/src/components/Wordmark.tsx b/frontend/src/components/Wordmark.tsx index 5f411c19..e003965b 100644 --- a/frontend/src/components/Wordmark.tsx +++ b/frontend/src/components/Wordmark.tsx @@ -7,11 +7,7 @@ type WordmarkProps = { size?: number; } & React.HTMLAttributes; -const Wordmark: React.FC = ({ - className, - size = 22, - ...rest -}) => { +const Wordmark: React.FC = ({ className, size = 22, ...rest }) => { const theme = useTheme(); return ( @@ -25,13 +21,7 @@ const Wordmark: React.FC = ({ color: theme.colors.text.main, }} > -