Compare commits

...
4 Commits
Author SHA1 Message Date
Marc-André Bombeck c584ead9c8 chore(release): 1.3.2
Hotfix release closing #108 — the dashboard glucose tiles rendered the
raw i18n key, and the bug-report screenshot upload promised a feature
that never reached the published GitHub issue.
2026-04-28 19:46:29 +02:00
Marc-André Bombeck 492748f105 fix(bugreport): remove screenshot upload, drop misleading attached note
The bug-report form 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. The publisher only
appended a placeholder line ("**Screenshot:** A screenshot was attached
in the original report.") which was visibly misleading to anyone
reading the issue (#108).

- Drop the file input, preview, validation, and unused icons from
  src/app/bugreport/page.tsx.
- Remove hasScreenshot from FeedbackInput and the corresponding line
  from publish-github.ts so feedback promotion no longer adds the
  misleading note (applies to legacy rows whose screenshots are still
  stored locally).
- Update the admin feedback→GitHub call site to match the new shape.

The screenshotBase64 column, the screenshot ingest path on /api/feedback,
and the admin-side preview of previously-submitted screenshots are
unchanged — existing reports keep their attachments locally.
2026-04-28 19:46:25 +02:00
Marc-André Bombeck 0a77558fd0 fix(i18n): merge duplicate targets blocks, add locale integrity test
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 glucoseFasting/Postprandial/Random/Bedtime — so glucose
tiles on the dashboard rendered the raw key "targets.glucoseFasting"
instead of the translated label (#108).

Collapsed the duplicate into a single block, dropped a duplicate
bugreport.bugTitlePlaceholder shadowed within the same namespace, and
filled in dashboard.sleep / dashboard.steps which were falling back to
hard-coded English.

New test src/lib/__tests__/i18n-locale-integrity.test.ts walks the raw
JSON to detect duplicate keys at any depth and verifies en/de key
parity, so this class of silent shadowing fails the build going forward.
2026-04-28 19:46:16 +02:00
Marc-André Bombeck 79764d3467 fix(compose): replace ${VAR:?required} with entrypoint validation (1.3.1)
The ${VAR:?error} syntax in docker-compose.yml broke Coolify-style
deploys: that platform parses the compose file eagerly and stores the
fallback error string as the env-var value when the var is unset, which
then propagates into DATABASE_URL and breaks the running app with
P1000 auth failures.

Switched to plain ${VAR} interpolation. Validation moves into
docker-entrypoint.sh, which checks DATABASE_URL, SESSION_SECRET,
ENCRYPTION_KEY, API_TOKEN_HMAC_KEY before connecting to the database
and exits with a deploy-platform-agnostic error listing the unset vars.
2026-04-27 20:19:30 +02:00
10 changed files with 270 additions and 186 deletions

No files matched your search

+61
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
+2 -83
View File
@@ -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: [] });
});
});
-5
View File
@@ -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}`}`;