From 5183d0f56b3dfa33958b6813202df6b444bbe14b Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:27:06 +0100 Subject: [PATCH 01/11] docs: add design doc for order history sensors (issue #52) Adds yearly and all-time spending sensors with local JSON storage for order history. Phase 1 of the order analytics feature. Co-Authored-By: Claude Opus 4.6 --- custom_components/rohlikcz/sensor.py | 4 +- ...2026-02-26-order-history-sensors-design.md | 128 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-02-26-order-history-sensors-design.md diff --git a/custom_components/rohlikcz/sensor.py b/custom_components/rohlikcz/sensor.py index c99a8c2..be194aa 100644 --- a/custom_components/rohlikcz/sensor.py +++ b/custom_components/rohlikcz/sensor.py @@ -612,12 +612,14 @@ def native_value(self) -> float | None: @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Store state for restoration.""" + count = len(self._processed_orders) return { "monthly_total": self._monthly_total, "processed_orders": list(self._processed_orders), "current_month": self._current_month, "last_reset": self._last_reset.isoformat() if self._last_reset else None, - "processed_count": len(self._processed_orders) + "processed_count": count, + "average_order_value": round(self._monthly_total / count, 2) if count > 0 else 0.0 } @property diff --git a/docs/plans/2026-02-26-order-history-sensors-design.md b/docs/plans/2026-02-26-order-history-sensors-design.md new file mode 100644 index 0000000..cd90b72 --- /dev/null +++ b/docs/plans/2026-02-26-order-history-sensors-design.md @@ -0,0 +1,128 @@ +# Order History Sensors - Design Doc + +**Date:** 2026-02-26 +**Branch:** `feat/order-history-sensors` +**Upstream issue:** [dvejsada/HA-RohlikCZ#52](https://github.com/dvejsada/HA-RohlikCZ/issues/52) +**Fork:** [kwaczek/HA-RohlikCZ](https://github.com/kwaczek/HA-RohlikCZ) + +## Goal + +Add yearly and all-time spending sensors to the Rohlik HA integration. This is Phase 1 — the foundation for future per-product and per-category breakdowns. + +## Current State + +- **MonthlySpent** sensor exists, tracks current month only, resets on the 1st +- API fetches `delivered_orders` with `limit=50` on every 600s poll cycle +- Only order-level data (ID, date, total) — no item details +- No persistent storage of order history — data lives in memory, only the monthly total survives restarts via `RestoreEntity` + +## What We're Adding + +### New Sensors + +1. **YearlySpent** — total spent this calendar year + - `SensorStateClass.TOTAL`, resets Jan 1 + - Attributes: `order_count`, `average_order_value`, `current_year` + +2. **AllTimeSpent** — cumulative total across all tracked orders + - `SensorStateClass.TOTAL_INCREASING` + - Attributes: `order_count`, `average_order_value`, `first_order_date`, `tracking_since` + +### Local Order Store + +JSON file at `/config/.storage/rohlikcz_{user_id}_orders.json`: + +```json +{ + "version": 1, + "user_id": "1086873", + "tracking_since": "2026-02-26T12:00:00+01:00", + "orders": { + "1119530344": {"date": "2026-02-25", "amount": 1523.50}, + "1119428209": {"date": "2026-02-22", "amount": 987.00} + } +} +``` + +- Written to HA's `.storage/` directory (standard location for integration data) +- Updated incrementally — only new orders added on each poll +- Survives restarts, updates, and reinstalls + +### New Service: `rohlikcz.fetch_order_history` + +One-time backfill of all historical orders: +- Paginates through `/api/v3/orders/delivered?offset=N&limit=50` +- Stores every order's ID, date, and total amount +- Rate-limited (200ms between pages) +- Logs progress + +### New API Method + +`get_delivered_orders_page(session, offset, limit)` in `rohlik_api.py`: +- Single authenticated call to the delivered orders endpoint +- Returns list of order dicts +- Used by both the regular poll (offset=0, limit=50) and the backfill service + +## Architecture + +``` + ┌─────────────────┐ + │ rohlik_api.py │ + │ (API calls) │ + └────────┬────────┘ + │ delivered orders + ▼ + ┌─────────────────┐ + │ hub.py │ + │ (data + store) │ + │ │ + │ order_store: │ + │ load/save JSON │ + │ process_orders │ + └────────┬────────┘ + │ hub.data + hub.order_store + ▼ + ┌────────────┴────────────┐ + │ │ + ┌────────┴──────┐ ┌─────────┴───────┐ + │ YearlySpent │ │ AllTimeSpent │ + │ sensor │ │ sensor │ + │ (reads store) │ │ (reads store) │ + └────────────────┘ └─────────────────┘ +``` + +## Files Changed + +| File | Change | +|------|--------| +| `rohlik_api.py` | Add `get_delivered_orders_page(session, offset, limit)` method | +| `hub.py` | Add `OrderStore` class (load/save JSON, process new orders). Add `fetch_all_order_history()` for backfill | +| `sensor.py` | Add `YearlySpent` and `AllTimeSpent` sensor classes | +| `services.py` | Register `fetch_order_history` service | +| `services.yaml` | Define `fetch_order_history` service schema | +| `const.py` | Add new icons and constants | +| `translations/en.json` | Add sensor names | +| `translations/cs.json` | Add Czech sensor names | + +## Design Decisions + +1. **JSON in `.storage/` vs SQLite** — JSON is simpler, human-readable, and sufficient for ~500 orders. `.storage/` is HA's standard location for integration data. + +2. **Separate store vs extending RestoreEntity** — RestoreEntity stores attributes in HA's state machine, which isn't designed for growing datasets (500+ order IDs). A separate file keeps the state machine clean. + +3. **Incremental sync vs full fetch** — On every poll, we process the 50 delivered orders already being fetched. New ones get added to the store. The backfill service is a one-time operation for historical data. + +4. **No item-level data yet** — Phase 1 only stores order totals. Phase 2 will add per-item data (requires additional API calls per order). The store schema has `version: 1` to allow migration. + +## Testing Strategy + +- Standalone Python script to test API pagination and order processing logic +- Unit tests for OrderStore (load, save, deduplication, yearly/alltime aggregation) +- Deploy to live HA only after local tests pass + +## Phase 2 (future) + +- Fetch order details (items, quantities, prices) via `/api/v3/orders/{id}` +- Fetch product categories via `/api/v1/products/{id}/categories` +- Cache product→category mapping +- Add category breakdown sensors From 809d2233d3552a05a36c9e2e500dfbcb15444354 Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:29:03 +0100 Subject: [PATCH 02/11] docs: add implementation plan for order history sensors 7 bite-sized tasks: API pagination, OrderStore, sensors, service, translations, tests, deploy. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-02-26-order-history-sensors.md | 642 ++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 docs/plans/2026-02-26-order-history-sensors.md diff --git a/docs/plans/2026-02-26-order-history-sensors.md b/docs/plans/2026-02-26-order-history-sensors.md new file mode 100644 index 0000000..8838523 --- /dev/null +++ b/docs/plans/2026-02-26-order-history-sensors.md @@ -0,0 +1,642 @@ +# Order History Sensors Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add YearlySpent and AllTimeSpent sensors with persistent local order storage and a backfill service. + +**Architecture:** New `OrderStore` class in `hub.py` manages a JSON file in `.storage/`. On each poll cycle, the existing `delivered_orders` data (50 orders) is processed and new orders are appended to the store. Two new sensors read from the store for yearly and all-time totals. A service call triggers a full historical backfill. + +**Tech Stack:** Python, Home Assistant custom component APIs, `requests`, JSON file storage + +--- + +### Task 1: Add `get_delivered_orders_page` to API + +**Files:** +- Modify: `custom_components/rohlikcz/rohlik_api.py:230` (after `get_data` method's finally block) + +**Step 1: Add the new API method** + +Add after the `get_data` method (after line ~230): + +```python +async def get_delivered_orders_page(self, session, offset: int = 0, limit: int = 50) -> list: + """Fetch a page of delivered orders using an existing authenticated session.""" + url = f"{BASE_URL}/api/v3/orders/delivered?offset={offset}&limit={limit}" + try: + response = await self._run_in_executor(session.get, url) + response.raise_for_status() + return response.json() + except RequestException as err: + _LOGGER.error(f"Error fetching delivered orders page (offset={offset}): {err}") + return [] + +async def fetch_all_delivered_orders(self) -> list: + """Fetch ALL delivered orders by paginating through the API. Returns list of all orders.""" + session = requests.Session() + all_orders = [] + offset = 0 + limit = 50 + + try: + await self.login(session) + + while True: + page = await self.get_delivered_orders_page(session, offset, limit) + if not page: + break + all_orders.extend(page) + _LOGGER.info(f"Fetched {len(all_orders)} orders so far (offset={offset})") + if len(page) < limit: + break + offset += limit + # Rate limit: 200ms between pages + await asyncio.sleep(0.2) + + return all_orders + + except RequestException as err: + _LOGGER.error(f"Error during full order history fetch: {err}") + return all_orders + finally: + await self.logout(session) + await self._run_in_executor(session.close) +``` + +**Step 2: Verify syntax** + +Run: `python3 -c "import py_compile; py_compile.compile('custom_components/rohlikcz/rohlik_api.py', doraise=True); print('OK')"` + +**Step 3: Commit** + +```bash +git add custom_components/rohlikcz/rohlik_api.py +git commit -m "feat: add paginated order history fetch to API" +``` + +--- + +### Task 2: Add OrderStore to hub.py + +**Files:** +- Modify: `custom_components/rohlikcz/hub.py` + +**Step 1: Add OrderStore class and wire it into RohlikAccount** + +Add imports at top of `hub.py`: +```python +import json +import os +from datetime import datetime +from zoneinfo import ZoneInfo +``` + +Add `OrderStore` class before `RohlikAccount`: + +```python +class OrderStore: + """Persistent storage for order history in HA's .storage directory.""" + + def __init__(self, storage_dir: str, user_id: str): + self._path = os.path.join(storage_dir, f"rohlikcz_{user_id}_orders.json") + self._data = {"version": 1, "user_id": user_id, "tracking_since": None, "orders": {}} + self.load() + + def load(self) -> None: + """Load order store from disk.""" + if os.path.exists(self._path): + try: + with open(self._path, "r") as f: + self._data = json.load(f) + except (json.JSONDecodeError, OSError) as err: + _LOGGER.error(f"Failed to load order store: {err}") + + def save(self) -> None: + """Save order store to disk.""" + try: + os.makedirs(os.path.dirname(self._path), exist_ok=True) + with open(self._path, "w") as f: + json.dump(self._data, f, indent=2) + except OSError as err: + _LOGGER.error(f"Failed to save order store: {err}") + + def process_orders(self, orders: list) -> int: + """Process a list of order dicts from the API. Returns count of new orders added.""" + if not orders: + return 0 + + new_count = 0 + for order in orders: + order_id = str(order.get("id", "")) + if not order_id or order_id in self._data["orders"]: + continue + + price_comp = order.get("priceComposition", {}) + total = price_comp.get("total", {}) + amount = total.get("amount") + if amount is None: + continue + + try: + amount = float(amount) + except (ValueError, TypeError): + continue + + order_time = order.get("orderTime", "") + date_str = order_time[:10] if order_time else "" + + self._data["orders"][order_id] = {"date": date_str, "amount": amount} + new_count += 1 + + if new_count > 0: + if not self._data["tracking_since"]: + self._data["tracking_since"] = datetime.now(ZoneInfo("Europe/Prague")).isoformat() + self.save() + _LOGGER.info(f"Added {new_count} new orders to store. Total: {len(self._data['orders'])}") + + return new_count + + @property + def orders(self) -> dict: + return self._data["orders"] + + @property + def tracking_since(self) -> str | None: + return self._data.get("tracking_since") + + def yearly_total(self, year: str) -> float: + """Sum of order amounts for a given year (e.g. '2026').""" + return sum( + o["amount"] for o in self._data["orders"].values() + if o["date"].startswith(year) + ) + + def yearly_count(self, year: str) -> int: + """Count of orders for a given year.""" + return sum(1 for o in self._data["orders"].values() if o["date"].startswith(year)) + + def alltime_total(self) -> float: + """Sum of all order amounts.""" + return sum(o["amount"] for o in self._data["orders"].values()) + + def alltime_count(self) -> int: + """Count of all orders.""" + return len(self._data["orders"]) + + def first_order_date(self) -> str | None: + """Date of the earliest order.""" + dates = [o["date"] for o in self._data["orders"].values() if o["date"]] + return min(dates) if dates else None +``` + +Add to `RohlikAccount.__init__` — after `self._callbacks` line: +```python +self._order_store: OrderStore | None = None +``` + +Add new property to `RohlikAccount`: +```python +@property +def order_store(self) -> OrderStore | None: + return self._order_store +``` + +Modify `RohlikAccount.async_update` — after `self.data = await self._rohlik_api.get_data()`, add: +```python +# Initialize order store on first update (need user_id from login) +if not self._order_store and self.data.get("login"): + user_id = str(self.data["login"]["data"]["user"]["id"]) + storage_dir = self._hass.config.path(".storage") + self._order_store = OrderStore(storage_dir, user_id) + +# Process delivered orders into persistent store +if self._order_store and self.data.get("delivered_orders"): + self._order_store.process_orders(self.data["delivered_orders"]) +``` + +Add new method to `RohlikAccount`: +```python +async def fetch_full_order_history(self) -> int: + """Fetch all historical orders and store them. Returns total order count.""" + all_orders = await self._rohlik_api.fetch_all_delivered_orders() + if self._order_store and all_orders: + new = self._order_store.process_orders(all_orders) + await self.publish_updates() + return self._order_store.alltime_count() + return 0 +``` + +**Step 2: Verify syntax** + +Run: `python3 -c "import py_compile; py_compile.compile('custom_components/rohlikcz/hub.py', doraise=True); print('OK')"` + +**Step 3: Commit** + +```bash +git add custom_components/rohlikcz/hub.py +git commit -m "feat: add OrderStore with persistent JSON storage" +``` + +--- + +### Task 3: Add YearlySpent and AllTimeSpent sensors + +**Files:** +- Modify: `custom_components/rohlikcz/sensor.py` +- Modify: `custom_components/rohlikcz/const.py` + +**Step 1: Add constants to `const.py`** + +Add to the icons section: +```python +ICON_YEARLY_SPENT = "mdi:calendar-text" +ICON_ALLTIME_SPENT = "mdi:chart-line" +``` + +**Step 2: Add sensor classes to `sensor.py`** + +Add imports at top (after existing imports): +```python +# No new imports needed — datetime, ZoneInfo, Mapping, Any already imported +``` + +Add to the entities list in `async_setup_entry` (after the `MonthlySpent` line): +```python + YearlySpent(rohlik_hub), + AllTimeSpent(rohlik_hub), +``` + +Add import of new icons in the existing import line from `.const`: +```python +ICON_YEARLY_SPENT, ICON_ALLTIME_SPENT +``` + +Add after the `MonthlySpent` class (before `NoLimitOrders`): + +```python +class YearlySpent(BaseEntity, SensorEntity): + """Sensor for amount spent in current year from persistent order store.""" + + _attr_translation_key = "yearly_spent" + _attr_should_poll = False + _attr_state_class = SensorStateClass.TOTAL + + @property + def native_value(self) -> float | None: + """Returns amount spent in current year.""" + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + return store.yearly_total(year) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + count = store.yearly_count(year) + total = store.yearly_total(year) + return { + "year": year, + "order_count": count, + "average_order_value": round(total / count, 2) if count > 0 else 0.0, + } + + @property + def icon(self) -> str: + return ICON_YEARLY_SPENT + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class AllTimeSpent(BaseEntity, SensorEntity): + """Sensor for total amount spent across all tracked orders.""" + + _attr_translation_key = "alltime_spent" + _attr_should_poll = False + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + @property + def native_value(self) -> float | None: + """Returns total amount spent across all orders.""" + store = self._rohlik_account.order_store + if not store: + return None + return store.alltime_total() + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + count = store.alltime_count() + total = store.alltime_total() + return { + "order_count": count, + "average_order_value": round(total / count, 2) if count > 0 else 0.0, + "first_order_date": store.first_order_date(), + "tracking_since": store.tracking_since, + } + + @property + def icon(self) -> str: + return ICON_ALLTIME_SPENT + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) +``` + +**Step 3: Verify syntax** + +Run: `python3 -c "import py_compile; py_compile.compile('custom_components/rohlikcz/sensor.py', doraise=True); py_compile.compile('custom_components/rohlikcz/const.py', doraise=True); print('OK')"` + +**Step 4: Commit** + +```bash +git add custom_components/rohlikcz/sensor.py custom_components/rohlikcz/const.py +git commit -m "feat: add YearlySpent and AllTimeSpent sensors" +``` + +--- + +### Task 4: Add `fetch_order_history` service + +**Files:** +- Modify: `custom_components/rohlikcz/services.py` +- Modify: `custom_components/rohlikcz/services.yaml` +- Modify: `custom_components/rohlikcz/const.py` + +**Step 1: Add constant to `const.py`** + +```python +SERVICE_FETCH_ORDER_HISTORY = "fetch_order_history" +``` + +**Step 2: Add service handler to `services.py`** + +Add import of `SERVICE_FETCH_ORDER_HISTORY` to the existing import line from `.const`. + +Add service function inside `register_services` (before the `# Register the services` comment): + +```python + async def async_fetch_order_history(call: ServiceCall) -> Dict[str, Any]: + """Fetch complete order history from Rohlik.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + + if config_entry_id not in hass.data[DOMAIN]: + raise HomeAssistantError(f"Config entry {config_entry_id} not found") + + account = hass.data[DOMAIN][config_entry_id] + try: + total = await account.fetch_full_order_history() + return {"total_orders": total} + except Exception as err: + _LOGGER.error(f"Failed to fetch order history: {err}") + raise HomeAssistantError(f"Failed to fetch order history: {err}") +``` + +Add registration at the bottom (after `SERVICE_UPDATE_DATA` registration): + +```python + hass.services.async_register( + DOMAIN, + SERVICE_FETCH_ORDER_HISTORY, + async_fetch_order_history, + schema=vol.Schema({ + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + }), + supports_response=True + ) +``` + +**Step 3: Add service YAML definition to `services.yaml`** + +Append to `services.yaml`: + +```yaml + +fetch_order_history: + name: Fetch order history + description: Fetch complete order history from Rohlik.cz and store locally. Run once to backfill historical data. + fields: + config_entry_id: + name: Account + description: The Rohlik account to use + required: true + selector: + config_entry: + integration: rohlikcz +``` + +**Step 4: Verify syntax** + +Run: `python3 -c "import py_compile; py_compile.compile('custom_components/rohlikcz/services.py', doraise=True); print('OK')"` + +**Step 5: Commit** + +```bash +git add custom_components/rohlikcz/services.py custom_components/rohlikcz/services.yaml custom_components/rohlikcz/const.py +git commit -m "feat: add fetch_order_history service for historical backfill" +``` + +--- + +### Task 5: Add translations + +**Files:** +- Modify: `custom_components/rohlikcz/translations/en.json` +- Modify: `custom_components/rohlikcz/translations/cs.json` + +**Step 1: Add English translations** + +Add to `entity.sensor` section in `en.json`: + +```json + "yearly_spent": { + "name": "Spent This Year", + "unit_of_measurement": "CZK" + }, + "alltime_spent": { + "name": "Spent All Time", + "unit_of_measurement": "CZK" + } +``` + +**Step 2: Add Czech translations** + +Add to `entity.sensor` section in `cs.json`: + +```json + "yearly_spent": { + "name": "Roční útrata", + "unit_of_measurement": "Kč" + }, + "alltime_spent": { + "name": "Celková útrata", + "unit_of_measurement": "Kč" + } +``` + +**Step 3: Commit** + +```bash +git add custom_components/rohlikcz/translations/ +git commit -m "feat: add translations for new spending sensors" +``` + +--- + +### Task 6: Test locally with standalone script + +**Files:** +- Create: `tests/test_order_store.py` + +**Step 1: Write standalone test for OrderStore logic** + +```python +"""Standalone test for OrderStore — run without HA.""" +import json +import os +import tempfile +import sys + +# Minimal test — no HA dependencies +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +def test_order_store(): + """Test OrderStore process, yearly, alltime logic.""" + # Inline the logic since we can't import HA-dependent modules directly + tmpdir = tempfile.mkdtemp() + store_path = os.path.join(tmpdir, "test_orders.json") + + # Simulate order data as returned by Rohlik API + orders = [ + {"id": 1001, "orderTime": "2026-01-15T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 500.0}}}, + {"id": 1002, "orderTime": "2026-02-20T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 1200.0}}}, + {"id": 1003, "orderTime": "2025-12-01T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 800.0}}}, + {"id": 1003, "orderTime": "2025-12-01T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 800.0}}}, # duplicate + {"id": 1004, "orderTime": "2025-06-01T10:00:00.000+01:00", + "priceComposition": {"total": None}}, # no amount — should skip + ] + + # Process + data = {"version": 1, "user_id": "test", "tracking_since": None, "orders": {}} + new_count = 0 + for order in orders: + order_id = str(order.get("id", "")) + if not order_id or order_id in data["orders"]: + continue + price_comp = order.get("priceComposition", {}) + total = price_comp.get("total", {}) + if not isinstance(total, dict): + continue + amount = total.get("amount") + if amount is None: + continue + try: + amount = float(amount) + except (ValueError, TypeError): + continue + order_time = order.get("orderTime", "") + date_str = order_time[:10] if order_time else "" + data["orders"][order_id] = {"date": date_str, "amount": amount} + new_count += 1 + + # Assertions + assert new_count == 3, f"Expected 3 new orders, got {new_count}" + assert len(data["orders"]) == 3, f"Expected 3 orders in store, got {len(data['orders'])}" + + # Yearly total for 2026 + yearly_2026 = sum(o["amount"] for o in data["orders"].values() if o["date"].startswith("2026")) + assert yearly_2026 == 1700.0, f"Expected 1700.0, got {yearly_2026}" + + # Yearly total for 2025 + yearly_2025 = sum(o["amount"] for o in data["orders"].values() if o["date"].startswith("2025")) + assert yearly_2025 == 800.0, f"Expected 800.0, got {yearly_2025}" + + # Alltime total + alltime = sum(o["amount"] for o in data["orders"].values()) + assert alltime == 2500.0, f"Expected 2500.0, got {alltime}" + + # Save and reload + with open(store_path, "w") as f: + json.dump(data, f) + with open(store_path, "r") as f: + reloaded = json.load(f) + assert len(reloaded["orders"]) == 3 + + # Cleanup + os.remove(store_path) + os.rmdir(tmpdir) + print("All tests passed!") + +if __name__ == "__main__": + test_order_store() +``` + +**Step 2: Run the test** + +Run: `python3 tests/test_order_store.py` +Expected: `All tests passed!` + +**Step 3: Commit** + +```bash +git add tests/test_order_store.py +git commit -m "test: add standalone OrderStore logic test" +``` + +--- + +### Task 7: Deploy to HA and verify + +**Step 1: Copy all modified files to HA** + +```bash +cp custom_components/rohlikcz/rohlik_api.py /Volumes/config/custom_components/rohlikcz/ +cp custom_components/rohlikcz/hub.py /Volumes/config/custom_components/rohlikcz/ +cp custom_components/rohlikcz/sensor.py /Volumes/config/custom_components/rohlikcz/ +cp custom_components/rohlikcz/const.py /Volumes/config/custom_components/rohlikcz/ +cp custom_components/rohlikcz/services.py /Volumes/config/custom_components/rohlikcz/ +cp custom_components/rohlikcz/services.yaml /Volumes/config/custom_components/rohlikcz/ +cp custom_components/rohlikcz/translations/en.json /Volumes/config/custom_components/rohlikcz/translations/ +cp custom_components/rohlikcz/translations/cs.json /Volumes/config/custom_components/rohlikcz/translations/ +``` + +**Step 2: Restart HA** (via MCP `ha_restart`) + +**Step 3: Verify new sensors exist** + +Check via MCP: +- `ha_search_entities(query="rohlik spent")` — should show 3 sensors (monthly, yearly, alltime) +- `ha_get_state("sensor.rohlik_spent_this_year")` — should show current year total (from the 50 most recent orders) +- `ha_get_state("sensor.rohlik_spent_all_time")` — should show total across all stored orders + +**Step 4: Trigger historical backfill** + +Call via MCP: `ha_call_service("rohlikcz", "fetch_order_history", data={"config_entry_id": ""})` + +**Step 5: Verify backfill worked** + +- Check `ha_get_state("sensor.rohlik_spent_all_time")` — attributes should show `order_count` >> 12 +- Check file exists: `cat /Volumes/config/.storage/rohlikcz_1086873_orders.json | python3 -m json.tool | head -20` + +**Step 6: Commit final state** + +```bash +git add -A +git commit -m "feat: order history sensors verified on live HA" +``` From 9f989f5ba60045cc3295ddd658d7054e46154989 Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:47:49 +0100 Subject: [PATCH 03/11] feat: add paginated order history fetch to API Co-Authored-By: Claude Opus 4.6 --- custom_components/rohlikcz/rohlik_api.py | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/custom_components/rohlikcz/rohlik_api.py b/custom_components/rohlikcz/rohlik_api.py index 4cad2e3..9c46da3 100644 --- a/custom_components/rohlikcz/rohlik_api.py +++ b/custom_components/rohlikcz/rohlik_api.py @@ -231,6 +231,48 @@ async def get_data(self): await self.logout(session) await self._run_in_executor(session.close) + async def get_delivered_orders_page(self, session, offset: int = 0, limit: int = 50) -> list: + """Fetch a page of delivered orders using an existing authenticated session.""" + url = f"{BASE_URL}/api/v3/orders/delivered?offset={offset}&limit={limit}" + try: + response = await self._run_in_executor(session.get, url) + response.raise_for_status() + return response.json() + except RequestException as err: + _LOGGER.error(f"Error fetching delivered orders page (offset={offset}): {err}") + return [] + + async def fetch_all_delivered_orders(self) -> list: + """Fetch ALL delivered orders by paginating through the API. Returns list of all orders.""" + session = requests.Session() + all_orders = [] + offset = 0 + limit = 50 + + try: + await self.login(session) + + while True: + page = await self.get_delivered_orders_page(session, offset, limit) + if not page: + break + all_orders.extend(page) + _LOGGER.info(f"Fetched {len(all_orders)} orders so far (offset={offset})") + if len(page) < limit: + break + offset += limit + # Rate limit: 200ms between pages + await asyncio.sleep(0.2) + + return all_orders + + except RequestException as err: + _LOGGER.error(f"Error during full order history fetch: {err}") + return all_orders + finally: + await self.logout(session) + await self._run_in_executor(session.close) + async def add_to_cart(self, product_list: list[dict]) -> dict: """ Add multiple products to the shopping cart. From 9d139f8dc30ce49e4fb77165285542566ccbb06b Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:50:04 +0100 Subject: [PATCH 04/11] feat: add OrderStore with persistent JSON storage Co-Authored-By: Claude Opus 4.6 --- custom_components/rohlikcz/hub.py | 126 ++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/custom_components/rohlikcz/hub.py b/custom_components/rohlikcz/hub.py index 0845367..b9511ad 100644 --- a/custom_components/rohlikcz/hub.py +++ b/custom_components/rohlikcz/hub.py @@ -1,12 +1,114 @@ from __future__ import annotations +import json +import logging +import os from collections.abc import Callable +from datetime import datetime from typing import Any, cast, List, Optional, Dict +from zoneinfo import ZoneInfo from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN from .rohlik_api import RohlikCZAPI +_LOGGER = logging.getLogger(__name__) + + +class OrderStore: + """Persistent storage for order history in HA's .storage directory.""" + + def __init__(self, storage_dir: str, user_id: str): + self._path = os.path.join(storage_dir, f"rohlikcz_{user_id}_orders.json") + self._data = {"version": 1, "user_id": user_id, "tracking_since": None, "orders": {}} + self.load() + + def load(self) -> None: + """Load order store from disk.""" + if os.path.exists(self._path): + try: + with open(self._path, "r") as f: + self._data = json.load(f) + except (json.JSONDecodeError, OSError) as err: + _LOGGER.error(f"Failed to load order store: {err}") + + def save(self) -> None: + """Save order store to disk.""" + try: + os.makedirs(os.path.dirname(self._path), exist_ok=True) + with open(self._path, "w") as f: + json.dump(self._data, f, indent=2) + except OSError as err: + _LOGGER.error(f"Failed to save order store: {err}") + + def process_orders(self, orders: list) -> int: + """Process a list of order dicts from the API. Returns count of new orders added.""" + if not orders: + return 0 + + new_count = 0 + for order in orders: + order_id = str(order.get("id", "")) + if not order_id or order_id in self._data["orders"]: + continue + + price_comp = order.get("priceComposition", {}) + total = price_comp.get("total", {}) + amount = total.get("amount") + if amount is None: + continue + + try: + amount = float(amount) + except (ValueError, TypeError): + continue + + order_time = order.get("orderTime", "") + date_str = order_time[:10] if order_time else "" + + self._data["orders"][order_id] = {"date": date_str, "amount": amount} + new_count += 1 + + if new_count > 0: + if not self._data["tracking_since"]: + self._data["tracking_since"] = datetime.now(ZoneInfo("Europe/Prague")).isoformat() + self.save() + _LOGGER.info(f"Added {new_count} new orders to store. Total: {len(self._data['orders'])}") + + return new_count + + @property + def orders(self) -> dict: + return self._data["orders"] + + @property + def tracking_since(self) -> str | None: + return self._data.get("tracking_since") + + def yearly_total(self, year: str) -> float: + """Sum of order amounts for a given year (e.g. '2026').""" + return sum( + o["amount"] for o in self._data["orders"].values() + if o["date"].startswith(year) + ) + + def yearly_count(self, year: str) -> int: + """Count of orders for a given year.""" + return sum(1 for o in self._data["orders"].values() if o["date"].startswith(year)) + + def alltime_total(self) -> float: + """Sum of all order amounts.""" + return sum(o["amount"] for o in self._data["orders"].values()) + + def alltime_count(self) -> int: + """Count of all orders.""" + return len(self._data["orders"]) + + def first_order_date(self) -> str | None: + """Date of the earliest order.""" + dates = [o["date"] for o in self._data["orders"].values() if o["date"]] + return min(dates) if dates else None + class RohlikAccount: """Setting RohlikCZ account as device.""" @@ -20,6 +122,7 @@ def __init__(self, hass: HomeAssistant, username: str, password: str) -> None: self._rohlik_api = RohlikCZAPI(self._username, self._password) self.data: dict = {} self._callbacks: set[Callable[[], None]] = set() + self._order_store: OrderStore | None = None @property def has_address(self): @@ -47,13 +150,36 @@ def unique_id(self) -> str: def is_ordered(self) -> bool: return len(self.data.get('next_order', [])) > 0 + @property + def order_store(self) -> OrderStore | None: + return self._order_store + async def async_update(self) -> None: """ Updates the data from API.""" self.data = await self._rohlik_api.get_data() + # Initialize order store on first update (need user_id from login) + if not self._order_store and self.data.get("login"): + user_id = str(self.data["login"]["data"]["user"]["id"]) + storage_dir = self._hass.config.path(".storage") + self._order_store = OrderStore(storage_dir, user_id) + + # Process delivered orders into persistent store + if self._order_store and self.data.get("delivered_orders"): + self._order_store.process_orders(self.data["delivered_orders"]) + await self.publish_updates() + async def fetch_full_order_history(self) -> int: + """Fetch all historical orders and store them. Returns total order count.""" + all_orders = await self._rohlik_api.fetch_all_delivered_orders() + if self._order_store and all_orders: + new = self._order_store.process_orders(all_orders) + await self.publish_updates() + return self._order_store.alltime_count() + return 0 + def register_callback(self, callback: Callable[[], None]) -> None: """Register callback, called when there are new data.""" self._callbacks.add(callback) From 504efb77e36c7dfc253d019d56f9055817db8f7c Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:51:39 +0100 Subject: [PATCH 05/11] feat: add YearlySpent and AllTimeSpent sensors Co-Authored-By: Claude Opus 4.6 --- custom_components/rohlikcz/const.py | 2 + custom_components/rohlikcz/sensor.py | 87 +++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/custom_components/rohlikcz/const.py b/custom_components/rohlikcz/const.py index 11c2f55..f0fede6 100644 --- a/custom_components/rohlikcz/const.py +++ b/custom_components/rohlikcz/const.py @@ -35,6 +35,8 @@ ICON_INFO = "mdi:information-outline" ICON_DELIVERY_TIME = "mdi:timer-sand" ICON_MONTHLY_SPENT = "mdi:cash-register" +ICON_YEARLY_SPENT = "mdi:calendar-text" +ICON_ALLTIME_SPENT = "mdi:chart-line" ICON_DELIVERY_CALENDAR = "mdi:calendar-clock" """ Service attributes """ diff --git a/custom_components/rohlikcz/sensor.py b/custom_components/rohlikcz/sensor.py index be194aa..ade26d7 100644 --- a/custom_components/rohlikcz/sensor.py +++ b/custom_components/rohlikcz/sensor.py @@ -17,7 +17,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import DOMAIN, ICON_UPDATE, ICON_CREDIT, ICON_NO_LIMIT, ICON_FREE_EXPRESS, ICON_DELIVERY, ICON_BAGS, \ ICON_CART, ICON_ACCOUNT, ICON_EMAIL, ICON_PHONE, ICON_PREMIUM_DAYS, ICON_LAST_ORDER, ICON_NEXT_ORDER_SINCE, \ - ICON_NEXT_ORDER_TILL, ICON_INFO, ICON_DELIVERY_TIME, ICON_MONTHLY_SPENT + ICON_NEXT_ORDER_TILL, ICON_INFO, ICON_DELIVERY_TIME, ICON_MONTHLY_SPENT, ICON_YEARLY_SPENT, ICON_ALLTIME_SPENT from .entity import BaseEntity from .hub import RohlikAccount from .utils import extract_delivery_datetime, get_earliest_order, parse_delivery_datetime_string @@ -50,7 +50,9 @@ async def async_setup_entry( NextOrderSince(rohlik_hub), DeliveryInfo(rohlik_hub), DeliveryTime(rohlik_hub), - MonthlySpent(rohlik_hub) + MonthlySpent(rohlik_hub), + YearlySpent(rohlik_hub), + AllTimeSpent(rohlik_hub), ] if rohlik_hub.has_address: @@ -630,6 +632,87 @@ async def async_will_remove_from_hass(self) -> None: self._rohlik_account.remove_callback(self.async_write_ha_state) +class YearlySpent(BaseEntity, SensorEntity): + """Sensor for amount spent in current year from persistent order store.""" + + _attr_translation_key = "yearly_spent" + _attr_should_poll = False + _attr_state_class = SensorStateClass.TOTAL + + @property + def native_value(self) -> float | None: + """Returns amount spent in current year.""" + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + return store.yearly_total(year) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + count = store.yearly_count(year) + total = store.yearly_total(year) + return { + "year": year, + "order_count": count, + "average_order_value": round(total / count, 2) if count > 0 else 0.0, + } + + @property + def icon(self) -> str: + return ICON_YEARLY_SPENT + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class AllTimeSpent(BaseEntity, SensorEntity): + """Sensor for total amount spent across all tracked orders.""" + + _attr_translation_key = "alltime_spent" + _attr_should_poll = False + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + @property + def native_value(self) -> float | None: + """Returns total amount spent across all orders.""" + store = self._rohlik_account.order_store + if not store: + return None + return store.alltime_total() + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + count = store.alltime_count() + total = store.alltime_total() + return { + "order_count": count, + "average_order_value": round(total / count, 2) if count > 0 else 0.0, + "first_order_date": store.first_order_date(), + "tracking_since": store.tracking_since, + } + + @property + def icon(self) -> str: + return ICON_ALLTIME_SPENT + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + class NoLimitOrders(BaseEntity, SensorEntity): """Sensor for remaining no limit orders.""" From d04c9d6b73a79937e1a6ede8e9063a910ede874a Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:52:42 +0100 Subject: [PATCH 06/11] feat: add translations for new spending sensors Co-Authored-By: Claude Opus 4.6 --- custom_components/rohlikcz/translations/cs.json | 8 ++++++++ custom_components/rohlikcz/translations/en.json | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/custom_components/rohlikcz/translations/cs.json b/custom_components/rohlikcz/translations/cs.json index 47ad5d1..96c1a1a 100644 --- a/custom_components/rohlikcz/translations/cs.json +++ b/custom_components/rohlikcz/translations/cs.json @@ -132,6 +132,14 @@ "monthly_spent": { "name": "Měsíční útrata", "unit_of_measurement": "Kč" + }, + "yearly_spent": { + "name": "Roční útrata", + "unit_of_measurement": "Kč" + }, + "alltime_spent": { + "name": "Celková útrata", + "unit_of_measurement": "Kč" } }, "todo": { diff --git a/custom_components/rohlikcz/translations/en.json b/custom_components/rohlikcz/translations/en.json index 8cfacd1..d46e605 100644 --- a/custom_components/rohlikcz/translations/en.json +++ b/custom_components/rohlikcz/translations/en.json @@ -132,6 +132,14 @@ "monthly_spent": { "name": "Spent This Month", "unit_of_measurement": "CZK" + }, + "yearly_spent": { + "name": "Spent This Year", + "unit_of_measurement": "CZK" + }, + "alltime_spent": { + "name": "Spent All Time", + "unit_of_measurement": "CZK" } }, "todo": { From f1d7c164da751376da1ea1297c872f2ff696e02e Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:53:06 +0100 Subject: [PATCH 07/11] feat: add fetch_order_history service for historical backfill Co-Authored-By: Claude Opus 4.6 --- custom_components/rohlikcz/const.py | 1 + custom_components/rohlikcz/services.py | 27 +++++++++++++++++++++++- custom_components/rohlikcz/services.yaml | 14 +++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/custom_components/rohlikcz/const.py b/custom_components/rohlikcz/const.py index f0fede6..e492ab6 100644 --- a/custom_components/rohlikcz/const.py +++ b/custom_components/rohlikcz/const.py @@ -55,3 +55,4 @@ SERVICE_GET_CART_CONTENT = "get_cart_content" SERVICE_SEARCH_AND_ADD_PRODUCT = "search_and_add_to_cart" SERVICE_UPDATE_DATA = "update_data" +SERVICE_FETCH_ORDER_HISTORY = "fetch_order_history" diff --git a/custom_components/rohlikcz/services.py b/custom_components/rohlikcz/services.py index 67f8eb1..619d8ba 100644 --- a/custom_components/rohlikcz/services.py +++ b/custom_components/rohlikcz/services.py @@ -10,7 +10,7 @@ from .const import DOMAIN, ATTR_CONFIG_ENTRY_ID, ATTR_PRODUCT_ID, ATTR_QUANTITY, ATTR_PRODUCT_NAME, \ ATTR_SHOPPING_LIST_ID, ATTR_LIMIT, ATTR_FAVOURITE_ONLY, SERVICE_ADD_TO_CART, SERVICE_SEARCH_PRODUCT, SERVICE_GET_SHOPPING_LIST, \ - SERVICE_GET_CART_CONTENT, SERVICE_SEARCH_AND_ADD_PRODUCT, SERVICE_UPDATE_DATA + SERVICE_GET_CART_CONTENT, SERVICE_SEARCH_AND_ADD_PRODUCT, SERVICE_UPDATE_DATA, SERVICE_FETCH_ORDER_HISTORY _LOGGER = logging.getLogger(__name__) @@ -131,6 +131,21 @@ async def async_update_data(call: ServiceCall) -> None: raise HomeAssistantError(f"Failed to update data: {err}") + async def async_fetch_order_history(call: ServiceCall) -> Dict[str, Any]: + """Fetch complete order history from Rohlik.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + + if config_entry_id not in hass.data[DOMAIN]: + raise HomeAssistantError(f"Config entry {config_entry_id} not found") + + account = hass.data[DOMAIN][config_entry_id] + try: + total = await account.fetch_full_order_history() + return {"total_orders": total} + except Exception as err: + _LOGGER.error(f"Failed to fetch order history: {err}") + raise HomeAssistantError(f"Failed to fetch order history: {err}") + # Register the services hass.services.async_register( DOMAIN, @@ -199,4 +214,14 @@ async def async_update_data(call: ServiceCall) -> None: vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string }), supports_response=SupportsResponse.NONE + ) + + hass.services.async_register( + DOMAIN, + SERVICE_FETCH_ORDER_HISTORY, + async_fetch_order_history, + schema=vol.Schema({ + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + }), + supports_response=True ) \ No newline at end of file diff --git a/custom_components/rohlikcz/services.yaml b/custom_components/rohlikcz/services.yaml index bf9a6b1..e979a2e 100644 --- a/custom_components/rohlikcz/services.yaml +++ b/custom_components/rohlikcz/services.yaml @@ -142,4 +142,16 @@ search_and_add_to_cart: required: false example: false selector: - boolean: \ No newline at end of file + boolean: + +fetch_order_history: + name: Fetch order history + description: Fetch complete order history from Rohlik.cz and store locally. Run once to backfill historical data. + fields: + config_entry_id: + name: Account + description: The Rohlik account to use + required: true + selector: + config_entry: + integration: rohlikcz \ No newline at end of file From a9cba04844cf35be4689d4c1dbbbdd5cb14bf220 Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:54:09 +0100 Subject: [PATCH 08/11] test: add standalone OrderStore logic test Co-Authored-By: Claude Opus 4.6 --- tests/test_order_store.py | 99 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/test_order_store.py diff --git a/tests/test_order_store.py b/tests/test_order_store.py new file mode 100644 index 0000000..ef049d2 --- /dev/null +++ b/tests/test_order_store.py @@ -0,0 +1,99 @@ +"""Standalone test for OrderStore logic — run without HA dependencies.""" +import json +import os +import tempfile + + +def test_order_store(): + """Test order processing, deduplication, yearly/alltime aggregation.""" + tmpdir = tempfile.mkdtemp() + store_path = os.path.join(tmpdir, "test_orders.json") + + # Simulate order data as returned by Rohlik API + orders = [ + {"id": 1001, "orderTime": "2026-01-15T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 500.0}}}, + {"id": 1002, "orderTime": "2026-02-20T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 1200.0}}}, + {"id": 1003, "orderTime": "2025-12-01T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 800.0}}}, + # Duplicate — should be skipped + {"id": 1003, "orderTime": "2025-12-01T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 800.0}}}, + # No amount — should be skipped + {"id": 1004, "orderTime": "2025-06-01T10:00:00.000+01:00", + "priceComposition": {"total": None}}, + # Missing priceComposition — should be skipped + {"id": 1005, "orderTime": "2025-05-01T10:00:00.000+01:00"}, + # Empty id — should be skipped + {"id": "", "orderTime": "2025-04-01T10:00:00.000+01:00", + "priceComposition": {"total": {"amount": 100.0}}}, + ] + + # Replicate OrderStore.process_orders logic + data = {"version": 1, "user_id": "test", "tracking_since": None, "orders": {}} + new_count = 0 + for order in orders: + order_id = str(order.get("id", "")) + if not order_id or order_id in data["orders"]: + continue + price_comp = order.get("priceComposition", {}) + total = price_comp.get("total", {}) + if not isinstance(total, dict): + continue + amount = total.get("amount") + if amount is None: + continue + try: + amount = float(amount) + except (ValueError, TypeError): + continue + order_time = order.get("orderTime", "") + date_str = order_time[:10] if order_time else "" + data["orders"][order_id] = {"date": date_str, "amount": amount} + new_count += 1 + + # Test: correct number of new orders (3 valid, skip duplicate + null + missing + empty) + assert new_count == 3, f"Expected 3 new orders, got {new_count}" + assert len(data["orders"]) == 3, f"Expected 3 in store, got {len(data['orders'])}" + + # Test: yearly totals + yearly_2026 = sum(o["amount"] for o in data["orders"].values() if o["date"].startswith("2026")) + assert yearly_2026 == 1700.0, f"Expected 2026 total 1700.0, got {yearly_2026}" + + yearly_2025 = sum(o["amount"] for o in data["orders"].values() if o["date"].startswith("2025")) + assert yearly_2025 == 800.0, f"Expected 2025 total 800.0, got {yearly_2025}" + + # Test: alltime total + alltime = sum(o["amount"] for o in data["orders"].values()) + assert alltime == 2500.0, f"Expected alltime 2500.0, got {alltime}" + + # Test: first order date + dates = [o["date"] for o in data["orders"].values() if o["date"]] + first = min(dates) + assert first == "2025-12-01", f"Expected first date 2025-12-01, got {first}" + + # Test: save and reload persistence + with open(store_path, "w") as f: + json.dump(data, f) + with open(store_path, "r") as f: + reloaded = json.load(f) + assert len(reloaded["orders"]) == 3, "Persistence failed" + + # Test: processing same orders again adds nothing (deduplication) + second_count = 0 + for order in orders[:3]: + order_id = str(order.get("id", "")) + if not order_id or order_id in data["orders"]: + continue + second_count += 1 + assert second_count == 0, f"Dedup failed: got {second_count} new on re-process" + + # Cleanup + os.remove(store_path) + os.rmdir(tmpdir) + print("All tests passed!") + + +if __name__ == "__main__": + test_order_store() From 9c4f4ce11dbf6ccf85e8347806786c8d22f13717 Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:17:54 +0100 Subject: [PATCH 09/11] docs: remove personal data from plan docs Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-02-26-order-history-sensors-design.md | 2 +- docs/plans/2026-02-26-order-history-sensors.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-02-26-order-history-sensors-design.md b/docs/plans/2026-02-26-order-history-sensors-design.md index cd90b72..5cb8c2e 100644 --- a/docs/plans/2026-02-26-order-history-sensors-design.md +++ b/docs/plans/2026-02-26-order-history-sensors-design.md @@ -35,7 +35,7 @@ JSON file at `/config/.storage/rohlikcz_{user_id}_orders.json`: ```json { "version": 1, - "user_id": "1086873", + "user_id": "", "tracking_since": "2026-02-26T12:00:00+01:00", "orders": { "1119530344": {"date": "2026-02-25", "amount": 1523.50}, diff --git a/docs/plans/2026-02-26-order-history-sensors.md b/docs/plans/2026-02-26-order-history-sensors.md index 8838523..f9f1331 100644 --- a/docs/plans/2026-02-26-order-history-sensors.md +++ b/docs/plans/2026-02-26-order-history-sensors.md @@ -632,7 +632,7 @@ Call via MCP: `ha_call_service("rohlikcz", "fetch_order_history", data={"config_ **Step 5: Verify backfill worked** - Check `ha_get_state("sensor.rohlik_spent_all_time")` — attributes should show `order_count` >> 12 -- Check file exists: `cat /Volumes/config/.storage/rohlikcz_1086873_orders.json | python3 -m json.tool | head -20` +- Check file exists: `cat /Volumes/config/.storage/rohlikcz__orders.json | python3 -m json.tool | head -20` **Step 6: Commit final state** From 2ccc1b001ffc4070228799211721de52c6d13ce0 Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:00:23 +0100 Subject: [PATCH 10/11] feat: add spending analytics with configurable options Add opt-in spending analytics with category breakdown sensors (L0-L3) and per-item spending sensors. Users can configure analytics levels, top N items to display (default 10), and hide discontinued products via the config flow wizard and options flow. Full order history is fetched in the background when analytics is enabled. Co-Authored-By: Claude Opus 4.6 --- custom_components/rohlikcz/__init__.py | 46 +- custom_components/rohlikcz/config_flow.py | 138 ++- custom_components/rohlikcz/const.py | 16 + custom_components/rohlikcz/hub.py | 377 +++++++- custom_components/rohlikcz/manifest.json | 2 +- custom_components/rohlikcz/rohlik_api.py | 117 +++ custom_components/rohlikcz/sensor.py | 455 ++++++++- custom_components/rohlikcz/services.py | 37 +- custom_components/rohlikcz/services.yaml | 12 + .../rohlikcz/translations/cs.json | 71 ++ .../rohlikcz/translations/en.json | 71 ++ .../2026-02-26-category-item-analytics.md | 322 +++++++ ...026-02-26-config-flow-analytics-options.md | 910 ++++++++++++++++++ readme.md | 24 +- 14 files changed, 2539 insertions(+), 59 deletions(-) create mode 100644 docs/plans/2026-02-26-category-item-analytics.md create mode 100644 docs/plans/2026-02-26-config-flow-analytics-options.md diff --git a/custom_components/rohlikcz/__init__.py b/custom_components/rohlikcz/__init__.py index 4ad2d50..b63ed70 100644 --- a/custom_components/rohlikcz/__init__.py +++ b/custom_components/rohlikcz/__init__.py @@ -7,7 +7,10 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import ( + DOMAIN, CONF_ANALYTICS, DEFAULT_ANALYTICS, + CONF_TOP_N, DEFAULT_TOP_N, CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED, +) from .hub import RohlikAccount from .services import register_services @@ -16,9 +19,26 @@ PLATFORMS: list[str] = ["sensor", "binary_sensor", "todo", "calendar"] +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entries to new format.""" + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version < 1: + # Pre-analytics entries: set empty analytics (opt-in) + new_options = {**entry.options, CONF_ANALYTICS: DEFAULT_ANALYTICS} + hass.config_entries.async_update_entry(entry, options=new_options, version=1) + _LOGGER.info("Migrated config entry to version 1 (analytics disabled by default)") + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Rohlik integration from a config entry flow.""" - rohlik_hub = RohlikAccount(hass, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) + analytics = entry.options.get(CONF_ANALYTICS, DEFAULT_ANALYTICS) + + top_n = int(entry.options.get(CONF_TOP_N, DEFAULT_TOP_N)) + hide_discontinued = entry.options.get(CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED) + rohlik_hub = RohlikAccount(hass, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], analytics=analytics, top_n=top_n, hide_discontinued=hide_discontinued) await rohlik_hub.async_update() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = rohlik_hub @@ -29,9 +49,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.info("Setting up platforms: %s", PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) _LOGGER.info("Platforms setup complete") + + # If analytics enabled, fetch full order history + enrich in background + if analytics: + async def _fetch_history(): + try: + if rohlik_hub.order_store: + await rohlik_hub.fetch_full_order_history(hass=hass) + except Exception as err: + _LOGGER.error("Background order history fetch failed: %s", err) + + entry.async_create_background_task(hass, _fetch_history(), "rohlik_fetch_history") + + # Reload when options change (user reconfigures analytics) + entry.async_on_unload(entry.add_update_listener(_async_reload_entry)) + return True +async def _async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the config entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -39,5 +79,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - diff --git a/custom_components/rohlikcz/config_flow.py b/custom_components/rohlikcz/config_flow.py index af6b75d..49acc0f 100644 --- a/custom_components/rohlikcz/config_flow.py +++ b/custom_components/rohlikcz/config_flow.py @@ -3,61 +3,155 @@ from homeassistant.const import CONF_PASSWORD, CONF_EMAIL from homeassistant import config_entries -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) import voluptuous as vol -from .const import DOMAIN +from .const import ( + DOMAIN, CONF_ANALYTICS, ANALYTICS_OPTIONS, DEFAULT_ANALYTICS, + CONF_TOP_N, DEFAULT_TOP_N, CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED, +) from .errors import InvalidCredentialsError from .rohlik_api import RohlikCZAPI - - _LOGGER = logging.getLogger(__name__) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[str, dict[str, Any]]: - """Validate the user input allows us to connect. - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - api = RohlikCZAPI(data[CONF_EMAIL], data[CONF_PASSWORD]) # type: ignore[Any] - + """Validate the user input allows us to connect.""" + api = RohlikCZAPI(data[CONF_EMAIL], data[CONF_PASSWORD]) reply = await api.get_data() - title: str = reply["login"]["data"]["user"]["name"] - return title, data +ANALYTICS_SCHEMA = vol.Schema({ + vol.Optional(CONF_ANALYTICS, default=DEFAULT_ANALYTICS): SelectSelector( + SelectSelectorConfig( + options=ANALYTICS_OPTIONS, + multiple=True, + mode=SelectSelectorMode.LIST, + translation_key=CONF_ANALYTICS, + ) + ), + vol.Optional(CONF_TOP_N, default=DEFAULT_TOP_N): NumberSelector( + NumberSelectorConfig( + min=5, + max=200, + step=5, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Optional(CONF_HIDE_DISCONTINUED, default=DEFAULT_HIDE_DISCONTINUED): BooleanSelector(), +}) + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - VERSION = 0.1 + VERSION = 1 - async def async_step_user(self, user_input: dict[str, Any] | None = None) -> config_entries.FlowResult: + def __init__(self) -> None: + super().__init__() + self._user_title: str | None = None + self._user_data: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: data_schema: dict[Any, Any] = { vol.Required(CONF_EMAIL, default="e-mail"): str, - vol.Required(CONF_PASSWORD, default="password"): str + vol.Required(CONF_PASSWORD, default="password"): str, } - # Set dict for errors errors: dict[str, str] = {} - # Steps to take if user input is received if user_input is not None: try: info, data = await validate_input(self.hass, user_input) - return self.async_create_entry(title=info, data=data) + self._user_title = info + self._user_data = data + return await self.async_step_analytics() except InvalidCredentialsError: - errors["base"] = "Invalid credentials provided" + errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown exception") - errors["base"] = "Unknown exception" + errors["base"] = "unknown" - # If there is no user input or there were errors, show the form again, including any errors that were found with the input. return self.async_show_form( step_id="user", data_schema=vol.Schema(data_schema), errors=errors ) + + async def async_step_analytics( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + """Second step: choose analytics levels.""" + if user_input is not None: + return self.async_create_entry( + title=self._user_title, + data=self._user_data, + options={ + CONF_ANALYTICS: user_input.get(CONF_ANALYTICS, []), + CONF_TOP_N: int(user_input.get(CONF_TOP_N, DEFAULT_TOP_N)), + CONF_HIDE_DISCONTINUED: user_input.get(CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED), + }, + ) + + return self.async_show_form( + step_id="analytics", + data_schema=ANALYTICS_SCHEMA, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: config_entries.ConfigEntry): + return RohlikOptionsFlowHandler() + + +class RohlikOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options for existing entries (reconfigure analytics).""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + if user_input is not None: + user_input[CONF_TOP_N] = int(user_input.get(CONF_TOP_N, DEFAULT_TOP_N)) + return self.async_create_entry(title="", data=user_input) + + current = self.config_entry.options.get(CONF_ANALYTICS, DEFAULT_ANALYTICS) + current_top_n = self.config_entry.options.get(CONF_TOP_N, DEFAULT_TOP_N) + current_hide = self.config_entry.options.get(CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema({ + vol.Optional(CONF_ANALYTICS, default=current): SelectSelector( + SelectSelectorConfig( + options=ANALYTICS_OPTIONS, + multiple=True, + mode=SelectSelectorMode.LIST, + translation_key=CONF_ANALYTICS, + ) + ), + vol.Optional(CONF_TOP_N, default=current_top_n): NumberSelector( + NumberSelectorConfig( + min=5, + max=200, + step=5, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Optional(CONF_HIDE_DISCONTINUED, default=current_hide): BooleanSelector(), + }), + ) diff --git a/custom_components/rohlikcz/const.py b/custom_components/rohlikcz/const.py index e492ab6..ebbf185 100644 --- a/custom_components/rohlikcz/const.py +++ b/custom_components/rohlikcz/const.py @@ -38,6 +38,7 @@ ICON_YEARLY_SPENT = "mdi:calendar-text" ICON_ALLTIME_SPENT = "mdi:chart-line" ICON_DELIVERY_CALENDAR = "mdi:calendar-clock" +ICON_CATEGORY_SPENDING = "mdi:shape" """ Service attributes """ ATTR_CONFIG_ENTRY_ID = "config_entry_id" @@ -56,3 +57,18 @@ SERVICE_SEARCH_AND_ADD_PRODUCT = "search_and_add_to_cart" SERVICE_UPDATE_DATA = "update_data" SERVICE_FETCH_ORDER_HISTORY = "fetch_order_history" + +""" Analytics options """ +CONF_ANALYTICS = "analytics" +CONF_TOP_N = "top_n" +ANALYTICS_OPTIONS = [ + "categories_l0", + "categories_l1", + "categories_l2", + "categories_l3", + "per_item", +] +DEFAULT_ANALYTICS = [] # Nothing enabled by default (opt-in) +DEFAULT_TOP_N = 10 +CONF_HIDE_DISCONTINUED = "hide_discontinued" +DEFAULT_HIDE_DISCONTINUED = True diff --git a/custom_components/rohlikcz/hub.py b/custom_components/rohlikcz/hub.py index b9511ad..ac8e221 100644 --- a/custom_components/rohlikcz/hub.py +++ b/custom_components/rohlikcz/hub.py @@ -14,26 +14,73 @@ _LOGGER = logging.getLogger(__name__) +_NOTIFICATIONS = { + "en": { + "title_progress": "Rohlik: Enrichment in progress", + "title_complete": "Rohlik: Enrichment complete", + "phase1": "Phase 1/2: Fetching item details for {count} orders...", + "phase2": "Phase 2/2: Categorizing {count} products...", + "phase2_progress": "Phase 2/2: Categorizing products: {done}/{total} ({pct}%)", + "complete": ( + "Enrichment complete!\n" + "Orders enriched: {orders_enriched}\n" + "Products categorized: {products_categorized}\n" + "Total orders: {total_orders} (enriched: {enriched_orders})\n" + "Products in cache: {products_in_cache}" + ), + }, + "cs": { + "title_progress": "Rohlik: Probíhá obohacování dat", + "title_complete": "Rohlik: Obohacování dokončeno", + "phase1": "Fáze 1/2: Stahování položek pro {count} objednávek...", + "phase2": "Fáze 2/2: Kategorizace {count} produktů...", + "phase2_progress": "Fáze 2/2: Kategorizace produktů: {done}/{total} ({pct} %)", + "complete": ( + "Obohacování dokončeno!\n" + "Obohacené objednávky: {orders_enriched}\n" + "Kategorizované produkty: {products_categorized}\n" + "Celkem objednávek: {total_orders} (obohacených: {enriched_orders})\n" + "Produktů v mezipaměti: {products_in_cache}" + ), + }, +} + class OrderStore: """Persistent storage for order history in HA's .storage directory.""" - def __init__(self, storage_dir: str, user_id: str): + def __init__(self, storage_dir: str, user_id: str, hass: HomeAssistant): self._path = os.path.join(storage_dir, f"rohlikcz_{user_id}_orders.json") - self._data = {"version": 1, "user_id": user_id, "tracking_since": None, "orders": {}} - self.load() + self._data = {"version": 2, "user_id": user_id, "tracking_since": None, "orders": {}, "product_categories": {}} + self._hass = hass + + @classmethod + async def async_create(cls, storage_dir: str, user_id: str, hass: HomeAssistant) -> "OrderStore": + """Create and load an OrderStore asynchronously.""" + store = cls(storage_dir, user_id, hass) + await hass.async_add_executor_job(store._load_sync) + return store - def load(self) -> None: - """Load order store from disk.""" + def _load_sync(self) -> None: + """Load order store from disk (blocking, run in executor).""" if os.path.exists(self._path): try: with open(self._path, "r") as f: self._data = json.load(f) + # Migrate v1 → v2 + if self._data.get("version", 1) < 2: + self._data["version"] = 2 + if "product_categories" not in self._data: + self._data["product_categories"] = {} + self._save_sync() + _LOGGER.info("Migrated order store from v1 to v2") + elif "product_categories" not in self._data: + self._data["product_categories"] = {} except (json.JSONDecodeError, OSError) as err: _LOGGER.error(f"Failed to load order store: {err}") - def save(self) -> None: - """Save order store to disk.""" + def _save_sync(self) -> None: + """Save order store to disk (blocking, run in executor).""" try: os.makedirs(os.path.dirname(self._path), exist_ok=True) with open(self._path, "w") as f: @@ -41,8 +88,15 @@ def save(self) -> None: except OSError as err: _LOGGER.error(f"Failed to save order store: {err}") + async def async_save(self) -> None: + """Save order store to disk asynchronously.""" + await self._hass.async_add_executor_job(self._save_sync) + def process_orders(self, orders: list) -> int: - """Process a list of order dicts from the API. Returns count of new orders added.""" + """Process a list of order dicts from the API. Returns count of new orders added. + + Note: Caller must call await async_save() after this if new_count > 0. + """ if not orders: return 0 @@ -72,7 +126,6 @@ def process_orders(self, orders: list) -> int: if new_count > 0: if not self._data["tracking_since"]: self._data["tracking_since"] = datetime.now(ZoneInfo("Europe/Prague")).isoformat() - self.save() _LOGGER.info(f"Added {new_count} new orders to store. Total: {len(self._data['orders'])}") return new_count @@ -109,11 +162,145 @@ def first_order_date(self) -> str | None: dates = [o["date"] for o in self._data["orders"].values() if o["date"]] return min(dates) if dates else None + def add_items_to_order(self, order_id: str, items: list) -> bool: + """Add item details to an existing order. Returns True if added.""" + if order_id not in self._data["orders"]: + return False + if "items" in self._data["orders"][order_id]: + return False # Already has items + self._data["orders"][order_id]["items"] = items + return True + + def update_product_categories(self, categories_map: dict[int, list]) -> int: + """Update the product->category cache. Returns count of new entries. + + Note: Caller must call await async_save() after this if new_count > 0. + """ + new_count = 0 + for pid, cats in categories_map.items(): + pid_str = str(pid) + if pid_str not in self._data["product_categories"]: + # Store simplified: {pid: {l0: name, l1: name, l2: name, l3: name}} + cat_dict = {} + for cat in cats: + level = cat.get("level") + if level is not None: + cat_dict[f"l{level}"] = cat.get("name", "Unknown") + self._data["product_categories"][pid_str] = cat_dict + new_count += 1 + return new_count + + def get_product_category(self, product_id: int, level: int = 1) -> str: + """Get category name for a product at a given level. Returns 'Uncategorized' if not found.""" + pid_str = str(product_id) + cat_data = self._data.get("product_categories", {}).get(pid_str, {}) + return cat_data.get(f"l{level}", "Uncategorized") + + def _is_discontinued(self, product_id: int) -> bool: + """Check if a product is discontinued (has sentinel 'Discontinued' category).""" + pid_str = str(product_id) + cat_data = self._data.get("product_categories", {}).get(pid_str, {}) + return cat_data.get("l1") == "Discontinued" + + @property + def unenriched_order_ids(self) -> list[str]: + """Order IDs that don't have item details yet.""" + return [oid for oid, o in self._data["orders"].items() if "items" not in o] + + @property + def enriched_count(self) -> int: + """Count of orders with item details.""" + return sum(1 for o in self._data["orders"].values() if "items" in o) + + @property + def cached_product_count(self) -> int: + """Count of products with cached categories.""" + return len(self._data.get("product_categories", {})) + + def uncategorized_product_ids(self) -> list[int]: + """Product IDs found in enriched orders but not yet in category cache.""" + known = set(self._data.get("product_categories", {}).keys()) + found = set() + for order in self._data["orders"].values(): + for item in order.get("items", []): + pid = item.get("id") + if pid is not None: + found.add(str(pid)) + return [int(pid) for pid in found - known] + + def category_totals(self, year: str | None = None, level: int = 1, hide_discontinued: bool = False) -> list[dict]: + """Aggregate spending by category at given level. Optionally filter by year/month prefix. + + Returns sorted list: [{"name": "Dairy", "spent": 5000.0, "units": 200, "avg_unit_price": 25.0}] + """ + from collections import defaultdict + cats = defaultdict(lambda: {"spent": 0.0, "units": 0}) + + for order in self._data["orders"].values(): + if year and not order["date"].startswith(year): + continue + for item in order.get("items", []): + pid = item.get("id") + if hide_discontinued and pid and self._is_discontinued(pid): + continue + cat_name = self.get_product_category(pid, level) if pid else "Uncategorized" + cats[cat_name]["spent"] += item.get("price", 0) + cats[cat_name]["units"] += item.get("quantity", 1) + + if hide_discontinued: + cats.pop("Discontinued", None) + + result = [] + for name, vals in cats.items(): + avg = round(vals["spent"] / vals["units"], 2) if vals["units"] > 0 else 0.0 + result.append({ + "name": name, + "spent": round(vals["spent"], 2), + "units": vals["units"], + "avg_unit_price": avg, + }) + result.sort(key=lambda x: x["spent"], reverse=True) + return result + + def item_totals(self, year: str | None = None, hide_discontinued: bool = False) -> list[dict]: + """Aggregate spending by individual product. Optionally filter by year. + + Returns sorted list: [{"name": "Tchibo Barista", "id": 1328327, "spent": 500.0, "units": 10, "avg_unit_price": 50.0}] + """ + from collections import defaultdict + items = defaultdict(lambda: {"name": "", "spent": 0.0, "units": 0}) + + for order in self._data["orders"].values(): + if year and not order["date"].startswith(year): + continue + for item in order.get("items", []): + pid = item.get("id") + if not pid: + continue + if hide_discontinued and self._is_discontinued(pid): + continue + items[pid]["name"] = item.get("name", "Unknown") + items[pid]["spent"] += item.get("price", 0) + items[pid]["units"] += item.get("quantity", 1) + + result = [] + for pid, vals in items.items(): + avg = round(vals["spent"] / vals["units"], 2) if vals["units"] > 0 else 0.0 + result.append({ + "name": vals["name"], + "id": pid, + "spent": round(vals["spent"], 2), + "units": vals["units"], + "avg_unit_price": avg, + }) + result.sort(key=lambda x: x["spent"], reverse=True) + return result + class RohlikAccount: """Setting RohlikCZ account as device.""" - def __init__(self, hass: HomeAssistant, username: str, password: str) -> None: + def __init__(self, hass: HomeAssistant, username: str, password: str, analytics: list[str] | None = None, top_n: int = 10, hide_discontinued: bool = True) -> None: """Initialize account info.""" super().__init__() self._hass = hass @@ -123,6 +310,29 @@ def __init__(self, hass: HomeAssistant, username: str, password: str) -> None: self.data: dict = {} self._callbacks: set[Callable[[], None]] = set() self._order_store: OrderStore | None = None + self._analytics: list[str] = analytics or [] + self._top_n: int = top_n + self._hide_discontinued: bool = hide_discontinued + + @property + def analytics_enabled(self) -> bool: + """Whether any analytics level is selected.""" + return len(self._analytics) > 0 + + @property + def analytics(self) -> list[str]: + """Selected analytics levels.""" + return self._analytics + + @property + def top_n(self) -> int: + """Number of top items to include in sensor attributes.""" + return self._top_n + + @property + def hide_discontinued(self) -> bool: + """Whether to exclude discontinued products from top N.""" + return self._hide_discontinued @property def has_address(self): @@ -159,26 +369,149 @@ async def async_update(self) -> None: self.data = await self._rohlik_api.get_data() - # Initialize order store on first update (need user_id from login) - if not self._order_store and self.data.get("login"): + # Initialize order store on first update (only if analytics enabled) + if self._analytics and not self._order_store and self.data.get("login"): user_id = str(self.data["login"]["data"]["user"]["id"]) storage_dir = self._hass.config.path(".storage") - self._order_store = OrderStore(storage_dir, user_id) + self._order_store = await OrderStore.async_create(storage_dir, user_id, self._hass) - # Process delivered orders into persistent store - if self._order_store and self.data.get("delivered_orders"): - self._order_store.process_orders(self.data["delivered_orders"]) + # Process delivered orders into persistent store and auto-enrich new ones + if self._analytics and self._order_store and self.data.get("delivered_orders"): + new = self._order_store.process_orders(self.data["delivered_orders"]) + if new > 0: + await self._order_store.async_save() + # Schedule enrichment in background (don't block update cycle / setup) + self._hass.async_create_task(self._auto_enrich_new_orders(new)) await self.publish_updates() - async def fetch_full_order_history(self) -> int: - """Fetch all historical orders and store them. Returns total order count.""" + async def _auto_enrich_new_orders(self, new_count: int) -> None: + """Auto-enrich recently added orders in the background.""" + try: + unenriched = self._order_store.unenriched_order_ids + recent_unenriched = unenriched[-new_count:] if len(unenriched) >= new_count else unenriched + if recent_unenriched: + items_map = await self._rohlik_api.enrich_orders_with_items(recent_unenriched) + for order_id, items in items_map.items(): + self._order_store.add_items_to_order(order_id, items) + uncategorized = self._order_store.uncategorized_product_ids() + if uncategorized: + cat_map = await self._rohlik_api.fetch_product_categories_batch(uncategorized) + self._order_store.update_product_categories(cat_map) + if items_map: + await self._order_store.async_save() + _LOGGER.info(f"Auto-enriched {len(items_map)} new orders") + await self.publish_updates() + except Exception as err: + _LOGGER.warning(f"Auto-enrichment of new orders failed: {err}") + + async def fetch_full_order_history(self, hass=None) -> dict: + """Fetch all historical orders, enrich with items, and categorize products. + + Returns dict with stats. + """ + # Step 1: Fetch order list (existing behavior) all_orders = await self._rohlik_api.fetch_all_delivered_orders() + new_orders = 0 if self._order_store and all_orders: - new = self._order_store.process_orders(all_orders) - await self.publish_updates() - return self._order_store.alltime_count() - return 0 + new_orders = self._order_store.process_orders(all_orders) + if new_orders > 0: + await self._order_store.async_save() + + # Step 2: Enrich with items and categories + if self._order_store: + enrich_stats = await self.enrich_order_details(hass=hass) + enrich_stats["new_orders"] = new_orders + return enrich_stats + + return {"total_orders": 0, "new_orders": 0} + + def _t(self, key: str) -> str: + """Get localized notification string based on HA language.""" + lang = self._hass.config.language or "en" + return _NOTIFICATIONS.get(lang, _NOTIFICATIONS["en"]).get(key, _NOTIFICATIONS["en"][key]) + + async def _notify(self, hass, message: str, title: str, notification_id: str = "rohlik_enrichment") -> None: + """Create a persistent notification via service call.""" + if not hass: + return + await hass.services.async_call( + "persistent_notification", "create", + {"message": message, "title": title, "notification_id": notification_id}, + ) + + async def enrich_order_details(self, hass=None) -> dict: + """Fetch item details and categories for all unenriched orders. + + Two-phase enrichment: + 1. Fetch items for orders missing them + 2. Fetch categories for products not yet in cache + + Returns stats dict. + """ + if not self._order_store: + return {"error": "Order store not initialized"} + + stats = {"orders_enriched": 0, "products_categorized": 0, "errors": 0} + + # Phase 1: Fetch items for unenriched orders + unenriched = self._order_store.unenriched_order_ids + if unenriched: + await self._notify(hass, + self._t("phase1").format(count=len(unenriched)), + self._t("title_progress")) + _LOGGER.info(f"Enriching {len(unenriched)} orders with item details...") + items_map = await self._rohlik_api.enrich_orders_with_items(unenriched) + for order_id, items in items_map.items(): + if self._order_store.add_items_to_order(order_id, items): + stats["orders_enriched"] += 1 + if stats["orders_enriched"] > 0: + await self._order_store.async_save() + _LOGGER.info(f"Added items to {stats['orders_enriched']} orders") + + # Phase 2: Fetch categories for uncategorized products + uncategorized = self._order_store.uncategorized_product_ids() + if uncategorized: + total_products = len(uncategorized) + await self._notify(hass, + self._t("phase2").format(count=total_products), + self._t("title_progress")) + + async def progress_cb(done, total): + pct = round(done / total * 100) if total > 0 else 0 + _LOGGER.info(f"Category enrichment progress: {done}/{total} ({pct}%)") + await self._notify(hass, + self._t("phase2_progress").format(done=done, total=total, pct=pct), + self._t("title_progress")) + + _LOGGER.info(f"Fetching categories for {total_products} products...") + cat_map = await self._rohlik_api.fetch_product_categories_batch(uncategorized, progress_callback=progress_cb) + new_cats = self._order_store.update_product_categories(cat_map) + stats["products_categorized"] = new_cats + if new_cats > 0: + await self._order_store.async_save() + _LOGGER.info(f"Categorized {new_cats} products") + + # Done + await self._notify(hass, + self._t("complete").format( + orders_enriched=stats["orders_enriched"], + products_categorized=stats["products_categorized"], + total_orders=self._order_store.alltime_count(), + enriched_orders=self._order_store.enriched_count, + products_in_cache=self._order_store.cached_product_count, + ), + self._t("title_complete")) + + await self.publish_updates() + return { + "total_orders": self._order_store.alltime_count(), + "enriched_orders": self._order_store.enriched_count, + "unenriched_remaining": len(self._order_store.unenriched_order_ids), + "products_in_cache": self._order_store.cached_product_count, + "orders_enriched_this_run": stats["orders_enriched"], + "products_categorized_this_run": stats["products_categorized"], + } def register_callback(self, callback: Callable[[], None]) -> None: """Register callback, called when there are new data.""" diff --git a/custom_components/rohlikcz/manifest.json b/custom_components/rohlikcz/manifest.json index f422610..06fa025 100644 --- a/custom_components/rohlikcz/manifest.json +++ b/custom_components/rohlikcz/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/dvejsada/HA-RohlikCZ/issues", "requirements": [], - "version": "0.3.1" + "version": "0.4.0" } diff --git a/custom_components/rohlikcz/rohlik_api.py b/custom_components/rohlikcz/rohlik_api.py index 9c46da3..474e385 100644 --- a/custom_components/rohlikcz/rohlik_api.py +++ b/custom_components/rohlikcz/rohlik_api.py @@ -242,6 +242,123 @@ async def get_delivered_orders_page(self, session, offset: int = 0, limit: int = _LOGGER.error(f"Error fetching delivered orders page (offset={offset}): {err}") return [] + async def get_order_detail(self, session, order_id: str) -> dict | None: + """Fetch detailed order info including items for a single order.""" + url = f"{BASE_URL}/api/v3/orders/{order_id}" + try: + response = await self._run_in_executor(session.get, url) + response.raise_for_status() + return response.json() + except RequestException as err: + _LOGGER.error(f"Error fetching order detail for {order_id}: {err}") + return None + + async def get_product_categories(self, session, product_id: int) -> list | None: + """Fetch category hierarchy for a product. Returns None for 404 (discontinued).""" + url = f"{BASE_URL}/api/v1/products/{product_id}/categories" + try: + response = await self._run_in_executor(session.get, url) + if response.status_code == 404: + return None # Product no longer exists + response.raise_for_status() + data = response.json() + return data.get("categories", []) + except RequestException as err: + _LOGGER.debug(f"Could not fetch categories for product {product_id}: {err}") + return [] + + async def get_product_detail(self, session, product_id: int) -> dict | None: + """Fetch product detail including brand.""" + url = f"{BASE_URL}/api/v1/products/{product_id}" + try: + response = await self._run_in_executor(session.get, url) + response.raise_for_status() + return response.json() + except RequestException as err: + _LOGGER.debug(f"Could not fetch product detail for {product_id}: {err}") + return None + + async def enrich_orders_with_items(self, order_ids: list[str]) -> dict[str, list]: + """Fetch item details for a list of order IDs. Returns {order_id: items_list}.""" + session = requests.Session() + results = {} + try: + await self.login(session) + except Exception as err: + _LOGGER.error(f"Login failed for order enrichment: {err}") + await self._run_in_executor(session.close) + return results + + try: + for i, order_id in enumerate(order_ids): + try: + detail = await self.get_order_detail(session, order_id) + if detail and detail.get("items"): + items = [] + for item in detail["items"]: + items.append({ + "id": item.get("id"), + "name": item.get("name", "Unknown"), + "quantity": item.get("amount", 1), + "price": item.get("priceComposition", {}).get("total", {}).get("amount", 0), + "unit_price": item.get("priceComposition", {}).get("unit", {}).get("amount", 0), + "textual_amount": item.get("textualAmount", ""), + }) + results[order_id] = items + except Exception as err: + _LOGGER.warning(f"Failed to fetch items for order {order_id}: {err}") + if i % 50 == 0 and i > 0: + _LOGGER.info(f"Fetched items for {i}/{len(order_ids)} orders") + await asyncio.sleep(0.2) + _LOGGER.info(f"Item fetch complete: {len(results)}/{len(order_ids)} orders") + return results + except Exception as err: + _LOGGER.error(f"Error during order item fetch: {err}") + return results + finally: + try: + await self.logout(session) + except Exception: + pass + await self._run_in_executor(session.close) + + async def fetch_product_categories_batch(self, product_ids: list[int], progress_callback=None) -> dict[int, list]: + """Fetch categories for a batch of product IDs. Returns {product_id: categories_list}.""" + session = requests.Session() + results = {} + try: + await self.login(session) + except Exception as err: + _LOGGER.error(f"Login failed for category fetch: {err}") + await self._run_in_executor(session.close) + return results + + try: + for i, pid in enumerate(product_ids): + try: + cats = await self.get_product_categories(session, pid) + if cats is None: + # Product discontinued (404) — mark with sentinel category + results[pid] = [{"level": 1, "name": "Discontinued"}] + elif cats: + results[pid] = cats + except Exception as err: + _LOGGER.debug(f"Failed to fetch categories for product {pid}: {err}") + if progress_callback and i % 50 == 0: + await progress_callback(i, len(product_ids)) + await asyncio.sleep(0.2) + _LOGGER.info(f"Category fetch complete: {len(results)}/{len(product_ids)} products") + return results + except Exception as err: + _LOGGER.error(f"Error during category fetch: {err}") + return results + finally: + try: + await self.logout(session) + except Exception: + pass + await self._run_in_executor(session.close) + async def fetch_all_delivered_orders(self) -> list: """Fetch ALL delivered orders by paginating through the API. Returns list of all orders.""" session = requests.Session() diff --git a/custom_components/rohlikcz/sensor.py b/custom_components/rohlikcz/sensor.py index ade26d7..9c460c9 100644 --- a/custom_components/rohlikcz/sensor.py +++ b/custom_components/rohlikcz/sensor.py @@ -17,7 +17,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import DOMAIN, ICON_UPDATE, ICON_CREDIT, ICON_NO_LIMIT, ICON_FREE_EXPRESS, ICON_DELIVERY, ICON_BAGS, \ ICON_CART, ICON_ACCOUNT, ICON_EMAIL, ICON_PHONE, ICON_PREMIUM_DAYS, ICON_LAST_ORDER, ICON_NEXT_ORDER_SINCE, \ - ICON_NEXT_ORDER_TILL, ICON_INFO, ICON_DELIVERY_TIME, ICON_MONTHLY_SPENT, ICON_YEARLY_SPENT, ICON_ALLTIME_SPENT + ICON_NEXT_ORDER_TILL, ICON_INFO, ICON_DELIVERY_TIME, ICON_MONTHLY_SPENT, ICON_YEARLY_SPENT, ICON_ALLTIME_SPENT, \ + ICON_CATEGORY_SPENDING from .entity import BaseEntity from .hub import RohlikAccount from .utils import extract_delivery_datetime, get_earliest_order, parse_delivery_datetime_string @@ -33,6 +34,7 @@ async def async_setup_entry( ) -> None: """Add sensors for passed config_entry in HA.""" rohlik_hub: RohlikAccount = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any] + analytics = rohlik_hub.analytics entities = [ FirstDeliverySensor(rohlik_hub), @@ -51,16 +53,35 @@ async def async_setup_entry( DeliveryInfo(rohlik_hub), DeliveryTime(rohlik_hub), MonthlySpent(rohlik_hub), - YearlySpent(rohlik_hub), - AllTimeSpent(rohlik_hub), ] + # Spending sensors that need the order store (only if any analytics enabled) + if analytics: + entities.append(YearlySpent(rohlik_hub)) + entities.append(AllTimeSpent(rohlik_hub)) + + # Category sensors per selected level + if "categories_l0" in analytics: + entities.append(CategorySpendingL0Yearly(rohlik_hub)) + entities.append(CategorySpendingL0AllTime(rohlik_hub)) + if "categories_l1" in analytics: + entities.append(CategorySpendingYearly(rohlik_hub)) + entities.append(CategorySpendingAllTime(rohlik_hub)) + if "categories_l2" in analytics: + entities.append(CategorySpendingL2Yearly(rohlik_hub)) + entities.append(CategorySpendingL2AllTime(rohlik_hub)) + if "categories_l3" in analytics: + entities.append(CategorySpendingL3Yearly(rohlik_hub)) + entities.append(CategorySpendingL3AllTime(rohlik_hub)) + if "per_item" in analytics: + entities.append(ItemSpendingYearly(rohlik_hub)) + entities.append(ItemSpendingAllTime(rohlik_hub)) + if rohlik_hub.has_address: entities.append(FirstExpressSlot(rohlik_hub)) entities.append(FirstStandardSlot(rohlik_hub)) entities.append(FirstEcoSlot(rohlik_hub)) - # Only add premium days remaining if the user is premium if rohlik_hub.data.get('login', {}).get('data', {}).get('user', {}).get('premium', {}).get('active', False): entities.append(PremiumDaysRemainingSensor(rohlik_hub)) @@ -713,6 +734,432 @@ async def async_will_remove_from_hass(self) -> None: self._rohlik_account.remove_callback(self.async_write_ha_state) +class CategorySpendingYearly(BaseEntity, SensorEntity): + """Sensor for spending breakdown by category for current year.""" + + _attr_translation_key = "categories_this_year" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of categories with spending this year.""" + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=1, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(categories) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=1, hide_discontinued=self._rohlik_account.hide_discontinued) + if not categories: + return {"year": year, "enriched_orders": store.enriched_count, "total_orders": store.yearly_count(year)} + return { + "year": year, + "total_count": len(categories), + "categories": categories[:self._rohlik_account.top_n], + "enriched_orders": store.enriched_count, + "total_orders": store.yearly_count(year), + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class CategorySpendingAllTime(BaseEntity, SensorEntity): + """Sensor for spending breakdown by category across all time.""" + + _attr_translation_key = "categories_all_time" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of categories with spending all time.""" + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=1, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(categories) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=1, hide_discontinued=self._rohlik_account.hide_discontinued) + if not categories: + return {"enriched_orders": store.enriched_count, "total_orders": store.alltime_count()} + return { + "total_count": len(categories), + "categories": categories[:self._rohlik_account.top_n], + "enriched_orders": store.enriched_count, + "total_orders": store.alltime_count(), + "products_in_cache": store.cached_product_count, + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class CategorySpendingL0Yearly(BaseEntity, SensorEntity): + """Sensor for spending breakdown by L0 category for current year.""" + + _attr_translation_key = "categories_l0_this_year" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of L0 categories with spending this year.""" + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=0, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(categories) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=0, hide_discontinued=self._rohlik_account.hide_discontinued) + if not categories: + return {"year": year, "enriched_orders": store.enriched_count, "total_orders": store.yearly_count(year)} + return { + "year": year, + "total_count": len(categories), + "categories": categories[:self._rohlik_account.top_n], + "enriched_orders": store.enriched_count, + "total_orders": store.yearly_count(year), + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class CategorySpendingL0AllTime(BaseEntity, SensorEntity): + """Sensor for spending breakdown by L0 category across all time.""" + + _attr_translation_key = "categories_l0_all_time" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of L0 categories with spending all time.""" + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=0, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(categories) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=0, hide_discontinued=self._rohlik_account.hide_discontinued) + if not categories: + return {"enriched_orders": store.enriched_count, "total_orders": store.alltime_count()} + return { + "total_count": len(categories), + "categories": categories[:self._rohlik_account.top_n], + "enriched_orders": store.enriched_count, + "total_orders": store.alltime_count(), + "products_in_cache": store.cached_product_count, + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class CategorySpendingL2Yearly(BaseEntity, SensorEntity): + """Sensor for spending breakdown by L2 category for current year.""" + + _attr_translation_key = "categories_l2_this_year" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of L2 categories with spending this year.""" + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=2, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(categories) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=2, hide_discontinued=self._rohlik_account.hide_discontinued) + if not categories: + return {"year": year, "enriched_orders": store.enriched_count, "total_orders": store.yearly_count(year)} + return { + "year": year, + "total_count": len(categories), + "categories": categories[:self._rohlik_account.top_n], + "enriched_orders": store.enriched_count, + "total_orders": store.yearly_count(year), + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class CategorySpendingL2AllTime(BaseEntity, SensorEntity): + """Sensor for spending breakdown by L2 category across all time.""" + + _attr_translation_key = "categories_l2_all_time" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of L2 categories with spending all time.""" + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=2, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(categories) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=2, hide_discontinued=self._rohlik_account.hide_discontinued) + if not categories: + return {"enriched_orders": store.enriched_count, "total_orders": store.alltime_count()} + return { + "total_count": len(categories), + "categories": categories[:self._rohlik_account.top_n], + "enriched_orders": store.enriched_count, + "total_orders": store.alltime_count(), + "products_in_cache": store.cached_product_count, + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class CategorySpendingL3Yearly(BaseEntity, SensorEntity): + """Sensor for spending breakdown by L3 category for current year.""" + + _attr_translation_key = "categories_l3_this_year" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of L3 categories with spending this year.""" + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=3, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(categories) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=3, hide_discontinued=self._rohlik_account.hide_discontinued) + if not categories: + return {"year": year, "enriched_orders": store.enriched_count, "total_orders": store.yearly_count(year)} + return { + "year": year, + "total_count": len(categories), + "categories": categories[:self._rohlik_account.top_n], + "enriched_orders": store.enriched_count, + "total_orders": store.yearly_count(year), + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class CategorySpendingL3AllTime(BaseEntity, SensorEntity): + """Sensor for spending breakdown by L3 category across all time.""" + + _attr_translation_key = "categories_l3_all_time" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of L3 categories with spending all time.""" + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=3, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(categories) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=3, hide_discontinued=self._rohlik_account.hide_discontinued) + if not categories: + return {"enriched_orders": store.enriched_count, "total_orders": store.alltime_count()} + return { + "total_count": len(categories), + "categories": categories[:self._rohlik_account.top_n], + "enriched_orders": store.enriched_count, + "total_orders": store.alltime_count(), + "products_in_cache": store.cached_product_count, + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class ItemSpendingYearly(BaseEntity, SensorEntity): + """Sensor for spending breakdown by individual item for current year.""" + + _attr_translation_key = "items_this_year" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of unique items purchased this year.""" + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + items = store.item_totals(year=year, hide_discontinued=self._rohlik_account.hide_discontinued) + return len(items) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + items = store.item_totals(year=year, hide_discontinued=self._rohlik_account.hide_discontinued) + if not items: + return {"year": year} + return { + "year": year, + "total_count": len(items), + "items": items[:self._rohlik_account.top_n], + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class ItemSpendingAllTime(BaseEntity, SensorEntity): + """Sensor for spending breakdown by individual item across all time.""" + + _attr_translation_key = "items_all_time" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + """Returns number of unique items purchased all time.""" + store = self._rohlik_account.order_store + if not store: + return None + items = store.item_totals(hide_discontinued=self._rohlik_account.hide_discontinued) + return len(items) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + items = store.item_totals(hide_discontinued=self._rohlik_account.hide_discontinued) + if not items: + return None + return { + "total_count": len(items), + "items": items[:self._rohlik_account.top_n], + "products_in_cache": store.cached_product_count, + } + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + class NoLimitOrders(BaseEntity, SensorEntity): """Sensor for remaining no limit orders.""" diff --git a/custom_components/rohlikcz/services.py b/custom_components/rohlikcz/services.py index 619d8ba..873a373 100644 --- a/custom_components/rohlikcz/services.py +++ b/custom_components/rohlikcz/services.py @@ -131,7 +131,7 @@ async def async_update_data(call: ServiceCall) -> None: raise HomeAssistantError(f"Failed to update data: {err}") - async def async_fetch_order_history(call: ServiceCall) -> Dict[str, Any]: + async def async_fetch_order_history(call: ServiceCall) -> None: """Fetch complete order history from Rohlik.""" config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] @@ -140,12 +140,27 @@ async def async_fetch_order_history(call: ServiceCall) -> Dict[str, Any]: account = hass.data[DOMAIN][config_entry_id] try: - total = await account.fetch_full_order_history() - return {"total_orders": total} + result = await account.fetch_full_order_history(hass=hass) + _LOGGER.info(f"Fetch order history result: {result}") except Exception as err: _LOGGER.error(f"Failed to fetch order history: {err}") raise HomeAssistantError(f"Failed to fetch order history: {err}") + async def async_enrich_orders(call: ServiceCall) -> None: + """Enrich stored orders with item details and product categories.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + + if config_entry_id not in hass.data[DOMAIN]: + raise HomeAssistantError(f"Config entry {config_entry_id} not found") + + account = hass.data[DOMAIN][config_entry_id] + try: + result = await account.enrich_order_details(hass=hass) + _LOGGER.info(f"Enrich orders result: {result}") + except Exception as err: + _LOGGER.error(f"Failed to enrich orders: {err}") + raise HomeAssistantError(f"Failed to enrich orders: {err}") + # Register the services hass.services.async_register( DOMAIN, @@ -223,5 +238,17 @@ async def async_fetch_order_history(call: ServiceCall) -> Dict[str, Any]: schema=vol.Schema({ vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, }), - supports_response=True - ) \ No newline at end of file + supports_response=SupportsResponse.NONE + ) + + hass.services.async_register( + DOMAIN, + "enrich_orders", + async_enrich_orders, + schema=vol.Schema({ + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + }), + supports_response=SupportsResponse.NONE + ) + + diff --git a/custom_components/rohlikcz/services.yaml b/custom_components/rohlikcz/services.yaml index e979a2e..51a4155 100644 --- a/custom_components/rohlikcz/services.yaml +++ b/custom_components/rohlikcz/services.yaml @@ -147,6 +147,18 @@ search_and_add_to_cart: fetch_order_history: name: Fetch order history description: Fetch complete order history from Rohlik.cz and store locally. Run once to backfill historical data. + fields: + config_entry_id: + name: Account + description: The Rohlik account to use + required: true + selector: + config_entry: + integration: rohlikcz + +enrich_orders: + name: Enrich orders + description: Enrich stored orders with item details and product categories. Use after fetch_order_history to populate category spending sensors. fields: config_entry_id: name: Account diff --git a/custom_components/rohlikcz/translations/cs.json b/custom_components/rohlikcz/translations/cs.json index 96c1a1a..c239a12 100644 --- a/custom_components/rohlikcz/translations/cs.json +++ b/custom_components/rohlikcz/translations/cs.json @@ -12,6 +12,19 @@ "username": "Váš e-mail pro přihlášení do Rohlik.cz", "password": "Vaše heslo pro přihlášení do Rohlik.cz" } + }, + "analytics": { + "title": "Analýza nákupů", + "description": "Vyberte, které úrovně útrat chcete sledovat. Pro každou vybranou úroveň se vytvoří senzory pro tento rok a celkově. Výběr jakékoli možnosti spustí jednorázové stažení historie objednávek (může trvat několik minut).", + "data": { + "analytics": "Úrovně analýzy", + "top_n": "Top položek v senzorech", + "hide_discontinued": "Skrýt zrušené produkty" + }, + "data_description": { + "top_n": "Kolik top položek zobrazit v atributech senzorů (podle útraty). Výchozí: 10.", + "hide_discontinued": "Vyloučit zrušené produkty z top kategorií a položek." + } } }, "error": { @@ -23,6 +36,34 @@ "already_configured": "Účet je již nakonfigurován" } }, + "options": { + "step": { + "init": { + "title": "Analýza nákupů", + "description": "Vyberte, které úrovně útrat chcete sledovat. Změny se projeví po reloadu.", + "data": { + "analytics": "Úrovně analýzy", + "top_n": "Top položek v senzorech", + "hide_discontinued": "Skrýt zrušené produkty" + }, + "data_description": { + "top_n": "Kolik top položek zobrazit v atributech senzorů (podle útraty). Výchozí: 10.", + "hide_discontinued": "Vyloučit zrušené produkty z top kategorií a položek." + } + } + } + }, + "selector": { + "analytics": { + "options": { + "categories_l0": "Nejvyšší kategorie (např. Nápoje, Drogerie, Ovoce a zelenina)", + "categories_l1": "Střední kategorie (např. Horké nápoje, Čisticí prostředky, Zelenina)", + "categories_l2": "Podrobné kategorie (např. Káva, Univerzální čistič, Rajčata)", + "categories_l3": "Nejpodrobnější kategorie (např. Zrnková káva, Ve spreji)", + "per_item": "Útrata po položkách (např. Tchibo Barista, Savo sprej)" + } + } + }, "entity": { "binary_sensor": { "is_express_available": { @@ -140,6 +181,36 @@ "alltime_spent": { "name": "Celková útrata", "unit_of_measurement": "Kč" + }, + "categories_l0_this_year": { + "name": "Hlavní kategorie tento rok" + }, + "categories_l0_all_time": { + "name": "Hlavní kategorie celkem" + }, + "categories_this_year": { + "name": "Kategorie tento rok" + }, + "categories_all_time": { + "name": "Kategorie celkem" + }, + "categories_l2_this_year": { + "name": "Podrobné kategorie tento rok" + }, + "categories_l2_all_time": { + "name": "Podrobné kategorie celkem" + }, + "categories_l3_this_year": { + "name": "Specifické kategorie tento rok" + }, + "categories_l3_all_time": { + "name": "Specifické kategorie celkem" + }, + "items_this_year": { + "name": "Položky tento rok" + }, + "items_all_time": { + "name": "Položky celkem" } }, "todo": { diff --git a/custom_components/rohlikcz/translations/en.json b/custom_components/rohlikcz/translations/en.json index d46e605..24bb388 100644 --- a/custom_components/rohlikcz/translations/en.json +++ b/custom_components/rohlikcz/translations/en.json @@ -12,6 +12,19 @@ "username": "Your Rohlik.cz account email", "password": "Your Rohlik.cz account password" } + }, + "analytics": { + "title": "Spending Analytics", + "description": "Choose which spending breakdowns to track. Each selected level creates sensors for this year and all time. Selecting any option will trigger a one-time download of your order history (may take several minutes).", + "data": { + "analytics": "Analytics levels", + "top_n": "Top items in sensors", + "hide_discontinued": "Hide discontinued products" + }, + "data_description": { + "top_n": "How many top items to show in sensor attributes (by spending). Default: 10.", + "hide_discontinued": "Exclude discontinued products from top categories and items." + } } }, "error": { @@ -23,6 +36,34 @@ "already_configured": "Account is already configured" } }, + "options": { + "step": { + "init": { + "title": "Spending Analytics", + "description": "Choose which spending breakdowns to track. Changes take effect after reload.", + "data": { + "analytics": "Analytics levels", + "top_n": "Top items in sensors", + "hide_discontinued": "Hide discontinued products" + }, + "data_description": { + "top_n": "How many top items to show in sensor attributes (by spending). Default: 10.", + "hide_discontinued": "Exclude discontinued products from top categories and items." + } + } + } + }, + "selector": { + "analytics": { + "options": { + "categories_l0": "Top-level categories (e.g. Drinks, Drugstore, Fruit & Vegetables)", + "categories_l1": "Mid-level categories (e.g. Hot drinks, Cleaning products, Vegetables)", + "categories_l2": "Detailed categories (e.g. Coffee, Universal cleaner, Tomatoes)", + "categories_l3": "Most specific categories (e.g. Bean coffee, Spray cleaner)", + "per_item": "Per-item spending (e.g. Tchibo Barista, Savo Spray)" + } + } + }, "entity": { "binary_sensor": { "is_express_available": { @@ -140,6 +181,36 @@ "alltime_spent": { "name": "Spent All Time", "unit_of_measurement": "CZK" + }, + "categories_l0_this_year": { + "name": "Top Categories This Year" + }, + "categories_l0_all_time": { + "name": "Top Categories All Time" + }, + "categories_this_year": { + "name": "Categories This Year" + }, + "categories_all_time": { + "name": "Categories All Time" + }, + "categories_l2_this_year": { + "name": "Detailed Categories This Year" + }, + "categories_l2_all_time": { + "name": "Detailed Categories All Time" + }, + "categories_l3_this_year": { + "name": "Specific Categories This Year" + }, + "categories_l3_all_time": { + "name": "Specific Categories All Time" + }, + "items_this_year": { + "name": "Items This Year" + }, + "items_all_time": { + "name": "Items All Time" } }, "todo": { diff --git a/docs/plans/2026-02-26-category-item-analytics.md b/docs/plans/2026-02-26-category-item-analytics.md new file mode 100644 index 0000000..cc0599f --- /dev/null +++ b/docs/plans/2026-02-26-category-item-analytics.md @@ -0,0 +1,322 @@ +# Category & Item Spending Analytics Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enrich stored orders with item-level details and add two new category breakdown sensors (this year + all time) with spent, units, and avg_unit_price per level-1 category. + +**Architecture:** Extend OrderStore (v1→v2) to store item details per order. Add `get_order_detail` API method for `/api/v3/orders/{orderId}`. Modify `fetch_order_history` to also enrich. Auto-enrich new orders during regular polls. Two new sensors expose category data as attributes for ApexCharts dashboards. No existing sensors modified. + +**Tech Stack:** Python, Home Assistant custom component APIs, `requests`, JSON file storage + +--- + +## Context + +### What exists (Phase 1) + +- `OrderStore` in `hub.py` stores `{order_id: {date, amount}}` in `.storage/rohlikcz_{user_id}_orders.json` (version 1) +- 483 orders stored, ~38KB, spanning 2017–2026 +- `YearlySpent` and `AllTimeSpent` sensors read from the store +- `fetch_order_history` service backfills all historical orders +- Regular 10-min poll fetches 50 most recent delivered orders + +### What the API provides + +- **Detail endpoint** (`/api/v3/orders/{orderId}`): full order with items array. Each item includes product ID, name, quantity, price, brand, and `categories` array with `{id, name, level}` hierarchy. +- Level 1 = mid-level grouping (e.g. "Pečivo", "Mléčné výrobky"). ~15-30 distinct categories. +- **Cost**: 1 API call per order. 483 orders × 200ms = ~100 seconds one-time. Storage grows to ~1.5-3MB. + +### Non-breaking guarantees + +- ALL existing sensors untouched (MonthlySpent, YearlySpent, AllTimeSpent, etc.) +- ALL existing services untouched +- Store v1→v2 migration is additive (just bumps version, existing data unchanged) +- Orders without `items` key are "unenriched" and silently skipped by analytics +- `fetch_order_history` response is a superset of the old response + +--- + +## Store Schema v2 + +```json +{ + "version": 2, + "user_id": "1086873", + "tracking_since": "2026-02-26T13:01:11+01:00", + "orders": { + "1119530344": { + "date": "2026-02-23", + "amount": 997.0, + "items": [ + { + "id": 12345, + "name": "Polotučné mléko 1,5% 1l", + "quantity": 2, + "price": 28.90, + "brand": "Madeta", + "category": "Mléčné výrobky", + "category_id": 300015 + } + ] + }, + "1119428209": { + "date": "2026-02-22", + "amount": 430.33 + } + } +} +``` + +Orders without `"items"` = unenriched. All existing methods (`yearly_total`, `alltime_total`, etc.) use only `date` and `amount` — completely unchanged. + +--- + +## New Sensors + +### `sensor.rohlik_categories_this_year` +- State: number of level-1 categories with spending this year +- Attribute `categories` (sorted by spent desc): +```json +[ + {"name": "Mléčné výrobky", "spent": 15230.50, "units": 612, "avg_unit_price": 24.89}, + {"name": "Pečivo", "spent": 12100.00, "units": 890, "avg_unit_price": 13.60} +] +``` +- Attribute `enriched_orders`: count of enriched orders this year +- Attribute `total_orders`: count of all orders this year + +### `sensor.rohlik_categories_all_time` +- Same structure, all time + +### Dashboard usage (ApexCharts) +```yaml +# Donut: where does my money go? +chart_type: donut +data_generator: | + return entity.attributes.categories.slice(0,5).map(c => [c.name, c.spent]); + +# Bar: what do I buy most? +chart_type: bar +data_generator: | + return entity.attributes.categories.slice(0,5).map(c => [c.name, c.units]); + +# Bar: most expensive per unit? +chart_type: bar +data_generator: | + return entity.attributes.categories.slice(0,10).map(c => [c.name, c.avg_unit_price]); +``` + +--- + +### Task 1: Probe API — confirm order detail structure + +**Files:** +- Modify: `custom_components/rohlikcz/rohlik_api.py` (add `get_order_detail` method after `get_delivered_orders_page`) +- Modify: `custom_components/rohlikcz/services.py` (temporary probe service) + +**Goal:** Fetch one real order's detail to confirm field names before building anything. + +**Step 1: Add `get_order_detail` to API** + +Add after `get_delivered_orders_page` in `rohlik_api.py`: + +```python +async def get_order_detail(self, session, order_id: str) -> dict | None: + """Fetch detailed order info including items for a single order.""" + url = f"{BASE_URL}/api/v3/orders/{order_id}" + try: + response = await self._run_in_executor(session.get, url) + response.raise_for_status() + return response.json() + except RequestException as err: + _LOGGER.error(f"Error fetching order detail for {order_id}: {err}") + return None +``` + +**Step 2: Add temporary probe service** + +Add to `services.py` a temporary `probe_order_detail` service. See implementation in code (will be removed after probe). + +**Step 3: Deploy to HA, restart, call probe** + +```bash +cp custom_components/rohlikcz/rohlik_api.py /Volumes/config/custom_components/rohlikcz/ +cp custom_components/rohlikcz/services.py /Volumes/config/custom_components/rohlikcz/ +``` + +Call via MCP: +``` +ha_call_service("rohlikcz", "probe_order_detail", + data={"config_entry_id": "", "order_id": "1119530344"}, + return_response=True) +``` + +**Step 4: Document actual field names, remove probe service** + +Record: items key name, product ID field, name field, quantity field, price structure, categories array structure, levels available. + +**Step 5: Commit (API method only, probe removed)** + +```bash +git add custom_components/rohlikcz/rohlik_api.py +git commit -m "feat: add get_order_detail API method for item-level data" +``` + +--- + +### Task 2: Upgrade OrderStore to v2 with item storage and aggregation + +**Files:** +- Modify: `custom_components/rohlikcz/hub.py` (OrderStore class) + +**Step 1: Add v1→v2 migration in `load()`** + +After loading JSON, if version < 2, bump to 2 and save. No data changes needed. + +**Step 2: Add `_extract_items` static method** + +Normalize item data from API response — handle field name variations (id/productId, name/productName, etc.). Extract level-1 category. Return list of `{id, name, quantity, price, brand, category, category_id}`. + +**Step 3: Add enrichment methods** + +- `enrich_order(order_id, detail)` → extract items and add to order entry +- `unenriched_order_ids` property → list of order IDs without items +- `enriched_count` property + +**Step 4: Add aggregation methods** + +- `category_totals(year=None)` → returns `[{"name": "Dairy", "spent": 5000, "units": 200, "avg_unit_price": 25.0}]` sorted by spent desc +- Uses only enriched orders, skips unenriched + +**Step 5: Verify syntax, commit** + +```bash +git add custom_components/rohlikcz/hub.py +git commit -m "feat: upgrade OrderStore to v2 with item storage and category aggregation" +``` + +--- + +### Task 3: Wire enrichment into hub and API + +**Files:** +- Modify: `custom_components/rohlikcz/rohlik_api.py` (add `enrich_orders` batch method) +- Modify: `custom_components/rohlikcz/hub.py` (modify `fetch_full_order_history`, add auto-enrich in `async_update`) +- Modify: `custom_components/rohlikcz/services.py` (update service handler return) + +**Step 1: Add `enrich_orders` to API** + +Batch method that takes list of order IDs, fetches detail for each with 200ms rate limit, returns `{order_id: detail}` dict. + +**Step 2: Modify `fetch_full_order_history` in hub** + +After fetching order list (existing), also enrich all unenriched orders. Return dict: `{total_orders, new_orders, enriched_orders, unenriched_remaining}`. + +**Step 3: Add auto-enrich in `async_update`** + +After `process_orders` detects new orders, fetch their details (0-1 API calls per poll). + +**Step 4: Update service handler** + +Pass through the new dict from `fetch_full_order_history` (superset of old response). + +**Step 5: Verify syntax, commit** + +```bash +git add custom_components/rohlikcz/rohlik_api.py custom_components/rohlikcz/hub.py custom_components/rohlikcz/services.py +git commit -m "feat: wire order enrichment into fetch and poll flows" +``` + +--- + +### Task 4: Add category breakdown sensors + +**Files:** +- Modify: `custom_components/rohlikcz/sensor.py` (add 2 new sensor classes) +- Modify: `custom_components/rohlikcz/const.py` (add icon) +- Modify: `custom_components/rohlikcz/translations/en.json` +- Modify: `custom_components/rohlikcz/translations/cs.json` + +**Step 1: Add icon to const.py** + +```python +ICON_CATEGORY_SPENDING = "mdi:shape" +``` + +**Step 2: Add `CategorySpendingYearly` sensor** + +- `_attr_translation_key = "categories_this_year"` +- State: number of categories with spending +- Attribute `categories`: sorted list of `{name, spent, units, avg_unit_price}` for all level-1 categories +- Attributes: `enriched_orders`, `total_orders`, `year` + +**Step 3: Add `CategorySpendingAllTime` sensor** + +Same structure, no year filter. + +**Step 4: Register in `async_setup_entry` entity list** + +Append after AllTimeSpent. + +**Step 5: Add translations** + +EN: "Categories This Year", "Categories All Time" +CS: "Kategorie tento rok", "Kategorie celkem" + +**Step 6: Verify syntax, commit** + +```bash +git add custom_components/rohlikcz/sensor.py custom_components/rohlikcz/const.py custom_components/rohlikcz/translations/ +git commit -m "feat: add category breakdown sensors for yearly and all-time" +``` + +--- + +### Task 5: Tests + +**Files:** +- Modify: `tests/test_order_store.py` + +**Step 1: Add tests for item extraction, category aggregation, v2 migration** + +- Test `_extract_items` with various API response formats +- Test `category_totals` with mixed enriched/unenriched orders +- Test v1→v2 migration preserves data +- Test incremental enrichment + +**Step 2: Run tests, commit** + +```bash +python3 tests/test_order_store.py +git add tests/test_order_store.py +git commit -m "test: add v2 item extraction and category aggregation tests" +``` + +--- + +### Task 6: Deploy, backfill, and verify + +**Step 1: Copy all files to HA, restart** + +**Step 2: Verify existing sensors still work (no regression)** + +**Step 3: Run `fetch_order_history` to enrich all 483 orders (~100 seconds)** + +**Step 4: Verify new category sensors show data** + +**Step 5: Verify store file has items** + +**Step 6: Commit final state** + +--- + +## Summary + +| Task | What | Touches existing? | +|------|------|-------------------| +| 1 | Probe API + `get_order_detail` method | No — new method only | +| 2 | OrderStore v2 + aggregation | No — additive migration, new methods | +| 3 | Wire enrichment into flows | Minimal — `fetch_full_order_history` returns superset | +| 4 | Two new category sensors | No — new classes appended | +| 5 | Tests | No | +| 6 | Deploy + verify | No | diff --git a/docs/plans/2026-02-26-config-flow-analytics-options.md b/docs/plans/2026-02-26-config-flow-analytics-options.md new file mode 100644 index 0000000..502b43d --- /dev/null +++ b/docs/plans/2026-02-26-config-flow-analytics-options.md @@ -0,0 +1,910 @@ +# Config Flow: Analytics Level Selection — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** After login, let users choose which spending analytics levels they want (L0–L3 categories, per-item), and only trigger enrichment if at least one is selected. + +**Architecture:** Add a second config flow step (`async_step_analytics`) after successful login. Store user selections in `entry.options`. Also add an OptionsFlow so users can change selections later (triggers reload). Sensor creation and enrichment are gated by these options. Existing users who upgrade get all analytics disabled by default (no surprise API calls). + +**Tech Stack:** Home Assistant ConfigFlow/OptionsFlow, `SelectSelector` with `multiple=True` + `mode=LIST` for checkboxes, voluptuous schemas. + +--- + +## Background / Key Files + +| File | Purpose | +|------|---------| +| `custom_components/rohlikcz/config_flow.py` | Login + new analytics step | +| `custom_components/rohlikcz/__init__.py` | Entry setup, options reload listener | +| `custom_components/rohlikcz/const.py` | New option constants | +| `custom_components/rohlikcz/sensor.py` | Conditional sensor creation | +| `custom_components/rohlikcz/hub.py` | Conditional enrichment | +| `custom_components/rohlikcz/translations/en.json` | English UI strings | +| `custom_components/rohlikcz/translations/cs.json` | Czech UI strings | +| `custom_components/rohlikcz/manifest.json` | Bump version | + +**How HA config flow steps work:** +- Each step is a method `async_step_` returning `async_show_form(step_id="", ...)`. +- Store intermediate data between steps as `self.`. +- Only the final step calls `self.async_create_entry(title=..., data=..., options=...)`. +- `entry.data` = credentials (immutable). `entry.options` = user preferences (mutable via OptionsFlow). +- OptionsFlow entry point is always `async_step_init`. + +**Analytics levels we offer:** + +| Option value | Display (EN) | Display (CS) | Example | +|---|---|---|---| +| `categories_l0` | Top-level categories | Kategorie nejvyšší úrovně | Nápoje, Drogerie | +| `categories_l1` | Mid-level categories | Kategorie střední úrovně | Horké nápoje, Čisticí prostředky | +| `categories_l2` | Detailed categories | Podrobné kategorie | Káva, Univerzální čistič | +| `categories_l3` | Most specific categories | Nejpodrobnější kategorie | Zrnková káva, Ve spreji | +| `per_item` | Per-item spending | Útrata po položkách | Tchibo Barista, Savo sprej | + +Each selected level creates 2 sensors: `*_this_year` and `*_all_time`. Per-item also creates 2 sensors. + +**Enrichment rule:** If ANY option is selected → enrichment runs. If NONE selected → no enrichment, no order store, no extra API calls. + +--- + +### Task 1: Add option constants to const.py + +**Files:** +- Modify: `custom_components/rohlikcz/const.py` + +**Step 1: Add the constants** + +Add these at the end of `const.py`, after the existing service name constants: + +```python +""" Analytics options """ +CONF_ANALYTICS = "analytics" +ANALYTICS_OPTIONS = [ + "categories_l0", + "categories_l1", + "categories_l2", + "categories_l3", + "per_item", +] +DEFAULT_ANALYTICS = [] # Nothing enabled by default (opt-in) +``` + +**Step 2: Commit** + +```bash +git add custom_components/rohlikcz/const.py +git commit -m "feat: add analytics option constants" +``` + +--- + +### Task 2: Add second config flow step (analytics selection) + +**Files:** +- Modify: `custom_components/rohlikcz/config_flow.py` + +**Step 1: Update config_flow.py** + +Replace the entire file with: + +```python +import logging +from typing import Any + +from homeassistant.const import CONF_PASSWORD, CONF_EMAIL +from homeassistant import config_entries +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +import voluptuous as vol + +from .const import DOMAIN, CONF_ANALYTICS, ANALYTICS_OPTIONS, DEFAULT_ANALYTICS +from .errors import InvalidCredentialsError +from .rohlik_api import RohlikCZAPI + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[str, dict[str, Any]]: + """Validate the user input allows us to connect.""" + api = RohlikCZAPI(data[CONF_EMAIL], data[CONF_PASSWORD]) + reply = await api.get_data() + title: str = reply["login"]["data"]["user"]["name"] + return title, data + + +ANALYTICS_SCHEMA = vol.Schema({ + vol.Optional(CONF_ANALYTICS, default=DEFAULT_ANALYTICS): SelectSelector( + SelectSelectorConfig( + options=ANALYTICS_OPTIONS, + multiple=True, + mode=SelectSelectorMode.LIST, + translation_key=CONF_ANALYTICS, + ) + ), +}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + VERSION = 1 + + def __init__(self) -> None: + super().__init__() + self._user_title: str | None = None + self._user_data: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + + data_schema: dict[Any, Any] = { + vol.Required(CONF_EMAIL, default="e-mail"): str, + vol.Required(CONF_PASSWORD, default="password"): str, + } + + errors: dict[str, str] = {} + + if user_input is not None: + try: + info, data = await validate_input(self.hass, user_input) + self._user_title = info + self._user_data = data + return await self.async_step_analytics() + + except InvalidCredentialsError: + errors["base"] = "invalid_auth" + + except Exception: + _LOGGER.exception("Unknown exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=vol.Schema(data_schema), errors=errors + ) + + async def async_step_analytics( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + """Second step: choose analytics levels.""" + if user_input is not None: + return self.async_create_entry( + title=self._user_title, + data=self._user_data, + options={CONF_ANALYTICS: user_input.get(CONF_ANALYTICS, [])}, + ) + + return self.async_show_form( + step_id="analytics", + data_schema=ANALYTICS_SCHEMA, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: config_entries.ConfigEntry): + return RohlikOptionsFlowHandler() + + +class RohlikOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options for existing entries (reconfigure analytics).""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + current = self.config_entry.options.get(CONF_ANALYTICS, DEFAULT_ANALYTICS) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema({ + vol.Optional(CONF_ANALYTICS, default=current): SelectSelector( + SelectSelectorConfig( + options=ANALYTICS_OPTIONS, + multiple=True, + mode=SelectSelectorMode.LIST, + translation_key=CONF_ANALYTICS, + ) + ), + }), + ) +``` + +**Key changes from original:** +- `VERSION` bumped from `0.1` to `1` (integer, required for options flow). +- `async_step_user` no longer calls `async_create_entry` directly — stores data and goes to step 2. +- `async_step_analytics` shows checkboxes and creates the entry with `options=`. +- `async_get_options_flow` registered so existing entries get a "Configure" button. +- `RohlikOptionsFlowHandler` lets users change analytics selections later. + +**Step 2: Commit** + +```bash +git add custom_components/rohlikcz/config_flow.py +git commit -m "feat: add analytics selection step to config flow" +``` + +--- + +### Task 3: Add translations for the analytics step + +**Files:** +- Modify: `custom_components/rohlikcz/translations/en.json` +- Modify: `custom_components/rohlikcz/translations/cs.json` + +**Step 1: Update en.json** + +Add the `"analytics"` step inside `"config" > "step"`, and add a `"selector"` block at root level. The full `"config"` block becomes: + +```json +{ + "config": { + "step": { + "user": { + "title": "Rohlik.cz Account", + "description": "Enter your Rohlik.cz login credentials.", + "data": { + "username": "Email", + "password": "Password" + }, + "data_description": { + "username": "Your Rohlik.cz account email", + "password": "Your Rohlik.cz account password" + } + }, + "analytics": { + "title": "Spending Analytics", + "description": "Choose which spending breakdowns to track. Each selected level creates sensors for this year and all time. Selecting any option will trigger a one-time download of your order history (may take several minutes).", + "data": { + "analytics": "Analytics levels" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Rohlik.cz", + "invalid_auth": "Invalid email or password", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Account is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Spending Analytics", + "description": "Choose which spending breakdowns to track. Changes take effect after reload.", + "data": { + "analytics": "Analytics levels" + } + } + } + }, + "selector": { + "analytics": { + "options": { + "categories_l0": "Top-level categories (e.g. Drinks, Drugstore, Fruit & Vegetables)", + "categories_l1": "Mid-level categories (e.g. Hot drinks, Cleaning products, Vegetables)", + "categories_l2": "Detailed categories (e.g. Coffee, Universal cleaner, Tomatoes)", + "categories_l3": "Most specific categories (e.g. Bean coffee, Spray cleaner)", + "per_item": "Per-item spending (e.g. Tchibo Barista, Savo Spray)" + } + } + }, + "entity": { ... } +} +``` + +**Note:** Keep the existing `"entity"` block unchanged. Only add/modify `"config.step.analytics"`, `"options"`, and `"selector"`. + +**Step 2: Update cs.json** + +Same structure, Czech text: + +```json +{ + "config": { + "step": { + "user": { ... }, + "analytics": { + "title": "Analýza nákupů", + "description": "Vyberte, které úrovně útrat chcete sledovat. Pro každou vybranou úroveň se vytvoří senzory pro tento rok a celkově. Výběr jakékoli možnosti spustí jednorázové stažení historie objednávek (může trvat několik minut).", + "data": { + "analytics": "Úrovně analýzy" + } + } + }, + ... + }, + "options": { + "step": { + "init": { + "title": "Analýza nákupů", + "description": "Vyberte, které úrovně útrat chcete sledovat. Změny se projeví po reloadu.", + "data": { + "analytics": "Úrovně analýzy" + } + } + } + }, + "selector": { + "analytics": { + "options": { + "categories_l0": "Nejvyšší kategorie (např. Nápoje, Drogerie, Ovoce a zelenina)", + "categories_l1": "Střední kategorie (např. Horké nápoje, Čisticí prostředky, Zelenina)", + "categories_l2": "Podrobné kategorie (např. Káva, Univerzální čistič, Rajčata)", + "categories_l3": "Nejpodrobnější kategorie (např. Zrnková káva, Ve spreji)", + "per_item": "Útrata po položkách (např. Tchibo Barista, Savo sprej)" + } + } + }, + "entity": { ... } +} +``` + +**Step 3: Commit** + +```bash +git add custom_components/rohlikcz/translations/en.json custom_components/rohlikcz/translations/cs.json +git commit -m "feat: add translations for analytics config step" +``` + +--- + +### Task 4: Add reload listener in __init__.py and pass options to hub + +**Files:** +- Modify: `custom_components/rohlikcz/__init__.py` + +**Step 1: Update __init__.py** + +```python +"""Rohlík CZ custom component.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, CONF_ANALYTICS, DEFAULT_ANALYTICS +from .hub import RohlikAccount +from .services import register_services + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = ["sensor", "binary_sensor", "todo", "calendar"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rohlik integration from a config entry flow.""" + analytics = entry.options.get(CONF_ANALYTICS, DEFAULT_ANALYTICS) + + rohlik_hub = RohlikAccount(hass, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], analytics=analytics) + await rohlik_hub.async_update() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = rohlik_hub + + # Register services + register_services(hass) + + _LOGGER.info("Setting up platforms: %s", PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _LOGGER.info("Platforms setup complete") + + # Reload when options change (user reconfigures analytics) + entry.async_on_unload(entry.add_update_listener(_async_reload_entry)) + + return True + + +async def _async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the config entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok +``` + +**Step 2: Commit** + +```bash +git add custom_components/rohlikcz/__init__.py +git commit -m "feat: pass analytics options to hub and add reload listener" +``` + +--- + +### Task 5: Gate enrichment in hub.py behind analytics options + +**Files:** +- Modify: `custom_components/rohlikcz/hub.py` + +**Step 1: Update RohlikAccount.__init__ to accept analytics** + +Change the `__init__` signature: + +```python +def __init__(self, hass: HomeAssistant, username: str, password: str, analytics: list[str] | None = None) -> None: + """Initialize account info.""" + super().__init__() + self._hass = hass + self._username: str = username + self._password: str = password + self._rohlik_api = RohlikCZAPI(self._username, self._password) + self.data: dict = {} + self._callbacks: set[Callable[[], None]] = set() + self._order_store: OrderStore | None = None + self._analytics: list[str] = analytics or [] +``` + +**Step 2: Add a property to check if analytics is enabled** + +```python +@property +def analytics_enabled(self) -> bool: + """Whether any analytics level is selected.""" + return len(self._analytics) > 0 + +@property +def analytics(self) -> list[str]: + """Selected analytics levels.""" + return self._analytics +``` + +**Step 3: Gate the order store init and enrichment in async_update** + +In the `async_update` method, wrap the order store block: + +```python +# Initialize order store on first update (only if analytics enabled) +if self._analytics and not self._order_store and self.data.get("login"): + user_id = str(self.data["login"]["data"]["user"]["id"]) + storage_dir = self._hass.config.path(".storage") + self._order_store = await OrderStore.async_create(storage_dir, user_id, self._hass) + +# Process delivered orders into persistent store and auto-enrich new ones +if self._analytics and self._order_store and self.data.get("delivered_orders"): + ... # existing enrichment code unchanged +``` + +Just add `self._analytics and` as a guard to both `if` blocks. Rest stays the same. + +**Step 4: Commit** + +```bash +git add custom_components/rohlikcz/hub.py +git commit -m "feat: gate order store and enrichment behind analytics options" +``` + +--- + +### Task 6: Create sensors conditionally based on options + +**Files:** +- Modify: `custom_components/rohlikcz/sensor.py` + +**Step 1: Add new sensor classes for L0, L2, L3, and per-item** + +Add these classes after the existing `CategorySpendingAllTime` class. They follow the exact same pattern but with different `level` values and translation keys. + +```python +class CategorySpendingL0Yearly(BaseEntity, SensorEntity): + """L0 category spending this year.""" + _attr_translation_key = "categories_l0_this_year" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + return len(store.category_totals(year=year, level=0)) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + categories = store.category_totals(year=year, level=0) + return {"year": year, "categories": categories} if categories else {"year": year} + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class CategorySpendingL0AllTime(BaseEntity, SensorEntity): + """L0 category spending all time.""" + _attr_translation_key = "categories_l0_all_time" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + store = self._rohlik_account.order_store + if not store: + return None + return len(store.category_totals(level=0)) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + categories = store.category_totals(level=0) + return {"categories": categories, "products_in_cache": store.cached_product_count} if categories else None + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) +``` + +Create the same pattern for L2 (`level=2`, translation keys `categories_l2_this_year`/`categories_l2_all_time`) and L3 (`level=3`, translation keys `categories_l3_this_year`/`categories_l3_all_time`). + +**Step 2: Add per-item sensor classes** + +These need a new `item_totals()` method on `OrderStore` (see Task 7). The sensor classes: + +```python +class ItemSpendingYearly(BaseEntity, SensorEntity): + """Per-item spending this year.""" + _attr_translation_key = "items_this_year" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + return len(store.item_totals(year=year)) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + items = store.item_totals(year=year) + return {"year": year, "items": items} if items else {"year": year} + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) + + +class ItemSpendingAllTime(BaseEntity, SensorEntity): + """Per-item spending all time.""" + _attr_translation_key = "items_all_time" + _attr_should_poll = False + + @property + def native_value(self) -> int | None: + store = self._rohlik_account.order_store + if not store: + return None + return len(store.item_totals()) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + store = self._rohlik_account.order_store + if not store: + return None + items = store.item_totals() + return {"items": items, "products_in_cache": store.cached_product_count} if items else None + + @property + def icon(self) -> str: + return ICON_CATEGORY_SPENDING + + async def async_added_to_hass(self) -> None: + self._rohlik_account.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + self._rohlik_account.remove_callback(self.async_write_ha_state) +``` + +**Step 3: Update async_setup_entry to conditionally add sensors** + +```python +async def async_setup_entry(hass, config_entry, async_add_entities): + rohlik_hub = hass.data[DOMAIN][config_entry.entry_id] + analytics = rohlik_hub.analytics + + entities = [ + # ... all existing base sensors (FirstDelivery through MonthlySpent) ... + ] + + # Spending sensors that need the order store (always created if analytics enabled, + # since they depend on the same order data) + if analytics: + entities.append(YearlySpent(rohlik_hub)) + entities.append(AllTimeSpent(rohlik_hub)) + + # Category sensors per selected level + if "categories_l0" in analytics: + entities.append(CategorySpendingL0Yearly(rohlik_hub)) + entities.append(CategorySpendingL0AllTime(rohlik_hub)) + if "categories_l1" in analytics: + entities.append(CategorySpendingYearly(rohlik_hub)) + entities.append(CategorySpendingAllTime(rohlik_hub)) + if "categories_l2" in analytics: + entities.append(CategorySpendingL2Yearly(rohlik_hub)) + entities.append(CategorySpendingL2AllTime(rohlik_hub)) + if "categories_l3" in analytics: + entities.append(CategorySpendingL3Yearly(rohlik_hub)) + entities.append(CategorySpendingL3AllTime(rohlik_hub)) + if "per_item" in analytics: + entities.append(ItemSpendingYearly(rohlik_hub)) + entities.append(ItemSpendingAllTime(rohlik_hub)) + + # ... existing conditional sensors (express slot, premium) ... +``` + +**Important:** `YearlySpent` and `AllTimeSpent` are moved inside the `if analytics:` block since they also depend on the order store. `MonthlySpent` stays outside because it uses `delivered_orders` from the API directly (no store needed). + +**Step 4: Commit** + +```bash +git add custom_components/rohlikcz/sensor.py +git commit -m "feat: conditionally create analytics sensors based on config options" +``` + +--- + +### Task 7: Add item_totals() to OrderStore + +**Files:** +- Modify: `custom_components/rohlikcz/hub.py` + +**Step 1: Add the method** + +Add after `category_totals()`: + +```python +def item_totals(self, year: str | None = None) -> list[dict]: + """Aggregate spending by individual product. Optionally filter by year. + + Returns sorted list: [{"name": "Tchibo Barista", "id": 1328327, "spent": 500.0, "units": 10, "avg_unit_price": 50.0}] + """ + from collections import defaultdict + items = defaultdict(lambda: {"name": "", "spent": 0.0, "units": 0}) + + for order in self._data["orders"].values(): + if year and not order["date"].startswith(year): + continue + for item in order.get("items", []): + pid = item.get("id") + if not pid: + continue + items[pid]["name"] = item.get("name", "Unknown") + items[pid]["spent"] += item.get("price", 0) + items[pid]["units"] += item.get("quantity", 1) + + result = [] + for pid, vals in items.items(): + avg = round(vals["spent"] / vals["units"], 2) if vals["units"] > 0 else 0.0 + result.append({ + "name": vals["name"], + "id": pid, + "spent": round(vals["spent"], 2), + "units": vals["units"], + "avg_unit_price": avg, + }) + result.sort(key=lambda x: x["spent"], reverse=True) + return result +``` + +**Step 2: Commit** + +```bash +git add custom_components/rohlikcz/hub.py +git commit -m "feat: add item_totals() aggregation to OrderStore" +``` + +--- + +### Task 8: Add translations for new sensor entities + +**Files:** +- Modify: `custom_components/rohlikcz/translations/en.json` +- Modify: `custom_components/rohlikcz/translations/cs.json` + +**Step 1: Add sensor translation keys** + +In `en.json`, inside `"entity" > "sensor"`, add: + +```json +"categories_l0_this_year": { "name": "Top Categories This Year" }, +"categories_l0_all_time": { "name": "Top Categories All Time" }, +"categories_l2_this_year": { "name": "Detailed Categories This Year" }, +"categories_l2_all_time": { "name": "Detailed Categories All Time" }, +"categories_l3_this_year": { "name": "Specific Categories This Year" }, +"categories_l3_all_time": { "name": "Specific Categories All Time" }, +"items_this_year": { "name": "Items This Year" }, +"items_all_time": { "name": "Items All Time" } +``` + +In `cs.json`, same keys: + +```json +"categories_l0_this_year": { "name": "Hlavní kategorie tento rok" }, +"categories_l0_all_time": { "name": "Hlavní kategorie celkem" }, +"categories_l2_this_year": { "name": "Podrobné kategorie tento rok" }, +"categories_l2_all_time": { "name": "Podrobné kategorie celkem" }, +"categories_l3_this_year": { "name": "Specifické kategorie tento rok" }, +"categories_l3_all_time": { "name": "Specifické kategorie celkem" }, +"items_this_year": { "name": "Položky tento rok" }, +"items_all_time": { "name": "Položky celkem" } +``` + +**Step 2: Commit** + +```bash +git add custom_components/rohlikcz/translations/en.json custom_components/rohlikcz/translations/cs.json +git commit -m "feat: add translations for all analytics sensor levels" +``` + +--- + +### Task 9: Bump version and handle migration for existing entries + +**Files:** +- Modify: `custom_components/rohlikcz/manifest.json` +- Modify: `custom_components/rohlikcz/config_flow.py` + +**Step 1: Bump version in manifest.json** + +Change `"version"` from `"0.3.1"` to `"0.4.0"`. + +**Step 2: Add migration handler for existing entries** + +In `__init__.py`, add migration so existing installs don't break. Before the `async_setup_entry` function: + +```python +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entries to new format.""" + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version < 1: + # Pre-analytics entries: set empty analytics (opt-in) + new_options = {**entry.options, CONF_ANALYTICS: DEFAULT_ANALYTICS} + hass.config_entries.async_update_entry(entry, options=new_options, version=1) + _LOGGER.info("Migrated config entry to version 1 (analytics disabled by default)") + + return True +``` + +**Step 3: Commit** + +```bash +git add custom_components/rohlikcz/manifest.json custom_components/rohlikcz/__init__.py +git commit -m "feat: bump version to 0.4.0 and add config entry migration" +``` + +--- + +### Task 10: Manual testing + +**No code changes — testing only.** + +#### Option A: Uninstall and reinstall on existing HA + +1. **Go to HA UI** → Settings → Devices & Services → Rohlík.cz +2. Click the three dots → **Delete** +3. Wait for full unload +4. **Deploy updated files:** + ```bash + cp -r custom_components/rohlikcz/* /Volumes/config/custom_components/rohlikcz/ + ``` +5. **Restart HA** (Settings → System → Restart) +6. After restart, go to Settings → Devices & Services → **Add Integration** → search "Rohlik" +7. **Step 1 (Login):** Enter email/password → Submit +8. **Verify:** After successful login, you should see the **second step** with checkboxes +9. **Step 2 (Analytics):** Check "Mid-level categories" (L1) → Submit +10. **Verify sensors:** Go to Developer Tools → States → filter "rohlik" + - `sensor.rohlik_categories_this_year` should appear + - `sensor.rohlik_categories_all_time` should appear + - Other unchecked levels should NOT appear +11. **Test OptionsFlow:** Go to Settings → Devices & Services → Rohlík.cz → **Configure** + - You should see the analytics checkboxes with L1 pre-selected + - Check "Per-item spending" too → Submit + - Integration reloads → new `sensor.rohlik_items_this_year` and `items_all_time` appear +12. **Test unchecking all:** Configure again → uncheck everything → Submit + - All analytics sensors should disappear + - `YearlySpent` and `AllTimeSpent` should also disappear + - No enrichment runs (check logs) + +#### Option B: Fresh Docker instance (recommended for clean test) + +1. **Spin up a fresh HA container:** + ```bash + docker run -d --name ha-test \ + -p 8124:8123 \ + -v /tmp/ha-test-config:/config \ + homeassistant/home-assistant:latest + ``` +2. **Wait for startup** (~2 min), open `http://localhost:8124` +3. **Complete onboarding** (create account, skip integrations) +4. **Copy the integration:** + ```bash + mkdir -p /tmp/ha-test-config/custom_components/rohlikcz + cp -r custom_components/rohlikcz/* /tmp/ha-test-config/custom_components/rohlikcz/ + ``` +5. **Restart the container:** + ```bash + docker restart ha-test + ``` +6. **Add the integration:** Settings → Add Integration → Rohlik +7. **Walk through both steps** as described in Option A steps 7-12 +8. **Verify enrichment notification:** After selecting any analytics level, check notifications (bell icon) for "Rohlik: Enrichment in progress" +9. **Clean up:** + ```bash + docker stop ha-test && docker rm ha-test + rm -rf /tmp/ha-test-config + ``` + +#### Test matrix (check each): + +| Test | Expected | +|------|----------| +| Login with valid credentials | Goes to analytics step (not direct entry creation) | +| Login with invalid credentials | Shows error, stays on login step | +| Analytics step: select nothing → Submit | Entry created, no analytics sensors, no enrichment | +| Analytics step: select L1 only → Submit | Only L1 sensors + YearlySpent + AllTimeSpent created | +| Analytics step: select all → Submit | All 12 analytics sensors created | +| Options flow: add L0 to existing | Integration reloads, L0 sensors appear | +| Options flow: remove all analytics | All analytics sensors removed, enrichment stops | +| Existing entry migration (upgrade from old version) | Entry migrated, analytics empty, no sensors break | + +--- + +### Task 11: Deploy to production HA and final commit + +**Step 1: Deploy all files** + +```bash +cp -r custom_components/rohlikcz/* /Volumes/config/custom_components/rohlikcz/ +``` + +**Step 2: Restart HA** + +The existing entry will be migrated (analytics = empty). Go to Configure to enable desired analytics levels. + +**Step 3: Final commit and squash if needed** + +```bash +git add -A +git commit -m "feat: add config flow step for analytics level selection with OptionsFlow" +``` diff --git a/readme.md b/readme.md index cf8b009..6600b94 100644 --- a/readme.md +++ b/readme.md @@ -36,10 +36,17 @@ From the Home Assistant front page go to **Configuration** and then select **Int Use the "plus" button in the bottom right to add a new integration called **Rohlik.cz**. Fill in: - + - Email (your Rohlik.cz account email) - Password (your Rohlik.cz account password) +After login, you can optionally enable **Spending Analytics**: +- Choose which category levels to track (top-level, mid-level, detailed, most specific, per-item) +- Set how many top items to display in sensor attributes (default: 10) +- Optionally hide discontinued products from rankings + +These options can be changed later via the integration's **Configure** button. Enabling analytics triggers a one-time download of your full order history (may take several minutes). + The integration will connect to your Rohlik.cz account and set up the entities. ## Features @@ -75,6 +82,20 @@ The integration provides the following entities: - **Delivery Slot End** - Timestamp of end of delivery window for order made - **Delivery Time** - Timestamp of predicted exact delivery time for order made - **Monthly Spent** - Total amount spent in the current month +- **Yearly Spent** - Total amount spent in the current year (requires analytics) +- **All Time Spent** - Total amount spent across all tracked orders (requires analytics) + +#### Spending Analytics Sensors (opt-in) + +When analytics levels are enabled in the integration options, the following sensors are created (each with "this year" and "all time" variants): + +- **Top Categories** (L0) - Spending by top-level categories (e.g. Drinks, Drugstore) +- **Categories** (L1) - Spending by mid-level categories (e.g. Hot drinks, Cleaning products) +- **Detailed Categories** (L2) - Spending by detailed categories (e.g. Coffee, Universal cleaner) +- **Specific Categories** (L3) - Spending by most specific categories (e.g. Bean coffee, Spray cleaner) +- **Per-Item** - Spending by individual product (e.g. Tchibo Barista, Savo Spray) + +Each sensor's attributes contain the top N items (configurable, default 10) sorted by spending, with `total_count`, `spent`, `units`, and `avg_unit_price` per entry. ### Calendar @@ -104,6 +125,7 @@ Integration provides these custom actions (service calls): - **Get Cart Content** - Retrieve items currently in your Rohlik shopping cart. - **Search and Add** - Find a product and add it to your cart in one step - just tell it what you want and how many. - **Update Data** - Force the integration to update data from Rohlik.cz immediately. +- **Fetch Order History** - Download full order history and enrich with item details and categories. Runs automatically when analytics is enabled, but can also be triggered manually. ## Data Updates From 488e16b4b6f6c16c583cfc33b4163a6e0920a50b Mon Sep 17 00:00:00 2001 From: Miro <77541423+mirobotur@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:09:10 +0100 Subject: [PATCH 11/11] docs: remove personal user ID from plan doc Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-02-26-category-item-analytics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-02-26-category-item-analytics.md b/docs/plans/2026-02-26-category-item-analytics.md index cc0599f..042841b 100644 --- a/docs/plans/2026-02-26-category-item-analytics.md +++ b/docs/plans/2026-02-26-category-item-analytics.md @@ -41,7 +41,7 @@ ```json { "version": 2, - "user_id": "1086873", + "user_id": "1234567", "tracking_since": "2026-02-26T13:01:11+01:00", "orders": { "1119530344": {