mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
style(medications): prettier sweep on the W3 intake-history fix
Run prettier --write on the four files the v1.4.37 W3 commits touched (the schema, the route, the V2 component, and the two test files). Drive-by also collapses two pre-existing whitespace drifts in the InventoryItem type aliases and the glp1PostBodySchema refine call so the file matches the formatter contract end-to-end. No behavioural change.
This commit is contained in:
1 parent
04aa2fc8b9
commit
f33c70b3a2
6 files changed
+74
-55
No files matched your search
@@ -62,6 +62,24 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/01-data-flow.svg" alt="Five-stage data pipeline: sources to ingest to Postgres + rollups to reads to surfaces" width="900" />
|
||||
</p>
|
||||
|
||||
Every reading walks the same five-stage pipeline. **Sources** (Withings webhook, an Apple Health `export.zip`, the iPhone HealthKit batch from the v1.5 native client, manual entry, the moodLog.app webhook) reach a small set of **ingest** endpoints, which dedup against the source-priority resolver before persisting into Postgres. **Storage** keeps two shapes — a `Measurement` row per sample plus pre-aggregated DAY / WEEK / MONTH `MeasurementRollup` buckets. **Reads** probe the rollups first and fall back to live SQL when the ask is too slope-heavy or the bucket is cold. **Surfaces** — dashboard tiles, Insights cards, the AI Coach, the doctor-report PDF — all consume the same read path, so a number on a tile is always the same number the Coach cites.
|
||||
|
||||
Three more diagrams cover the pieces that matter most when you're evaluating the project:
|
||||
|
||||
- [`02-coach-pipeline.svg`](docs/diagrams/02-coach-pipeline.svg) — how the AI Coach grounds every reply in your own data via the snapshot builder + provider chain + Zod-validated parser.
|
||||
- [`04-source-priority.svg`](docs/diagrams/04-source-priority.svg) — why steps logged simultaneously by Apple Watch, a Withings ScanWatch and the iPhone don't triple-count.
|
||||
- [`05-security-model.svg`](docs/diagrams/05-security-model.svg) — the three concentric perimeters (auth, session, encrypted core) plus rate-limiter, audit-log and SSRF rails.
|
||||
|
||||
The deployment topology lives further down under [Deployment](#deployment).
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
|
||||
@@ -145,6 +163,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
| Native client | SwiftUI iOS app (separate repo, active for v1.5) |
|
||||
|
||||
---
|
||||
|
||||
@@ -457,6 +476,24 @@ The included `docker-compose.yml` runs the app and PostgreSQL. The entrypoint au
|
||||
|
||||
The app listens on port **3000**. Place it behind Nginx, Caddy, or Traefik for TLS termination. Works out of the box with [Coolify](https://coolify.io/).
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/03-self-hosting-topology.svg" alt="Self-hosting topology: internet → reverse proxy → Next.js app + pg-boss worker → PostgreSQL, with GHCR image pull and optional Coolify autodeploy and S3 backup" width="900" />
|
||||
</p>
|
||||
|
||||
For a single-process default the same container hosts both the web and worker (`HEALTHLOG_PROCESS_TYPE=all`, the default). Split them via `HEALTHLOG_PROCESS_TYPE=web` and `HEALTHLOG_PROCESS_TYPE=worker` for horizontal scale. Off-host AES-GCM weekly backups to any S3-compatible bucket (R2, B2, MinIO, AWS) are opt-in via the admin panel. See [`docs/self-hosting/`](docs/self-hosting/) and [`docs/ops/`](docs/ops/) for the full operator manual.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Release line | Focus |
|
||||
| ------------ | ----- |
|
||||
| **v1.4.x** (current) | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
|
||||
| **v1.5** (in active development) | Native iOS client (SwiftUI). The backend contract is already locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
|
||||
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
|
||||
|
||||
The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md); per-release audit summaries live under [`docs/audit/`](docs/audit/).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -2498,6 +2498,17 @@ paths:
|
||||
enum:
|
||||
- rollup
|
||||
type: string
|
||||
- in: query
|
||||
name: groupBy
|
||||
schema:
|
||||
enum:
|
||||
- day
|
||||
type: string
|
||||
- in: query
|
||||
name: dayKey
|
||||
schema:
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
|
||||
@@ -134,10 +134,7 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
|
||||
expect(where).toEqual({
|
||||
medicationId: "med-1",
|
||||
userId: "user-1",
|
||||
OR: [
|
||||
{ takenAt: { not: null }, skipped: false },
|
||||
{ skipped: true },
|
||||
],
|
||||
OR: [{ takenAt: { not: null }, skipped: false }, { skipped: true }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,10 +151,7 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
|
||||
expect(countArgs?.where).toEqual({
|
||||
medicationId: "med-1",
|
||||
userId: "user-1",
|
||||
OR: [
|
||||
{ takenAt: { not: null }, skipped: false },
|
||||
{ skipped: true },
|
||||
],
|
||||
OR: [{ takenAt: { not: null }, skipped: false }, { skipped: true }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -96,10 +96,7 @@ describe("<IntakeHistoryListV2> — empty state", () => {
|
||||
it("renders the empty-state title and CTA in English", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", []);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
const html = render(<IntakeHistoryListV2 medicationId="med-1" />, client);
|
||||
expect(html).toContain("No intakes recorded yet");
|
||||
expect(html).toContain("Open daily intake");
|
||||
expect(html).toContain('href="/medications"');
|
||||
@@ -141,10 +138,7 @@ describe("<IntakeHistoryListV2> — populated rows", () => {
|
||||
createdAt: "2026-05-14T20:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
const html = render(<IntakeHistoryListV2 medicationId="med-1" />, client);
|
||||
expect(html).toContain("Taken");
|
||||
expect(html).toContain("Skipped");
|
||||
expect(html).toContain("Website");
|
||||
@@ -238,10 +232,7 @@ describe("<IntakeHistoryListV2> — populated rows", () => {
|
||||
it("renders the section title across both locales", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", []);
|
||||
const enHtml = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
const enHtml = render(<IntakeHistoryListV2 medicationId="med-1" />, client);
|
||||
expect(enHtml).toContain("Intake history");
|
||||
|
||||
const deClient = makeClient();
|
||||
@@ -269,10 +260,7 @@ describe("<IntakeHistoryListV2> — sort indicators", () => {
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
const html = render(<IntakeHistoryListV2 medicationId="med-1" />, client);
|
||||
// Default sort = takenAt desc, so the "taken" header carries the
|
||||
// arrow-down icon and the "scheduled" header carries the neutral
|
||||
// dual-arrow icon.
|
||||
@@ -322,10 +310,7 @@ describe("<IntakeHistoryListV2> — sort indicators", () => {
|
||||
],
|
||||
50,
|
||||
);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-2" />,
|
||||
client,
|
||||
);
|
||||
const html = render(<IntakeHistoryListV2 medicationId="med-2" />, client);
|
||||
expect(html).toContain("Previous");
|
||||
expect(html).toContain("Next");
|
||||
// a11y: pagination buttons clear the 44 px mobile touch floor and
|
||||
@@ -352,10 +337,7 @@ describe("<IntakeHistoryListV2> — sort indicators", () => {
|
||||
],
|
||||
1,
|
||||
);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-3" />,
|
||||
client,
|
||||
);
|
||||
const html = render(<IntakeHistoryListV2 medicationId="med-3" />, client);
|
||||
expect(html).not.toContain(">Previous<");
|
||||
expect(html).not.toContain(">Next<");
|
||||
});
|
||||
|
||||
@@ -17,12 +17,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -233,8 +228,12 @@ export function IntakeHistoryListV2({
|
||||
{sortIndicator("scheduledFor")}
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>{t("medications.intakeHistoryColStatus")}</TableHead>
|
||||
<TableHead>{t("medications.intakeHistoryColSource")}</TableHead>
|
||||
<TableHead>
|
||||
{t("medications.intakeHistoryColStatus")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("medications.intakeHistoryColSource")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -264,10 +263,7 @@ export function IntakeHistoryListV2({
|
||||
variant="secondary"
|
||||
className="gap-1 bg-green-500/20 text-xs text-green-400"
|
||||
>
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<Check aria-hidden="true" className="h-3 w-3" />
|
||||
{t("medications.intakeHistoryStatusTaken")}
|
||||
</Badge>
|
||||
) : isSkipped ? (
|
||||
|
||||
@@ -160,8 +160,12 @@ export const updateInventoryItemSchema = z.object({
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
});
|
||||
|
||||
export type CreateInventoryItemInput = z.infer<typeof createInventoryItemSchema>;
|
||||
export type UpdateInventoryItemInput = z.infer<typeof updateInventoryItemSchema>;
|
||||
export type CreateInventoryItemInput = z.infer<
|
||||
typeof createInventoryItemSchema
|
||||
>;
|
||||
export type UpdateInventoryItemInput = z.infer<
|
||||
typeof updateInventoryItemSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-K — `POST /api/medications/[id]/glp1` body validators.
|
||||
@@ -200,11 +204,7 @@ export const glp1DoseChangePostSchema = z.object({
|
||||
.refine((d) => d.getTime() <= Date.now() + FIVE_YEARS_MS, {
|
||||
message: "effectiveFrom must be within 5 years of now",
|
||||
}),
|
||||
doseValue: z
|
||||
.number()
|
||||
.finite()
|
||||
.min(0)
|
||||
.max(MAX_DOSE_MG),
|
||||
doseValue: z.number().finite().min(0).max(MAX_DOSE_MG),
|
||||
doseUnit: z.string().min(1).max(10),
|
||||
note: z.string().max(MAX_NOTE_CHARS).nullable().optional(),
|
||||
});
|
||||
@@ -225,10 +225,9 @@ export const glp1PostBodySchema = z
|
||||
doseChange: glp1DoseChangePostSchema.optional(),
|
||||
inventory: glp1InventoryPostSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(b) => Boolean(b.doseChange) !== Boolean(b.inventory),
|
||||
{ message: "Body must carry exactly one of doseChange or inventory" },
|
||||
);
|
||||
.refine((b) => Boolean(b.doseChange) !== Boolean(b.inventory), {
|
||||
message: "Body must carry exactly one of doseChange or inventory",
|
||||
});
|
||||
|
||||
export type Glp1DoseChangePostInput = z.infer<typeof glp1DoseChangePostSchema>;
|
||||
export type Glp1InventoryPostInput = z.infer<typeof glp1InventoryPostSchema>;
|
||||
|
||||
Reference in new issue
Block a user