Compare commits

12 Commits
Author SHA1 Message Date
Salastil 24766099e9 Merge branch 'development'
Build and push Docker images / backend (push) Failing after 24s
Build and push Docker images / frontend (push) Failing after 29s
2026-10-10 21:08:55 -04:00
Salastil 9818415658 Merge branch 'development'
Build and push Docker images / backend (push) Failing after 47s
Build and push Docker images / frontend (push) Failing after 29s
2026-09-19 20:43:20 -04:00
Salastil a310665838 Merge branch 'development'
Build and push Docker images / backend (push) Failing after 40s
Build and push Docker images / frontend (push) Failing after 28s
2026-09-11 15:52:09 -04:00
Salastil c51f53e64d Merge branch 'development'
Build and push Docker images / backend (push) Failing after 27s
Build and push Docker images / frontend (push) Has been cancelled
# Conflicts:
#	README.md
2026-08-19 16:43:32 -04:00
Salastil 81ab69cfad Merge branch 'development'
Build and push Docker images / frontend (push) Has been cancelled
Build and push Docker images / backend (push) Has been cancelled
2026-08-15 18:39:33 -04:00
Salastil cbadc9302c Merge branch 'development'
Build and push Docker images / frontend (push) Has been cancelled
Build and push Docker images / backend (push) Has been cancelled
2026-08-14 22:49:07 -04:00
Salastil 54a88320b1 Revise README for improved clarity and features
Updated project description and features for clarity and detail.
2026-08-14 21:08:33 -04:00
Salastil 180a3eca5d Add features section to README
Added a features section outlining the capabilities of the application, including content ingestion, AI article merging, tracked items for events, and a dynamic widget system.
2026-08-14 21:02:55 -04:00
Salastil b18a4170a9 Revise README for clarity and updated project info
Updated project description and removed outdated sections.
2026-08-14 20:51:30 -04:00
Salastil 47a7122ee5 Merge branch 'development'
Build and push Docker images / frontend (push) Has been cancelled
Build and push Docker images / backend (push) Has been cancelled
2026-08-14 20:30:31 -04:00
Salastil 0667f41203 Merge branch 'development'
Build and push Docker images / backend (push) Has been cancelled
Build and push Docker images / frontend (push) Has been cancelled
2026-08-14 14:49:35 -04:00
Salastil 238b368638 Merge pull request #24 from Salastil/development
Development
2026-08-03 22:34:29 -04:00
18 changed files with 380 additions and 1732 deletions
+3 -18
View File
@@ -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
+11 -235
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`);
}
+4 -29
View File
@@ -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();
-6
View File
@@ -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);
}
+14 -555
View File
@@ -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}` +
`&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` +
`&current=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
};
});
+3 -50
View File
@@ -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
View File
@@ -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
-11
View File
@@ -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;
+2 -43
View File
@@ -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);
+3
View File
@@ -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