mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(dawarich): pin the movement block, the channels and the mocks; document the connection
Covers the day section, the model projection without the travel country and the two correlation channels; the backup and correlation mocks learn the new delegate. Adds the operator page for DAWARICH_PRIVATE_ORIGINS and regenerates the API contract.
This commit is contained in:
1 parent
b99bcaeec6
commit
c03f79afd5
7 files changed
+372
-58
No files matched your search
+145
-58
@@ -20466,12 +20466,12 @@ paths:
|
||||
tags:
|
||||
- Integrations
|
||||
summary: Read every integration's status in one call
|
||||
description: "The consolidated envelope behind the integrations settings surface: all eight providers, always, in a
|
||||
fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this
|
||||
over eight per-provider round-trips. Pure read. Auth via cookie or Bearer."
|
||||
description: "The consolidated envelope behind the integrations settings surface: all nine providers, always, in a fixed
|
||||
order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this over nine
|
||||
per-provider round-trips. Pure read. Auth via cookie or Bearer."
|
||||
responses:
|
||||
"200":
|
||||
description: All eight provider entries plus the alert threshold.
|
||||
description: All nine provider entries plus the alert threshold.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -24359,7 +24359,6 @@ paths:
|
||||
"403": *a19
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
/api/dawarich/status:
|
||||
get:
|
||||
tags:
|
||||
@@ -24376,7 +24375,6 @@ paths:
|
||||
"403": *a19
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
/api/dawarich/places:
|
||||
get:
|
||||
tags:
|
||||
@@ -24407,7 +24405,6 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
"502":
|
||||
description: "`dawarich.unreachable`."
|
||||
content:
|
||||
@@ -24419,7 +24416,10 @@ paths:
|
||||
tags:
|
||||
- Integrations
|
||||
summary: Dawarich home place and country (v1.43)
|
||||
description: 422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have.
|
||||
description: 422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have; 409
|
||||
`dawarich.not_connected`; 502 `dawarich.unreachable` when the place cannot be checked. A new home country
|
||||
re-judges the stored travel days at once; a new home place fetches the stored days again in the background (up
|
||||
to a year).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -24435,16 +24435,29 @@ paths:
|
||||
$ref: "#/components/schemas/DawarichSettingsEnvelope"
|
||||
"401": *a1
|
||||
"403": *a19
|
||||
"409":
|
||||
description: "`dawarich.not_connected`."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
"502":
|
||||
description: "`dawarich.unreachable`."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/dawarich/test:
|
||||
post:
|
||||
tags:
|
||||
- Integrations
|
||||
summary: Probe the Dawarich connection (v1.43)
|
||||
description: Answers the shared integration probe shape; a failed probe is an error status. The probe classes are those
|
||||
of the other `/test` routes.
|
||||
description: Answers the shared integration probe shape. A failed probe is a 502 whose `errorCode` is one of the shared
|
||||
probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `url_not_public`,
|
||||
`connection_failed`); 422 `not_configured` without a connection; 429 `rate_limited_self` past five probes a
|
||||
minute.
|
||||
responses:
|
||||
"200":
|
||||
description: The probe reached the instance.
|
||||
@@ -24456,13 +24469,20 @@ paths:
|
||||
"403": *a19
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
"502":
|
||||
description: A probe class; see the description.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/dawarich/sync:
|
||||
post:
|
||||
tags:
|
||||
- Integrations
|
||||
summary: Fetch Dawarich daily values again (v1.43)
|
||||
description: From `fromDate` (at most 365 days back). 409 `dawarich.not_connected`; 422 `dawarich.invalid`.
|
||||
description: From `fromDate` (inside the 365 days ending yesterday; without it the nightly window of seven days).
|
||||
`enqueued` is false when a run for this account is already queued or no worker is bound. 409
|
||||
`dawarich.not_connected`; 422 `dawarich.invalid`.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -24491,13 +24511,13 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
/api/dawarich/disconnect:
|
||||
post:
|
||||
tags:
|
||||
- Integrations
|
||||
summary: Disconnect Dawarich (v1.43)
|
||||
description: "Forgets the connection; with `deleteData: true` the stored days go too."
|
||||
description: "Forgets the connection; with `deleteData: true` the stored days go too. No body is `{ deleteData: false
|
||||
}`. Disconnecting twice is harmless."
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -24520,12 +24540,13 @@ paths:
|
||||
"403": *a19
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
/api/environment/travel/suggestions:
|
||||
get:
|
||||
tags:
|
||||
- Environment
|
||||
summary: Suggested trips from Dawarich travel days (v1.43)
|
||||
description: Runs of at least two consecutive travel days in one country, inside the last year, that no stored trip
|
||||
overlaps and the person has not dismissed; newest first. Empty without a Dawarich connection.
|
||||
responses:
|
||||
"200":
|
||||
description: The suggestions.
|
||||
@@ -24537,7 +24558,6 @@ paths:
|
||||
"403": *a19
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
/api/environment/travel/suggestions/{id}/dismiss:
|
||||
post:
|
||||
tags:
|
||||
@@ -24567,7 +24587,6 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a69
|
||||
/api/medications/{id}/links:
|
||||
post:
|
||||
tags:
|
||||
@@ -35713,42 +35732,43 @@ components:
|
||||
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
|
||||
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
|
||||
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
|
||||
dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid; demo — demo.readOnly; documents —
|
||||
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
|
||||
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
|
||||
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
|
||||
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
|
||||
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
|
||||
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
|
||||
documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed,
|
||||
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
|
||||
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
|
||||
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
|
||||
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
|
||||
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
|
||||
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
|
||||
documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid,
|
||||
dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo —
|
||||
demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid,
|
||||
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
|
||||
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
|
||||
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
|
||||
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
|
||||
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
|
||||
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
|
||||
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
|
||||
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
|
||||
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; encryption
|
||||
— encryption.keyBackup.stale, encryption.key_mismatch; environment — environment.backfill_pending,
|
||||
environment.backfill_rate_limited, environment.invalid, environment.no_home,
|
||||
environment.range_too_large, environment.travel.not_found; export — export.selection.empty,
|
||||
export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid;
|
||||
health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness —
|
||||
illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid,
|
||||
illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import —
|
||||
import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights —
|
||||
insights.generate.budgetExceeded, insights.generate.outboundScreened,
|
||||
insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
|
||||
labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate,
|
||||
labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound,
|
||||
labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
|
||||
labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge,
|
||||
labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
|
||||
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch,
|
||||
labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
|
||||
environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found;
|
||||
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
|
||||
feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
|
||||
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
|
||||
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
|
||||
illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy,
|
||||
import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded,
|
||||
insights.generate.outboundScreened, insights.generate.profileScopeChanged,
|
||||
insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid,
|
||||
labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch,
|
||||
labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
|
||||
labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed,
|
||||
labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic,
|
||||
labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound,
|
||||
labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
|
||||
labs.update.qualitativeExpected, labs.update.referenceRangeInvalid,
|
||||
labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found;
|
||||
managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
|
||||
@@ -43343,6 +43363,71 @@ components:
|
||||
- lon
|
||||
- label
|
||||
additionalProperties: {}
|
||||
dawarichDays:
|
||||
default: []
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
totalDistanceM:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
drivingDistanceM:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
trainDistanceM:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
placesVisited:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
awayFromHomeMinutes:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 1500
|
||||
- type: "null"
|
||||
countryCode:
|
||||
anyOf:
|
||||
- type: string
|
||||
pattern: ^[A-Z]{2}$
|
||||
- type: "null"
|
||||
countryCodeEncrypted:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
isTravelDay:
|
||||
anyOf:
|
||||
- type: boolean
|
||||
- type: "null"
|
||||
fetchedAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
createdAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
updatedAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- date
|
||||
additionalProperties: {}
|
||||
ecgRecordings:
|
||||
default: []
|
||||
type: array
|
||||
@@ -43541,6 +43626,7 @@ components:
|
||||
- userAchievements
|
||||
- environmentContexts
|
||||
- environmentTravelLocations
|
||||
- dawarichDays
|
||||
- ecgRecordings
|
||||
- manifest
|
||||
- decryptFailures
|
||||
@@ -77836,26 +77922,26 @@ components:
|
||||
metricFreshnessDegraded:
|
||||
type: boolean
|
||||
description: "The per-metric freshness query failed, so EVERY entry's `metricFreshness` is an empty array regardless of
|
||||
what the record actually holds. One flag rather than one per entry, because it is a single query for all
|
||||
eight providers: it fails for all of them or for none. When true, render the freshness section as
|
||||
unavailable rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire,
|
||||
which turned the honesty signal into the thing it was built to prevent. The rest of the envelope is
|
||||
unaffected and still complete."
|
||||
what the record actually holds. One flag rather than one per entry, because it is a single query for every
|
||||
provider: it fails for all of them or for none. When true, render the freshness section as unavailable
|
||||
rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire, which turned
|
||||
the honesty signal into the thing it was built to prevent. The rest of the envelope is unaffected and still
|
||||
complete."
|
||||
integrations:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IntegrationStatusEntry"
|
||||
description: "All eight providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura,
|
||||
strava, nightscout. A provider the user has never touched still appears, with `connected: false` and a
|
||||
synthesised `connected` ledger state — absence from this list never means anything, because nothing is ever
|
||||
absent from it."
|
||||
description: "All nine providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura,
|
||||
strava, nightscout, dawarich. A provider the user has never touched still appears, with `connected: false`
|
||||
and a synthesised `connected` ledger state — absence from this list never means anything, because nothing is
|
||||
ever absent from it."
|
||||
required:
|
||||
- threshold
|
||||
- metricFreshnessDegraded
|
||||
- integrations
|
||||
additionalProperties: false
|
||||
description: The one read behind the integrations settings surface. It carries every field the per-provider status
|
||||
routes carry, so a client has the choice of eight round-trips or this one; the per-provider routes remain for
|
||||
routes carry, so a client has the choice of nine round-trips or this one; the per-provider routes remain for
|
||||
callers that want a single provider.
|
||||
IntegrationStatusEntry:
|
||||
type: object
|
||||
@@ -77871,6 +77957,7 @@ components:
|
||||
- oura
|
||||
- strava
|
||||
- nightscout
|
||||
- dawarich
|
||||
description: Which provider this entry describes.
|
||||
state:
|
||||
type: string
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Dawarich integration
|
||||
|
||||
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
|
||||
instance: how far you moved, how much of it by car or by train, how many places
|
||||
you visited, how long you were away from home, and whether a day was spent in
|
||||
another country. It is part of the Environment module and runs only while that
|
||||
module is on.
|
||||
|
||||
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
|
||||
the day summaries and keeps one row of numbers per day. The place list is read
|
||||
live when you pick your home and is not saved.
|
||||
|
||||
## What is read
|
||||
|
||||
Once a night (03:20, server time) HealthLog fetches the last seven days, ending
|
||||
yesterday. Connecting fetches the last 30 days. Each request carries your API
|
||||
key as `Authorization: Bearer <key>`.
|
||||
|
||||
| Dawarich endpoint | What HealthLog keeps |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
|
||||
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
|
||||
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
|
||||
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
|
||||
| `GET /api/v1/users/me` | nothing; used to check the key |
|
||||
|
||||
Time away from home is the day's length minus the time a visit at your home
|
||||
place covers. Without a home place, or on a day without a single visit, it is
|
||||
left empty rather than shown as zero. Dawarich leaves out a mode that moved
|
||||
less than half a kilometre; HealthLog then reads that mode as zero for the day.
|
||||
|
||||
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
|
||||
has it. Without it there are no travel days, and everything else still works.
|
||||
Dawarich keys residency by UTC date, so on a day that crosses a border late in
|
||||
the evening the country can be the next morning's.
|
||||
|
||||
## Connect
|
||||
|
||||
1. In Dawarich, open your account settings and copy the API key.
|
||||
2. In HealthLog, turn on the Environment module, then open
|
||||
**Settings → Integrations → Dawarich**.
|
||||
3. Enter the address of the instance and the key, and connect.
|
||||
4. Pick your home place from the list, and set your home country if you like.
|
||||
Without one, the country you spent most days in over the last year counts.
|
||||
|
||||
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
|
||||
timeline by its own zone, HealthLog by yours; if they differ, a day can come
|
||||
back without its distances.
|
||||
|
||||
## A Dawarich instance on your own network
|
||||
|
||||
A public instance works without any server setting. An instance on a private
|
||||
network (a LAN address, a `.lan` name, a container on the same host) is refused
|
||||
unless the operator lists its exact origin:
|
||||
|
||||
```env
|
||||
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
|
||||
```
|
||||
|
||||
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
|
||||
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
|
||||
separated, no path, no wildcard and no range. Listing a host does not open its
|
||||
subdomains or another port. The unspecified address, link-local and the
|
||||
cloud-metadata range can never be listed. Restart HealthLog after changing it.
|
||||
The variable is on the `environment:` whitelist in `docker-compose.yml`.
|
||||
|
||||
## Where the values show up
|
||||
|
||||
- The day view has an **On the move** section with the day's figures.
|
||||
- Time away from home and the distance travelled join the correlation scan as
|
||||
two more factors.
|
||||
- Runs of at least two travel days that no trip covers yet are offered as
|
||||
trips under **Settings → Environment → Location periods**. Using one fills
|
||||
in the dates; you pick the place.
|
||||
- The Coach and the MCP `get_day` tool see the figures and whether it was a
|
||||
travel day, never the country.
|
||||
|
||||
## Disconnect
|
||||
|
||||
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
|
||||
and delete days** removes them as well. The days are part of the backup; the
|
||||
address and key are not, so a restored account connects again.
|
||||
@@ -216,6 +216,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.environmentTravelLocation.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.dawarichDay.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -160,6 +160,7 @@ beforeEach(() => {
|
||||
).mockResolvedValue([]);
|
||||
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.dawarichDay.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(hadFindingsBeforeSeasonalAdjustment).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* "On the move" renders the server's values as they come and leaves out what
|
||||
* the day has no value for: a null is no coverage, never a zero.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { DayMovementSection } from "../day-movement";
|
||||
|
||||
function render(
|
||||
movement: Parameters<typeof DayMovementSection>[0]["movement"],
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DayMovementSection movement={movement} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
source: "dawarich" as const,
|
||||
distance: { unit: "km" as const, total: 48.3, driving: 12, train: 0 },
|
||||
placesVisited: 4,
|
||||
awayFromHomeMinutes: 600,
|
||||
travel: { isTravelDay: true, countryCode: "ES" },
|
||||
fetchedAt: "2026-06-16T01:20:00.000Z",
|
||||
};
|
||||
|
||||
describe("DayMovementSection", () => {
|
||||
it("shows each value the day has", () => {
|
||||
const html = render(base);
|
||||
expect(html).toContain('data-movement="total"');
|
||||
expect(html).toContain("48.3 km");
|
||||
expect(html).toContain('data-movement="driving"');
|
||||
expect(html).toContain('data-movement="away"');
|
||||
expect(html).toContain("Spain");
|
||||
// No train that day: no row, rather than "0 km".
|
||||
expect(html).not.toContain('data-movement="train"');
|
||||
});
|
||||
|
||||
it("leaves out time away from home without coverage", () => {
|
||||
const html = render({
|
||||
...base,
|
||||
awayFromHomeMinutes: null,
|
||||
travel: null,
|
||||
});
|
||||
expect(html).not.toContain('data-movement="away"');
|
||||
expect(html).not.toContain('data-movement="travel"');
|
||||
});
|
||||
|
||||
it("renders nothing when the day has no value at all", () => {
|
||||
const html = render({
|
||||
...base,
|
||||
distance: { unit: "mi", total: null, driving: null, train: null },
|
||||
placesVisited: null,
|
||||
awayFromHomeMinutes: null,
|
||||
travel: null,
|
||||
});
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -222,3 +222,36 @@ describe("scores", () => {
|
||||
expect(JSON.stringify(projected.scores)).not.toContain("COMPUTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the movement block (v1.43)", () => {
|
||||
const withMovement: DayResponse = {
|
||||
...day,
|
||||
movement: {
|
||||
source: "dawarich",
|
||||
distance: { unit: "km", total: 48.3, driving: 12, train: 30.5 },
|
||||
placesVisited: 4,
|
||||
awayFromHomeMinutes: 600,
|
||||
travel: { isTravelDay: true, countryCode: "ES" },
|
||||
fetchedAt: "2026-03-30T01:20:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
it("reaches a model without the country of the travel day", () => {
|
||||
const model = projectDayForModel(withMovement, []);
|
||||
expect(model.movement).toEqual({
|
||||
distance: { unit: "km", total: 48.3, driving: 12, train: 30.5 },
|
||||
placesVisited: 4,
|
||||
awayFromHomeMinutes: 600,
|
||||
travel: { isTravelDay: true },
|
||||
});
|
||||
expect(JSON.stringify(model)).not.toContain("ES");
|
||||
});
|
||||
|
||||
it("stays out when the caller excludes the environment section", () => {
|
||||
const model = projectDayForModel(withMovement, [], undefined, {
|
||||
sections: new Set(["environment"]),
|
||||
types: new Set(),
|
||||
});
|
||||
expect(model).not.toHaveProperty("movement");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The two Dawarich movement channels: hours away from home and kilometres
|
||||
* travelled, one point per stored day that has the value. A day without it is
|
||||
* no coverage and gets no point, never a zero.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { dawarichDay: { findMany } } }));
|
||||
|
||||
import { fetchMovementSeries } from "@/lib/insights/correlation-channel-series";
|
||||
import { metricFamily } from "@/lib/insights/correlation-discovery";
|
||||
|
||||
describe("fetchMovementSeries", () => {
|
||||
it("emits a point only where the day has the value", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ date: "2026-06-14", awayFromHomeMinutes: 90, totalDistanceM: null },
|
||||
{ date: "2026-06-15", awayFromHomeMinutes: null, totalDistanceM: 48_300 },
|
||||
]);
|
||||
const [away, distance] = await fetchMovementSeries(
|
||||
"u1",
|
||||
"Europe/Berlin",
|
||||
new Date("2026-06-01T12:00:00Z"),
|
||||
);
|
||||
expect(away).toMatchObject({
|
||||
key: "MOVEMENT_AWAY",
|
||||
role: "behaviour",
|
||||
points: [{ day: "2026-06-14", value: 1.5 }],
|
||||
});
|
||||
expect(distance).toMatchObject({
|
||||
key: "MOVEMENT_DISTANCE",
|
||||
role: "behaviour",
|
||||
points: [{ day: "2026-06-15", value: 48.3 }],
|
||||
});
|
||||
expect(findMany.mock.calls[0][0].where).toEqual({
|
||||
userId: "u1",
|
||||
date: { gte: "2026-06-01" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the two channels in one family so they never pair with each other", () => {
|
||||
expect(metricFamily("MOVEMENT_AWAY")).toBe(
|
||||
metricFamily("MOVEMENT_DISTANCE"),
|
||||
);
|
||||
expect(metricFamily("MOVEMENT_AWAY")).not.toBe(metricFamily("MOOD"));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user