diff --git a/Makefile b/Makefile index 16879fd..24588ee 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,8 @@ help: @echo " make install - full install S0b–S5" @echo " make install-s0-s2 - install stages S0b through S2" @echo " make install-s3-s5 - install stages S3 through S5 (stack, sandbox, policy, skills)" + @echo " make doctor - health checks (S6)" + @echo " make verify - unit tests (fixtures, no model)" @echo "" @echo "Staged install:" @echo " make install-s0b - S0b only: Docker bootstrap" @@ -23,8 +25,6 @@ help: @echo "" @echo "Not yet implemented (stubbed):" @echo " make upgrade - product upgrade" - @echo " make doctor - health checks" - @echo " make verify - lint + tests + smoke (fixtures)" @echo " make sync-design - list design pack paths" # ── Implemented targets ──────────────────────────────────────────────────── @@ -61,10 +61,23 @@ install-s4: install-s5: @bash scripts/install.sh --stage s5 -# ── Stubbed targets (S6+ not yet implemented) ────────────────────────────── +# ── S6: Doctor ───────────────────────────────────────────────────────────── -upgrade doctor verify: - @echo "not implemented — S6+ stages pending" >&2; exit 1 +doctor: + @bash scripts/doctor.sh + +# ── Stubbed targets ──────────────────────────────────────────────────────── + +upgrade: + @echo "not implemented — upgrade pending" >&2; exit 1 + +# ── Verify ───────────────────────────────────────────────────────────────── + +verify: + @echo "Running unit tests..." + @python3 -m pytest tests/unit/ -v + @echo "" + @echo "verify: OK" sync-design: @echo "Design SSOT:" diff --git a/data/fixtures/README.md b/data/fixtures/README.md index e7a30f7..d715cb3 100644 --- a/data/fixtures/README.md +++ b/data/fixtures/README.md @@ -1,11 +1,44 @@ -# Fixtures (scaffold) +# Fixtures -Demo persona when implemented: **Claire Bennett**, **Lumina Hair Studio & Spa**. +Demo persona: **Claire Bennett**, **Lumina Hair Studio & Spa**. + +All fixture data is clearly labeled in skill output — never silent fake live data. | Path | Purpose | |------|---------| -| `books/` | Sample QBO-shaped JSON | +| `scheduling/` | Sample appointment data for daily-board | +| `books/` | Sample QBO-shaped JSON (future) | | `media/` | Sample social media assets for vision tests | | `../recorded/` | Optional recorded responses (local only; do not commit secrets) | -No fixture JSON committed until **build** unless explicitly requested. +## Scheduling fixtures + +| File | Description | +|------|-------------| +| `scheduling/claire_bennett_2026-07-28.json` | Sample salon day: 7 appointments (2 staff, 1 cancelled) | + +### Fixture schema + +```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:30: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" + } + ] +} +``` + +**Status values:** `confirmed`, `pending`, `cancelled`, `completed`, `no_show` diff --git a/data/fixtures/scheduling/claire_bennett_2026-07-28.json b/data/fixtures/scheduling/claire_bennett_2026-07-28.json new file mode 100644 index 0000000..9b4de12 --- /dev/null +++ b/data/fixtures/scheduling/claire_bennett_2026-07-28.json @@ -0,0 +1,97 @@ +{ + "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" + }, + { + "name": "Maya Torres", + "role": "colorist" + } + ], + "appointments": [ + { + "id": "APT-001", + "start": "2026-07-28T09:00:00", + "end": "2026-07-28T10:30: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. Allergic to ammonia." + }, + { + "id": "APT-002", + "start": "2026-07-28T10:30:00", + "end": "2026-07-28T11:30:00", + "client_name": "Sarah Kim", + "service_name": "Blowout + Updo", + "staff_name": "Claire Bennett", + "status": "pending", + "needs_confirmation": true, + "notes": "Wedding guest — updo reference photo sent." + }, + { + "id": "APT-003", + "start": "2026-07-28T11:00:00", + "end": "2026-07-28T12:30:00", + "client_name": "Jasmine Patel", + "service_name": "Root Touch-Up", + "staff_name": "Maya Torres", + "status": "confirmed", + "needs_confirmation": false, + "notes": "2B dark brown. Regular client." + }, + { + "id": "APT-004", + "start": "2026-07-28T13:00:00", + "end": "2026-07-28T14:00:00", + "client_name": "Chris Nguyen", + "service_name": "Men's Cut", + "staff_name": "Claire Bennett", + "status": "pending", + "needs_confirmation": true, + "notes": "Running late from work — may be 15 min behind." + }, + { + "id": "APT-005", + "start": "2026-07-28T14:00:00", + "end": "2026-07-28T15:30:00", + "client_name": "Priya Sharma", + "service_name": "Deep Conditioning Treatment", + "staff_name": "Maya Torres", + "status": "confirmed", + "needs_confirmation": false, + "notes": "Post-color repair. Keratin-safe product only." + }, + { + "id": "APT-006", + "start": "2026-07-28T15:30:00", + "end": "2026-07-28T17:00:00", + "client_name": "Aisha Williams", + "service_name": "Full Color + Style", + "staff_name": "Claire Bennett", + "status": "confirmed", + "needs_confirmation": false, + "notes": "First visit — consultation included." + }, + { + "id": "APT-007", + "start": "2026-07-28T12:30:00", + "end": "2026-07-28T13:00:00", + "client_name": "Tom Bradley", + "service_name": "Quick Trim", + "staff_name": "Maya Torres", + "status": "cancelled", + "needs_confirmation": false, + "notes": "Client cancelled — rescheduled to next week." + } + ] +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e8050fc..596837a 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,6 +1,6 @@ # Install -**Status:** Stages S0–S5 implemented. S6–S7 pending. +**Status:** Stages S0–S6 implemented. S7 pending. ## Stages @@ -13,7 +13,7 @@ | S3 | Host script | Stack alignment (compose docs; OpenShell owns sandbox) | ✅ Implemented | | S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented | | S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented | -| S6 | Host script | Doctor green | ⏳ Pending | +| S6 | Host script | Doctor green | ✅ Implemented | | S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ⏳ Pending | ## Platform commands (normative) @@ -156,13 +156,14 @@ make install make install-s3-s5 ``` -## After install (S0–S5) +## After install (S0–S6) - Verify `.env` values are correct for your environment. - Check policy: `nemohermes policy-list` -- Continue with S6 (doctor) when implemented. +- Run health checks: `make doctor` - See [SETUP_UX.md](SETUP_UX.md) for owner-facing setup after full install. - See [design/scenarios.md](../design/scenarios.md) (S1–S5) for operational scenarios. +- See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands. ## UAT host notes diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index b6415f1..8f98280 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -1,14 +1,54 @@ # Operations (day-2) -**Status:** Outline. +**Status:** Doctor (S6) implemented. -## Operator commands (when implemented) +## Health checks (doctor) ```bash +# Run all health checks (human-readable) +make doctor +# or ./scripts/doctor.sh + +# Machine-readable JSON summary +./scripts/doctor.sh --json +``` + +**What doctor checks:** + +| Check | What it validates | Severity | +|-------|-------------------|----------| +| Docker | Daemon running and accessible | Critical | +| nemohermes CLI | Installed and versioned | Critical | +| openshell CLI | Installed and versioned | Critical | +| Sandbox | `nemohermes doctor` healthy | Critical | +| Policy | `lumina-inference` preset applied | Warning | +| Skills | Skill directories with SKILL.md present | Warning | +| Inference endpoint | `/v1/models` reachable from .env URL | Critical | +| Inference gateway | `openshell inference get` configured | Warning | + +**Exit codes:** `0` = all critical checks passed; `1` = one or more critical failures. + +## Platform diagnostics + +```bash +# Sandbox status nemohermes status + +# Sandbox doctor (platform-level) +nemohermes doctor + +# Sandbox logs nemohermes logs --follow -docker compose -f deploy/compose/docker-compose.yml logs + +# Gateway status +openshell status + +# Inference config +openshell inference get + +# Policy presets +nemohermes policy-list ``` ## Log levels diff --git a/scripts/README.md b/scripts/README.md index 073d86a..3a54012 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,6 @@ # Host scripts -**Status:** S0b–S5 implemented. S6–S7 pending. +**Status:** S0b–S6 implemented. S7 pending. All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API. @@ -14,8 +14,8 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP | `install/s2-models.sh` | S2: model + vision config + smoke | ✅ S2 | | `install/s4-sandbox.sh` | S4: sandbox verify (attach) or onboard | ✅ S4 | | `install/s5-policy-skills.sh` | S5: policy overlays + skills sync | ✅ S5 | +| `doctor.sh` | Health checks (Docker, CLIs, sandbox, policy, skills, inference) | ✅ S6 | | `upgrade.sh` | Snapshot, pull pins, migrate, re-apply policy, doctor | ⏳ Pending | -| `doctor.sh` | Health checks | ⏳ Pending | | `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) | ⏳ Pending | ## Shared library @@ -42,6 +42,10 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP ./scripts/install.sh --stage s5 # policy + skills ./scripts/install.sh --stage s3-s5 # S3 through S5 +# Health checks (S6) +./scripts/doctor.sh # human-readable +./scripts/doctor.sh --json # machine-readable + # Or via Make make bootstrap make install @@ -49,6 +53,7 @@ make install-s1 make install-s2 make install-s3-s5 make install-s5 +make doctor ``` ## Design reference diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..20a6d62 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash +# scripts/doctor.sh — S6: Lumina product health checks +# +# Composes platform-layer health checks: +# Docker · nemohermes · openshell · sandbox · policy · skills · inference +# +# Platform-first: wraps nemohermes / openshell / Docker. No parallel control API. +# +# Usage: +# ./scripts/doctor.sh # full check +# ./scripts/doctor.sh --json # machine-readable summary +# ./scripts/doctor.sh --help + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source shared helpers +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck source=lib/env.sh +source "$SCRIPT_DIR/lib/env.sh" + +# ── Defaults ─────────────────────────────────────────────────────────────── +JSON_OUTPUT=0 + +# ── Parse args ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + cat </dev/null 2>&1 || true +else + load_env 2>/dev/null || true +fi + +SANDBOX_NAME="$(get_sandbox_name)" +SKILLS_DIR="$REPO_ROOT/skills" +POLICY_DIR="$REPO_ROOT/policy/openshell/overlays" + +# ── Check: Docker ────────────────────────────────────────────────────────── +check_docker() { + if ! cmd_exists docker; then + record_check "Docker" "CLI" "FAIL" "docker command not found" + return + fi + if ! docker info &>/dev/null; then + record_check "Docker" "Daemon" "FAIL" "docker daemon not running or not accessible" + return + fi + local version + version="$(docker --version 2>/dev/null | sed 's/^Docker version //' | cut -d',' -f1 | tr -d ' ')" + record_check "Docker" "Daemon" "OK" "running ($version)" +} + +# ── Check: nemohermes CLI ────────────────────────────────────────────────── +check_nemohermes() { + if ! cmd_exists nemohermes; then + record_check "CLI" "nemohermes" "FAIL" "nemohermes not found — install NemoClaw platform" + return + fi + local version + version="$(nemohermes --version 2>/dev/null | head -1 | grep -oP 'v[\d.]+' || echo 'unknown')" + record_check "CLI" "nemohermes" "OK" "$version" +} + +# ── Check: openshell CLI ─────────────────────────────────────────────────── +check_openshell() { + if ! cmd_exists openshell; then + record_check "CLI" "openshell" "FAIL" "openshell not found — install OpenShell" + return + fi + local version + version="$(openshell --version 2>/dev/null | head -1 | grep -oP '[\d.]+' || echo 'unknown')" + record_check "CLI" "openshell" "OK" "$version" +} + +# ── Check: Sandbox status ────────────────────────────────────────────────── +check_sandbox() { + # Skip if nemohermes is missing (already flagged) + if ! cmd_exists nemohermes; then + record_check "Sandbox" "Status" "FAIL" "skipped — nemohermes not available" + return + fi + + # Run nemohermes doctor for the sandbox — this is the authoritative platform check + local doctor_output + doctor_output="$(nemohermes "$SANDBOX_NAME" doctor 2>&1)" || true + + # Check for summary line + if echo "$doctor_output" | grep -qi "Summary: healthy"; then + record_check "Sandbox" "Doctor" "OK" "$SANDBOX_NAME healthy" + elif echo "$doctor_output" | grep -qi "Summary:.*warning"; then + record_check "Sandbox" "Doctor" "WARN" "$SANDBOX_NAME has warnings" + elif echo "$doctor_output" | grep -qi "Summary:.*unhealthy\|Summary:.*critical"; then + record_check "Sandbox" "Doctor" "FAIL" "$SANDBOX_NAME unhealthy" + else + # Fallback: check status command + if nemohermes "$SANDBOX_NAME" status &>/dev/null; then + record_check "Sandbox" "Status" "OK" "$SANDBOX_NAME reachable" + else + record_check "Sandbox" "Status" "FAIL" "$SANDBOX_NAME not found or not reachable" + fi + fi +} + +# ── Check: Policy overlays ───────────────────────────────────────────────── +check_policy() { + # Skip if nemohermes is missing + if ! cmd_exists nemohermes; then + record_check "Policy" "Overlays" "FAIL" "skipped — nemohermes not available" + return + fi + + # Check that policy overlay files exist in the repo + local inference_policy="$POLICY_DIR/inference.yaml" + if [[ ! -f "$inference_policy" ]]; then + record_check "Policy" "Overlay files" "WARN" "inference.yaml not found in $POLICY_DIR" + fi + + # Check that lumina-inference preset is applied in the sandbox + local policy_list + policy_list="$(nemohermes "$SANDBOX_NAME" policy-list 2>&1)" || { + record_check "Policy" "policy-list" "FAIL" "could not list policy presets" + return + } + + if echo "$policy_list" | grep -q "lumina-inference"; then + record_check "Policy" "lumina-inference" "OK" "preset applied" + else + record_check "Policy" "lumina-inference" "WARN" "preset not found in sandbox — run S5 or: nemohermes $SANDBOX_NAME policy-add --from-file $inference_policy --yes" + fi +} + +# ── Check: Skills ────────────────────────────────────────────────────────── +check_skills() { + if [[ ! -d "$SKILLS_DIR" ]]; then + record_check "Skills" "Directory" "FAIL" "skills directory not found at $SKILLS_DIR" + return + fi + + # Count skill directories (exclude _lib and hidden) + local skill_count=0 + local skill_with_md=0 + for skill_dir in "$SKILLS_DIR"/*/; do + [[ -d "$skill_dir" ]] || continue + local name + name="$(basename "$skill_dir")" + [[ "$name" == "_lib" ]] && continue + [[ "$name" == "README.md" ]] && continue + skill_count=$((skill_count + 1)) + if [[ -f "$skill_dir/SKILL.md" ]]; then + skill_with_md=$((skill_with_md + 1)) + fi + done + + if [[ $skill_count -eq 0 ]]; then + record_check "Skills" "Pack" "WARN" "no skill directories found in $SKILLS_DIR" + elif [[ $skill_with_md -lt $skill_count ]]; then + record_check "Skills" "Pack" "WARN" "$skill_with_md/$skill_count skills have SKILL.md" + else + record_check "Skills" "Pack" "OK" "$skill_count skills with SKILL.md" + fi +} + +# ── Check: Inference endpoint ────────────────────────────────────────────── +check_inference() { + # Check .env has the required keys + local env_file="${REPO_ROOT}/.env" + if [[ ! -f "$env_file" ]]; then + record_check "Inference" "Config" "FAIL" ".env not found — run S1 first" + return + fi + + # Source .env to get variables (already done by load_env, but verify) + local base_url="${LUMINA_INFERENCE_BASE_URL:-}" + local model="${LUMINA_INFERENCE_MODEL:-}" + + if [[ -z "$base_url" ]]; then + record_check "Inference" "Endpoint URL" "FAIL" "LUMINA_INFERENCE_BASE_URL not set in .env" + return + fi + + if [[ -z "$model" ]]; then + record_check "Inference" "Model" "FAIL" "LUMINA_INFERENCE_MODEL not set in .env" + return + fi + + # Check endpoint reachability + local url="$base_url" + # Ensure URL ends with /v1 for the models endpoint + if [[ "$url" != */v1 && "$url" != */v1/* ]]; then + url="${url%/}/v1" + fi + + if curl -sf --max-time 15 "${url}/models" &>/dev/null; then + record_check "Inference" "Endpoint" "OK" "reachable ($base_url)" + else + record_check "Inference" "Endpoint" "FAIL" "unreachable at $base_url" + fi + + # Check openshell inference config (optional — gateway may not be connected) + if cmd_exists openshell; then + local inf_output + inf_output="$(openshell inference get 2>&1)" || true + if echo "$inf_output" | grep -q "Provider:"; then + local provider + # Strip ANSI escape codes and whitespace + provider="$(echo "$inf_output" | grep "Provider:" | head -1 | sed 's/.*Provider: *//' | sed 's/\x1b\[[0-9;]*m//g' | tr -d '[:space:]')" + record_check "Inference" "Gateway route" "OK" "configured ($provider)" + else + record_check "Inference" "Gateway route" "WARN" "not configured via openshell" + fi + fi +} + +# ── Run all checks ───────────────────────────────────────────────────────── +if [[ $JSON_OUTPUT -eq 0 ]]; then + log_section "Lumina Doctor (S6)" +fi + +check_docker +check_nemohermes +check_openshell +check_sandbox +check_policy +check_skills +check_inference + +# ── Output results ───────────────────────────────────────────────────────── +if [[ $JSON_OUTPUT -eq 1 ]]; then + # Machine-readable JSON summary + checks_json="[" + first=1 + for entry in "${CHECK_RESULTS[@]}"; do + IFS='|' read -r group label status detail <<< "$entry" + if [[ $first -eq 1 ]]; then + first=0 + else + checks_json+="," + fi + # Escape backslashes first, then double quotes (JSON-safe) + detail="${detail//\\/\\\\}" + detail="${detail//\"/\\\"}" + checks_json+="{\"group\":\"$group\",\"label\":\"$label\",\"status\":\"$status\",\"detail\":\"$detail\"}" + done + checks_json+="]" + + overall="healthy" + [[ $CRITICAL_FAIL -eq 1 ]] && overall="unhealthy" + + cat < 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, "❓") diff --git a/skills/_lib/lumina_skills/domain.py b/skills/_lib/lumina_skills/domain.py new file mode 100644 index 0000000..8eb9d62 --- /dev/null +++ b/skills/_lib/lumina_skills/domain.py @@ -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, + } diff --git a/skills/_lib/lumina_skills/providers/__init__.py b/skills/_lib/lumina_skills/providers/__init__.py new file mode 100644 index 0000000..1cc3233 --- /dev/null +++ b/skills/_lib/lumina_skills/providers/__init__.py @@ -0,0 +1 @@ +"""Provider adapters for external data sources.""" diff --git a/skills/_lib/lumina_skills/providers/books/__init__.py b/skills/_lib/lumina_skills/providers/books/__init__.py new file mode 100644 index 0000000..54024af --- /dev/null +++ b/skills/_lib/lumina_skills/providers/books/__init__.py @@ -0,0 +1 @@ +"""Books provider: QuickBooks Online (future).""" diff --git a/skills/_lib/lumina_skills/providers/mcp/__init__.py b/skills/_lib/lumina_skills/providers/mcp/__init__.py new file mode 100644 index 0000000..c332517 --- /dev/null +++ b/skills/_lib/lumina_skills/providers/mcp/__init__.py @@ -0,0 +1 @@ +"""MCP client helpers and allowlist metadata (future).""" diff --git a/skills/_lib/lumina_skills/providers/scheduling/__init__.py b/skills/_lib/lumina_skills/providers/scheduling/__init__.py new file mode 100644 index 0000000..9ae1851 --- /dev/null +++ b/skills/_lib/lumina_skills/providers/scheduling/__init__.py @@ -0,0 +1 @@ +"""Scheduling provider: fixtures, Vagaro, Square (future).""" diff --git a/skills/_lib/lumina_skills/providers/scheduling/fixture_provider.py b/skills/_lib/lumina_skills/providers/scheduling/fixture_provider.py new file mode 100644 index 0000000..3cd084f --- /dev/null +++ b/skills/_lib/lumina_skills/providers/scheduling/fixture_provider.py @@ -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", []), + } diff --git a/skills/daily-board/README.md b/skills/daily-board/README.md index 7aeaf9c..8ecf3be 100644 --- a/skills/daily-board/README.md +++ b/skills/daily-board/README.md @@ -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 diff --git a/skills/daily-board/SKILL.md b/skills/daily-board/SKILL.md index 59514aa..7cc1af5 100644 --- a/skills/daily-board/SKILL.md +++ b/skills/daily-board/SKILL.md @@ -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) diff --git a/skills/daily-board/scripts/build_board.py b/skills/daily-board/scripts/build_board.py new file mode 100644 index 0000000..affcab6 --- /dev/null +++ b/skills/daily-board/scripts/build_board.py @@ -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()) diff --git a/tests/README.md b/tests/README.md index ef5c4f1..1a4ca43 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,12 +1,22 @@ -# Tests (scaffold) +# Tests -**Status:** Structure only — no product tests until **build**. +| Path | Purpose | Status | +|------|---------|--------| +| `unit/` | Deterministic domain/skill logic (no live model required) | ✅ Partial | +| `contract/` | Provider/MCP allow-deny contracts | ⏳ | +| `integration/` | Optional cheap-model dialogue paths | ⏳ | +| `fixtures/` | Test-only fixtures | ⏳ | -| Path | Purpose | -|------|---------| -| `unit/` | Deterministic domain/skill logic (no live model required) | -| `contract/` | Provider/MCP allow-deny contracts | -| `integration/` | Optional cheap-model dialogue paths | -| `fixtures/` | Test-only fixtures | +## Running tests -Design boundary: [design/det-vs-inf.md](../design/det-vs-inf.md). +```bash +# All unit tests +python -m pytest tests/unit/ -v + +# Specific test file +python -m pytest tests/unit/test_board_builder.py -v +``` + +## Design boundary + +[design/det-vs-inf.md](../design/det-vs-inf.md) — unit tests cover the deterministic column without a live model. diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..f7ac707 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for Salon_Assistant deterministic logic.""" diff --git a/tests/unit/test_board_builder.py b/tests/unit/test_board_builder.py new file mode 100644 index 0000000..7485f68 --- /dev/null +++ b/tests/unit/test_board_builder.py @@ -0,0 +1,306 @@ +"""Tests for the deterministic board builder.""" + +from __future__ import annotations + +from datetime import datetime, time + +import pytest + +import sys +import pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib")) + +from lumina_skills.domain import ( + Appointment, + AppointmentStatus, + DayBoard, + Gap, +) +from lumina_skills.board_builder import ( + build_board, + format_board_text, +) + + +def _apt( + apt_id: str, + start_h: int, + start_m: int, + end_h: int, + end_m: int, + client: str = "Client", + service: str = "Service", + staff: str = "Staff", + status: AppointmentStatus = AppointmentStatus.CONFIRMED, + needs_confirmation: bool = False, + notes: str = "", +) -> Appointment: + """Helper to create an Appointment quickly.""" + return Appointment( + appointment_id=apt_id, + start_time=datetime(2026, 7, 28, start_h, start_m), + end_time=datetime(2026, 7, 28, end_h, end_m), + client_name=client, + service_name=service, + staff_name=staff, + status=status, + notes=notes, + needs_confirmation=needs_confirmation, + ) + + +# ── build_board ──────────────────────────────────────────────────────────── + +def test_build_board_basic(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + _apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon", source="fixtures") + assert len(board.appointments) == 2 + assert board.total_booked_minutes == 120 + assert board.is_offline is True + + +def test_build_board_excludes_cancelled(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + _apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire", status=AppointmentStatus.CANCELLED), + _apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + assert len(board.appointments) == 2 # Cancelled excluded + assert board.appointments[0].appointment_id == "A1" + assert board.appointments[1].appointment_id == "A3" + + +def test_build_board_sorts_by_time(): + apts = [ + _apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"), + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + assert board.appointments[0].appointment_id == "A1" + assert board.appointments[1].appointment_id == "A2" + + +def test_build_board_confirmation_flags(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", needs_confirmation=False), + _apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire", needs_confirmation=True), + _apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire", needs_confirmation=True), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + assert len(board.needs_confirmation) == 2 + assert board.needs_confirmation[0].appointment_id == "A2" + assert board.needs_confirmation[1].appointment_id == "A3" + + +def test_build_board_gaps_between_apts(): + """Gap between two appointments on the same staff.""" + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + _apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + assert len(board.gaps) == 1 + gap = board.gaps[0] + assert gap.start_time == time(10, 0) + assert gap.end_time == time(11, 0) + assert gap.duration_minutes == 60 + assert gap.staff_name == "Claire" + + +def test_build_board_no_small_gaps(): + """Gaps under 30 minutes are not included.""" + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + _apt("A2", 10, 15, 11, 15, "Bob", "Color", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + # 15-minute gap should be excluded. + assert len(board.gaps) == 0 + + +def test_build_board_overlapping_appointments_warns(): + """Overlapping appointments emit a warning and skip the negative gap.""" + apts = [ + _apt("A1", 9, 0, 10, 30, "Alice", "Cut", "Claire"), + _apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"), # starts 30 min before A1 ends + ] + with pytest.warns(UserWarning, match="Overlapping appointments"): + board = build_board(apts, "2026-07-28", "Test Salon") + # The negative gap should not appear in the board. + assert all(g.duration_minutes >= 0 for g in board.gaps) + # Both appointments still appear (overlap is a data issue, not a filter). + assert len(board.appointments) == 2 + + +def test_build_board_boundary_gaps(): + """Gaps from open→first and last→close when business_hours provided.""" + apts = [ + _apt("A1", 10, 0, 11, 0, "Alice", "Cut", "Claire"), + _apt("A2", 15, 0, 16, 0, "Bob", "Color", "Claire"), + ] + board = build_board( + apts, "2026-07-28", "Test Salon", + business_hours={"open": "09:00", "close": "18:00"}, + ) + # Should have: 09:00-10:00 (60 min), 11:00-15:00 (240 min), 16:00-18:00 (120 min) + assert len(board.gaps) == 3 + assert board.gaps[0].start_time == time(9, 0) + assert board.gaps[0].end_time == time(10, 0) + assert board.gaps[2].start_time == time(16, 0) + assert board.gaps[2].end_time == time(18, 0) + + +def test_build_board_multi_staff_gaps(): + """Gaps are computed per staff member.""" + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + _apt("A2", 9, 0, 10, 0, "Bob", "Color", "Maya"), + _apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire"), + _apt("A4", 11, 0, 12, 0, "Dave", "Trim", "Maya"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + # Each staff has a 60-min gap. + assert len(board.gaps) == 2 + staff_gaps = {g.staff_name: g.duration_minutes for g in board.gaps} + assert staff_gaps["Claire"] == 60 + assert staff_gaps["Maya"] == 60 + + +def test_build_board_invalid_business_hours_warns(): + """Malformed business_hours values warn and skip boundary gaps.""" + apts = [ + _apt("A1", 10, 0, 11, 0, "Alice", "Cut", "Claire"), + ] + with pytest.warns(UserWarning, match="Invalid business_hours"): + board = build_board( + apts, "2026-07-28", "Test Salon", + business_hours={"open": "nine", "close": "18:00"}, + ) + # Only the close boundary gap should appear (open was invalid). + assert len(board.gaps) == 1 + assert board.gaps[0].start_time == time(11, 0) + assert board.gaps[0].end_time == time(18, 0) + + +def test_build_board_empty(): + board = build_board([], "2026-07-28", "Empty Salon") + assert len(board.appointments) == 0 + assert len(board.gaps) == 0 + assert board.total_booked_minutes == 0 + assert board.total_gap_minutes == 0 + + +def test_build_board_source_labeling(): + """Source is correctly set and is_offline derived.""" + board = build_board([], "2026-07-28", "Test", source="fixtures") + assert board.source == "fixtures" + assert board.is_offline is True + + board2 = build_board([], "2026-07-28", "Test", source="offline") + assert board2.is_offline is True + + board3 = build_board([], "2026-07-28", "Test", source="vagaro") + assert board3.is_offline is False + + +def test_build_board_totals(): + apts = [ + _apt("A1", 9, 0, 10, 30, "Alice", "Cut", "Claire"), # 90 min + _apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"), # 60 min + ] + board = build_board( + apts, "2026-07-28", "Test Salon", + business_hours={"open": "09:00", "close": "18:00"}, + ) + assert board.total_booked_minutes == 150 + # Gaps: 10:30-11:00 (30 min), 12:00-18:00 (360 min) + assert board.total_gap_minutes == 390 + + +# ── format_board_text ────────────────────────────────────────────────────── + +def test_format_text_includes_offline_label(): + board = DayBoard( + date="2026-07-28", + salon_name="Test Salon", + source="fixtures", + is_offline=True, + ) + text = format_board_text(board) + assert "FIXTURE DATA" in text + + +def test_format_text_includes_appointments(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + text = format_board_text(board) + assert "Alice" in text + assert "Cut" in text + assert "Claire" in text + assert "09:00" in text + + +def test_format_text_includes_gaps(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + _apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + text = format_board_text(board) + assert "Gaps" in text + assert "60 min" in text + + +def test_format_text_includes_confirmation_flags(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", needs_confirmation=True), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + text = format_board_text(board) + assert "CONFIRM" in text + assert "Needs Confirmation" in text + + +def test_format_text_includes_summary(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + text = format_board_text(board) + assert "Summary" in text + assert "60 min" in text + + +def test_format_text_no_appointments(): + board = DayBoard( + date="2026-07-28", + salon_name="Empty Salon", + source="fixtures", + is_offline=True, + ) + text = format_board_text(board) + assert "No appointments" in text + + +def test_format_text_no_gaps(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + text = format_board_text(board) + assert "No significant gaps" in text + + +def test_format_text_notes(): + apts = [ + _apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", notes="Allergic to ammonia"), + ] + board = build_board(apts, "2026-07-28", "Test Salon") + text = format_board_text(board) + assert "Allergic to ammonia" in text diff --git a/tests/unit/test_domain.py b/tests/unit/test_domain.py new file mode 100644 index 0000000..ea4c75b --- /dev/null +++ b/tests/unit/test_domain.py @@ -0,0 +1,182 @@ +"""Tests for lumina_skills.domain types.""" + +from __future__ import annotations + +import json +from datetime import datetime, time + +import pytest + +import sys +import pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib")) + +from lumina_skills.domain import ( + Appointment, + AppointmentStatus, + DayBoard, + Gap, +) + + +# ── Appointment ──────────────────────────────────────────────────────────── + +def test_appointment_duration(): + apt = Appointment( + appointment_id="APT-001", + start_time=datetime(2026, 7, 28, 9, 0), + end_time=datetime(2026, 7, 28, 10, 30), + client_name="Elena Rossi", + service_name="Balayage + Cut", + staff_name="Claire Bennett", + status=AppointmentStatus.CONFIRMED, + ) + assert apt.duration_minutes() == 90 + + +def test_appointment_duration_one_hour(): + apt = Appointment( + appointment_id="APT-002", + start_time=datetime(2026, 7, 28, 13, 0), + end_time=datetime(2026, 7, 28, 14, 0), + client_name="Chris Nguyen", + service_name="Men's Cut", + staff_name="Claire Bennett", + status=AppointmentStatus.PENDING, + ) + assert apt.duration_minutes() == 60 + + +def test_appointment_time_only(): + apt = Appointment( + appointment_id="APT-003", + start_time=datetime(2026, 7, 28, 11, 15), + end_time=datetime(2026, 7, 28, 12, 45), + client_name="Jasmine Patel", + service_name="Root Touch-Up", + staff_name="Maya Torres", + status=AppointmentStatus.CONFIRMED, + ) + assert apt.start_time_only() == time(11, 15) + assert apt.end_time_only() == time(12, 45) + + +def test_appointment_frozen(): + """Appointment is immutable.""" + apt = Appointment( + appointment_id="APT-001", + start_time=datetime(2026, 7, 28, 9, 0), + end_time=datetime(2026, 7, 28, 10, 0), + client_name="Test", + service_name="Test", + staff_name="Test", + status=AppointmentStatus.CONFIRMED, + ) + with pytest.raises(Exception): # FrozenInstanceError + apt.client_name = "Hacker" + + +def test_appointment_needs_confirmation_default(): + apt = Appointment( + appointment_id="APT-001", + start_time=datetime(2026, 7, 28, 9, 0), + end_time=datetime(2026, 7, 28, 10, 0), + client_name="Test", + service_name="Test", + staff_name="Test", + status=AppointmentStatus.PENDING, + ) + assert apt.needs_confirmation is False + + +def test_appointment_status_enum(): + assert AppointmentStatus.CONFIRMED.value == "confirmed" + assert AppointmentStatus.PENDING.value == "pending" + assert AppointmentStatus.CANCELLED.value == "cancelled" + assert AppointmentStatus.COMPLETED.value == "completed" + assert AppointmentStatus.NO_SHOW.value == "no_show" + + +# ── Gap ──────────────────────────────────────────────────────────────────── + +def test_gap_creation(): + gap = Gap( + start_time=time(12, 0), + end_time=time(13, 30), + duration_minutes=90, + staff_name="Claire Bennett", + ) + assert gap.duration_minutes == 90 + assert gap.staff_name == "Claire Bennett" + + +def test_gap_with_references(): + gap = Gap( + start_time=time(12, 0), + end_time=time(13, 0), + duration_minutes=60, + staff_name="Claire Bennett", + preceding_appointment_id="APT-001", + following_appointment_id="APT-002", + ) + assert gap.preceding_appointment_id == "APT-001" + assert gap.following_appointment_id == "APT-002" + + +# ── DayBoard ─────────────────────────────────────────────────────────────── + +def test_dayboard_to_dict(): + apt = Appointment( + appointment_id="APT-001", + start_time=datetime(2026, 7, 28, 9, 0), + end_time=datetime(2026, 7, 28, 10, 0), + client_name="Elena Rossi", + service_name="Cut", + staff_name="Claire Bennett", + status=AppointmentStatus.CONFIRMED, + needs_confirmation=False, + ) + board = DayBoard( + date="2026-07-28", + salon_name="Lumina Hair Studio & Spa", + source="fixtures", + is_offline=True, + appointments=[apt], + gaps=[], + needs_confirmation=[], + total_booked_minutes=60, + total_gap_minutes=0, + ) + d = board.to_dict() + assert d["date"] == "2026-07-28" + assert d["salon_name"] == "Lumina Hair Studio & Spa" + assert d["source"] == "fixtures" + assert d["is_offline"] is True + assert len(d["appointments"]) == 1 + assert d["appointments"][0]["client"] == "Elena Rossi" + assert d["total_booked_minutes"] == 60 + + +def test_dayboard_to_dict_serializable(): + """to_dict output must be JSON-serializable.""" + board = DayBoard( + date="2026-07-28", + salon_name="Test Salon", + source="fixtures", + is_offline=True, + ) + d = board.to_dict() + # Should not raise. + json.dumps(d) + + +def test_dayboard_empty(): + board = DayBoard( + date="2026-07-28", + salon_name="Empty Salon", + source="offline", + is_offline=True, + ) + assert len(board.appointments) == 0 + assert len(board.gaps) == 0 + assert board.total_booked_minutes == 0 diff --git a/tests/unit/test_fixture_provider.py b/tests/unit/test_fixture_provider.py new file mode 100644 index 0000000..c7515f6 --- /dev/null +++ b/tests/unit/test_fixture_provider.py @@ -0,0 +1,156 @@ +"""Tests for the scheduling fixture provider.""" + +from __future__ import annotations + +import json +import pathlib +import tempfile + +import pytest + +import sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib")) + +from lumina_skills.domain import Appointment, AppointmentStatus +from lumina_skills.providers.scheduling.fixture_provider import ( + load_fixtures, + load_fixture_metadata, +) + +# Path to the real fixture file. +_FIXTURE_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "scheduling" / "claire_bennett_2026-07-28.json" + + +def _make_fixture_file(tmp_path: pathlib.Path, data: dict) -> pathlib.Path: + """Write a fixture dict to a temp JSON file.""" + p = tmp_path / "test_fixture.json" + p.write_text(json.dumps(data), encoding="utf-8") + return p + + +# ── load_fixtures ────────────────────────────────────────────────────────── + +def test_load_real_fixture(): + """Load the Claire Bennett fixture file.""" + apts = load_fixtures(_FIXTURE_PATH) + assert len(apts) == 7 # 7 appointments in the fixture + assert all(isinstance(a, Appointment) for a in apts) + + +def test_load_fixture_statuses(): + """Fixture statuses are correctly parsed.""" + apts = load_fixtures(_FIXTURE_PATH) + statuses = {a.appointment_id: a.status for a in apts} + assert statuses["APT-001"] == AppointmentStatus.CONFIRMED + assert statuses["APT-002"] == AppointmentStatus.PENDING + assert statuses["APT-007"] == AppointmentStatus.CANCELLED + + +def test_load_fixture_needs_confirmation(): + """needs_confirmation flag is loaded from fixture.""" + apts = load_fixtures(_FIXTURE_PATH) + flags = {a.appointment_id: a.needs_confirmation for a in apts} + assert flags["APT-001"] is False + assert flags["APT-002"] is True + assert flags["APT-004"] is True + + +def test_load_fixture_notes(): + """Notes are loaded from fixture.""" + apts = load_fixtures(_FIXTURE_PATH) + notes = {a.appointment_id: a.notes for a in apts} + assert "ammonia" in notes["APT-001"].lower() + assert "Wedding" in notes["APT-002"] + + +def test_load_fixture_missing_file(tmp_path: pathlib.Path): + """FileNotFoundError for missing fixture.""" + with pytest.raises(FileNotFoundError): + load_fixtures(tmp_path / "nonexistent.json") + + +def test_load_fixture_empty_appointments(tmp_path: pathlib.Path): + """Empty appointments list returns empty list.""" + data = {"salon_name": "Test", "date": "2026-01-01", "appointments": []} + path = _make_fixture_file(tmp_path, data) + apts = load_fixtures(path) + assert apts == [] + + +def test_load_fixture_default_status(tmp_path: pathlib.Path): + """Missing status field defaults to PENDING (via .get default).""" + data = { + "appointments": [{ + "id": "APT-X", + "start": "2026-01-01T09:00:00", + "end": "2026-01-01T10:00:00", + "client_name": "Test", + "service_name": "Test", + "staff_name": "Test", + # No status field. + }] + } + path = _make_fixture_file(tmp_path, data) + apts = load_fixtures(path) + assert apts[0].status == AppointmentStatus.PENDING + + +def test_load_fixture_unknown_status_raises(tmp_path: pathlib.Path): + """Unknown status string raises ValueError.""" + data = { + "appointments": [{ + "id": "APT-X", + "start": "2026-01-01T09:00:00", + "end": "2026-01-01T10:00:00", + "client_name": "Test", + "service_name": "Test", + "staff_name": "Test", + "status": "typo_status", + }] + } + path = _make_fixture_file(tmp_path, data) + with pytest.raises(ValueError, match="Unknown appointment status"): + load_fixtures(path) + + +def test_load_fixture_default_needs_confirmation(tmp_path: pathlib.Path): + """Missing needs_confirmation defaults to False.""" + data = { + "appointments": [{ + "id": "APT-X", + "start": "2026-01-01T09:00:00", + "end": "2026-01-01T10:00:00", + "client_name": "Test", + "service_name": "Test", + "staff_name": "Test", + "status": "confirmed", + # No needs_confirmation field. + }] + } + path = _make_fixture_file(tmp_path, data) + apts = load_fixtures(path) + assert apts[0].needs_confirmation is False + + +def test_load_fixture_malformed_json(tmp_path: pathlib.Path): + """ValueError for invalid JSON.""" + p = tmp_path / "bad.json" + p.write_text("{not valid json}", encoding="utf-8") + with pytest.raises(ValueError): + load_fixtures(p) + + +# ── load_fixture_metadata ───────────────────────────────────────────────── + +def test_load_metadata(): + meta = load_fixture_metadata(_FIXTURE_PATH) + assert meta["salon_name"] == "Lumina Hair Studio & Spa" + assert meta["date"] == "2026-07-28" + assert meta["business_hours"]["open"] == "09:00" + assert meta["business_hours"]["close"] == "18:00" + assert len(meta["staff"]) == 2 + + +def test_load_metadata_missing_file(tmp_path: pathlib.Path): + with pytest.raises(FileNotFoundError): + load_fixture_metadata(tmp_path / "nonexistent.json")