Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
281 lines
9.4 KiB
Python
281 lines
9.4 KiB
Python
"""Planning (addendum-planning.md) at service level: derived "done" days,
|
|
adherence over crafted weeks, streaks across week boundaries, timezone edges."""
|
|
|
|
import datetime as dt
|
|
from decimal import Decimal
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.modules.auth.models import User
|
|
from app.modules.health import service, stats
|
|
from app.modules.health.models import (
|
|
FoodEntry,
|
|
ScheduleKind,
|
|
TrackingSchedule,
|
|
WeightEntry,
|
|
Workout,
|
|
)
|
|
|
|
PARIS = ZoneInfo("Europe/Paris")
|
|
|
|
|
|
def utc_at(day: dt.date, hour: int, minute: int = 0) -> dt.datetime:
|
|
"""UTC instant for a Paris wall-clock time."""
|
|
return dt.datetime(
|
|
day.year, day.month, day.day, hour, minute, tzinfo=PARIS
|
|
).astimezone(dt.UTC)
|
|
|
|
|
|
def schedule(db: Session, user: User, kind: str, weekdays: list[int], enabled=True):
|
|
db.add(
|
|
TrackingSchedule(
|
|
user_id=user.id,
|
|
kind=ScheduleKind(kind),
|
|
weekdays=weekdays,
|
|
enabled=enabled,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def add_weight(db: Session, user: User, day: dt.date, kg: float, hour: int = 7) -> None:
|
|
db.add(
|
|
WeightEntry(
|
|
user_id=user.id,
|
|
source="manual",
|
|
measured_at=utc_at(day, hour),
|
|
weight_kg=Decimal(str(kg)),
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def add_workout(db: Session, user: User, day: dt.date, hidden: bool = False) -> None:
|
|
start = utc_at(day, 18)
|
|
db.add(
|
|
Workout(
|
|
user_id=user.id,
|
|
source="manual",
|
|
started_at=start,
|
|
ended_at=start + dt.timedelta(hours=1),
|
|
sport_type="running",
|
|
is_hidden=hidden,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def add_food(
|
|
db: Session, user: User, day: dt.date, kcal: float, hour: int = 13
|
|
) -> None:
|
|
db.add(
|
|
FoodEntry(
|
|
user_id=user.id,
|
|
source="manual",
|
|
eaten_at=utc_at(day, hour),
|
|
meal="lunch",
|
|
name="Repas",
|
|
quantity=Decimal(100),
|
|
unit="g",
|
|
kcal=Decimal(str(kcal)),
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
# --- Derived "done" days -------------------------------------------------------
|
|
|
|
|
|
def test_done_days_are_derived_from_the_three_tables(db: Session, user: User) -> None:
|
|
day = dt.date(2026, 8, 12)
|
|
add_weight(db, user, day, 91.6)
|
|
add_weight(db, user, day, 92.4, hour=20) # second weigh-in of the day
|
|
add_workout(db, user, day)
|
|
add_food(db, user, day, 620.0)
|
|
add_food(db, user, day, 380.0)
|
|
|
|
done = service.done_days_by_kind(db, user.id, day, day, PARIS)
|
|
assert done["weigh_in"] == {day: 91.6} # FIRST weigh-in of the local day
|
|
assert done["workout"] == {day: 1.0}
|
|
assert done["food_log"] == {day: 1000.0}
|
|
|
|
|
|
def test_hidden_workouts_do_not_count_as_done(db: Session, user: User) -> None:
|
|
day = dt.date(2026, 8, 12)
|
|
add_workout(db, user, day, hidden=True)
|
|
done = service.done_days_by_kind(db, user.id, day, day, PARIS)
|
|
assert done["workout"] == {}
|
|
|
|
|
|
def test_local_day_boundary_uses_the_profile_timezone(db: Session, user: User) -> None:
|
|
# 22:30 UTC on 12/08 is 00:30 on 13/08 in Paris (CEST = UTC+2).
|
|
db.add(
|
|
WeightEntry(
|
|
user_id=user.id,
|
|
source="manual",
|
|
measured_at=dt.datetime(2026, 8, 12, 22, 30, tzinfo=dt.UTC),
|
|
weight_kg=Decimal("91.6"),
|
|
)
|
|
)
|
|
db.commit()
|
|
paris = service.done_days_by_kind(
|
|
db, user.id, dt.date(2026, 8, 12), dt.date(2026, 8, 13), PARIS
|
|
)
|
|
assert dt.date(2026, 8, 13) in paris["weigh_in"]
|
|
assert dt.date(2026, 8, 12) not in paris["weigh_in"]
|
|
|
|
utc = service.done_days_by_kind(
|
|
db, user.id, dt.date(2026, 8, 12), dt.date(2026, 8, 13), ZoneInfo("UTC")
|
|
)
|
|
assert dt.date(2026, 8, 12) in utc["weigh_in"]
|
|
|
|
|
|
# --- Schedules -----------------------------------------------------------------
|
|
|
|
|
|
def test_missing_schedules_are_returned_disabled(db: Session, user: User) -> None:
|
|
rows = service.list_schedules(db, user.id)
|
|
assert [row.kind.value for row in rows] == ["weigh_in", "workout", "food_log"]
|
|
assert all(row.enabled is False and row.weekdays == [] for row in rows)
|
|
|
|
|
|
def test_upsert_schedule_is_idempotent_per_kind(db: Session, user: User) -> None:
|
|
from app.modules.health.schemas import ScheduleUpdate
|
|
|
|
first = service.upsert_schedule(
|
|
db,
|
|
user.id,
|
|
ScheduleKind.WEIGH_IN,
|
|
ScheduleUpdate(weekdays=[4, 0], enabled=True),
|
|
)
|
|
assert first.weekdays == [0, 4]
|
|
second = service.upsert_schedule(
|
|
db, user.id, ScheduleKind.WEIGH_IN, ScheduleUpdate(weekdays=[2], enabled=False)
|
|
)
|
|
assert first.id == second.id
|
|
assert second.weekdays == [2]
|
|
assert second.enabled is False
|
|
|
|
|
|
# --- Adherence over crafted weeks ---------------------------------------------
|
|
|
|
|
|
def test_adherence_over_four_weeks_of_mondays(db: Session, user: User) -> None:
|
|
today = service.today_local(PARIS)
|
|
weekday = today.weekday()
|
|
schedule(db, user, "weigh_in", [weekday])
|
|
planned = [today - dt.timedelta(days=7 * n) for n in range(4)]
|
|
for day in planned[:3]: # the oldest planned day is missed
|
|
add_weight(db, user, day, 92.0)
|
|
|
|
body = stats.adherence_stats(db, user.id, planned[-1], today, PARIS, "weigh_in")
|
|
assert [kind.kind.value for kind in body.kinds] == ["weigh_in"]
|
|
weigh_in = body.kinds[0]
|
|
assert weigh_in.weekdays == [weekday]
|
|
assert weigh_in.planned_days == 4
|
|
assert weigh_in.done_days == 3
|
|
assert weigh_in.missed_days == 1
|
|
assert weigh_in.adherence_pct == 75.0
|
|
assert weigh_in.streak.current == 3
|
|
assert weigh_in.streak.best == 3
|
|
assert len(weigh_in.days) == 22 # inclusive range
|
|
|
|
statuses = {row.date: row.status for row in weigh_in.days}
|
|
assert statuses[today] == "done"
|
|
assert statuses[planned[-1]] == "missed"
|
|
assert statuses[today - dt.timedelta(days=1)] == "rest"
|
|
|
|
|
|
def test_streak_spans_week_boundaries_with_two_days_a_week(
|
|
db: Session, user: User
|
|
) -> None:
|
|
today = service.today_local(PARIS)
|
|
# Two consecutive weekdays: for a Sunday anchor the pair straddles two ISO weeks.
|
|
first_day, second_day = today.weekday(), (today.weekday() + 1) % 7
|
|
schedule(db, user, "workout", sorted({first_day, second_day}))
|
|
start = today - dt.timedelta(days=21)
|
|
for offset in range((today - start).days + 1):
|
|
day = start + dt.timedelta(days=offset)
|
|
if day.weekday() in {first_day, second_day}:
|
|
add_workout(db, user, day)
|
|
|
|
body = stats.adherence_stats(db, user.id, start, today, PARIS, "workout")
|
|
workout = body.kinds[0]
|
|
assert workout.planned_days == workout.done_days
|
|
assert workout.missed_days == 0
|
|
assert workout.adherence_pct == 100.0
|
|
assert workout.streak.current == workout.planned_days
|
|
assert workout.streak.best >= workout.planned_days
|
|
|
|
|
|
def test_adherence_without_schedule_reports_no_planned_day(
|
|
db: Session, user: User
|
|
) -> None:
|
|
today = service.today_local(PARIS)
|
|
add_weight(db, user, today, 92.0)
|
|
body = stats.adherence_stats(
|
|
db, user.id, today - dt.timedelta(days=6), today, PARIS
|
|
)
|
|
kinds = {kind.kind.value: kind for kind in body.kinds}
|
|
assert set(kinds) == {"weigh_in", "workout", "food_log"}
|
|
weigh_in = kinds["weigh_in"]
|
|
assert weigh_in.planned_days == 0
|
|
assert weigh_in.adherence_pct is None
|
|
assert weigh_in.streak.current == 0
|
|
# An unplanned weigh-in is still reported for the heatmap.
|
|
statuses = {row.date: row.status for row in weigh_in.days}
|
|
assert statuses[today] == "done_unplanned"
|
|
|
|
|
|
def test_disabled_schedule_plans_nothing(db: Session, user: User) -> None:
|
|
today = service.today_local(PARIS)
|
|
schedule(db, user, "weigh_in", [today.weekday()], enabled=False)
|
|
body = stats.adherence_stats(
|
|
db, user.id, today - dt.timedelta(days=6), today, PARIS, "weigh_in"
|
|
)
|
|
assert body.kinds[0].planned_days == 0
|
|
assert body.kinds[0].enabled is False
|
|
|
|
|
|
# --- Today ---------------------------------------------------------------------
|
|
|
|
|
|
def test_today_reports_planned_done_and_values(db: Session, user: User) -> None:
|
|
today = service.today_local(PARIS)
|
|
weekday = today.weekday()
|
|
schedule(db, user, "weigh_in", [weekday])
|
|
schedule(db, user, "workout", [(weekday + 3) % 7]) # not today
|
|
schedule(db, user, "food_log", [weekday])
|
|
add_weight(db, user, today, 91.6)
|
|
add_food(db, user, today, 620.0)
|
|
|
|
body = stats.today_view(db, user.id, PARIS)
|
|
assert body.date == today
|
|
items = {item.kind.value: item for item in body.items}
|
|
assert items["weigh_in"].planned is True
|
|
assert items["weigh_in"].done is True
|
|
assert items["weigh_in"].value == 91.6
|
|
assert items["food_log"].value == 620.0
|
|
assert items["workout"].planned is False
|
|
assert items["workout"].done is False
|
|
assert items["workout"].value is None
|
|
assert body.streaks["weigh_in"].current == 1
|
|
assert body.streaks["workout"].current == 0
|
|
|
|
|
|
def test_today_streak_survives_a_planned_but_unfinished_day(
|
|
db: Session, user: User
|
|
) -> None:
|
|
today = service.today_local(PARIS)
|
|
schedule(db, user, "weigh_in", [today.weekday()])
|
|
for offset in (7, 14):
|
|
add_weight(db, user, today - dt.timedelta(days=offset), 92.0)
|
|
body = stats.today_view(db, user.id, PARIS)
|
|
items = {item.kind.value: item for item in body.items}
|
|
assert items["weigh_in"].planned is True
|
|
assert items["weigh_in"].done is False
|
|
# Today is not over yet: the two previous planned days still count.
|
|
assert body.streaks["weigh_in"].current == 2
|
|
assert body.streaks["weigh_in"].best == 2
|