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:
@@ -1,9 +1,31 @@
|
||||
# Shared skill library (scaffold)
|
||||
# Shared skill library
|
||||
|
||||
**Status:** Empty until **build**.
|
||||
**Status:** Partially implemented.
|
||||
|
||||
Planned packages under `providers/`:
|
||||
Shared deterministic library used by Salon_Assistant skills. All code here is
|
||||
**deterministic** — no model inference, no network calls. See
|
||||
[design/det-vs-inf.md](../../../design/det-vs-inf.md).
|
||||
|
||||
- `scheduling/` — Vagaro / Square adapters
|
||||
- `books/` — QuickBooks Online adapters
|
||||
- `mcp/` — MCP client helpers / allowlist metadata
|
||||
## Packages
|
||||
|
||||
| Package | Status | Purpose |
|
||||
|---------|--------|---------|
|
||||
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
|
||||
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
|
||||
| `providers/scheduling/fixture_provider.py` | ✅ | Fixture JSON loader for scheduling data |
|
||||
| `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) |
|
||||
| `providers/books/` | ⏳ | QuickBooks Online adapters (future) |
|
||||
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from lumina_skills.domain import Appointment, AppointmentStatus
|
||||
from lumina_skills.board_builder import build_board, format_board_text
|
||||
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
|
||||
```
|
||||
|
||||
## Design references
|
||||
|
||||
- Deterministic boundary: [design/det-vs-inf.md](../../../design/det-vs-inf.md)
|
||||
- Use cases: [design/use-cases.md](../../../design/use-cases.md)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""lumina_skills — shared deterministic library for Salon_Assistant skills."""
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Deterministic board builder.
|
||||
|
||||
Takes a list of Appointment objects and produces a DayBoard with:
|
||||
- Sorted appointments
|
||||
- Computed gaps between consecutive appointments per staff member
|
||||
- Confirmation flags
|
||||
- Offline/fixture labeling
|
||||
|
||||
All logic is deterministic — no model inference.
|
||||
See design/det-vs-inf.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import warnings
|
||||
from datetime import datetime, time
|
||||
from typing import Any
|
||||
|
||||
from lumina_skills.domain import Appointment, AppointmentStatus, DayBoard, Gap
|
||||
|
||||
|
||||
def build_board(
|
||||
appointments: list[Appointment],
|
||||
date: str,
|
||||
salon_name: str,
|
||||
source: str = "fixtures",
|
||||
business_hours: dict[str, str] | None = None,
|
||||
) -> DayBoard:
|
||||
"""Build a complete DayBoard from a list of appointments.
|
||||
|
||||
Args:
|
||||
appointments: Raw appointment list (from fixtures or live source).
|
||||
date: The board date in YYYY-MM-DD format.
|
||||
salon_name: Display name of the salon.
|
||||
source: Data source label — "fixtures", "offline", "vagaro", "square".
|
||||
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"} to
|
||||
compute gaps at day boundaries.
|
||||
|
||||
Returns:
|
||||
A fully populated DayBoard.
|
||||
"""
|
||||
is_offline = source in ("fixtures", "offline")
|
||||
|
||||
# Filter out cancelled appointments for the board view.
|
||||
active = [a for a in appointments if a.status != AppointmentStatus.CANCELLED]
|
||||
|
||||
# Sort by start time.
|
||||
active.sort(key=lambda a: a.start_time)
|
||||
|
||||
# Compute gaps per staff member.
|
||||
gaps = _compute_gaps(active, date, business_hours)
|
||||
|
||||
# Confirmation flags: pending appointments that need confirmation.
|
||||
needs_confirmation = [a for a in active if a.needs_confirmation]
|
||||
|
||||
# Totals.
|
||||
total_booked = sum(a.duration_minutes() for a in active)
|
||||
total_gap = sum(g.duration_minutes for g in gaps)
|
||||
|
||||
return DayBoard(
|
||||
date=date,
|
||||
salon_name=salon_name,
|
||||
source=source,
|
||||
is_offline=is_offline,
|
||||
appointments=active,
|
||||
gaps=gaps,
|
||||
needs_confirmation=needs_confirmation,
|
||||
total_booked_minutes=total_booked,
|
||||
total_gap_minutes=total_gap,
|
||||
)
|
||||
|
||||
|
||||
def _compute_gaps(
|
||||
appointments: list[Appointment],
|
||||
date: str,
|
||||
business_hours: dict[str, str] | None = None,
|
||||
) -> list[Gap]:
|
||||
"""Compute unbooked gaps between consecutive appointments per staff.
|
||||
|
||||
Gaps are computed per staff member. If business_hours is provided,
|
||||
gaps from open→first appointment and last appointment→close are
|
||||
included (only if >= 30 minutes).
|
||||
|
||||
Args:
|
||||
appointments: Sorted list of active appointments.
|
||||
date: Board date string (YYYY-MM-DD).
|
||||
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"}.
|
||||
|
||||
Returns:
|
||||
List of Gap objects.
|
||||
"""
|
||||
gaps: list[Gap] = []
|
||||
|
||||
# Group appointments by staff.
|
||||
staff_apts: dict[str, list[Appointment]] = {}
|
||||
for apt in appointments:
|
||||
staff_apts.setdefault(apt.staff_name, []).append(apt)
|
||||
|
||||
for staff_name, apts in staff_apts.items():
|
||||
# apts is already sorted by start_time from the caller.
|
||||
open_time = None
|
||||
close_time = None
|
||||
if business_hours:
|
||||
open_str = business_hours.get("open", "")
|
||||
close_str = business_hours.get("close", "")
|
||||
if open_str:
|
||||
open_time = _parse_time_str(open_str)
|
||||
if open_time is None:
|
||||
warnings.warn(
|
||||
f"Invalid business_hours.open format: {open_str!r} "
|
||||
f"(expected HH:MM). Skipping open boundary gap.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if close_str:
|
||||
close_time = _parse_time_str(close_str)
|
||||
if close_time is None:
|
||||
warnings.warn(
|
||||
f"Invalid business_hours.close format: {close_str!r} "
|
||||
f"(expected HH:MM). Skipping close boundary gap.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Gap from open to first appointment.
|
||||
if open_time and apts:
|
||||
first_start = apts[0].start_time_only()
|
||||
gap_mins = _time_diff_minutes(open_time, first_start)
|
||||
if gap_mins >= 30:
|
||||
gaps.append(Gap(
|
||||
start_time=open_time,
|
||||
end_time=first_start,
|
||||
duration_minutes=gap_mins,
|
||||
staff_name=staff_name,
|
||||
following_appointment_id=apts[0].appointment_id,
|
||||
))
|
||||
|
||||
# Gaps between consecutive appointments.
|
||||
for i in range(len(apts) - 1):
|
||||
current_end = apts[i].end_time_only()
|
||||
next_start = apts[i + 1].start_time_only()
|
||||
gap_mins = _time_diff_minutes(current_end, next_start)
|
||||
if gap_mins < 0:
|
||||
warnings.warn(
|
||||
f"Overlapping appointments for {staff_name}: "
|
||||
f"{apts[i].appointment_id} ends at {current_end} but "
|
||||
f"{apts[i + 1].appointment_id} starts at {next_start} "
|
||||
f"({abs(gap_mins)} min overlap). Gap skipped.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
continue
|
||||
if gap_mins >= 30:
|
||||
gaps.append(Gap(
|
||||
start_time=current_end,
|
||||
end_time=next_start,
|
||||
duration_minutes=gap_mins,
|
||||
staff_name=staff_name,
|
||||
preceding_appointment_id=apts[i].appointment_id,
|
||||
following_appointment_id=apts[i + 1].appointment_id,
|
||||
))
|
||||
|
||||
# Gap from last appointment to close.
|
||||
if close_time and apts:
|
||||
last_end = apts[-1].end_time_only()
|
||||
gap_mins = _time_diff_minutes(last_end, close_time)
|
||||
if gap_mins >= 30:
|
||||
gaps.append(Gap(
|
||||
start_time=last_end,
|
||||
end_time=close_time,
|
||||
duration_minutes=gap_mins,
|
||||
staff_name=staff_name,
|
||||
preceding_appointment_id=apts[-1].appointment_id,
|
||||
))
|
||||
|
||||
return gaps
|
||||
|
||||
|
||||
def _parse_time_str(raw: str) -> time | None:
|
||||
"""Parse an HH:MM string into a time object.
|
||||
|
||||
Returns None if the format is invalid (not HH:MM with valid ranges).
|
||||
"""
|
||||
m = re.fullmatch(r"(\d{2}):(\d{2})", raw)
|
||||
if m is None:
|
||||
return None
|
||||
h, mi = int(m.group(1)), int(m.group(2))
|
||||
if h > 23 or mi > 59:
|
||||
return None
|
||||
return time(h, mi)
|
||||
|
||||
|
||||
def _time_diff_minutes(start: time, end: time) -> int:
|
||||
"""Minutes between two time objects (same day assumed).
|
||||
|
||||
Returns a negative value when *end* is before *start* (overlap).
|
||||
Callers should check for negative results and warn.
|
||||
"""
|
||||
diff = datetime.combine(datetime.today(), end) - datetime.combine(datetime.today(), start)
|
||||
return int(diff.total_seconds() // 60)
|
||||
|
||||
|
||||
def format_board_text(board: DayBoard) -> str:
|
||||
"""Format a DayBoard as structured text for chat display.
|
||||
|
||||
This is deterministic formatting — no model inference.
|
||||
The model may rephrase when presenting to the owner, but the
|
||||
facts come from this function.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# Header with offline label.
|
||||
source_label = "📋 FIXTURE DATA" if board.is_offline else "📅 LIVE DATA"
|
||||
lines.append(f"═══ {board.salon_name} — {board.date} ═══")
|
||||
lines.append(f"[{source_label}]")
|
||||
lines.append("")
|
||||
|
||||
# Appointments.
|
||||
lines.append("── Appointments ──")
|
||||
if not board.appointments:
|
||||
lines.append(" No appointments.")
|
||||
else:
|
||||
for apt in board.appointments:
|
||||
start_str = apt.start_time.strftime("%H:%M")
|
||||
end_str = apt.end_time.strftime("%H:%M")
|
||||
status_icon = _status_icon(apt.status)
|
||||
confirm_flag = " ⚠️ CONFIRM" if apt.needs_confirmation else ""
|
||||
lines.append(
|
||||
f" {start_str}–{end_str} {status_icon} {apt.client_name}"
|
||||
f" — {apt.service_name} ({apt.staff_name}){confirm_flag}"
|
||||
)
|
||||
if apt.notes:
|
||||
lines.append(f" 📝 {apt.notes}")
|
||||
lines.append("")
|
||||
|
||||
# Gaps.
|
||||
lines.append("── Gaps (≥30 min) ──")
|
||||
if not board.gaps:
|
||||
lines.append(" No significant gaps.")
|
||||
else:
|
||||
for gap in board.gaps:
|
||||
start_str = gap.start_time.strftime("%H:%M")
|
||||
end_str = gap.end_time.strftime("%H:%M")
|
||||
lines.append(
|
||||
f" {start_str}–{end_str} ({gap.duration_minutes} min) "
|
||||
f"— {gap.staff_name}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Confirmation needed.
|
||||
if board.needs_confirmation:
|
||||
lines.append("── Needs Confirmation ──")
|
||||
for apt in board.needs_confirmation:
|
||||
start_str = apt.start_time.strftime("%H:%M")
|
||||
lines.append(
|
||||
f" ⚠️ {apt.client_name} — {apt.service_name} at {start_str}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Summary.
|
||||
lines.append("── Summary ──")
|
||||
lines.append(f" Booked: {board.total_booked_minutes} min | Gaps: {board.total_gap_minutes} min")
|
||||
lines.append(f" Appointments: {len(board.appointments)} | "
|
||||
f"Need confirmation: {len(board.needs_confirmation)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _status_icon(status: AppointmentStatus) -> str:
|
||||
"""Emoji icon for appointment status."""
|
||||
icons = {
|
||||
AppointmentStatus.CONFIRMED: "✅",
|
||||
AppointmentStatus.PENDING: "⏳",
|
||||
AppointmentStatus.COMPLETED: "✔️",
|
||||
AppointmentStatus.NO_SHOW: "❌",
|
||||
AppointmentStatus.CANCELLED: "🚫",
|
||||
}
|
||||
return icons.get(status, "❓")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Provider adapters for external data sources."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Books provider: QuickBooks Online (future)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""MCP client helpers and allowlist metadata (future)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Scheduling provider: fixtures, Vagaro, Square (future)."""
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Fixture provider for scheduling data.
|
||||
|
||||
Loads appointment fixtures from JSON files under data/fixtures/scheduling/.
|
||||
This is the *only* data source for the daily-board until live SaaS adapters
|
||||
(Vagaro, Square) are implemented.
|
||||
|
||||
All output is labeled `source: fixtures` / `is_offline: True` so the owner
|
||||
never sees silent fake live data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from lumina_skills.domain import Appointment, AppointmentStatus
|
||||
|
||||
|
||||
# Mapping from fixture status strings to domain enum.
|
||||
_STATUS_MAP: dict[str, AppointmentStatus] = {
|
||||
"confirmed": AppointmentStatus.CONFIRMED,
|
||||
"pending": AppointmentStatus.PENDING,
|
||||
"cancelled": AppointmentStatus.CANCELLED,
|
||||
"completed": AppointmentStatus.COMPLETED,
|
||||
"no_show": AppointmentStatus.NO_SHOW,
|
||||
}
|
||||
|
||||
|
||||
def _parse_status(raw: str) -> AppointmentStatus:
|
||||
"""Convert a fixture status string to AppointmentStatus.
|
||||
|
||||
Raises:
|
||||
ValueError: If the status string is not recognized.
|
||||
"""
|
||||
key = raw.lower()
|
||||
if key not in _STATUS_MAP:
|
||||
raise ValueError(
|
||||
f"Unknown appointment status {raw!r}. "
|
||||
f"Expected one of: {', '.join(sorted(_STATUS_MAP))}"
|
||||
)
|
||||
return _STATUS_MAP[key]
|
||||
|
||||
|
||||
def _parse_datetime(raw: str) -> datetime:
|
||||
"""Parse ISO-format datetime strings from fixtures."""
|
||||
return datetime.fromisoformat(raw)
|
||||
|
||||
|
||||
def load_fixtures(fixture_path: str | pathlib.Path) -> list[Appointment]:
|
||||
"""Load appointments from a fixture JSON file.
|
||||
|
||||
Expected top-level shape:
|
||||
```json
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"date": "2026-07-28",
|
||||
"business_hours": {"open": "09:00", "close": "18:00"},
|
||||
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
|
||||
"appointments": [
|
||||
{
|
||||
"id": "APT-001",
|
||||
"start": "2026-07-28T09:00:00",
|
||||
"end": "2026-07-28T10:00:00",
|
||||
"client_name": "Elena Rossi",
|
||||
"service_name": "Balayage + Cut",
|
||||
"staff_name": "Claire Bennett",
|
||||
"status": "confirmed",
|
||||
"needs_confirmation": false,
|
||||
"notes": "Formula: 9.1 + 0-45 gloss"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Args:
|
||||
fixture_path: Path to a JSON fixture file.
|
||||
|
||||
Returns:
|
||||
List of Appointment domain objects.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the fixture file does not exist.
|
||||
ValueError: If the fixture JSON is malformed.
|
||||
"""
|
||||
path = pathlib.Path(fixture_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Fixture not found: {path}")
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
appointments: list[Appointment] = []
|
||||
for apt_raw in raw.get("appointments", []):
|
||||
appointments.append(Appointment(
|
||||
appointment_id=apt_raw["id"],
|
||||
start_time=_parse_datetime(apt_raw["start"]),
|
||||
end_time=_parse_datetime(apt_raw["end"]),
|
||||
client_name=apt_raw["client_name"],
|
||||
service_name=apt_raw["service_name"],
|
||||
staff_name=apt_raw["staff_name"],
|
||||
status=_parse_status(apt_raw.get("status", "pending")),
|
||||
notes=apt_raw.get("notes", ""),
|
||||
needs_confirmation=apt_raw.get("needs_confirmation", False),
|
||||
))
|
||||
|
||||
return appointments
|
||||
|
||||
|
||||
def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]:
|
||||
"""Load non-appointment metadata from a fixture file.
|
||||
|
||||
Returns salon_name, date, business_hours, staff list, etc.
|
||||
"""
|
||||
path = pathlib.Path(fixture_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Fixture not found: {path}")
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"salon_name": raw.get("salon_name", "Unknown Salon"),
|
||||
"date": raw.get("date", ""),
|
||||
"business_hours": raw.get("business_hours", {}),
|
||||
"staff": raw.get("staff", []),
|
||||
}
|
||||
@@ -1,5 +1,47 @@
|
||||
# `daily-board` (scaffold)
|
||||
# `daily-board`
|
||||
|
||||
**Status:** Not implemented. Implementation requires explicit **build**.
|
||||
**Status:** Implemented (fixtures only).
|
||||
|
||||
Intent: see [design/use-cases.md](../../design/use-cases.md) and [design/scenarios.md](../../design/scenarios.md).
|
||||
Builds a structured daily board from scheduling data: appointments, gaps, confirmation flags, and summary.
|
||||
|
||||
## What it does
|
||||
|
||||
- Loads appointment data from fixture JSON files
|
||||
- Computes gaps between appointments (≥30 min)
|
||||
- Flags appointments needing client confirmation
|
||||
- Labels all output as `📋 FIXTURE DATA` — never silent fake live data
|
||||
- Outputs structured text or JSON
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Text output (default)
|
||||
python skills/daily-board/scripts/build_board.py
|
||||
|
||||
# JSON output
|
||||
python skills/daily-board/scripts/build_board.py --format json
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Skill spec and usage |
|
||||
| `scripts/build_board.py` | CLI entrypoint |
|
||||
| `../../skills/_lib/lumina_skills/domain.py` | Domain types |
|
||||
| `../../skills/_lib/lumina_skills/board_builder.py` | Board builder logic |
|
||||
| `../../skills/_lib/lumina_skills/providers/scheduling/fixture_provider.py` | Fixture loader |
|
||||
| `../../data/fixtures/scheduling/` | Fixture JSON files |
|
||||
|
||||
## Design references
|
||||
|
||||
- Use case: [A1 — Morning / day board](../../design/use-cases.md)
|
||||
- Scenario: [S8 — Morning board on WhatsApp](../../design/scenarios.md)
|
||||
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
|
||||
|
||||
## Future
|
||||
|
||||
- Live Vagaro adapter (S5)
|
||||
- Live Square adapter (S3)
|
||||
- Staff filtering
|
||||
- Multi-day boards
|
||||
|
||||
@@ -1,18 +1,95 @@
|
||||
---
|
||||
name: daily-board
|
||||
description: "Today's salon board: appointments, tasks, and priorities"
|
||||
description: "Today's salon board: appointments, gaps, and confirmation flags"
|
||||
domain: operations
|
||||
---
|
||||
|
||||
# daily-board
|
||||
|
||||
Today's salon board: appointments, tasks, and priorities.
|
||||
Today's salon board: appointments, gaps, and confirmation flags.
|
||||
|
||||
## Description
|
||||
|
||||
Pulls together the day's schedule, pending tasks, and key metrics into a single board view for the salon owner.
|
||||
Builds a structured daily board from scheduling data showing:
|
||||
- **Appointments** — time, client, service, staff, status
|
||||
- **Gaps** — unbooked slots ≥30 minutes between appointments
|
||||
- **Confirmation flags** — appointments that still need client confirmation
|
||||
- **Summary** — total booked time, gap time, appointment count
|
||||
|
||||
All data is labeled with its source. When using fixtures, output is clearly
|
||||
marked `📋 FIXTURE DATA` so the owner never sees silent fake live data.
|
||||
|
||||
## Data sources
|
||||
|
||||
| Source | Status | Label in output |
|
||||
|--------|--------|-----------------|
|
||||
| Fixtures (JSON) | ✅ Implemented | `📋 FIXTURE DATA` |
|
||||
| Vagaro | Not yet | `📅 LIVE DATA` (future) |
|
||||
| Square | Not yet | `📅 LIVE DATA` (future) |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Deterministic facts from tools; inference for ranking and wording only.
|
||||
- Deterministic facts from fixtures; no model inference for board data.
|
||||
- No silent send or publish.
|
||||
- Cancelled appointments are excluded from the board view.
|
||||
- Gaps under 30 minutes are not shown (too short for a meaningful slot).
|
||||
- Output always labels fixture/offline — never silent fake live data.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Build board from fixtures (default demo data)
|
||||
python skills/daily-board/scripts/build_board.py
|
||||
|
||||
# Build board for a specific fixture file
|
||||
python skills/daily-board/scripts/build_board.py \
|
||||
--fixtures data/fixtures/scheduling/claire_bennett_2026-07-28.json
|
||||
|
||||
# Output as JSON
|
||||
python skills/daily-board/scripts/build_board.py --format json
|
||||
|
||||
# Specify date and salon name explicitly
|
||||
python skills/daily-board/scripts/build_board.py \
|
||||
--date 2026-07-28 --salon "Lumina Hair Studio & Spa"
|
||||
```
|
||||
|
||||
### Programmatic
|
||||
|
||||
```python
|
||||
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
|
||||
from lumina_skills.board_builder import build_board, format_board_text
|
||||
|
||||
appointments = load_fixtures("data/fixtures/scheduling/claire_bennett_2026-07-28.json")
|
||||
board = build_board(appointments, date="2026-07-28", salon_name="Lumina Hair Studio & Spa")
|
||||
print(format_board_text(board))
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
### Text (default)
|
||||
|
||||
Structured text with sections for appointments, gaps, confirmation flags, and summary.
|
||||
|
||||
### JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-28",
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"source": "fixtures",
|
||||
"is_offline": true,
|
||||
"appointments": [...],
|
||||
"gaps": [...],
|
||||
"needs_confirmation": [...],
|
||||
"total_booked_minutes": 420,
|
||||
"total_gap_minutes": 120
|
||||
}
|
||||
```
|
||||
|
||||
## Design references
|
||||
|
||||
- Use case: [A1 — Morning / day board](../../design/use-cases.md)
|
||||
- Scenario: [S8 — Morning board on WhatsApp](../../design/scenarios.md)
|
||||
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a daily board from scheduling fixtures.
|
||||
|
||||
Usage:
|
||||
python build_board.py [--fixtures PATH] [--date YYYY-MM-DD] [--salon NAME] [--format text|json]
|
||||
|
||||
All data is labeled as fixture/offline — never silent fake live data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
# Ensure the _lib package is importable regardless of cwd.
|
||||
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(_REPO_ROOT / "skills" / "_lib"))
|
||||
|
||||
from lumina_skills.providers.scheduling.fixture_provider import (
|
||||
load_fixtures,
|
||||
load_fixture_metadata,
|
||||
)
|
||||
from lumina_skills.board_builder import build_board, format_board_text
|
||||
|
||||
# Default fixture: Claire Bennett sample day.
|
||||
_DEFAULT_FIXTURE = _REPO_ROOT / "data" / "fixtures" / "scheduling" / "claire_bennett_2026-07-28.json"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build a daily salon board from fixture data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fixtures",
|
||||
type=pathlib.Path,
|
||||
default=_DEFAULT_FIXTURE,
|
||||
help="Path to fixture JSON file (default: Claire Bennett sample day).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override board date (YYYY-MM-DD). Defaults to fixture date.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--salon",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override salon name. Defaults to fixture salon_name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load fixture data.
|
||||
try:
|
||||
appointments = load_fixtures(args.fixtures)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
||||
print(f"Error parsing fixture: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Load metadata for defaults.
|
||||
try:
|
||||
meta = load_fixture_metadata(args.fixtures)
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
||||
print(f"Warning: could not parse fixture metadata: {exc}", file=sys.stderr)
|
||||
meta = {}
|
||||
|
||||
date = args.date or meta.get("date", "unknown")
|
||||
salon_name = args.salon or meta.get("salon_name", "Unknown Salon")
|
||||
business_hours = meta.get("business_hours")
|
||||
|
||||
# Build the board.
|
||||
board = build_board(
|
||||
appointments=appointments,
|
||||
date=date,
|
||||
salon_name=salon_name,
|
||||
source="fixtures",
|
||||
business_hours=business_hours,
|
||||
)
|
||||
|
||||
# Output.
|
||||
if args.format == "json":
|
||||
print(json.dumps(board.to_dict(), indent=2))
|
||||
else:
|
||||
print(format_board_text(board))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user