← Files Vacation Planner ProARCHIVED FILE
skills/trip-dashboard/scripts/build_dashboard.py
12.1 KB · Oct 2, 2026 · 00:35 UTC
#!/usr/bin/env python3
"""Build a standalone trip dashboard from validated JSON data."""
from __future__ import annotations
import argparse
import json
import math
import re
from pathlib import Path
from urllib.parse import urlparse
MARKER = "__TRIP_DATA_JSON__"
REQUIRED_ARRAYS = (
"assumptions", "fixed_costs", "hotels", "activities", "restaurants",
"transport", "days", "sources",
)
VALID_STATUSES = {"observed", "estimated", "included", "optional", "excluded", "confirmed", "lead-only"}
def require_text(record: dict, field: str, path: str) -> str:
value = record.get(field)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{path}.{field} must be non-empty text")
return value.strip()
def require_amount(value: object, path: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{path} must be numeric")
number = float(value)
if not math.isfinite(number) or number < 0:
raise ValueError(f"{path} must be finite and nonnegative")
return number
def validate_url(value: object, path: str, *, required: bool = False) -> None:
if value in (None, "") and not required:
return
if not isinstance(value, str):
raise ValueError(f"{path} must be a URL")
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"{path} must use http or https")
def validate_text_list(value: object, path: str, *, required: bool = False) -> None:
if value is None and not required:
return
if not isinstance(value, list) or (required and not value):
raise ValueError(f"{path} must be {'a non-empty ' if required else 'an '}array")
for index, item in enumerate(value):
if not isinstance(item, str) or not item.strip():
raise ValueError(f"{path}[{index}] must be non-empty text")
def validate_images(value: object, path: str) -> None:
if not isinstance(value, list) or not value:
raise ValueError(f"{path} must be a non-empty image array")
for index, image in enumerate(value):
ipath = f"{path}[{index}]"
if not isinstance(image, dict):
raise ValueError(f"{ipath} must be an object")
validate_url(image.get("url"), f"{ipath}.url", required=True)
require_text(image, "alt", ipath)
require_text(image, "credit", ipath)
validate_url(image.get("source_url"), f"{ipath}.source_url", required=True)
def validate(data: dict) -> dict:
if not isinstance(data, dict):
raise ValueError("dashboard input must be an object")
meta = data.get("meta")
if not isinstance(meta, dict):
raise ValueError("meta must be an object")
for field in ("title", "currency", "route", "researched_at"):
require_text(meta, field, "meta")
if not re.fullmatch(r"[A-Z]{3}", meta["currency"]):
raise ValueError("meta.currency must be a three-letter uppercase code")
travelers = meta.get("travelers")
trip_days = meta.get("trip_days")
if not isinstance(travelers, int) or travelers < 1:
raise ValueError("meta.travelers must be a positive integer")
if not isinstance(trip_days, int) or trip_days < 1:
raise ValueError("meta.trip_days must be a positive integer")
if meta.get("target") is not None:
require_amount(meta["target"], "meta.target")
canonical_total = require_amount(meta.get("canonical_total"), "meta.canonical_total")
contingency_rate = require_amount(meta.get("contingency_rate"), "meta.contingency_rate")
if contingency_rate > 0.5:
raise ValueError("meta.contingency_rate cannot exceed 0.5")
for key in REQUIRED_ARRAYS:
if not isinstance(data.get(key), list):
raise ValueError(f"{key} must be an array")
ids: set[str] = set()
for i, item in enumerate(data["assumptions"]):
path = f"assumptions[{i}]"
item_id = require_text(item, "id", path)
if item_id in ids:
raise ValueError(f"duplicate id: {item_id}")
ids.add(item_id)
for field in ("label", "category", "status"):
require_text(item, field, path)
for field in ("value", "min", "max", "step"):
require_amount(item.get(field), f"{path}.{field}")
if item["min"] > item["value"] or item["value"] > item["max"] or item["step"] <= 0:
raise ValueError(f"{path} min/value/max/step are inconsistent")
for i, item in enumerate(data["fixed_costs"]):
path = f"fixed_costs[{i}]"
for field in ("label", "category", "status"):
require_text(item, field, path)
require_amount(item.get("amount"), f"{path}.amount")
bases: set[str] = set()
for i, stay in enumerate(data["hotels"]):
path = f"hotels[{i}]"
base = require_text(stay, "base", path)
if base in bases:
raise ValueError(f"duplicate hotel base: {base}")
bases.add(base)
options = stay.get("options")
if not isinstance(options, list) or not options:
raise ValueError(f"{path}.options must be a non-empty array")
selected = 0
for j, option in enumerate(options):
opath = f"{path}.options[{j}]"
option_id = require_text(option, "id", opath)
if option_id in ids:
raise ValueError(f"duplicate id: {option_id}")
ids.add(option_id)
for field in ("name", "location", "description", "why_fit"):
require_text(option, field, opath)
require_amount(option.get("amount"), f"{opath}.amount")
require_amount(option.get("nights"), f"{opath}.nights")
if not isinstance(option.get("selected"), bool):
raise ValueError(f"{opath}.selected must be Boolean")
selected += int(option["selected"])
validate_url(option.get("url"), f"{opath}.url")
validate_images(option.get("images"), f"{opath}.images")
for field in ("amenities", "pros", "cautions"):
validate_text_list(option.get(field), f"{opath}.{field}")
if selected != 1:
raise ValueError(f"{path} must have exactly one selected option")
for i, activity in enumerate(data["activities"]):
path = f"activities[{i}]"
activity_id = require_text(activity, "id", path)
if activity_id in ids:
raise ValueError(f"duplicate id: {activity_id}")
ids.add(activity_id)
for field in ("name", "day", "source_type", "description", "why_fit"):
require_text(activity, field, path)
require_amount(activity.get("amount"), f"{path}.amount")
if not isinstance(activity.get("selected"), bool):
raise ValueError(f"{path}.selected must be Boolean")
validate_url(activity.get("url"), f"{path}.url")
validate_url(activity.get("direct_url"), f"{path}.direct_url")
validate_images(activity.get("images"), f"{path}.images")
for field in ("includes", "pros", "cautions"):
validate_text_list(activity.get(field), f"{path}.{field}")
for i, restaurant in enumerate(data["restaurants"]):
path = f"restaurants[{i}]"
restaurant_id = require_text(restaurant, "id", path)
if restaurant_id in ids:
raise ValueError(f"duplicate id: {restaurant_id}")
ids.add(restaurant_id)
for field in ("name", "location", "cuisine", "description", "why_fit"):
require_text(restaurant, field, path)
require_amount(restaurant.get("amount", 0), f"{path}.amount")
if not isinstance(restaurant.get("selected"), bool):
raise ValueError(f"{path}.selected must be Boolean")
if restaurant.get("budget_mode", "included") not in {"included", "additive"}:
raise ValueError(f"{path}.budget_mode must be included or additive")
validate_url(restaurant.get("url"), f"{path}.url")
validate_images(restaurant.get("images"), f"{path}.images")
for field in ("signature_dishes", "dietary", "pros", "cautions"):
validate_text_list(restaurant.get(field), f"{path}.{field}")
for i, leg in enumerate(data["transport"]):
path = f"transport[{i}]"
require_text(leg, "leg", path)
options = leg.get("options")
if not isinstance(options, list) or not options:
raise ValueError(f"{path}.options must be a non-empty array")
selected = 0
for j, option in enumerate(options):
opath = f"{path}.options[{j}]"
option_id = require_text(option, "id", opath)
if option_id in ids:
raise ValueError(f"duplicate id: {option_id}")
ids.add(option_id)
for field in ("name", "mode", "description", "why_fit"):
require_text(option, field, opath)
require_amount(option.get("amount"), f"{opath}.amount")
if not isinstance(option.get("selected"), bool):
raise ValueError(f"{opath}.selected must be Boolean")
selected += int(option["selected"])
validate_url(option.get("url"), f"{opath}.url")
validate_images(option.get("images"), f"{opath}.images")
for field in ("pros", "cautions"):
validate_text_list(option.get(field), f"{opath}.{field}")
if selected != 1:
raise ValueError(f"{path} must have exactly one selected option")
for i, day in enumerate(data["days"]):
path = f"days[{i}]"
for field in ("label", "date", "base", "morning", "afternoon", "evening", "transit", "notes"):
require_text(day, field, path)
commitments = day.get("commitments")
if not isinstance(commitments, int) or commitments < 0:
raise ValueError(f"{path}.commitments must be a nonnegative integer")
for i, source in enumerate(data["sources"]):
path = f"sources[{i}]"
for field in ("label", "type", "checked_at"):
require_text(source, field, path)
validate_url(source.get("url"), f"{path}.url", required=True)
subtotal = sum(float(item["value"]) for item in data["assumptions"])
subtotal += sum(float(item["amount"]) for item in data["fixed_costs"] if item["status"] in {"included", "observed", "estimated"})
subtotal += sum(float(option["amount"]) for stay in data["hotels"] for option in stay["options"] if option["selected"])
subtotal += sum(float(item["amount"]) for item in data["activities"] if item["selected"])
subtotal += sum(
float(item.get("amount", 0)) for item in data["restaurants"]
if item["selected"] and item.get("budget_mode", "included") == "additive"
)
subtotal += sum(
float(option["amount"]) for leg in data["transport"]
for option in leg["options"] if option["selected"]
)
computed_total = round(subtotal * (1 + contingency_rate), 2)
if abs(computed_total - canonical_total) > 0.005:
raise ValueError(f"dashboard total {computed_total:.2f} does not match meta.canonical_total {canonical_total:.2f}")
return data
def build(data_path: Path, output_path: Path, template_path: Path | None = None) -> Path:
if template_path is None:
template_path = Path(__file__).resolve().parents[1] / "assets" / "trip-dashboard-template.html"
data = validate(json.loads(data_path.read_text(encoding="utf-8")))
template = template_path.read_text(encoding="utf-8")
if template.count(MARKER) != 1:
raise ValueError(f"template must contain exactly one {MARKER} marker")
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(template.replace(MARKER, payload), encoding="utf-8")
return output_path
def main() -> None:
parser = argparse.ArgumentParser(description="Build a validated interactive trip dashboard")
parser.add_argument("input", type=Path, help="Trip dashboard JSON")
parser.add_argument("output", type=Path, help="Standalone HTML output")
parser.add_argument("--template", type=Path)
args = parser.parse_args()
print(build(args.input, args.output, args.template))
if __name__ == "__main__":
main()
SHA-256: e973898a8a47ec7dc7c38ca218a82656732d6476faf2e3cfc4140bb1d6537c5d