mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59448d473c | ||
|
|
75d142df25 | ||
|
|
5e721e0791 | ||
|
|
be7df4bc17 | ||
|
|
85f9a07daf | ||
|
|
9d0983dc22 | ||
|
|
068c40270f | ||
|
|
8258f33d02 | ||
|
|
37af3e9e50 | ||
|
|
0081134cd7 | ||
|
|
50e23a38a1 | ||
|
|
6fab09041a | ||
|
|
bfb346db33 | ||
|
|
46aa01bb8a | ||
|
|
922648d375 | ||
|
|
4655a1b030 | ||
|
|
3cfde9fa7a | ||
|
|
d7cd2bc3e6 | ||
|
|
38bfb0520d | ||
|
|
588b7c66c1 | ||
|
|
6a159e7ac3 | ||
|
|
1c13a16d49 | ||
|
|
f4c983c8ba | ||
|
|
784b62b656 | ||
|
|
adcacfd1a4 | ||
|
|
fd6170618a | ||
|
|
2e8279a2fd |
No files matched your search
+92
-23
@@ -1,37 +1,106 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public"
|
||||
# =============================================================================
|
||||
# HealthLog — Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values.
|
||||
# $ cp .env.example .env
|
||||
#
|
||||
# Required variables: DATABASE_URL or POSTGRES_PASSWORD, SESSION_SECRET,
|
||||
# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
|
||||
#
|
||||
# Generate the three secrets with:
|
||||
# $ openssl rand -hex 32 # any UNIX shell
|
||||
# $ node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
|
||||
#
|
||||
# All secrets must be 64-character hex strings (= 32 bytes).
|
||||
# =============================================================================
|
||||
|
||||
# App
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
# When using docker-compose.yml: the app container reaches Postgres via the
|
||||
# service name "db", so the hostname is "db" — NOT "localhost".
|
||||
# When developing without Docker (`pnpm dev` against a host-installed Postgres):
|
||||
# change the hostname to "localhost" and adjust the credentials.
|
||||
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
|
||||
|
||||
# Postgres password — used both by the db service AND by DATABASE_URL above.
|
||||
# Pick a strong random password, e.g. `openssl rand -base64 24`.
|
||||
POSTGRES_PASSWORD="CHANGE-ME"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# App URL
|
||||
# -----------------------------------------------------------------------------
|
||||
# The public URL where users reach the app. Behind a reverse proxy with TLS,
|
||||
# this looks like https://healthlog.example.com. For local dev, leave default.
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Node environment: "development" for `pnpm dev`, "production" for Docker.
|
||||
NODE_ENV="development"
|
||||
|
||||
# Session
|
||||
SESSION_SECRET="change-me-to-a-random-64-char-hex-string"
|
||||
|
||||
# Encryption (for tokens, sensitive notes)
|
||||
ENCRYPTION_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required Secrets — generate with `openssl rand -hex 32`
|
||||
# -----------------------------------------------------------------------------
|
||||
# Signs session cookies. Rotating it logs out everyone.
|
||||
SESSION_SECRET="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# HMAC key for API token hashing
|
||||
API_TOKEN_HMAC_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
# AES-256-GCM key for encrypting Withings tokens, GitHub PATs, AI provider
|
||||
# keys, and VAPID private keys at rest. Rotate carefully — old ciphertexts
|
||||
# need re-encryption.
|
||||
ENCRYPTION_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# Withings OAuth (M5)
|
||||
# HMAC-SHA256 key for hashing external-API Bearer tokens before storing them
|
||||
# in the database. Rotating it invalidates all issued tokens.
|
||||
API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Withings OAuth (optional — for automatic scale/BP/activity sync)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register an app at https://developer.withings.com/ and paste the credentials
|
||||
# here. Per-user OAuth credentials can also be configured in Settings.
|
||||
# WITHINGS_CLIENT_ID=""
|
||||
# WITHINGS_CLIENT_SECRET=""
|
||||
# WITHINGS_REDIRECT_URI=""
|
||||
# WITHINGS_WEBHOOK_SECRET="" # random secret for webhook callback URL hardening
|
||||
# WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback"
|
||||
# WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL
|
||||
|
||||
# Telegram webhook hardening (optional but recommended)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Telegram (optional — webhook hardening)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Set to verify incoming Telegram updates. The bot token itself is configured
|
||||
# per-user in Settings (encrypted at rest).
|
||||
# TELEGRAM_WEBHOOK_SECRET=""
|
||||
|
||||
# OpenAI (optional, user-provided keys stored encrypted)
|
||||
# No server-side key needed - users bring their own
|
||||
|
||||
# Structured Logging (Wide Events)
|
||||
# LOG_LEVEL="info" # debug | info | warn | error
|
||||
# LOG_SAMPLE_RATE="1.0" # 0.0–1.0, fraction of successful requests to log
|
||||
# LOG_SLOW_THRESHOLD_MS="3000" # Requests slower than this are always logged
|
||||
# LOG_INCLUDE_STACK="true" # Include stack traces in error events
|
||||
# LOKI_ENDPOINT="" # Optional: Loki Push API (e.g. http://loki:3100)
|
||||
# LOKI_USERNAME="" # Optional: Loki basic auth username
|
||||
# LOKI_PASSWORD="" # Optional: Loki basic auth password
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Provider (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Users bring their own API keys via Settings. No server-side key needed by
|
||||
# default. The admin can configure a fallback key in the Admin Panel.
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Structured Logging — Wide Events (optional, sensible defaults)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Levels: debug | info | warn | error
|
||||
# LOG_LEVEL="info"
|
||||
|
||||
# Sample rate for successful requests (0.0 = sample nothing, 1.0 = log all).
|
||||
# Errors and slow requests are ALWAYS logged regardless of this setting.
|
||||
# LOG_SAMPLE_RATE="1.0"
|
||||
|
||||
# Slow-request threshold in milliseconds — anything slower is always kept.
|
||||
# LOG_SLOW_THRESHOLD_MS="3000"
|
||||
|
||||
# Include stack traces on error events. Disable in environments where logs
|
||||
# leave the host unencrypted.
|
||||
# LOG_INCLUDE_STACK="true"
|
||||
|
||||
# Optional: ship Wide Events to Loki. Leave blank for stdout-only logging.
|
||||
# LOKI_ENDPOINT="http://loki:3100"
|
||||
# LOKI_USERNAME=""
|
||||
# LOKI_PASSWORD=""
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Build & Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
# Cancel in-progress runs when a new commit is pushed to the same ref.
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & push multi-arch image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU (multi-arch)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Skip login on PRs from forks — they don't have package:write.
|
||||
# Image is built but not pushed in that case.
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute image tags & labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
labels: |
|
||||
org.opencontainers.image.title=HealthLog
|
||||
org.opencontainers.image.description=Self-hosted health-tracking PWA
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
- name: Build & push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
@@ -6,7 +6,7 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
|
||||
|
||||
**Status**: Feature-complete (v1.0.0). See GitHub Issues for open tasks.
|
||||
**Status**: v1.2.0 (Custom Thresholds, Blood Glucose, Dashboard Customization, Built-in Feedback, Multi-Provider AI). See GitHub Issues for open tasks.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -117,7 +117,7 @@ messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (22 models)
|
||||
├── schema.prisma # Database schema (23 models)
|
||||
└── migrations/ # Migration files (0001–0018)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
@@ -207,7 +207,7 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
22 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
23 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
|
||||
## When Making Changes
|
||||
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [1.2.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Onboarding**: Medications added during onboarding are now actually persisted (closes #87). The wizard previously sent an empty `schedules: []` array, the server-side validation rejected it with a 422, the client never checked `response.ok`, and the user was redirected to the dashboard as if everything had worked. Onboarding now wraps each step in `try/catch`, surfaces failures via toast, and attaches a default reminder window (`08:00–09:00 daily`) so the medication actually persists. A hint under the medication list explains the default.
|
||||
- **Docker setup** (closes #88):
|
||||
- `docker-compose.yml` now uses `ports: "3000:3000"` (was `expose: "3000"`, which made the app unreachable from the host).
|
||||
- `POSTGRES_PASSWORD` is a single env var that both the Postgres service and `DATABASE_URL` interpolate, so they cannot drift apart.
|
||||
- `.env.example` now points at the in-container hostname `db:5432` (was `localhost:5432`, which never resolves inside the app container).
|
||||
- **Documentation**:
|
||||
- `package.json` synced to 1.2.0 (was lagging on 1.1.0).
|
||||
- `CLAUDE.md` and `AGENTS.md` corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
|
||||
- `README.md` Quick Start gives a realistic time estimate, generates the four secrets in one block straight into `.env`, and points reverse-proxy users at the docs.
|
||||
|
||||
### Added — Tooling & Supply Chain
|
||||
|
||||
- **Pre-built multi-arch images on GHCR**: `.github/workflows/docker-publish.yml` now builds `linux/amd64` + `linux/arm64` images on every push to `main` and on every `v*` tag, publishing to `ghcr.io/mbombeck/healthlog`. Self-hosters no longer need a build toolchain — `docker compose pull && docker compose up -d` is enough. The bundled `docker-compose.yml` references the published image with a `build:` block as fallback for contributors.
|
||||
- **Supply-chain attestations**: each published image carries a SLSA build provenance statement and a Software Bill of Materials. `SECURITY.md` documents how to verify them and how to pin a specific version.
|
||||
- **Documentation single source of truth**: `getting-started/installation.mdx` is now the canonical setup guide (mirrors the bundled `docker-compose.yml`); `self-hosting/docker.mdx` slimmed to image internals + ops notes only. The landing page's Quick Start terminal block now includes the secrets-generation step (was missing).
|
||||
|
||||
### Notes
|
||||
|
||||
This is a patch release that closes the install/onboarding friction reported in #87 and #88. The bigger user-facing changes (additional measurement types like total body water and bone mass per #89, full onboarding redesign, typed API client) are tracked for `1.3.0`.
|
||||
|
||||
## [1.2.0] — 2026-04-18
|
||||
|
||||
### Added — Personalization, Glucose & Multi-Provider AI
|
||||
|
||||
@@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs
|
||||
## Architecture
|
||||
|
||||
- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity.
|
||||
- **Prisma 7** ORM with PostgreSQL (22 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **Prisma 7** ORM with PostgreSQL (23 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add <component>`.
|
||||
- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors.
|
||||
- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`.
|
||||
@@ -81,7 +81,7 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/lib/validations/` — Zod schemas shared between API + client
|
||||
- `src/hooks/` — React hooks (`use-auth`)
|
||||
- `messages/de.json` + `messages/en.json` — i18n translations
|
||||
- `prisma/schema.prisma` — database schema (21 models)
|
||||
- `prisma/schema.prisma` — database schema (23 models)
|
||||
- `prisma.config.ts` — Prisma config (DB URL here, not in schema)
|
||||
- `public/sw.js` — Service worker for Web Push notifications + offline caching
|
||||
- `docs/` — architecture, security, assumptions, ops, API docs, ADRs
|
||||
|
||||
@@ -67,21 +67,24 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
## Quick Start
|
||||
|
||||
Plan ~5 minutes for a working install. The bundled `docker-compose.yml` pulls a pre-built multi-arch image (`linux/amd64` + `linux/arm64`) from [GitHub Container Registry](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required for self-hosters. Contributors who want to test local changes can `docker compose up --build`.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MBombeck/HealthLog.git
|
||||
cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Generate the required secrets:
|
||||
Generate the four required secrets and paste them into `.env`:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # SESSION_SECRET
|
||||
openssl rand -hex 32 # ENCRYPTION_KEY
|
||||
openssl rand -hex 32 # API_TOKEN_HMAC_KEY
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
Add them to `.env`, then:
|
||||
Then bring the stack up:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -89,25 +92,27 @@ docker compose up -d
|
||||
|
||||
Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
|
||||
> Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set `NEXT_PUBLIC_APP_URL` and `APP_URL` to your public URL in `.env` before starting. See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/) for examples.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (22 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
| Data Fetching | TanStack Query 5 |
|
||||
| Forms | React Hook Form 7 + Zod 4 |
|
||||
| Auth | SimpleWebAuthn 13, Argon2id |
|
||||
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
| Layer | Technology |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (23 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
| Data Fetching | TanStack Query 5 |
|
||||
| Forms | React Hook Form 7 + Zod 4 |
|
||||
| Auth | SimpleWebAuthn 13, Argon2id |
|
||||
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
|
||||
---
|
||||
|
||||
@@ -130,23 +135,24 @@ HealthLog is designed for people who take data ownership seriously.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
|
||||
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
|
||||
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
|
||||
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
|
||||
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
|
||||
| Variable | Description |
|
||||
| ------------------------- | ---------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
|
||||
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
|
||||
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
|
||||
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
|
||||
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
|
||||
|
||||
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
|
||||
|
||||
@@ -205,60 +211,60 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
<details>
|
||||
<summary><strong>Health Data</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
|
||||
| `POST` | `/api/measurements` | Create measurement |
|
||||
| `DELETE` | `/api/measurements/:id` | Delete measurement |
|
||||
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
|
||||
| `GET` | `/api/export` | Export as CSV or JSON |
|
||||
| `POST` | `/api/import` | Import from JSON |
|
||||
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------- | ----------------------------------------- |
|
||||
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
|
||||
| `POST` | `/api/measurements` | Create measurement |
|
||||
| `DELETE` | `/api/measurements/:id` | Delete measurement |
|
||||
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
|
||||
| `GET` | `/api/export` | Export as CSV or JSON |
|
||||
| `POST` | `/api/import` | Import from JSON |
|
||||
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Mood</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/mood-entries` | List mood entries |
|
||||
| `POST` | `/api/mood-entries` | Create mood entry |
|
||||
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
|
||||
| `GET` | `/api/mood/analytics` | Mood trend analytics |
|
||||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------------------- | -------------------- |
|
||||
| `GET` | `/api/mood-entries` | List mood entries |
|
||||
| `POST` | `/api/mood-entries` | Create mood entry |
|
||||
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
|
||||
| `GET` | `/api/mood/analytics` | Mood trend analytics |
|
||||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Medications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/medications` | List all medications |
|
||||
| `POST` | `/api/medications` | Create medication |
|
||||
| `PUT` | `/api/medications/:id` | Update medication |
|
||||
| `DELETE` | `/api/medications/:id` | Delete medication |
|
||||
| `POST` | `/api/medications/:id/intake` | Log intake event |
|
||||
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
|
||||
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | ------------------------ |
|
||||
| `GET` | `/api/medications` | List all medications |
|
||||
| `POST` | `/api/medications` | Create medication |
|
||||
| `PUT` | `/api/medications/:id` | Update medication |
|
||||
| `DELETE` | `/api/medications/:id` | Delete medication |
|
||||
| `POST` | `/api/medications/:id/intake` | Log intake event |
|
||||
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
|
||||
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Auth and Integrations</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ----------------------- |
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -266,16 +272,16 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
|
||||
## Integrations
|
||||
|
||||
| Integration | Setup | Purpose |
|
||||
|-------------|-------|---------|
|
||||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||||
| **OpenAI** | User Settings | AI health insights (BYOK) |
|
||||
| **moodLog.app** | User Settings | Mood tracking sync |
|
||||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||||
| Integration | Setup | Purpose |
|
||||
| --------------- | ------------- | ---------------------------------------- |
|
||||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||||
| **OpenAI** | User Settings | AI health insights (BYOK) |
|
||||
| **moodLog.app** | User Settings | Mood tracking sync |
|
||||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-1
@@ -5,7 +5,7 @@
|
||||
Only the latest release on the `main` branch is actively supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| ------- | --------- |
|
||||
| Latest | Yes |
|
||||
| Older | No |
|
||||
|
||||
@@ -59,3 +59,17 @@ HealthLog is designed with security as a core principle:
|
||||
- **Proxy-level route protection** via `proxy.ts` (session cookie validation on all non-public paths)
|
||||
|
||||
For more details, see the [security documentation](https://docs.healthlog.dev/security/overview/).
|
||||
|
||||
## Supply Chain
|
||||
|
||||
HealthLog Docker images are built and published from this repository's CI:
|
||||
|
||||
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
|
||||
- **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog)
|
||||
- **Architectures**: `linux/amd64` and `linux/arm64`
|
||||
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
|
||||
- **SBOM**: each image includes a [Software Bill of Materials](https://docs.docker.com/build/metadata/attestations/sbom/) you can inspect with `docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:latest --format '{{ json .SBOM }}'`
|
||||
|
||||
To pin to a specific version in production, replace `:latest` with the released tag in your `docker-compose.yml`, e.g. `ghcr.io/mbombeck/healthlog:1.2.0`. Pinning is recommended for self-hosters who want explicit control over upgrades.
|
||||
|
||||
If you discover a tampered or unexpected image, please report it as a vulnerability via the email above.
|
||||
+11
-4
@@ -1,13 +1,20 @@
|
||||
services:
|
||||
app:
|
||||
# Pre-built multi-arch images are published to GHCR by
|
||||
# .github/workflows/docker-publish.yml on every push to main and on
|
||||
# tagged releases. Pin to a specific version in production:
|
||||
# e.g. ghcr.io/mbombeck/healthlog:1.2.0
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
# Fallback build context: if the image is not yet in the registry,
|
||||
# or you want to test local changes, run `docker compose up --build`.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: healthlog-app
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public"
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
|
||||
@@ -24,7 +31,7 @@ services:
|
||||
container_name: healthlog-db
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
POSTGRES_DB: healthlog
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# HealthLog Audit-Plan — Phasen-Übersicht
|
||||
**Datum:** 2026-04-26
|
||||
**Audit-Bericht:** `~/infra/reports/healthlog-audit-2026-04-26.md`
|
||||
**Status:** Geplant, noch nicht in Execution
|
||||
|
||||
## Strategische Decisions
|
||||
|
||||
1. **Onboarding-Redesign:** 4-Step-Wizard → 2-Step + Empty-State-Dashboard + Getting-Started-Card.
|
||||
2. **Custom-Measurement-Types:** Pragmatic Hardcoding (jetzt #89), Cronometer-Pattern als v1.5+-Roadmap.
|
||||
3. **`apiClient`-Refactor vor Onboarding-Redesign**, damit neues Onboarding nicht denselben Silent-Failure-Bug reproduziert.
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Sequenz (10 Phasen)
|
||||
|
||||
| # | Phase | Skill | Effort | Issue-Bezug |
|
||||
|---|---|---|---|---|
|
||||
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
|
||||
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
|
||||
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
|
||||
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
|
||||
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
|
||||
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
|
||||
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
|
||||
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
|
||||
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
|
||||
|
||||
**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Setup-Notfall (≤4 h)
|
||||
|
||||
**Goal:** Self-Hoster kann mit `git clone && docker compose up -d` tatsächlich starten + Issue #87 stoppt zu blocken.
|
||||
|
||||
- `docker-compose.yml`: `expose:3000` → `ports: "3000:3000"` (DX-002)
|
||||
- `docker-compose.yml`: `POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?required}"` (SEC-003)
|
||||
- `.env.example`: `localhost:5432` → `db:5432` (DX-003)
|
||||
- `package.json`: `"version": "1.2.0"` (DOC-007)
|
||||
- `CLAUDE.md` + `AGENTS.md`: 23 Models statt 21/22 (DOC-001)
|
||||
- `src/app/onboarding/page.tsx`: `await fetch(...)` mit `.ok`-Check + `toast.error()` (DX-005 Pflaster)
|
||||
- README „minutes"-Claim entschärfen (DX-006)
|
||||
|
||||
**Verification:** Frischer Clone + neue VM → `cp .env.example .env && set secrets && docker compose up -d` → App auf `http://localhost:3000` erreichbar in <5 min.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Docker-Image-Pipeline (~1 d)
|
||||
|
||||
**Goal:** Issue #88 vollständig schließen. Pre-built Image auf GHCR. Docs konsolidiert.
|
||||
|
||||
- `.github/workflows/docker-publish.yml`: Multi-Arch (amd64+arm64), Trigger auf Tag-Push + main, GHCR-Push mit GITHUB_TOKEN
|
||||
- `healthlog-docs/getting-started/installation.mdx` → Verweis auf `self-hosting/docker.mdx` statt Duplikat (DX-004)
|
||||
- Repo-`docker-compose.yml` vs Docs-`docker-compose.yml`: eine Variante als Single-Source-of-Truth (Compose verwendet `image:` aus GHCR; Doc-Snippet wird daraus generiert)
|
||||
- `SECURITY.md` + Image-Pull-Instructions
|
||||
- `healthlog-landing` prüfen ob „Quick Start" aktualisiert werden muss
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Issue #89: TBW + Bone Mass (~1 d)
|
||||
|
||||
**Goal:** User-Anfrage von Manschk3rl beantworten. Blueprint = v1.2-Glucose-Migration.
|
||||
|
||||
**Touchpoints:**
|
||||
1. `prisma/schema.prisma`: `MeasurementType` Enum + 2 neue Werte
|
||||
2. `prisma/migrations/0019_*` (additive `ALTER TYPE ... ADD VALUE`)
|
||||
3. `src/lib/validations/measurement.ts`: `unitMap`, `VALUE_RANGES`
|
||||
4. `src/lib/format.ts` + i18n-Keys (4-6 neue Keys)
|
||||
5. `src/lib/doctor-report-pdf.ts`: `TYPE_LABEL_KEYS`, `TYPE_UNIT_KEYS`, `vitalTypes`
|
||||
6. `src/components/measurements/measurement-form.tsx`: `MEASUREMENT_TYPES` Array
|
||||
7. `src/lib/withings/client.ts` + `sync.ts`: Mapping aus Withings `body_composition`
|
||||
8. `src/app/api/dashboard/widgets/route.ts` + `DEFAULT_DASHBOARD_LAYOUT`: 2 neue Widget-IDs
|
||||
9. healthlog-docs `health-metrics.mdx` aktualisieren
|
||||
10. Optional: `/api/ingest/measurements` Bearer-Endpoint für n8n (analog `/api/ingest/medication`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — `apiClient`-Wrapper (2-3 d)
|
||||
|
||||
**Goal:** Silent-Failure-Pattern strukturell eliminieren. Voraussetzung für 3.5.
|
||||
|
||||
- `src/lib/api-client.ts`: `apiClient.{get,post,put,delete}<T>(path, body?, schema?) → Result<T, ApiError>`
|
||||
- Auto-`.ok`-Check + Toast bei Fehler (mit i18n-Mapping)
|
||||
- Geteilte Zod-Schemas: Validations-Module in `src/lib/validations/` werden client-importable
|
||||
- Migration: alle ~50 `fetch(...)`-Aufrufe in `src/app/` und `src/components/` → `apiClient.*`
|
||||
- Tests für `apiClient` (Mock-Fetch, Error-Mapping, Schema-Validation)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3.5 — Onboarding-Redesign (~2 d)
|
||||
|
||||
**Goal:** TTFV <60s, 90% Completion (statt aktuell ~8% global).
|
||||
|
||||
**Neuer Flow:**
|
||||
- Step 1: Welcome + Anzeigename (optional) + Einheiten + Größe (optional). Sekundärlink „Skip everything → Dashboard".
|
||||
- Step 2: Multi-Select-Chips „Was willst du tracken?" (Weight/BP/Pulse/Mood/Glucose/Sleep/Steps/BodyFat/TBW/BoneMass/Medications). Pre-checked Defaults. Speichert in `User.dashboardWidgetsJson`.
|
||||
- Routing nach Onboarding: direkt zum Dashboard mit „Getting Started"-Card (s. Phase 4.6).
|
||||
|
||||
**Entfällt ersatzlos:** Targets-Step, Notifications-Teaser-Step, Front-loaded-Med-Wizard.
|
||||
|
||||
**Permissions deferred:** Notifications nur bei „Add medication → Reminder ein", PWA-Install nur bei Day-2-Return-Visit oder nach 3. Eintrag.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.5 — Empty-State-Dashboard-Tiles (~1 d)
|
||||
|
||||
**Goal:** Mealie/Tandoor/Linear-Pattern. Jede Metric-Tile hat Empty-State mit „Add your first X"-CTA.
|
||||
|
||||
- `src/components/dashboard/EmptyStateTile.tsx` als wiederverwendbare Komponente
|
||||
- Pro `MeasurementType`: eigene Empty-State-Card (Icon + Headline + CTA-Button → opens MeasurementForm prefilled)
|
||||
- `Medications` analog: Empty-State „Add your first medication" → opens MedicationForm mit Default-Schedule (08:00–09:00 daily)
|
||||
- Dashboard-Routing: User mit `dashboardWidgetsJson` ohne Daten sieht Empty-States, nicht leeres Grid
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.6 — „Getting Started"-Checklist (~4 h)
|
||||
|
||||
**Goal:** Linear-Pattern. Persistent dismissible Card auf Dashboard.
|
||||
|
||||
- `User.gettingStartedDismissedAt: DateTime?` (nullable, NULL = noch sichtbar)
|
||||
- 4 Items mit Live-Status:
|
||||
- ☐ Log your first measurement (✓ wenn ≥1 Measurement existiert)
|
||||
- ☐ Add a medication (✓ wenn ≥1 Medication existiert)
|
||||
- ☐ Set a target value (✓ wenn `thresholdsJson` non-empty)
|
||||
- ☐ Install HealthLog as App (✓ wenn `beforeinstallprompt` accepted, sonst nur sichtbar bei PWA-fähigen Browsern)
|
||||
- „Dismiss"-Button → setzt Timestamp, Card verschwindet
|
||||
- Vollständig durchgeklickt → Card verschwindet automatisch
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Security + Reliability Hardening (1-2 d)
|
||||
|
||||
- SEC-002: SSRF-IPv4-Parser-Härtung (führende Nullen)
|
||||
- SEC-004: GitHub-PAT-Logging-Sanitization (Sensitive-Field-Filter)
|
||||
- SEC-005: AES-256-GCM Key-Rotation-Doc + optional `keyVersion`
|
||||
- SEC-006: Custom-Thresholds-Rate-Limit
|
||||
- PERF-001: N+1 in `insights/comprehensive/route.ts:219` per `groupBy` auflösen
|
||||
- REL-002: Onboarding-Error-Handling über Pflaster hinaus (via apiClient aus Phase 4)
|
||||
- REL-003: pg-boss `process.on('SIGTERM', ...)` für graceful shutdown
|
||||
- LOG-001: 6× `console.log()` in `src/lib/jobs/reminder-worker.ts` → Wide-Event-Annotations
|
||||
- CI: ESLint+Typecheck `continue-on-error: true` → blocking
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Test-Coverage-Boost (1-2 d)
|
||||
|
||||
Coverage 35% → 60% auf kritischen Pfaden.
|
||||
|
||||
**Top-5-Test-Suites:**
|
||||
1. Session-Validation Roundtrip (`src/lib/auth/session.ts`)
|
||||
2. Passkey-Register/Verify-Integration (Attestation-Flow)
|
||||
3. Notification-Dispatcher Multi-Channel (TELEGRAM/NTFY/WEB_PUSH)
|
||||
4. pg-boss Reminder-Worker (Queue-Init, Job-Execution, Cron, Retry)
|
||||
5. Withings-OAuth + Sync (`exchangeCode` → `refresh` → `fetchMeasurements`)
|
||||
|
||||
**Plus:** TEST-006 Custom-Thresholds-Resolution, TEST-007 AI-Provider-SSRF.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — UX/i18n/a11y Polish (≤4 h)
|
||||
|
||||
**Residuals aus Issue #73 + neue Findings:**
|
||||
- I18N-002: Admin-Page Hardcoded „Web Push" / „Bug Report" → `t(...)`
|
||||
- I18N-003: `medication-card.tsx` `Intl.DateTimeFormat("en-CA")` und `toLocaleString("en-US")` → `useFormatters()`
|
||||
- A11Y-001: `phase-config-dialog.tsx` Text-Indicator neben Farb-Punkt (WCAG 1.4.1)
|
||||
- A11Y-002: Admin StatusItem `aria-label` verifizieren
|
||||
- UX-002: Admin-Save-Error-UX (Field-Highlight bei Fehler)
|
||||
- UX-003: Bottom-Nav-Touch-Targets `p-2.5` → `p-3` (≥44×44 px)
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Settings-Page-Splitting (2-3 d)
|
||||
|
||||
**Goal:** 3143 Zeilen → Sub-Components mit klarer Boundary.
|
||||
|
||||
- Research-Phase: Sub-Routes (`/settings/profile`, `/settings/notifications`, …) vs Sub-Components in einer Page?
|
||||
- Plan-Phase: Migrationsstrategie ohne URL-Bruch
|
||||
- Insights-Page (1750 Zeilen) folgt analogem Pattern
|
||||
|
||||
---
|
||||
|
||||
## Issue-Vorschlags-Abdeckung
|
||||
|
||||
### Issue #87 (Onboarding-Meds verschwinden)
|
||||
- **Pflaster:** Phase 1 (`.ok`-Check + Toast)
|
||||
- **Strukturelle Lösung:** Phase 4 (`apiClient`) + Phase 3.5 (neuer Flow ohne Med-Wizard) + Phase 4.5 (Empty-State-Tiles, Meds direkt sichtbar) + Phase 4.6 (Checklist)
|
||||
|
||||
### Issue #88 (Setup holprig — 4 Hauptpunkte + 3 Verbesserungsvorschläge)
|
||||
| #88 Punkt | Phase | Status |
|
||||
|---|---|---|
|
||||
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
|
||||
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
|
||||
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
|
||||
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
|
||||
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
|
||||
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
|
||||
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
|
||||
|
||||
### Issue #89 (TBW + Bone Mass)
|
||||
- Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3.
|
||||
|
||||
### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2)
|
||||
| #73 Punkt | v1.2-Status | Residual? |
|
||||
|---|---|---|
|
||||
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
|
||||
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
|
||||
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
|
||||
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
|
||||
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
|
||||
| 6. Built-in Feedback | Done in v1.2 | — |
|
||||
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
|
||||
| 8. Blood-Sugar | Done in v1.2 | — |
|
||||
|
||||
### Issue #74 (raw locale keys in UI)
|
||||
Resolved in v1.2.
|
||||
|
||||
---
|
||||
|
||||
## Time-to-First-Value (TTFV) Ziel
|
||||
|
||||
Nach Onboarding-Redesign + Empty-State-Pattern:
|
||||
- Login → Dashboard mit Empty-States: 2 Klicks
|
||||
- Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek
|
||||
- **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s)
|
||||
|
||||
## Anti-Patterns explizit vermieden
|
||||
|
||||
- Permissions-Cold-Prompt (Notifications/PWA) auf Day 1
|
||||
- Front-loaded Targets vor erster Messung
|
||||
- Multi-Step-Wizard mit identischem Skip-Button
|
||||
- `fetch()` ohne `.ok`-Check (apiClient löst strukturell)
|
||||
- Mega-Page-Pattern (Phase 8 löst Settings 3143 Z.)
|
||||
- Custom-Measurement-Types vorzeitig (40-60h Wartung/Jahr für 50% Feature-Loss)
|
||||
+6
-1
@@ -819,7 +819,12 @@
|
||||
"targetsDescription": "Setze optionale Zielwerte für Blutdruck und Gewicht.",
|
||||
"targetSys": "Systolisch (mmHg)",
|
||||
"targetDia": "Diastolisch (mmHg)",
|
||||
"targetWeight": "Zielgewicht (kg)"
|
||||
"targetWeight": "Zielgewicht (kg)",
|
||||
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
|
||||
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
|
||||
+6
-1
@@ -819,7 +819,12 @@
|
||||
"targetsDescription": "Set optional target values for blood pressure and weight.",
|
||||
"targetSys": "Systolic (mmHg)",
|
||||
"targetDia": "Diastolic (mmHg)",
|
||||
"targetWeight": "Target weight (kg)"
|
||||
"targetWeight": "Target weight (kg)",
|
||||
"errorProfile": "Could not save your profile. Please try again.",
|
||||
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
|
||||
"errorTargets": "Could not save your target values. Please try again.",
|
||||
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
+16
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -22,25 +22,25 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.3",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg-boss": "^12.15.0",
|
||||
"prisma": "^7.7.0",
|
||||
"pg-boss": "^12.18.1",
|
||||
"prisma": "^7.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.74.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -56,14 +56,14 @@
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"prettier": "^3.8.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"shadcn": "^4.2.0",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.3",
|
||||
"shadcn": "^4.5.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.4"
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+970
-993
File diff suppressed because it is too large.
Load diff
+87
-51
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -39,45 +40,63 @@ export default function OnboardingPage() {
|
||||
async function finishOnboarding() {
|
||||
setSaving(true);
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
|
||||
await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
// Create medications if any
|
||||
for (const med of meds) {
|
||||
await fetch("/api/medications", {
|
||||
const profileRes = await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
schedules: [],
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
if (!profileRes.ok) {
|
||||
throw new Error(t("onboarding.errorProfile"));
|
||||
}
|
||||
|
||||
// Save targets if set
|
||||
if (targetSys || targetDia || targetWeight) {
|
||||
const targets: Record<string, unknown> = {};
|
||||
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
|
||||
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
|
||||
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
|
||||
await fetch("/api/insights/targets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(targets),
|
||||
});
|
||||
}
|
||||
// Create medications. Server validation requires at least one schedule,
|
||||
// so we attach a sensible default — the user is informed via medScheduleHint
|
||||
// and can refine it in Medications later. Phase 3.5 of the audit replaces
|
||||
// this entire wizard with an empty-state-driven flow.
|
||||
for (const med of meds) {
|
||||
const medRes = await fetch("/api/medications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
});
|
||||
if (!medRes.ok) {
|
||||
throw new Error(t("onboarding.errorMedication", { name: med.name }));
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.replace("/");
|
||||
if (targetSys || targetDia || targetWeight) {
|
||||
const targets: Record<string, unknown> = {};
|
||||
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
|
||||
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
|
||||
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
|
||||
const targetsRes = await fetch("/api/insights/targets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(targets),
|
||||
});
|
||||
if (!targetsRes.ok) {
|
||||
throw new Error(t("onboarding.errorTargets"));
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("onboarding.errorGeneric");
|
||||
toast.error(message);
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addMedication() {
|
||||
@@ -191,27 +210,40 @@ export default function OnboardingPage() {
|
||||
placeholder={t("onboarding.medDosePlaceholder")}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addMedication}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addMedication}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{meds.length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li key={i} className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5">
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
<>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.medScheduleHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -226,8 +258,12 @@ export default function OnboardingPage() {
|
||||
{t("onboarding.notificationsDescription")}
|
||||
</p>
|
||||
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
|
||||
<p>📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}</p>
|
||||
<p>🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}</p>
|
||||
<p>
|
||||
📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}
|
||||
</p>
|
||||
<p>
|
||||
🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.notificationsLater")}
|
||||
|
||||
Reference in new issue
Block a user