feat: garden review fixes — honest dose answer grows it, quiet after crisis, no-promise texts (ADR-015)
CI / ruff · pytest (pull_request) Successful in 2m21s

Refs #120

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:47:34 +03:00
co-authored by Claude Opus 5.5
parent 78312f5076
commit e2e1283d8e
7 changed files with 100 additions and 14 deletions
+23 -6
View File
@@ -5,11 +5,13 @@ activity rows — no own table, no migration (ADR-003), and changing the rules r
«Without pressure»: points only add up (a skipped day takes nothing away), growth counts the fact of an activity
and never its content (scale scores, mood), and there is no comparison between patients. Only the patient sees it.
An honest «Пропущу» on a dose grows the garden like «Принял(а)», so the garden never rewards a false «taken» (#120).
After a recent acute/high alert the garden is `quiet`: growth stays, celebration does not.
"""
from collections import defaultdict
from collections.abc import Iterable
from datetime import date, datetime
from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Literal
@@ -21,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock
from app.core.intake_questions import DIARY_KINDS
from app.db.clinical import DailyCheckin, Dose, ScaleResult
from app.db.clinical import Alert, DailyCheckin, Dose, ScaleResult
from app.db.intake import QuestionnaireRecord
Activity = Literal["meds", "diary", "questionnaire"]
@@ -37,8 +39,15 @@ class TitleRule(BaseModel):
title: str
class QuietRule(BaseModel):
alert_levels: list[str]
hours: int
class GardenConfig(BaseModel):
activities: dict[Activity, ActivityRule]
answered_dose_statuses: list[str]
quiet: QuietRule
step_points: int
stages_per_plant: int
max_plants: int
@@ -73,6 +82,7 @@ class Garden(BaseModel):
points_in_step: int # progress towards the next growth step: «8 из 10»
plants: list[Plant]
today: list[TodayItem]
quiet: bool = False # recent acute/high alert: no «Сад подрос», no grow-in animation
def compute(events: Iterable[tuple[Activity, date]], today: date, cfg: GardenConfig | None = None) -> Garden:
@@ -113,7 +123,7 @@ async def garden_for(s: AsyncSession, token: str) -> Garden:
doses = (await s.scalars(select(Dose).where(Dose.token == token))).all()
for d in doses:
if d.status == "taken":
if d.status in cfg.answered_dose_statuses:
events.append(("meds", _day(d.answered_at or d.planned_at, tz)))
checkins = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for c in checkins:
@@ -133,19 +143,26 @@ async def garden_for(s: AsyncSession, token: str) -> Garden:
events.append(("questionnaire", _day(sc.ts, tz)))
garden = compute(events, today, cfg)
since = now - timedelta(hours=cfg.quiet.hours)
recent_alert = await s.scalar(
select(Alert.id)
.where(Alert.token == token, Alert.level.in_(cfg.quiet.alert_levels), Alert.ts >= since, Alert.ts <= now)
.limit(1)
)
garden.quiet = recent_alert is not None
done_today = {kind for kind, day in events if day == today}
items: list[TodayItem] = []
due = [d for d in doses if _day(d.planned_at, tz) == today]
if due:
taken = sum(d.status == "taken" for d in due)
answered = sum(d.status in cfg.answered_dose_statuses for d in due)
due_now = sum(d.planned_at <= now for d in due)
items.append(
TodayItem(
activity="meds",
title=cfg.today["meds"],
done=taken > 0 and taken >= due_now,
detail=f"{taken} из {len(due)}",
done=answered > 0 and answered >= due_now,
detail=f"{answered} из {len(due)}",
)
)
items.append(TodayItem(activity="diary", title=cfg.today["diary"], done="diary" in done_today))