mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
63b54587a8
commit
cb75986eb5
2 files changed
+138
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user