Implement S6 doctor and A1 daily-board with fixtures.

Add operator health checks (make doctor) wrapping platform CLIs, and the fixtures-only daily board skill library with unit tests (make verify).
This commit is contained in:
Ty
2026-07-27 12:41:40 -07:00
parent 0198ab6881
commit 15f6a6c713
24 changed files with 2011 additions and 40 deletions
+121
View File
@@ -0,0 +1,121 @@
"""Deterministic domain types for scheduling / board building.
These are pure data classes — no model inference, no network calls.
See design/det-vs-inf.md for the deterministic boundary.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, time
from enum import Enum
from typing import Optional
class AppointmentStatus(str, Enum):
"""Standardized appointment status."""
CONFIRMED = "confirmed"
PENDING = "pending"
CANCELLED = "cancelled"
COMPLETED = "completed"
NO_SHOW = "no_show"
@dataclass(frozen=True)
class Appointment:
"""A single salon appointment — the core domain object.
Fields match what the daily-board (A1) needs to display:
time, client, service, staff, status, confirmation flag.
"""
appointment_id: str
start_time: datetime
end_time: datetime
client_name: str
service_name: str
staff_name: str
status: AppointmentStatus
notes: str = ""
# Whether the client still needs a confirmation call/message.
# Derived at build time from status + last_contact, but stored here
# for fixture convenience.
needs_confirmation: bool = False
def duration_minutes(self) -> int:
"""Appointment duration in whole minutes."""
delta = self.end_time - self.start_time
return int(delta.total_seconds() // 60)
def start_time_only(self) -> time:
return self.start_time.time()
def end_time_only(self) -> time:
return self.end_time.time()
@dataclass(frozen=True)
class Gap:
"""An unbooked time slot between two appointments (or day boundary)."""
start_time: time
end_time: time
duration_minutes: int
staff_name: str
# The appointment immediately before this gap (if any).
preceding_appointment_id: Optional[str] = None
# The appointment immediately after this gap (if any).
following_appointment_id: Optional[str] = None
@dataclass(frozen=True)
class DayBoard:
"""The complete daily board for one staff member or the whole salon.
This is the structured output that the daily-board skill presents.
All data is deterministic — no model inference.
"""
date: str # YYYY-MM-DD
salon_name: str
source: str # "fixtures" | "offline" | "vagaro" | "square" (future)
is_offline: bool # True when source is fixtures or offline
appointments: list[Appointment] = field(default_factory=list)
gaps: list[Gap] = field(default_factory=list)
needs_confirmation: list[Appointment] = field(default_factory=list)
total_booked_minutes: int = 0
total_gap_minutes: int = 0
def to_dict(self) -> dict:
"""Serialize to a plain dict for JSON output."""
return {
"date": self.date,
"salon_name": self.salon_name,
"source": self.source,
"is_offline": self.is_offline,
"appointments": [
{
"id": a.appointment_id,
"start": a.start_time.isoformat(),
"end": a.end_time.isoformat(),
"client": a.client_name,
"service": a.service_name,
"staff": a.staff_name,
"status": a.status.value,
"needs_confirmation": a.needs_confirmation,
"notes": a.notes,
}
for a in self.appointments
],
"gaps": [
{
"start": g.start_time.isoformat(),
"end": g.end_time.isoformat(),
"duration_minutes": g.duration_minutes,
"staff": g.staff_name,
}
for g in self.gaps
],
"needs_confirmation": [
a.appointment_id for a in self.needs_confirmation
],
"total_booked_minutes": self.total_booked_minutes,
"total_gap_minutes": self.total_gap_minutes,
}