Compare commits

7 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 6f8a5316b3 Stop the direct-publish and passthrough cycles publishing the same item twice
Build and push Docker images / backend (push) Canceled after 0s
Build and push Docker images / frontend (push) Canceled after 0s
Both cycles snapshot the unclustered items when they start and then await between
publishes, so whichever ran second republished items the first had already done.
With the AI service unreachable the passthrough cycle runs every minute next to the
direct-publish one, so every RSS item in a category with AI turned off came out as
two identical articles. Each item is now claimed (in-process, re-checked against the
database) before publishing. Reproduced with both cycles run concurrently on 12
items: 24 articles before, 12 after.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 22:55:12 -04:00
SalastilandClaude Sonnet 5.5 02c8efa7e9 Pin the production data volume by name so no project name can swap the database
Build and push Docker images / backend (push) Canceled after 0s
Build and push Docker images / frontend (push) Canceled after 0s
Compose Manager's project_name file drifted to 'homefeed' while the data lived
in 'homefeed-001_backend-data', so its Up button created a fresh empty volume and
a duplicate stack. The volume is now external and named explicitly: any project
name resolves to the same data, and a missing volume fails loudly instead of
starting a blank site.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 18:22:40 -04:00
SalastilandClaude Sonnet 5.5 e8e18ee2ec Make the SSR hairpin address configurable (LAN_HOST_IP) and follow the NAS to 192.168.0.60
Build and push Docker images / backend (push) Canceled after 0s
Build and push Docker images / frontend (push) Canceled after 0s
The backend container maps the public domain to the reverse proxy's LAN IP so
server-side renders stay on the LAN. That address was hardcoded to the old NAS
IP, so after the move every SSR fetch failed and each page returned a 500.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 18:17:21 -04:00
SalastilandClaude Sonnet 5.5 a0a8ce7e09 Make ADMIN_API_KEY work as a chosen admin password under Docker
Build and push Docker images / backend (push) Failing after 30s
Build and push Docker images / frontend (push) Failing after 31s
Setting ADMIN_API_KEY in the Unraid project's .env had no effect: the deployed compose.yaml
was a stale copy of docker-compose.prod.yml from before that file forwarded the variable, so
nothing referenced it, it never reached the container, and the backend invented a random key
on every recreate. The repo's prod file already had the line; the fix on the host is to bring
the deployed copy back in line with it.

The deployed copy had also drifted the other way: the extra_hosts entry that keeps the
frontend's server-side rendering on the LAN had only ever been applied on the host. It is
now in docker-compose.prod.yml, so the repo is the source of truth again and a re-copy of
the file can't silently drop it.

Backend changes so a chosen password behaves sensibly:
- A key supplied through the environment is no longer printed at startup. The banner echoed
  it into the container log on every start, leaving a copy of the password in `docker logs`.
  Generated and file-persisted keys are still printed, since that is the only way to learn
  one you didn't choose.
- The env value is trimmed. A password pasted into a compose or env editor routinely picks up
  a stray space or newline and then never matches, which looks exactly like the setting being
  ignored.
- Startup warns when a supplied key is shorter than 16 characters. Nothing limits repeated
  wrong guesses on /api/admin/*, so a short hand-picked key is genuinely guessable.

The README, backend README and .env.example still described a fresh random key on every
start that "isn't configurable via .env", which stopped being true when the key began to
persist; they now describe the three ways it is resolved and the Docker forwarding gotcha.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 23:38:11 -04:00
SalastilandClaude Sonnet 5.5 2893b1906b Let story feeds and articles fill the same column as the weather page
Build and push Docker images / backend (push) Failing after 30s
Build and push Docker images / frontend (push) Failing after 24s
The feed list and the More page were capped at 720px and the article at 640px, so on a
desktop they stopped well short of the sidebar. The caps are gone and each now runs the full
width of the main column (862px at a 1290px viewport); the layout grid keeps that column
clear of the sidebar widgets, leaving the same 32px gutter, and the page container's own
1242px limit still bounds it on very wide screens.

The tweet and Telegram photo frames needed a matching change. They were fixed heights
(380px for one photo, 240px for two, 300px for three or four) tuned for a 640px column, so
simply widening the feed left landscape photos letterboxed with dark side bars. They now keep
the proportions they had at 640px and scale with the card, with the old heights kept as a
minimum so a narrow column is unchanged.

That is done with container-relative heights rather than aspect-ratio plus min-height. The
first attempt used aspect-ratio and, on a phone, converted the 380px minimum height into a
676px minimum width through the ratio, pushing the frames past the screen edge and making the
page scroll sideways; only caught by checking at phone width. Measured at 375px, 1024px and
1290px: no horizontal scroll at any, the old frame sizes preserved at the narrow end, and no
overlap with the sidebar.

Body text on the article page now runs about 113 characters per line at this width.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 23:19:05 -04:00
SalastilandClaude Sonnet 5.5 39cd9717c2 Let the weather page fill its column instead of stopping at 640px
Build and push Docker images / backend (push) Failing after 28s
Build and push Docker images / frontend (push) Failing after 24s
The conditions card, alerts and 7-day list were capped at 640px and the summary at 560px,
leaving about 220px of empty space between the content and the sidebar on a normal desktop.
The caps are gone, so everything runs up to the sidebar's gutter; the page container's own
1242px limit still bounds it on very wide screens. The timeline bar grows from about 608px
to 828px at a 1290px viewport, which gives the hourly view more resolution.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 23:04:33 -04:00
SalastilandClaude Sonnet 5.5 513149fda5 Rework the weather page: one conditions card, day/night forecast rows, timeline bar
Build and push Docker images / backend (push) Failing after 24s
Build and push Docker images / frontend (push) Failing after 36s
Three connected changes to the weather page, plus the bug the work turned up.

The header and the hourly stat panel are now one card. Hovering or dragging along the
timeline swaps every figure in it, header included, and leaving restores the live reading.
It carries wind gusts, dew point, cloud cover, visibility, cloud ceiling, indoor humidity
and a per-hour feels-like, built by one shared function for "now" and each hour so the two
cannot drift apart.

Indoor humidity follows AccuWeather's published figure and its 11-step description, neither
of which has a documented method, so both were reverse-engineered from 28 live readings
(Antarctica to Singapore) and checked against 6 more cities not used in the fit: all 34
descriptions match and every percentage is within 2 points. The description is a function
of the outdoor dew point alone, not of the percentage (Minneapolis at 41% is "Ideal" while
Reykjavik at 42% is "Slightly Dry"). The percentage is the outdoor air warmed to 68F, plus
an empirically fitted moisture allowance in cold dry weather.

Cloud ceiling comes from the National Weather Service gridpoint forecast, so it exists for
US locations only and the row is left out elsewhere. Open-Meteo has a cloud_base variable
but returns null for every model at every location, and the usual temperature/dew-point
estimate describes convective cloud bases and contradicted a real high ceiling.

The 7-day list now describes each day and each night ("A morning shower", "Night: A couple
of evening showers") composed from the hourly series, since the daily weather code names
only the single worst condition of the day. An hour at 50% chance of precipitation or more
counts as showers even when the model's one run is dry there, because AccuWeather's own
wording follows the probability and a row otherwise read "Partly sunny" beside 56%. The
low shown is now the night's low, matching AccuWeather, and the first row reads "Tonight"
once the sun has set. One extra forecast day is requested so the seventh night is complete.

The row of per-hour icon buttons is replaced by a single bar in the style of MerrySky:
consecutive hours that look alike merge into one labelled stretch, with a time and
temperature axis beneath. Mouse hover scrubs it, touch scrubs while pressed and stays
pinned until dismissed, and the keyboard steps through hours. Each hour carries a coarse
condition kind computed from the weather code rather than parsed from display text.

Also fixed along the way: "today" is anchored on the first hour actually shown, so the
daily list starts on the right date shortly after local midnight.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 22:58:15 -04:00
18 changed files with 1736 additions and 384 deletions
+18 -3
View File
@@ -24,11 +24,20 @@ FRONTEND_PORT=3000
BACKEND_PORT=4000
# Turns on the admin cog icon + /admin/* pages — off by default, matching the
# non-Docker deployment. The backend's per-launch API key (printed to
# `docker compose logs backend` on every start) is what actually protects
# /api/admin/* either way.
# non-Docker deployment. The admin API key (see ADMIN_API_KEY below) is what
# actually protects /api/admin/* either way.
ADMIN_PANEL_ENABLED=false
# The admin password: the key the admin login page asks for, and the only thing
# protecting /api/admin/*. Set it to a long string of your choosing (16+ characters;
# the backend warns at startup if shorter) and it stays the same across restarts and
# container recreates. Leave it blank and the backend invents a random key instead —
# printed to `docker compose logs backend`, and in a container with no writable .env
# a NEW one every time the container is recreated.
# Avoid `$` in the value (compose treats it as the start of a variable). A chosen
# key is deliberately NOT printed to the log.
# ADMIN_API_KEY=
# Optional — unlocks "private" categories for visitors who log in with this
# password on the public site's lock icon. Leave unset/blank to disable private
# categories entirely.
@@ -41,3 +50,9 @@ ADMIN_PANEL_ENABLED=false
# or a specific tag to deploy.
# REGISTRY=git.salastil.com/salastil
# IMAGE_TAG=latest
# LAN address of the machine running your reverse proxy (docker-compose.prod.yml only).
# The backend container maps your public domain to it so the frontend's server-side
# fetches stay on the LAN instead of going out to Cloudflare and back. If this is wrong
# or stale (e.g. the server got a new IP), every page returns a 500 "fetch failed".
# LAN_HOST_IP=192.168.0.60
+235 -11
View File
@@ -1,15 +1,239 @@
# Homefeed — An all‑in‑one RSS, social media and news blotter
# Homefeed — frontend, real backend, and mock backend
<img width="320" height="320" alt="Homefeed" src="https://github.com/user-attachments/assets/e6fda335-3e24-4251-b413-e4cc4b5ad728" />
<img width="320" height="320" alt="Article" src="https://github.com/user-attachments/assets/d4d56f64-b07e-4e32-b470-7634970add82" />
```
frontend/ SvelteKit app — the actual site (homepage, category pages, article view, admin panel)
backend/ The real backend — Node.js/TypeScript, SQLite, RSS/API ingestion, Ollama-backed synthesis
mock-backend/ Tiny Express server serving dummy articles — useful for pure frontend UI work without Ollama running
```
Homefeed aggregates news, social posts and media into a clean, personal feed you can host on your own site — without the clutter of ads and invasive tracking. It pulls content from a variety of sources, normalizes and groups related items, and presents them in a readable, customizable format.
Both `backend/` and `mock-backend/` implement the identical `/api/feed`,
`/api/article/:id`, `/api/tags`, `/api/events`, `/api/admin/*` contract — the frontend
doesn't know or care which one it's talking to. Switch between them by changing
`VITE_BACKEND_URL` in `frontend/.env`.
## Features
## Running the real backend
- Lightweight, user-hosted feed: display news and media on your own site with minimal ads and tracking.
- Multiple input sources: ingest content from RSS feeds, Nitter, Telegram, YouTube, and other supported providers.
- AI-assisted consolidation: when multiple outlets publish the same or very similar articles, Homefeed can merge and rewrite them in a voice or style you choose, reducing duplication and improving readability.
- Event tracking and queries: create tracked items (queries) to follow events or topics — search for emoji (flags), names, organizations, or keywords and surface related posts and articles. Periodic AI recaps: configure recap intervals (for example hourly, daily, weekly, or a custom cadence) and Homefeed will generate concise summaries for each tracked item on schedule using AI.
- Dynamic widget system: build and configure widgets to highlight important data at a glance.
- Modular architecture: frontend and backend communicate via a clear API so you can swap or build custom clients (for example, a terminal UI, Android app, or iPhone app) without changing the ingestion or storage layer.
```bash
cd backend
cp .env.example .env
npm install
npm run dev
```
The admin login page asks for an admin API key. Either set `ADMIN_API_KEY` in
`backend/.env` to a password of your choosing, or leave it unset and copy the random
key the console prints the first time it starts (it's saved to `.env` and reused after
that). See `backend/README.md` for details.
See `backend/README.md` for what's fully implemented vs. stubbed (Telegram adapter,
etc.), and how it behaves when Ollama isn't reachable.
## Running the mock backend instead (frontend-only work, no Ollama needed)
Two terminals:
```bash
# Terminal 1 — mock backend (http://localhost:4000)
cd mock-backend
npm install
npm start
# Terminal 2 — frontend (http://localhost:5173)
cd frontend
npm install
npm run dev
```
Open http://localhost:5173.
## What's implemented
- **Homepage** (`/`) — hero story, Local section, Business and Tech rails
- **Category pages** (`/category/local`, `/category/world`, etc.) — full listing per category, `local` maps to the Philadelphia geo filter
- **Article page** (`/article/:id`) — merge badge, hero image with single-source attribution, body, video slot, tag chips, thread continuation banners (both directions — "newer coverage" / "earlier coverage"), sources footer
- **Article cards** — show source count (`⇄ N sources`), single-source attribution, or a video indicator, matching the design decided earlier
- **Light/dark theme toggle** — slider in the masthead, top right, left of the settings cog. Dark is a genuine slate palette (not an inverted light theme). Persists via `localStorage`, respects system preference on first load, no flash-of-wrong-theme (set before hydration in `app.html`).
- **Admin panel** (`/admin/settings`) — disabled by default; set `ADMIN_PANEL_ENABLED=true` in `frontend/.env` to turn on the cog icon and the `/admin/*` pages (see `frontend/.env.example`). Six tabs, all wired to the mock backend's `/api/admin/*` routes:
- **Merge** — strictness slider, poll interval, hold-before-publish, follow-up thresholds, category priority (reorderable), tag dedup threshold, tag expiry
- **Sources** — list, add, enable/disable, delete RSS/API/Telegram feeds
- **Models** — independent inference-server connection (host/port, test/save) and model selection for embedding/clustering and article synthesis, fetched from the mock's simulated Ollama catalog
- **Retention** — published-article and raw-item age presets, storage cap with FIFO note and usage bar
- **Tracked events** — list, create, toggle active/paused, delete
- **Connections** — the asymmetric pair: frontend→backend URL (saved to *this browser* via `localStorage`, not a backend setting) and backend→AI-service host/port (a real backend setting, saved via `/api/admin/settings`)
## Mock data
`mock-backend/data.js` has ~10 dummy articles covering: a 4-source merge with a follow-up (`art-1` → `art-2`, same `threadId`), single-source articles across Business/Tech, and a Local section with a mix of merged, single-source, and video items — enough variety to sanity-check every card/badge state in the design.
Timestamps are generated relative to `Date.now()` (see `hoursAgo()` in `data.js`) rather than hardcoded, so "time ago" labels stay sensible no matter when you run this.
## Connecting to the real backend later
The frontend never hardcodes `localhost:4000` — see `frontend/src/lib/config.ts`. It reads `VITE_BACKEND_URL` (set in `frontend/.env`) or a value saved via `setBackendUrl()`. Pointing this project at the real backend instead of the mock is a one-line change, not a rewrite — swap the URL in `.env` and everything else keeps working, since both servers implement the same `/api/feed`, `/api/article/:id`, `/api/tags`, `/api/events` contract from `homefeed-data-schema.md`.
## Docker
`docker-compose.yml` builds and runs the real backend and the frontend as two
separate containers (the mock backend isn't included — it's a dev-only
convenience, not meant to be deployed).
```bash
cp .env.example .env
docker compose up --build
```
Open http://localhost:3000. For the admin panel, set `ADMIN_API_KEY` in `.env` to a
password of your choosing — it's passed through to the backend (see `.env.example`).
If you leave it unset the backend invents a random key and prints it to its container
log (`docker compose logs backend`), but a container has no writable `.env` to remember
it in, so you'd get a different one every time the container is recreated.
`.env.example` documents every variable `docker-compose.yml` reads; the two that
matter most:
- **`PUBLIC_ORIGIN`** — the one URL everything treats as "where this site is."
Feeds both the backend's `FRONTEND_ORIGIN` (CORS) and the frontend's `ORIGIN`
(adapter-node) — see the reverse-proxy section above for why these two must
match exactly. Defaults to `http://localhost:3000` for local testing.
- **`VITE_BACKEND_URL`** — baked into the frontend image at *build* time (same
build-time-vs-runtime distinction as the non-Docker deployment below), so
changing it needs `docker compose build frontend`, not just a restart or
`up`. The frontend container runs with `network_mode: service:backend` (shares
the backend container's network namespace rather than getting its own)
specifically so `http://localhost:4000` correctly reaches the backend from
*both* the visitor's browser and the frontend container's own
server-rendered (SSR) requests on a page's first load — no single URL value
would otherwise work for both. Don't remove that `network_mode` line without
replacing it with something that solves the same problem (a reverse proxy
routing by path, as below, is the standard fix).
For a real deployment, put both containers behind a reverse proxy exactly as
described in the next section (`FRONTEND_PORT`/`BACKEND_PORT`'s published ports
are what you'd point the proxy at), and set `PUBLIC_ORIGIN`/`VITE_BACKEND_URL` to
your public domain instead of `localhost`.
The backend's SQLite DB, downloaded media, and uploaded widgets all persist in
the `backend-data` named volume — `docker compose down` alone doesn't touch it;
add `-v` if you actually want to wipe it. Once running, point the admin panel's
AI Service host (Connections tab) at your Ollama instance — if Ollama runs on
the same machine outside Docker, use `http://host.docker.internal:11434`, not
`localhost` (which inside the container means the container itself).
### Registry and CI (Gitea)
Both services also carry an `image:` name pointed at this project's Gitea
container registry (`git.salastil.com/salastil/homefeed-{backend,frontend}`),
so `docker compose build` tags them correctly and `docker compose push`/`pull`
work directly against it — override `REGISTRY`/`IMAGE_TAG` in `.env` for a
different registry or a specific tag (see `.env.example`).
`.gitea/workflows/docker-build.yml` builds and pushes both images on every
push to `master` or `development` — `latest` on `master`, the branch name and
commit SHA otherwise. It reads `VITE_BACKEND_URL` from the repo's Actions
variables (Settings → Actions → Variables) for the frontend build, since that
needs to be the real public URL, not `localhost`.
## Deploying behind a reverse proxy (e.g. Nginx Proxy Manager)
Both apps can also run as plain, long-lived Node processes on your own host
without Docker at all — two servers you point a reverse proxy at.
This assumes **one public domain**, with the reverse proxy routing by path:
everything under `/api/` and `/media/` goes to the backend, everything else goes to
the frontend. (Two separate domains — e.g. `homefeed.example.com` +
`api.homefeed.example.com` — works too and is actually simpler to set up, since it
needs no path-based routing at all; skip the "Custom Locations" step below and just
point `VITE_BACKEND_URL` at the second domain instead of the first.)
### 1. Build and run the backend
```bash
cd backend
cp .env.example .env
# set FRONTEND_ORIGIN to your public domain, e.g. https://homefeed.example.com
npm install
npm run build
node --experimental-sqlite --env-file=.env dist/index.js
```
Keep this running (systemd, pm2, tmux — whatever you'd normally use). Set
`ADMIN_API_KEY` in `backend/.env` to choose your admin password; if you don't, it
generates one the first time, prints it to the console and saves it to `.env` so it
stays the same on later starts (see `backend/README.md`).
> **`FRONTEND_ORIGIN` here and `ORIGIN` in `frontend/.env` (next step) must be the
> exact same value** — same scheme, same host, same trailing-slash-or-not. SvelteKit's
> server-side `fetch` (used to load the homepage's data) enforces real CORS during
> SSR, just like a browser would; if these two don't match character-for-character,
> every page fails with `CORS error: Incorrect 'Access-Control-Allow-Origin' header
> is present on the requested resource`. Both should be your public HTTPS domain —
> not `localhost`, not an internal port — since that's what the browser (and
> SvelteKit's own SSR fetch) actually sees as the origin.
### 2. Build and run the frontend
Now that it uses `@sveltejs/adapter-node`, `npm run build` produces a standalone
server at `build/index.js` rather than needing a specific hosting platform.
```bash
cd frontend
cp .env.example .env
# same-domain path routing (this section's assumption): point this at your one
# public domain, since /api and /media resolve there too, e.g.:
# VITE_BACKEND_URL=https://homefeed.example.com
# (separate domains instead: point this at the backend's own domain)
#
# also add to this same .env file (read at runtime, not build time):
# PORT=3000
# ORIGIN=https://homefeed.example.com
# HOST=0.0.0.0
npm install
npm run build
node --env-file=.env build/index.js
```
`ORIGIN` must match the public HTTPS URL exactly — adapter-node uses it to validate
requests and build absolute URLs; getting it wrong is the classic "works on
localhost, breaks behind the proxy" bug. Unlike `PORT`/`ORIGIN`/`HOST` (read fresh
at runtime, so `.env` changes take effect on restart), `VITE_BACKEND_URL` is baked
in at **build time** — changing your domain later means rebuilding the frontend,
not just editing `.env`.
### 3. Nginx Proxy Manager configuration
Add one Proxy Host for your domain:
- **Forward Hostname/IP**: wherever the frontend process is reachable from NPM
(`127.0.0.1` if NPM and the app run on the same host, a container/host name
otherwise) · **Forward Port**: `3000` (or whatever you set above)
- **SSL tab**: request a Let's Encrypt certificate, enable "Force SSL"
- Websockets support isn't needed — this app doesn't use any
Then, same-domain path routing only, add three **Custom Locations** on that same
Proxy Host:
| Location | Forward Hostname/IP | Forward Port |
| ---------------- | -------------------- | ------------ |
| `/api` | the backend's host | `4000` |
| `/media` | the backend's host | `4000` |
| `/widget-assets` | the backend's host | `4000` |
Leave the forwarded path as-is (don't strip the prefix) — the backend's own
routes already expect it, exactly as proxied. `/widget-assets` serves a
pluggable widget's `frontendEntry` bundle (see `DynamicWidgetSlot.svelte`) —
easy to miss since it only matters once a widget with a frontend bundle is
actually installed, unlike `/api`/`/media` which are needed from the very
first page load.
### Why this works without further code changes
- The frontend never assumes same-origin vs. cross-origin — every backend call goes
through `getBackendUrl()` (see `frontend/src/lib/config.ts`), producing a full
absolute URL either way.
- The admin API key (see the earlier hardening pass) is a request header, not a
cookie, so there's no cross-domain cookie/SameSite concern regardless of which
domain layout you pick.
- `backend/src/index.ts`'s CORS is still locked to `FRONTEND_ORIGIN` as a
defense-in-depth measure, even though same-domain path routing makes it moot for
actual browser traffic (the browser sees one origin the whole time, so CORS
doesn't come into play) — set it to your public domain regardless.
+18 -9
View File
@@ -17,13 +17,22 @@ npm run dev
Runs on `:4000` by default. Point the frontend's `VITE_BACKEND_URL` at it instead of
the mock backend and everything else keeps working unchanged — same API contract.
On startup, the console prints an admin API key — a fresh random value generated
every time the process starts (see `api/apiKey.ts`), not stored anywhere and not
configurable via `.env`. Every `/api/admin/*` request must send it as an
`X-Api-Key` header (enforced in `api/auth.ts`); the admin login page just asks for
this key and stashes it in the browser's `localStorage` rather than issuing its own
session. Restarting the backend invalidates the previous key — check the console
each time.
Every `/api/admin/*` request must send an admin API key as an `X-Api-Key` header
(enforced in `api/auth.ts`); the admin login page just asks for this key and stashes
it in the browser's `localStorage` rather than issuing its own session. The key comes
from, in order (see `api/apiKey.ts`):
1. the `ADMIN_API_KEY` environment variable — **set this to choose your own password.**
It's used exactly as given (surrounding whitespace is trimmed) and, because you
already know it, it is *not* printed at startup. Use 16+ characters: nothing limits
repeated wrong guesses, and the backend warns when the key is shorter than that.
2. an `ADMIN_API_KEY=` line already saved in `backend/.env` by a previous launch;
3. otherwise a random one is generated on the first launch, printed to the console and
appended to `backend/.env` so it stays the same on later launches.
In Docker the environment variable has to be forwarded by the compose file
(`ADMIN_API_KEY: ${ADMIN_API_KEY:-}` under `backend.environment`) — a value sitting in
the project's `.env` that no compose line references never reaches the container.
You'll also need a running Ollama instance (see `AI_SERVICE_HOST`/`AI_SERVICE_PORT` in
the admin panel's Connections tab, or `PATCH /api/admin/settings` directly) with at
@@ -95,8 +104,8 @@ real RSS parsing, real HTTP calls to a stub Ollama server, real media download t
disk, real tag dedup across separate synthesis calls):
- SQLite schema + repository layer for every entity in `homefeed-data-schema.md`
- Per-launch API key auth (random key printed to the console on every startup,
checked via a timing-safe comparison against an `X-Api-Key` header on every
- Admin API key auth (your own `ADMIN_API_KEY`, or a random one generated once and
persisted, checked via a timing-safe comparison against an `X-Api-Key` header on every
request, CORS locked to the configured frontend origin) protecting all
`/api/admin/*` routes
- RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter
+39 -21
View File
@@ -1,49 +1,67 @@
// Persisted across restarts, not just generated once per process start. Resolution
// order: an explicit ADMIN_API_KEY env var (set directly, e.g. in Docker, where
// there's no local .env file to write back to) wins outright; otherwise an existing
// key already written to backend/.env from a previous launch is reused; only a truly
// first-ever launch (no env var, no key in .env yet) generates a new random one and
// writes it back so every later launch finds it via the second branch. Still
// console/log-only to *learn* the key (see index.ts's startup banner) — the
// difference from the old every-restart-regenerates design is durability across
// restarts, not how you retrieve it.
// The admin API key is the only credential protecting /api/admin/*. Resolution order:
//
// 1. An explicit ADMIN_API_KEY environment variable wins outright. This is how you pick
// your own password - set it in docker-compose/.env (or Compose Manager) to any string
// and that is the key, unchanged across restarts and recreates. In Docker there is no
// local .env to write back to, so this is the only way the key can survive a recreate.
// 2. Otherwise a key already written to backend/.env by a previous launch is reused.
// 3. Only a truly first-ever launch (neither of the above) generates a random one and
// writes it back, so every later launch finds it via branch 2.
//
// Branches 2 and 3 still print the key at startup (see index.ts's banner) - that is the only
// way to learn a key you didn't choose. Branch 1 doesn't: you already know it, and echoing a
// password into the container log would just leave a copy in `docker logs`.
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
import path from 'node:path';
// Matches where backend/.env.example already lives and where this app's own
// documented launch command (`node --env-file=.env dist/index.js`) reads from —
// documented launch command (`node --env-file=.env dist/index.js`) reads from -
// process.cwd() is the backend project root in every deployment that has a .env
// file at all. A container without a mounted/writable .env at this path (the
// PRIVATE_ACCESS_PASSWORD-style Docker deployment, which sets ADMIN_API_KEY as a
// real environment variable instead) never reaches the write path below, since
// process.env.ADMIN_API_KEY is already set in that case.
// file at all. A container without a mounted/writable .env at this path never reaches
// the write path below when ADMIN_API_KEY is supplied as a real environment variable.
const ENV_FILE = path.resolve(process.cwd(), '.env');
const KEY_LINE_RE = /^ADMIN_API_KEY=(.+)$/m;
export type AdminApiKeySource = 'environment' | 'env-file' | 'generated';
function readExistingKey(): string | null {
if (!existsSync(ENV_FILE)) return null;
const match = readFileSync(ENV_FILE, 'utf8').match(KEY_LINE_RE);
return match ? match[1].trim() : null;
}
function loadOrCreateAdminApiKey(): string {
if (process.env.ADMIN_API_KEY) return process.env.ADMIN_API_KEY;
function loadOrCreateAdminApiKey(): { key: string; source: AdminApiKeySource } {
// Trimmed because a value pasted into a compose or env editor routinely picks up a
// trailing space or newline, and then the password you typed never matches the one the
// server holds - which looks exactly like the server ignoring the setting. A password
// that genuinely starts or ends with whitespace isn't something worth supporting.
const fromEnvironment = process.env.ADMIN_API_KEY?.trim();
if (fromEnvironment) return { key: fromEnvironment, source: 'environment' };
const existing = readExistingKey();
if (existing) return existing;
if (existing) return { key: existing, source: 'env-file' };
const key = randomBytes(24).toString('hex');
try {
appendFileSync(ENV_FILE, `\nADMIN_API_KEY=${key}\n`);
} catch (err) {
// No writable .env here (e.g. a container without one mounted) — fall back to
// No writable .env here (e.g. a container without one mounted) - fall back to
// the old behavior for this run rather than crashing startup over it, but say
// so plainly since a silently-regenerating key is exactly what this exists to
// avoid.
console.warn(`[apiKey] Could not persist ADMIN_API_KEY to ${ENV_FILE} (${(err as Error).message}) — it will regenerate on next restart.`);
console.warn(`[apiKey] Could not persist ADMIN_API_KEY to ${ENV_FILE} (${(err as Error).message}) - it will regenerate on next restart. Set the ADMIN_API_KEY environment variable to pin it.`);
}
return key;
return { key, source: 'generated' };
}
export const ADMIN_API_KEY = loadOrCreateAdminApiKey();
const loaded = loadOrCreateAdminApiKey();
export const ADMIN_API_KEY = loaded.key;
export const ADMIN_API_KEY_SOURCE = loaded.source;
/**
* Nothing limits repeated wrong guesses on /api/admin/*, so a key someone chose by hand and
* keeps short is genuinely guessable. Only a startup warning - it's their call - but a
* random key from this file is 48 characters, which is the bar worth comparing against.
*/
export const MIN_RECOMMENDED_KEY_LENGTH = 16;
+18 -4
View File
@@ -1,5 +1,5 @@
import { migrate } from './storage/db/index.js';
import { ADMIN_API_KEY } from './api/apiKey.js';
import { ADMIN_API_KEY, ADMIN_API_KEY_SOURCE, MIN_RECOMMENDED_KEY_LENGTH } from './api/apiKey.js';
import { privateAccessConfigured } from './api/privateAccess.js';
import { startScheduler } from './queue/scheduler.js';
import { initFromSavedSession } from './telegram/client.js';
@@ -8,15 +8,29 @@ import { loadAllWidgets } from './widgets/registry.js';
import { reloadServerRoutes } from './server.js';
function printApiKeyBanner() {
if (ADMIN_API_KEY_SOURCE === 'environment') {
// You chose this value, so you already know it - printing it would only leave a copy of
// your password in `docker logs`, readable by anyone who can read the host's containers.
console.log(`[auth] Admin API key loaded from the ADMIN_API_KEY environment variable (${ADMIN_API_KEY.length} characters) - not printed.`);
if (ADMIN_API_KEY.length < MIN_RECOMMENDED_KEY_LENGTH) {
console.warn(
`[auth] WARNING: that key is only ${ADMIN_API_KEY.length} characters. Nothing limits repeated wrong guesses on /api/admin/*, ` +
`so use at least ${MIN_RECOMMENDED_KEY_LENGTH} - a long random string is best.`
);
}
return;
}
const line = '='.repeat(64);
// Deliberately console.log, not the DB-backed logger — the Logs tab in the admin
// Deliberately console.log, not the DB-backed logger - the Logs tab in the admin
// panel is itself behind this key, so printing there would be unreachable until
// you already have the key. This is the one and only place it's ever surfaced.
console.log(`\n${line}`);
console.log(' Homefeed admin API key (required for every /api/admin/* request)');
console.log(` ${ADMIN_API_KEY}`);
console.log(' Generated once and persisted to backend/.env — it stays the same across');
console.log(' restarts. Delete the ADMIN_API_KEY line from .env to force a new one.');
console.log(' Generated once and persisted to backend/.env - it stays the same across');
console.log(' restarts. Delete the ADMIN_API_KEY line from .env to force a new one, or');
console.log(' set the ADMIN_API_KEY environment variable to choose your own password.');
console.log(`${line}\n`);
}
+29 -4
View File
@@ -75,6 +75,11 @@ async function publishItemsDirect(
): Promise<number> {
let published = 0;
for (const item of items) {
// `items` is a snapshot taken when this cycle started, and publishing awaits between
// items - so by the time we reach one, another cycle (see runPassthroughCycle) may
// already have published it. Claim it first; the claim is synchronous, so two cycles
// can never both get past this point for the same item.
if (!claimForDirectPublish(item.id)) continue;
try {
const eventId = claimedEventId(item, activeEvents) ?? undefined;
const article = await publishDirect(item, settings, { eventId, embeddingProvider });
@@ -83,21 +88,41 @@ async function publishItemsDirect(
logger.info('synthesis', `Published "${article.title}" directly (${describeSuccess(item)})`);
} catch (err) {
logger.error('synthesis', `${failureLabel} for "${item.title}": ${(err as Error).message}`);
} finally {
directPublishInFlight.delete(item.id);
}
}
return published;
}
// Items a direct-publish pass is working on right now. Module state rather than anything
// persisted: it only has to bridge the awaits inside one process, and assignCluster (below)
// is what makes the answer permanent for every later cycle.
const directPublishInFlight = new Set<string>();
/**
* True if the caller now owns publishing this item. False when another pass is mid-publish
* on it, or already finished it since the caller's snapshot was taken (re-read from the DB,
* since the snapshot's own cluster_id is stale by definition).
*/
function claimForDirectPublish(itemId: string): boolean {
if (directPublishInFlight.has(itemId)) return false;
if (!contentItemsDb.isUnclustered(itemId)) return false;
directPublishInFlight.add(itemId);
return true;
}
/**
* Fallback for when the AI service isn't reachable yet — publishes every eligible item
* immediately rather than leaving pages empty until Ollama is set up. Unlike the AI
* pipeline, this doesn't wait out the hold-before-publish window: that window exists to
* give corroborating sources time to arrive before an AI merge locks in, which doesn't
* apply here since there's no merging happening at all — each item is just itself.
* Still respects category priority. Harmless overlap with runDirectPublishCycle (which
* runs regardless of reachability) — an item already published by one is simply gone
* from the other's next "unclustered" query, since assignCluster lands before either
* moves on to its next item.
* Still respects category priority. Overlaps with runDirectPublishCycle (which runs
* regardless of reachability) on the items both consider theirs: each works from its own
* snapshot of the unclustered list, so without publishItemsDirect's per-item claim they
* published the same item twice - the duplicate articles seen on RSS feeds in categories
* with AI turned off.
*/
export async function runPassthroughCycle(settings: GlobalSettings): Promise<number> {
const activeEvents = eventsDb.listActiveEvents();
+6
View File
@@ -69,6 +69,12 @@ export function unclusteredItemsExcludingSources(excludeSourceIds: string[]): Co
return items.filter((i) => !excludeSourceIds.includes(i.sourceId));
}
/** Live check (not a snapshot) that nothing has published or clustered this item yet. */
export function isUnclustered(id: string): boolean {
const row = db.prepare('SELECT cluster_id FROM content_items WHERE id = ?').get(id) as { cluster_id: string | null } | undefined;
return row !== undefined && row.cluster_id === null;
}
export function setEmbedding(id: string, embedding: number[]) {
db.prepare('UPDATE content_items SET embedding = ? WHERE id = ?').run(JSON.stringify(embedding), id);
}
+555 -14
View File
@@ -3,6 +3,8 @@
// the only file that talks to it; poller.ts orchestrates when/how the result gets saved,
// same separation as backend/src/telegram/ keeps between the raw client and its callers.
import type { ConditionDetails, ConditionKind } from './db.js';
export interface GeocodeResult {
name: string;
admin1: string | null;
@@ -70,6 +72,19 @@ const NIGHT_CONDITIONS: Record<number, WeatherCondition> = {
81: { text: 'Moderate rain showers', icon: '🌧️' }
};
/** See ConditionKind. Unknown codes read as cloudy, the safest neutral description. */
export function conditionKindOfCode(code: number): ConditionKind {
if (code <= 1) return 'clear';
if (code === 2) return 'partly';
if (code === 45 || code === 48) return 'fog';
if (code === 51 || code === 53 || code === 55 || code === 61 || code === 80) return 'light-rain';
if (code === 63 || code === 65 || code === 81 || code === 82) return 'rain';
if (code === 56 || code === 57 || code === 66 || code === 67) return 'freezing';
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return 'snow';
if (code === 95 || code === 96 || code === 99) return 'storm';
return 'cloudy';
}
/**
* `isDay` defaults to true so a caller with no day/night context — notably the daily
* forecast, which summarises a whole day and should read "Sunny", never "Clear 🌙" —
@@ -477,7 +492,7 @@ export async function geocodeLocation(query: string): Promise<GeocodeResult[]> {
}));
}
export interface CurrentConditions {
export interface CurrentConditions extends ConditionDetails {
temp: number;
feelsLike: number;
conditionText: string;
@@ -497,9 +512,12 @@ export interface CurrentConditions {
export interface ForecastResult {
current: CurrentConditions;
hourly: {
hourly: (ConditionDetails & {
time: string;
temp: number;
/** Apparent temperature, in the caller's configured temperature unit. */
feelsLike: number;
conditionKind: ConditionKind;
conditionText: string;
icon: string;
/** Percent, 0-100. */
@@ -516,8 +534,25 @@ export interface ForecastResult {
precipAmount: number;
/** True when precipAmount is snow depth rather than rainfall. */
precipIsSnow: boolean;
})[];
daily: {
date: string;
tempMax: number;
/** The calendar day's minimum, kept as the fallback for nightLow when too little of the night is in the forecast. */
tempMin: number;
/** Short daytime description composed from the hourly series, e.g. "Mostly cloudy with a shower". */
conditionText: string;
icon: string;
/** Short description of the night that FOLLOWS this day (dusk to the next dawn), e.g. "A couple of evening showers". */
nightDescription: string;
nightIcon: string;
/** Lowest temperature of that night - AccuWeather's "low" - or null when under 6 night hours are in the forecast (the last day), in which case tempMin stands in. */
nightLow: number | null;
/** Percent, 0-100: the day's maximum hourly chance of precipitation. */
precipitationChance: number | null;
/** Same, over that night's hours alone - what a "Tonight" row shows once the day itself is over. */
nightPrecipitationChance: number | null;
}[];
daily: { date: string; tempMax: number; tempMin: number; conditionText: string; icon: string }[];
/** A plain-English wrap-up ("Current conditions in X are 86°F and sunny with a light northwest wind at 5 mph. Tonight will turn...") — composed from the same numbers above, not a separate API call. Null if there isn't enough data to build one (e.g. tomorrow's forecast missing). */
summary: string | null;
}
@@ -669,6 +704,408 @@ function buildDaySummary(
.join(' ');
}
// ---------------------------------------------------------------------------
// Indoor humidity
// ---------------------------------------------------------------------------
// AccuWeather shows an "Indoor Humidity" percentage and an 11-step description of it
// ("Dangerously Dry" ... "Ideal Humidity" ... "Dangerously Humid"), but publishes neither
// the thresholds nor the method. Both below were reverse-engineered from 28 live readings
// taken off their current-weather pages (Antarctica to Singapore) and then checked against
// 6 more cities that were NOT used in the fit: all 34 descriptions match and every
// percentage is within 2 points (33 of 34 within 1). Treat them as a close approximation of
// AccuWeather's, not a copy of it.
//
// What those readings showed:
//
// 1. The PERCENTAGE is the outdoor air brought to a fixed 68°F without gaining or losing
// moisture. Outdoors already 68°F or warmer, indoor simply equals outdoor RH (all 20
// warm readings matched exactly); colder, it is the RH that the outdoor dew point
// implies at 68°F (Philadelphia 63%, Seattle 49%, Moscow 37%, Atlanta 72% - each within
// a point). Below roughly a 34-39°F dew point the real figures run ~14 points HIGHER than
// that, i.e. they allow for moisture people add indoors; INDOOR_MOISTURE_GAIN_* is an
// empirical fit to the 8 readings in that regime (each within 2 points, and Calgary and
// Edmonton, held out, within 1) and tapers to nothing by a 39°F dew point, because
// readings at 41°F and up showed no allowance at all.
//
// 2. The DESCRIPTION is a function of the outdoor dew point alone - NOT of the percentage.
// Minneapolis at 41% is "Ideal" while Reykjavik at 42% is "Slightly Dry"; sorting every
// reading by dew point instead gives one clean monotonic ladder (see the scale below).
const INDOOR_TEMP_C = 20; // 68°F
const INDOOR_MOISTURE_GAIN_HPA = 3.3;
const INDOOR_GAIN_FULL_AT_DEW_C = 1.1; // 34°F
const INDOOR_GAIN_NONE_AT_DEW_C = 3.9; // 39°F
const INDOOR_GAIN_SATURATION_HPA = 1.4;
function toCelsius(value: number, unit: 'celsius' | 'fahrenheit'): number {
return unit === 'celsius' ? value : ((value - 32) * 5) / 9;
}
/** Magnus approximation, in hPa. */
function saturationVaporPressure(tempC: number): number {
return 6.112 * Math.exp((17.62 * tempC) / (243.12 + tempC));
}
export function estimateIndoorHumidity(outdoorTempC: number, dewPointC: number, outdoorRh: number): number {
if (outdoorTempC >= INDOOR_TEMP_C) return Math.round(outdoorRh);
const outdoorVapor = saturationVaporPressure(dewPointC);
const taper = Math.min(
1,
Math.max(0, (INDOOR_GAIN_NONE_AT_DEW_C - dewPointC) / (INDOOR_GAIN_NONE_AT_DEW_C - INDOOR_GAIN_FULL_AT_DEW_C))
);
// The allowance also vanishes as the air gets bone dry (nothing to carry moisture through
// to), which is what keeps Antarctic readings from being credited the full amount.
const gain = INDOOR_MOISTURE_GAIN_HPA * taper * (1 - Math.exp(-outdoorVapor / INDOOR_GAIN_SATURATION_HPA));
const percent = (100 * (outdoorVapor + gain)) / saturationVaporPressure(INDOOR_TEMP_C);
return Math.round(Math.min(100, Math.max(0, percent)));
}
/**
* Upper-exclusive cut points on the outdoor dew point in °F. Each cut sits inside the bracket
* the live readings pinned it to: the narrow ones are good to about ±1°F (Slightly Dry/Ideal at
* 39-41, Ideal/Slightly Humid at 55-57, Slightly Humid/Humid at 62-64, Very/Extremely Humid at
* 71-72) and the wide ones are midpoints of their bracket (Dangerously/Extremely Dry between
* -24 and -1, Extremely/Very Dry between -1 and 10, Very Dry/Dry between 18 and 27, Humid/Very
* Humid between 64 and 67, Extremely/Dangerously Humid between 75 and 78).
*/
const INDOOR_HUMIDITY_SCALE: { belowDewF: number; label: string; level: number }[] = [
{ belowDewF: -12, label: 'Dangerously Dry', level: -5 },
{ belowDewF: 5, label: 'Extremely Dry', level: -4 },
{ belowDewF: 23, label: 'Very Dry', level: -3 },
{ belowDewF: 28.5, label: 'Dry', level: -2 },
{ belowDewF: 40, label: 'Slightly Dry', level: -1 },
{ belowDewF: 56, label: 'Ideal Humidity', level: 0 },
{ belowDewF: 63, label: 'Slightly Humid', level: 1 },
{ belowDewF: 66, label: 'Humid', level: 2 },
{ belowDewF: 71.5, label: 'Very Humid', level: 3 },
{ belowDewF: 76.5, label: 'Extremely Humid', level: 4 },
{ belowDewF: Infinity, label: 'Dangerously Humid', level: 5 }
];
export function describeIndoorHumidity(dewPointC: number): { label: string; level: number } {
const dewF = (dewPointC * 9) / 5 + 32;
const step = INDOOR_HUMIDITY_SCALE.find((s) => dewF < s.belowDewF) ?? INDOOR_HUMIDITY_SCALE[INDOOR_HUMIDITY_SCALE.length - 1];
return { label: step.label, level: step.level };
}
/** Open-Meteo reports metres; show miles for mph users and kilometres for kph users, one decimal while it's short enough to matter. */
function formatVisibility(meters: number | null | undefined, windUnit: 'mph' | 'kph'): number | null {
if (meters === null || meters === undefined) return null;
const value = windUnit === 'mph' ? meters / 1609.344 : meters / 1000;
return value < 10 ? Math.round(value * 10) / 10 : Math.round(value);
}
// ---------------------------------------------------------------------------
// Cloud ceiling
// ---------------------------------------------------------------------------
// Open-Meteo has a `cloud_base` variable but returns null for every model at every location
// (checked against 13 models), so there is no ceiling to be had from it. The National
// Weather Service's gridpoint forecast does carry a real ceilingHeight series, in coarse
// blocks (anywhere from 1 to 8 hours), US only. Used where it exists; elsewhere the
// ceiling is simply null and the page leaves the row out rather than guessing — the usual
// temperature/dew-point "lifting condensation level" estimate describes convective cloud
// bases and would contradict a real ceiling at higher altitudes (it works out to roughly 1,800 ft for a
// 63°F/55°F day where AccuWeather reported 26,700 ft under a high overcast).
interface CeilingBlock {
startMs: number;
endMs: number;
feet: number | null;
}
const NWS_HEADERS = {
// NWS's API usage policy requires an identifying User-Agent on every request.
'User-Agent': 'Homefeed/1.0 (self-hosted news aggregator)',
Accept: 'application/geo+json'
};
// A location's grid cell never changes, so the points lookup only needs doing once per process.
const nwsGridUrlCache = new Map<string, string>();
/** ISO-8601 duration limited to the day/hour/minute forms NWS actually emits (P1DT6H, PT8H, PT30M). */
function isoDurationMs(duration: string): number {
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/.exec(duration);
if (!m) return 0;
return (Number(m[1] ?? 0) * 24 * 60 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) * 60_000;
}
/**
* Never throws: the ceiling is a nice-to-have, and NWS answering 404 for anywhere outside the
* US is the normal case, not a fault. Any failure just yields no blocks, so every hour's
* ceiling comes out null and the forecast itself is unaffected.
*/
async function fetchCeilingBlocks(latitude: number, longitude: number): Promise<CeilingBlock[]> {
try {
// NWS redirects coordinates with more than 4 decimal places, so trim them up front.
const key = `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
let gridUrl = nwsGridUrlCache.get(key);
if (!gridUrl) {
const points = await fetch(`https://api.weather.gov/points/${key}`, {
headers: NWS_HEADERS,
signal: AbortSignal.timeout(8000)
});
if (!points.ok) return [];
gridUrl = ((await points.json()) as { properties?: { forecastGridData?: string } }).properties?.forecastGridData;
if (!gridUrl) return [];
nwsGridUrlCache.set(key, gridUrl);
}
const grid = await fetch(gridUrl, { headers: NWS_HEADERS, signal: AbortSignal.timeout(8000) });
if (!grid.ok) return [];
const values = ((await grid.json()) as { properties?: { ceilingHeight?: { values?: { validTime: string; value: number | null }[] } } })
.properties?.ceilingHeight?.values;
return (values ?? []).flatMap((v) => {
const [start, duration] = v.validTime.split('/');
const startMs = new Date(start).getTime();
if (Number.isNaN(startMs)) return [];
return [
{
startMs,
endMs: startMs + isoDurationMs(duration ?? ''),
// metres -> feet, to the nearest 100: the source steps in coarse increments anyway
feet: v.value === null ? null : Math.round((v.value * 3.28084) / 100) * 100
}
];
});
} catch {
return [];
}
}
function ceilingAt(blocks: CeilingBlock[], atMs: number): number | null {
return blocks.find((b) => atMs >= b.startMs && atMs < b.endMs)?.feet ?? null;
}
// ---------------------------------------------------------------------------
// Day and night descriptions for the 7-day list
// ---------------------------------------------------------------------------
// AccuWeather describes each day in a short phrase ("Mostly cloudy with a shower", "A couple
// of morning showers") and each night separately ("Night: Cloudy, a couple of t-storms"). The
// daily weather_code can't say that - it names only the single worst condition of the whole
// 24 hours - so both phrases are composed from the hourly series for that period instead:
// cloud cover picks the sky word, and the wet hours decide whether, how much, and when.
type PrecipFamily =
| 'drizzle'
| 'freezing drizzle'
| 'showers'
| 'rain'
| 'freezing rain'
| 'snow showers'
| 'snow'
| 'thunderstorms';
const WMO_PRECIP: Record<number, { family: PrecipFamily; heavy: boolean }> = {
51: { family: 'drizzle', heavy: false },
53: { family: 'drizzle', heavy: false },
55: { family: 'drizzle', heavy: false },
56: { family: 'freezing drizzle', heavy: false },
57: { family: 'freezing drizzle', heavy: false },
61: { family: 'rain', heavy: false },
63: { family: 'rain', heavy: false },
65: { family: 'rain', heavy: true },
66: { family: 'freezing rain', heavy: false },
67: { family: 'freezing rain', heavy: true },
71: { family: 'snow', heavy: false },
73: { family: 'snow', heavy: false },
75: { family: 'snow', heavy: true },
77: { family: 'snow', heavy: false },
80: { family: 'showers', heavy: false },
81: { family: 'showers', heavy: false },
82: { family: 'showers', heavy: true },
85: { family: 'snow showers', heavy: false },
86: { family: 'snow showers', heavy: true },
95: { family: 'thunderstorms', heavy: false },
96: { family: 'thunderstorms', heavy: false },
99: { family: 'thunderstorms', heavy: false }
};
/**
* `singular` is null for mass nouns ("rain", "snow"), which take "a little ..." / "periods of
* ..." rather than "a ..." / "a couple of ...". `code` is the WMO code whose icon represents
* the family (and `heavyCode` its heavy form), so the row's icon always agrees with its text.
*/
const PRECIP_NOUNS: Record<PrecipFamily, { singular: string | null; plural: string; code: number; heavyCode: number }> = {
drizzle: { singular: null, plural: 'drizzle', code: 51, heavyCode: 51 },
'freezing drizzle': { singular: null, plural: 'freezing drizzle', code: 56, heavyCode: 56 },
showers: { singular: 'shower', plural: 'showers', code: 80, heavyCode: 82 },
rain: { singular: null, plural: 'rain', code: 61, heavyCode: 65 },
'freezing rain': { singular: null, plural: 'freezing rain', code: 66, heavyCode: 67 },
'snow showers': { singular: 'snow shower', plural: 'snow showers', code: 85, heavyCode: 86 },
snow: { singular: null, plural: 'snow', code: 71, heavyCode: 75 },
thunderstorms: { singular: 'thunderstorm', plural: 'thunderstorms', code: 95, heavyCode: 95 }
};
const SKY_WORDS_DAY = ['Sunny', 'Mostly sunny', 'Partly sunny', 'Mostly cloudy', 'Cloudy'];
const SKY_WORDS_NIGHT = ['Clear', 'Mostly clear', 'Partly cloudy', 'Mostly cloudy', 'Cloudy'];
// One WMO code per sky bucket, so the dry icon comes from the same lookup (and the same
// night variants) as everywhere else.
const SKY_BUCKET_CODES = [0, 1, 2, 3, 3];
interface PeriodHour {
hourOfDay: number;
code: number;
cloud: number;
/** Percent chance of precipitation this hour, when the forecast has one. */
chance?: number | null;
}
/**
* At or above this chance an hour is described as wet even when the model's single
* deterministic run is dry there. AccuWeather's own wording follows the probability, not the
* model run: its 10-day list says "an afternoon shower" at 55% and "a couple of morning
* showers" at 72%, but plain sky wording at 25% and below. Without this, a row could read
* "Partly sunny" right beside a 56% chance of precipitation.
*/
const LIKELY_PRECIP_CHANCE = 50;
function precipKindOf(hour: PeriodHour): { family: PrecipFamily; heavy: boolean } | null {
const coded = WMO_PRECIP[hour.code];
if (coded) return coded;
return (hour.chance ?? 0) >= LIKELY_PRECIP_CHANCE ? { family: 'showers', heavy: false } : null;
}
const SEGMENT_ADJECTIVE: Record<DaySegment, string> = {
overnight: 'overnight',
morning: 'morning',
afternoon: 'afternoon',
evening: 'evening'
};
const SEGMENT_TRAILING: Record<DaySegment, string> = {
overnight: 'overnight',
morning: 'in the morning',
afternoon: 'in the afternoon',
evening: 'in the evening'
};
function withIndefiniteArticle(phrase: string): string {
return /^[aeiou]/i.test(phrase) ? `an ${phrase}` : `a ${phrase}`;
}
/**
* How much of the period was wet, said the way a forecaster would: a count of discrete
* showers/storms for the countable families, a duration for the rest.
*/
function quantifyPrecip(family: PrecipFamily, wetHours: number, totalHours: number, heavy: boolean, timing: DaySegment | null): string {
const noun = PRECIP_NOUNS[family];
const heavyWord = heavy && family !== 'thunderstorms' ? 'heavy ' : '';
const fraction = wetHours / totalHours;
// One or two brief spells read best with the time folded in as an adjective:
// "an afternoon shower", "a couple of morning showers", "a little overnight rain".
if (timing && (noun.singular ? wetHours <= 3 : wetHours <= 2)) {
const when = SEGMENT_ADJECTIVE[timing];
if (noun.singular) {
return wetHours === 1
? withIndefiniteArticle(`${when} ${heavyWord}${noun.singular}`)
: `a couple of ${when} ${heavyWord}${noun.plural}`;
}
return `a little ${when} ${heavyWord}${noun.plural}`;
}
let core: string;
if (noun.singular) {
core = wetHours === 1 ? `a ${heavyWord}${noun.singular}` : wetHours <= 3 ? `a couple of ${heavyWord}${noun.plural}` : `${heavyWord}${noun.plural}`;
} else {
core = wetHours <= 2 ? `a little ${heavyWord}${noun.plural}` : fraction < 0.6 ? `periods of ${heavyWord}${noun.plural}` : `${heavyWord}${noun.plural}`;
}
return timing ? `${core} ${SEGMENT_TRAILING[timing]}` : core;
}
function capitalizeFirst(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
/**
* Null for an empty period. `isDay` only changes the vocabulary ("Sunny" vs "Clear") and which
* icon variants are used; which hours belong to the period is the caller's decision.
*/
export function describePeriod(hours: PeriodHour[], isDay: boolean): { text: string; icon: string } | null {
if (hours.length === 0) return null;
const total = hours.length;
const meanCloud = hours.reduce((sum, h) => sum + h.cloud, 0) / total;
// Eighths of the sky, the same cut-points forecasters use for sunny/partly/mostly/cloudy.
const bucket = meanCloud < 12.5 ? 0 : meanCloud < 37.5 ? 1 : meanCloud < 62.5 ? 2 : meanCloud < 87.5 ? 3 : 4;
const sky = (isDay ? SKY_WORDS_DAY : SKY_WORDS_NIGHT)[bucket];
const wet = hours.filter((h) => precipKindOf(h) !== null);
if (wet.length === 0) {
return { text: sky, icon: wmoToCondition(SKY_BUCKET_CODES[bucket], isDay).icon };
}
const counts = new Map<PrecipFamily, number>();
let heavyHours = 0;
for (const h of wet) {
const kind = precipKindOf(h)!;
counts.set(kind.family, (counts.get(kind.family) ?? 0) + 1);
if (kind.heavy) heavyHours++;
}
const count = (family: PrecipFamily) => counts.get(family) ?? 0;
// A day with both rain and snow in it is a mix, not whichever happened to win by an hour.
const snowy = count('snow') + count('snow showers');
const rainy = count('rain') + count('showers') + count('drizzle');
const isMix = snowy > 0 && rainy > 0;
// Models flip between drizzle, rain and showers codes hour to hour within one wet spell, so
// how MUCH it rained comes from the whole class of like precipitation, not just the single
// code that happened to be most common - otherwise 4 drizzle + 3 rain + 2 shower hours
// would be described as "periods of drizzle" and undercounted at 4.
const classOf: Record<PrecipFamily, string> = {
drizzle: 'liquid',
showers: 'liquid',
rain: 'liquid',
'snow showers': 'snow',
snow: 'snow',
'freezing drizzle': 'freezing',
'freezing rain': 'freezing',
thunderstorms: 'thunder'
};
const classHours = new Map<string, number>();
for (const [family, n] of counts) classHours.set(classOf[family], (classHours.get(classOf[family]) ?? 0) + n);
// Severity breaks ties, so a 50/50 day reads "thunderstorms", not "showers".
const severity: PrecipFamily[] = ['drizzle', 'showers', 'rain', 'snow showers', 'snow', 'freezing drizzle', 'freezing rain', 'thunderstorms'];
const bySize = (a: [PrecipFamily, number], b: [PrecipFamily, number]) =>
(classHours.get(classOf[b[0]]) ?? 0) - (classHours.get(classOf[a[0]]) ?? 0) || b[1] - a[1] || severity.indexOf(b[0]) - severity.indexOf(a[0]);
const primary = [...counts.entries()].sort(bySize)[0][0];
const primaryHours = isMix ? wet.length : (classHours.get(classOf[primary]) ?? 0);
const heavy = heavyHours / wet.length >= 0.4;
// Timing is only worth stating when ALL the wet hours fall in one part of the day.
const segments = new Set(wet.map((h) => segmentOfHour(h.hourOfDay)));
const timing = segments.size === 1 ? [...segments][0] : null;
const lead = isMix ? 'rain and snow' : null;
let main: string;
if (lead) {
main = `${wet.length <= 2 ? 'a little ' : primaryHours / total < 0.6 ? 'periods of ' : ''}${lead}${timing ? ` ${SEGMENT_TRAILING[timing]}` : ''}`;
} else {
main = quantifyPrecip(primary, primaryHours, total, heavy, timing);
}
// Anything dangerous that isn't the headline still gets named.
const extras: string[] = [];
if (!isMix && primary !== 'thunderstorms' && count('thunderstorms') > 0) {
const t = count('thunderstorms');
extras.push(t === 1 ? 'a thunderstorm' : t <= 3 ? 'a couple of thunderstorms' : 'thunderstorms');
}
if (primary !== 'freezing rain' && primary !== 'freezing drizzle' && count('freezing rain') + count('freezing drizzle') > 0) {
extras.push(count('freezing rain') + count('freezing drizzle') <= 2 ? 'a little freezing rain' : 'freezing rain');
}
const withExtras = [main, ...extras].join(' and ');
// A spell that fills most of the period is the whole story - no sky word needed. A timed
// one is too ("A couple of morning showers"). Otherwise it's weather laid over a sky.
const steady = primaryHours / total >= 0.6;
const text = timing || steady ? capitalizeFirst(withExtras) : `${sky} with ${withExtras}`;
// Thunder trumps everything for the icon; otherwise the headline family, in its heavy
// form when most of the wet hours were heavy.
const iconCode = count('thunderstorms') > 0 ? 95 : isMix ? 61 : heavy ? PRECIP_NOUNS[primary].heavyCode : PRECIP_NOUNS[primary].code;
return { text, icon: wmoToCondition(iconCode, isDay).icon };
}
export async function fetchForecast(
latitude: number,
longitude: number,
@@ -680,18 +1117,20 @@ export async function fetchForecast(
): Promise<ForecastResult> {
const url =
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
`&current=temperature_2m,apparent_temperature,weather_code,is_day,precipitation,snowfall,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` +
`&current=temperature_2m,apparent_temperature,weather_code,is_day,precipitation,snowfall,relative_humidity_2m,dew_point_2m,wind_speed_10m,wind_gusts_10m,wind_direction_10m,pressure_msl,cloud_cover,visibility` +
// humidity/wind/pressure are per-hour duplicates of what `current` already reports for
// right now — the weather page's stat panel swaps to the hovered hour's values, so it
// needs the whole series, not just the current hour's.
`&hourly=temperature_2m,weather_code,is_day,precipitation,snowfall,precipitation_probability,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` +
`&hourly=temperature_2m,apparent_temperature,weather_code,is_day,precipitation,snowfall,precipitation_probability,relative_humidity_2m,dew_point_2m,wind_speed_10m,wind_gusts_10m,wind_direction_10m,pressure_msl,cloud_cover,visibility` +
`&daily=temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset,precipitation_probability_max,rain_sum,showers_sum,snowfall_sum` +
// precipitation_unit affects amount-based fields (snowfall_sum) — precipitation_probability_max is a
// percentage either way, unaffected. "inch" here regardless of the admin's chosen temperature/wind
// units — amounts follow the admin's precipUnit, snowfall arriving as cm when metric.
`&precipitation_unit=${precipUnit === 'in' ? 'inch' : 'mm'}` +
`&temperature_unit=${unit}&wind_speed_unit=${windUnit === 'kph' ? 'kmh' : 'mph'}&timezone=auto&forecast_days=7`;
const res = await fetch(url);
`&temperature_unit=${unit}&wind_speed_unit=${windUnit === 'kph' ? 'kmh' : 'mph'}&timezone=auto&forecast_days=8`;
// The ceiling lookup never throws (see fetchCeilingBlocks), so it can't fail the forecast
// it is fetched alongside.
const [res, ceilingBlocks] = await Promise.all([fetch(url), fetchCeilingBlocks(latitude, longitude)]);
if (!res.ok) throw new Error(`Forecast API returned ${res.status}`);
const data = (await res.json()) as {
utc_offset_seconds: number;
@@ -703,22 +1142,31 @@ export async function fetchForecast(
precipitation: number;
snowfall: number;
relative_humidity_2m: number;
dew_point_2m: number;
wind_speed_10m: number;
wind_gusts_10m: number;
wind_direction_10m: number;
pressure_msl: number;
cloud_cover: number;
visibility: number | null;
};
hourly: {
time: string[];
temperature_2m: number[];
apparent_temperature: number[];
weather_code: number[];
is_day: number[];
precipitation: number[];
snowfall: number[];
precipitation_probability: number[];
relative_humidity_2m: number[];
dew_point_2m: number[];
wind_speed_10m: number[];
wind_gusts_10m: number[];
wind_direction_10m: number[];
pressure_msl: number[];
cloud_cover: number[];
visibility: (number | null)[];
};
daily: {
time: string[];
@@ -758,6 +1206,31 @@ export async function fetchForecast(
const todayIdx = Math.max(0, data.daily.time.indexOf(todayStr));
const tomorrowIdx = todayIdx + 1;
// One builder for both "right now" and each hour, so the two can never drift apart — the
// weather page renders whichever one is being pointed at through a single code path.
const detailsFor = (
tempValue: number,
dewValue: number,
humidity: number,
windGust: number,
cloudCover: number,
visibilityMeters: number | null | undefined,
atMs: number
): ConditionDetails => {
const dewC = toCelsius(dewValue, unit);
const indoor = describeIndoorHumidity(dewC);
return {
windGust,
cloudCover,
visibility: formatVisibility(visibilityMeters, windUnit),
dewPoint: dewValue,
indoorHumidity: estimateIndoorHumidity(toCelsius(tempValue, unit), dewC, humidity),
indoorHumidityLabel: indoor.label,
indoorHumidityLevel: indoor.level,
cloudCeilingFt: ceilingAt(ceilingBlocks, atMs)
};
};
const currentCondition = wmoToCondition(data.current.weather_code, data.current.is_day === 1);
const pressure = pressureUnit === 'inHg' ? hPaToInHg(data.current.pressure_msl) : data.current.pressure_msl;
const current: CurrentConditions = {
@@ -774,6 +1247,15 @@ export async function fetchForecast(
windSpeed: data.current.wind_speed_10m,
windDirection: degreesToCompass(data.current.wind_direction_10m),
pressure: pressureUnit === 'inHg' ? Math.round(pressure * 100) / 100 : Math.round(pressure),
...detailsFor(
data.current.temperature_2m,
data.current.dew_point_2m,
data.current.relative_humidity_2m,
data.current.wind_gusts_10m,
data.current.cloud_cover,
data.current.visibility,
now
),
sunrise: withUtcOffset(data.daily.sunrise[todayIdx], data.utc_offset_seconds),
sunset: withUtcOffset(data.daily.sunset[todayIdx], data.utc_offset_seconds)
};
@@ -790,6 +1272,8 @@ export async function fetchForecast(
return {
time,
temp: data.hourly.temperature_2m[idx],
feelsLike: data.hourly.apparent_temperature[idx],
conditionKind: conditionKindOfCode(data.hourly.weather_code[idx]),
conditionText: condition.text,
icon: condition.icon,
humidity: data.hourly.relative_humidity_2m[idx],
@@ -798,18 +1282,75 @@ export async function fetchForecast(
precipIsSnow: (data.hourly.snowfall[idx] ?? 0) > 0,
windSpeed: data.hourly.wind_speed_10m[idx],
windDirection: degreesToCompass(data.hourly.wind_direction_10m[idx]),
pressure: pressureUnit === 'inHg' ? Math.round(hourPressure * 100) / 100 : Math.round(hourPressure)
pressure: pressureUnit === 'inHg' ? Math.round(hourPressure * 100) / 100 : Math.round(hourPressure),
...detailsFor(
data.hourly.temperature_2m[idx],
data.hourly.dew_point_2m[idx],
data.hourly.relative_humidity_2m[idx],
data.hourly.wind_gusts_10m[idx],
data.hourly.cloud_cover[idx],
data.hourly.visibility[idx],
new Date(time).getTime()
)
};
});
const daily = data.daily.time.map((date, i) => {
const condition = wmoToCondition(data.daily.weather_code[i]);
// Per-day descriptions come from the hourly series, split into the day proper (daylight hours)
// and the night that follows it (last light to the next first light). Daylight comes from
// Open-Meteo's own is_day flag, so the split tracks the season and latitude by itself.
const hourDates = hourlyTimes.map((t) => t.slice(0, 10));
const hourOfDayAt = (i: number) => Number(hourlyTimes[i].slice(11, 13));
const indicesForDate = (date: string) => hourDates.flatMap((d, i) => (d === date ? [i] : []));
const periodHours = (indices: number[]): PeriodHour[] =>
indices.map((i) => ({
hourOfDay: hourOfDayAt(i),
code: data.hourly.weather_code[i],
cloud: data.hourly.cloud_cover[i] ?? 0,
chance: data.hourly.precipitation_probability[i] ?? null
}));
// Starts at TODAY in the location's own calendar (todayIdx), not at whatever day the API
// anchored to, and runs seven days from there.
const daily = data.daily.time.slice(todayIdx, todayIdx + 7).map((date, k) => {
const d = todayIdx + k;
const dateIdxs = indicesForDate(date);
const nextDate = data.daily.time[d + 1];
const nextIdxs = nextDate ? indicesForDate(nextDate) : [];
let dayIdxs = dateIdxs.filter((i) => data.hourly.is_day[i] === 1);
// Polar night has no daylight hours at all; fall back to the middle of the day so the
// row still describes something rather than going blank.
if (dayIdxs.length === 0) dayIdxs = dateIdxs.filter((i) => hourOfDayAt(i) >= 9 && hourOfDayAt(i) <= 16);
const nightStart = dayIdxs.length > 0 ? dayIdxs[dayIdxs.length - 1] + 1 : (dateIdxs[0] ?? 0);
const nextFirstLight = nextIdxs.find((i) => data.hourly.is_day[i] === 1);
const nightEnd = nextFirstLight ?? (nextIdxs.length > 0 ? nextIdxs[0] + 6 : data.hourly.time.length);
let nightIdxs = Array.from({ length: Math.max(0, Math.min(nightEnd, data.hourly.time.length) - nightStart) }, (_, n) => nightStart + n);
// Midnight sun: it never gets dark, so the "night" would be empty. Use late evening to
// early morning instead.
if (nightIdxs.length === 0) {
nightIdxs = [...dateIdxs.filter((i) => hourOfDayAt(i) >= 22), ...nextIdxs.filter((i) => hourOfDayAt(i) <= 5)];
}
const fallback = wmoToCondition(data.daily.weather_code[d]);
const day = describePeriod(periodHours(dayIdxs), true) ?? fallback;
const night = describePeriod(periodHours(nightIdxs), false);
return {
date,
tempMax: data.daily.temperature_2m_max[i],
tempMin: data.daily.temperature_2m_min[i],
conditionText: condition.text,
icon: condition.icon
tempMax: data.daily.temperature_2m_max[d],
tempMin: data.daily.temperature_2m_min[d],
conditionText: day.text,
icon: day.icon,
nightDescription: night?.text ?? '',
nightIcon: night?.icon ?? '',
// A night cut short by the end of the forecast would report a low that's too high
// (the cold hours are the ones after midnight), so it isn't claimed.
nightLow: nightIdxs.length >= 6 ? Math.min(...nightIdxs.map((i) => data.hourly.temperature_2m[i])) : null,
precipitationChance: data.daily.precipitation_probability_max[d] ?? null,
nightPrecipitationChance: (() => {
const chances = nightIdxs.map((i) => data.hourly.precipitation_probability[i]).filter((c): c is number => c !== null && c !== undefined);
return chances.length > 0 ? Math.max(...chances) : null;
})()
};
});
+50 -3
View File
@@ -1,8 +1,44 @@
import { getKv, setKv } from '../../storage/db/widgetKv.js';
export interface WeatherHourEntry {
/**
* The at-a-glance conditions an hour (or "right now") carries beyond temperature and
* wind — the same fields on both, so the weather page's header card can show either the
* live reading or whichever hour is being hovered with one rendering path.
*/
export interface ConditionDetails {
/** Already in the admin's configured windUnit. */
windGust: number;
/** Percent, 0-100. */
cloudCover: number;
/** Miles when windUnit is mph, kilometres when kph — so imperial and metric users each get the distance unit their wind speed already implies. */
visibility: number | null;
/** In the admin's configured temperature unit. */
dewPoint: number;
/** Estimated relative humidity indoors, percent — see indoorHumidity() in client.ts for how and how closely it tracks AccuWeather's figure. */
indoorHumidity: number;
/** One of the 11 descriptions from "Dangerously Dry" through "Ideal Humidity" to "Dangerously Humid". Driven by the outdoor DEW POINT, not by indoorHumidity — see INDOOR_HUMIDITY_SCALE. */
indoorHumidityLabel: string;
/** -5 (dangerously dry) .. 0 (ideal) .. +5 (dangerously humid), for colouring the label without the frontend re-deriving the scale. */
indoorHumidityLevel: number;
/** Lowest-cloud-layer height in feet, or null when unknown. NWS gridpoint data, so US locations only — Open-Meteo has no cloud-base field for any model. Values at or above NWS's 25,000 ft cap mean "no ceiling". */
cloudCeilingFt: number | null;
}
/**
* What an hour looks like at a glance, coarse enough to merge neighbouring hours into one
* labelled stretch on the weather page's timeline bar. Derived from the WMO weather code
* rather than from the display text, so rewording a label can never silently reclassify
* an hour. Rain is split into light and heavier because that is the distinction the bar
* draws; the rest of the precipitation types are one colour each.
*/
export type ConditionKind = 'clear' | 'partly' | 'cloudy' | 'fog' | 'light-rain' | 'rain' | 'freezing' | 'snow' | 'storm';
export interface WeatherHourEntry extends ConditionDetails {
conditionKind: ConditionKind;
time: string;
temp: number;
/** Apparent temperature (Open-Meteo's heat-index/wind-chill blend), in the configured temperature unit. */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
@@ -24,9 +60,20 @@ export interface WeatherHourEntry {
export interface WeatherDayEntry {
date: string;
tempMax: number;
/** The calendar day's minimum - the fallback for nightLow when too little of the night is in the forecast. */
tempMin: number;
/** Short daytime description composed from the hourly series (see describePeriod in client.ts), e.g. "Mostly cloudy with a shower". */
conditionText: string;
icon: string;
/** Short description of the night that follows this day (dusk to the next dawn). Empty when there were no hours to describe. */
nightDescription: string;
nightIcon: string;
/** That night's lowest temperature - what AccuWeather shows as the day's "low". Null when under 6 night hours are in the forecast. */
nightLow: number | null;
/** Percent, 0-100: the day's maximum hourly chance of precipitation. */
precipitationChance: number | null;
/** Same, over that night's hours alone - what a "Tonight" row shows once the day itself is over. */
nightPrecipitationChance: number | null;
}
export interface WeatherAlert {
@@ -49,7 +96,7 @@ export interface WeatherConfig {
}
export interface WeatherCache {
current: {
current: (ConditionDetails & {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
@@ -71,7 +118,7 @@ export interface WeatherCache {
precipIsSnow: boolean;
sunrise: string;
sunset: string;
} | null;
}) | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See client.ts's fetchActiveAlerts. */
+31 -5
View File
@@ -27,11 +27,15 @@ services:
WIDGETS_INSTALLED_DIR: /app/data/widgets-installed
NODE_ENV: production
PRIVATE_ACCESS_PASSWORD: ${PRIVATE_ACCESS_PASSWORD:-}
# Optional — without this, the container regenerates the admin key on every
# recreate (no writable backend/.env inside it). Set to any random string to
# pin a stable one. If this stack's .env substitution doesn't reliably apply
# (as PUBLIC_ORIGIN's didn't — see FRONTEND_ORIGIN/ORIGIN above), set it as a
# literal value directly in Compose Manager's own environment editor instead.
# The admin password: whatever you set here IS the key the admin panel asks for, and
# it is the only thing protecting /api/admin/*. Use a long one (16+ characters; the
# backend warns at startup if it is shorter). It must be listed HERE to reach the
# container - a value in this project's .env that isn't forwarded by a line like this
# is silently ignored, and the backend then invents a random key of its own on every
# recreate (no writable backend/.env inside the container to remember it).
# If this stack's .env substitution doesn't reliably apply (as PUBLIC_ORIGIN's didn't
# - see FRONTEND_ORIGIN/ORIGIN above), set it as a literal value in Compose Manager's
# own environment editor instead. Avoid $ in the value (compose treats it as a variable).
ADMIN_API_KEY: ${ADMIN_API_KEY:-}
volumes:
- backend-data:/app/data
@@ -40,6 +44,18 @@ services:
- "${FRONTEND_PORT:-3000}:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
# Keep server-side rendering on the LAN. VITE_BACKEND_URL is baked in as the public
# https://salastil.com (the browser needs that), but without this entry the frontend's
# SSR fetches resolve it to Cloudflare, fail over from IPv6 (this network has none), then
# hairpin back in through the router - a WAN round trip and extra router connections for
# every render. Pointing it straight at NPM keeps SNI/TLS and the vhost match intact.
# Declared on backend because the frontend uses network_mode: service:backend and so
# shares this container's /etc/hosts (Docker rejects extra_hosts on a container-networked
# service). Like FRONTEND_ORIGIN/ORIGIN, this is deployment-specific: change the name if
# you fork this file for another domain. The address is the LAN IP of the host running
# NPM - set LAN_HOST_IP in .env when that machine's address changes, otherwise every
# server-rendered page 500s with "fetch failed" until this is updated.
- "salastil.com:${LAN_HOST_IP:-192.168.0.60}"
healthcheck:
test:
[
@@ -69,3 +85,13 @@ services:
volumes:
backend-data:
# Pinned to an existing volume by its exact name instead of letting Compose derive
# "<project>_backend-data". The derived name changes with the project name, and
# Compose Manager's project name is whatever is in its project_name file - which once
# drifted from the folder name, so its Up button silently created a fresh, empty
# database next to the real one. `external` also means Compose will refuse to start
# (rather than quietly create a blank volume) if this one is ever missing, and that
# `down -v` can never delete it. On a brand-new host, create it first:
# docker volume create homefeed-001_backend-data
external: true
name: homefeed-001_backend-data
+11
View File
@@ -27,9 +27,20 @@ export interface WeatherHourEntry {
export interface WeatherDayEntry {
date: string;
tempMax: number;
/** The calendar day's minimum - the fallback for nightLow. */
tempMin: number;
/** Short daytime description composed from the hourly forecast, e.g. "Mostly cloudy with a shower". */
conditionText: string;
icon: string;
/** Short description of the night that follows this day, e.g. "A couple of evening showers". */
nightDescription: string;
nightIcon: string;
/** That night's low - what AccuWeather shows as the day's "low". Null when too little of the night is in the forecast. */
nightLow: number | null;
/** Percent, 0-100: the day's highest hourly chance of precipitation. */
precipitationChance: number | null;
/** Same, for that night alone — what a "Tonight" row shows. */
nightPrecipitationChance: number | null;
}
export interface WeatherCurrentConditions {
@@ -203,9 +203,6 @@
{/if}
<style>
.list {
max-width: 720px;
}
.entry {
position: relative;
}
@@ -150,23 +150,40 @@
margin-bottom: 8px;
background: var(--surface-1);
}
/* Each shape keeps the proportions it had when the feed column was 640px wide (1 item
380px tall, 2 items 240px, 3-4 items 300px) and scales with the card from there. A
fixed height alone left landscape photos letterboxed with dark side bars once the
column grew past that. The old heights survive as a floor, so a narrow phone column
still gets the same tall frame it always did.
Sized with container units rather than `aspect-ratio` + `min-height`: that pair turns
the minimum HEIGHT into a minimum WIDTH through the ratio (380px becomes 676px wide),
which pushed these frames past the edge of a phone screen. The plain height line above
each is the fallback for a browser without container units. */
.telegram-card {
container-type: inline-size;
}
.media-grid[data-count='1'] {
grid-template-columns: 1fr;
height: 380px;
height: max(380px, 56.25cqw);
}
.media-grid[data-count='2'] {
grid-template-columns: 1fr 1fr;
height: 240px;
height: max(240px, 37.5cqw);
}
.media-grid[data-count='3'] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 300px;
height: max(300px, 46.875cqw);
}
.media-grid[data-count='4'] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 300px;
height: max(300px, 46.875cqw);
}
.media-cell {
overflow: hidden;
@@ -172,23 +172,40 @@
margin-bottom: 8px;
background: var(--surface-1);
}
/* Each shape keeps the proportions it had when the feed column was 640px wide (1 item
380px tall, 2 items 240px, 3-4 items 300px) and scales with the card from there. A
fixed height alone left landscape photos letterboxed with dark side bars once the
column grew past that. The old heights survive as a floor, so a narrow phone column
still gets the same tall frame it always did.
Sized with container units rather than `aspect-ratio` + `min-height`: that pair turns
the minimum HEIGHT into a minimum WIDTH through the ratio (380px becomes 676px wide),
which pushed these frames past the edge of a phone screen. The plain height line above
each is the fallback for a browser without container units. */
.tweet-card {
container-type: inline-size;
}
.media-grid[data-count='1'] {
grid-template-columns: 1fr;
height: 380px;
height: max(380px, 56.25cqw);
}
.media-grid[data-count='2'] {
grid-template-columns: 1fr 1fr;
height: 240px;
height: max(240px, 37.5cqw);
}
.media-grid[data-count='3'] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 300px;
height: max(300px, 46.875cqw);
}
.media-grid[data-count='4'] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 300px;
height: max(300px, 46.875cqw);
}
.media-cell {
overflow: hidden;
+43 -2
View File
@@ -95,7 +95,37 @@ export interface Category {
isSpillover: boolean;
}
export interface WeatherHourEntry {
/**
* The at-a-glance conditions an hour and "right now" both carry, so the weather page's
* header card can render either through one code path (see backend widgets/weather/db.ts).
*/
export interface WeatherConditionDetails {
/** Already in the feed's configured windUnit. */
windGust: number;
/** Percent, 0-100. */
cloudCover: number;
/** Miles when windUnit is mph, kilometres when kph. Null when the forecast model has none. */
visibility: number | null;
/** In the feed's configured temperature unit. */
dewPoint: number;
/** Estimated indoor relative humidity, percent. */
indoorHumidity: number;
/** "Ideal Humidity", "Slightly Dry", "Dangerously Humid", ... — one of AccuWeather's 11 descriptions. */
indoorHumidityLabel: string;
/** -5 (dangerously dry) .. 0 (ideal) .. +5 (dangerously humid). */
indoorHumidityLevel: number;
/** Feet; null when unknown. NWS data, so US only — see the backend's fetchCeilingBlocks. At or above 25,000 means "no ceiling below that". */
cloudCeilingFt: number | null;
}
/** See the backend's ConditionKind: a coarse look for one hour, so neighbouring hours can merge into one stretch of the timeline bar. */
export type WeatherConditionKind = 'clear' | 'partly' | 'cloudy' | 'fog' | 'light-rain' | 'rain' | 'freezing' | 'snow' | 'storm';
export interface WeatherHourEntry extends WeatherConditionDetails {
/** Absent on a forecast cached before the field existed - until the next poll, a few seconds after a deploy. */
conditionKind?: WeatherConditionKind;
/** Apparent temperature, in the feed's configured temperature unit. */
feelsLike: number;
time: string;
temp: number;
conditionText: string;
@@ -118,12 +148,23 @@ export interface WeatherHourEntry {
export interface WeatherDayEntry {
date: string;
tempMax: number;
/** The calendar day's minimum - the fallback for nightLow. */
tempMin: number;
/** Short daytime description composed from the hourly forecast, e.g. "Mostly cloudy with a shower". */
conditionText: string;
icon: string;
/** Short description of the night that follows this day, e.g. "A couple of evening showers". */
nightDescription: string;
nightIcon: string;
/** That night's low - what AccuWeather shows as the day's "low". Null when too little of the night is in the forecast. */
nightLow: number | null;
/** Percent, 0-100: the day's highest hourly chance of precipitation. */
precipitationChance: number | null;
/** Same, for that night alone — what a "Tonight" row shows. */
nightPrecipitationChance: number | null;
}
export interface WeatherCurrentConditions {
export interface WeatherCurrentConditions extends WeatherConditionDetails {
temp: number;
feelsLike: number;
conditionText: string;
@@ -162,9 +162,6 @@
display: flex;
gap: 8px;
}
article {
max-width: 640px;
}
.meta {
font-size: 11px;
color: var(--text-accent);
-3
View File
@@ -70,9 +70,6 @@
.cat-name:hover {
color: var(--text-accent);
}
.list {
max-width: 720px;
}
.empty {
font-size: 12px;
color: var(--text-muted);
File diff suppressed because it is too large Load Diff