mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c584ead9c8 | ||
|
|
492748f105 | ||
|
|
0a77558fd0 | ||
|
|
79764d3467 |
No files matched your search
@@ -1,5 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.2] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Glucose tiles on the dashboard rendered the raw i18n key
|
||||
`targets.glucoseFasting` instead of the translated label** (closes
|
||||
#108). Both `messages/en.json` and `messages/de.json` had two
|
||||
top-level `targets` blocks; `JSON.parse` silently keeps the last
|
||||
occurrence, and that block was missing the four glucose labels. The
|
||||
duplicate is now collapsed into a single block. A duplicate
|
||||
`bugreport.bugTitlePlaceholder` shadowed inside `bugreport` was
|
||||
cleaned up too. Two further keys (`dashboard.sleep`,
|
||||
`dashboard.steps`) were missing from both locales and were falling
|
||||
back to hard-coded English; both are now translated.
|
||||
- **New i18n locale-integrity test**
|
||||
(`src/lib/__tests__/i18n-locale-integrity.test.ts`) fails the build
|
||||
on duplicate keys at any nesting depth and on key drift between
|
||||
`en` and `de` — closes the structural gap that let the duplicate
|
||||
`targets` block ship in the first place.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Screenshot upload removed from the bug-report form** (also part
|
||||
of #108). The form previously accepted an image attachment that
|
||||
was stored in the local DB but never reached the published GitHub
|
||||
issue — GitHub does not accept inline base64 data URIs in issue
|
||||
bodies and offers no public API to attach images to an issue
|
||||
programmatically. Rather than ship misleading
|
||||
"a screenshot was attached" placeholder text in the resulting
|
||||
issue, the upload UI is now gone and the placeholder note is no
|
||||
longer added when promoting feedback. The `screenshotBase64`
|
||||
column and the admin-side preview of previously-submitted
|
||||
screenshots are unchanged — existing reports keep their
|
||||
attachments locally. We plan to revisit a real screenshot pipeline
|
||||
in a future release.
|
||||
|
||||
## [1.3.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compose env-var validation no longer breaks Coolify-style deploys.**
|
||||
`docker-compose.yml` previously used `${VAR:?required}` shell-parameter
|
||||
syntax for the four secrets and `POSTGRES_PASSWORD`. Some hosting
|
||||
platforms (Coolify in particular) parse compose files eagerly and
|
||||
store the _fallback error string_ (`"POSTGRES_PASSWORD is required"`)
|
||||
as the literal env-var value when `POSTGRES_PASSWORD` was unset,
|
||||
which then collided with `DATABASE_URL` and broke the running app
|
||||
with `P1000: Authentication failed`. Compose now uses plain `${VAR}`
|
||||
interpolation; validation moved into `docker-entrypoint.sh`, which
|
||||
fails fast with a clear stderr message listing the unset variables.
|
||||
|
||||
### Notes
|
||||
|
||||
If you upgraded an existing Compose stack from 1.2.x → 1.3.0 and hit
|
||||
the `POSTGRES_PASSWORD is required` literal-as-value bug, set
|
||||
`POSTGRES_PASSWORD` in your environment to whatever your existing
|
||||
Postgres data volume was originally initialised with (likely
|
||||
`healthlog` if you started from a pre-1.2.1 release), then redeploy.
|
||||
Postgres only honours `POSTGRES_PASSWORD` on first volume init — the
|
||||
existing user keeps the original password regardless of env changes.
|
||||
|
||||
## [1.3.0] — 2026-04-27
|
||||
|
||||
### Added — Body composition + targeted hardening
|
||||
|
||||
+10
-5
@@ -14,11 +14,16 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/healthlog?schema=public"
|
||||
# Plain ${VAR} interpolation only — no ${VAR:?error} validation
|
||||
# syntax. Some hosting platforms (e.g. Coolify) parse compose files
|
||||
# eagerly and store the *fallback error string* as the literal value
|
||||
# when the env var is unset, which then breaks the deployment.
|
||||
# Validation is enforced by docker-entrypoint.sh before the app starts.
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY:?API_TOKEN_HMAC_KEY is required}"
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
depends_on:
|
||||
@@ -31,7 +36,7 @@ services:
|
||||
container_name: healthlog-db
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: healthlog
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Validate required env vars ───────────────────────────────
|
||||
# We do this in the entrypoint instead of in docker-compose.yml's
|
||||
# environment block (which used to use the ${VAR:?error} syntax)
|
||||
# because some hosting platforms parse compose files eagerly and
|
||||
# end up storing the fallback error string as the literal value.
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL SESSION_SECRET ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "HealthLog: ERROR — required env vars not set:$missing" >&2
|
||||
echo "HealthLog: see .env.example for the full list and how to generate each value." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
@@ -15,7 +35,7 @@ until node -e "
|
||||
" 2>/dev/null; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting."
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
+6
-45
@@ -199,6 +199,8 @@
|
||||
"bloodPressureDia": "Blutdruck (Dia)",
|
||||
"pulse": "Puls",
|
||||
"bodyFat": "Körperfett",
|
||||
"sleep": "Schlaf",
|
||||
"steps": "Schritte",
|
||||
"bpInTarget": "BD im Zielbereich",
|
||||
"days30Short": "30T",
|
||||
"medications": "Medikamente",
|
||||
@@ -761,50 +763,6 @@
|
||||
"unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil",
|
||||
"introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.",
|
||||
"current": "Aktuell",
|
||||
"average30d": "30-Tage-Durchschnitt:",
|
||||
"targetRange": "Zielbereich",
|
||||
"classification": "Bewertung",
|
||||
"source": "Quelle",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Ruhepuls",
|
||||
"sleep": "Schlafdauer",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Körperfett",
|
||||
"steps": "Schritte/Tag",
|
||||
"compliance": "Einnahmetreue",
|
||||
"compliancePerMedication": "Pro Medikament",
|
||||
"diastolic": "Diastolisch",
|
||||
"noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.",
|
||||
"profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.",
|
||||
"profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.",
|
||||
"noMeasurementData": "Noch keine Messdaten vorhanden.",
|
||||
"noMeasurementYet": "Noch keine Messung vorhanden",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"inTarget": "Im Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Ziel",
|
||||
"aboveTarget": "{delta} {unit} über Ziel",
|
||||
"loadError": "Fehler beim Laden",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Erhöht",
|
||||
"classificationHigh": "Hoch",
|
||||
"classificationLow": "Niedrig",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen bei HealthLog!",
|
||||
"setupDescription": "Richte dein Profil ein, um loszulegen. Du kannst das auch später in den Einstellungen ändern.",
|
||||
@@ -1461,7 +1419,6 @@
|
||||
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
|
||||
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
|
||||
"bugTitle": "Titel",
|
||||
"bugTitlePlaceholder": "Kurze Beschreibung des Problems",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1627,6 +1584,10 @@
|
||||
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
|
||||
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
|
||||
"compliancePerMedication": "Einnahmetreue pro Medikament",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"passwordStrength": {
|
||||
|
||||
+6
-45
@@ -199,6 +199,8 @@
|
||||
"bloodPressureDia": "Blood Pressure (Dia)",
|
||||
"pulse": "Pulse",
|
||||
"bodyFat": "Body Fat",
|
||||
"sleep": "Sleep",
|
||||
"steps": "Steps",
|
||||
"bpInTarget": "BP in Target",
|
||||
"days30Short": "30d",
|
||||
"medications": "Medications",
|
||||
@@ -761,50 +763,6 @@
|
||||
"unsetExplanation": "No override — using computed default.",
|
||||
"loadError": "Could not load thresholds"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"subtitle": "Personal reference ranges based on your profile",
|
||||
"introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.",
|
||||
"current": "Current",
|
||||
"average30d": "30-day average:",
|
||||
"targetRange": "Target range",
|
||||
"classification": "Rating",
|
||||
"source": "Source",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Resting Pulse",
|
||||
"sleep": "Sleep Duration",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Body Fat",
|
||||
"steps": "Steps/Day",
|
||||
"compliance": "Compliance",
|
||||
"compliancePerMedication": "Per medication",
|
||||
"diastolic": "Diastolic",
|
||||
"noProfile": "Please complete your profile to get personalized targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.",
|
||||
"profileIncompleteHeight": "Add your height in your profile to calculate weight target range.",
|
||||
"profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.",
|
||||
"noMeasurementData": "No measurement data available yet.",
|
||||
"noMeasurementYet": "No measurement available yet",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target range: {min}–{max} {unit}",
|
||||
"inTarget": "In target range",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"loadError": "Error loading data",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Elevated",
|
||||
"classificationHigh": "High",
|
||||
"classificationLow": "Low",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to HealthLog!",
|
||||
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
|
||||
@@ -1461,7 +1419,6 @@
|
||||
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
|
||||
"loginRequired": "Please sign in to report a bug.",
|
||||
"bugTitle": "Title",
|
||||
"bugTitlePlaceholder": "Brief description of the problem",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1627,6 +1584,10 @@
|
||||
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
|
||||
"compliancePerMedication": "Compliance per medication",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime",
|
||||
"loadError": "Failed to load targets"
|
||||
},
|
||||
"passwordStrength": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -62,7 +62,6 @@ export const POST = apiHandler(
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
hasScreenshot: Boolean(feedback.screenshotBase64),
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Loader2,
|
||||
Upload,
|
||||
X,
|
||||
CheckCircle2,
|
||||
ImageIcon,
|
||||
Bug,
|
||||
GitPullRequest,
|
||||
Info,
|
||||
@@ -46,14 +43,11 @@ export default function BugReportPage() {
|
||||
const [category, setCategory] = useState<CategoryValue>("BUG");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshot, setScreenshot] = useState<string | null>(null);
|
||||
const [screenshotName, setScreenshotName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Status is now informational only — submission always works.
|
||||
const { data: status } = useQuery({
|
||||
@@ -67,34 +61,6 @@ export default function BugReportPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleScreenshot(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setResult({ type: "error", message: t("bugreport.imageOnly") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setResult({ type: "error", message: t("bugreport.tooLarge") });
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setScreenshot(reader.result as string);
|
||||
setScreenshotName(file.name);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function removeScreenshot() {
|
||||
setScreenshot(null);
|
||||
setScreenshotName(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
@@ -108,7 +74,6 @@ export default function BugReportPage() {
|
||||
category,
|
||||
subject: subject || description.slice(0, 60),
|
||||
description,
|
||||
...(screenshot ? { screenshot } : {}),
|
||||
metadata: {
|
||||
locale,
|
||||
userAgent:
|
||||
@@ -124,7 +89,6 @@ export default function BugReportPage() {
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
removeScreenshot();
|
||||
} else {
|
||||
setResult({
|
||||
type: "error",
|
||||
@@ -226,42 +190,6 @@ export default function BugReportPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("bugreport.screenshot")}</Label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleScreenshot}
|
||||
className="hidden"
|
||||
/>
|
||||
{screenshot ? (
|
||||
<div className="bg-muted/50 relative rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{screenshotName}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={removeScreenshot}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={screenshot}
|
||||
alt={t("bugreport.screenshotPreview")}
|
||||
className="mt-2 max-h-48 rounded border object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
@@ -277,16 +205,7 @@ export default function BugReportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("bugreport.attachScreenshot")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const EN_PATH = join(ROOT, "messages/en.json");
|
||||
const DE_PATH = join(ROOT, "messages/de.json");
|
||||
|
||||
// JSON.parse silently keeps the last value when an object has duplicate keys,
|
||||
// which lets shadowed keys hide bugs. Walk the source manually to surface them.
|
||||
function findDuplicateKeys(src: string): { path: string; line: number; firstLine: number }[] {
|
||||
const dups: { path: string; line: number; firstLine: number }[] = [];
|
||||
const pathStack: string[] = [];
|
||||
let i = 0;
|
||||
let line = 1;
|
||||
|
||||
function err(msg: string): never {
|
||||
throw new Error(`${msg} at line ${line}`);
|
||||
}
|
||||
|
||||
function skipWs() {
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === " " || c === "\t" || c === "\r") i++;
|
||||
else if (c === "\n") { i++; line++; }
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(): { value: string; line: number } {
|
||||
if (src[i] !== '"') err("expected string");
|
||||
const startLine = line;
|
||||
i++;
|
||||
let raw = "";
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === "\\") {
|
||||
raw += c + src[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
i++;
|
||||
return { value: JSON.parse(`"${raw}"`), line: startLine };
|
||||
}
|
||||
if (c === "\n") line++;
|
||||
raw += c;
|
||||
i++;
|
||||
}
|
||||
err("unterminated string");
|
||||
}
|
||||
|
||||
function parseValue() {
|
||||
skipWs();
|
||||
const c = src[i];
|
||||
if (c === "{") return parseObject();
|
||||
if (c === "[") return parseArray();
|
||||
if (c === '"') { readString(); return; }
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === "," || ch === "}" || ch === "]" || ch === " " || ch === "\n" || ch === "\t" || ch === "\r") break;
|
||||
if (ch === "\n") line++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArray() {
|
||||
i++; skipWs();
|
||||
if (src[i] === "]") { i++; return; }
|
||||
while (true) {
|
||||
parseValue();
|
||||
skipWs();
|
||||
if (src[i] === ",") { i++; skipWs(); continue; }
|
||||
if (src[i] === "]") { i++; return; }
|
||||
err("expected , or ] in array");
|
||||
}
|
||||
}
|
||||
|
||||
function parseObject() {
|
||||
i++;
|
||||
const seen = new Map<string, number>();
|
||||
skipWs();
|
||||
if (src[i] === "}") { i++; return; }
|
||||
while (true) {
|
||||
skipWs();
|
||||
const { value: key, line: keyLine } = readString();
|
||||
const seenAt = seen.get(key);
|
||||
if (seenAt != null) {
|
||||
dups.push({ path: [...pathStack, key].join("."), line: keyLine, firstLine: seenAt });
|
||||
} else {
|
||||
seen.set(key, keyLine);
|
||||
}
|
||||
skipWs();
|
||||
if (src[i] !== ":") err("expected : after key");
|
||||
i++;
|
||||
pathStack.push(key);
|
||||
parseValue();
|
||||
pathStack.pop();
|
||||
skipWs();
|
||||
if (src[i] === ",") { i++; continue; }
|
||||
if (src[i] === "}") { i++; return; }
|
||||
err("expected , or } in object");
|
||||
}
|
||||
}
|
||||
|
||||
parseValue();
|
||||
return dups;
|
||||
}
|
||||
|
||||
describe("i18n locale file integrity", () => {
|
||||
it("messages/en.json has no duplicate keys", () => {
|
||||
const dups = findDuplicateKeys(readFileSync(EN_PATH, "utf8"));
|
||||
expect(
|
||||
dups,
|
||||
`Duplicate keys silently shadow earlier definitions:\n` +
|
||||
dups
|
||||
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
|
||||
.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("messages/de.json has no duplicate keys", () => {
|
||||
const dups = findDuplicateKeys(readFileSync(DE_PATH, "utf8"));
|
||||
expect(
|
||||
dups,
|
||||
`Duplicate keys silently shadow earlier definitions:\n` +
|
||||
dups
|
||||
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
|
||||
.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("en and de share the same key shape (locale parity)", () => {
|
||||
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, unknown>;
|
||||
const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record<string, unknown>;
|
||||
|
||||
function flatten(obj: unknown, prefix: string, out: string[]) {
|
||||
if (obj == null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.push(key);
|
||||
else if (typeof v === "object") flatten(v, key, out);
|
||||
}
|
||||
}
|
||||
|
||||
const enKeys = new Set<string>();
|
||||
const deKeys = new Set<string>();
|
||||
const enArr: string[] = [];
|
||||
const deArr: string[] = [];
|
||||
flatten(en, "", enArr);
|
||||
flatten(de, "", deArr);
|
||||
enArr.forEach((k) => enKeys.add(k));
|
||||
deArr.forEach((k) => deKeys.add(k));
|
||||
|
||||
const onlyInEn = [...enKeys].filter((k) => !deKeys.has(k)).sort();
|
||||
const onlyInDe = [...deKeys].filter((k) => !enKeys.has(k)).sort();
|
||||
|
||||
expect(
|
||||
{ onlyInEn, onlyInDe },
|
||||
"Locale files drifted apart — every key must exist in both locales.",
|
||||
).toEqual({ onlyInEn: [], onlyInDe: [] });
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ export interface FeedbackInput {
|
||||
subject: string;
|
||||
description: string;
|
||||
username: string;
|
||||
hasScreenshot: boolean;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
@@ -131,10 +130,6 @@ export async function publishFeedbackToGithub(
|
||||
}
|
||||
}
|
||||
|
||||
if (input.hasScreenshot) {
|
||||
body += `\n**Screenshot:** A screenshot was attached in the original report.`;
|
||||
}
|
||||
|
||||
body += `\n\n---\n*Created via HealthLog feedback*`;
|
||||
|
||||
const title = `[${categoryLabel}] ${safeSubject || `Feedback – ${dateStr}`}`;
|
||||
|
||||
Reference in new issue
Block a user