diff --git a/CHANGELOG.md b/CHANGELOG.md index edcbf7a..7540391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-08-03 + +### Added + +- `sensor.fresh_harvest_next_delivery_add_ons`, the combined cost of the + add-ons excluding the produce box. +- `add_ons_total` and `box_price` attributes on the item-count sensor. +- A test asserting `add_ons_total + box_price` equals the portal's own + subtotal, so a mis-parsed price cannot pass silently. + +### Changed + +- `add_ons` attribute entries now carry quantity, unit, and extended price + (`4 Complete Recovery Smoothie 15.2 fl oz — $17.96`) rather than a bare name. + Templates reading these strings will need updating. +- The test fixture's totals are now internally consistent, so the subtotal + reconciliation is a real invariant rather than copied numbers. + ## [0.1.0] - 2026-08-03 ### Added diff --git a/README.md b/README.md index 8784c77..972bd03 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,16 @@ the Georgia local-produce delivery subscription. | --- | --- | --- | | `sensor.fresh_harvest_next_delivery` | `2026-08-04` | Attributes: `delivery_day`, `box` | | `sensor.fresh_harvest_next_delivery_total` | `109.06` | Attributes: `subtotal`, `tax`, `delivery_fee` | -| `sensor.fresh_harvest_next_delivery_items` | `14` | Attributes: `box`, `produce`, `add_ons` | +| `sensor.fresh_harvest_next_delivery_add_ons` | `72.88` | Add-ons only, excluding the box. Attributes: `box_price`, `add_ons` | +| `sensor.fresh_harvest_next_delivery_items` | `14` | Attributes: `box`, `box_price`, `produce`, `add_ons`, `add_ons_total` | | `sensor.fresh_harvest_open_order_delivery` | `2026-08-11` | The order you can still change | | `sensor.fresh_harvest_shopping_window` | `Shop tomorrow` | `closed` when nothing is customizable | +Each `add_ons` entry carries its quantity, unit, and extended price — a +multi-quantity line bills as one amount, so 4 smoothies read +`4 Complete Recovery Smoothie 15.2 fl oz — $17.96`. `add_ons_total` plus +`box_price` always equals the portal's own subtotal, which a test asserts. + The next delivery and the *open* order are usually two different deliveries. Once an order passes its cutoff it locks for packing, and the cart you can still edit is the following week's. diff --git a/custom_components/freshharvest/api.py b/custom_components/freshharvest/api.py index cf7c787..f5ec047 100644 --- a/custom_components/freshharvest/api.py +++ b/custom_components/freshharvest/api.py @@ -110,6 +110,15 @@ class DeliveryOrder: def all_items(self) -> list[OrderItem]: return [*self.items, *self.addons] + @property + def addons_total(self) -> float: + """Combined cost of the add-ons, excluding the produce box itself. + + Add-on prices are already extended (4 smoothies bill as one $17.96 + line), so this is a plain sum. It should equal `subtotal - box_price`. + """ + return round(sum(a.price for a in self.addons if a.price is not None), 2) + @dataclass class AccountSnapshot: diff --git a/custom_components/freshharvest/manifest.json b/custom_components/freshharvest/manifest.json index d57e4c8..55adf3f 100644 --- a/custom_components/freshharvest/manifest.json +++ b/custom_components/freshharvest/manifest.json @@ -1,12 +1,16 @@ { "domain": "freshharvest", "name": "Fresh Harvest", - "codeowners": ["@flan"], + "codeowners": [ + "@flan" + ], "config_flow": true, "documentation": "https://git.onetick.ninja/flan/ha-freshharvest", "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues", - "requirements": ["beautifulsoup4>=4.12"], - "version": "0.1.0" + "requirements": [ + "beautifulsoup4>=4.12" + ], + "version": "0.2.0" } diff --git a/custom_components/freshharvest/sensor.py b/custom_components/freshharvest/sensor.py index e0cb327..6dd1791 100644 --- a/custom_components/freshharvest/sensor.py +++ b/custom_components/freshharvest/sensor.py @@ -18,21 +18,30 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FreshHarvestConfigEntry -from .api import AccountSnapshot, DeliveryOrder +from .api import AccountSnapshot, DeliveryOrder, OrderItem from .const import DOMAIN from .coordinator import FreshHarvestCoordinator +def _format_item(item: OrderItem, with_price: bool = False) -> str: + """Render one line, e.g. '4 Complete Recovery Smoothie 15.2 fl oz — $17.96'.""" + line = " ".join( + part for part in (str(item.quantity or ""), item.name, item.unit) if part + ) + if with_price and item.price is not None: + line = f"{line} — ${item.price:,.2f}" + return line + + def _items_attrs(order: DeliveryOrder | None) -> dict[str, Any] | None: if order is None: return None return { "box": order.box_name, - "produce": [ - " ".join(part for part in (str(i.quantity or ""), i.name, i.unit) if part) - for i in order.items - ], - "add_ons": [a.name for a in order.addons], + "box_price": order.box_price, + "produce": [_format_item(i) for i in order.items], + "add_ons": [_format_item(a, with_price=True) for a in order.addons], + "add_ons_total": order.addons_total, } @@ -70,6 +79,22 @@ SENSORS: tuple[FreshHarvestSensorDescription, ...] = ( "delivery_fee": s.next_order.delivery_fee, }, ), + FreshHarvestSensorDescription( + key="next_delivery_addons_total", + translation_key="next_delivery_addons_total", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="USD", + value_fn=lambda s: s.next_order.addons_total if s.next_order else None, + attrs_fn=lambda s: None + if s.next_order is None + else { + "box_price": s.next_order.box_price, + "add_ons": [ + _format_item(a, with_price=True) for a in s.next_order.addons + ], + }, + ), FreshHarvestSensorDescription( key="next_delivery_items", translation_key="next_delivery_items", diff --git a/custom_components/freshharvest/strings.json b/custom_components/freshharvest/strings.json index 7ae2955..db53606 100644 --- a/custom_components/freshharvest/strings.json +++ b/custom_components/freshharvest/strings.json @@ -19,11 +19,24 @@ }, "entity": { "sensor": { - "next_delivery": { "name": "Next delivery" }, - "next_delivery_total": { "name": "Next delivery total" }, - "next_delivery_items": { "name": "Next delivery items" }, - "open_order_delivery": { "name": "Open order delivery" }, - "shop_window": { "name": "Shopping window" } + "next_delivery": { + "name": "Next delivery" + }, + "next_delivery_total": { + "name": "Next delivery total" + }, + "next_delivery_addons_total": { + "name": "Next delivery add-ons" + }, + "next_delivery_items": { + "name": "Next delivery items" + }, + "open_order_delivery": { + "name": "Open order delivery" + }, + "shop_window": { + "name": "Shopping window" + } } } } diff --git a/custom_components/freshharvest/translations/en.json b/custom_components/freshharvest/translations/en.json index 7ae2955..db53606 100644 --- a/custom_components/freshharvest/translations/en.json +++ b/custom_components/freshharvest/translations/en.json @@ -19,11 +19,24 @@ }, "entity": { "sensor": { - "next_delivery": { "name": "Next delivery" }, - "next_delivery_total": { "name": "Next delivery total" }, - "next_delivery_items": { "name": "Next delivery items" }, - "open_order_delivery": { "name": "Open order delivery" }, - "shop_window": { "name": "Shopping window" } + "next_delivery": { + "name": "Next delivery" + }, + "next_delivery_total": { + "name": "Next delivery total" + }, + "next_delivery_addons_total": { + "name": "Next delivery add-ons" + }, + "next_delivery_items": { + "name": "Next delivery items" + }, + "open_order_delivery": { + "name": "Open order delivery" + }, + "shop_window": { + "name": "Shopping window" + } } } } diff --git a/examples/dashboard-view.yaml b/examples/dashboard-view.yaml index d00eaa4..22340e6 100644 --- a/examples/dashboard-view.yaml +++ b/examples/dashboard-view.yaml @@ -4,7 +4,7 @@ # next delivery, what is in the box, and the order that can still be changed. # # To use it, open your dashboard, choose "Edit dashboard" -> "Raw configuration -# editor", and paste this under `views:`. It only needs the five sensors the +# editor", and paste this under `views:`. It only needs the six sensors the # integration creates. type: sections @@ -40,6 +40,11 @@ sections: name: Order total icon: mdi:cash-multiple color: teal + - type: tile + entity: sensor.fresh_harvest_next_delivery_add_ons + name: Add-ons + icon: mdi:cart-plus + color: purple - type: tile entity: sensor.fresh_harvest_next_delivery_items name: Items @@ -55,8 +60,10 @@ sections: content: |- {%- set produce = state_attr('sensor.fresh_harvest_next_delivery_items', 'produce') or [] -%} {%- set addons = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons') or [] -%} + {%- set addons_total = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons_total') -%} + {%- set box_price = state_attr('sensor.fresh_harvest_next_delivery_items', 'box_price') -%} {%- if produce -%} - **In the box** + **In the box**{% if box_price %} · ${{ '%.2f' | format(box_price) }}{% endif %} {% for i in produce %} - {{ i }} {%- endfor %} @@ -64,7 +71,7 @@ sections: _Box contents not assigned yet._ {%- endif %} {% if addons %} - **Add-ons** + **Add-ons**{% if addons_total %} · ${{ '%.2f' | format(addons_total) }}{% endif %} {% for a in addons %} - {{ a }} {%- endfor %} diff --git a/tests/fixtures/dashboard.html b/tests/fixtures/dashboard.html index 6901804..d3e160a 100644 --- a/tests/fixtures/dashboard.html +++ b/tests/fixtures/dashboard.html @@ -70,13 +70,46 @@ + +
+ Complete Recovery Smoothie +
+
Complete Recovery Smoothie
+ $17.96 +
15.2 fl oz
+
+
+
+ 4 +
+
+
+
+
+
+ Organic Fruit Punch Juice Boxes +
+
Organic Fruit Punch Juice Boxes
+ $13.98 +
8 count
+
+
+
+ 2 +
+
+
+
+
- Subtotal$105.88 - Tax$3.18 + + Subtotal$72.93 + Tax$2.19 Delivery$0.00 - Order Total See Details$109.06 + Order Total See Details$75.12
diff --git a/tests/test_parser.py b/tests/test_parser.py index 16e5d69..74218d2 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -70,16 +70,30 @@ def test_next_order_is_the_locked_one(snapshot): def test_next_order_totals_and_contents(snapshot): order = snapshot.next_order assert order.box_name == "Georgia Grown Small Box" - assert (order.subtotal, order.tax, order.delivery_fee) == (105.88, 3.18, 0.0) - assert order.total == 109.06 + assert (order.subtotal, order.tax, order.delivery_fee) == (72.93, 2.19, 0.0) + assert order.total == 75.12 assert [(i.quantity, i.name, i.unit) for i in order.items] == [ (1, "Bolero Carrots", ".5 lb"), (2, "Georgia Peaches", "6 count"), ] - assert [ - (a.name, a.price, a.quantity, a.unit) for a in order.addons - ] == [("Black Mission Figs", 7.99, 1, "1 pint")] - assert len(order.all_items) == 3 + assert [(a.name, a.price, a.quantity, a.unit) for a in order.addons] == [ + ("Black Mission Figs", 7.99, 1, "1 pint"), + ("Complete Recovery Smoothie", 17.96, 4, "15.2 fl oz"), + ("Organic Fruit Punch Juice Boxes", 13.98, 2, "8 count"), + ] + assert len(order.all_items) == 5 + + +def test_addons_total_reconciles_with_the_subtotal(snapshot): + """Add-ons plus the box price must equal the subtotal the portal reports.""" + order = snapshot.next_order + assert order.addons_total == 39.93 + assert round(order.addons_total + order.box_price, 2) == order.subtotal + + +def test_addons_total_is_zero_when_nothing_is_added(snapshot): + assert snapshot.open_order.addons == [] + assert snapshot.open_order.addons_total == 0.0 def test_open_order(snapshot):