Vital / db /queries.py
eddyejembi's picture
Upload folder using huggingface_hub
4e88df3 verified
Raw
History Blame Contribute Delete
53.1 kB
"""All database read/write operations for Vitál."""
import json
from datetime import date, datetime, timedelta, timezone
from typing import Any, cast
from db.database import _write_lock, get_connection
from vital_types.db import (
DailyLogEntry,
DailyLogSchemaField,
ExerciseLogEntry,
FoodLogEntry,
MealPlanEntry,
MedicationLogEntry,
MedicationRecord,
NotificationLogEntry,
PresenceConfig,
PresenceLogEntry,
ProfileInput,
ProfileRecord,
ScheduledJob,
ScheduledJobType,
WeeklyReport,
)
from vital_types.daily_plan import DailyPlan, DailyScheduleJob
from vital_types.onboarding import OnboardingCommitData
from vital_types.settings_prefs import TtsPreferences
SETTING_ONBOARDING_COMPLETE = "onboarding_complete"
SETTING_HYDRATION_GOAL_LITERS = "hydration_goal_liters"
SETTING_TTS_PREFERENCES = "tts_preferences"
SETTING_PRESENCE_CONFIG = "presence_config"
SETTING_SYSTEM_PROMPT_ADDITIONS = "system_prompt_additions"
SETTING_MEAL_PLAN_FRAMEWORK = "meal_plan_framework"
SETTING_EXERCISE_PLAN = "exercise_plan"
SETTING_WEEKLY_CHECK_STRUCTURE = "weekly_check_structure"
SETTING_LAST_GREETED_DATE = "last_greeted_date"
SETTING_COACH_QUICK_QUESTIONS = "coach_quick_questions"
SETTING_MORNING_BRIEFING = "morning_briefing_cache"
def _utc_now() -> datetime:
"""Return the current UTC timestamp."""
return datetime.now(timezone.utc)
def _format_datetime(value: datetime) -> str:
"""Serialize a datetime for SQLite storage."""
return value.isoformat()
def _parse_datetime(value: str) -> datetime:
"""Parse a datetime string from SQLite."""
return datetime.fromisoformat(value)
def _format_date(value: date) -> str:
"""Serialize a date for SQLite storage."""
return value.isoformat()
def _parse_date(value: str) -> date:
"""Parse a date string from SQLite."""
return date.fromisoformat(value)
def _bool_to_int(value: bool) -> int:
"""Convert a boolean to a SQLite integer."""
return 1 if value else 0
def _int_to_bool(value: int | None) -> bool:
"""Convert a SQLite integer to a boolean."""
return bool(value)
def _medications_to_json(medications: list[MedicationRecord]) -> str:
"""Serialize medication records for profile storage."""
payload = [
{"name": item.name, "dose": item.dose, "time": item.time}
for item in medications
]
return json.dumps(payload)
def _medications_from_json(raw_value: str | None) -> list[MedicationRecord]:
"""Deserialize medication records from profile storage."""
if not raw_value:
return []
payload = json.loads(raw_value)
return [
MedicationRecord(
name=item["name"],
dose=item["dose"],
time=item["time"],
)
for item in payload
]
def _profile_row_to_record(row: Any) -> ProfileRecord:
"""Map a profile SQLite row to a ProfileRecord."""
return ProfileRecord(
id=row["id"],
name=row["name"],
age=row["age"],
city=row["city"],
profession=row["profession"] or "",
goal=row["goal"],
conditions=json.loads(row["conditions"] or "[]"),
medications=_medications_from_json(row["medications"]),
triggers=json.loads(row["triggers"] or "[]"),
wake_time=row["wake_time"],
sleep_time=row["sleep_time"],
desk_worker=_int_to_bool(row["desk_worker"]),
exercise_level=row["exercise_level"],
dietary_notes=row["dietary_notes"] or "",
local_foods=row["local_foods"] or "",
created_at=_parse_datetime(row["created_at"]),
updated_at=_parse_datetime(row["updated_at"]),
)
def save_profile(profile: ProfileInput) -> int:
"""Insert or replace the single user profile row."""
now = _format_datetime(_utc_now())
connection = get_connection()
with _write_lock:
existing = connection.execute("SELECT id FROM profile LIMIT 1;").fetchone()
if existing:
connection.execute(
"""
UPDATE profile SET
name = ?, age = ?, city = ?, profession = ?, goal = ?,
conditions = ?, medications = ?, triggers = ?,
wake_time = ?, sleep_time = ?, desk_worker = ?,
exercise_level = ?, dietary_notes = ?, local_foods = ?,
updated_at = ?
WHERE id = ?;
""",
(
profile.name,
profile.age,
profile.city,
profile.profession,
profile.goal,
json.dumps(profile.conditions),
_medications_to_json(profile.medications),
json.dumps(profile.triggers),
profile.wake_time,
profile.sleep_time,
_bool_to_int(profile.desk_worker),
profile.exercise_level,
profile.dietary_notes,
profile.local_foods,
now,
existing["id"],
),
)
connection.commit()
return int(existing["id"])
cursor = connection.execute(
"""
INSERT INTO profile (
name, age, city, profession, goal, conditions, medications, triggers,
wake_time, sleep_time, desk_worker, exercise_level,
dietary_notes, local_foods, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""",
(
profile.name,
profile.age,
profile.city,
profile.profession,
profile.goal,
json.dumps(profile.conditions),
_medications_to_json(profile.medications),
json.dumps(profile.triggers),
profile.wake_time,
profile.sleep_time,
_bool_to_int(profile.desk_worker),
profile.exercise_level,
profile.dietary_notes,
profile.local_foods,
now,
now,
),
)
connection.commit()
return int(cursor.lastrowid)
def get_profile() -> ProfileRecord | None:
"""Return the user profile if one exists."""
connection = get_connection()
row = connection.execute("SELECT * FROM profile LIMIT 1;").fetchone()
if row is None:
return None
return _profile_row_to_record(row)
def set_setting(key: str, value: str) -> None:
"""Upsert a key-value application setting."""
connection = get_connection()
with _write_lock:
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(key, value),
)
connection.commit()
def get_setting(key: str) -> str | None:
"""Read a single application setting value."""
connection = get_connection()
row = connection.execute(
"SELECT value FROM settings WHERE key = ?;",
(key,),
).fetchone()
if row is None:
return None
return row["value"]
def set_onboarding_complete(complete: bool) -> None:
"""Mark whether onboarding has finished."""
set_setting(SETTING_ONBOARDING_COMPLETE, "true" if complete else "false")
def check_onboarding_status() -> bool:
"""Return true when a valid onboarded profile exists."""
profile = get_profile()
if profile is None or not profile.name:
return False
onboarding_flag = get_setting(SETTING_ONBOARDING_COMPLETE)
return onboarding_flag == "true"
def save_presence_config(config: PresenceConfig) -> None:
"""Persist the user-approved presence configuration."""
payload = {
"enabled": config.enabled,
"max_continuous_minutes": config.max_continuous_minutes,
"break_message": config.break_message,
"break_duration_minutes": config.break_duration_minutes,
"check_interval_seconds": config.check_interval_seconds,
}
set_setting(SETTING_PRESENCE_CONFIG, json.dumps(payload))
def get_presence_config() -> PresenceConfig | None:
"""Load the presence configuration from settings."""
raw_value = get_setting(SETTING_PRESENCE_CONFIG)
if not raw_value:
return None
payload = json.loads(raw_value)
return PresenceConfig(
enabled=payload["enabled"],
max_continuous_minutes=payload["max_continuous_minutes"],
break_message=payload["break_message"],
break_duration_minutes=payload["break_duration_minutes"],
check_interval_seconds=payload.get("check_interval_seconds", 300),
)
def get_presence_break_message() -> str:
"""Return the desk-break notification message."""
config = get_presence_config()
if config is None:
return "You have been at your desk for a while. Stand up, stretch, and drink water."
return config.break_message
def save_system_prompt_additions(text: str) -> None:
"""Store LLM-generated condition-specific prompt additions."""
set_setting(SETTING_SYSTEM_PROMPT_ADDITIONS, text)
def get_system_prompt_additions() -> str:
"""Read condition-specific prompt additions."""
return get_setting(SETTING_SYSTEM_PROMPT_ADDITIONS) or ""
def replace_daily_log_schema(fields: list[DailyLogSchemaField]) -> None:
"""Replace all daily check-in field definitions."""
connection = get_connection()
with _write_lock:
connection.execute("DELETE FROM daily_log_schema;")
for field in fields:
connection.execute(
"""
INSERT INTO daily_log_schema (
field_id, label, type, options, display_order, reason
) VALUES (?, ?, ?, ?, ?, ?);
""",
(
field.field_id,
field.label,
field.type,
json.dumps(field.options),
field.display_order,
field.reason,
),
)
connection.commit()
def get_daily_log_schema() -> list[DailyLogSchemaField]:
"""Return all daily check-in field definitions in display order."""
connection = get_connection()
rows = connection.execute(
"""
SELECT * FROM daily_log_schema
ORDER BY display_order ASC, id ASC;
"""
).fetchall()
return [
DailyLogSchemaField(
id=row["id"],
field_id=row["field_id"],
label=row["label"],
type=row["type"],
options=json.loads(row["options"] or "[]"),
display_order=row["display_order"],
reason=row["reason"] or "",
)
for row in rows
]
def upsert_daily_log(entry_date: date, field_id: str, value: str) -> None:
"""Insert or update one daily check-in value for a date."""
connection = get_connection()
now = _format_datetime(_utc_now())
date_value = _format_date(entry_date)
with _write_lock:
existing = connection.execute(
"""
SELECT id FROM daily_logs
WHERE date = ? AND field_id = ?;
""",
(date_value, field_id),
).fetchone()
if existing:
connection.execute(
"""
UPDATE daily_logs
SET value = ?, logged_at = ?
WHERE id = ?;
""",
(value, now, existing["id"]),
)
else:
connection.execute(
"""
INSERT INTO daily_logs (date, field_id, value, logged_at)
VALUES (?, ?, ?, ?);
""",
(date_value, field_id, value, now),
)
connection.commit()
def get_daily_logs_for_date(entry_date: date) -> list[DailyLogEntry]:
"""Return all daily check-in values for a given date."""
connection = get_connection()
rows = connection.execute(
"SELECT * FROM daily_logs WHERE date = ? ORDER BY id ASC;",
(_format_date(entry_date),),
).fetchall()
return [
DailyLogEntry(
id=row["id"],
date=_parse_date(row["date"]),
field_id=row["field_id"],
value=row["value"],
logged_at=_parse_datetime(row["logged_at"]),
)
for row in rows
]
def insert_medication_log(entry: MedicationLogEntry) -> int:
"""Insert a medication log row."""
connection = get_connection()
with _write_lock:
cursor = connection.execute(
"""
INSERT INTO medication_log (
date, medication_name, dose, scheduled_time, taken, taken_at
) VALUES (?, ?, ?, ?, ?, ?);
""",
(
_format_date(entry.date),
entry.medication_name,
entry.dose,
entry.scheduled_time,
_bool_to_int(entry.taken),
_format_datetime(entry.taken_at) if entry.taken_at else None,
),
)
connection.commit()
return int(cursor.lastrowid)
def get_medications_for_date(entry_date: date) -> list[MedicationLogEntry]:
"""Return medication doses scheduled for a date."""
connection = get_connection()
rows = connection.execute(
"""
SELECT * FROM medication_log
WHERE date = ?
ORDER BY scheduled_time ASC, id ASC;
""",
(_format_date(entry_date),),
).fetchall()
return [
MedicationLogEntry(
id=row["id"],
date=_parse_date(row["date"]),
medication_name=row["medication_name"],
dose=row["dose"] or "",
scheduled_time=row["scheduled_time"],
taken=_int_to_bool(row["taken"]),
taken_at=_parse_datetime(row["taken_at"]) if row["taken_at"] else None,
)
for row in rows
]
def mark_medication_taken(
entry_date: date,
medication_name: str,
scheduled_time: str,
) -> bool:
"""Mark a scheduled medication dose as taken."""
return set_medication_taken_status(
entry_date,
medication_name,
scheduled_time,
taken=True,
)
def set_medication_taken_status(
entry_date: date,
medication_name: str,
scheduled_time: str,
taken: bool,
) -> bool:
"""Set whether a scheduled medication dose was taken."""
connection = get_connection()
taken_at = _format_datetime(_utc_now()) if taken else None
with _write_lock:
cursor = connection.execute(
"""
UPDATE medication_log
SET taken = ?, taken_at = ?
WHERE date = ? AND medication_name = ? AND scheduled_time = ?;
""",
(
_bool_to_int(taken),
taken_at,
_format_date(entry_date),
medication_name,
scheduled_time,
),
)
connection.commit()
return cursor.rowcount > 0
def insert_food_log(entry: FoodLogEntry) -> int:
"""Insert a food log row."""
connection = get_connection()
with _write_lock:
cursor = connection.execute(
"""
INSERT INTO food_log (
date, meal_type, food_description, llm_notes, logged_at
) VALUES (?, ?, ?, ?, ?);
""",
(
_format_date(entry.date),
entry.meal_type,
entry.food_description,
entry.llm_notes,
_format_datetime(entry.logged_at),
),
)
connection.commit()
return int(cursor.lastrowid)
def get_food_logs_for_date(entry_date: date) -> list[FoodLogEntry]:
"""Return food log entries for a date."""
connection = get_connection()
rows = connection.execute(
"SELECT * FROM food_log WHERE date = ? ORDER BY logged_at ASC;",
(_format_date(entry_date),),
).fetchall()
return [
FoodLogEntry(
id=row["id"],
date=_parse_date(row["date"]),
meal_type=row["meal_type"],
food_description=row["food_description"],
llm_notes=row["llm_notes"],
logged_at=_parse_datetime(row["logged_at"]),
)
for row in rows
]
def insert_exercise_log(entry: ExerciseLogEntry) -> int:
"""Insert an exercise log row."""
connection = get_connection()
with _write_lock:
cursor = connection.execute(
"""
INSERT INTO exercise_log (
date, exercise_type, duration_minutes, completed, notes, logged_at
) VALUES (?, ?, ?, ?, ?, ?);
""",
(
_format_date(entry.date),
entry.exercise_type,
entry.duration_minutes,
_bool_to_int(entry.completed),
entry.notes,
_format_datetime(entry.logged_at),
),
)
connection.commit()
return int(cursor.lastrowid)
def get_exercise_logs_for_date(entry_date: date) -> list[ExerciseLogEntry]:
"""Return exercise log entries for a date."""
connection = get_connection()
rows = connection.execute(
"SELECT * FROM exercise_log WHERE date = ? ORDER BY logged_at ASC;",
(_format_date(entry_date),),
).fetchall()
return [
ExerciseLogEntry(
id=row["id"],
date=_parse_date(row["date"]),
exercise_type=row["exercise_type"],
duration_minutes=row["duration_minutes"] or 0,
completed=_int_to_bool(row["completed"]),
notes=row["notes"],
logged_at=_parse_datetime(row["logged_at"]),
)
for row in rows
]
def replace_scheduled_jobs(jobs: list[ScheduledJob]) -> None:
"""Replace all scheduled jobs with a new set."""
connection = get_connection()
with _write_lock:
connection.execute("DELETE FROM scheduled_jobs;")
for job in jobs:
connection.execute(
"""
INSERT INTO scheduled_jobs (
job_id, type, schedule_type, time, interval_minutes,
days, message, tts, active, context
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""",
(
job.job_id,
job.type,
job.schedule_type,
job.time,
job.interval_minutes,
job.days,
job.message,
_bool_to_int(job.tts),
_bool_to_int(job.active),
job.context,
),
)
connection.commit()
def get_all_scheduled_jobs(active_only: bool = True) -> list[ScheduledJob]:
"""Return scheduled jobs, optionally filtering to active rows only."""
connection = get_connection()
if active_only:
rows = connection.execute(
"SELECT * FROM scheduled_jobs WHERE active = 1 ORDER BY id ASC;"
).fetchall()
else:
rows = connection.execute(
"SELECT * FROM scheduled_jobs ORDER BY id ASC;"
).fetchall()
return [
ScheduledJob(
id=row["id"],
job_id=row["job_id"],
type=row["type"],
schedule_type=row["schedule_type"],
time=row["time"],
interval_minutes=row["interval_minutes"],
days=row["days"],
message=row["message"],
tts=_int_to_bool(row["tts"]),
active=_int_to_bool(row["active"]),
context=row["context"] or "",
)
for row in rows
]
def insert_meal_plan_entries(entries: list[MealPlanEntry]) -> None:
"""Insert meal plan rows."""
connection = get_connection()
with _write_lock:
for entry in entries:
connection.execute(
"""
INSERT INTO meal_plan (
week_start, day_of_week, meal_type, suggestion,
nutrients_focus, generated_at
) VALUES (?, ?, ?, ?, ?, ?);
""",
(
_format_date(entry.week_start),
entry.day_of_week,
entry.meal_type,
entry.suggestion,
entry.nutrients_focus,
_format_datetime(entry.generated_at),
),
)
connection.commit()
def get_meal_plan_for_week(week_start: date) -> list[MealPlanEntry]:
"""Return meal plan entries for a week."""
connection = get_connection()
rows = connection.execute(
"""
SELECT * FROM meal_plan
WHERE week_start = ?
ORDER BY id ASC;
""",
(_format_date(week_start),),
).fetchall()
return [
MealPlanEntry(
id=row["id"],
week_start=_parse_date(row["week_start"]),
day_of_week=row["day_of_week"],
meal_type=row["meal_type"],
suggestion=row["suggestion"],
nutrients_focus=row["nutrients_focus"] or "",
generated_at=_parse_datetime(row["generated_at"]),
)
for row in rows
]
def insert_weekly_report(report: WeeklyReport) -> int:
"""Insert a weekly report row."""
connection = get_connection()
with _write_lock:
cursor = connection.execute(
"""
INSERT INTO weekly_reports (
week_start, report_text, avg_pain, water_goals_hit,
medication_adherence, exercises_completed, generated_at
) VALUES (?, ?, ?, ?, ?, ?, ?);
""",
(
_format_date(report.week_start),
report.report_text,
report.avg_pain,
report.water_goals_hit,
report.medication_adherence,
report.exercises_completed,
_format_datetime(report.generated_at),
),
)
connection.commit()
return int(cursor.lastrowid)
def get_latest_weekly_report() -> WeeklyReport | None:
"""Return the most recently generated weekly report."""
connection = get_connection()
row = connection.execute(
"SELECT * FROM weekly_reports ORDER BY generated_at DESC LIMIT 1;"
).fetchone()
if row is None:
return None
return WeeklyReport(
id=row["id"],
week_start=_parse_date(row["week_start"]),
report_text=row["report_text"],
avg_pain=row["avg_pain"],
water_goals_hit=row["water_goals_hit"] or 0,
medication_adherence=row["medication_adherence"] or 0.0,
exercises_completed=row["exercises_completed"] or 0,
generated_at=_parse_datetime(row["generated_at"]),
)
def delete_weekly_report(week_start: date) -> None:
"""Delete the weekly report for a given week start date."""
connection = get_connection()
with _write_lock:
connection.execute(
"DELETE FROM weekly_reports WHERE week_start = ?;",
(_format_date(week_start),),
)
connection.commit()
def get_weekly_report(week_start: date) -> WeeklyReport | None:
"""Return the weekly report for a given week start date."""
connection = get_connection()
row = connection.execute(
"SELECT * FROM weekly_reports WHERE week_start = ? LIMIT 1;",
(_format_date(week_start),),
).fetchone()
if row is None:
return None
return WeeklyReport(
id=row["id"],
week_start=_parse_date(row["week_start"]),
report_text=row["report_text"],
avg_pain=row["avg_pain"],
water_goals_hit=row["water_goals_hit"] or 0,
medication_adherence=row["medication_adherence"] or 0.0,
exercises_completed=row["exercises_completed"] or 0,
generated_at=_parse_datetime(row["generated_at"]),
)
def insert_notification_log(entry: NotificationLogEntry) -> int:
"""Insert a delivered notification record."""
connection = get_connection()
with _write_lock:
cursor = connection.execute(
"""
INSERT INTO notifications_log (job_id, message, delivered_at, tts_spoken)
VALUES (?, ?, ?, ?);
""",
(
entry.job_id,
entry.message,
_format_datetime(entry.delivered_at),
_bool_to_int(entry.tts_spoken),
),
)
connection.commit()
return int(cursor.lastrowid)
def insert_presence_log(entry: PresenceLogEntry) -> int:
"""Insert a presence detection check result."""
connection = get_connection()
with _write_lock:
cursor = connection.execute(
"""
INSERT INTO presence_log (detected, checked_at, continuous_minutes)
VALUES (?, ?, ?);
""",
(
_bool_to_int(entry.detected),
_format_datetime(entry.checked_at),
entry.continuous_minutes,
),
)
connection.commit()
return int(cursor.lastrowid)
def already_greeted_today() -> bool:
"""Return true if the morning greeting already ran today."""
last_greeted = get_setting(SETTING_LAST_GREETED_DATE)
if not last_greeted:
return False
return last_greeted == _format_date(date.today())
def mark_greeted_today() -> None:
"""Record that today's morning greeting has been delivered."""
set_setting(SETTING_LAST_GREETED_DATE, _format_date(date.today()))
def _monday_of_week(entry_date: date) -> date:
"""Return the Monday that starts the week containing entry_date."""
return entry_date - timedelta(days=entry_date.weekday())
def monday_of_week(entry_date: date) -> date:
"""Return the Monday that starts the week containing entry_date."""
return _monday_of_week(entry_date)
def get_food_logs_between(
start_date: date,
end_date: date,
) -> list[FoodLogEntry]:
"""Return food log entries for each day from start_date through end_date inclusive."""
if end_date < start_date:
return []
entries: list[FoodLogEntry] = []
current = start_date
while current <= end_date:
entries.extend(get_food_logs_for_date(current))
current += timedelta(days=1)
return entries
def get_meal_plan_for_date(entry_date: date | None = None) -> list[MealPlanEntry]:
"""Return meal plan entries for a specific calendar date."""
target_date = entry_date or date.today()
week_start = _monday_of_week(target_date)
day_name = target_date.strftime("%A")
week_entries = get_meal_plan_for_week(week_start)
return [entry for entry in week_entries if entry.day_of_week == day_name]
def get_recent_logs(days: int = 7) -> list[DailyLogEntry]:
"""Return daily check-in logs for the last N days."""
if days < 1:
days = 1
if days > 30:
days = 30
connection = get_connection()
start_date = date.today() - timedelta(days=days - 1)
rows = connection.execute(
"""
SELECT * FROM daily_logs
WHERE date >= ?
ORDER BY date ASC, id ASC;
""",
(_format_date(start_date),),
).fetchall()
return [
DailyLogEntry(
id=row["id"],
date=_parse_date(row["date"]),
field_id=row["field_id"],
value=row["value"],
logged_at=_parse_datetime(row["logged_at"]),
)
for row in rows
]
def get_weekly_summary_for_week(week_start: date) -> dict[str, object]:
"""Aggregate wellness stats for a specific Monday-based week."""
week_end = week_start + timedelta(days=6)
medications_taken = 0
medications_total = 0
exercises_completed = 0
food_entries = 0
daily_log_count = 0
current = week_start
while current <= week_end:
for med in get_medications_for_date(current):
medications_total += 1
if med.taken:
medications_taken += 1
for exercise in get_exercise_logs_for_date(current):
if exercise.completed:
exercises_completed += 1
food_entries += len(get_food_logs_for_date(current))
daily_log_count += len(get_daily_logs_for_date(current))
current += timedelta(days=1)
adherence = 0.0
if medications_total > 0:
adherence = round((medications_taken / medications_total) * 100, 1)
return {
"week_start": _format_date(week_start),
"week_end": _format_date(week_end),
"medications_taken": medications_taken,
"medications_total": medications_total,
"medication_adherence_percent": adherence,
"exercises_completed": exercises_completed,
"food_entries": food_entries,
"daily_log_entries": daily_log_count,
}
def get_weekly_summary(week: str = "current") -> dict[str, object]:
"""Aggregate wellness stats for the current or previous week."""
today = date.today()
reference_date = today if week == "current" else today - timedelta(days=7)
week_start = _monday_of_week(reference_date)
return get_weekly_summary_for_week(week_start)
def log_water(cups: int, entry_date: date | None = None) -> None:
"""Update today's water cup count in daily logs."""
target_date = entry_date or date.today()
upsert_daily_log(target_date, "water_cups", str(cups))
def save_meal_plan_framework(data: dict[str, object]) -> None:
"""Persist the onboarding meal plan framework to settings."""
set_setting(SETTING_MEAL_PLAN_FRAMEWORK, json.dumps(data))
def get_meal_plan_framework() -> dict[str, object] | None:
"""Load the meal plan framework from settings."""
raw_value = get_setting(SETTING_MEAL_PLAN_FRAMEWORK)
if not raw_value:
return None
payload = json.loads(raw_value)
if isinstance(payload, dict):
return payload
return None
def save_exercise_plan(data: dict[str, object]) -> None:
"""Persist the onboarding exercise plan to settings."""
set_setting(SETTING_EXERCISE_PLAN, json.dumps(data))
def get_exercise_plan() -> dict[str, object] | None:
"""Load the exercise plan from settings."""
raw_value = get_setting(SETTING_EXERCISE_PLAN)
if not raw_value:
return None
payload = json.loads(raw_value)
if isinstance(payload, dict):
return payload
return None
def save_weekly_check_structure(data: dict[str, object]) -> None:
"""Persist the weekly report/replan schedule to settings."""
set_setting(SETTING_WEEKLY_CHECK_STRUCTURE, json.dumps(data))
def get_weekly_check_structure() -> dict[str, object] | None:
"""Load the weekly report/replan schedule from settings."""
raw_value = get_setting(SETTING_WEEKLY_CHECK_STRUCTURE)
if not raw_value:
return None
payload = json.loads(raw_value)
if isinstance(payload, dict):
return payload
return None
def save_coach_quick_questions(questions: list[str]) -> None:
"""Persist coach quick-question chips from onboarding."""
set_setting(SETTING_COACH_QUICK_QUESTIONS, json.dumps(questions))
def get_coach_quick_questions() -> list[str]:
"""Load coach quick-question chips."""
raw_value = get_setting(SETTING_COACH_QUICK_QUESTIONS)
if not raw_value:
return []
payload = json.loads(raw_value)
if isinstance(payload, list):
return [str(item) for item in payload]
return []
def save_hydration_goal_liters(liters: float) -> None:
"""Persist the user's daily hydration target in litres."""
set_setting(SETTING_HYDRATION_GOAL_LITERS, str(round(max(0.5, liters), 2)))
def get_hydration_goal_liters() -> float:
"""Load the user's daily hydration target in litres."""
raw_value = get_setting(SETTING_HYDRATION_GOAL_LITERS)
if not raw_value:
return 2.5
try:
return max(0.5, float(raw_value))
except ValueError:
return 2.5
def save_tts_preferences(preferences: TtsPreferences) -> None:
"""Persist per-category TTS notification toggles."""
payload = {
"hydration": preferences.hydration,
"exercise": preferences.exercise,
"medication": preferences.medication,
"meal": preferences.meal,
"check_in": preferences.check_in,
}
set_setting(SETTING_TTS_PREFERENCES, json.dumps(payload))
def get_tts_preferences() -> TtsPreferences:
"""Load per-category TTS notification toggles."""
raw_value = get_setting(SETTING_TTS_PREFERENCES)
if not raw_value:
return TtsPreferences()
payload = json.loads(raw_value)
if not isinstance(payload, dict):
return TtsPreferences()
return TtsPreferences(
hydration=bool(payload.get("hydration", True)),
exercise=bool(payload.get("exercise", True)),
medication=bool(payload.get("medication", True)),
meal=bool(payload.get("meal", True)),
check_in=bool(payload.get("check_in", True)),
)
def tts_enabled_for_job_type(job_type: str) -> bool:
"""Return whether TTS is enabled for a scheduled job category."""
preferences = get_tts_preferences()
if job_type == "hydration":
return preferences.hydration
if job_type == "exercise":
return preferences.exercise
if job_type == "medication":
return preferences.medication
if job_type == "meal":
return preferences.meal
if job_type == "check_in":
return preferences.check_in
return True
def clear_all_daily_schedules() -> int:
"""Delete every row from daily plan tables; return rows removed."""
connection = get_connection()
with _write_lock:
job_count = connection.execute("SELECT COUNT(*) AS c FROM daily_schedule_jobs;").fetchone()["c"]
plan_count = connection.execute("SELECT COUNT(*) AS c FROM daily_plans;").fetchone()["c"]
connection.execute("DELETE FROM daily_schedule_jobs;")
connection.execute("DELETE FROM daily_plans;")
connection.commit()
return int(job_count) + int(plan_count)
def find_previous_daily_plan(before_date: date, max_lookback_days: int = 30) -> DailyPlan | None:
"""Return the most recent daily plan strictly before the given date."""
for offset in range(1, max_lookback_days + 1):
candidate = before_date - timedelta(days=offset)
plan = get_daily_plan(candidate)
if plan is not None:
return plan
return None
def has_daily_plan(plan_date: date) -> bool:
"""Return True when a daily plan exists for the given date."""
connection = get_connection()
row = connection.execute(
"SELECT id FROM daily_plans WHERE plan_date = ? LIMIT 1;",
(_format_date(plan_date),),
).fetchone()
return row is not None
def get_daily_plan(plan_date: date) -> DailyPlan | None:
"""Load the full daily plan and its jobs for a date."""
connection = get_connection()
row = connection.execute(
"SELECT * FROM daily_plans WHERE plan_date = ? LIMIT 1;",
(_format_date(plan_date),),
).fetchone()
if row is None:
return None
job_rows = connection.execute(
"SELECT * FROM daily_schedule_jobs WHERE plan_date = ? ORDER BY time ASC;",
(_format_date(plan_date),),
).fetchall()
jobs = [
DailyScheduleJob(
id=job_row["id"],
job_id=job_row["job_id"],
type=cast(ScheduledJobType, job_row["type"]),
time=job_row["time"],
message=job_row["message"],
tts=_int_to_bool(job_row["tts"]),
context=job_row["context"] or "",
volume_ml=job_row["volume_ml"],
exercise_type=job_row["exercise_type"],
duration_minutes=job_row["duration_minutes"],
)
for job_row in job_rows
]
return DailyPlan(
id=row["id"],
plan_date=_parse_date(row["plan_date"]),
summary=row["summary"],
hydration_goal_liters=float(row["hydration_goal_liters"]),
generated_at=_parse_datetime(row["generated_at"]),
jobs=jobs,
)
def delete_daily_plan(plan_date: date) -> None:
"""Remove a daily plan and its jobs for a date."""
connection = get_connection()
with _write_lock:
connection.execute(
"DELETE FROM daily_schedule_jobs WHERE plan_date = ?;",
(_format_date(plan_date),),
)
connection.execute(
"DELETE FROM daily_plans WHERE plan_date = ?;",
(_format_date(plan_date),),
)
connection.commit()
def save_daily_plan(plan: DailyPlan) -> None:
"""Persist a daily plan and replace jobs for that date."""
connection = get_connection()
with _write_lock:
connection.execute(
"DELETE FROM daily_schedule_jobs WHERE plan_date = ?;",
(_format_date(plan.plan_date),),
)
connection.execute(
"DELETE FROM daily_plans WHERE plan_date = ?;",
(_format_date(plan.plan_date),),
)
connection.execute(
"""
INSERT INTO daily_plans (
plan_date, summary, hydration_goal_liters, generated_at
) VALUES (?, ?, ?, ?);
""",
(
_format_date(plan.plan_date),
plan.summary,
plan.hydration_goal_liters,
_format_datetime(plan.generated_at),
),
)
for job in plan.jobs:
connection.execute(
"""
INSERT INTO daily_schedule_jobs (
plan_date, job_id, type, time, message, tts, context,
volume_ml, exercise_type, duration_minutes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""",
(
_format_date(plan.plan_date),
job.job_id,
job.type,
job.time,
job.message,
_bool_to_int(job.tts),
job.context,
job.volume_ml,
job.exercise_type,
job.duration_minutes,
),
)
connection.commit()
def build_medication_scheduler_jobs(profile: ProfileInput) -> list[ScheduledJob]:
"""Build daily medication reminder jobs from the user profile."""
med_tts = tts_enabled_for_job_type("medication")
jobs: list[ScheduledJob] = []
for medication in profile.medications:
safe_name = medication.name.lower().replace(" ", "_")
safe_time = medication.time.replace(":", "")
jobs.append(
ScheduledJob(
job_id=f"med_{safe_name}_{safe_time}",
type="medication",
schedule_type="daily_time",
time=medication.time,
interval_minutes=None,
days="daily",
message=f"Time for your {medication.name} ({medication.dose})",
tts=med_tts,
active=True,
context="Daily medication from your profile",
)
)
return jobs
def _daily_job_to_scheduled(job: DailyScheduleJob, plan_date: date) -> ScheduledJob:
"""Convert a daily schedule job into an APScheduler-compatible job.
TTS is resolved from Settings at load/fire time (single source of truth),
so toggling a category in Settings takes effect on the next reminder
without regenerating the plan or restarting the app.
"""
return ScheduledJob(
job_id=f"daily_{plan_date.isoformat()}_{job.job_id}",
type=job.type,
schedule_type="daily_time",
time=job.time,
interval_minutes=None,
days="daily",
message=job.message,
tts=tts_enabled_for_job_type(job.type),
active=True,
context=job.context,
)
def get_combined_scheduler_jobs(plan_date: date | None = None) -> list[ScheduledJob]:
"""Merge medication jobs and today's LLM-generated daily schedule jobs."""
target_date = plan_date or date.today()
jobs: list[ScheduledJob] = []
profile = get_profile()
if profile is not None:
jobs.extend(build_medication_scheduler_jobs(profile))
daily_plan = get_daily_plan(target_date)
if daily_plan is not None:
for daily_job in daily_plan.jobs:
jobs.append(_daily_job_to_scheduled(daily_job, target_date))
return jobs
legacy_jobs = get_all_scheduled_jobs(active_only=True)
for legacy_job in legacy_jobs:
if legacy_job.type == "medication":
continue
jobs.append(legacy_job)
return jobs
def save_morning_briefing_cache(entry_date: date, text: str) -> None:
"""Cache today's morning briefing for the dashboard."""
payload = {"date": _format_date(entry_date), "text": text}
set_setting(SETTING_MORNING_BRIEFING, json.dumps(payload))
def clear_morning_briefing_cache() -> None:
"""Drop the cached briefing so the next dashboard load regenerates it."""
set_setting(SETTING_MORNING_BRIEFING, "")
def get_morning_briefing_cache(entry_date: date | None = None) -> str | None:
"""Return the cached morning briefing for a date if present."""
target_date = entry_date or date.today()
raw_value = get_setting(SETTING_MORNING_BRIEFING)
if not raw_value:
return None
payload = json.loads(raw_value)
if not isinstance(payload, dict):
return None
if payload.get("date") != _format_date(target_date):
return None
text = payload.get("text")
if isinstance(text, str) and text.strip():
return text.strip()
return None
def write_profile_cache() -> None:
"""Write profile.json cache after onboarding commit."""
from core.app_config import PROJECT_ROOT
profile = get_profile()
if profile is None:
return
cache_path = PROJECT_ROOT / "data" / "profile.json"
cache_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"name": profile.name,
"age": profile.age,
"city": profile.city,
"profession": profile.profession,
"goal": profile.goal,
"conditions": profile.conditions,
"medications": [
{"name": item.name, "dose": item.dose, "time": item.time}
for item in profile.medications
],
"triggers": profile.triggers,
"wake_time": profile.wake_time,
"sleep_time": profile.sleep_time,
"desk_worker": profile.desk_worker,
"exercise_level": profile.exercise_level,
"dietary_notes": profile.dietary_notes,
"local_foods": profile.local_foods,
}
cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def commit_onboarding_plan(data: OnboardingCommitData) -> int:
"""Atomically write the approved onboarding plan to the database."""
connection = get_connection()
now = _format_datetime(_utc_now())
profile = data.profile
plan = data.plan
with _write_lock:
try:
connection.execute("BEGIN IMMEDIATE;")
existing = connection.execute("SELECT id FROM profile LIMIT 1;").fetchone()
if existing:
connection.execute(
"""
UPDATE profile SET
name = ?, age = ?, city = ?, profession = ?, goal = ?,
conditions = ?, medications = ?, triggers = ?,
wake_time = ?, sleep_time = ?, desk_worker = ?,
exercise_level = ?, dietary_notes = ?, local_foods = ?,
updated_at = ?
WHERE id = ?;
""",
(
profile.name,
profile.age,
profile.city,
profile.profession,
profile.goal,
json.dumps(profile.conditions),
_medications_to_json(profile.medications),
json.dumps(profile.triggers),
profile.wake_time,
profile.sleep_time,
_bool_to_int(profile.desk_worker),
profile.exercise_level,
profile.dietary_notes,
profile.local_foods,
now,
existing["id"],
),
)
profile_id = int(existing["id"])
else:
cursor = connection.execute(
"""
INSERT INTO profile (
name, age, city, profession, goal, conditions, medications, triggers,
wake_time, sleep_time, desk_worker, exercise_level,
dietary_notes, local_foods, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""",
(
profile.name,
profile.age,
profile.city,
profile.profession,
profile.goal,
json.dumps(profile.conditions),
_medications_to_json(profile.medications),
json.dumps(profile.triggers),
profile.wake_time,
profile.sleep_time,
_bool_to_int(profile.desk_worker),
profile.exercise_level,
profile.dietary_notes,
profile.local_foods,
now,
now,
),
)
profile_id = int(cursor.lastrowid)
connection.execute("DELETE FROM daily_log_schema;")
for field in plan.daily_log_fields:
connection.execute(
"""
INSERT INTO daily_log_schema (
field_id, label, type, options, display_order, reason
) VALUES (?, ?, ?, ?, ?, ?);
""",
(
field.field_id,
field.label,
field.type,
json.dumps(field.options),
field.display_order,
field.reason,
),
)
connection.execute("DELETE FROM scheduled_jobs;")
for job in plan.scheduled_jobs:
connection.execute(
"""
INSERT INTO scheduled_jobs (
job_id, type, schedule_type, time, interval_minutes,
days, message, tts, active, context
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""",
(
job.job_id,
job.type,
job.schedule_type,
job.time,
job.interval_minutes,
job.days,
job.message,
_bool_to_int(job.tts),
_bool_to_int(job.active),
job.context,
),
)
presence_payload = {
"enabled": plan.presence_check.enabled,
"max_continuous_minutes": plan.presence_check.max_continuous_minutes,
"break_message": plan.presence_check.break_message,
"break_duration_minutes": plan.presence_check.break_duration_minutes,
"check_interval_seconds": plan.presence_check.check_interval_seconds,
}
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(SETTING_PRESENCE_CONFIG, json.dumps(presence_payload)),
)
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(SETTING_SYSTEM_PROMPT_ADDITIONS, plan.system_prompt_additions),
)
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(
SETTING_MEAL_PLAN_FRAMEWORK,
json.dumps(
{
"nutrients_to_prioritise": plan.meal_plan_framework.nutrients_to_prioritise,
"nutrients_to_moderate": plan.meal_plan_framework.nutrients_to_moderate,
"meal_frequency": plan.meal_plan_framework.meal_frequency,
"notes": plan.meal_plan_framework.notes,
}
),
),
)
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(
SETTING_EXERCISE_PLAN,
json.dumps(
{
"frequency": plan.exercise_plan.frequency,
"intensity": plan.exercise_plan.intensity,
"session_duration_minutes": plan.exercise_plan.session_duration_minutes,
"types": plan.exercise_plan.types,
"avoid": plan.exercise_plan.avoid,
"notes": plan.exercise_plan.notes,
}
),
),
)
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(
SETTING_WEEKLY_CHECK_STRUCTURE,
json.dumps(
{
"report_day": plan.weekly_check_structure.report_day,
"report_time": plan.weekly_check_structure.report_time,
"replan_day": plan.weekly_check_structure.replan_day,
"replan_time": plan.weekly_check_structure.replan_time,
}
),
),
)
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(SETTING_HYDRATION_GOAL_LITERS, str(plan.hydration_goal_liters)),
)
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(SETTING_COACH_QUICK_QUESTIONS, json.dumps(plan.coach_quick_questions)),
)
connection.execute(
"""
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""",
(SETTING_ONBOARDING_COMPLETE, "true"),
)
connection.commit()
except Exception:
connection.rollback()
raise
write_profile_cache()
return profile_id
def ensure_daily_rows(entry_date: date | None = None) -> None:
"""Seed today's medication_log rows from the profile schedule (idempotent)."""
target_date = entry_date or date.today()
profile = get_profile()
if profile is None:
return
existing = get_medications_for_date(target_date)
existing_keys = {(item.medication_name, item.scheduled_time) for item in existing}
for medication in profile.medications:
key = (medication.name, medication.time)
if key in existing_keys:
continue
insert_medication_log(
MedicationLogEntry(
date=target_date,
medication_name=medication.name,
dose=medication.dose,
scheduled_time=medication.time,
taken=False,
taken_at=None,
)
)