diff --git a/README.md b/README.md index 2be0cd5..5cc0f13 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ are: - **/** focuses the search box; **Esc** clears search and returns to the table. - **A** adds, **E** edits, **Delete** deletes/removes, and **R** toggles review candidates when the relevant table is focused. +- In Trips, **D** duplicates and **C** compares. In a trip dashboard, **I** + edits an item's quantity/note and **P** opens the pack audit. - **Ctrl+S** saves forms and picker dialogs; **Enter** confirms confirmations. - **Ctrl+B** writes a manual `.bak` snapshot beside your data file. @@ -67,8 +69,18 @@ are: - **Trips tab** — click a trip to open its dashboard: live weight summary, a colored category-weight bar chart, and the assigned-gear list. Add or remove items right there; the same gear item can be on any number of - trips without affecting the others. Every trip you've ever planned stays - in the file — full history, nothing gets overwritten. + trips without affecting the others. Quantities and notes are trip-specific, + so a two-night plan can carry fewer consumables than a week-long plan + without changing Gear Inventory. +- **Loadout variants** — select a trip and choose **Duplicate** (`D`) to make + an independent copy. Choose **Compare** (`C`) to see base/skin-out weight, + category, gear, and quantity differences between two trip plans. +- **Pack audit** — open a trip and choose **Pack Audit** (`P`). Categories + represented by assigned gear are automatic; absent categories can be marked + covered elsewhere, intentionally omitted, or left unresolved. This is a + planning aid, not a universal safety prescription. +- **Trip history** — every trip you've planned stays in the file until you + delete it; duplicating and editing a loadout never overwrites its source. - **Reports tab** — export any trip's pack list, or the whole inventory, to a polished Markdown file (weight summary, category breakdown with a bar chart, heaviest items, review candidates, and a checkbox pack list) @@ -100,4 +112,8 @@ Run the core and headless Textual tests with: uv run python -m unittest discover -s tests -v ``` +The suite covers JSON migration and validation, persistence, trip-specific +weight calculations, duplication, comparisons, pack-audit logic, Markdown +exports, and headless keyboard workflows. + The same suite runs on Python 3.9 and 3.12 for every pull request. diff --git a/gear_core.py b/gear_core.py index 1c6d47d..1fbd383 100644 --- a/gear_core.py +++ b/gear_core.py @@ -5,6 +5,7 @@ CLI, or a test script without dragging in a terminal. """ +import copy import json import math import os @@ -31,7 +32,8 @@ REVIEW_WEIGHT_THRESHOLD_OZ = 8.0 REVIEW_USEFULNESS_THRESHOLD = 3 -DATA_VERSION = 1 +AUDIT_STATUSES = ("covered", "omitted", "unresolved") +DATA_VERSION = 2 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_DATA_PATH = os.path.join(SCRIPT_DIR, "gear_data.json") @@ -91,12 +93,13 @@ def example_data(): "target_base_weight_lb": 15.0, "notes": "Mt. Rogers / Grayson Highlands / Wilburn Ridge loop, ~35 mi", "created": date.today().isoformat(), + "audit": {}, "items": [ - {"gear_id": "G001", "note": ""}, - {"gear_id": "G002", "note": ""}, - {"gear_id": "G003", "note": ""}, - {"gear_id": "G004", "note": ""}, - {"gear_id": "G005", "note": "Worn, not packed"}, + {"gear_id": "G001", "qty": 1, "note": ""}, + {"gear_id": "G002", "qty": 1, "note": ""}, + {"gear_id": "G003", "qty": 1, "note": ""}, + {"gear_id": "G004", "qty": 1, "note": ""}, + {"gear_id": "G005", "qty": 1, "note": "Worn, not packed"}, ]}, ] return data @@ -174,6 +177,7 @@ def validate_data(data): f"{label}.weight_type must be one of: {', '.join(WEIGHT_TYPES)}" ) + gear_by_id = {gear["id"]: gear for gear in data["gear"]} trip_ids = set() for index, trip in enumerate(data["trips"]): label = f"trips[{index}]" @@ -195,6 +199,16 @@ def validate_data(data): target = trip.setdefault("target_base_weight_lb", None) if target is not None: _number(target, f"{label}.target_base_weight_lb") + audit = trip.setdefault("audit", {}) + if not isinstance(audit, dict): + raise DataValidationError(f"{label}.audit must be an object") + for category, status in audit.items(): + if category not in CATEGORIES: + raise DataValidationError(f"{label}.audit contains unknown category: {category}") + if status not in AUDIT_STATUSES: + raise DataValidationError( + f"{label}.audit.{category} must be one of: {', '.join(AUDIT_STATUSES)}" + ) if not isinstance(trip.setdefault("items", []), list): raise DataValidationError(f"{label}.items must be a list") assigned = set() @@ -208,9 +222,14 @@ def validate_data(data): if gear_id in assigned: raise DataValidationError(f"{label} assigns gear {gear_id} more than once") assigned.add(gear_id) + default_qty = gear_by_id.get(gear_id, {}).get("qty", 1) + qty = _number(entry.setdefault("qty", default_qty), f"{entry_label}.qty", minimum=1) + if not isinstance(qty, int): + raise DataValidationError(f"{entry_label}.qty must be an integer") entry.setdefault("note", "") if not isinstance(entry["note"], str): raise DataValidationError(f"{entry_label}.note must be a string") + data["meta"]["version"] = DATA_VERSION return data @@ -336,6 +355,11 @@ def total_weight_lb(item): return round(total_weight_oz(item) / 16, 4) +def trip_item_weight_oz(gear, entry): + """Return the per-trip weight using the assignment quantity.""" + return round(gear["weight_oz"] * entry.get("qty", gear.get("qty", 1)), 3) + + def is_review_flagged(item): return (item["usefulness"] < REVIEW_USEFULNESS_THRESHOLD and total_weight_oz(item) > REVIEW_WEIGHT_THRESHOLD_OZ) @@ -345,6 +369,92 @@ def trips_referencing_gear(data, gear_id): return [t for t in data["trips"] if any(i["gear_id"] == gear_id for i in t["items"])] +def duplicate_trip(data, trip, name=None): + """Return an independent copy of a trip with a fresh identity.""" + duplicate = copy.deepcopy(trip) + duplicate["id"] = next_id(data["trips"], "T") + duplicate["name"] = name or f"{trip['name']} (Copy)" + duplicate["created"] = date.today().isoformat() + return duplicate + + +def compute_pack_audit(data, trip): + """Report category coverage without pretending every category is required.""" + packed_categories = { + gear["category"] + for entry in trip["items"] + for gear in [find_gear(data, entry["gear_id"])] + if gear is not None + } + saved = trip.get("audit", {}) + rows = [] + for category in CATEGORIES: + status = "packed" if category in packed_categories else saved.get(category, "unresolved") + rows.append({"category": category, "status": status}) + return { + "rows": rows, + "packed": sum(row["status"] == "packed" for row in rows), + "covered": sum(row["status"] == "covered" for row in rows), + "omitted": sum(row["status"] == "omitted" for row in rows), + "unresolved": sum(row["status"] == "unresolved" for row in rows), + } + + +def compare_trips(data, left, right): + """Compare two loadouts by weight, category, membership, and quantity.""" + left_summary = compute_trip_summary(data, left) + right_summary = compute_trip_summary(data, right) + left_items = {entry["gear_id"]: entry for entry in left["items"]} + right_items = {entry["gear_id"]: entry for entry in right["items"]} + + def item_row(gear_id, entry, side): + gear = find_gear(data, gear_id) + return { + "gear_id": gear_id, + "name": gear["name"] if gear else gear_id, + "category": gear["category"] if gear else "Missing", + "qty": entry.get("qty", gear.get("qty", 1) if gear else 1), + "total_oz": trip_item_weight_oz(gear, entry) if gear else 0.0, + "side": side, + } + + added = [item_row(gid, right_items[gid], "right") for gid in right_items.keys() - left_items.keys()] + removed = [item_row(gid, left_items[gid], "left") for gid in left_items.keys() - right_items.keys()] + changed = [] + for gid in left_items.keys() & right_items.keys(): + gear = find_gear(data, gid) + if gear is None: + continue + left_qty = left_items[gid].get("qty", gear.get("qty", 1)) + right_qty = right_items[gid].get("qty", gear.get("qty", 1)) + if left_qty != right_qty: + changed.append({ + "gear_id": gid, + "name": gear["name"], + "category": gear["category"], + "left_qty": left_qty, + "right_qty": right_qty, + "delta_oz": round((right_qty - left_qty) * gear["weight_oz"], 3), + }) + + category_deltas = {} + for category in CATEGORIES: + delta = right_summary["category_oz"].get(category, 0) - left_summary["category_oz"].get(category, 0) + if delta: + category_deltas[category] = round(delta, 2) + + return { + "left": left_summary, + "right": right_summary, + "base_delta_lb": round(right_summary["base_lb"] - left_summary["base_lb"], 3), + "total_delta_lb": round(right_summary["total_lb"] - left_summary["total_lb"], 3), + "category_deltas": category_deltas, + "added": sorted(added, key=lambda row: (-row["total_oz"], row["name"])), + "removed": sorted(removed, key=lambda row: (-row["total_oz"], row["name"])), + "changed": sorted(changed, key=lambda row: (-abs(row["delta_oz"]), row["name"])), + } + + def compute_trip_summary(data, trip): base_oz = worn_oz = consumable_oz = 0.0 category_oz = {c: 0.0 for c in CATEGORIES} @@ -357,7 +467,7 @@ def compute_trip_summary(data, trip): if gear is None: missing.append(entry["gear_id"]) continue - oz = total_weight_oz(gear) + oz = trip_item_weight_oz(gear, entry) if gear["weight_type"] == "Base Weight": base_oz += oz elif gear["weight_type"] == "Worn Weight": @@ -370,6 +480,7 @@ def compute_trip_summary(data, trip): rows.append({ "gear": gear, "trip_note": entry.get("note", ""), + "trip_qty": entry.get("qty", gear.get("qty", 1)), "total_oz": oz, "review_flag": is_review_flagged(gear), }) @@ -402,6 +513,8 @@ def compute_trip_summary(data, trip): "rows_by_weight": rows_sorted_by_weight, "missing_gear_ids": missing, "item_count": len(rows), + "unit_count": sum(row["trip_qty"] for row in rows), + "audit": compute_pack_audit(data, trip), } @@ -430,7 +543,10 @@ def render_trip_markdown(data, trip): meta_bits = [] if trip.get("dates"): meta_bits.append(f"**{trip['dates']}**") - meta_bits.append(f"{s['item_count']} items") + item_label = f"{s['item_count']} gear items" + if s["unit_count"] != s["item_count"]: + item_label += f" / {s['unit_count']} total units" + meta_bits.append(item_label) if s["target_lb"]: meta_bits.append(f"target base **{s['target_lb']:.1f} lb**") add(" · ".join(meta_bits)) @@ -502,6 +618,18 @@ def render_trip_markdown(data, trip): add(f"- **{g['name']}** — {row['total_oz']:.1f} oz, usefulness {g['usefulness']}/5") add("") + # --- Pack audit --------------------------------------------------------- + audit = s["audit"] + add("## Pack Audit") + add("") + add(f"{audit['packed']} represented · {audit['covered']} covered elsewhere · " + f"{audit['omitted']} intentionally omitted · {audit['unresolved']} unresolved") + add("") + unresolved = [row["category"] for row in audit["rows"] if row["status"] == "unresolved"] + if unresolved: + add("Unresolved categories: " + ", ".join(unresolved)) + add("") + # --- Pack list ---------------------------------------------------------- add("## Pack List") add("") @@ -519,7 +647,7 @@ def render_trip_markdown(data, trip): for row in sorted(by_cat[cat], key=lambda r: -r["total_oz"]): g = row["gear"] brand = f" ({g['brand']})" if g.get("brand") else "" - qty = f" ×{g['qty']}" if g["qty"] > 1 else "" + qty = f" ×{row['trip_qty']}" if row["trip_qty"] > 1 else "" flag = " ⚠️" if row["review_flag"] else "" note = f" — _{row['trip_note']}_" if row["trip_note"] else "" add(f"- [ ] {g['name']}{brand}{qty} — {row['total_oz']:.1f} oz{note}{flag}") diff --git a/gear_tui.py b/gear_tui.py index 1b3aaf8..bfee592 100644 --- a/gear_tui.py +++ b/gear_tui.py @@ -433,8 +433,13 @@ def compose(self) -> ComposeResult: yield Label("Add Gear to Trip — click a row to add it", classes="dialog-title") yield Input(placeholder="Search gear...", id="gp-search") yield DataTable(id="gp-table", cursor_type="row", zebra_stripes=True) - yield Label("Trip-specific note (optional, applies to the item you add)") - yield Input(id="gp-note", placeholder="e.g. borrowed, worn not packed") + with Horizontal(classes="field-row"): + with Vertical(classes="field-col"): + yield Label("Trip quantity") + yield Input(value="1", id="gp-qty", type="integer") + with Vertical(classes="field-col"): + yield Label("Trip-specific note (optional)") + yield Input(id="gp-note", placeholder="e.g. borrowed, worn not packed") with Horizontal(classes="dialog-buttons"): yield Button("Cancel", id="gp-cancel") yield Button("Add Selected", id="gp-add", variant="success") @@ -481,8 +486,7 @@ def _current_row_key(self) -> Optional[str]: @on(DataTable.RowSelected, "#gp-table") def _row_selected(self, event: DataTable.RowSelected) -> None: - note = self.query_one("#gp-note", Input).value.strip() - self.dismiss((event.row_key.value, note)) + self._dismiss_selection(event.row_key.value) @on(Button.Pressed, "#gp-add") def _add(self) -> None: @@ -490,8 +494,215 @@ def _add(self) -> None: if gear_id is None: self.app.notify("No gear left to add (or nothing matches your search)", severity="warning") return + self._dismiss_selection(gear_id) + + def _dismiss_selection(self, gear_id: str) -> None: + try: + qty = int(self.query_one("#gp-qty", Input).value or 1) + except ValueError: + qty = 0 + if qty < 1: + self.app.notify("Trip quantity must be at least 1", severity="error") + return note = self.query_one("#gp-note", Input).value.strip() - self.dismiss((gear_id, note)) + self.dismiss((gear_id, qty, note)) + + +class TripItemFormScreen(ModalScreen[Optional[dict]]): + BINDINGS = [Binding("escape", "cancel", "Cancel"), Binding("ctrl+s", "save", "Save")] + + def __init__(self, gear: dict, entry: dict): + super().__init__() + self.gear = gear + self.entry = entry + + def compose(self) -> ComposeResult: + with Vertical(id="dialog", classes="form-dialog"): + yield Label(f"Edit trip item: {self.gear['name']}", classes="dialog-title") + yield Label(f"Inventory weight per unit: {self.gear['weight_oz']:.1f} oz") + yield Label("Trip quantity") + yield Input(value=str(self.entry.get("qty", self.gear.get("qty", 1))), + id="ti-qty", type="integer") + yield Label("Trip-specific note") + yield Input(value=self.entry.get("note", ""), id="ti-note") + with Horizontal(classes="dialog-buttons"): + yield Button("Cancel", id="ti-cancel") + yield Button("Save", id="ti-save", variant="success") + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_save(self) -> None: + self._save() + + @on(Button.Pressed, "#ti-cancel") + def _cancel(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#ti-save") + def _save(self) -> None: + try: + qty = int(self.query_one("#ti-qty", Input).value or 0) + except ValueError: + qty = 0 + if qty < 1: + self.app.notify("Trip quantity must be at least 1", severity="error") + return + self.dismiss({"qty": qty, "note": self.query_one("#ti-note", Input).value.strip()}) + + +class PackAuditScreen(ModalScreen[Optional[dict]]): + """Let the user resolve categories that are not represented by gear.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("space", "cycle", "Cycle status"), + Binding("ctrl+s", "save", "Save"), + ] + + def __init__(self, data: dict, trip: dict): + super().__init__() + self.data = data + self.trip = trip + self.audit = copy.deepcopy(trip.get("audit", {})) + + def compose(self) -> ComposeResult: + with Vertical(id="dialog", classes="picker-dialog"): + yield Label("Pack Audit", classes="dialog-title") + yield Static("Packed categories are automatic. For anything absent, mark it covered " + "elsewhere, intentionally omitted, or leave it unresolved.") + yield DataTable(id="audit-table", cursor_type="row", zebra_stripes=True) + with Horizontal(classes="dialog-buttons"): + yield Button("Cycle Status", id="audit-cycle", variant="primary") + yield Button("Cancel", id="audit-cancel") + yield Button("Save", id="audit-save", variant="success") + + def on_mount(self) -> None: + self.query_one("#audit-table", DataTable).add_columns("Category", "Status") + self._refresh() + + def _audit_summary(self) -> dict: + temporary = copy.deepcopy(self.trip) + temporary["audit"] = self.audit + return gc.compute_pack_audit(self.data, temporary) + + def _refresh(self) -> None: + table = self.query_one("#audit-table", DataTable) + selected = None + if table.row_count: + try: + selected = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value + except Exception: + pass + table.clear() + for row in self._audit_summary()["rows"]: + table.add_row(row["category"], row["status"].replace("_", " ").title(), + key=row["category"]) + if selected: + table.move_cursor(row=table.get_row_index(selected), animate=False) + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_cycle(self) -> None: + self._cycle() + + def action_save(self) -> None: + self.dismiss({key: value for key, value in self.audit.items() if value != "unresolved"}) + + @on(Button.Pressed, "#audit-cancel") + def _cancel(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#audit-save") + def _save(self) -> None: + self.action_save() + + @on(Button.Pressed, "#audit-cycle") + @on(DataTable.RowSelected, "#audit-table") + def _cycle(self) -> None: + table = self.query_one("#audit-table", DataTable) + if not table.row_count: + return + category = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value + row = next(row for row in self._audit_summary()["rows"] if row["category"] == category) + if row["status"] == "packed": + self.app.notify("This category is represented by assigned gear", timeout=2) + return + cycle = {"unresolved": "covered", "covered": "omitted", "omitted": "unresolved"} + self.audit[category] = cycle[row["status"]] + self._refresh() + + +class TripComparisonScreen(Screen): + BINDINGS = [Binding("escape", "go_back", "Back"), Binding("b", "go_back", "Back")] + + def __init__(self, data: dict, left_trip_id: str): + super().__init__() + self.data = data + self.left_trip_id = left_trip_id + self.other_trips = [trip for trip in data["trips"] if trip["id"] != left_trip_id] + + def compose(self) -> ComposeResult: + left = gc.find_trip(self.data, self.left_trip_id) + options = [(trip["name"], trip["id"]) for trip in self.other_trips] + yield Header(show_clock=True, time_format="%I:%M %p") + with VerticalScroll(id="dash-scroll"): + yield Static(f"Compare against: {left['name']}", classes="dash-title") + yield Select(options, value=options[0][1], allow_blank=False, id="compare-trip") + yield Static(id="compare-summary", classes="panel") + yield Static("Category Changes (comparison minus baseline)", classes="section-title") + yield DataTable(id="compare-categories", cursor_type="none", zebra_stripes=True) + yield Static("Gear Changes", classes="section-title") + yield DataTable(id="compare-items", cursor_type="none", zebra_stripes=True) + with Horizontal(classes="toolbar"): + yield Button("← Back", id="compare-back") + yield Footer() + + def on_mount(self) -> None: + self.query_one("#compare-categories", DataTable).add_columns("Category", "Delta (oz)") + self.query_one("#compare-items", DataTable).add_columns( + "Change", "Category", "Item", "Qty", "Delta (oz)") + self._refresh(self.other_trips[0]["id"]) + + @on(Select.Changed, "#compare-trip") + def _selection_changed(self, event: Select.Changed) -> None: + if event.value != Select.BLANK: + self._refresh(str(event.value)) + + def _refresh(self, right_trip_id: str) -> None: + left = gc.find_trip(self.data, self.left_trip_id) + right = gc.find_trip(self.data, right_trip_id) + comparison = gc.compare_trips(self.data, left, right) + self.query_one("#compare-summary", Static).update( + f"[b]{right['name']}[/b] compared with [b]{left['name']}[/b]\n" + f"Base: {comparison['left']['base_lb']:.2f} → {comparison['right']['base_lb']:.2f} lb " + f"({comparison['base_delta_lb']:+.2f} lb) " + f"Skin-out: {comparison['left']['total_lb']:.2f} → " + f"{comparison['right']['total_lb']:.2f} lb ({comparison['total_delta_lb']:+.2f} lb)") + categories = self.query_one("#compare-categories", DataTable) + categories.clear() + for category, delta in sorted(comparison["category_deltas"].items(), + key=lambda item: -abs(item[1])): + categories.add_row(category, f"{delta:+.1f}") + items = self.query_one("#compare-items", DataTable) + items.clear() + for row in comparison["added"]: + items.add_row("Added", row["category"], row["name"], str(row["qty"]), + f"+{row['total_oz']:.1f}") + for row in comparison["removed"]: + items.add_row("Removed", row["category"], row["name"], str(row["qty"]), + f"-{row['total_oz']:.1f}") + for row in comparison["changed"]: + items.add_row("Quantity", row["category"], row["name"], + f"{row['left_qty']}→{row['right_qty']}", f"{row['delta_oz']:+.1f}") + + def action_go_back(self) -> None: + self.dismiss() + + @on(Button.Pressed, "#compare-back") + def _back(self) -> None: + self.dismiss() class ShortcutHelpScreen(ModalScreen[None]): @@ -507,8 +718,9 @@ def compose(self) -> ComposeResult: Ctrl+B Backup data Q Quit [b]Gear[/b] A Add E Edit Delete Delete R Review filter -[b]Trips[/b] A Add Enter Open Delete Delete -[b]Trip dashboard[/b] A Add item E Edit trip Delete Remove X Export +[b]Trips[/b] A Add Enter Open D Duplicate C Compare Delete Delete +[b]Trip dashboard[/b] A Add item I Edit qty/note E Edit trip P Pack audit + Delete Remove X Export [b]Dialogs[/b] Ctrl+S Save/add Esc Cancel [b]Confirmations[/b] Enter Confirm Esc Cancel @@ -536,7 +748,9 @@ class TripDashboardScreen(Screen): Binding("escape", "go_back", "Back"), Binding("b", "go_back", "Back"), Binding("a", "add_item", "Add item"), + Binding("i", "edit_item", "Edit item"), Binding("e", "edit_trip", "Edit trip"), + Binding("p", "pack_audit", "Pack audit"), Binding("delete", "remove_item", "Remove"), Binding("x", "export", "Export"), ] @@ -557,7 +771,9 @@ def compose(self) -> ComposeResult: yield DataTable(id="dash-items-table", cursor_type="row", zebra_stripes=True) with Horizontal(classes="toolbar"): yield Button("+ Add Item", id="dash-add-item", variant="success") + yield Button("Edit Qty/Note", id="dash-edit-item", variant="primary") yield Button("Remove Selected", id="dash-remove-item", variant="error") + yield Button("Pack Audit", id="dash-audit", variant="primary") yield Button("Edit Trip Info", id="dash-edit-trip", variant="primary") yield Button("Export to Markdown", id="dash-export", variant="primary") yield Button("← Back", id="dash-back") @@ -567,7 +783,7 @@ def on_mount(self) -> None: self.query_one("#dash-cat-table", DataTable).add_columns( "Category", "Wt (oz)", "Wt (lb)", "Distribution") self.query_one("#dash-items-table", DataTable).add_columns( - "ID", "Category", "Item", "Wt (oz)", "Flag", "Note") + "ID", "Category", "Item", "Qty", "Wt (oz)", "Flag", "Note") self.refresh_dashboard() def refresh_dashboard(self) -> None: @@ -601,6 +817,11 @@ def refresh_dashboard(self) -> None: f"({big3_pct:.0f}% of total)") if trip.get("notes"): lines.append(f"[dim]{trip['notes']}[/dim]") + audit = s["audit"] + if audit["unresolved"]: + lines.append(f"[#E0B46A]Pack audit: {audit['unresolved']} unresolved categories[/#E0B46A]") + else: + lines.append("[#7CD992]Pack audit resolved[/#7CD992]") self.query_one("#dash-summary", Static).update("\n".join(lines)) cat_table = self.query_one("#dash-cat-table", DataTable) @@ -616,7 +837,8 @@ def refresh_dashboard(self) -> None: for row in sorted(s["rows"], key=lambda r: -r["total_oz"]): g = row["gear"] flag = "REVIEW" if row["review_flag"] else "" - items_table.add_row(g["id"], g["category"], g["name"], f"{row['total_oz']:.1f}", + items_table.add_row(g["id"], g["category"], g["name"], str(row["trip_qty"]), + f"{row['total_oz']:.1f}", flag, row["trip_note"], key=g["id"]) def action_go_back(self) -> None: @@ -625,9 +847,15 @@ def action_go_back(self) -> None: def action_add_item(self) -> None: self._add_item() + def action_edit_item(self) -> None: + self._edit_item() + def action_edit_trip(self) -> None: self._edit_trip() + def action_pack_audit(self) -> None: + self._pack_audit() + def action_remove_item(self) -> None: self._remove_item() @@ -655,8 +883,8 @@ def _add_item(self) -> None: def handle(result): if result: - gear_id, note = result - trip["items"].append({"gear_id": gear_id, "note": note}) + gear_id, qty, note = result + trip["items"].append({"gear_id": gear_id, "qty": qty, "note": note}) if not app.save(): return self.refresh_dashboard() @@ -664,6 +892,41 @@ def handle(result): self.app.push_screen(GearPickerScreen(app.data["gear"], exclude), handle) + @on(Button.Pressed, "#dash-edit-item") + def _edit_item(self) -> None: + gear_id = self._current_item_gear_id() + if gear_id is None: + return + app: "GearTrackerApp" = self.app # type: ignore + trip = gc.find_trip(app.data, self.trip_id) + gear = gc.find_gear(app.data, gear_id) + entry = next(item for item in trip["items"] if item["gear_id"] == gear_id) + + def handle(result): + if result: + entry.update(result) + if not app.save(): + self.refresh_dashboard() + return + self.refresh_dashboard() + + self.app.push_screen(TripItemFormScreen(gear, entry), handle) + + @on(Button.Pressed, "#dash-audit") + def _pack_audit(self) -> None: + app: "GearTrackerApp" = self.app # type: ignore + trip = gc.find_trip(app.data, self.trip_id) + + def handle(result): + if result is not None: + trip["audit"] = result + if not app.save(): + self.refresh_dashboard() + return + self.refresh_dashboard() + + self.app.push_screen(PackAuditScreen(app.data, trip), handle) + @on(Button.Pressed, "#dash-remove-item") def _remove_item(self) -> None: gear_id = self._current_item_gear_id() @@ -883,6 +1146,8 @@ class TripsPane(Vertical): BINDINGS = [ Binding("a", "add", "Add"), Binding("enter", "open", "Open"), + Binding("d", "duplicate", "Duplicate"), + Binding("c", "compare", "Compare"), Binding("delete", "delete", "Delete"), Binding("escape", "clear_search", "Clear search", show=False), ] @@ -894,6 +1159,8 @@ def compose(self) -> ComposeResult: yield DataTable(id="trip-table", cursor_type="row", zebra_stripes=True) with Horizontal(classes="toolbar"): yield Button("Open Dashboard", id="trip-open", variant="primary") + yield Button("Duplicate", id="trip-duplicate", variant="primary") + yield Button("Compare", id="trip-compare", variant="primary") yield Button("Delete", id="trip-delete", variant="error") yield Static(id="trip-status", classes="status") @@ -943,6 +1210,12 @@ def action_open(self) -> None: def action_delete(self) -> None: self._delete() + def action_duplicate(self) -> None: + self._duplicate() + + def action_compare(self) -> None: + self._compare() + def action_clear_search(self) -> None: search = self.query_one("#trip-search", Input) search.value = "" @@ -987,6 +1260,34 @@ def _row_selected(self, event: DataTable.RowSelected) -> None: def _open_button(self) -> None: self._open_dashboard(self._current_trip_id()) + @on(Button.Pressed, "#trip-duplicate") + def _duplicate(self) -> None: + trip_id = self._current_trip_id() + if trip_id is None: + return + app: "GearTrackerApp" = self.app # type: ignore + source = gc.find_trip(app.data, trip_id) + duplicate = gc.duplicate_trip(app.data, source) + app.data["trips"].append(duplicate) + if not app.save(): + self.refresh_table() + return + self.refresh_table(self.query_one("#trip-search", Input).value) + self.query_one("#trip-table", DataTable).move_cursor( + row=self.query_one("#trip-table", DataTable).get_row_index(duplicate["id"]), animate=False) + self.app.notify(f"Created {duplicate['name']}", timeout=3) + + @on(Button.Pressed, "#trip-compare") + def _compare(self) -> None: + trip_id = self._current_trip_id() + if trip_id is None: + return + app: "GearTrackerApp" = self.app # type: ignore + if len(app.data["trips"]) < 2: + self.app.notify("Duplicate or add another trip before comparing", severity="warning") + return + self.app.push_screen(TripComparisonScreen(app.data, trip_id)) + @on(Button.Pressed, "#trip-delete") def _delete(self) -> None: trip_id = self._current_trip_id() diff --git a/tests/test_core.py b/tests/test_core.py index a3cac6e..cf48f6c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -30,10 +30,29 @@ def test_rejects_invalid_values(self): duplicate_id["gear"][1]["id"] = duplicate_id["gear"][0]["id"] cases.append(duplicate_id) + bad_trip_qty = copy.deepcopy(self.data) + bad_trip_qty["trips"][0]["items"][0]["qty"] = 0 + cases.append(bad_trip_qty) + + bad_audit = copy.deepcopy(self.data) + bad_audit["trips"][0]["audit"] = {"Water": "probably"} + cases.append(bad_audit) + for invalid_data in cases: with self.subTest(data=invalid_data), self.assertRaises(gc.DataValidationError): gc.validate_data(invalid_data) + def test_legacy_trip_quantities_are_migrated_without_weight_changes(self): + data = gc.example_data() + gear = data["gear"][0] + gear["qty"] = 3 + entry = data["trips"][0]["items"][0] + entry.pop("qty") + gc.validate_data(data) + self.assertEqual(entry["qty"], 3) + self.assertEqual(data["meta"]["version"], gc.DATA_VERSION) + self.assertEqual(gc.compute_trip_summary(data, data["trips"][0])["rows"][0]["total_oz"], 90) + def test_load_reports_json_location(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "gear.json" @@ -80,6 +99,67 @@ def test_missing_gear_is_reported_without_breaking_summary(self): self.assertEqual(summary["missing_gear_ids"], ["G999"]) self.assertIn("no longer exists", gc.render_trip_markdown(data, data["trips"][0])) + def test_trip_quantity_overrides_inventory_quantity(self): + data = gc.example_data() + gear = data["gear"][2] + gear["qty"] = 8 + entry = data["trips"][0]["items"][2] + entry["qty"] = 2 + summary = gc.compute_trip_summary(data, data["trips"][0]) + row = next(row for row in summary["rows"] if row["gear"]["id"] == gear["id"]) + self.assertEqual(row["trip_qty"], 2) + self.assertEqual(row["total_oz"], 5.2) + self.assertIn("×2", gc.render_trip_markdown(data, data["trips"][0])) + + def test_review_candidates_remain_in_export_with_pack_audit(self): + data = gc.example_data() + candidate = data["gear"][5] + candidate["weight_oz"] = 9 + data["trips"][0]["items"].append({"gear_id": candidate["id"], "qty": 1, "note": ""}) + export = gc.render_trip_markdown(data, data["trips"][0]) + self.assertIn("## ⚠️ Review Candidates", export) + self.assertIn("Low usefulness rating and meaningful weight", export) + self.assertLess(export.index("## ⚠️ Review Candidates"), export.index("## Pack Audit")) + + +class PlanningWorkflowTests(unittest.TestCase): + def setUp(self): + self.data = gc.example_data() + self.trip = self.data["trips"][0] + + def test_duplicate_trip_is_independent_and_gets_next_id(self): + duplicate = gc.duplicate_trip(self.data, self.trip) + self.assertEqual(duplicate["id"], "T002") + self.assertEqual(duplicate["name"], f"{self.trip['name']} (Copy)") + duplicate["items"][0]["qty"] = 9 + duplicate["audit"]["Water"] = "omitted" + self.assertEqual(self.trip["items"][0]["qty"], 1) + self.assertNotIn("Water", self.trip["audit"]) + + def test_compare_trips_reports_membership_quantity_and_weight_deltas(self): + duplicate = gc.duplicate_trip(self.data, self.trip, "Alternative") + duplicate["items"] = duplicate["items"][1:] + duplicate["items"][0]["qty"] = 2 + duplicate["items"].append({"gear_id": "G006", "qty": 1, "note": ""}) + comparison = gc.compare_trips(self.data, self.trip, duplicate) + self.assertEqual([row["gear_id"] for row in comparison["removed"]], ["G001"]) + self.assertEqual([row["gear_id"] for row in comparison["added"]], ["G006"]) + self.assertEqual(comparison["changed"][0]["gear_id"], "G002") + self.assertEqual(comparison["changed"][0]["delta_oz"], 29.0) + self.assertNotEqual(comparison["base_delta_lb"], 0) + + def test_pack_audit_distinguishes_packed_resolved_and_unresolved(self): + self.trip["audit"] = {"Hygiene": "covered", "Repair/Tools": "omitted"} + audit = gc.compute_pack_audit(self.data, self.trip) + statuses = {row["category"]: row["status"] for row in audit["rows"]} + self.assertEqual(statuses["Shelter"], "packed") + self.assertEqual(statuses["Hygiene"], "covered") + self.assertEqual(statuses["Repair/Tools"], "omitted") + self.assertEqual(statuses["Miscellaneous"], "unresolved") + export = gc.render_trip_markdown(self.data, self.trip) + self.assertIn("## Pack Audit", export) + self.assertIn("intentionally omitted", export) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_tui.py b/tests/test_tui.py index becac19..9d3c271 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -4,7 +4,15 @@ from textual.widgets import Input, TabbedContent -from gear_tui import GearFormScreen, GearTrackerApp, ShortcutHelpScreen +from gear_tui import ( + GearFormScreen, + GearTrackerApp, + PackAuditScreen, + ShortcutHelpScreen, + TripComparisonScreen, + TripDashboardScreen, + TripItemFormScreen, +) class KeyboardWorkflowTests(unittest.IsolatedAsyncioTestCase): @@ -40,6 +48,35 @@ async def test_gear_hotkey_and_ctrl_s_add_an_item(self): self.assertEqual(len(app.data["gear"]), starting_count + 1) self.assertEqual(app.data["gear"][-1]["name"], "test") + async def test_duplicate_compare_quantity_and_audit_workflow(self): + with tempfile.TemporaryDirectory() as directory: + app = GearTrackerApp(str(Path(directory) / "gear.json")) + async with app.run_test(size=(140, 48)) as pilot: + await pilot.press("2") + app.query_one("#trip-table").focus() + await pilot.press("d") + self.assertEqual(len(app.data["trips"]), 2) + self.assertTrue(app.data["trips"][1]["name"].endswith("(Copy)")) + + await pilot.press("c") + self.assertIsInstance(app.screen, TripComparisonScreen) + await pilot.press("escape") + + await pilot.click("#trip-open") + self.assertIsInstance(app.screen, TripDashboardScreen) + app.screen.query_one("#dash-items-table").focus() + await pilot.press("i") + self.assertIsInstance(app.screen, TripItemFormScreen) + app.screen.query_one("#ti-qty", Input).value = "3" + await pilot.press("ctrl+s") + self.assertEqual(app.data["trips"][1]["items"][0]["qty"], 3) + + await pilot.press("p") + self.assertIsInstance(app.screen, PackAuditScreen) + app.screen.query_one("#audit-table").focus() + await pilot.press("down", "down", "space", "ctrl+s") + self.assertTrue(app.data["trips"][1]["audit"]) + if __name__ == "__main__": unittest.main()