mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The JSON import read six measurement fields and six mood fields while the full backup and the JSON export carry twice that. A sleep night came back as stageless rows, a range reading as a single value, an ECG alert without its verdict, and a mood entry without its note, its level-A values, its zone, its tag and factor links or its day context. The import now reads valueMin/valueMax, sleepStage, rhythmClassification and deviceType on a reading, and note, a1 to a5, tz, factors, structuredTags and context on a mood entry. Each mood entry is written with its links and context in one transaction, the way the bulk mood ingest writes one. A link to a tag the importing account does not have is dropped and named in droppedTagKeys / droppedFactorKeys, and the import card says which. The schema moves to src/lib/import/json/schema.ts so the guards can read it. json-import-field-coverage.test.ts runs the JSON export and both backup variants on an account with every column filled in: every field they write is either read by the import or named with the reason it is not (row ids, source, deletion records, ciphertext, sync bookkeeping), and a reason for a field no exporter writes fails as stale. The round trip imports each file into a fresh account and compares the re-export field for field. The published contract is held to the runtime schema.
268 lines
7.1 KiB
TypeScript
268 lines
7.1 KiB
TypeScript
/**
|
|
* One account with every column the exporters read filled in, for the JSON
|
|
* import's round trip and its field-coverage guard.
|
|
*
|
|
* Every reading carries what makes it that reading: a range keeps its bounds,
|
|
* a sleep night its stages and its writer, a rhythm alert its verdict, a
|
|
* glucose value its meal-time context, a note its text. The mood entry
|
|
* carries a note, all five level-A values, its zone, a rated factor, a
|
|
* catalogue tag, a tag of the account's own and a full day context. A field
|
|
* the seed leaves empty is a field neither test can see arrive, so nothing
|
|
* here is left at its default.
|
|
*/
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { encryptNote } from "@/lib/crypto/note-cipher";
|
|
import { encodeKeyList } from "@/lib/mood/context-vocabulary";
|
|
|
|
import { makeUser } from "../kit/factories";
|
|
|
|
export const JI_FACTOR_KEY = "ji_sleep_quality";
|
|
export const JI_TAG_KEY = "ji_headache";
|
|
/** The account's own tag: another account importing the file cannot resolve it. */
|
|
export const JI_OWN_TAG_KEY = "custom:ji_long_walk";
|
|
|
|
export const JI_MEASUREMENTS = [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 81.4,
|
|
unit: "kg",
|
|
at: "2026-09-10T06:30:00.000Z",
|
|
note: "after the run",
|
|
},
|
|
{
|
|
type: "PULSE",
|
|
value: 64,
|
|
valueMin: 52,
|
|
valueMax: 91,
|
|
unit: "bpm",
|
|
at: "2026-09-11T07:00:00.000Z",
|
|
deviceType: "watch",
|
|
},
|
|
{
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 104,
|
|
unit: "mg/dL",
|
|
at: "2026-09-12T08:00:00.000Z",
|
|
glucoseContext: "FASTING",
|
|
},
|
|
{
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 141,
|
|
unit: "mg/dL",
|
|
at: "2026-09-12T13:00:00.000Z",
|
|
note: "after lunch",
|
|
glucoseContext: "POSTPRANDIAL",
|
|
},
|
|
{
|
|
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
|
value: 1,
|
|
unit: "count",
|
|
at: "2026-09-12T15:00:00.000Z",
|
|
rhythmClassification: "IRREGULAR",
|
|
deviceType: "watch",
|
|
},
|
|
// One night, as a watch writes it: a stage per row.
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 70,
|
|
unit: "minutes",
|
|
at: "2026-09-12T23:40:00.000Z",
|
|
sleepStage: "DEEP",
|
|
deviceType: "watch",
|
|
},
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 240,
|
|
unit: "minutes",
|
|
at: "2026-09-13T03:40:00.000Z",
|
|
sleepStage: "CORE",
|
|
deviceType: "watch",
|
|
},
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 95,
|
|
unit: "minutes",
|
|
at: "2026-09-13T05:15:00.000Z",
|
|
sleepStage: "REM",
|
|
deviceType: "watch",
|
|
},
|
|
] as const;
|
|
|
|
export const JI_MOOD = {
|
|
date: "2026-09-12",
|
|
mood: "GUT",
|
|
score: 4,
|
|
a1: 8,
|
|
a2: 3,
|
|
a3: 6,
|
|
a4: 7,
|
|
a5: 5,
|
|
tags: JSON.stringify(["work", "sleep"]),
|
|
note: "long day, good evening",
|
|
tz: "America/New_York",
|
|
at: "2026-09-13T01:30:00.000Z",
|
|
} as const;
|
|
|
|
/** A second entry with nothing but the label: what a one-tap check-in writes. */
|
|
export const JI_MOOD_BARE = {
|
|
date: "2026-09-11",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
at: "2026-09-11T20:30:00.000Z",
|
|
} as const;
|
|
|
|
export interface SeededOwner {
|
|
ownerId: string;
|
|
}
|
|
|
|
/** The catalogue the links resolve against: seeded rows, owned by no account. */
|
|
export async function seedJsonImportCatalogue(
|
|
prisma: PrismaClient,
|
|
): Promise<void> {
|
|
const category = await prisma.moodTagCategory.create({
|
|
data: { key: "ji_category", labelKey: "mood.tagCategory.ji" },
|
|
});
|
|
await prisma.moodTag.create({
|
|
data: {
|
|
categoryId: category.id,
|
|
key: JI_TAG_KEY,
|
|
labelKey: "mood.tag.jiHeadache",
|
|
kind: "BINARY",
|
|
},
|
|
});
|
|
await prisma.moodTag.create({
|
|
data: {
|
|
categoryId: category.id,
|
|
key: JI_FACTOR_KEY,
|
|
labelKey: "mood.tag.jiSleepQuality",
|
|
kind: "RATED",
|
|
scaleMin: 1,
|
|
scaleMax: 5,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function seedJsonImportOwner(
|
|
prisma: PrismaClient,
|
|
): Promise<SeededOwner> {
|
|
const owner = await makeUser({
|
|
timezone: "Europe/Berlin",
|
|
glucoseUnit: "mmol/L" /* the JSON export stays in the stored unit */,
|
|
});
|
|
const ownerId = owner.id;
|
|
for (const m of JI_MEASUREMENTS) {
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: ownerId,
|
|
type: m.type,
|
|
value: m.value,
|
|
valueMin: "valueMin" in m ? m.valueMin : null,
|
|
valueMax: "valueMax" in m ? m.valueMax : null,
|
|
unit: m.unit,
|
|
measuredAt: new Date(m.at),
|
|
source: "APPLE_HEALTH",
|
|
notes: null,
|
|
notesEncrypted: encryptNote("note" in m ? m.note : null),
|
|
glucoseContext: "glucoseContext" in m ? m.glucoseContext : null,
|
|
sleepStage: "sleepStage" in m ? m.sleepStage : null,
|
|
rhythmClassification:
|
|
"rhythmClassification" in m ? m.rhythmClassification : null,
|
|
deviceType: "deviceType" in m ? m.deviceType : null,
|
|
},
|
|
});
|
|
}
|
|
// A reading deleted before the backup: its row is the record of a
|
|
// deletion, and the import leaves it out.
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: ownerId,
|
|
type: "WEIGHT",
|
|
value: 90,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-09-09T06:30:00.000Z"),
|
|
source: "MANUAL",
|
|
deletedAt: new Date("2026-09-09T07:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const category = await prisma.moodTagCategory.findUniqueOrThrow({
|
|
where: { key: "ji_category" },
|
|
});
|
|
const ownTag = await prisma.moodTag.create({
|
|
data: {
|
|
userId: ownerId,
|
|
categoryId: category.id,
|
|
key: JI_OWN_TAG_KEY,
|
|
labelKey: "mood.tag.custom",
|
|
kind: "BINARY",
|
|
},
|
|
});
|
|
const [tag, factor] = await Promise.all([
|
|
prisma.moodTag.findUniqueOrThrow({ where: { key: JI_TAG_KEY } }),
|
|
prisma.moodTag.findUniqueOrThrow({ where: { key: JI_FACTOR_KEY } }),
|
|
]);
|
|
|
|
const entry = await prisma.moodEntry.create({
|
|
data: {
|
|
userId: ownerId,
|
|
date: JI_MOOD.date,
|
|
mood: JI_MOOD.mood,
|
|
score: JI_MOOD.score,
|
|
moodA1: JI_MOOD.a1,
|
|
stressA2: JI_MOOD.a2,
|
|
energyA3: JI_MOOD.a3,
|
|
connectionA4: JI_MOOD.a4,
|
|
stabilityA5: JI_MOOD.a5,
|
|
tags: JI_MOOD.tags,
|
|
note: null,
|
|
noteEncrypted: encryptNote(JI_MOOD.note),
|
|
tz: JI_MOOD.tz,
|
|
source: "MANUAL",
|
|
moodLoggedAt: new Date(JI_MOOD.at),
|
|
},
|
|
});
|
|
await prisma.moodEntryTagLink.createMany({
|
|
data: [
|
|
{ moodEntryId: entry.id, moodTagId: tag.id },
|
|
{ moodEntryId: entry.id, moodTagId: ownTag.id },
|
|
{ moodEntryId: entry.id, moodTagId: factor.id, rating: 4 },
|
|
],
|
|
});
|
|
await prisma.moodContext.create({
|
|
data: {
|
|
moodEntryId: entry.id,
|
|
userId: ownerId,
|
|
workStatus: "regular",
|
|
workMinutes: 540,
|
|
overtimeMinutes: 60,
|
|
workLoad: 7,
|
|
workSatisfaction: 6,
|
|
contactCircles: encodeKeyList(["partner", "family"]),
|
|
contactForm: "inPerson",
|
|
contactExtent: "extended",
|
|
contactQuality: 8,
|
|
contactSupport: 7,
|
|
leisureCategories: encodeKeyList(["reading"]),
|
|
leisureMinutes: 90,
|
|
leisureJoy: 8,
|
|
leisureRecovery: 7,
|
|
eventType: "goodNews",
|
|
eventValence: 3,
|
|
eventAt: new Date("2026-09-12T18:00:00.000Z"),
|
|
notesEncrypted: encryptNote("the call went well"),
|
|
},
|
|
});
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
userId: ownerId,
|
|
date: JI_MOOD_BARE.date,
|
|
mood: JI_MOOD_BARE.mood,
|
|
score: JI_MOOD_BARE.score,
|
|
moodA1: 5,
|
|
source: "MANUAL",
|
|
moodLoggedAt: new Date(JI_MOOD_BARE.at),
|
|
},
|
|
});
|
|
return { ownerId };
|
|
}
|