""" appointment_tracker.py — CRUD + lifecycle for appointments.json Each record is scoped to a care profile via profile_id. """ from __future__ import annotations import json, os, uuid from datetime import date, datetime, timedelta from typing import Any, Dict, List, Optional from config import APPOINTMENTS_JSON def _default_recurrence() -> Dict[str, Any]: return {"frequency": "none", "interval": 1, "end_type": "never", "end_after": 1, "end_date": ""} def _migrate(appt: Dict[str, Any]) -> Dict[str, Any]: if "recurrence" not in appt: rec = _default_recurrence() if appt.get("is_recurring"): rec["frequency"] = "weekly" appt["recurrence"] = rec if "status" not in appt: appt["status"] = "scheduled" if "completed_at" not in appt: appt["completed_at"] = "" return appt def _load() -> List[Dict[str, Any]]: if APPOINTMENTS_JSON.exists(): try: with open(APPOINTMENTS_JSON, "r", encoding="utf-8") as f: return [_migrate(a) for a in json.load(f)] except Exception: return [] return [] def _save(appts: List[Dict[str, Any]]): APPOINTMENTS_JSON.parent.mkdir(parents=True, exist_ok=True) with open(APPOINTMENTS_JSON, "w", encoding="utf-8") as f: json.dump(appts, f, indent=2, ensure_ascii=False) f.flush() os.fsync(f.fileno()) def _now() -> str: return datetime.now().isoformat(timespec="seconds") def _today_str() -> str: return date.today().isoformat() def _days_until(date_str: str) -> int: try: return (date.fromisoformat(date_str) - date.today()).days except Exception: return 9999 def _for_profile(appts: List[Dict[str, Any]], profile_id: Optional[str]) -> List[Dict[str, Any]]: if not profile_id: return [] return [a for a in appts if a.get("profile_id") == profile_id] def _is_in_current_window(appt: Dict[str, Any]) -> bool: try: now = datetime.now() appt_date = date.fromisoformat(appt["date"]) time_str = appt.get("time", "00:00").strip() try: appt_time = datetime.strptime(time_str[:8], "%I:%M %p").time() except ValueError: try: appt_time = datetime.strptime(time_str[:5], "%H:%M").time() except ValueError: appt_time = datetime.strptime("00:00", "%H:%M").time() appt_dt = datetime.combine(appt_date, appt_time) return appt_dt <= now <= (appt_dt + timedelta(hours=3)) except Exception: return False def run_lifecycle_check(profile_id: Optional[str] = None) -> int: appts = _load() count = 0 for appt in appts: if profile_id and appt.get("profile_id") != profile_id: continue status = appt.get("status", "scheduled") if status == "scheduled" and _is_in_current_window(appt): appt["status"] = "current" count += 1 elif status == "current" and not _is_in_current_window(appt): appt["status"] = "completed" appt["completed_at"] = _now() count += 1 if count: _save(appts) return count def add_appointment( title: str, appt_date: str, appt_time: str = "", location: str = "", notes: str = "", reminders: Optional[List[int]] = None, recurrence: Optional[Dict[str, Any]] = None, profile_id: str = "", ) -> Dict[str, Any]: if not profile_id: raise ValueError("No active care profile.") if not title.strip(): raise ValueError("Appointment title is required.") if not appt_date.strip(): raise ValueError("Appointment date is required.") try: date.fromisoformat(appt_date.strip()) except ValueError: raise ValueError(f"Invalid date: {appt_date!r}. Use YYYY-MM-DD.") appt = { "id": str(uuid.uuid4()), "profile_id": profile_id, "title": title.strip(), "date": appt_date.strip(), "time": appt_time.strip(), "location": location.strip(), "notes": notes.strip(), "reminders": reminders or [], "recurrence": recurrence if recurrence is not None else _default_recurrence(), "status": "scheduled", "completed_at": "", "created_at": _now(), "updated_at": _now(), } appts = _load() appts.append(appt) _save(appts) return appt def edit_appointment( appt_id: str, title: Optional[str] = None, appt_date: Optional[str] = None, appt_time: Optional[str] = None, location: Optional[str] = None, notes: Optional[str] = None, reminders: Optional[List[int]] = None, recurrence: Optional[Dict[str, Any]] = None, ) -> Optional[Dict[str, Any]]: appts = _load() for appt in appts: if appt["id"] == appt_id: if title is not None: appt["title"] = title.strip() if appt_date is not None: appt["date"] = appt_date.strip() if appt_time is not None: appt["time"] = appt_time.strip() if location is not None: appt["location"] = location.strip() if notes is not None: appt["notes"] = notes.strip() if reminders is not None: appt["reminders"] = reminders if recurrence is not None: appt["recurrence"] = recurrence appt["updated_at"] = _now() _save(appts) return appt return None def complete_appointment(appt_id: str) -> bool: appts = _load() for appt in appts: if appt["id"] == appt_id: appt["status"] = "completed" appt["completed_at"] = _now() appt["updated_at"] = _now() _save(appts) return True return False def delete_appointment(appt_id: str) -> bool: appts = _load() new_appts = [a for a in appts if a["id"] != appt_id] if len(new_appts) == len(appts): return False _save(new_appts) return True def get_all_appointments(include_past: bool = False, profile_id: Optional[str] = None) -> List[Dict[str, Any]]: today = _today_str() appts = [a for a in _for_profile(_load(), profile_id) if a.get("status") != "completed"] if not include_past: appts = [a for a in appts if a.get("date", "") >= today] appts.sort(key=lambda a: (a.get("date", ""), a.get("time", ""))) return appts def get_completed_appointments(profile_id: Optional[str] = None) -> List[Dict[str, Any]]: appts = [a for a in _for_profile(_load(), profile_id) if a.get("status") == "completed"] appts.sort(key=lambda a: a.get("completed_at", ""), reverse=True) return appts def has_conflicting_appointment( appt_date: str, appt_time: str, exclude_id: str = None, profile_id: Optional[str] = None, ) -> bool: for a in _for_profile(_load(), profile_id): if a.get("status") == "completed": continue if exclude_id and a.get("id") == exclude_id: continue if a.get("date") == appt_date and a.get("time") == appt_time: return True return False def get_recent_completed(days: int = 30, profile_id: Optional[str] = None) -> List[Dict[str, Any]]: cutoff_dt = datetime.now() - timedelta(days=days) cutoff = cutoff_dt.isoformat(timespec="seconds") appts = [a for a in _for_profile(_load(), profile_id) if a.get("status") == "completed" and a.get("completed_at", "") >= cutoff] appts.sort(key=lambda a: a.get("completed_at", ""), reverse=True) return appts def get_today(profile_id: Optional[str] = None) -> List[Dict[str, Any]]: today = _today_str() appts = [a for a in _for_profile(_load(), profile_id) if a.get("date", "") == today and a.get("status") != "completed"] appts.sort(key=lambda a: a.get("time", "")) return appts def get_upcoming(profile_id: Optional[str] = None) -> List[Dict[str, Any]]: today = _today_str() appts = [a for a in _for_profile(_load(), profile_id) if a.get("date", "") >= today and a.get("status") != "completed"] appts.sort(key=lambda a: (a.get("date", ""), a.get("time", ""))) return appts def get_appointment_by_id(appt_id: str) -> Optional[Dict[str, Any]]: for a in _load(): if a["id"] == appt_id: return a return None def get_due_reminders(profile_id: Optional[str] = None) -> List[Dict[str, Any]]: results = [] for appt in _for_profile(_load(), profile_id): if appt.get("status") == "completed": continue days = _days_until(appt.get("date", "")) if days >= 0 and days in appt.get("reminders", []): results.append({**appt, "days_until": days}) return results def delete_for_profile(profile_id: str) -> int: appts = _load() kept = [a for a in appts if a.get("profile_id") != profile_id] deleted = len(appts) - len(kept) if deleted: _save(kept) return deleted