Files
Tatami/CLAUDE.md
T
lars 6b0fca0100 Move Participant/Group to pydantic and add a saveable/editable Plan model
Participant and Group are now pydantic BaseModels. Group.hosts can form
cycles between groups, so it's kept as a private, non-persisted live list
(set via set_hosts()/add_host()) backed by a serializable host_uuids field,
re-linked via resolve_hosts() after a reload.

A new Plan model (src/tatami/plan.py) bundles groups, the after-party group,
and the event config (course_times, organizer_contacts, info_text,
spreadsheet_id) and supports save()/load() to/from JSON. tatami_masterplan's
__main__ now saves to masterplan.json (PLAN_FILE env var to override) on
first run and loads it on later runs instead of recomputing, so the plan can
be hand-edited (move a member between groups, change a course, fill in
spreadsheet_id) and picked up on rerun without hitting the Routes API again.
spreadsheet_id moves out of .env (GOOGLE_SHEETS_SPREADSHEET_ID) onto the plan
itself, since it's part of the plan rather than a secret.
2026-06-19 16:07:47 +02:00

60 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Tatami ("Tool for Arranging Tasty Appointments, Meetings & Invitations") generates a "running dinner" masterplan: given a list of participants (with home addresses), it groups them into hosting groups for starter/main/dessert courses, assigns which groups visit which hosts for each course, and orders things to minimize travel time (by bike, via the Google Maps Routes API), finishing at a shared after-party location.
The actively developed package is `src/tatami/`. `running_dinner/running_dinner.py` is a legacy, pre-package standalone script (German-language field names, brute-force search over team pairings) kept for reference — it is not wired into the `tatami` package and uses a different/older Google Maps API (the legacy Distance Matrix API vs. the new Routes API used in `traveltimes.py`). Don't assume code or conventions from `running_dinner.py` apply to `src/tatami/`.
## Commands
This project uses `uv` for dependency management (Python >=3.13).
- Install deps: `uv sync`
- Run the masterplan script: `uv run python -m tatami.tatami_masterplan` (requires `test-config.csv` in the working directory and `GOOGLE_MAPS_API_KEY` set; optionally set `GOOGLE_SHEETS_CREDENTIALS_FILE` in `.env` + `spreadsheet_id` in the saved plan to also export to a shared Google Sheet — see `.env.example`). The first run computes a plan and saves it to `masterplan.json` (override via `PLAN_FILE`); later runs load that file instead of recomputing, so it's the place to hand-edit groups/courses/contacts/`spreadsheet_id` between runs.
- Lint: `uv run ruff check`
- Format: `uv run ruff format`
- Type check: `uv run mypy --allow-redefinition src/` (mypy is configured to treat untyped imports as errors except where ignored)
- Test: `uv run pytest` (offline suite, HTTP mocked); `uv run pytest -m e2e` for the opt-in live-API tests
- Pre-commit runs ruff check, ruff format, and mypy automatically (see `.pre-commit-config.yaml`); install hooks with `uv run pre-commit install` if working interactively.
## Architecture
The pipeline (see `src/tatami/tatami_masterplan.py` `__main__` block) is:
1. **Load participants**`load_csv_to_participants` reads a tab-separated CSV (`name`, `address`, `phone`, `kitchen_size`, `allergies`) into `Participant` objects (`src/tatami/classes.py`).
2. **Fetch travel times**`traveltimes.get_participant_distance_matrix` calls the Google Routes API (`GOOGLE_MAPS_API_KEY` env var required) to build a full pairwise duration matrix between all participant addresses plus the after-party address, indexed by participant UUID.
3. **Build masterplan**`compute_masterplan_groups` (the live-object core; `get_masterplan` is a thin wrapper around it that returns plain dicts instead):
- Splits participants into `hosts` (one per group, `len(participants)//6` groups of 3 courses each) and `semi_hosts` (non-hosting members assigned round-robin into existing groups), ranked by each participant's `get_after_party_time` (kitchen size penalty + distance to after-party).
- Reduces the full distance matrix to just host-to-host distances (`reduce_distance_matrix`), adding each host's kitchen-size penalty into their row.
- Runs `run_simulated_annealing` / `simulated_annealing` (Boltzmann-style annealing over `itertools.permutations` of group order — note this is brute-force over all permutations per iteration, so it only scales to a small number of groups) to find a low-travel-time ordering of groups.
- `assign_courses` assigns each group a course (starter/main/dessert cycling) and, via `get_courses`, determines which other groups host it for each course (offsets of `+1` and `-4` mod total groups — this fixed relationship is what defines the dinner-rotation topology).
4. `get_masterplan` returns two lists of plain dicts (`group.dict()`, `participant.dict()`) suitable for serialization; `compute_masterplan_groups` returns the live `Group`/`Participant` objects, which is what the `Plan`/sheet export step needs (`.hosts`, `.get_guests(...)`).
5. **Wrap in a `Plan` and save** (`src/tatami/plan.py`) — `__main__` bundles the computed `groups` + `after_party_group` with the event config (`course_times`, `organizer_contacts`, `info_text`, `spreadsheet_id`) into a `Plan` and calls `plan.save(PLAN_FILE)`. On the next invocation, if that file exists, `Plan.load()` reads it back instead of recomputing — this is the save/reload/edit path: hand-edit `masterplan.json` (move a member between groups, change a course, fill in `spreadsheet_id`, ...) and rerun to pick up the edit without hitting the Routes API again.
6. **Export to Google Sheets (optional)** — if the loaded/built `Plan.spreadsheet_id` is set, `__main__` calls `sheets_export.export_masterplan_to_sheet` to populate a pre-existing, pre-shared spreadsheet with an Overview tab and one tab per group. This is opt-in and never sends anything directly to participants — the organizer still shares the sheet link manually.
### Core domain model (`src/tatami/classes.py`, `src/tatami/plan.py`)
`Participant` and `Group` are pydantic `BaseModel`s (so they support `model_dump()`/`model_dump_json()`/`model_validate_json()` directly); `Plan` wraps the whole thing for persistence.
- `Participant`: a person with an address, phone, kitchen size (010, used as a "willingness/suitability to host" proxy via `get_penalty`, which adds travel-time-equivalent minutes for smaller kitchens), and allergies.
- `Group`: a hosting unit with a `main_member` (a property resolved from `main_member_uuid` against `members` — used as the group's representative location for all distance lookups; other members' addresses are not used for travel calculations), a `course`, and a `hosts` property (the groups that host *this* group across the evening, kept sorted starter→main→dessert via `sort_hosts`). `get_total_time` sums travel + penalty across this group's full route (its hosts, then the after-party).
- `hosts` references *other* `Group`s and these references are genuinely cyclic (a group's hosts can host it back), so they can't be embedded directly in JSON. The live `hosts` list is a private, non-persisted attribute set via `set_hosts()`/`add_host()`; the persisted field is `host_uuids` (kept in sync automatically). After `Plan.load()`, `Group.resolve_hosts()` re-links `hosts` from `host_uuids` against the sibling groups in the same `Plan` — call it yourself if you ever construct `Group`s outside of a `Plan` and need `.hosts` populated from `host_uuids`.
- `Plan` (`plan.py`): bundles `groups`, `after_party_group`, and the event-wide config (`course_times`, `organizer_contacts`, `info_text`, `spreadsheet_id`). `Plan.save(path)` / `Plan.load(path)` round-trip the whole thing to/from JSON; `plan.participants` is a derived property (flattened, deduplicated `group.members` across all groups + the after party), not a separately stored field, so editing a participant's data in a group's `members` is the single source of truth.
- Distance/time lookups throughout the codebase are keyed by `Participant.uuid` (host groups are addressed via `main_member.uuid`), not by name — when adding new matrix operations, index by uuid for consistency with `traveltimes.py` and `classes.py`.
### Travel times (`src/tatami/traveltimes.py`)
- Wraps the Google Routes API `computeRouteMatrix` endpoint. Raises at import time if `GOOGLE_MAPS_API_KEY` is unset.
- `get_distance_matrix` returns a square `DataFrame` of `pd.Timedelta` (or float meters) indexed/columned by integer position; `get_participant_distance_matrix` relabels both axes to participant UUIDs.
- `reduce_distance_matrix` is the bridge between the full participant-level matrix and the group-level matrix used by the annealing step.
### Sheet export (`src/tatami/sheets_export.py`)
- Wraps `gspread` (Google Sheets API). Unlike `traveltimes.py`, validation of `GOOGLE_SHEETS_CREDENTIALS_FILE` happens lazily inside `load_sheets_client`, not at import time — this feature is optional/opt-in, so importing the module must not require the env var.
- `export_masterplan_to_sheet` expects an existing `gspread.Spreadsheet` (organizer pre-creates it and shares Editor access with the service account's `client_email` once) and rewrites it idempotently: an "Overview" tab listing every participant, plus one tab per group named after all of that group's members joined with `&` (deduplicated; see `_group_titles` — deliberately not a single member's name, since a group can have several members) showing that group's course, route (their `hosts`, sorted starter→main→dessert, plus the after-party), and their guests for the course they host (`Group.get_guests`). Course start times are a fixed dict passed in by the caller (`COURSE_TIMES` in `tatami_masterplan.py`) — the dinner runs on a synchronized schedule, not on travel-time-derived timing.
- The Overview tab also gets three caller-supplied sections below the group assignments, each rendered as-is (no computation) and each skippable: a "Meal Times" table built straight from `course_times`, a "Support Contacts" table from the optional `organizer_contacts: list[tuple[str, str]]` argument, and a free-text "Info" block from the optional `info_text: str` argument (split into one row per line). All three are configured in `tatami_masterplan.py` (`COURSE_TIMES`, `ORGANIZER_CONTACTS`, `INFO_TEXT`) and just passed through — `sheets_export.py` has no event-specific content baked in.
- Tabs left over from a previous run with a different group count are deleted so reruns don't accumulate stale tabs.