Skip to content

feat: implement HA-side accumulation for monthly spent sensor#54

Merged
dvejsada merged 4 commits into
dvejsada:devfrom
eMeF1:feature/monthly-spent-accumulation
Dec 8, 2025
Merged

feat: implement HA-side accumulation for monthly spent sensor#54
dvejsada merged 4 commits into
dvejsada:devfrom
eMeF1:feature/monthly-spent-accumulation

Conversation

@eMeF1
Copy link
Copy Markdown
Contributor

@eMeF1 eMeF1 commented Dec 7, 2025

Summary

This PR includes three related improvements:

  1. Fix for handling concurrent orders - Adds utility to properly select the earliest order when multiple orders exist
  2. HA-side accumulation for monthly spent sensor - Implements state persistence and accumulation
  3. Fix for unit of measurement conflict - Resolves ValueError from conflicting unit definitions

Changes

✅ Fix: Handle concurrent orders with get_earliest_order utility

  • Added get_earliest_order() function in utils.py to find the order with earliest delivery time
  • Updated NextOrderSince and NextOrderTill sensors to use this utility
  • Updated IsOrderedSensor binary sensor to use this utility
  • Handles edge cases with missing or invalid order data
  • Supports multiple datetime formats (with/without microseconds)
  • Fixes issue where sensors could show incorrect delivery times when multiple concurrent orders exist

✅ Feature: HA-side accumulation for monthly spent sensor

  • Uses RestoreEntity to persist monthly totals across Home Assistant restarts
  • Tracks processed orders using order IDs to avoid double-counting
  • Automatically resets at month boundaries
  • Only processes finalized orders from delivered_orders endpoint
  • Validates orders have final prices before processing
  • Fixes issue where only 15 days of history were visible due to API limit (50 orders)
  • Eliminates dependency on API order limit and provides full monthly history regardless of order count

✅ Fix: Remove conflicting native_unit_of_measurement

  • Translation file already defines unit_of_measurement: "CZK" for monthly_spent
  • Removes code-level _attr_native_unit_of_measurement to avoid ValueError conflict

Technical Details

Concurrent Orders Fix

  • get_earliest_order() parses delivery slot since timestamps from all orders
  • Compares all orders to find the earliest delivery time
  • Returns None if no valid orders found
  • Used by DeliveryTime, NextOrderSince, NextOrderTill sensors and IsOrderedSensor binary sensor

Monthly Spent Accumulation

  • Sensor uses SensorStateClass.TOTAL for proper accumulation behavior
  • State is restored from Home Assistant's restore state mechanism
  • Order deduplication using order IDs stored in processed_orders set
  • Month change detection with automatic reset and logging
  • Stores state in extra_state_attributes for restoration:
    • monthly_total: Current month's total spending
    • processed_orders: List of processed order IDs
    • current_month: Current month identifier (YYYY-MM)
    • last_reset: Timestamp of last month reset
    • processed_count: Number of processed orders

Testing

  • Multiple concurrent orders correctly show earliest delivery time
  • Monthly total persists across HA restarts
  • Orders are not double-counted
  • Month change triggers automatic reset
  • Only finalized orders are processed
  • Unit of measurement displays correctly (CZK)

eMeF1 added 3 commits December 4, 2025 23:03
- Replace API-based calculation with Home Assistant state accumulation
- Only track orders with final prices (delivered and closed)
- Use RestoreEntity to persist monthly totals across restarts
- Track processed order IDs to avoid duplicates
- Automatically reset at start of each month
- Add state_class for proper history tracking
- Fixes issue where only 15 days of history were visible due to API limit (50 orders)

This approach eliminates dependency on API order limit and provides
full monthly history regardless of order count.
The translation file already defines unit_of_measurement for monthly_spent,
so we should not set _attr_native_unit_of_measurement in code to avoid
the ValueError conflict.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements three improvements to the Rohlík CZ integration: handling concurrent orders correctly, implementing Home Assistant-side accumulation for the monthly spent sensor with state persistence, and ensuring consistent unit of measurement handling.

  • Adds get_earliest_order() utility to properly handle multiple concurrent orders by selecting the one with earliest delivery time
  • Implements RestoreEntity for MonthlySpent sensor to persist monthly totals across HA restarts with order deduplication
  • Updates NextOrderSince, NextOrderTill, and IsOrderedSensor to use the new utility function

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 9 comments.

File Description
custom_components/rohlikcz/utils.py Adds get_earliest_order() function to find order with earliest delivery time, handling multiple datetime formats
custom_components/rohlikcz/sensor.py Refactors MonthlySpent to use RestoreEntity with state persistence and order deduplication; updates NextOrderSince and NextOrderTill to use get_earliest_order()
custom_components/rohlikcz/binary_sensor.py Updates IsOrderedSensor to use get_earliest_order() utility for consistent order selection

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

self._monthly_total = 0.0
self._processed_orders = set()
self._current_month = current_month
self._last_reset = datetime.now(ZoneInfo("Europe/Prague"))
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timezone inconsistency: In _check_and_reset_month(), self._last_reset is set using datetime.now(ZoneInfo("Europe/Prague")) (line 500), but in __init__() and when checking month boundaries, the code uses datetime.now() without timezone (lines 444, 494, 512). This could lead to inconsistent datetime comparisons or timezone-related issues. Consider using timezone-aware datetime consistently throughout, particularly datetime.now(ZoneInfo("Europe/Prague")) for all datetime.now() calls in this class.

Copilot uses AI. Check for mistakes.
Comment thread custom_components/rohlikcz/sensor.py Outdated
super().__init__(rohlik_account)
self._monthly_total: float = 0.0
self._processed_orders: set[str] = set() # Store order IDs
self._current_month: str = datetime.now().strftime("%Y-%m")
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timezone inconsistency: Using naive datetime.now() for month calculation could cause issues around month boundaries if the system timezone differs from "Europe/Prague" (the timezone used elsewhere in this integration). Consider using datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m") to ensure consistent month detection with the integration's target timezone.

Suggested change
self._current_month: str = datetime.now().strftime("%Y-%m")
self._current_month: str = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m")

Copilot uses AI. Check for mistakes.
Comment thread custom_components/rohlikcz/sensor.py Outdated

def _check_and_reset_month(self) -> None:
"""Reset total if month changed."""
current_month = datetime.now().strftime("%Y-%m")
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timezone inconsistency: Using naive datetime.now() for month boundary checking. This should use datetime.now(ZoneInfo("Europe/Prague")) to match the timezone used in line 500 and ensure consistent month detection regardless of the system timezone.

Copilot uses AI. Check for mistakes.
Comment thread custom_components/rohlikcz/sensor.py Outdated
if not orders:
return

current_month_pattern = datetime.now().strftime("%Y-%m-")
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timezone inconsistency: Using naive datetime.now() for current month pattern. This should use datetime.now(ZoneInfo("Europe/Prague")) to ensure orders are filtered based on the correct timezone, matching the integration's target timezone.

Suggested change
current_month_pattern = datetime.now().strftime("%Y-%m-")
current_month_pattern = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m-")

Copilot uses AI. Check for mistakes.
Comment on lines 434 to +562
def native_value(self) -> float | None:
"""Returns amount spend within last month."""
return calculate_current_month_orders_total(self._rohlik_account.data.get('delivered_orders', []))
"""Returns amount spent in current month."""
self._check_and_reset_month()
self._process_new_orders()
return self._monthly_total if self._monthly_total > 0 else 0.0
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance concern: The native_value property processes orders on every access by calling _process_new_orders(). While the deduplication check (if order_key in self._processed_orders) prevents double-counting, it still iterates through all delivered orders on every property access. Consider creating a dedicated callback method that calls these processing functions, and register it instead of async_write_ha_state. For example:

async def _handle_coordinator_update(self):
    self._check_and_reset_month()
    self._process_new_orders()
    await self.async_write_ha_state()

async def async_added_to_hass(self) -> None:
    ...
    self._rohlik_account.register_callback(self._handle_coordinator_update)

Then native_value can simply return self._monthly_total.

Copilot uses AI. Check for mistakes.
Comment thread custom_components/rohlikcz/utils.py Outdated
Returns:
dict: The order with the earliest delivery time, or None if no valid order found
"""
if not orders or len(orders) == 0:
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant check: len(orders) == 0 is unnecessary since not orders already covers empty lists. The condition can be simplified to just if not orders:.

Suggested change
if not orders or len(orders) == 0:
if not orders:

Copilot uses AI. Check for mistakes.
Comment thread custom_components/rohlikcz/sensor.py Outdated
Comment on lines +739 to +749
slot_start = datetime.strptime(earliest_order.get("deliverySlot", {}).get("since", None),
"%Y-%m-%dT%H:%M:%S.%f%z")
return slot_start
except (ValueError, TypeError):
# Try without microseconds if the format doesn't match
try:
slot_start = datetime.strptime(earliest_order.get("deliverySlot", {}).get("since", None),
"%Y-%m-%dT%H:%M:%S%z")
return slot_start
except (ValueError, TypeError):
return None
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code duplication: The datetime parsing logic with fallback (lines 738-749) duplicates the same logic already implemented in get_earliest_order() (lines 191-199 in utils.py). Since get_earliest_order() already returns an order that has been validated to have a parseable datetime, this additional parsing with the same fallback is redundant. Consider either:

  1. Trusting that get_earliest_order() returns a valid order and simplify this to a single parse attempt, or
  2. Extract the datetime from the earliest_order that get_earliest_order() has already validated, or
  3. Create a helper function to extract and parse datetime strings to avoid duplication.
Suggested change
slot_start = datetime.strptime(earliest_order.get("deliverySlot", {}).get("since", None),
"%Y-%m-%dT%H:%M:%S.%f%z")
return slot_start
except (ValueError, TypeError):
# Try without microseconds if the format doesn't match
try:
slot_start = datetime.strptime(earliest_order.get("deliverySlot", {}).get("since", None),
"%Y-%m-%dT%H:%M:%S%z")
return slot_start
except (ValueError, TypeError):
return None
slot_start = datetime.strptime(
earliest_order.get("deliverySlot", {}).get("since", None),
"%Y-%m-%dT%H:%M:%S.%f%z"
)
return slot_start
except (ValueError, TypeError):
return None

Copilot uses AI. Check for mistakes.
Comment thread custom_components/rohlikcz/sensor.py Outdated
Comment on lines +774 to +785
try:
slot_end = datetime.strptime(earliest_order.get("deliverySlot", {}).get("till", None),
"%Y-%m-%dT%H:%M:%S.%f%z")
return slot_start
else:
return None
return slot_end
except (ValueError, TypeError):
# Try without microseconds if the format doesn't match
try:
slot_end = datetime.strptime(earliest_order.get("deliverySlot", {}).get("till", None),
"%Y-%m-%dT%H:%M:%S%z")
return slot_end
except (ValueError, TypeError):
return None
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code duplication: The datetime parsing logic with fallback (lines 774-785) is duplicated from NextOrderSince and also duplicates logic in get_earliest_order(). This is the same parsing pattern repeated three times in the codebase. Consider extracting this into a reusable helper function or simplifying since get_earliest_order() already validates datetime parseability.

Copilot uses AI. Check for mistakes.
Comment thread custom_components/rohlikcz/sensor.py Outdated
from .entity import BaseEntity
from .hub import RohlikAccount
from .utils import extract_delivery_datetime, calculate_current_month_orders_total
from .utils import extract_delivery_datetime, calculate_current_month_orders_total, get_earliest_order
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'calculate_current_month_orders_total' is not used.

Suggested change
from .utils import extract_delivery_datetime, calculate_current_month_orders_total, get_earliest_order
from .utils import extract_delivery_datetime, get_earliest_order

Copilot uses AI. Check for mistakes.
@dvejsada
Copy link
Copy Markdown
Owner

dvejsada commented Dec 7, 2025

@eMeF1 Can you have a look on copilot comments to see if anything needs to be addressed? Thanks!

@eMeF1
Copy link
Copy Markdown
Contributor Author

eMeF1 commented Dec 7, 2025

@eMeF1 Can you have a look on copilot comments to see if anything needs to be addressed? Thanks!

@dvejsada Frankly I am using cursor.com for that but it says the recommendations makes sense. The below is a summary of fixes.

Completed Changes

  1. Removed unused import — Removed calculate_current_month_orders_total from imports
  2. Fixed timezone inconsistencies — Updated 4 locations to use datetime.now(ZoneInfo("Europe/Prague")):
    • Line 444: __init__() method
    • Line 483: async_added_to_hass() method (restore state)
    • Line 494: _check_and_reset_month() method
    • Line 512: _process_new_orders() method
  3. Created datetime parsing helper — Added parse_delivery_datetime_string() in utils.py to centralize datetime parsing logic
  4. Refactored NextOrderSince — Simplified to use the helper function, removing duplicate try-except blocks
  5. Refactored NextOrderTill — Simplified to use the helper function, removing duplicate try-except blocks
  6. Fixed performance issue — Created _handle_coordinator_update() async method that processes orders only when data updates, not on every native_value access. The native_value property now simply returns the value.
  7. Fixed redundant check — Removed len(orders) == 0 check in utils.py since not orders already covers it
  8. Updated imports — Added parse_delivery_datetime_string to imports

All changes pass linting with no errors. The code now addresses all Copilot AI review comments from the PR.

- Remove unused import calculate_current_month_orders_total
- Fix timezone inconsistencies (use ZoneInfo consistently)
- Create parse_delivery_datetime_string helper to eliminate code duplication
- Refactor NextOrderSince and NextOrderTill to use helper function
- Fix performance issue: move order processing to async callback instead of native_value property
- Remove redundant len(orders) == 0 check in utils.py

Addresses all Copilot AI review comments from PR dvejsada#54
@eMeF1
Copy link
Copy Markdown
Contributor Author

eMeF1 commented Dec 7, 2025

Hi @dvejsada,
I've installed the changes from this PR and will test them over the next week. The updates look good, and all Copilot review comments have been addressed.

Given the scope of changes (HA-side accumulation, performance improvements, timezone fixes), would you consider creating a beta release (e.g., v0.3.2-beta or v0.4.0-beta) after merging? This would let users test before a stable release.

@dvejsada dvejsada self-assigned this Dec 8, 2025
@dvejsada dvejsada self-requested a review December 8, 2025 17:41
@dvejsada dvejsada changed the base branch from master to dev December 8, 2025 21:42
@dvejsada dvejsada linked an issue Dec 8, 2025 that may be closed by this pull request
@dvejsada dvejsada merged commit 6b36986 into dvejsada:dev Dec 8, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enhancement] Handle concurrent orders

3 participants