Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24766099e9 | ||
|
|
9818415658 | ||
|
|
a310665838 | ||
|
|
c51f53e64d | ||
|
|
81ab69cfad | ||
|
|
cbadc9302c | ||
|
|
54a88320b1 | ||
|
|
180a3eca5d | ||
|
|
b18a4170a9 | ||
|
|
47a7122ee5 | ||
|
|
0667f41203 | ||
|
|
238b368638 |
+3
-18
@@ -24,20 +24,11 @@ FRONTEND_PORT=3000
|
||||
BACKEND_PORT=4000
|
||||
|
||||
# Turns on the admin cog icon + /admin/* pages — off by default, matching the
|
||||
# non-Docker deployment. The admin API key (see ADMIN_API_KEY below) is what
|
||||
# actually protects /api/admin/* either way.
|
||||
# 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.
|
||||
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.
|
||||
@@ -50,9 +41,3 @@ 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
|
||||
|
||||
@@ -1,239 +1,15 @@
|
||||
# Homefeed — frontend, real backend, and mock backend
|
||||
# Homefeed — An all‑in‑one RSS, social media and news blotter
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
<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" />
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
## Running the real backend
|
||||
## Features
|
||||
|
||||
```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.
|
||||
- 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.
|
||||
|
||||
+9
-18
@@ -17,22 +17,13 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -104,8 +95,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`
|
||||
- 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
|
||||
- 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
|
||||
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
|
||||
|
||||
+21
-39
@@ -1,67 +1,49 @@
|
||||
// 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`.
|
||||
// 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.
|
||||
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 never reaches
|
||||
// the write path below when ADMIN_API_KEY is supplied as a real environment variable.
|
||||
// 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.
|
||||
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(): { 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' };
|
||||
function loadOrCreateAdminApiKey(): string {
|
||||
if (process.env.ADMIN_API_KEY) return process.env.ADMIN_API_KEY;
|
||||
|
||||
const existing = readExistingKey();
|
||||
if (existing) return { key: existing, source: 'env-file' };
|
||||
if (existing) return existing;
|
||||
|
||||
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. Set the ADMIN_API_KEY environment variable to pin it.`);
|
||||
console.warn(`[apiKey] Could not persist ADMIN_API_KEY to ${ENV_FILE} (${(err as Error).message}) — it will regenerate on next restart.`);
|
||||
}
|
||||
return { key, source: 'generated' };
|
||||
return key;
|
||||
}
|
||||
|
||||
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;
|
||||
export const ADMIN_API_KEY = loadOrCreateAdminApiKey();
|
||||
|
||||
+4
-18
@@ -1,5 +1,5 @@
|
||||
import { migrate } from './storage/db/index.js';
|
||||
import { ADMIN_API_KEY, ADMIN_API_KEY_SOURCE, MIN_RECOMMENDED_KEY_LENGTH } from './api/apiKey.js';
|
||||
import { ADMIN_API_KEY } from './api/apiKey.js';
|
||||
import { privateAccessConfigured } from './api/privateAccess.js';
|
||||
import { startScheduler } from './queue/scheduler.js';
|
||||
import { initFromSavedSession } from './telegram/client.js';
|
||||
@@ -8,29 +8,15 @@ 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, or');
|
||||
console.log(' set the ADMIN_API_KEY environment variable to choose your own password.');
|
||||
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(`${line}\n`);
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,6 @@ 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 });
|
||||
@@ -88,41 +83,21 @@ 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. 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.
|
||||
* 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.
|
||||
*/
|
||||
export async function runPassthroughCycle(settings: GlobalSettings): Promise<number> {
|
||||
const activeEvents = eventsDb.listActiveEvents();
|
||||
|
||||
@@ -69,12 +69,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
// 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;
|
||||
@@ -72,19 +70,6 @@ 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 🌙" —
|
||||
@@ -492,7 +477,7 @@ export async function geocodeLocation(query: string): Promise<GeocodeResult[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
export interface CurrentConditions extends ConditionDetails {
|
||||
export interface CurrentConditions {
|
||||
temp: number;
|
||||
feelsLike: number;
|
||||
conditionText: string;
|
||||
@@ -512,12 +497,9 @@ export interface CurrentConditions extends ConditionDetails {
|
||||
|
||||
export interface ForecastResult {
|
||||
current: CurrentConditions;
|
||||
hourly: (ConditionDetails & {
|
||||
hourly: {
|
||||
time: string;
|
||||
temp: number;
|
||||
/** Apparent temperature, in the caller's configured temperature unit. */
|
||||
feelsLike: number;
|
||||
conditionKind: ConditionKind;
|
||||
conditionText: string;
|
||||
icon: string;
|
||||
/** Percent, 0-100. */
|
||||
@@ -534,25 +516,8 @@ 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;
|
||||
}
|
||||
@@ -704,408 +669,6 @@ 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,
|
||||
@@ -1117,20 +680,18 @@ export async function fetchForecast(
|
||||
): Promise<ForecastResult> {
|
||||
const url =
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
|
||||
`¤t=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` +
|
||||
`¤t=temperature_2m,apparent_temperature,weather_code,is_day,precipitation,snowfall,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` +
|
||||
// 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,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` +
|
||||
`&hourly=temperature_2m,weather_code,is_day,precipitation,snowfall,precipitation_probability,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` +
|
||||
`&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=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)]);
|
||||
`&temperature_unit=${unit}&wind_speed_unit=${windUnit === 'kph' ? 'kmh' : 'mph'}&timezone=auto&forecast_days=7`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Forecast API returned ${res.status}`);
|
||||
const data = (await res.json()) as {
|
||||
utc_offset_seconds: number;
|
||||
@@ -1142,31 +703,22 @@ 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[];
|
||||
@@ -1206,31 +758,6 @@ 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 = {
|
||||
@@ -1247,15 +774,6 @@ 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)
|
||||
};
|
||||
@@ -1272,8 +790,6 @@ 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],
|
||||
@@ -1282,75 +798,18 @@ 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),
|
||||
...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()
|
||||
)
|
||||
pressure: pressureUnit === 'inHg' ? Math.round(hourPressure * 100) / 100 : Math.round(hourPressure)
|
||||
};
|
||||
});
|
||||
|
||||
// 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);
|
||||
const daily = data.daily.time.map((date, i) => {
|
||||
const condition = wmoToCondition(data.daily.weather_code[i]);
|
||||
return {
|
||||
date,
|
||||
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;
|
||||
})()
|
||||
tempMax: data.daily.temperature_2m_max[i],
|
||||
tempMin: data.daily.temperature_2m_min[i],
|
||||
conditionText: condition.text,
|
||||
icon: condition.icon
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,44 +1,8 @@
|
||||
import { getKv, setKv } from '../../storage/db/widgetKv.js';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
export interface WeatherHourEntry {
|
||||
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. */
|
||||
@@ -60,20 +24,9 @@ export interface WeatherHourEntry extends ConditionDetails {
|
||||
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 {
|
||||
@@ -96,7 +49,7 @@ export interface WeatherConfig {
|
||||
}
|
||||
|
||||
export interface WeatherCache {
|
||||
current: (ConditionDetails & {
|
||||
current: {
|
||||
temp: number;
|
||||
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
|
||||
feelsLike: number;
|
||||
@@ -118,7 +71,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. */
|
||||
|
||||
+5
-31
@@ -27,15 +27,11 @@ services:
|
||||
WIDGETS_INSTALLED_DIR: /app/data/widgets-installed
|
||||
NODE_ENV: production
|
||||
PRIVATE_ACCESS_PASSWORD: ${PRIVATE_ACCESS_PASSWORD:-}
|
||||
# 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).
|
||||
# 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.
|
||||
ADMIN_API_KEY: ${ADMIN_API_KEY:-}
|
||||
volumes:
|
||||
- backend-data:/app/data
|
||||
@@ -44,18 +40,6 @@ 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:
|
||||
[
|
||||
@@ -85,13 +69,3 @@ 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
|
||||
|
||||
@@ -27,20 +27,9 @@ 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,6 +203,9 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-width: 720px;
|
||||
}
|
||||
.entry {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -150,40 +150,23 @@
|
||||
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,40 +172,23 @@
|
||||
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;
|
||||
|
||||
@@ -95,37 +95,7 @@ export interface Category {
|
||||
isSpillover: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
export interface WeatherHourEntry {
|
||||
time: string;
|
||||
temp: number;
|
||||
conditionText: string;
|
||||
@@ -148,23 +118,12 @@ export interface WeatherHourEntry extends WeatherConditionDetails {
|
||||
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 extends WeatherConditionDetails {
|
||||
export interface WeatherCurrentConditions {
|
||||
temp: number;
|
||||
feelsLike: number;
|
||||
conditionText: string;
|
||||
|
||||
@@ -162,6 +162,9 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
article {
|
||||
max-width: 640px;
|
||||
}
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-accent);
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
.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
Reference in New Issue
Block a user