mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff215af7e | ||
|
|
d438a8520d | ||
|
|
dc01ab7934 | ||
|
|
c05f48f520 | ||
|
|
622099c0fd | ||
|
|
8c0593fe79 | ||
|
|
58e9d1451a | ||
|
|
0810a859e1 | ||
|
|
680b0019c1 | ||
|
|
cfbd822862 | ||
|
|
7cb9221339 |
No files matched your search
@@ -1,5 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.3] — 2026-05-08
|
||||
|
||||
### Fixed — Dashboard
|
||||
|
||||
- **Tile strip is now the same width as the charts below it.** The
|
||||
small negative-margin bleed that helped the snap-scroll feel on
|
||||
mobile was leaking onto desktop and made the tile row 16 px wider
|
||||
than the chart strip. Fixed at `md` and up.
|
||||
- **Tiles all have the same height.** Blood pressure used to render
|
||||
as two stacked tiles inside one slot which made it taller than
|
||||
every neighbour. Sys/dia now share **one** tile and display as
|
||||
`117/79 mmHg` with one trend arrow. Tile count drops from 6 to 5.
|
||||
- **Settings sub-section "Übersicht" renamed to "Dashboard"** —
|
||||
matches the label in the main nav.
|
||||
- **"Persönliche Zielwerte" is its own settings section now** at
|
||||
`/settings/thresholds`, instead of being buried inside the
|
||||
Dashboard settings panel under the layout customizer.
|
||||
|
||||
### Fixed — Settings
|
||||
|
||||
- **API & Tokens tables fit the content column on desktop.**
|
||||
- **About → "Auf Updates prüfen" works again.** Production CSP
|
||||
blocked the direct call to `api.github.com` — the check now goes
|
||||
through a server-side proxy. The page also auto-checks once on
|
||||
mount when the previous result is older than 24 h, and shows a
|
||||
"Zuletzt geprüft am …" timestamp so you can tell when the answer
|
||||
was confirmed.
|
||||
- **About restructured into three titled cards** — HealthLog (version
|
||||
+ license inline, no boxed badge), Quellen & Dokumentation, and
|
||||
Updates.
|
||||
- **KI provider — OpenAI users can enter their own API key.** Schema
|
||||
didn't have a column for it before; added it, plumbed it through
|
||||
the resolver and test endpoint, surfaced the input in the Settings
|
||||
→ KI panel.
|
||||
- **Model field is now a dropdown** with provider-specific presets
|
||||
plus an "Eigenes…" option for power users.
|
||||
- **"Mit ChatGPT verbinden" no longer dead-ends on chatgpt.com.** The
|
||||
OAuth URL was missing its `client_id`; with `CODEX_OAUTH_CLIENT_ID`
|
||||
set the handshake completes. When the env var isn't configured the
|
||||
button is hidden and a hint points the user at the API-key path.
|
||||
|
||||
### Improved — KI Insights
|
||||
|
||||
- **AI can illustrate findings with `metric:MOOD` charts inline.** A
|
||||
mood drift or adherence-risk finding now renders the dedicated
|
||||
Mood chart under the paragraph instead of just prose.
|
||||
- **Every finding must cite a concrete number** ("138/85 mmHg",
|
||||
"+0.4 mmol/L vs 30d-avg") rather than adjectives. Findings without
|
||||
a snapshot anchor are now omitted; cardinality is variable 0–8
|
||||
sorted by salience. Generic boilerplate ("drink enough water",
|
||||
"consult your doctor", etc.) is explicitly forbidden outside the
|
||||
disclaimer.
|
||||
|
||||
### Improved — Admin
|
||||
|
||||
- **Bug-report feature has an explicit on/off toggle.** Previously
|
||||
the only way to hide the report button was to clear the encrypted
|
||||
GitHub token, which forced a re-entry on resume.
|
||||
|
||||
### Improved — Polish
|
||||
|
||||
- **"Trennen" looks the same everywhere** (Withings, moodLog,
|
||||
KI/Codex). Outlined button with red text — same affordance for
|
||||
the same action.
|
||||
|
||||
## [1.4.2] — 2026-05-08
|
||||
|
||||
### Fixed — Production deploy hotfixes
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ services:
|
||||
# to the last release that's verifiably running on self-host installs to
|
||||
# rule out runtime / Docker-network issues. No `build:` block on purpose:
|
||||
# we want Coolify to PULL the prebuilt image, not build from source.
|
||||
image: ghcr.io/mbombeck/healthlog:1.4.0
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
container_name: healthlog-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+16
-4
@@ -890,8 +890,12 @@
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"description": "Kachel-Layout, Schwellwerte."
|
||||
"title": "Dashboard",
|
||||
"description": "Kachel-Layout und Reihenfolge."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Persönliche Zielwerte",
|
||||
"description": "Eigene Zielbereiche für jede Metrik."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Auswertungen",
|
||||
@@ -1162,9 +1166,15 @@
|
||||
"repository": "Quellcode",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"checkUpdates": "Auf Updates prüfen",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Jetzt prüfen",
|
||||
"checking": "Prüfe…",
|
||||
"lastChecked": "Zuletzt geprüft: {time}",
|
||||
"lastCheckedNever": "Noch nicht geprüft",
|
||||
"upToDate": "Du nutzt die aktuelle Version",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
"newerAvailable": "Neue Version: {tag}",
|
||||
"checkFailed": "Konnte GitHub nicht erreichen — versuche es später erneut"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
@@ -1263,6 +1273,8 @@
|
||||
"monitoringSave": "Monitoring speichern",
|
||||
"bugReportGithub": "Bugreport (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
|
||||
"bugReportEnabledLabel": "Bug-Reports aktiviert",
|
||||
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue-Token",
|
||||
|
||||
+15
-3
@@ -891,7 +891,11 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Tile layout, thresholds."
|
||||
"description": "Tile layout and order."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Personal targets",
|
||||
"description": "Override target ranges for every metric."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Insights",
|
||||
@@ -1162,9 +1166,15 @@
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"checkUpdates": "Check for updates",
|
||||
"linksHeading": "Sources & docs",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Check now",
|
||||
"checking": "Checking…",
|
||||
"lastChecked": "Last checked: {time}",
|
||||
"lastCheckedNever": "Not checked yet",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"checkFailed": "Could not reach GitHub — try again later"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -1263,6 +1273,8 @@
|
||||
"monitoringSave": "Save monitoring",
|
||||
"bugReportGithub": "Bug report (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- v1.4.3: per-user OpenAI API key.
|
||||
--
|
||||
-- Until this release the schema stored Anthropic and Local-provider API
|
||||
-- keys but no field for the OPENAI provider, even though OPENAI was a
|
||||
-- valid value in `users.ai_provider`. Users who picked it had no place
|
||||
-- in the UI to drop their key and the runtime fell through to whichever
|
||||
-- admin-level fallback was configured. This column is the missing slot.
|
||||
--
|
||||
-- AES-256-GCM encrypted at the application layer (`src/lib/crypto.ts`)
|
||||
-- before the value lands on disk. Plaintext keys never touch Postgres
|
||||
-- backups.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "ai_openai_key_encrypted" TEXT;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v1.4.3: explicit on/off switch for the bug-report surface.
|
||||
--
|
||||
-- Until this release the bug-report feature was implicitly gated on
|
||||
-- "is the GitHub token + repo configured?". That coupling meant an
|
||||
-- admin who wanted to *temporarily* hide the bug-report button (e.g.
|
||||
-- during a deploy freeze) had to clear the encrypted token, which
|
||||
-- forced a re-entry on resume — destructive for a reversible
|
||||
-- intent.
|
||||
--
|
||||
-- Default `true` so existing instances with credentials configured
|
||||
-- keep the feature visible after the upgrade. Admins who want it off
|
||||
-- flip the switch in `/admin/integrations` (or whichever section
|
||||
-- mounts the bug-report card).
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN "bug_report_enabled" BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -70,6 +70,11 @@ model User {
|
||||
aiBaseUrl String? @map("ai_base_url")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
|
||||
// v1.4.3: per-user OpenAI API key for the OPENAI provider — previously
|
||||
// the schema only stored Anthropic and Local keys, leaving OPENAI without
|
||||
// a credential surface. Encrypted with the active ENCRYPTION_KEYS entry
|
||||
// before storage.
|
||||
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
|
||||
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
@@ -461,6 +466,11 @@ model AppSettings {
|
||||
glitchtipEnvironment String? @map("glitchtip_environment")
|
||||
githubIssueTokenEncrypted String? @map("github_issue_token_encrypted")
|
||||
githubIssueRepo String? @map("github_issue_repo")
|
||||
// v1.4.3: explicit toggle so an admin can hide the bug-report
|
||||
// surface without nuking the token+repo config. Default true so
|
||||
// existing instances with credentials don't accidentally lose
|
||||
// the feature on upgrade.
|
||||
bugReportEnabled Boolean @default(true) @map("bug_report_enabled")
|
||||
|
||||
// Medication reminder thresholds (minutes after windowEnd)
|
||||
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
|
||||
|
||||
@@ -41,6 +41,7 @@ export const GET = apiHandler(async () => {
|
||||
bugReportConfigured: Boolean(
|
||||
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings?.bugReportEnabled ?? true,
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
@@ -73,6 +74,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"moodLogGlobal",
|
||||
"bugReportEnabled",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
@@ -220,6 +222,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
bugReportConfigured: Boolean(
|
||||
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings.bugReportEnabled,
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
|
||||
@@ -190,11 +190,11 @@ export const GET = apiHandler(async () => {
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt.toISOString() ?? null,
|
||||
lastErrorAt: lastErrorEntry?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt.toISOString() ?? null,
|
||||
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
@@ -203,13 +203,13 @@ export const GET = apiHandler(async () => {
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt.toISOString() ?? null,
|
||||
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt.toISOString() ?? null,
|
||||
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
|
||||
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt.toISOString() ?? null,
|
||||
lastLoginAt: lastLoginEntry?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const overrideSchema = z
|
||||
baseUrl: z.string().url().max(2048).optional().nullable(),
|
||||
anthropicKey: z.string().min(1).max(500).optional().nullable(),
|
||||
localKey: z.string().min(1).max(500).optional().nullable(),
|
||||
openaiKey: z.string().min(1).max(500).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
generatePKCE,
|
||||
generateState,
|
||||
buildAuthorizationUrl,
|
||||
CodexOAuthNotConfiguredError,
|
||||
isCodexOAuthConfigured,
|
||||
} from "@/lib/ai/codex-oauth";
|
||||
import { cookies } from "next/headers";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -12,6 +14,17 @@ import { annotate } from "@/lib/logging/context";
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Surface configuration errors as 503 instead of silently redirecting
|
||||
// to a dead chatgpt.com URL. The v1.4.2 build dropped the `client_id`
|
||||
// out of the authorize URL — the user landed on the chatgpt.com login
|
||||
// page, no callback ever fired, and the connect button "did nothing".
|
||||
if (!isCodexOAuthConfigured()) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
|
||||
);
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
@@ -35,11 +48,21 @@ export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
cookieStore.set("codex_verifier", verifier, cookieOptions);
|
||||
cookieStore.set("codex_state", state, cookieOptions);
|
||||
|
||||
const authUrl = buildAuthorizationUrl({
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
redirectUri,
|
||||
});
|
||||
let authUrl: string;
|
||||
try {
|
||||
authUrl = buildAuthorizationUrl({
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
redirectUri,
|
||||
});
|
||||
} catch (err) {
|
||||
// Race between the env check above and the URL build — extremely
|
||||
// unlikely but the typed error path is cheap insurance.
|
||||
if (err instanceof CodexOAuthNotConfiguredError) {
|
||||
throw new HttpError(503, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
annotate({ action: { name: "codex.oauth.authorize" } });
|
||||
|
||||
return NextResponse.redirect(authUrl);
|
||||
|
||||
@@ -36,9 +36,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.3: explicit toggle. When the admin disables bug reports we
|
||||
// return 503 immediately — the client already renders a "not
|
||||
// configured" UI for that status, so the report button gracefully
|
||||
// disappears without needing additional client-side wiring.
|
||||
if (appSettings && appSettings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
|
||||
@@ -246,7 +246,7 @@ export const GET = apiHandler(async () => {
|
||||
unit: METRIC_UNITS.weight,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
updatedAt: latest?.at?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ export const GET = apiHandler(async () => {
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
updatedAt:
|
||||
latestSys?.at.toISOString() ?? latestDia?.at.toISOString() ?? null,
|
||||
latestSys?.at?.toISOString() ?? latestDia?.at?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ export const GET = apiHandler(async () => {
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
updatedAt: latest?.at?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
// Only expose detailed info to authenticated admins
|
||||
const session = await getSession().catch(() => null);
|
||||
if (session?.user.role === "ADMIN") {
|
||||
if (session?.user?.role === "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isCodexOAuthConfigured } from "@/lib/ai/codex-oauth";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -28,6 +29,11 @@ export const GET = apiHandler(async () => {
|
||||
codexStatus: dbUser?.codexConnectionStatus ?? "disconnected",
|
||||
codexConnectedAt: dbUser?.codexConnectedAt ?? null,
|
||||
hasAdminKey: !!settings?.adminAiKeyEncrypted,
|
||||
// v1.4.3: surface whether the operator has a `CODEX_OAUTH_CLIENT_ID`
|
||||
// configured. Without it the Connect-with-ChatGPT flow is dead, so
|
||||
// the UI hides the button instead of redirecting to a chatgpt.com
|
||||
// login the user can never complete.
|
||||
codexOauthConfigured: isCodexOAuthConfigured(),
|
||||
privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
lastInsightAt: dbUser?.insightsCachedAt ?? null,
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export const GET = apiHandler(async () => {
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,6 +35,10 @@ export const GET = apiHandler(async () => {
|
||||
? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
hasLocalKey: Boolean(u?.aiLocalKeyEncrypted),
|
||||
hasOpenaiKey: Boolean(u?.aiOpenaiKeyEncrypted),
|
||||
openaiKeyPreview: u?.aiOpenaiKeyEncrypted
|
||||
? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +105,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (body.openaiKey !== undefined) {
|
||||
if (body.openaiKey === null || body.openaiKey === "") {
|
||||
updates.aiOpenaiKeyEncrypted = null;
|
||||
} else if (typeof body.openaiKey === "string") {
|
||||
updates.aiOpenaiKeyEncrypted = encrypt(body.openaiKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new HttpError(422, "No valid fields");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/version/check-updates
|
||||
*
|
||||
* Server-side proxy for the GitHub Releases API. Required because the
|
||||
* production CSP only whitelists `'self'` + Withings for `connect-src`
|
||||
* — fetching `https://api.github.com/...` directly from the browser is
|
||||
* silently blocked, which is why the v1.4.2 "Check for updates" button
|
||||
* appeared to do nothing. Proxying through `/api` keeps the CSP locked
|
||||
* down while the server (no CSP attached) fetches GitHub on the user's
|
||||
* behalf.
|
||||
*
|
||||
* Compares the running app version (from `package.json`) against the
|
||||
* latest GitHub release tag and returns one of three states:
|
||||
* - `up_to_date` — running version >= latest release
|
||||
* - `newer_available` — running version < latest release; payload
|
||||
* includes the tag name and HTML URL so the UI can deep-link
|
||||
* - `unknown` — couldn't reach GitHub (network issue, rate-limit,
|
||||
* repo missing the release manifest); UI surfaces a non-blocking
|
||||
* warning so the user can retry
|
||||
*
|
||||
* Responses are not cached at the route level. The browser bears the
|
||||
* load with React Query's `staleTime`, so a single user spamming
|
||||
* "Check now" still hits the GH API at most every 30 seconds.
|
||||
*/
|
||||
|
||||
const RELEASES_URL =
|
||||
"https://api.github.com/repos/MBombeck/HealthLog/releases/latest";
|
||||
|
||||
interface GithubReleaseShape {
|
||||
tag_name?: string;
|
||||
html_url?: string;
|
||||
name?: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
function stripV(s: string): string {
|
||||
return s.replace(/^v/i, "").trim();
|
||||
}
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const partsA = stripV(a)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const partsB = stripV(b)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const len = Math.max(partsA.length, partsB.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const ai = partsA[i] ?? 0;
|
||||
const bi = partsB[i] ?? 0;
|
||||
if (Number.isNaN(ai) || Number.isNaN(bi)) {
|
||||
// Pre-release suffixes — fall back to lexical ordering of the
|
||||
// stripped strings so `1.4.3-rc.1` sorts before `1.4.3`.
|
||||
const sA = stripV(a);
|
||||
const sB = stripV(b);
|
||||
return sA < sB ? -1 : sA > sB ? 1 : 0;
|
||||
}
|
||||
if (ai < bi) return -1;
|
||||
if (ai > bi) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
// Auth-gated: there's no reason an anonymous client should poll the
|
||||
// GH API through us. Cookie OR bearer token both satisfy this.
|
||||
await requireAuth();
|
||||
annotate({ action: { name: "version.check_updates" } });
|
||||
|
||||
const current = packageJson.version;
|
||||
|
||||
let release: GithubReleaseShape | null = null;
|
||||
const callStart = Date.now();
|
||||
try {
|
||||
const res = await fetch(RELEASES_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
// GitHub anon rate limit is 60/hr per IP — tight but not zero.
|
||||
// We don't pass a token because the repo is public and the route
|
||||
// is auth-gated against our users.
|
||||
cache: "no-store",
|
||||
});
|
||||
getEvent()?.addExternalCall({
|
||||
service: "github_releases_api",
|
||||
method: "GET",
|
||||
duration_ms: Date.now() - callStart,
|
||||
status: res.status,
|
||||
});
|
||||
if (!res.ok) {
|
||||
annotate({
|
||||
meta: {
|
||||
status: res.status,
|
||||
outcome: "github_unreachable",
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
status: "unknown" as const,
|
||||
current,
|
||||
reason: `github_status_${res.status}`,
|
||||
});
|
||||
}
|
||||
release = (await res.json()) as GithubReleaseShape;
|
||||
} catch (error) {
|
||||
getEvent()?.addExternalCall({
|
||||
service: "github_releases_api",
|
||||
method: "GET",
|
||||
duration_ms: Date.now() - callStart,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
// Network glitch, DNS hiccup, GH outage. Don't fail the request —
|
||||
// tell the UI it's unknown so it can retry without showing a red
|
||||
// banner the user can't act on.
|
||||
annotate({
|
||||
meta: {
|
||||
outcome: "fetch_threw",
|
||||
message: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
status: "unknown" as const,
|
||||
current,
|
||||
reason: "network_error",
|
||||
});
|
||||
}
|
||||
|
||||
const latestTag = release?.tag_name?.trim();
|
||||
if (!latestTag) {
|
||||
return apiError("GitHub release manifest missing tag_name", 502);
|
||||
}
|
||||
|
||||
const cmp = compareSemver(current, latestTag);
|
||||
if (cmp >= 0) {
|
||||
return apiSuccess({
|
||||
status: "up_to_date" as const,
|
||||
current,
|
||||
latest_tag: latestTag,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
status: "newer_available" as const,
|
||||
current,
|
||||
latest_tag: latestTag,
|
||||
html_url: release.html_url ?? null,
|
||||
published_at: release.published_at ?? null,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -633,10 +633,10 @@ export default function InsightsPage() {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const w = analytics?.summaries?.WEIGHT;
|
||||
const sys = analytics?.summaries.BLOOD_PRESSURE_SYS;
|
||||
const dia = analytics?.summaries.BLOOD_PRESSURE_DIA;
|
||||
const p = analytics?.summaries.PULSE;
|
||||
const bf = analytics?.summaries.BODY_FAT;
|
||||
const sys = analytics?.summaries?.BLOOD_PRESSURE_SYS;
|
||||
const dia = analytics?.summaries?.BLOOD_PRESSURE_DIA;
|
||||
const p = analytics?.summaries?.PULSE;
|
||||
const bf = analytics?.summaries?.BODY_FAT;
|
||||
const showBodyFatCard = (bf?.count ?? 0) > 0;
|
||||
const moodSummary = data?.moodSummary;
|
||||
const showMoodCard = (moodSummary?.count ?? 0) > 0;
|
||||
@@ -710,7 +710,7 @@ export default function InsightsPage() {
|
||||
classifyRangeValue(bmiAvg30, bmiRange),
|
||||
]);
|
||||
const bpMedications =
|
||||
data?.medications.filter(
|
||||
data?.medications?.filter(
|
||||
(medication) => medication.category === "BLOOD_PRESSURE",
|
||||
) ?? [];
|
||||
const bpMedicationCompliance30 =
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function NotificationsPage() {
|
||||
});
|
||||
|
||||
function isEnabled(channelId: string, eventType: string): boolean {
|
||||
const pref = data?.preferences.find(
|
||||
const pref = data?.preferences?.find(
|
||||
(p) => p.channelId === channelId && p.eventType === eventType,
|
||||
);
|
||||
// Opt-out model: default is ON when no preference row exists
|
||||
|
||||
+48
-64
@@ -198,12 +198,12 @@ export default function DashboardPage() {
|
||||
});
|
||||
|
||||
const w = data?.summaries?.WEIGHT;
|
||||
const sys = data?.summaries.BLOOD_PRESSURE_SYS;
|
||||
const dia = data?.summaries.BLOOD_PRESSURE_DIA;
|
||||
const p = data?.summaries.PULSE;
|
||||
const bf = data?.summaries.BODY_FAT;
|
||||
const sleepSummary = data?.summaries.SLEEP_DURATION;
|
||||
const stepsSummary = data?.summaries.ACTIVITY_STEPS;
|
||||
const sys = data?.summaries?.BLOOD_PRESSURE_SYS;
|
||||
const dia = data?.summaries?.BLOOD_PRESSURE_DIA;
|
||||
const p = data?.summaries?.PULSE;
|
||||
const bf = data?.summaries?.BODY_FAT;
|
||||
const sleepSummary = data?.summaries?.SLEEP_DURATION;
|
||||
const stepsSummary = data?.summaries?.ACTIVITY_STEPS;
|
||||
const moodSummary = moodData?.summary;
|
||||
|
||||
// Resolve full dashboard layout — controls visibility + order of every widget
|
||||
@@ -460,66 +460,50 @@ export default function DashboardPage() {
|
||||
});
|
||||
}
|
||||
if (showBpCards) {
|
||||
// BP renders as ONE tile with paired sys/dia values (e.g. "117/79")
|
||||
// so the strip width matches the chart strip below — and so the
|
||||
// tile count stays at 5 instead of 6 with a wrap-around. The
|
||||
// sparkline/trend arrow follows the systolic value (the common
|
||||
// primary signal); the colour-banding tooltips cover sys only
|
||||
// because dia uses a different target range and stuffing both
|
||||
// into one hint became unreadable on mobile.
|
||||
trendCards.push({
|
||||
id: "bp",
|
||||
order: widgetOrder("bp"),
|
||||
node: (
|
||||
<React.Fragment key="bp">
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureSys")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureDia")}
|
||||
latest={dia?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={dia?.avg7 ?? null}
|
||||
avg30={dia?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpDiaRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpDiaRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
</React.Fragment>
|
||||
<TrendCard
|
||||
key="bp"
|
||||
label={t("dashboard.bloodPressure")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
secondary={{
|
||||
latest: dia?.latest ?? null,
|
||||
avg7: dia?.avg7 ?? null,
|
||||
avg30: dia?.avg30 ?? null,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -868,14 +852,14 @@ export default function DashboardPage() {
|
||||
* touch.
|
||||
*/}
|
||||
<div
|
||||
className="-mx-2 flex snap-x snap-mandatory gap-3 overflow-x-auto px-2 pb-2"
|
||||
className="-mx-2 flex snap-x snap-mandatory items-stretch gap-3 overflow-x-auto px-2 pb-2 md:mx-0 md:px-0"
|
||||
data-slot="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="shrink-0 grow basis-[10rem] snap-start"
|
||||
className="flex shrink-0 grow basis-[10rem] snap-start"
|
||||
>
|
||||
{entry.node}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DashboardSection } from "@/components/settings/dashboard-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
import { ThresholdsSettingsSection } from "@/components/settings/thresholds-settings-section";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
@@ -42,6 +43,7 @@ const SECTION_COMPONENTS: Record<
|
||||
integrations: IntegrationsSection,
|
||||
notifications: NotificationsSection,
|
||||
dashboard: DashboardSection,
|
||||
thresholds: ThresholdsSettingsSection,
|
||||
api: ApiSection,
|
||||
advanced: AdvancedSection,
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface AdminSettings {
|
||||
glitchtipEnvironment: string | null;
|
||||
bugReportRepo: string | null;
|
||||
bugReportConfigured: boolean;
|
||||
bugReportEnabled: boolean;
|
||||
reminderLateMinutes: number;
|
||||
reminderMissedMinutes: number;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
@@ -21,6 +22,7 @@ export function BugReportSection({ id }: { id: string }) {
|
||||
const bugReportRepoValue =
|
||||
bugReportRepoDraft ?? settings?.bugReportRepo ?? "";
|
||||
const configured = settings?.bugReportConfigured ?? false;
|
||||
const enabled = settings?.bugReportEnabled ?? true;
|
||||
|
||||
function saveBugReportSettings() {
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -38,6 +40,10 @@ export function BugReportSection({ id }: { id: string }) {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEnabled(next: boolean) {
|
||||
updateSettings.mutate({ bugReportEnabled: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
@@ -62,6 +68,23 @@ export function BugReportSection({ id }: { id: string }) {
|
||||
{t("admin.bugReportGithubDescription")}
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/40 mt-4 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.bugReportEnabledLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.bugReportEnabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={toggleEnabled}
|
||||
disabled={updateSettings.isPending}
|
||||
aria-label={t("admin.bugReportEnabledLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-repo" className="text-xs">
|
||||
|
||||
@@ -49,7 +49,7 @@ export function FeedbackInboxSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useSystemStatus();
|
||||
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
|
||||
const githubConfigured = Boolean(status?.integrations?.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
@@ -65,7 +65,7 @@ export function FeedbackInboxSection({ id }: { id: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
const counts = data?.meta.countsByStatus ?? {};
|
||||
const counts = data?.meta?.countsByStatus ?? {};
|
||||
|
||||
function refresh() {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
|
||||
@@ -113,7 +113,7 @@ export function FeedbackInboxSection({ id }: { id: string }) {
|
||||
{t("admin.feedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : !data?.items.length ? (
|
||||
) : !data?.items?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.noEntries")}
|
||||
</p>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function LoginOverviewSection({ id }: { id: string }) {
|
||||
|
||||
const entries =
|
||||
filter === "failed"
|
||||
? data?.entries.filter((e) => e.action === "auth.login.failed")
|
||||
? data?.entries?.filter((e) => e.action === "auth.login.failed")
|
||||
: data?.entries;
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
interface SecondaryMetric {
|
||||
/** Sub-value latest reading (e.g. diastolic when latest is systolic). */
|
||||
latest: number | null;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
}
|
||||
|
||||
interface TrendCardProps {
|
||||
label: string;
|
||||
latest: number | null;
|
||||
@@ -23,6 +30,10 @@ interface TrendCardProps {
|
||||
avg30Hint?: React.ReactNode;
|
||||
slope30: TrendSlope | null;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
/** Optional second value rendered next to the primary as `X / Y` (used for
|
||||
* paired metrics like blood-pressure systolic/diastolic so a single tile
|
||||
* shows both numbers). */
|
||||
secondary?: SecondaryMetric;
|
||||
}
|
||||
|
||||
export function TrendCard({
|
||||
@@ -37,6 +48,7 @@ export function TrendCard({
|
||||
avg30Hint,
|
||||
slope30,
|
||||
icon: Icon,
|
||||
secondary,
|
||||
}: TrendCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -58,8 +70,19 @@ export function TrendCard({
|
||||
|
||||
const formatValue = (value: number) => fmt.number(value, 1);
|
||||
|
||||
const renderPair = (
|
||||
primary: number | null,
|
||||
secondaryValue: number | null | undefined,
|
||||
): string => {
|
||||
if (primary === null) return "—";
|
||||
if (secondary && secondaryValue !== null && secondaryValue !== undefined) {
|
||||
return `${formatValue(primary)}/${formatValue(secondaryValue)}`;
|
||||
}
|
||||
return formatValue(primary);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-3">
|
||||
<div className="bg-card border-border flex h-full flex-col rounded-xl border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
{label}
|
||||
@@ -68,13 +91,15 @@ export function TrendCard({
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold">
|
||||
{latest !== null ? formatValue(latest) : "—"}
|
||||
{latest !== null
|
||||
? renderPair(latest, secondary?.latest)
|
||||
: "—"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">{unit}</span>
|
||||
{slope30 && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="text-muted-foreground mt-1 flex gap-3 text-xs">
|
||||
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs">
|
||||
{avg7 !== null && (
|
||||
<span>
|
||||
{t("charts.avg7dShort")}:{" "}
|
||||
@@ -82,7 +107,7 @@ export function TrendCard({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("font-medium", avg7ColorClass)}>
|
||||
{formatValue(avg7)}
|
||||
{renderPair(avg7, secondary?.avg7)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-muted border-border text-foreground">
|
||||
@@ -91,7 +116,7 @@ export function TrendCard({
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cn("font-medium", avg7ColorClass)}>
|
||||
{formatValue(avg7)}
|
||||
{renderPair(avg7, secondary?.avg7)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -103,7 +128,7 @@ export function TrendCard({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("font-medium", avg30ColorClass)}>
|
||||
{formatValue(avg30)}
|
||||
{renderPair(avg30, secondary?.avg30)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-muted border-border text-foreground">
|
||||
@@ -112,7 +137,7 @@ export function TrendCard({
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cn("font-medium", avg30ColorClass)}>
|
||||
{formatValue(avg30)}
|
||||
{renderPair(avg30, secondary?.avg30)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { HealthChart } from "@/components/charts/health-chart";
|
||||
import { MoodChart } from "@/components/charts/mood-chart";
|
||||
import {
|
||||
parseChartTokens,
|
||||
stripChartTokens,
|
||||
tokenKind,
|
||||
tokenToMetric,
|
||||
type ChartToken,
|
||||
} from "@/lib/insights/chart-tokens";
|
||||
@@ -238,6 +240,7 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
|
||||
<div data-slot="insight-inline-charts" className="space-y-3">
|
||||
{unique.map((token) => {
|
||||
const metric = tokenToMetric(token);
|
||||
const kind = tokenKind(token);
|
||||
const titleKey = INLINE_CHART_TITLE_KEYS[metric];
|
||||
const title = titleKey ? t(titleKey) : metric;
|
||||
return (
|
||||
@@ -246,7 +249,11 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
|
||||
data-slot="insight-inline-chart"
|
||||
data-metric={metric}
|
||||
>
|
||||
<HealthChart types={[metric]} title={title} />
|
||||
{kind === "mood" ? (
|
||||
<MoodChart />
|
||||
) : (
|
||||
<HealthChart types={[metric]} title={title} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -287,7 +287,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{data?.meta.total !== undefined && (
|
||||
{data?.meta?.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("measurements.measurementCount", {
|
||||
count: fmt.integer(data.meta.total),
|
||||
@@ -300,7 +300,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : !data?.measurements.length ? (
|
||||
) : !data?.measurements?.length ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
{t("measurements.noMeasurements")}
|
||||
</div>
|
||||
|
||||
@@ -362,7 +362,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-end">
|
||||
{data?.meta.total !== undefined && (
|
||||
{data?.meta?.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("medications.intakeCount", { count: data.meta.total })}
|
||||
</span>
|
||||
@@ -373,7 +373,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : !data?.events.length ? (
|
||||
) : !data?.events?.length ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
{t("medications.noIntakesYet")}
|
||||
</div>
|
||||
|
||||
@@ -243,9 +243,9 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const rate7 = compliance?.compliance7.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30.rate ?? 0;
|
||||
const streak = compliance?.compliance7.streak ?? 0;
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
const categoryLabels: Record<string, string> = {
|
||||
BLOOD_PRESSURE: t("medications.categoryBloodPressure"),
|
||||
VITAMIN: t("medications.categoryVitamin"),
|
||||
|
||||
@@ -259,7 +259,7 @@ export function MedicationForm({
|
||||
);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>(
|
||||
sortSchedules(
|
||||
initial?.schedules.length
|
||||
initial?.schedules?.length
|
||||
? initial.schedules.map((s) => {
|
||||
const recurrence = {
|
||||
daysOfWeek: s.daysOfWeek ?? [],
|
||||
|
||||
@@ -283,7 +283,7 @@ export function MoodList() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{data?.meta.total !== undefined && (
|
||||
{data?.meta?.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("mood.entryCount", {
|
||||
count: fmt.integer(data.meta.total),
|
||||
@@ -296,7 +296,7 @@ export function MoodList() {
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : !data?.entries.length ? (
|
||||
) : !data?.entries?.length ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
{t("mood.noEntries")}
|
||||
</div>
|
||||
|
||||
@@ -59,18 +59,12 @@ const ITEM_LABEL_KEYS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
interface MedicationsResponse {
|
||||
data?: { medications?: Array<{ id: string }> };
|
||||
interface AnalyticsData {
|
||||
summaries?: Record<string, { count?: number } | undefined>;
|
||||
}
|
||||
|
||||
interface AnalyticsResponse {
|
||||
data?: {
|
||||
summaries?: Record<string, { count?: number } | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
interface WithingsStatusResponse {
|
||||
data?: { connected?: boolean };
|
||||
interface WithingsStatus {
|
||||
connected?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationChannel {
|
||||
@@ -78,8 +72,8 @@ interface NotificationChannel {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationsResponse {
|
||||
data?: { channels?: NotificationChannel[] };
|
||||
interface NotificationsPreferences {
|
||||
channels?: NotificationChannel[];
|
||||
}
|
||||
|
||||
function readDismissedSet(): Set<ChecklistItemId> {
|
||||
@@ -150,59 +144,70 @@ export function GettingStartedChecklist() {
|
||||
// Light-weight queries: each fetch is small and cached by tanstack.
|
||||
// We rely on the React Query cache the dashboard already uses for
|
||||
// analytics, so this won't fire a second request when the dashboard
|
||||
// and the checklist mount together.
|
||||
const { data: analyticsRes } = useQuery<AnalyticsResponse>({
|
||||
// and the checklist mount together. Critically, every queryFn here
|
||||
// must return `json.data` (the unwrapped shape) so it matches the
|
||||
// canonical consumer (dashboard / medications page / notifications
|
||||
// page / integrations section). Returning `res.json()` would write
|
||||
// the full `{data, error}` envelope into the shared cache, which then
|
||||
// makes the canonical consumers' `data?.summaries` / `data?.length`
|
||||
// reads silently return undefined — that's how the v1.4.2 dashboard
|
||||
// ended up rendering only the mood tile.
|
||||
const { data: analyticsData } = useQuery<AnalyticsData>({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: medsRes } = useQuery<MedicationsResponse>({
|
||||
const { data: medsData } = useQuery<Array<{ id: string }>>({
|
||||
queryKey: ["medications"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/medications");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: withingsRes } = useQuery<WithingsStatusResponse>({
|
||||
const { data: withingsData } = useQuery<WithingsStatus>({
|
||||
queryKey: ["withings", "status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/withings/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: notificationsRes } = useQuery<NotificationsResponse>({
|
||||
const { data: notificationsData } = useQuery<NotificationsPreferences>({
|
||||
queryKey: ["notifications", "preferences"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/notifications/preferences");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const measurementCount = useMemo(() => {
|
||||
const summaries = analyticsRes?.data?.summaries ?? {};
|
||||
const summaries = analyticsData?.summaries ?? {};
|
||||
let total = 0;
|
||||
for (const key of Object.keys(summaries)) {
|
||||
total += summaries[key]?.count ?? 0;
|
||||
}
|
||||
return total;
|
||||
}, [analyticsRes]);
|
||||
}, [analyticsData]);
|
||||
|
||||
const medicationCount = medsRes?.data?.medications?.length ?? 0;
|
||||
const withingsConnected = withingsRes?.data?.connected === true;
|
||||
const notificationsConfigured = (notificationsRes?.data?.channels ?? []).some(
|
||||
const medicationCount = medsData?.length ?? 0;
|
||||
const withingsConnected = withingsData?.connected === true;
|
||||
const notificationsConfigured = (notificationsData?.channels ?? []).some(
|
||||
(channel) => channel?.enabled === true,
|
||||
);
|
||||
|
||||
|
||||
@@ -101,8 +101,13 @@ describe("settings sections — SSR smoke", () => {
|
||||
it("<AboutSection> renders version + license + check-for-updates button", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("About");
|
||||
// The new about.* keys resolve.
|
||||
expect(html).toContain("Check for updates");
|
||||
// The new about.* keys resolve. v1.4.3 split the page into three
|
||||
// titled cards (HealthLog identity, Sources & docs, Updates) and
|
||||
// shortened the button label from "Check for updates" to "Check now"
|
||||
// because the surrounding heading already names the section.
|
||||
expect(html).toContain("Updates");
|
||||
expect(html).toContain("Sources & docs");
|
||||
expect(html).toContain("Check now");
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
@@ -122,11 +127,16 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).toContain("Notifications");
|
||||
});
|
||||
|
||||
it("<DashboardSection> renders heading and embeds layout + thresholds", () => {
|
||||
it("<DashboardSection> renders heading and embeds the layout customizer", () => {
|
||||
// v1.4.3 split: the per-metric thresholds (Persönliche Zielwerte) moved
|
||||
// out into their own settings section under `/settings/thresholds`,
|
||||
// so the dashboard panel is now layout-only. Verifying the heading
|
||||
// says "Dashboard" (not "Übersicht" — the v1.4.2 placeholder) and the
|
||||
// layout slot still mounts.
|
||||
const html = render(<DashboardSection />);
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain('data-testid="dashboard-layout"');
|
||||
expect(html).toContain('data-testid="thresholds"');
|
||||
expect(html).not.toContain('data-testid="thresholds"');
|
||||
});
|
||||
|
||||
it("<ApiSection> renders endpoints + tokens cards", () => {
|
||||
@@ -141,6 +151,10 @@ describe("settings sections — SSR smoke", () => {
|
||||
|
||||
it("<AboutSection> resolves about.* keys in German", () => {
|
||||
const html = render(<AboutSection />, "de");
|
||||
expect(html).toContain("Auf Updates prüfen");
|
||||
// v1.4.3: button label shortened to "Jetzt prüfen" because the
|
||||
// section heading "Updates" already provides context.
|
||||
expect(html).toContain("Updates");
|
||||
expect(html).toContain("Jetzt prüfen");
|
||||
expect(html).toContain("Quellen & Dokumentation");
|
||||
});
|
||||
});
|
||||
@@ -35,14 +35,18 @@ function renderShell(props: {
|
||||
}
|
||||
|
||||
describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
it("declares exactly the eight sections agreed for v1.4", () => {
|
||||
it("declares exactly the nine sections agreed for v1.4.3", () => {
|
||||
// Order matters — `generateStaticParams()` and the sidebar derive their
|
||||
// ordering from this constant, so a reorder is a behaviour change.
|
||||
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
|
||||
// per-metric overrides moved to their own `thresholds` slug so it's
|
||||
// a top-level entry in the settings nav.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"thresholds",
|
||||
"ai",
|
||||
"api",
|
||||
"advanced",
|
||||
@@ -67,7 +71,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
});
|
||||
|
||||
describe("<SettingsShell>", () => {
|
||||
it("renders all eight section links — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
it("renders every section link — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
|
||||
@@ -138,7 +142,12 @@ describe("<SettingsShell>", () => {
|
||||
expect(html).toContain("Konto");
|
||||
expect(html).toContain("Integrationen");
|
||||
expect(html).toContain("Benachrichtigungen");
|
||||
expect(html).toContain("Übersicht");
|
||||
// v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now
|
||||
// "Dashboard" (matching the term users see in the main nav). The
|
||||
// per-metric overrides moved out into their own "Persönliche Zielwerte"
|
||||
// section, which is the new entry below the Dashboard one.
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain("Persönliche Zielwerte");
|
||||
expect(html).toContain("KI-Auswertungen");
|
||||
// API & Tokens is identical in both locales (proper noun + ampersand)
|
||||
expect(html).toContain("API & Tokens");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
@@ -9,14 +9,11 @@ import {
|
||||
GitBranch,
|
||||
Info,
|
||||
Loader2,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -30,42 +27,46 @@ interface VersionPayload {
|
||||
docs: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
type CheckUpdatesResult =
|
||||
| {
|
||||
status: "up_to_date";
|
||||
current: string;
|
||||
latest_tag: string;
|
||||
checked_at: string;
|
||||
}
|
||||
| {
|
||||
status: "newer_available";
|
||||
current: string;
|
||||
latest_tag: string;
|
||||
html_url: string | null;
|
||||
published_at: string | null;
|
||||
checked_at: string;
|
||||
}
|
||||
| { status: "unknown"; current: string; reason: string };
|
||||
|
||||
const LAST_CHECKED_KEY = "healthlog-about-last-checked-iso";
|
||||
// One day between auto-checks. We don't want every page-mount to spend a
|
||||
// GitHub-API rate-limit slot just to confirm the version is unchanged
|
||||
// 30 seconds after the previous one resolved. The user can always force
|
||||
// a re-check via the button.
|
||||
const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function readLastCheckedISO(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(LAST_CHECKED_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const RELEASES_API_URL =
|
||||
"https://api.github.com/repos/MBombeck/HealthLog/releases/latest";
|
||||
|
||||
/**
|
||||
* Compare a semantic version (e.g. "1.4.0") with a release tag (e.g.
|
||||
* "v1.4.0", "1.4.1"). Returns `true` when `tag` is strictly newer than
|
||||
* `current`. Falls back to lexical compare when the strings don't parse,
|
||||
* which is intentional — better to surface "newer available" on a parse miss
|
||||
* than silently swallow a release.
|
||||
*/
|
||||
function isNewer(current: string, tag: string): boolean {
|
||||
const stripV = (s: string) => s.replace(/^v/i, "").trim();
|
||||
const a = stripV(current)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const b = stripV(tag)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const maxLen = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const ai = a[i] ?? 0;
|
||||
const bi = b[i] ?? 0;
|
||||
if (Number.isNaN(ai) || Number.isNaN(bi)) {
|
||||
// Fall back to lexical compare for pre-release suffixes etc.
|
||||
return stripV(tag) > stripV(current);
|
||||
}
|
||||
if (bi > ai) return true;
|
||||
if (bi < ai) return false;
|
||||
function writeLastCheckedISO(iso: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(LAST_CHECKED_KEY, iso);
|
||||
} catch {
|
||||
/* storage may be full or disabled — silently skip */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function AboutSection() {
|
||||
@@ -85,42 +86,59 @@ export function AboutSection() {
|
||||
});
|
||||
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [updateResult, setUpdateResult] = useState<
|
||||
| { kind: "up_to_date" }
|
||||
| { kind: "newer"; tag: string; url: string }
|
||||
| { kind: "error" }
|
||||
| null
|
||||
>(null);
|
||||
const [updateResult, setUpdateResult] = useState<CheckUpdatesResult | null>(
|
||||
null,
|
||||
);
|
||||
const [lastCheckedISO, setLastCheckedISO] = useState<string | null>(() =>
|
||||
readLastCheckedISO(),
|
||||
);
|
||||
|
||||
async function handleCheckForUpdates() {
|
||||
if (!version) return;
|
||||
async function runCheck(): Promise<void> {
|
||||
setChecking(true);
|
||||
setUpdateResult(null);
|
||||
try {
|
||||
const res = await fetch(RELEASES_API_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
const res = await fetch("/api/version/check-updates");
|
||||
if (!res.ok) {
|
||||
setUpdateResult({ kind: "error" });
|
||||
setUpdateResult({
|
||||
status: "unknown",
|
||||
current: version?.version ?? "",
|
||||
reason: `http_${res.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const release = (await res.json()) as GithubRelease;
|
||||
if (isNewer(version.version, release.tag_name)) {
|
||||
setUpdateResult({
|
||||
kind: "newer",
|
||||
tag: release.tag_name,
|
||||
url: release.html_url,
|
||||
});
|
||||
} else {
|
||||
setUpdateResult({ kind: "up_to_date" });
|
||||
const json = await res.json();
|
||||
const result = json.data as CheckUpdatesResult;
|
||||
setUpdateResult(result);
|
||||
if (result.status !== "unknown") {
|
||||
const iso = new Date().toISOString();
|
||||
writeLastCheckedISO(iso);
|
||||
setLastCheckedISO(iso);
|
||||
}
|
||||
} catch {
|
||||
setUpdateResult({ kind: "error" });
|
||||
setUpdateResult({
|
||||
status: "unknown",
|
||||
current: version?.version ?? "",
|
||||
reason: "network_error",
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check on mount when the last successful check is older than the
|
||||
// refresh interval (or has never run). Keeps the UI populated without
|
||||
// requiring a click — but won't spam GitHub on every navigation.
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
const last = readLastCheckedISO();
|
||||
const lastMs = last ? Date.parse(last) : NaN;
|
||||
const stale =
|
||||
Number.isNaN(lastMs) || Date.now() - lastMs > AUTO_CHECK_INTERVAL_MS;
|
||||
if (stale) {
|
||||
void runCheck();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [version?.version]);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-about-title"
|
||||
@@ -138,6 +156,10 @@ export function AboutSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Identity card — version + license inline. The v1.4.2 layout
|
||||
stacked the license under the version inside its own boxed Badge,
|
||||
which read as a separate field even though the two values belong
|
||||
on the same row. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Info className="text-primary h-5 w-5" />
|
||||
@@ -152,69 +174,56 @@ export function AboutSection() {
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Package className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.version")}
|
||||
</dt>
|
||||
<dd className="font-mono text-sm font-medium">
|
||||
v{version.version}
|
||||
</dd>
|
||||
</div>
|
||||
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("settings.about.version")}
|
||||
</dt>
|
||||
<dd className="font-mono font-medium">v{version.version}</dd>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Scale className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.license")}
|
||||
</dt>
|
||||
<dd>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{version.license}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("settings.about.license")}
|
||||
</dt>
|
||||
<dd className="font-mono">{version.license}</dd>
|
||||
</div>
|
||||
|
||||
{version.buildSha && (
|
||||
<div className="flex items-start gap-3">
|
||||
<GitBranch className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.gitSha")}
|
||||
</dt>
|
||||
<dd className="font-mono text-sm">
|
||||
{version.buildSha.slice(0, 7)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("settings.about.gitSha")}
|
||||
</dt>
|
||||
<dd className="font-mono">{version.buildSha.slice(0, 7)}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{version.builtAt && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.builtAt", {
|
||||
time: fmt.dateTime(version.builtAt),
|
||||
})}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{fmt.date(version.builtAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("settings.about.builtAt", { time: "" }).trim() ||
|
||||
t("settings.about.gitSha")}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground">
|
||||
{fmt.date(version.builtAt)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{/* Sources & docs — every link card was untitled in the v1.4.2
|
||||
About surface, so the buttons floated alone with no heading
|
||||
telling the user what the section was for. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<BookOpen className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.about.linksHeading")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
@@ -249,24 +258,47 @@ export function AboutSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check for updates */}
|
||||
{/* Updates — proper heading + last-check timestamp + manual button
|
||||
that proxies through `/api/version/check-updates`. The v1.4.2
|
||||
version called `api.github.com` directly from the browser, which
|
||||
the production CSP blocked silently — that's why "nothing
|
||||
happened" when Marc clicked the button. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCheckForUpdates}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.about.checkUpdates")}
|
||||
</Button>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.about.updatesHeading")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{updateResult?.kind === "up_to_date" && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={runCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{checking
|
||||
? t("settings.about.checking")
|
||||
: t("settings.about.checkUpdates")}
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{lastCheckedISO
|
||||
? t("settings.about.lastChecked", {
|
||||
time: fmt.dateTime(lastCheckedISO),
|
||||
})
|
||||
: t("settings.about.lastCheckedNever")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{updateResult?.status === "up_to_date" && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-dracula-green mt-3 flex items-center gap-1.5 text-sm"
|
||||
@@ -276,29 +308,37 @@ export function AboutSection() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.kind === "newer" && (
|
||||
{updateResult?.status === "newer_available" && (
|
||||
<p role="status" className="mt-3 flex items-center gap-1.5 text-sm">
|
||||
<Sparkles className="text-dracula-purple h-4 w-4" />
|
||||
<a
|
||||
href={updateResult.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.tag,
|
||||
})}
|
||||
</a>
|
||||
{updateResult.html_url ? (
|
||||
<a
|
||||
href={updateResult.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
</a>
|
||||
) : (
|
||||
<span>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.kind === "error" && (
|
||||
{updateResult?.status === "unknown" && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive mt-3 flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{t("settings.testConnection.errors.connection_failed")}
|
||||
{t("settings.about.checkFailed")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,11 @@ interface InsightsSettings {
|
||||
codexStatus: string;
|
||||
codexConnectedAt: string | null;
|
||||
hasAdminKey: boolean;
|
||||
/** True when the operator has set `CODEX_OAUTH_CLIENT_ID` on this
|
||||
* instance. The UI hides the "Connect with ChatGPT" button when
|
||||
* false to avoid the v1.4.2 dead-end where the click bounced the
|
||||
* user to chatgpt.com without any OAuth flow. */
|
||||
codexOauthConfigured?: boolean;
|
||||
privacyMode: string;
|
||||
lastInsightAt: string | null;
|
||||
}
|
||||
@@ -29,8 +34,30 @@ interface UserAIProvider {
|
||||
hasAnthropicKey: boolean;
|
||||
anthropicKeyPreview: string | null;
|
||||
hasLocalKey: boolean;
|
||||
hasOpenaiKey: boolean;
|
||||
openaiKeyPreview: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model presets per provider — keeps users out of the freetext box for
|
||||
* the common case and signals which models the resolver will actually
|
||||
* use as defaults if the field is left blank. The `__custom__` sentinel
|
||||
* surfaces a freetext Input so power users can still target any model
|
||||
* the underlying API speaks (e.g. preview models, fine-tunes).
|
||||
*/
|
||||
const MODEL_PRESETS: Record<string, ReadonlyArray<string>> = {
|
||||
OPENAI: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-5", "o3-mini"],
|
||||
ANTHROPIC: [
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-7",
|
||||
"claude-haiku-4-5",
|
||||
"claude-3-5-sonnet-latest",
|
||||
],
|
||||
LOCAL: ["llama3.1:8b", "llama3.1:70b", "mistral", "qwen2.5"],
|
||||
};
|
||||
|
||||
const CUSTOM_MODEL_SENTINEL = "__custom__";
|
||||
|
||||
export function AiSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -250,8 +277,11 @@ function InsightsSettingsCard({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* v1.4.3: aligned with the Withings/moodLog disconnect
|
||||
buttons — outline + text-destructive so the same action
|
||||
reads the same everywhere in Settings. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive shrink-0"
|
||||
onClick={handleDisconnect}
|
||||
@@ -275,14 +305,21 @@ function InsightsSettingsCard({
|
||||
Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Mit ChatGPT verbinden
|
||||
</Button>
|
||||
{settings?.codexOauthConfigured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Mit ChatGPT verbinden
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert —
|
||||
nutze stattdessen einen eigenen API-Key unten.
|
||||
</p>
|
||||
)}
|
||||
{settings?.hasAdminKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Alternativ nutzt HealthLog den vom Administrator
|
||||
@@ -353,10 +390,12 @@ function InsightsSettingsCard({
|
||||
function UserAIProviderSubsection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [provider, setProvider] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [modelChoice, setModelChoice] = useState<string>("");
|
||||
const [customModel, setCustomModel] = useState<string>("");
|
||||
const [baseUrl, setBaseUrl] = useState<string>("");
|
||||
const [anthropicKey, setAnthropicKey] = useState<string>("");
|
||||
const [localKey, setLocalKey] = useState<string>("");
|
||||
const [openaiKey, setOpenaiKey] = useState<string>("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveOk, setSaveOk] = useState<boolean>(false);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
@@ -374,26 +413,57 @@ function UserAIProviderSubsection() {
|
||||
});
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
// The model dropdown reflects the saved value: an exact match against
|
||||
// the preset list selects that preset; anything else (or the empty
|
||||
// "use default" string) collapses to the custom-input mode so the
|
||||
// user keeps full control.
|
||||
const dataKey = data
|
||||
? `${data.provider ?? ""}|${data.model ?? ""}|${data.baseUrl ?? ""}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (dataKey && dataKey !== seededKey) {
|
||||
setSeededKey(dataKey);
|
||||
setProvider(data!.provider ?? "");
|
||||
setModel(data!.model ?? "");
|
||||
const nextProvider = data!.provider ?? "";
|
||||
setProvider(nextProvider);
|
||||
const presets = MODEL_PRESETS[nextProvider] ?? [];
|
||||
const savedModel = data!.model ?? "";
|
||||
if (savedModel && presets.includes(savedModel)) {
|
||||
setModelChoice(savedModel);
|
||||
setCustomModel("");
|
||||
} else if (savedModel) {
|
||||
setModelChoice(CUSTOM_MODEL_SENTINEL);
|
||||
setCustomModel(savedModel);
|
||||
} else {
|
||||
setModelChoice("");
|
||||
setCustomModel("");
|
||||
}
|
||||
setBaseUrl(data!.baseUrl ?? "");
|
||||
}
|
||||
|
||||
const presets = MODEL_PRESETS[provider] ?? [];
|
||||
const effectiveModel =
|
||||
modelChoice === CUSTOM_MODEL_SENTINEL
|
||||
? customModel.trim()
|
||||
: modelChoice.trim();
|
||||
|
||||
// Reset model dropdown when provider changes — the preset list is
|
||||
// provider-specific. Don't wipe the custom model the user typed in
|
||||
// case they wanted to keep using it (rare but cheap).
|
||||
function handleProviderChange(next: string) {
|
||||
setProvider(next);
|
||||
setModelChoice("");
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
provider: provider || null,
|
||||
model: model || null,
|
||||
model: effectiveModel || null,
|
||||
baseUrl: baseUrl || null,
|
||||
};
|
||||
if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim();
|
||||
if (localKey.trim()) body.localKey = localKey.trim();
|
||||
if (openaiKey.trim()) body.openaiKey = openaiKey.trim();
|
||||
const res = await fetch("/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -407,6 +477,7 @@ function UserAIProviderSubsection() {
|
||||
setSaveOk(true);
|
||||
setAnthropicKey("");
|
||||
setLocalKey("");
|
||||
setOpenaiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
},
|
||||
@@ -422,10 +493,11 @@ function UserAIProviderSubsection() {
|
||||
try {
|
||||
const overrideBody: Record<string, string> = {};
|
||||
if (provider) overrideBody.provider = provider;
|
||||
if (model.trim()) overrideBody.model = model.trim();
|
||||
if (effectiveModel) overrideBody.model = effectiveModel;
|
||||
if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim();
|
||||
if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim();
|
||||
if (localKey.trim()) overrideBody.localKey = localKey.trim();
|
||||
if (openaiKey.trim()) overrideBody.openaiKey = openaiKey.trim();
|
||||
const hasOverride = Object.keys(overrideBody).length > 0;
|
||||
const res = await fetch("/api/ai/test", {
|
||||
method: "POST",
|
||||
@@ -460,7 +532,8 @@ function UserAIProviderSubsection() {
|
||||
<p className="text-sm font-medium">KI-Provider (persönlich)</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen
|
||||
für Standard.
|
||||
für Standard. Für ChatGPT-Pro/Max-OAuth nutze den Verbinden-Button
|
||||
oben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -470,34 +543,80 @@ function UserAIProviderSubsection() {
|
||||
<select
|
||||
id="ai-provider-select"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
>
|
||||
<option value="">— Standard (Admin/Codex) —</option>
|
||||
<option value="OPENAI">OpenAI</option>
|
||||
<option value="OPENAI">OpenAI (API-Key)</option>
|
||||
<option value="ANTHROPIC">Anthropic (Claude)</option>
|
||||
<option value="LOCAL">Lokal (OpenAI-kompatibel)</option>
|
||||
<option value="CHATGPT_OAUTH">ChatGPT OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="ai-model-input">Modell</Label>
|
||||
<Input
|
||||
id="ai-model-input"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={
|
||||
provider === "ANTHROPIC"
|
||||
? "claude-3-5-sonnet-latest"
|
||||
: provider === "LOCAL"
|
||||
? "llama3:8b"
|
||||
: "gpt-4o-mini"
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<Label htmlFor="ai-model-select">Modell</Label>
|
||||
<select
|
||||
id="ai-model-select"
|
||||
value={modelChoice}
|
||||
onChange={(e) => setModelChoice(e.target.value)}
|
||||
disabled={!provider}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="">— Standard —</option>
|
||||
{presets.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
{provider && (
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>Eigenes…</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{modelChoice === CUSTOM_MODEL_SENTINEL && provider && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-model-custom">Eigener Modellname</Label>
|
||||
<Input
|
||||
id="ai-model-custom"
|
||||
value={customModel}
|
||||
onChange={(e) => setCustomModel(e.target.value)}
|
||||
placeholder={
|
||||
provider === "ANTHROPIC"
|
||||
? "claude-3-5-sonnet-latest"
|
||||
: provider === "LOCAL"
|
||||
? "llama3:8b"
|
||||
: "gpt-4o-mini"
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "OPENAI" && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-openai-key">
|
||||
OpenAI API Key
|
||||
{data?.hasOpenaiKey && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
(gespeichert {data.openaiKeyPreview})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ai-openai-key"
|
||||
value={openaiKey}
|
||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Eigener Key — keine Verbindung zu deinem ChatGPT-Abo. Für das
|
||||
Abo nutze den Verbinden-Button oben.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "ANTHROPIC" && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-anthropic-key">
|
||||
|
||||
@@ -71,7 +71,7 @@ function ApiEndpointsCard() {
|
||||
</p>
|
||||
|
||||
<div className="border-border mt-4 overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[760px] text-xs">
|
||||
<table className="w-full min-w-[760px] text-xs md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
@@ -237,7 +237,7 @@ function ApiTokensCard() {
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<table className="w-full min-w-[860px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
@@ -360,7 +360,7 @@ function ApiTokensCard() {
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function DashboardSection() {
|
||||
@@ -25,7 +24,6 @@ export function DashboardSection() {
|
||||
</header>
|
||||
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
<ThresholdsSection id="thresholds" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -644,7 +644,16 @@ function MoodLogCard() {
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
{/* v1.4.3: aligned with the Withings disconnect button —
|
||||
outline + text-destructive instead of solid destructive
|
||||
so the trigger reads as a reversible action; the actual
|
||||
"yes, disconnect" confirmation inside the dialog keeps
|
||||
its solid-red treatment. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Unlink className="mr-2 h-4 w-4" />
|
||||
{t("settings.moodLogDisconnect")}
|
||||
</Button>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
type LucideIcon,
|
||||
@@ -32,6 +33,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
integrations: Link2,
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
thresholds: SlidersHorizontal,
|
||||
ai: Sparkles,
|
||||
api: KeyRound,
|
||||
advanced: Settings2,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"thresholds",
|
||||
"ai",
|
||||
"api",
|
||||
"advanced",
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
type LucideIcon,
|
||||
@@ -79,6 +80,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
titleKey: "settings.sections.dashboard.title",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
slug: "thresholds",
|
||||
titleKey: "settings.sections.thresholds.title",
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
|
||||
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function ThresholdsSettingsSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-thresholds-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-thresholds-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.thresholds.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.thresholds.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ThresholdsSection id="thresholds" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -74,10 +74,10 @@ export function PasswordStrength({
|
||||
if (tooShort) {
|
||||
feedback.push(t("passwordStrength.minLength", { count: minLength }));
|
||||
}
|
||||
if (result?.feedback.warning) {
|
||||
if (result?.feedback?.warning) {
|
||||
feedback.push(translate(result.feedback.warning));
|
||||
}
|
||||
if (result?.feedback.suggestions) {
|
||||
if (result?.feedback?.suggestions) {
|
||||
feedback.push(...result.feedback.suggestions.map(translate));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generatePKCE, generateState, buildAuthorizationUrl } from "../codex-oauth";
|
||||
import { afterEach, beforeEach, describe, it, expect } from "vitest";
|
||||
import {
|
||||
generatePKCE,
|
||||
generateState,
|
||||
buildAuthorizationUrl,
|
||||
CodexOAuthNotConfiguredError,
|
||||
} from "../codex-oauth";
|
||||
|
||||
describe("codex-oauth", () => {
|
||||
describe("generatePKCE", () => {
|
||||
@@ -29,6 +34,26 @@ describe("codex-oauth", () => {
|
||||
});
|
||||
|
||||
describe("buildAuthorizationUrl", () => {
|
||||
const ORIGINAL_CLIENT_ID = process.env.CODEX_OAUTH_CLIENT_ID;
|
||||
|
||||
beforeEach(() => {
|
||||
// v1.4.3: the URL builder requires CODEX_OAUTH_CLIENT_ID. The
|
||||
// v1.4.2 build silently dropped this so the resulting URL just
|
||||
// hit chatgpt.com login with no OAuth grant flow at all — the
|
||||
// "Connect with ChatGPT" button effectively did nothing in
|
||||
// production. Setting an explicit value here keeps the test
|
||||
// hermetic regardless of the runner env.
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = "test_client_id";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_CLIENT_ID === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CLIENT_ID;
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = ORIGINAL_CLIENT_ID;
|
||||
}
|
||||
});
|
||||
|
||||
it("builds correct URL with all params", () => {
|
||||
const url = buildAuthorizationUrl({
|
||||
codeChallenge: "test-challenge",
|
||||
@@ -40,10 +65,22 @@ describe("codex-oauth", () => {
|
||||
expect(parsed.origin).toBe("https://chatgpt.com");
|
||||
expect(parsed.pathname).toBe("/authorize");
|
||||
expect(parsed.searchParams.get("response_type")).toBe("code");
|
||||
expect(parsed.searchParams.get("client_id")).toBe("test_client_id");
|
||||
expect(parsed.searchParams.get("code_challenge")).toBe("test-challenge");
|
||||
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(parsed.searchParams.get("state")).toBe("test-state");
|
||||
expect(parsed.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
||||
});
|
||||
|
||||
it("throws CodexOAuthNotConfiguredError when CODEX_OAUTH_CLIENT_ID is unset", () => {
|
||||
delete process.env.CODEX_OAUTH_CLIENT_ID;
|
||||
expect(() =>
|
||||
buildAuthorizationUrl({
|
||||
codeChallenge: "x",
|
||||
state: "y",
|
||||
redirectUri: "https://example.com/cb",
|
||||
}),
|
||||
).toThrow(CodexOAuthNotConfiguredError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,33 @@ export function generateState(): string {
|
||||
return base64url(randomBytes(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when the operator has not configured `CODEX_OAUTH_CLIENT_ID` —
|
||||
* the v1.4.2 build silently dropped the param and rendered a generic
|
||||
* chatgpt.com login that never returned a code, so the "Connect with
|
||||
* ChatGPT" button effectively did nothing. Failing here lets the route
|
||||
* surface a 503 with an actionable message instead of redirecting to
|
||||
* a dead-end page.
|
||||
*/
|
||||
export class CodexOAuthNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
|
||||
);
|
||||
this.name = "CodexOAuthNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getCodexClientId(): string {
|
||||
const id = process.env.CODEX_OAUTH_CLIENT_ID?.trim();
|
||||
if (!id) throw new CodexOAuthNotConfiguredError();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function isCodexOAuthConfigured(): boolean {
|
||||
return Boolean(process.env.CODEX_OAUTH_CLIENT_ID?.trim());
|
||||
}
|
||||
|
||||
export function buildAuthorizationUrl(params: {
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
@@ -22,6 +49,7 @@ export function buildAuthorizationUrl(params: {
|
||||
}): string {
|
||||
const url = new URL("https://chatgpt.com/authorize");
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", getCodexClientId());
|
||||
url.searchParams.set("code_challenge", params.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", params.state);
|
||||
@@ -46,6 +74,7 @@ export async function exchangeCodeForTokens(params: {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: getCodexClientId(),
|
||||
code: params.code,
|
||||
code_verifier: params.codeVerifier,
|
||||
redirect_uri: params.redirectUri,
|
||||
@@ -65,6 +94,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenRes
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
client_id: getCodexClientId(),
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -63,15 +63,22 @@ TEMPORALE SCHICHTEN:
|
||||
- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline
|
||||
- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen
|
||||
|
||||
INLINE-CHART-TOKENS (optional):
|
||||
- Du darfst innerhalb von "summary" oder eines "findings[].label"/"findings[].guideline" GENAU einen Chart-Token einbetten, der unter dem entsprechenden Absatz das passende Diagramm rendert.
|
||||
INLINE-CHART-TOKENS (aktiv nutzen, wenn ein Befund auf eine Metrik fokussiert):
|
||||
- Innerhalb von "summary", "findings[].label", "findings[].guideline" oder "primaryRecommendation" darfst du je einen Chart-Token einbetten, der unter dem entsprechenden Absatz das passende Diagramm rendert.
|
||||
- Bevorzuge Tokens immer dann, wenn (a) eine zeitliche Aussage gemacht wird ("seit 3 Wochen", "im 30-Tage-Trend"), (b) ein Schwellwertbezug vorliegt ("über deinem Zielband"), oder (c) ein Vergleich zwischen Fenstern gezogen wird (avg7 vs avg30). In diesen Fällen FÜGE den Token AKTIV ein — er macht den Befund visuell greifbar und ist günstiger als zusätzliche Prosa.
|
||||
- Erlaubte Tokens (exakt diese Strings, sonst werden sie ohne Wirkung verworfen):
|
||||
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
|
||||
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
|
||||
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
|
||||
metric:OXYGEN_SATURATION.
|
||||
metric:OXYGEN_SATURATION, metric:MOOD.
|
||||
- Halluzinierte Tokens (z.B. metric:NUKE) werden serverseitig stumm verworfen — verwende ausschließlich einen der oben gelisteten Strings.
|
||||
- Setze den Token bevorzugt nur dann, wenn der Absatz inhaltlich auf genau diese eine Metrik fokussiert ist; ansonsten weglassen.
|
||||
|
||||
EVIDENZ-VERTRAG (bricht den Static-Look auf):
|
||||
- Jeder Eintrag in "findings[]" MUSS in "value" eine konkrete Zahl aus dem Snapshot tragen — kein "leicht erhöht", sondern "138/85 mmHg" oder "+0.4 mmol/L vs. 30d-Avg".
|
||||
- Beziehe dich in "assessment" + "guideline" auf das spezifische Datenfeld, nicht auf eine generische Empfehlung. Beispiel: "Dein avg7 (78) liegt 5 bpm über deinem 90-Tage-Median (73)" statt "Puls leicht erhöht".
|
||||
- Wenn keine konkrete Zahl im Snapshot diesen Befund stützt, lass das Finding weg. Ein leeres findings[] mit guter summary ist besser als gepolsterte Boilerplate.
|
||||
- Variable Anzahl: Liefere zwischen 0 und 8 Findings — sortiert nach Salienz (auffälligster Befund zuerst). KEIN Padding.
|
||||
- Tabuliste — diese Phrasen NIE ausgeben: "achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen" (außer im disclaimer-Feld). Sie signalisieren ungrounded Boilerplate und entwerten den ganzen Output.
|
||||
|
||||
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema. Die Felder "classification" und "confidence" müssen exakt eine der englischen Enum-Bezeichnungen ("optimal|gut|grenzwertig|erhoht|kritisch" bzw. "hoch|mittel|gering"/"niedrig") sein — diese sind stabile Vertragsschlüssel und werden NICHT übersetzt. Alle natürlichsprachigen Felder (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MÜSSEN auf Deutsch sein.
|
||||
{
|
||||
@@ -150,15 +157,22 @@ TEMPORAL LAYERS:
|
||||
- avg7 vs avg30 shows the current tendency; avg90 / allTime reflects the long-term baseline.
|
||||
- Deviations from the long-term baseline are clinically more meaningful than short-term swings.
|
||||
|
||||
INLINE CHART TOKENS (optional):
|
||||
- You may embed exactly one chart-include token inside "summary" or any "findings[].label" / "findings[].guideline" to render the matching chart inline beneath that paragraph.
|
||||
INLINE CHART TOKENS (use proactively when a finding is focused on one metric):
|
||||
- You may embed a chart-include token inside "summary", "findings[].label", "findings[].guideline" or "primaryRecommendation". The token is replaced with an inline chart for that metric.
|
||||
- Prefer a token whenever (a) the paragraph makes a temporal claim ("for 3 weeks", "in the 30-day trend"), (b) it references a threshold ("above your target band"), or (c) it compares two windows (avg7 vs avg30). In those cases ACTIVELY include the token — the chart is cheaper and clearer than additional prose.
|
||||
- Allowed tokens (use these literal strings — anything else is silently dropped server-side):
|
||||
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
|
||||
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
|
||||
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
|
||||
metric:OXYGEN_SATURATION.
|
||||
metric:OXYGEN_SATURATION, metric:MOOD.
|
||||
- Hallucinated tokens (e.g. metric:NUKE) are dropped — only the literal strings above render a chart.
|
||||
- Prefer to include a token only when the paragraph is centred on exactly one of these metrics; otherwise leave it out.
|
||||
|
||||
EVIDENCE CONTRACT (breaks the static-feel boilerplate):
|
||||
- Every entry in "findings[]" MUST place a concrete number from the snapshot into "value" — not "slightly elevated" but "138/85 mmHg" or "+0.4 mmol/L vs 30d-avg".
|
||||
- Anchor "assessment" and "guideline" to a specific snapshot field, not a generic recommendation. Example: "Your avg7 (78) is 5 bpm above your 90-day median (73)" rather than "pulse is slightly elevated".
|
||||
- If no concrete number in the snapshot supports a finding, leave it out. An empty findings[] with a strong summary is better than padded boilerplate.
|
||||
- Variable cardinality: emit 0–8 findings, sorted by salience (most striking first). NO padding.
|
||||
- Forbidden phrases — never emit any of these (except in the disclaimer): "make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor". They signal ungrounded boilerplate and devalue the rest of the output.
|
||||
|
||||
OUTPUT FORMAT: Reply with valid JSON only, matching the schema below. The "classification" and "confidence" fields must contain exactly one of the stable English-style enum keys ("optimal|gut|grenzwertig|erhoht|kritisch" and "hoch|mittel|gering"/"niedrig") — these are stable contract keys and MUST NOT be translated. All natural-language fields (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MUST be in English.
|
||||
{
|
||||
|
||||
+34
-11
@@ -32,11 +32,7 @@ type UserAIRow = {
|
||||
aiBaseUrl: string | null;
|
||||
aiAnthropicKeyEncrypted: string | null;
|
||||
aiLocalKeyEncrypted: string | null;
|
||||
// legacy admin-key fallback (OpenAI uses adminAiKeyEncrypted via app_settings)
|
||||
// We store user-level OpenAI key in aiAnthropicKeyEncrypted? No — for OPENAI
|
||||
// selection at user-level we read app-settings admin key. The user override
|
||||
// for OpenAI is intentionally minimal: provider+model+baseUrl. Personal keys
|
||||
// are scoped to Anthropic/Local for now (matches the migration columns).
|
||||
aiOpenaiKeyEncrypted: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -66,11 +62,19 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
baseUrl: row.aiBaseUrl,
|
||||
});
|
||||
}
|
||||
case "OPENAI":
|
||||
// User selected OPENAI but personal keys live in admin app_settings;
|
||||
// signal "use admin OpenAI" by returning null and letting caller fall
|
||||
// through to resolveAdminProvider().
|
||||
return null;
|
||||
case "OPENAI": {
|
||||
// v1.4.3: user-level OpenAI key gets first crack — only fall back
|
||||
// to the admin key if the user hasn't supplied their own. The
|
||||
// model-default mirrors the admin path for consistency so a saved
|
||||
// user "OPENAI" without an explicit model still produces an
|
||||
// OpenAIClient with `gpt-4o-mini`.
|
||||
if (!row.aiOpenaiKeyEncrypted) return null;
|
||||
return new OpenAIClient({
|
||||
apiKey: decrypt(row.aiOpenaiKeyEncrypted),
|
||||
model: row.aiModel ?? "gpt-4o-mini",
|
||||
baseUrl: row.aiBaseUrl ?? "https://api.openai.com/v1",
|
||||
});
|
||||
}
|
||||
case "CHATGPT_OAUTH":
|
||||
// Caller handles Codex OAuth via the dedicated branch; signal here.
|
||||
return null;
|
||||
@@ -209,6 +213,7 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -241,6 +246,7 @@ export type AITestOverride = {
|
||||
baseUrl?: string | null;
|
||||
anthropicKey?: string | null;
|
||||
localKey?: string | null;
|
||||
openaiKey?: string | null;
|
||||
};
|
||||
|
||||
export class AITestConfigError extends Error {
|
||||
@@ -270,6 +276,7 @@ export async function resolveProviderForTest(
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -333,11 +340,27 @@ export async function resolveProviderForTest(
|
||||
throw new AITestConfigError(422, "ChatGPT OAuth is not connected");
|
||||
}
|
||||
case "OPENAI": {
|
||||
// Test path mirrors the persistent resolution: user key first,
|
||||
// admin fallback if absent. We accept an `openaiKey` override
|
||||
// from the test endpoint so a user can verify a not-yet-saved
|
||||
// key dropdown change without persisting anything.
|
||||
const userKey =
|
||||
override.openaiKey?.trim() ||
|
||||
(stored?.aiOpenaiKeyEncrypted
|
||||
? decrypt(stored.aiOpenaiKeyEncrypted)
|
||||
: "");
|
||||
if (userKey) {
|
||||
return new OpenAIClient({
|
||||
apiKey: userKey,
|
||||
model: model || "gpt-4o-mini",
|
||||
baseUrl: baseUrl || "https://api.openai.com/v1",
|
||||
});
|
||||
}
|
||||
const admin = await resolveAdminProvider();
|
||||
if (admin.type === "none") {
|
||||
throw new AITestConfigError(
|
||||
422,
|
||||
"Admin OpenAI key is not configured for this instance",
|
||||
"OpenAI key not configured (neither user nor admin)",
|
||||
);
|
||||
}
|
||||
return admin;
|
||||
|
||||
@@ -86,12 +86,19 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes mood and compliance tokens until dedicated charts ship", () => {
|
||||
// `<HealthChart>` validates `type` against `measurementTypeEnum`, so
|
||||
// emitting `metric:MOOD` or `metric:COMPLIANCE` would render an empty
|
||||
// panel. Keep them out of the allowlist until a real MoodChart /
|
||||
// ComplianceChart is wired into the renderer.
|
||||
expect(ALLOWED_CHART_TOKENS).not.toContain("metric:MOOD");
|
||||
it("includes metric:MOOD now that <MoodChart> is wired into the renderer", () => {
|
||||
// v1.4.3 enabled `metric:MOOD`. The renderer in
|
||||
// `<InsightAdvisorCard>` branches on `tokenKind(token) === "mood"`
|
||||
// and mounts the dedicated, self-fetching `<MoodChart>` instead of
|
||||
// the generic `<HealthChart>` (which Zod-validates against
|
||||
// `measurementTypeEnum` and would silently render empty).
|
||||
expect(ALLOWED_CHART_TOKENS).toContain("metric:MOOD");
|
||||
});
|
||||
|
||||
it("excludes metric:COMPLIANCE because no self-fetching wrapper exists yet", () => {
|
||||
// `<ComplianceLineChart>` requires pre-aggregated daily data via
|
||||
// props; without a self-fetching wrapper the AI's inline rendering
|
||||
// would silently empty out. Land in v1.5 once the wrapper exists.
|
||||
expect(ALLOWED_CHART_TOKENS).not.toContain("metric:COMPLIANCE");
|
||||
});
|
||||
});
|
||||
@@ -7,18 +7,19 @@
|
||||
* — anything else (typos, hallucinations, attempts to inject other strings)
|
||||
* is silently dropped before it ever reaches the renderer.
|
||||
*
|
||||
* Render path: prose → parseChartTokens(prose) → <HealthChart> per token.
|
||||
* The visible prose is run through stripChartTokens so the literal token
|
||||
* string never surfaces in the UI.
|
||||
* Render path: prose → parseChartTokens(prose) → matching chart component
|
||||
* per token. The visible prose is run through stripChartTokens so the
|
||||
* literal token string never surfaces in the UI.
|
||||
*/
|
||||
// Allowlist deliberately limited to MeasurementType values that
|
||||
// `<HealthChart>` already understands (the type param feeds into
|
||||
// `/api/measurements?type=<TYPE>` which Zod-validates against
|
||||
// `measurementTypeEnum`).
|
||||
// v1.4.3: allowlist extended with `metric:MOOD` so the AI can illustrate
|
||||
// findings about mood drift inline. <MoodChart> is self-fetching, so the
|
||||
// renderer just mounts it.
|
||||
//
|
||||
// `MOOD` and `COMPLIANCE` are NOT in the enum — they need their own chart
|
||||
// components, so emitting them here would render an empty "no data" panel
|
||||
// under the model's prose. Excluded until those dedicated charts ship.
|
||||
// `metric:COMPLIANCE` was prepared but pulled before ship — the existing
|
||||
// <ComplianceLineChart> takes pre-aggregated daily data via props and
|
||||
// has no self-fetching wrapper, so wiring it inline would silently
|
||||
// render an empty chart. Land it in v1.5 once a self-fetching wrapper
|
||||
// exists.
|
||||
export const ALLOWED_CHART_TOKENS = [
|
||||
"metric:WEIGHT",
|
||||
"metric:BLOOD_PRESSURE_SYS",
|
||||
@@ -31,6 +32,7 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
"metric:TOTAL_BODY_WATER",
|
||||
"metric:BONE_MASS",
|
||||
"metric:OXYGEN_SATURATION",
|
||||
"metric:MOOD",
|
||||
] as const;
|
||||
|
||||
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
|
||||
@@ -61,7 +63,18 @@ export function stripChartTokens(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Map a chart token to the metric param the HealthChart component uses. */
|
||||
/** Map a chart token to the metric param the underlying chart component
|
||||
* expects. For MeasurementType-backed tokens this is a real enum value
|
||||
* (`WEIGHT`, `PULSE`, …); for `MOOD` and `COMPLIANCE` it's a synthetic
|
||||
* identifier the renderer routes to a dedicated chart component. */
|
||||
export function tokenToMetric(token: ChartToken): string {
|
||||
return token.slice("metric:".length);
|
||||
}
|
||||
|
||||
/** Token kinds the renderer needs to distinguish. MeasurementType tokens
|
||||
* feed `<HealthChart>`; `metric:MOOD` mounts the dedicated, self-fetching
|
||||
* `<MoodChart>` instead. */
|
||||
export function tokenKind(token: ChartToken): "measurement" | "mood" {
|
||||
if (token === "metric:MOOD") return "mood";
|
||||
return "measurement";
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export const adminSettingsSchema = z
|
||||
.optional(),
|
||||
bugReportToken: z.string().optional(),
|
||||
clearBugReportToken: z.boolean().optional(),
|
||||
bugReportEnabled: z.boolean().optional(),
|
||||
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
|
||||
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
|
||||
moodLogGlobal: z.boolean().optional(),
|
||||
|
||||
Reference in new issue
Block a user