From cb75986eb5e6cfd8cdb7b468ef82d7796e660cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:02:01 +0200 Subject: [PATCH] Add the health-score record table The score is computed live from today's rows, so once the readings behind a past day change the number the person actually saw that day is gone. Nothing wrote it down. This adds the table that does: one row per account per local day, holding the composite, its band, the algorithm version, the composition that produced it, every counted pillar's own score, and a fingerprint over exactly what those depend on. Append-only by construction. A unique index on account plus local day gives the writer something to conflict against, and there is no updated_at, because a later computation of the same day must leave the earlier row exactly as it stood. --- .../0290_health_score_record/migration.sql | 56 +++++++++++++ prisma/schema.prisma | 82 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 prisma/migrations/0290_health_score_record/migration.sql diff --git a/prisma/migrations/0290_health_score_record/migration.sql b/prisma/migrations/0290_health_score_record/migration.sql new file mode 100644 index 000000000..6bcd13b9b --- /dev/null +++ b/prisma/migrations/0290_health_score_record/migration.sql @@ -0,0 +1,56 @@ +-- v1.35.0 — the health score as it was shown, one row per account per local day. +-- +-- Nothing can rebuild this. The live computation always answers for today from +-- today's rows, so once the readings behind a past day have been corrected, +-- re-synced or simply grown, the number the person saw on that day is gone +-- unless it was written down. This table is the writing down. +-- +-- Two properties are enforced here rather than left to the application: +-- +-- * one row per account per local day, so a second computation on the same +-- day cannot mint a rival record of the same day, and the writer's +-- ON CONFLICT DO NOTHING has something to conflict against; +-- * a closed set of bands, so a row can never carry a band no surface knows +-- how to render. +-- +-- There is deliberately no updated_at column. The row is written once and +-- never rewritten: a later computation under different readings, or under a +-- different configuration, leaves the earlier day exactly as it stood. + +CREATE TABLE "health_score_records" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "day_key" VARCHAR(10) NOT NULL, + "timezone" TEXT NOT NULL, + "composite" INTEGER NOT NULL, + "band" VARCHAR(8) NOT NULL, + "score_version" INTEGER NOT NULL, + "composition" TEXT[] NOT NULL, + "pillar_scores" JSONB NOT NULL, + "input_fingerprint" VARCHAR(64) NOT NULL, + "config_version" INTEGER, + "config_changed_at" TIMESTAMPTZ(3), + "computed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"), + CONSTRAINT "health_score_records_band_check" + CHECK ("band" IN ('green', 'yellow', 'red')), + CONSTRAINT "health_score_records_composite_range_check" + CHECK ("composite" >= 0 AND "composite" <= 100), + CONSTRAINT "health_score_records_day_key_check" + CHECK ("day_key" ~ '^\d{4}-\d{2}-\d{2}$'), + CONSTRAINT "health_score_records_composition_not_empty_check" + CHECK (array_length("composition", 1) >= 1) +); + +CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key" + ON "health_score_records" ("user_id", "day_key"); + +CREATE INDEX "health_score_records_user_id_day_key_desc_idx" + ON "health_score_records" ("user_id", "day_key" DESC); + +ALTER TABLE "health_score_records" + ADD CONSTRAINT "health_score_records_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6cd6fa6a8..8d7af385f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -846,6 +846,10 @@ model User { customMetrics CustomMetric[] customMetricEntries CustomMetricEntry[] correlationPatterns CorrelationPattern[] + // v1.35.0 — one row per local day holding the health score as it was shown + // that day. Append-only: a later recomputation of the same day never + // rewrites the row it finds. + healthScoreRecords HealthScoreRecord[] // v1.19.0 — illness / condition journal (born-gated by the module system). // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the // per-day timeline mirroring the CycleDayLog shape. @@ -5050,6 +5054,84 @@ model UserAchievement { @@map("user_achievements") } +// ─── Health Score record ────── + +/// The health score as it was SHOWN to the account on one local day. +/// +/// Not a cache and not a derived tier: nothing can rebuild it. The live +/// computation always answers for today from today's rows, so once the rows +/// behind an old day have been edited, re-synced, corrected or simply grown, +/// the number the person actually saw that day is gone unless it was written +/// down. That is what this table is, and it is why the write is append-only: +/// the FIRST score computed on a local day is the one that is kept, and a +/// later computation of the same day leaves the row exactly as it found it. +/// +/// It never overrides the live computation either. Every surface keeps +/// reading the live score; this table is read only where a past day is the +/// question. +model HealthScoreRecord { + id String @id @default(cuid()) + userId String @map("user_id") + /// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in + /// `timezone`. Lexicographically ordered, so a trailing-window read is a + /// range scan. + dayKey String @map("day_key") @db.VarChar(10) + /// The IANA zone the day was cut in. An account that moves zones leaves + /// behind days cut on the old clock; carrying the zone is what lets a + /// reader see that rather than silently compare days of different lengths. + timezone String + + /// The composite 0-100 as shown. Integer, matching the composite's own + /// rounding. + composite Int + /// The band as shown (`green` / `yellow` / `red`). Stored rather than + /// re-derived from `composite`, because the worst pillar can pull the band + /// below the one the mean alone would give, so the number does not + /// determine the band. + band String @db.VarChar(8) + /// Algorithm identity the row was computed under. + scoreVersion Int @map("score_version") + /// Registry-ordered pillar ids that produced the composite. Part of the + /// number's identity: the same value under a different composition is a + /// different measurement. + composition String[] + /// Each counted pillar's own 0-100 score, keyed by pillar id. The composite + /// breaks across a composition change; these stay comparable through one, + /// which is the whole reason they are stored beside it rather than folded + /// away. + pillarScores Json @map("pillar_scores") + /// SHA-256 over exactly what the stored composite and band depend on: the + /// score version, the composition, and every counted pillar's score with + /// its scoring-mode identity. Two rows with the same fingerprint were + /// computed from the same standing; two rows that differ, were not. + inputFingerprint String @db.VarChar(64) @map("input_fingerprint") + + /// The account's health-score configuration version in force when the row + /// was written, and when that configuration last changed. Written by + /// whoever computes the score; today nothing supplies them, because the + /// per-user configuration itself lands beside this table and its delta + /// guard lands after it. They are here now so that guard does not need a + /// second migration over a table that will already hold rows. + /// @pending-consumer(score delta guard) suppresses the week-over-week delta + /// across a configuration change instead of narrating a settings action as + /// a health event. + configVersion Int? @map("config_version") + /// @pending-consumer(score delta guard) dates the series break on the first + /// record written after a configuration change. + configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3) + + /// When the score was computed. Distinct from `dayKey`: the day says which + /// day was scored, this says when the scoring ran. + computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, dayKey]) + @@index([userId, dayKey(sort: Desc)]) + @@map("health_score_records") +} + // ─── Host Metrics (admin system-status host-load chart) ────── /// Per-minute snapshot of host-process load + memory captured by the