| """ | |
| 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 | |