Files
lifetrack/apps/api/app/modules/vape/service.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
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>
2026-08-14 10:48:57 +02:00

1591 lines
53 KiB
Python

"""Vape business logic (datamodel-health-vape.md §6-§7, endpoints §8.4).
Every function filters by `user_id`; user-facing messages are French.
Money is handled in euro cents everywhere.
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.core.errors import ConflictError, DomainValidationError, NotFoundError
from app.core.pagination import Page, PageParams, paginate
from app.core.timeutils import local_day, resolve_tz, utcnow
from app.modules.vape import calculations as calc
from app.modules.vape.models import (
LIQUID_KINDS,
CoilChange,
LiquidEntry,
LiquidEntryKind,
Mix,
MixComponent,
Product,
ProductKind,
Purchase,
SizeUnit,
VapeSettings,
)
from app.modules.vape.schemas import (
CoilChangeCreate,
CoilChangeCreated,
CoilChangeRead,
CoilChangeUpdate,
LiquidEntryCreate,
LiquidEntryRead,
LiquidEntryUpdate,
MilestoneRead,
MixCalculatorRequest,
MixCalculatorResult,
MixComponentRead,
MixCreate,
MixRead,
MixUpdate,
ProductCreate,
ProductRead,
ProductUpdate,
PurchaseCreate,
PurchaseRead,
PurchaseUpdate,
SeriesModel,
StatsResponse,
VapeDashboard,
VapeSettingsPut,
)
DEFAULT_WINDOW_DAYS = 30
SHORT_WINDOW_DAYS = 7
DEFAULT_COST_MONTHS = 12
NICOTINE_TREND_WINDOW_DAYS = 30
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def _round(value: float | Decimal | None, digits: int = 2) -> float | None:
return None if value is None else round(float(value), digits)
def _iso(day: date) -> str:
return day.isoformat()
def _as_utc(value: datetime) -> datetime:
"""SQLite gives naive datetimes back; stored values are always UTC."""
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _apply_sort(
stmt: Select,
sort: str | None,
allowed: dict[str, Any],
default: str,
tiebreak: Any,
) -> Select:
"""`?sort=field` / `?sort=-field` with an explicit whitelist (C2.8)."""
raw = sort or default
descending = raw.startswith("-")
name = raw.lstrip("-")
column = allowed.get(name)
if column is None:
raise DomainValidationError("Champ de tri non autorisé.", details={"sort": raw})
return stmt.order_by(column.desc() if descending else column.asc(), tiebreak)
def _resolve_period(
tz_name: str | None, from_: date | None, to: date | None, window: int
) -> tuple[ZoneInfo, date, date]:
"""(tz, from, to) with inclusive bounds; defaults to the last `window` days."""
tz = resolve_tz(tz_name)
today = local_day(utcnow(), tz)
end = to or today
start = from_ or end - timedelta(days=window - 1)
if start > end:
raise DomainValidationError(
"La date de début doit précéder la date de fin.",
details={"from": _iso(start), "to": _iso(end)},
)
return tz, start, end
# ---------------------------------------------------------------------------
# Settings (§6.1)
# ---------------------------------------------------------------------------
def find_settings(db: Session, user_id: int) -> VapeSettings | None:
return db.scalar(select(VapeSettings).where(VapeSettings.user_id == user_id))
def get_settings(db: Session, user_id: int) -> VapeSettings:
settings = find_settings(db, user_id)
if settings is None:
raise NotFoundError(
"Paramètres vape non configurés.", details={"setup_required": True}
)
return settings
def put_settings(db: Session, user_id: int, payload: VapeSettingsPut) -> VapeSettings:
"""Upsert of the per-user singleton (§8.4 PUT /vape/settings)."""
settings = find_settings(db, user_id)
if settings is None:
settings = VapeSettings(user_id=user_id, **payload.model_dump())
db.add(settings)
else:
for field, value in payload.model_dump().items():
setattr(settings, field, value)
db.commit()
db.refresh(settings)
return settings
# ---------------------------------------------------------------------------
# Products (§6.2)
# ---------------------------------------------------------------------------
def _product_read(product: Product) -> ProductRead:
return ProductRead(
id=product.id,
kind=product.kind,
name=product.name,
brand=product.brand,
price_cents=product.price_cents,
size_value=product.size_value,
size_unit=product.size_unit,
nicotine_mg_ml=product.nicotine_mg_ml,
vg_pct=product.vg_pct,
ohm=product.ohm,
is_archived=product.is_archived,
note=product.note,
unit_price_cents=float(product.unit_price_cents),
)
def get_product(db: Session, user_id: int, product_id: int) -> Product:
product = db.get(Product, product_id)
if product is None or product.user_id != user_id:
raise NotFoundError("Produit introuvable.")
return product
def list_products(
db: Session,
user_id: int,
params: PageParams,
kind: ProductKind | None = None,
include_archived: bool = False,
sort: str | None = None,
) -> Page[ProductRead]:
stmt = select(Product).where(Product.user_id == user_id)
if kind is not None:
stmt = stmt.where(Product.kind == kind)
if not include_archived:
stmt = stmt.where(Product.is_archived.is_(False))
stmt = _apply_sort(
stmt,
sort,
{
"name": Product.name,
"kind": Product.kind,
"price_cents": Product.price_cents,
},
"name",
Product.id.desc(),
)
rows, total = paginate(db, stmt, params)
return Page(
items=[_product_read(row) for row in rows],
total=total,
page=params.page,
page_size=params.page_size,
)
def _check_product_consistency(
kind: ProductKind, nicotine_mg_ml: Decimal | None
) -> None:
if kind is ProductKind.BOOSTER and nicotine_mg_ml is None:
raise DomainValidationError(
"Un booster doit indiquer son taux de nicotine (mg/ml)."
)
def _check_product_unique(
db: Session,
user_id: int,
kind: ProductKind,
name: str,
brand: str | None,
exclude_id: int | None = None,
) -> None:
stmt = select(Product.id).where(
Product.user_id == user_id,
Product.kind == kind,
Product.name == name,
Product.brand.is_(None) if brand is None else Product.brand == brand,
)
if exclude_id is not None:
stmt = stmt.where(Product.id != exclude_id)
if db.scalar(stmt) is not None:
raise ConflictError("Un produit identique existe déjà dans le catalogue.")
def create_product(db: Session, user_id: int, payload: ProductCreate) -> ProductRead:
_check_product_consistency(payload.kind, payload.nicotine_mg_ml)
_check_product_unique(db, user_id, payload.kind, payload.name, payload.brand)
product = Product(user_id=user_id, **payload.model_dump())
db.add(product)
db.commit()
db.refresh(product)
return _product_read(product)
def update_product(
db: Session, user_id: int, product_id: int, payload: ProductUpdate
) -> ProductRead:
product = get_product(db, user_id, product_id)
changes = payload.model_dump(exclude_unset=True)
kind = changes.get("kind", product.kind)
nicotine = changes.get("nicotine_mg_ml", product.nicotine_mg_ml)
_check_product_consistency(kind, nicotine)
_check_product_unique(
db,
user_id,
kind,
changes.get("name", product.name),
changes.get("brand", product.brand),
exclude_id=product.id,
)
for field, value in changes.items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return _product_read(product)
def _product_references(db: Session, product_id: int) -> int:
counts = (
db.scalar(
select(func.count())
.select_from(MixComponent)
.where(MixComponent.product_id == product_id)
)
or 0
)
counts += (
db.scalar(
select(func.count())
.select_from(CoilChange)
.where(CoilChange.product_id == product_id)
)
or 0
)
counts += (
db.scalar(
select(func.count())
.select_from(Purchase)
.where(Purchase.product_id == product_id)
)
or 0
)
return counts
def delete_product(db: Session, user_id: int, product_id: int) -> None:
product = get_product(db, user_id, product_id)
if _product_references(db, product_id):
raise ConflictError(
"Ce produit est utilisé par une recette, un achat ou un changement "
"de résistance : archivez-le plutôt que de le supprimer.",
details={"product_id": product_id},
)
db.delete(product)
db.commit()
# ---------------------------------------------------------------------------
# Mixes (§6.3)
# ---------------------------------------------------------------------------
def _component_inputs(mix: Mix) -> list[calc.ComponentInput]:
return [
calc.ComponentInput(
quantity=component.quantity,
price_cents=component.product.price_cents,
size_value=component.product.size_value,
nicotine_mg_ml=component.product.nicotine_mg_ml,
vg_pct=component.product.vg_pct,
)
for component in mix.components
]
def _mix_read(mix: Mix) -> MixRead:
inputs = _component_inputs(mix)
cost_total = calc.mix_cost_total_cents(inputs)
cost_per_ml = calc.mix_cost_per_ml_cents(inputs, mix.total_ml)
check = calc.mix_nicotine_check_mg_ml(inputs, mix.total_ml)
vg = calc.mix_vg_pct(inputs, mix.total_ml)
return MixRead(
id=mix.id,
name=mix.name,
total_ml=mix.total_ml,
target_nicotine_mg_ml=mix.target_nicotine_mg_ml,
is_active=mix.is_active,
is_archived=mix.is_archived,
note=mix.note,
components=[
MixComponentRead(
id=component.id,
product_id=component.product_id,
product_name=component.product.name,
product_kind=component.product.kind,
quantity=component.quantity,
cost_cents=_round(
calc.component_cost_cents(
calc.ComponentInput(
quantity=component.quantity,
price_cents=component.product.price_cents,
size_value=component.product.size_value,
)
)
)
or 0.0,
)
for component in mix.components
],
cost_total_cents=_round(cost_total) or 0.0,
cost_per_ml_cents=_round(cost_per_ml, 4),
nicotine_check_mg_ml=_round(check, 3) or 0.0,
vg_pct_mix=_round(vg),
warning=calc.nicotine_mismatch_warning(mix.target_nicotine_mg_ml, check),
)
def get_mix(db: Session, user_id: int, mix_id: int) -> Mix:
mix = db.get(Mix, mix_id)
if mix is None or mix.user_id != user_id:
raise NotFoundError("Recette introuvable.")
return mix
def active_mix(db: Session, user_id: int) -> Mix | None:
return db.scalar(select(Mix).where(Mix.user_id == user_id, Mix.is_active.is_(True)))
def list_mixes(
db: Session,
user_id: int,
params: PageParams,
include_archived: bool = False,
sort: str | None = None,
) -> Page[MixRead]:
stmt = select(Mix).where(Mix.user_id == user_id)
if not include_archived:
stmt = stmt.where(Mix.is_archived.is_(False))
stmt = _apply_sort(
stmt,
sort,
{"name": Mix.name, "created_at": Mix.created_at},
"-created_at",
Mix.id.desc(),
)
rows, total = paginate(db, stmt, params)
return Page(
items=[_mix_read(row) for row in rows],
total=total,
page=params.page,
page_size=params.page_size,
)
def _load_components(
db: Session, user_id: int, lines: Sequence[Any]
) -> list[MixComponent]:
seen: set[int] = set()
components: list[MixComponent] = []
for line in lines:
if line.product_id in seen:
raise DomainValidationError(
"Un même produit ne peut apparaître qu'une fois dans la recette.",
details={"product_id": line.product_id},
)
seen.add(line.product_id)
product = get_product(db, user_id, line.product_id)
if product.kind not in LIQUID_KINDS:
raise DomainValidationError(
"Une recette n'accepte que des bases, boosters et arômes.",
details={"product_id": product.id, "kind": product.kind.value},
)
components.append(MixComponent(product_id=product.id, quantity=line.quantity))
return components
def create_mix(db: Session, user_id: int, payload: MixCreate) -> MixRead:
mix = Mix(
user_id=user_id,
name=payload.name,
total_ml=payload.total_ml,
target_nicotine_mg_ml=payload.target_nicotine_mg_ml,
note=payload.note,
)
mix.components = _load_components(db, user_id, payload.components)
db.add(mix)
db.commit()
db.refresh(mix)
return _mix_read(mix)
def update_mix(db: Session, user_id: int, mix_id: int, payload: MixUpdate) -> MixRead:
mix = get_mix(db, user_id, mix_id)
changes = payload.model_dump(exclude_unset=True)
components = changes.pop("components", None)
for field, value in changes.items():
setattr(mix, field, value)
if mix.is_archived and mix.is_active:
# An archived recipe is hidden from the lists: leaving it active would
# keep it driving cost_per_ml while the UI shows no active recipe.
mix.is_active = False
if components is not None:
# Full replacement of the recipe lines (§8.4). The old rows must be
# deleted before the new ones are inserted, otherwise re-using the same
# product violates uq_mix_components_mix_product during the flush.
replacement = _load_components(db, user_id, payload.components or [])
mix.components.clear()
db.flush()
mix.components = replacement
db.commit()
db.refresh(mix)
return _mix_read(mix)
def delete_mix(db: Session, user_id: int, mix_id: int) -> None:
mix = get_mix(db, user_id, mix_id)
db.delete(mix) # components cascade; liquid_entries.mix_id -> NULL
db.commit()
def activate_mix(db: Session, user_id: int, mix_id: int) -> MixRead:
"""Single active mix per user (partial unique index)."""
mix = get_mix(db, user_id, mix_id)
if mix.is_archived:
raise ConflictError("Une recette archivée ne peut pas être activée.")
for other in db.scalars(
select(Mix).where(
Mix.user_id == user_id, Mix.is_active.is_(True), Mix.id != mix.id
)
):
other.is_active = False
db.flush() # release the partial unique index before claiming it
mix.is_active = True
db.commit()
db.refresh(mix)
return _mix_read(mix)
def mix_calculator(
db: Session, user_id: int, payload: MixCalculatorRequest
) -> MixCalculatorResult:
"""Stateless recipe assistant (§6.3) — persists nothing."""
booster = get_product(db, user_id, payload.booster_product_id)
base = get_product(db, user_id, payload.base_product_id)
aroma = (
get_product(db, user_id, payload.aroma_product_id)
if payload.aroma_product_id is not None
else None
)
if booster.nicotine_mg_ml is None or booster.nicotine_mg_ml <= 0:
raise DomainValidationError(
"Le booster sélectionné doit avoir un taux de nicotine supérieur à 0."
)
try:
quantities = calc.recipe_quantities(
payload.total_ml,
payload.target_nicotine_mg_ml,
booster.nicotine_mg_ml,
payload.aroma_pct,
)
except ValueError as exc:
raise DomainValidationError(
"Recette impossible : le volume de base serait nul ou négatif, "
"réduisez le taux de nicotine ou le dosage d'arôme."
) from exc
components = [
calc.ComponentInput(
quantity=quantities.booster_ml,
price_cents=booster.price_cents,
size_value=booster.size_value,
nicotine_mg_ml=booster.nicotine_mg_ml,
),
calc.ComponentInput(
quantity=quantities.base_ml,
price_cents=base.price_cents,
size_value=base.size_value,
),
]
if aroma is not None and quantities.aroma_ml > 0:
components.append(
calc.ComponentInput(
quantity=quantities.aroma_ml,
price_cents=aroma.price_cents,
size_value=aroma.size_value,
)
)
cost_total = calc.mix_cost_total_cents(components)
cost_per_ml = calc.mix_cost_per_ml_cents(components, payload.total_ml)
check = calc.mix_nicotine_check_mg_ml(components, payload.total_ml)
return MixCalculatorResult(
total_ml=payload.total_ml,
target_nicotine_mg_ml=payload.target_nicotine_mg_ml,
booster_ml=_round(quantities.booster_ml) or 0.0,
aroma_ml=_round(quantities.aroma_ml) or 0.0,
base_ml=_round(quantities.base_ml) or 0.0,
nicotine_check_mg_ml=_round(check, 3) or 0.0,
cost_total_cents=_round(cost_total) or 0.0,
cost_per_ml_cents=_round(cost_per_ml, 4) or 0.0,
)
# ---------------------------------------------------------------------------
# Liquid entries (§6.4)
# ---------------------------------------------------------------------------
def _entries_between(
db: Session, user_id: int, start: date, end: date
) -> list[LiquidEntry]:
return list(
db.scalars(
select(LiquidEntry)
.where(
LiquidEntry.user_id == user_id,
LiquidEntry.entry_date >= start,
LiquidEntry.entry_date <= end,
)
.order_by(LiquidEntry.entry_date, LiquidEntry.id)
)
)
def _group_by_day(entries: Iterable[LiquidEntry]) -> dict[date, list[LiquidEntry]]:
grouped: dict[date, list[LiquidEntry]] = {}
for entry in entries:
grouped.setdefault(entry.entry_date, []).append(entry)
return grouped
def _counting_entries(entries: Sequence[LiquidEntry]) -> list[LiquidEntry]:
"""The entries that actually count for a day: daily_total wins over refills."""
totals = [e for e in entries if e.kind is LiquidEntryKind.DAILY_TOTAL]
if totals:
return totals[:1]
return [e for e in entries if e.kind is LiquidEntryKind.REFILL]
def _daily_ml_map(
db: Session, user_id: int, start: date, end: date
) -> dict[date, Decimal]:
"""Tracked days only: {day: effective ml} (§6.4 daily_total override)."""
grouped = _group_by_day(_entries_between(db, user_id, start, end))
out: dict[date, Decimal] = {}
for day, entries in grouped.items():
value = calc.effective_daily_ml((e.kind.value, e.ml) for e in entries)
if value is not None:
out[day] = value
return out
def _effective_nicotine(
entry: LiquidEntry,
mix_targets: dict[int, Decimal],
settings: VapeSettings | None,
) -> Decimal | None:
"""entry override -> mix target -> settings default (§6.4)."""
if entry.nicotine_mg_ml is not None:
return entry.nicotine_mg_ml
if entry.mix_id is not None and entry.mix_id in mix_targets:
return mix_targets[entry.mix_id]
if settings is not None:
return settings.default_nicotine_mg_ml
return None
def _mix_targets(db: Session, user_id: int) -> dict[int, Decimal]:
rows = db.execute(
select(Mix.id, Mix.target_nicotine_mg_ml).where(Mix.user_id == user_id)
).all()
return {row[0]: row[1] for row in rows}
def _daily_nicotine_map(
db: Session,
user_id: int,
start: date,
end: date,
settings: VapeSettings | None,
) -> dict[date, float]:
grouped = _group_by_day(_entries_between(db, user_id, start, end))
targets = _mix_targets(db, user_id)
out: dict[date, float] = {}
for day, entries in grouped.items():
counting = _counting_entries(entries)
value = calc.nicotine_mg_for_day(
(e.ml, _effective_nicotine(e, targets, settings)) for e in counting
)
if value is not None:
out[day] = value
return out
def _liquid_read(
entry: LiquidEntry,
mix_targets: dict[int, Decimal],
settings: VapeSettings | None,
) -> LiquidEntryRead:
effective = _effective_nicotine(entry, mix_targets, settings)
return LiquidEntryRead(
id=entry.id,
entry_date=entry.entry_date,
kind=entry.kind,
ml=entry.ml,
nicotine_mg_ml=entry.nicotine_mg_ml,
nicotine_effective_mg_ml=_round(effective, 1),
nicotine_mg=_round(entry.ml * effective) if effective is not None else None,
mix_id=entry.mix_id,
source=entry.source,
note=entry.note,
)
def get_liquid(db: Session, user_id: int, entry_id: int) -> LiquidEntry:
entry = db.get(LiquidEntry, entry_id)
if entry is None or entry.user_id != user_id:
raise NotFoundError("Saisie de consommation introuvable.")
return entry
def list_liquids(
db: Session,
user_id: int,
params: PageParams,
from_: date | None = None,
to: date | None = None,
kind: LiquidEntryKind | None = None,
sort: str | None = None,
) -> Page[LiquidEntryRead]:
stmt = select(LiquidEntry).where(LiquidEntry.user_id == user_id)
if from_ is not None:
stmt = stmt.where(LiquidEntry.entry_date >= from_)
if to is not None:
stmt = stmt.where(LiquidEntry.entry_date <= to)
if kind is not None:
stmt = stmt.where(LiquidEntry.kind == kind)
stmt = _apply_sort(
stmt,
sort,
{"entry_date": LiquidEntry.entry_date, "ml": LiquidEntry.ml},
"-entry_date",
LiquidEntry.id.desc(),
)
rows, total = paginate(db, stmt, params)
settings = find_settings(db, user_id)
targets = _mix_targets(db, user_id)
return Page(
items=[_liquid_read(row, targets, settings) for row in rows],
total=total,
page=params.page,
page_size=params.page_size,
)
def _assert_single_daily_total(
db: Session, user_id: int, day: date, exclude_id: int | None = None
) -> None:
stmt = select(LiquidEntry.id).where(
LiquidEntry.user_id == user_id,
LiquidEntry.entry_date == day,
LiquidEntry.kind == LiquidEntryKind.DAILY_TOTAL,
)
if exclude_id is not None:
stmt = stmt.where(LiquidEntry.id != exclude_id)
if db.scalar(stmt) is not None:
raise ConflictError(
"Un total quotidien existe déjà pour ce jour : modifiez-le.",
details={"entry_date": _iso(day)},
)
def create_liquid(
db: Session, user_id: int, payload: LiquidEntryCreate
) -> LiquidEntryRead:
if payload.kind is LiquidEntryKind.DAILY_TOTAL:
_assert_single_daily_total(db, user_id, payload.entry_date)
mix_id = payload.mix_id
if mix_id is not None:
get_mix(db, user_id, mix_id)
else:
current = active_mix(db, user_id)
mix_id = current.id if current is not None else None
entry = LiquidEntry(
user_id=user_id,
entry_date=payload.entry_date,
kind=payload.kind,
ml=payload.ml,
nicotine_mg_ml=payload.nicotine_mg_ml,
mix_id=mix_id,
note=payload.note,
source="manual",
)
db.add(entry)
db.commit()
db.refresh(entry)
return _liquid_read(entry, _mix_targets(db, user_id), find_settings(db, user_id))
def update_liquid(
db: Session, user_id: int, entry_id: int, payload: LiquidEntryUpdate
) -> LiquidEntryRead:
entry = get_liquid(db, user_id, entry_id)
changes = payload.model_dump(exclude_unset=True)
kind = changes.get("kind", entry.kind)
day = changes.get("entry_date", entry.entry_date)
if kind is LiquidEntryKind.DAILY_TOTAL:
_assert_single_daily_total(db, user_id, day, exclude_id=entry.id)
if changes.get("mix_id") is not None:
get_mix(db, user_id, changes["mix_id"])
for field, value in changes.items():
setattr(entry, field, value)
db.commit()
db.refresh(entry)
return _liquid_read(entry, _mix_targets(db, user_id), find_settings(db, user_id))
def delete_liquid(db: Session, user_id: int, entry_id: int) -> None:
entry = get_liquid(db, user_id, entry_id)
db.delete(entry)
db.commit()
# ---------------------------------------------------------------------------
# Cost model (§7.1) and coils (§7.3)
# ---------------------------------------------------------------------------
def _liquid_purchase_pairs(
db: Session, user_id: int, until: date, window_days: int
) -> list[tuple[Decimal, Decimal]]:
"""(total_cents, ml) of the liquid purchases of the fallback window."""
since = until - timedelta(days=window_days)
rows = db.scalars(
select(Purchase)
.join(Product, Purchase.product_id == Product.id)
.where(
Purchase.user_id == user_id,
Purchase.purchased_on >= since,
Purchase.purchased_on <= until,
Product.kind.in_(LIQUID_KINDS),
Product.size_unit == SizeUnit.ML,
)
)
return [(row.total_cents, row.qty * row.product.size_value) for row in rows]
def cost_per_ml_cents(db: Session, user_id: int, today: date) -> Decimal | None:
"""Active mix cost/ml, else weighted purchases fallback, else None (§7.1)."""
mix = active_mix(db, user_id)
if mix is not None and mix.components:
value = calc.mix_cost_per_ml_cents(_component_inputs(mix), mix.total_ml)
if value is not None:
return value
return calc.fallback_cost_per_ml_cents(
_liquid_purchase_pairs(db, user_id, today, calc.PURCHASE_FALLBACK_WINDOW_DAYS)
)
@dataclass
class CoilContext:
changes: list[CoilChange]
intervals_days: list[float]
avg_lifespan_days: float
is_default: bool
unit_price_cents: float | None
cost_per_day_cents: float
current_age_days: float | None
current_product_id: int | None
def _coil_changes(db: Session, user_id: int) -> list[CoilChange]:
return list(
db.scalars(
select(CoilChange)
.where(CoilChange.user_id == user_id)
.order_by(CoilChange.changed_at, CoilChange.id)
)
)
def coil_context(db: Session, user_id: int, now_utc: datetime) -> CoilContext:
changes = _coil_changes(db, user_id)
intervals = calc.coil_intervals_days([_as_utc(c.changed_at) for c in changes])
avg_days, is_default = calc.avg_coil_lifespan_days(intervals)
unit_price: float | None = None
for change in reversed(changes):
if change.product is not None:
unit_price = float(change.product.unit_price_cents)
break
current = changes[-1] if changes else None
return CoilContext(
changes=changes,
intervals_days=intervals,
avg_lifespan_days=avg_days,
is_default=is_default,
unit_price_cents=unit_price,
cost_per_day_cents=calc.coil_cost_per_day_cents(unit_price, avg_days),
current_age_days=(
calc.coil_age_days(_as_utc(current.changed_at), now_utc)
if current is not None
else None
),
current_product_id=current.product_id if current is not None else None,
)
def _coil_derived(
changes: Sequence[CoilChange],
daily_ml: dict[date, Decimal],
tz: ZoneInfo,
now_utc: datetime,
) -> dict[int, tuple[float, float | None, bool]]:
"""{coil_change_id: (lifespan_days, ml_through, is_current)}."""
out: dict[int, tuple[float, float | None, bool]] = {}
for index, change in enumerate(changes):
is_current = index == len(changes) - 1
started_at = _as_utc(change.changed_at)
end_dt = now_utc if is_current else _as_utc(changes[index + 1].changed_at)
lifespan = (end_dt - started_at).total_seconds() / calc.SECONDS_PER_DAY
start_day = local_day(started_at, tz)
end_day = local_day(end_dt, tz)
# [start_day, end_day) so two consecutive coils never share a day;
# the coil in use also counts the current (unfinished) day.
last_day = end_day + timedelta(days=1) if is_current else end_day
ml = calc.sum_ml_between(daily_ml, start_day, last_day)
out[change.id] = (lifespan, float(ml) if ml is not None else None, is_current)
return out
def _coil_read(
change: CoilChange, derived: dict[int, tuple[float, float | None, bool]]
) -> CoilChangeRead:
lifespan, ml_through, is_current = derived.get(change.id, (0.0, None, False))
return CoilChangeRead(
id=change.id,
changed_at=_as_utc(change.changed_at),
product_id=change.product_id,
product_name=change.product.name if change.product is not None else None,
reason=change.reason,
note=change.note,
is_current=is_current,
lifespan_days=_round(lifespan),
ml_through=_round(ml_through),
)
def get_coil(db: Session, user_id: int, coil_id: int) -> CoilChange:
change = db.get(CoilChange, coil_id)
if change is None or change.user_id != user_id:
raise NotFoundError("Changement de résistance introuvable.")
return change
def _coil_derived_all(
db: Session, user_id: int, tz_name: str | None
) -> tuple[dict[int, tuple[float, float | None, bool]], list[CoilChange]]:
tz = resolve_tz(tz_name)
now = utcnow()
changes = _coil_changes(db, user_id)
if not changes:
return {}, changes
start_day = local_day(_as_utc(changes[0].changed_at), tz)
daily_ml = _daily_ml_map(db, user_id, start_day, local_day(now, tz))
return _coil_derived(changes, daily_ml, tz, now), changes
def list_coils(
db: Session,
user_id: int,
params: PageParams,
from_: date | None = None,
to: date | None = None,
sort: str | None = None,
tz_name: str | None = None,
) -> Page[CoilChangeRead]:
derived, _ = _coil_derived_all(db, user_id, tz_name)
stmt = select(CoilChange).where(CoilChange.user_id == user_id)
if from_ is not None:
stmt = stmt.where(CoilChange.changed_at >= _start_of_day(from_, tz_name))
if to is not None:
stmt = stmt.where(CoilChange.changed_at < _start_of_day(to, tz_name, offset=1))
stmt = _apply_sort(
stmt,
sort,
{"changed_at": CoilChange.changed_at},
"-changed_at",
CoilChange.id.desc(),
)
rows, total = paginate(db, stmt, params)
return Page(
items=[_coil_read(row, derived) for row in rows],
total=total,
page=params.page,
page_size=params.page_size,
)
def _start_of_day(day: date, tz_name: str | None, offset: int = 0) -> datetime:
"""Local midnight of `day` (+offset days) expressed in UTC, for filters."""
tz = resolve_tz(tz_name)
return datetime.combine(
day + timedelta(days=offset), datetime.min.time(), tzinfo=tz
).astimezone(UTC)
def _created_coil_read(
db: Session, user_id: int, change: CoilChange, tz_name: str | None
) -> CoilChangeCreated:
derived, changes = _coil_derived_all(db, user_id, tz_name)
previous: float | None = None
for index, item in enumerate(changes):
if item.id == change.id and index > 0:
previous_change = changes[index - 1]
previous = _round(
(
_as_utc(item.changed_at) - _as_utc(previous_change.changed_at)
).total_seconds()
/ calc.SECONDS_PER_DAY
)
base = _coil_read(change, derived)
return CoilChangeCreated(**base.model_dump(), previous_lifespan_days=previous)
def create_coil(
db: Session,
user_id: int,
payload: CoilChangeCreate,
tz_name: str | None = None,
) -> CoilChangeCreated:
"""CRUD create and the one-click « Résistance changée » action (§12.6)."""
if payload.product_id is not None:
product = get_product(db, user_id, payload.product_id)
if product.kind not in (ProductKind.COIL, ProductKind.POD):
raise DomainValidationError(
"Le produit posé doit être une résistance ou un pod."
)
change = CoilChange(
user_id=user_id,
# always persisted in UTC, whatever offset the client sent
changed_at=_as_utc(payload.changed_at).astimezone(UTC)
if payload.changed_at is not None
else utcnow(),
product_id=payload.product_id,
reason=payload.reason,
note=payload.note,
)
db.add(change)
db.commit()
db.refresh(change)
return _created_coil_read(db, user_id, change, tz_name)
def update_coil(
db: Session,
user_id: int,
coil_id: int,
payload: CoilChangeUpdate,
tz_name: str | None = None,
) -> CoilChangeRead:
change = get_coil(db, user_id, coil_id)
changes = payload.model_dump(exclude_unset=True)
if changes.get("product_id") is not None:
get_product(db, user_id, changes["product_id"])
if changes.get("changed_at") is not None:
changes["changed_at"] = _as_utc(changes["changed_at"]).astimezone(UTC)
for field, value in changes.items():
setattr(change, field, value)
db.commit()
db.refresh(change)
derived, _ = _coil_derived_all(db, user_id, tz_name)
return _coil_read(change, derived)
def delete_coil(db: Session, user_id: int, coil_id: int) -> None:
change = get_coil(db, user_id, coil_id)
db.delete(change)
db.commit()
def coil_stats(db: Session, user_id: int, tz_name: str | None = None) -> StatsResponse:
"""Lifespan history + amortization metrics (§7.3)."""
tz = resolve_tz(tz_name)
now = utcnow()
today = local_day(now, tz)
ctx = coil_context(db, user_id, now)
derived, changes = _coil_derived_all(db, user_id, tz_name)
points: list[tuple[str, float | None]] = []
ml_points: list[tuple[str, float | None]] = []
start = local_day(_as_utc(changes[0].changed_at), tz) if changes else today
for change in changes:
lifespan, ml_through, _is_current = derived[change.id]
day = local_day(_as_utc(change.changed_at), tz)
points.append((_iso(day), _round(lifespan)))
ml_points.append((_iso(day), _round(ml_through)))
# §7.3: the average volume is computed on *completed* cycles only (the coil
# in use has been vaped for a few days at most and would drag the mean down),
# and like avg_lifespan_days it looks at the last COIL_AVG_LAST_N of them.
completed_ml: list[float] = []
for change in changes:
_lifespan, ml_through, is_current = derived[change.id]
if not is_current and ml_through is not None:
completed_ml.append(ml_through)
ml_values = completed_ml[-calc.COIL_AVG_LAST_N :]
return StatsResponse(
from_=start,
to=today,
unit="days",
series=[
SeriesModel(name="lifespan_days", type="bar", points=points),
SeriesModel(name="ml_through", type="line", points=ml_points),
],
meta={
"avg_lifespan_days": _round(ctx.avg_lifespan_days),
"avg_lifespan_is_default": ctx.is_default,
"avg_ml_through_coil": (
_round(sum(ml_values) / len(ml_values)) if ml_values else None
),
"current_coil_age_days": _round(ctx.current_age_days),
"current_coil_product_id": ctx.current_product_id,
"coil_unit_price_cents": _round(ctx.unit_price_cents),
"coil_cost_per_day_cents": _round(ctx.cost_per_day_cents),
"changes_count": len(changes),
},
)
# ---------------------------------------------------------------------------
# Purchases (§6.6)
# ---------------------------------------------------------------------------
def _purchase_read(purchase: Purchase) -> PurchaseRead:
return PurchaseRead(
id=purchase.id,
purchased_on=purchase.purchased_on,
product_id=purchase.product_id,
product_name=purchase.product.name,
qty=purchase.qty,
unit_price_cents=purchase.unit_price_cents,
total_cents=_round(purchase.total_cents) or 0.0,
note=purchase.note,
)
def get_purchase(db: Session, user_id: int, purchase_id: int) -> Purchase:
purchase = db.get(Purchase, purchase_id)
if purchase is None or purchase.user_id != user_id:
raise NotFoundError("Achat introuvable.")
return purchase
def list_purchases(
db: Session,
user_id: int,
params: PageParams,
from_: date | None = None,
to: date | None = None,
product_id: int | None = None,
sort: str | None = None,
) -> Page[PurchaseRead]:
stmt = select(Purchase).where(Purchase.user_id == user_id)
if from_ is not None:
stmt = stmt.where(Purchase.purchased_on >= from_)
if to is not None:
stmt = stmt.where(Purchase.purchased_on <= to)
if product_id is not None:
stmt = stmt.where(Purchase.product_id == product_id)
stmt = _apply_sort(
stmt,
sort,
{"purchased_on": Purchase.purchased_on, "qty": Purchase.qty},
"-purchased_on",
Purchase.id.desc(),
)
rows, total = paginate(db, stmt, params)
return Page(
items=[_purchase_read(row) for row in rows],
total=total,
page=params.page,
page_size=params.page_size,
)
def create_purchase(db: Session, user_id: int, payload: PurchaseCreate) -> PurchaseRead:
get_product(db, user_id, payload.product_id)
purchase = Purchase(user_id=user_id, **payload.model_dump())
db.add(purchase)
db.commit()
db.refresh(purchase)
return _purchase_read(purchase)
def update_purchase(
db: Session, user_id: int, purchase_id: int, payload: PurchaseUpdate
) -> PurchaseRead:
purchase = get_purchase(db, user_id, purchase_id)
changes = payload.model_dump(exclude_unset=True)
if "product_id" in changes and changes["product_id"] is not None:
get_product(db, user_id, changes["product_id"])
for field, value in changes.items():
setattr(purchase, field, value)
db.commit()
db.refresh(purchase)
return _purchase_read(purchase)
def delete_purchase(db: Session, user_id: int, purchase_id: int) -> None:
purchase = get_purchase(db, user_id, purchase_id)
db.delete(purchase)
db.commit()
def _spend_by_date(
db: Session, user_id: int, start: date, end: date
) -> dict[date, float]:
out: dict[date, float] = {}
rows = db.scalars(
select(Purchase).where(
Purchase.user_id == user_id,
Purchase.purchased_on >= start,
Purchase.purchased_on <= end,
)
)
for row in rows:
out[row.purchased_on] = out.get(row.purchased_on, 0.0) + float(row.total_cents)
return out
def _has_purchases(db: Session, user_id: int) -> bool:
return (
db.scalar(
select(func.count())
.select_from(Purchase)
.where(Purchase.user_id == user_id)
)
or 0
) > 0
# ---------------------------------------------------------------------------
# Stats (§8.1 chart-ready payloads)
# ---------------------------------------------------------------------------
def consumption_stats(
db: Session,
user_id: int,
from_: date | None = None,
to: date | None = None,
tz_name: str | None = None,
) -> StatsResponse:
"""ml/day + 7-day moving average (§7.2)."""
tz, start, end = _resolve_period(tz_name, from_, to, DEFAULT_WINDOW_DAYS)
today = local_day(utcnow(), tz)
daily = _daily_ml_map(db, user_id, start, end)
days = list(calc.date_range(start, end))
values: list[float | None] = [
float(daily[day]) if day in daily else None for day in days
]
ma7 = calc.moving_average(values, SHORT_WINDOW_DAYS)
window7 = _daily_ml_map(
db, user_id, today - timedelta(days=SHORT_WINDOW_DAYS - 1), today
)
window30 = _daily_ml_map(
db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today
)
tracked = [daily.get(day) for day in days]
return StatsResponse(
from_=start,
to=end,
unit="ml",
series=[
SeriesModel(
name="ml",
type="line",
points=[
(_iso(d), _round(v)) for d, v in zip(days, values, strict=True)
],
),
SeriesModel(
name="ml_ma7",
type="line",
points=[(_iso(d), _round(v)) for d, v in zip(days, ma7, strict=True)],
),
],
meta={
"ml_per_day_7": _round(calc.mean_ml_per_day(window7.values())),
"ml_per_day_30": _round(calc.mean_ml_per_day(window30.values())),
"ml_per_day_period": _round(calc.mean_ml_per_day(tracked)),
"tracked_days_ratio": _round(calc.tracked_days_ratio(tracked), 3),
"tracked_days": sum(1 for v in tracked if v is not None),
"total_ml": _round(sum(float(v) for v in daily.values())),
},
)
def nicotine_stats(
db: Session,
user_id: int,
from_: date | None = None,
to: date | None = None,
tz_name: str | None = None,
) -> StatsResponse:
"""mg/day + moving average, 30-day trend and cigarette equivalent (§7.6)."""
_tz, start, end = _resolve_period(tz_name, from_, to, DEFAULT_WINDOW_DAYS)
settings = find_settings(db, user_id)
daily = _daily_nicotine_map(db, user_id, start, end, settings)
days = list(calc.date_range(start, end))
values: list[float | None] = [daily.get(day) for day in days]
ma7 = calc.moving_average(values, SHORT_WINDOW_DAYS)
trend_start = max(start, end - timedelta(days=NICOTINE_TREND_WINDOW_DAYS - 1))
trend_points = [
(day, daily[day]) for day in calc.date_range(trend_start, end) if day in daily
]
slope = calc.regression_slope(trend_points)
tracked_values = [v for v in values if v is not None]
mean_mg = sum(tracked_values) / len(tracked_values) if tracked_values else None
return StatsResponse(
from_=start,
to=end,
unit="mg",
series=[
SeriesModel(
name="nicotine_mg",
type="bar",
points=[
(_iso(d), _round(v)) for d, v in zip(days, values, strict=True)
],
),
SeriesModel(
name="nicotine_mg_ma7",
type="line",
points=[(_iso(d), _round(v)) for d, v in zip(days, ma7, strict=True)],
),
],
meta={
"nicotine_mg_per_day": _round(mean_mg),
"slope_30d_mg_per_day": _round(slope, 4),
"trend_status": calc.trend_status(slope),
"cig_equivalent_per_day": (
_round(calc.cig_equivalent(mean_mg)) if mean_mg is not None else None
),
"nicotine_mg_per_cig": calc.NICOTINE_MG_PER_CIG,
},
)
def _is_vaping_day(day: date, today: date, settings: VapeSettings | None) -> bool:
"""Whether an *untracked* day may be mean-imputed (§7.5).
Only days the user could actually have vaped count: from the quit date (when
it is known) up to today — never the future.
"""
if day > today:
return False
return settings is None or day >= settings.quit_date
def _month_start(day: date) -> date:
return day.replace(day=1)
def _add_month(day: date) -> date:
return (day.replace(day=28) + timedelta(days=4)).replace(day=1)
def cost_stats(
db: Session,
user_id: int,
from_: date | None = None,
to: date | None = None,
tz_name: str | None = None,
) -> StatsResponse:
"""Monthly theoretical cost vs real spending (§7.4)."""
tz = resolve_tz(tz_name)
today = local_day(utcnow(), tz)
end = to or today
if from_ is None:
start = _month_start(end)
for _ in range(DEFAULT_COST_MONTHS - 1):
start = _month_start(start - timedelta(days=1))
else:
start = from_
if start > end:
raise DomainValidationError("La date de début doit précéder la date de fin.")
daily = _daily_ml_map(db, user_id, start, end)
cpm = cost_per_ml_cents(db, user_id, today)
ctx = coil_context(db, user_id, utcnow())
window30 = _daily_ml_map(
db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today
)
imputed = calc.mean_ml_per_day(window30.values())
spend = _spend_by_date(db, user_id, start, end)
settings = find_settings(db, user_id)
theoretical: dict[date, float] = {}
for day in calc.date_range(start, end):
if daily.get(day) is None and not _is_vaping_day(day, today, settings):
# Mean-imputation fills tracking *gaps* (§7.5); it must not invent a
# vape cost before the quit date or in the future.
continue
month = _month_start(day)
theoretical[month] = theoretical.get(
month, 0.0
) + calc.theoretical_vape_cost_cents(
daily.get(day), imputed, cpm, ctx.cost_per_day_cents
)
real: dict[date, float] = {}
for day, amount in spend.items():
month = _month_start(day)
real[month] = real.get(month, 0.0) + amount
months: list[date] = []
cursor = _month_start(start)
while cursor <= end:
months.append(cursor)
cursor = _add_month(cursor)
window_days = (end - start).days + 1
ml_per_day = calc.mean_ml_per_day(
[daily.get(day) for day in calc.date_range(start, end)]
)
vape_cpd = calc.vape_cost_per_day_cents(ml_per_day, cpm, ctx.cost_per_day_cents)
return StatsResponse(
from_=start,
to=end,
unit="cents",
series=[
SeriesModel(
name="theoretical_cost",
type="bar",
points=[(_iso(m), _round(theoretical.get(m, 0.0))) for m in months],
),
SeriesModel(
name="real_spend",
type="bar",
points=[(_iso(m), _round(real.get(m, 0.0))) for m in months],
),
],
meta={
"cost_per_ml_cents": _round(cpm, 4),
"cost_per_ml_source": (
"active_mix" if active_mix(db, user_id) is not None else "purchases"
),
"vape_cost_per_day_cents": _round(vape_cpd),
"coil_cost_per_day_cents": _round(ctx.cost_per_day_cents),
"coil_avg_lifespan_days": _round(ctx.avg_lifespan_days),
"real_cost_per_day_cents": _round(
calc.real_cost_per_day_cents(sum(spend.values()), window_days)
),
"total_real_spend_cents": _round(sum(spend.values())),
},
)
def savings_stats(
db: Session,
user_id: int,
from_: date | None = None,
to: date | None = None,
tz_name: str | None = None,
) -> StatsResponse:
"""Cumulative savings since quit_date: theoretical vs real (§7.5)."""
settings = get_settings(db, user_id)
tz = resolve_tz(tz_name)
today = local_day(utcnow(), tz)
# Cumulative savings stop today: a `to` in the future would project imputed
# days and inflate the headline « économies » (§7.5).
end = min(to, today) if to is not None else today
quit_date = settings.quit_date
start = from_ or quit_date
daily = _daily_ml_map(db, user_id, quit_date, end)
window30 = _daily_ml_map(
db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today
)
imputed = calc.mean_ml_per_day(window30.values())
cpm = cost_per_ml_cents(db, user_id, today)
ctx = coil_context(db, user_id, utcnow())
cig_cpd = calc.cig_cost_per_day_cents(
settings.cigs_per_day_before,
settings.cigs_per_pack,
settings.cig_pack_price_cents,
)
theoretical = calc.cumulative_savings_theoretical(
quit_date, end, daily, imputed, cpm, ctx.cost_per_day_cents, cig_cpd
)
real = calc.cumulative_savings_real(
quit_date, end, _spend_by_date(db, user_id, quit_date, end), cig_cpd
)
elapsed = calc.days_since_quit(quit_date, today)
avoided = calc.cigarettes_avoided(elapsed, settings.cigs_per_day_before)
ml_per_day = calc.mean_ml_per_day(window30.values())
vape_cpd = calc.vape_cost_per_day_cents(ml_per_day, cpm, ctx.cost_per_day_cents)
return StatsResponse(
from_=max(start, quit_date),
to=end,
unit="cents",
series=[
SeriesModel(
name="savings_theoretical",
type="line",
points=[(_iso(d), _round(v)) for d, v in theoretical if d >= start],
),
SeriesModel(
name="savings_real",
type="line",
points=[(_iso(d), _round(v)) for d, v in real if d >= start],
),
],
meta={
"quit_date": _iso(quit_date),
"days_since_quit": elapsed,
"cig_cost_per_day_cents": _round(cig_cpd),
"vape_cost_per_day_cents": _round(vape_cpd),
"savings_per_day_cents": _round(
calc.savings_per_day_cents(cig_cpd, vape_cpd)
),
"savings_theoretical_cents": _round(
theoretical[-1][1] if theoretical else 0.0
),
"savings_real_cents": _round(real[-1][1] if real else 0.0),
"has_purchases": _has_purchases(db, user_id),
"cigarettes_avoided": avoided,
"packs_avoided": _round(
calc.packs_avoided(avoided, settings.cigs_per_pack)
),
"time_regained_minutes": calc.time_regained_minutes(avoided),
},
)
# ---------------------------------------------------------------------------
# Milestones (§7.8) and dashboard
# ---------------------------------------------------------------------------
def milestones(
db: Session, user_id: int, tz_name: str | None = None
) -> list[MilestoneRead]:
settings = get_settings(db, user_id)
tz = resolve_tz(tz_name)
statuses = calc.milestone_statuses(settings.quit_date, tz, utcnow())
return [
MilestoneRead(
code=status.code,
label_fr=status.label_fr,
reached_at=status.reached_at,
achieved=status.achieved,
progress_pct=_round(status.progress_pct) or 0.0,
)
for status in statuses
]
def dashboard(db: Session, user_id: int, tz_name: str | None = None) -> VapeDashboard:
"""One call for the home screen KPIs (§8.4 GET /vape/dashboard)."""
settings = get_settings(db, user_id)
tz = resolve_tz(tz_name)
now = utcnow()
today = local_day(now, tz)
quit_date = settings.quit_date
daily = _daily_ml_map(db, user_id, quit_date, today)
window7 = {
day: value
for day, value in daily.items()
if day >= today - timedelta(days=SHORT_WINDOW_DAYS - 1)
}
window30 = {
day: value
for day, value in daily.items()
if day >= today - timedelta(days=DEFAULT_WINDOW_DAYS - 1)
}
imputed = calc.mean_ml_per_day(window30.values())
cpm = cost_per_ml_cents(db, user_id, today)
ctx = coil_context(db, user_id, now)
cig_cpd = calc.cig_cost_per_day_cents(
settings.cigs_per_day_before,
settings.cigs_per_pack,
settings.cig_pack_price_cents,
)
theoretical = calc.cumulative_savings_theoretical(
quit_date, today, daily, imputed, cpm, ctx.cost_per_day_cents, cig_cpd
)
real = calc.cumulative_savings_real(
quit_date, today, _spend_by_date(db, user_id, quit_date, today), cig_cpd
)
savings_theoretical = theoretical[-1][1] if theoretical else 0.0
savings_real = real[-1][1] if real else 0.0
has_purchases = _has_purchases(db, user_id)
nicotine_today = _daily_nicotine_map(db, user_id, today, today, settings).get(today)
elapsed = calc.days_since_quit(quit_date, today)
avoided = calc.cigarettes_avoided(elapsed, settings.cigs_per_day_before)
upcoming = calc.next_milestone(calc.milestone_statuses(quit_date, tz, now))
vape_cpd = calc.vape_cost_per_day_cents(imputed, cpm, ctx.cost_per_day_cents)
return VapeDashboard(
quit_date=quit_date,
days_since_quit=elapsed,
ml_today=_round(daily.get(today)),
ml_per_day_7=_round(calc.mean_ml_per_day(window7.values())),
ml_per_day_30=_round(imputed),
nicotine_today_mg=_round(nicotine_today),
cost_per_ml_cents=_round(cpm, 4),
coil_cost_per_day_cents=_round(ctx.cost_per_day_cents) or 0.0,
vape_cost_per_day_cents=_round(vape_cpd),
cig_cost_per_day_cents=_round(cig_cpd) or 0.0,
savings_theoretical_cents=_round(savings_theoretical) or 0.0,
savings_real_cents=_round(savings_real) or 0.0,
savings_display_cents=_round(
savings_real if has_purchases else savings_theoretical
)
or 0.0,
has_purchases=has_purchases,
cigarettes_avoided=avoided,
packs_avoided=_round(calc.packs_avoided(avoided, settings.cigs_per_pack))
or 0.0,
current_coil_age_days=_round(ctx.current_age_days),
coil_avg_lifespan_days=_round(ctx.avg_lifespan_days) or 0.0,
coil_lifespan_is_default=ctx.is_default,
next_milestone=(
MilestoneRead(
code=upcoming.code,
label_fr=upcoming.label_fr,
reached_at=upcoming.reached_at,
achieved=upcoming.achieved,
progress_pct=_round(upcoming.progress_pct) or 0.0,
)
if upcoming is not None
else None
),
)