diff --git a/addon-edge/CHANGELOG.md b/addon-edge/CHANGELOG.md index 341eb7c..e71d8e2 100644 --- a/addon-edge/CHANGELOG.md +++ b/addon-edge/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.0.0-edge.26 + +- Let people with task configuration permission set optional base points when creating a task. +- Keep the household default for title-only creation and accept localized decimal input. +- Explain how to correct a fractional amount when the current allocation requires whole base points. + ## 0.0.0-edge.21 - Use singular units when reviewing an intake of exactly one item in English and Spanish. diff --git a/addon-edge/DOCS.md b/addon-edge/DOCS.md index 647527d..2c0d16a 100644 --- a/addon-edge/DOCS.md +++ b/addon-edge/DOCS.md @@ -1,7 +1,9 @@ # Installation and operation -Edge 0.0.0-edge.23 packages the immutable runtime declared in -[artifact.json](artifact.json). Home Assistant can read the same due-today and +Edge 0.0.0-edge.26 packages the immutable runtime declared in +[artifact.json](artifact.json). People with task configuration permission can set +optional base points when creating a task. Leaving the field empty uses the +household default. Home Assistant can read the same due-today and overdue task counts shown on Overview through a dedicated installation grant. Enable the [native deadline sensors](../docs/tasks/ha-deadline-sensors.md) using the optional `ha_deadline_digest` setting. The calendar uses its own diff --git a/addon-edge/Dockerfile b/addon-edge/Dockerfile index 4310699..793b9ec 100644 --- a/addon-edge/Dockerfile +++ b/addon-edge/Dockerfile @@ -1,8 +1,8 @@ # The Supervisor builds this folder. Fetch only the reviewed immutable runtime and UI. FROM ghcr.io/astral-sh/uv:0.11.29@sha256:eb2843a1e56fd9e30c7276ce1a52cba86e64c7b385f5e3279a0e08e02dd058fc AS uv FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 AS source -ARG DOMUS_SOURCE_COMMIT=b72b5e40c0c552ae597ce77c95447c12a8a40cc4 -ARG DOMUS_SOURCE_SHA256=52bb0b1a109e4cd51a321168ffdefd7d1d56eeda2910e24df28902c0b2145b75 +ARG DOMUS_SOURCE_COMMIT=691496c299ff371763e445acb9d36b9623a81404 +ARG DOMUS_SOURCE_SHA256=535c0b95a2db96fe3ebc927596f4e74f6eaadb7b641f2a674c23e672489f0d3b ADD https://codeload.github.com/vypdev/domus/tar.gz/${DOMUS_SOURCE_COMMIT} /tmp/source.tar.gz RUN printf '%s /tmp/source.tar.gz\n' "$DOMUS_SOURCE_SHA256" | sha256sum --check - \ && mkdir /app \ @@ -24,9 +24,9 @@ RUN uv sync --frozen --no-dev --package domus-server --no-editable RUN .venv/bin/python -c "from pathlib import Path; import domus; from domus.entrypoints.ui_bundle import load_bundle; assert load_bundle(Path(domus.__file__).parent / 'ui') is not None" FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 -ARG BUILD_VERSION=0.0.0-edge.23 +ARG BUILD_VERSION=0.0.0-edge.26 ARG BUILD_ARCH -ARG DOMUS_SOURCE_COMMIT=b72b5e40c0c552ae597ce77c95447c12a8a40cc4 +ARG DOMUS_SOURCE_COMMIT=691496c299ff371763e445acb9d36b9623a81404 LABEL io.hass.type="app" \ io.hass.version="${BUILD_VERSION}" \ io.hass.arch="${BUILD_ARCH}" \ diff --git a/addon-edge/README.md b/addon-edge/README.md index 08e153a..890997a 100644 --- a/addon-edge/README.md +++ b/addon-edge/README.md @@ -3,7 +3,8 @@ Development preview of Domus installed as a Home Assistant app. Open it through HA's Open Web UI using your existing Home Assistant session. -This preview adds optional Home Assistant sensors for the same due-today and +This preview lets people with task configuration permission set optional base +points when creating a task. It also includes Home Assistant sensors for the same due-today and overdue task counts shown on Overview. This candidate uses SQLite schema 31. Keep a diff --git a/addon-edge/artifact.json b/addon-edge/artifact.json index 01f57f0..e3da5f0 100644 --- a/addon-edge/artifact.json +++ b/addon-edge/artifact.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "version": "0.0.0-edge.23", - "sourceCommit": "b72b5e40c0c552ae597ce77c95447c12a8a40cc4", - "sourceArchiveSHA256": "52bb0b1a109e4cd51a321168ffdefd7d1d56eeda2910e24df28902c0b2145b75", + "version": "0.0.0-edge.26", + "sourceCommit": "691496c299ff371763e445acb9d36b9623a81404", + "sourceArchiveSHA256": "535c0b95a2db96fe3ebc927596f4e74f6eaadb7b641f2a674c23e672489f0d3b", "storageSchema": 31, - "wheelSHA256": "3933b2d56ca64d8b5a23462cd53950e74466a0fa05d9a295b10dadc3953aa98f", + "wheelSHA256": "5bb060c69c0b2caf9b9f8d99ecff0b56e44a3096f6339582816f5dd52c5bf089", "pythonFiles": { "domus/__init__.py": "7182c88b92234b4328f94497f4e70222d4390d8093e4b62893dfa78ac5530ac6", "domus/application/__init__.py": "1e8c0c87bf2a8ca947112543070583c0cc55d9d40b56eab0d86ed59a4ec1e82c", @@ -42,13 +42,13 @@ "domus/application/member_profile_ports.py": "0e041d1923997b6c4894f185cfaad3912a396703373ece9a6e90a829e9aafc39", "domus/application/member_source_ports.py": "619b1e6eff75ea2ed84b41f268959a99ffa864f6c78bc8d096b52aa5d57d38dd", "domus/application/point_allocation_ports.py": "9ecbf2d76d7c97df71ff4872e0e66e86bc4fcc6f279849e20adfad7aecc47fb6", - "domus/application/point_allocations.py": "20199fb0d877de279a6b95418fef88cf8983bd5c4437fb67fed67cf178932517", + "domus/application/point_allocations.py": "140f86b2689d03ff41df01fc59b48ad17456811424cbc742522d7619362d3292", "domus/application/points.py": "a2a660198151d5b65a62b57cb90b35a82a28f38a9a3b7b214b49d6cf6cf0294e", "domus/application/points_ports.py": "8392b778007d4c55bc55498d38c959d6dc1d4eeaa1dc501cf4d4928ce09f4ac0", "domus/application/policy_commands.py": "bc588b5b5feb72fd5cb57d7b4a10ef5a05ae48da5ec042fd569142ce50fedeb6", "domus/application/ports.py": "9a7d2e39c07896faf6799789971174687a7290035ac6283af2f8b699a2ed45d0", "domus/application/recovery_commands.py": "2b8c198cde97f0213ec1612ea32bce5e00637aab99862fe08b59418d3c64af49", - "domus/application/recurrence_commands.py": "eb2544947770e456c780b3e30026ff63dd8a7f821344429f85a619e34e319a58", + "domus/application/recurrence_commands.py": "4d91c498ced40c335b8a081719a33dde97ab081a050ab352c3f1fac83e6f1447", "domus/application/recurrence_ports.py": "651d86417a20810c198aba0cd48129de6396db185278c01836366575027d6c24", "domus/application/recurrence_review.py": "c4d2a60551f774936662cb9244d227ea52a71a5e48fff0eadfbea4d1cc8b0e8b", "domus/application/recurrence_review_target.py": "e3c25fd2f9462769817acf739742117c1fadf95927d4efc3360c2e9a168e2dc2", @@ -57,7 +57,7 @@ "domus/application/relative_lifecycle.py": "e52a3077da1dff9dd7577dd9847579e0f4c346632798c2081b1d043f8a291b69", "domus/application/relative_materialization.py": "1271838d37bbe5e64cc940189d3168b18a6ca7f9ad4a64ff810e61f42f61bc55", "domus/application/relative_recurrence.py": "f0d595e9cbf2022977c7940c3b4fc50dd6f242b3dfa615415099f639fa11394f", - "domus/application/relative_recurrence_commands.py": "3ecf7c819a7ec79c43fd57ff5557c5aef6bd7cb572d920a5248c451d9aad3030", + "domus/application/relative_recurrence_commands.py": "159d12b30f9aa02ca7374fa03d41f39daeb7a86243c4c669ec2ef029a2df3aab", "domus/application/relative_state_review.py": "da62fe52f0de100fc7263c1d17a54b3183c996a61b3c357dcde90a505fdff30a", "domus/application/reschedule_review.py": "5d36a7ba163bb7b31e5fc3c90cb6ffff69a8792bdaa736a1929e2be797e5539b", "domus/application/restoration.py": "32a301e9d733f453526dbba70dbe99c036d6485a4c9bc8483d4f52456534a6eb", @@ -73,8 +73,8 @@ "domus/application/snapshots.py": "c4f0862f590d222711018de3b23a7155f5702375ce6ece7dd0e2170c86a22ca4", "domus/application/task_authorization.py": "abe17920d3131a5e1e27f9e2eff95ba152cd73463ec6cd9deca619c9009a9610", "domus/application/task_category_commands.py": "24544f80b3a8b9d0b42757a2df8d03b706337d5594bdcfcfd92128830f2ab36e", - "domus/application/task_commands.py": "ebcf93308cf7e099e87b204a736d64ea937b1c49682aec348eabf71210e79c7a", - "domus/application/task_creation.py": "a293bff1581f36439b4ebfd91dfc1d6c4aa7bf130a309bf464f0e81eed5cd8cd", + "domus/application/task_commands.py": "70c9ea6bee4045853e1553073892b0c5cb084409add9ee5ab21e633d474d60d9", + "domus/application/task_creation.py": "9f1344b45e705b21f811c770b2c58f61058772465618fde03f8fa163ed1399b5", "domus/application/task_events.py": "8586629b5f0322e45e299d15a8c21a2a834343d86feea0278d70e9d15c491d6a", "domus/application/task_facts.py": "a6ba841a719c8b8b4f4556d0381cccb52f40365f6e3539c8aca7bdd1c13cb935", "domus/application/task_material_commands.py": "bc0715ce0ed15a4807b773fda634945037bdc877ccc8417df77e6a34f1125ba6", @@ -83,7 +83,7 @@ "domus/application/task_material_views.py": "1f4e1b52c17064b73e3c6502b38fc578a7589d68044279312c783b656e5aa5bf", "domus/application/task_materials.py": "caa132a7b16fca293770deba3da149cf9bda2e24d4719abe9de39470d686698e", "domus/application/task_ports.py": "165f04fc639310da0b742c8c2d58b2eadd459142720fdc138df384f34e53e35a", - "domus/application/task_queries.py": "b9b320d080dcb699266cc6c33d978f199e1ecc0ca1ca9a70c02516b89ffc54a9", + "domus/application/task_queries.py": "a2ac362fd7b62cd83518ff7c8d8889d1e0d16ec50d9c02a8b6c78774902a69c8", "domus/application/task_requests.py": "2e9ead8f5458d45335ec55bad50f5f7e12b42ee9b00ff54634f3ff681b320897", "domus/application/task_reviews.py": "dcc288cda6cdcf5073bad4f24133b9d3496c0b0cf1272e8328e6df38129f3328", "domus/composition/__init__.py": "a902d8faf7dd19da6829fb730d81e32d34ec38ee6cfafd676966a95d0ad31366", @@ -109,7 +109,7 @@ "domus/domain/member_profiles.py": "2790ca55a7b37a390d006165a44520085e1ff513783b9cf1a7b681435b8ae4d2", "domus/domain/member_sources.py": "b5e9d19d501d3d5dd5caf5b4ce945d4c3ff71b1967e92bd8933f26661a416c01", "domus/domain/nominal_recurrence.py": "2c3678f220a311b8b08e6cad0d146a1a68135b5fd2a5c0d8ab9ed809fe47d8c1", - "domus/domain/point_allocation.py": "1c27b3dbb262093d5e4b37dd35ea3b824d7b0c817cf3ea243c4135d25901e433", + "domus/domain/point_allocation.py": "69768d52023194bad248cee0635532b9be1b5f81328cc9e1d8cbde3a709b4c81", "domus/domain/points.py": "e6a8fa845920f28270f1d7c0ed2f81542b60573fb1be95c4e16e152e4d531413", "domus/domain/recovery.py": "0bbe731961d4481e8002382ab0c9948fd415534b0cbbc03bdf0a0e35676f0cb0", "domus/domain/recurrence_frontier.py": "d0b1150e2bdbe855413c353e11222a649d3dcd2408e4eba18fcea09a3f4d5ad3", @@ -161,9 +161,9 @@ "domus/entrypoints/recurrence_dto.py": "094e6f64e1f963bfb053e830b214eed1189258e4f0eb8ff15ddba1f059649a1a", "domus/entrypoints/recurrence_http.py": "bb60e6fad11b09efa7fe97924451d85919fecce9df105e06a1999fc82bd2e1cc", "domus/entrypoints/reward_http.py": "503e46ccc8b26758799a229b75939085e3295bdfcdbf2cb9d137db95e3644737", - "domus/entrypoints/schedule_creation_http.py": "f5bc369e5135229e9eb62ad1ac1046c3fb2d0c9c53e6acad6bd61631c2a7042e", + "domus/entrypoints/schedule_creation_http.py": "2a3c3ea682f978eb2767dc8808a218c312861f31079cc2c76dbc3847ddec3197", "domus/entrypoints/task_category_http.py": "e5443015f5e5cb2684eb6807e0a3bac5116703b5611508281afd7d1addee9672", - "domus/entrypoints/task_dto.py": "df12654a0917a06742ea4605f0aca552d339df610ecb0f7467f69aa89b5a7c9d", + "domus/entrypoints/task_dto.py": "10657d1df0180bf05e71d1f38ac3214d2cea34c8822f50ea25ab331b9c014049", "domus/entrypoints/task_http.py": "5189ade4f7ab546ba84d0b88a82b02bf7e53cc436bf312920ed6839761aa0810", "domus/entrypoints/task_response.py": "d2f4c0795aee429fc318b32495601031c559fa38af1d7da63b4735f7e6eb5d13", "domus/entrypoints/ui_bundle.py": "beae3d4a7a57375ee195cca81efccdbc899d9db4286029d53eabaf30de6e14f7", @@ -226,9 +226,9 @@ "domus/infrastructure/task_materials.py": "d5d2934e36f290c7bf44b473392f014eab8749f1f3b3d26a3402079e65d2818f" }, "uiFiles": { - ".vite/manifest.json": "d3e5d88e254b44f115434200d7c06e3786503ddd3dcc54159b12cfa478a0e8d3", + ".vite/manifest.json": "734a1f5ba77da03257115ade8cb5f680b19febd79c853f95a3fa51993e0f2cdb", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "assets/index-DOfU-Xei.js": "58c0a0583f61ddb56f7990abdb08988801cb12ab8cd261424507f6a1d6489a84", - "index.html": "640506dad66475ec8db78b827e96c250a8ce3fbacbc4c2f77aa1173266c51f7d" + "assets/index-CrrA8YHS.js": "c32342fbb48c4d5e7266a3a6272e77d6a4479290440c4e801887750470b99bb2", + "index.html": "1198c1ad6b3475ec0c03041f145522f4454e7782aec020a2779204a4bea2f399" } } diff --git a/addon-edge/config.yaml b/addon-edge/config.yaml index f42e195..bd2582e 100644 --- a/addon-edge/config.yaml +++ b/addon-edge/config.yaml @@ -1,5 +1,5 @@ name: Domus Edge -version: "0.0.0-edge.23" +version: "0.0.0-edge.26" slug: domus_edge description: Development preview of the local Domus household application url: https://github.com/vypdev/domus diff --git a/backend/src/domus/application/point_allocations.py b/backend/src/domus/application/point_allocations.py index f04c5f6..0ef5d8c 100644 --- a/backend/src/domus/application/point_allocations.py +++ b/backend/src/domus/application/point_allocations.py @@ -24,17 +24,22 @@ ALLOCATION_ACTION = "points.allocation.update" +def household_allocation(unit: UnitOfWork, home: str, base: int) -> tuple[AllocationPolicy, int]: + default = unit.point_allocations.setting(home, None) + if default is None: + return AllocationPolicy(precision=2 if base % 100 else 0), 0 + assert default.policy is not None + return default.policy, default.revision + + def effective_allocation( unit: UnitOfWork, home: str, definition_id: str, base: int ) -> tuple[AllocationPolicy, int, str | None]: override = unit.point_allocations.setting(home, definition_id) if override is not None and override.policy is not None: return override.policy, override.revision, definition_id - default = unit.point_allocations.setting(home, None) - if default is None: - return AllocationPolicy(precision=2 if base % 100 else 0), 0, None - assert default.policy is not None - return default.policy, default.revision, None + policy, revision = household_allocation(unit, home, base) + return policy, revision, None def resolve_allocation( diff --git a/backend/src/domus/application/recurrence_commands.py b/backend/src/domus/application/recurrence_commands.py index 43b177c..10d77c0 100644 --- a/backend/src/domus/application/recurrence_commands.py +++ b/backend/src/domus/application/recurrence_commands.py @@ -10,7 +10,7 @@ from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork from domus.application.schedule_creation_policy import require_schedule_creation_policy from domus.application.task_authorization import TaskAccess, TaskCommandAuthorization -from domus.application.task_creation import TaskCreation +from domus.application.task_creation import TaskCreation, task_creation_result from domus.application.task_material_ports import TaskInventory from domus.application.task_requests import CreateTask from domus.domain.errors import IntegrationUnavailable, InvalidInput @@ -114,7 +114,7 @@ def apply(unit: UnitOfWork) -> Change: ) unit.recurrence.save_schedule(schedule, 0) unit.recurrence.save_progress(progress, 0) - result = JsonDocument.from_value( + result = task_creation_result( { "schedule_id": schedule.schedule_id, "definition_id": item.definition_id, @@ -123,7 +123,8 @@ def apply(unit: UnitOfWork) -> Change: "state": schedule.state.value, "processing": "pending", "first_occurrence_id": None, - } + }, + item, ) detail = JsonDocument.from_value( { diff --git a/backend/src/domus/application/relative_recurrence_commands.py b/backend/src/domus/application/relative_recurrence_commands.py index 38414ec..c1d71b8 100644 --- a/backend/src/domus/application/relative_recurrence_commands.py +++ b/backend/src/domus/application/relative_recurrence_commands.py @@ -10,7 +10,7 @@ from domus.application.recurrence_ports import RecurrenceTimes from domus.application.schedule_creation_policy import require_schedule_creation_policy from domus.application.task_authorization import TaskAccess, TaskCommandAuthorization -from domus.application.task_creation import TaskCreation +from domus.application.task_creation import TaskCreation, task_creation_result from domus.application.task_material_ports import TaskInventory from domus.application.task_requests import CreateTask from domus.domain.errors import IntegrationUnavailable, InvalidInput @@ -136,7 +136,7 @@ def apply(unit: UnitOfWork) -> Change: ) unit.recurrence.save_relative_schedule(schedule, 0) unit.recurrence.save_relative_progress(progress, 0) - result = JsonDocument.from_value( + result = task_creation_result( { "schedule_id": schedule.schedule_id, "definition_id": item.definition_id, @@ -145,7 +145,8 @@ def apply(unit: UnitOfWork) -> Change: "state": schedule.state.value, "processing": "pending", "first_occurrence_id": None, - } + }, + item, ) detail = JsonDocument.from_value( { diff --git a/backend/src/domus/application/task_commands.py b/backend/src/domus/application/task_commands.py index 7f5817d..3b91ace 100644 --- a/backend/src/domus/application/task_commands.py +++ b/backend/src/domus/application/task_commands.py @@ -14,7 +14,7 @@ TaskCommandAuthorization, ) from domus.application.task_category_commands import require_active_category -from domus.application.task_creation import TaskCreation +from domus.application.task_creation import TaskCreation, task_creation_result from domus.application.task_facts import ( current_cycle, definition_result, @@ -294,7 +294,9 @@ def apply(unit: UnitOfWork, access: TaskAccess) -> Change: ) work = item.new_occurrence(self.identifiers.new(), item.created_at) unit.tasks.save_occurrence(work, 0) - return _change("task.created", request, _result(work)) + return _change( + "task.created", request, task_creation_result(_result(work).decode(), item) + ) return self._execute( key, diff --git a/backend/src/domus/application/task_creation.py b/backend/src/domus/application/task_creation.py index bade790..abb2221 100644 --- a/backend/src/domus/application/task_creation.py +++ b/backend/src/domus/application/task_creation.py @@ -3,6 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass +from domus.application.point_allocations import household_allocation from domus.application.ports import Clock, Identifiers, UnitOfWork from domus.application.task_authorization import TaskAccess, TaskMaterialAccess from domus.application.task_category_commands import require_active_category @@ -10,10 +11,19 @@ from domus.application.task_material_ports import MissingTaskInventory, TaskInventory from domus.application.task_requests import CreateTask from domus.domain.errors import IntegrationUnavailable +from domus.domain.storage import JsonDocument, JsonObject from domus.domain.task_values import TaskRules from domus.domain.tasks import HouseholdTaskPolicy, TaskDefinition +def task_creation_result(result: JsonObject, definition: TaskDefinition) -> JsonDocument: + """Preserve the explicit base amount in the original creation receipt.""" + amount = definition.overrides.point_value_minor + return JsonDocument.from_value( + result if amount is None else {**result, "point_value_minor": amount} + ) + + @dataclass class TaskCreation: clock: Clock @@ -42,6 +52,10 @@ def materials( def definition( self, unit: UnitOfWork, access: TaskAccess, household_id: str, request: CreateTask ) -> TaskDefinition: + amount = request.overrides.point_value_minor + if amount is not None: + allocation, _ = household_allocation(unit, household_id, amount) + allocation.require_representable_base(amount) category = ( None if request.content.category_id is None diff --git a/backend/src/domus/application/task_queries.py b/backend/src/domus/application/task_queries.py index 88d2ca4..60a4f7b 100644 --- a/backend/src/domus/application/task_queries.py +++ b/backend/src/domus/application/task_queries.py @@ -80,6 +80,7 @@ class TaskWorkspaceView: can_record_for_others: bool can_manage_points: bool schedule_policy: TimePolicy | None = None + can_configure: bool = False @dataclass(frozen=True, slots=True) @@ -731,12 +732,13 @@ def workspace(self) -> TaskWorkspaceView: unit, self.household_id, Permission.TASKS_MANAGE ) ) + catalog = category_catalog(unit, TaskAccess(self.human), self.household_id) return TaskWorkspaceView( self.household_id, actor.member.member_id, can_create, can_review, - category_catalog(unit, TaskAccess(self.human), self.household_id), + catalog, _possible( lambda: TaskAccess(self.human).require( unit, self.household_id, Permission.TASKS_RECORD_FOR_OTHERS @@ -750,6 +752,7 @@ def workspace(self) -> TaskWorkspaceView: schedule_creation_policy(unit, self.household_id, self.schedule_data_identity) if can_create and self.schedule_data_identity is not None else None, + can_configure=catalog.can_manage, ) def _operation_access(self, unit: UnitOfWork) -> None: diff --git a/backend/src/domus/domain/point_allocation.py b/backend/src/domus/domain/point_allocation.py index 3fc8fb0..789a5ef 100644 --- a/backend/src/domus/domain/point_allocation.py +++ b/backend/src/domus/domain/point_allocation.py @@ -45,6 +45,12 @@ def __post_init__(self) -> None: def quantum(self) -> int: return 100 if self.precision == 0 else 1 + def require_representable_base(self, base: int) -> None: + if self.mode in (AllocationMode.CONSERVE_TOTAL, AllocationMode.FULL_PER_PARTICIPANT) and ( + base % self.quantum + ): + raise InvalidInput("points_precision_two_required") + @dataclass(frozen=True, slots=True) class PointAllocation: @@ -110,8 +116,7 @@ def allocate_points( ): units += 1 return PointAllocation(tuple((member, units * quantum) for member in members), None) - if base % quantum: - raise InvalidInput("points_precision_two_required") + policy.require_representable_base(base) if policy.mode is AllocationMode.FULL_PER_PARTICIPANT: return PointAllocation(tuple((member, base) for member in members), None) return _conserved(base, members, quantum, next_recipient) diff --git a/backend/src/domus/entrypoints/schedule_creation_http.py b/backend/src/domus/entrypoints/schedule_creation_http.py index 28ba2b4..b9e6489 100644 --- a/backend/src/domus/entrypoints/schedule_creation_http.py +++ b/backend/src/domus/entrypoints/schedule_creation_http.py @@ -16,6 +16,7 @@ from domus.domain.recurrence_rule_text import parse_fixed_pattern from domus.domain.recurrence_time import FoldPolicy, RelativeInterval, TimePolicy from domus.domain.relative_recurrence import SuccessorVisibility +from domus.domain.task_values import TaskOverrides from domus.entrypoints.household_http import ( OperationResponse, ReceiptResponse, @@ -27,7 +28,7 @@ from domus.entrypoints.http_contract import StrictModel from domus.entrypoints.recurrence_dto import NominalBody, nominal from domus.entrypoints.recurrence_http import _require_context -from domus.entrypoints.task_dto import Identifier, Revision, TaskTextBody +from domus.entrypoints.task_dto import Identifier, PointValueMinor, Revision, TaskTextBody ScheduleCreationAction = Literal["recurrence.fixed.create", "recurrence.relative.create"] @@ -44,8 +45,20 @@ def domain(self) -> TimePolicy: ) +class ScheduledTaskOverridesBody(StrictModel): + point_value_minor: PointValueMinor + + +class ScheduledTaskTextBody(TaskTextBody): + overrides: ScheduledTaskOverridesBody | None = None + + def domain(self) -> CreateTask: + amount = None if self.overrides is None else self.overrides.point_value_minor + return CreateTask(self.content(), overrides=TaskOverrides(point_value_minor=amount)) + + class ScheduledTaskBody(StrictModel): - task: TaskTextBody + task: ScheduledTaskTextBody start: NominalBody policy: ScheduleCreationPolicyBody @@ -89,7 +102,7 @@ def create_fixed( fixed(request).create_fixed( _key(request), CreateFixedTask( - CreateTask(body.task.content()), + body.task.domain(), parse_fixed_pattern(body.rrule, nominal(body.start)), body.policy.domain(), ), @@ -104,7 +117,7 @@ def create_relative( relative(request).create( _key(request), CreateRelativeTask( - CreateTask(body.task.content()), + body.task.domain(), nominal(body.start), RelativeInterval(body.interval_days), body.policy.domain(), diff --git a/backend/src/domus/entrypoints/task_dto.py b/backend/src/domus/entrypoints/task_dto.py index 5b0ad85..c2bf842 100644 --- a/backend/src/domus/entrypoints/task_dto.py +++ b/backend/src/domus/entrypoints/task_dto.py @@ -30,6 +30,7 @@ Identifier = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")] Revision = PositiveRevision TaskTime = NonnegativeRevision +PointValueMinor = Annotated[int, Field(ge=0, le=1_000_000)] WireRole = Annotated[Role, Field(strict=False)] Quantity = Annotated[ str, @@ -119,7 +120,7 @@ class OverridesBody(StrictModel): adult_performer_may_approve: bool | None = None task_scoped_material_consumption: bool | None = None material_recording: MaterialRecording | None = Field(default=None, strict=False) - point_value_minor: int | None = Field(default=None, ge=0, le=1_000_000) + point_value_minor: PointValueMinor | None = None dependency_gate: DependencyGate | None = Field(default=None, strict=False) def domain(self) -> TaskOverrides: @@ -146,7 +147,7 @@ class RulesBody(StrictModel): material_recording: MaterialRecording = Field( default=MaterialRecording.REVIEW_ON_COMPLETION, strict=False ) - point_value_minor: int = Field(default=0, ge=0, le=1_000_000) + point_value_minor: PointValueMinor = 0 dependency_gate: DependencyGate = Field(default=DependencyGate.PERFORMED, strict=False) def domain(self) -> TaskRules: diff --git a/docs/contracts/household-api.openapi.json b/docs/contracts/household-api.openapi.json index f06bd9f..4793ee1 100644 --- a/docs/contracts/household-api.openapi.json +++ b/docs/contracts/household-api.openapi.json @@ -2683,7 +2683,7 @@ "title": "Start" }, "task": { - "$ref": "#/components/schemas/TaskTextBody" + "$ref": "#/components/schemas/ScheduledTaskTextBody" } }, "required": [ @@ -5645,7 +5645,7 @@ "title": "Start" }, "task": { - "$ref": "#/components/schemas/TaskTextBody" + "$ref": "#/components/schemas/ScheduledTaskTextBody" }, "visibility": { "default": "at_completion", @@ -7411,6 +7411,70 @@ "title": "ScheduledTaskOriginalResponse", "type": "object" }, + "ScheduledTaskOverridesBody": { + "additionalProperties": false, + "properties": { + "point_value_minor": { + "maximum": 1000000.0, + "minimum": 0.0, + "title": "Point Value Minor", + "type": "integer" + } + }, + "required": [ + "point_value_minor" + ], + "title": "ScheduledTaskOverridesBody", + "type": "object" + }, + "ScheduledTaskTextBody": { + "additionalProperties": false, + "properties": { + "category_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "description": { + "default": "", + "maxLength": 4000, + "title": "Description", + "type": "string" + }, + "overrides": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScheduledTaskOverridesBody" + }, + { + "type": "null" + } + ] + }, + "priority": { + "$ref": "#/components/schemas/Priority", + "default": "normal" + }, + "title": { + "maxLength": 160, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "title" + ], + "title": "ScheduledTaskTextBody", + "type": "object" + }, "SessionResponse": { "additionalProperties": false, "properties": { @@ -9083,46 +9147,13 @@ "title": "TaskRules", "type": "object" }, - "TaskTextBody": { - "additionalProperties": false, - "properties": { - "category_id": { - "anyOf": [ - { - "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Category Id" - }, - "description": { - "default": "", - "maxLength": 4000, - "title": "Description", - "type": "string" - }, - "priority": { - "$ref": "#/components/schemas/Priority", - "default": "normal" - }, - "title": { - "maxLength": 160, - "minLength": 1, - "title": "Title", - "type": "string" - } - }, - "required": [ - "title" - ], - "title": "TaskTextBody", - "type": "object" - }, "TaskWorkspaceView": { "properties": { + "can_configure": { + "default": false, + "title": "Can Configure", + "type": "boolean" + }, "can_create": { "title": "Can Create", "type": "boolean" diff --git a/docs/contracts/native-household-api.openapi.json b/docs/contracts/native-household-api.openapi.json index a2e5141..fc356ea 100644 --- a/docs/contracts/native-household-api.openapi.json +++ b/docs/contracts/native-household-api.openapi.json @@ -2677,7 +2677,7 @@ "title": "Start" }, "task": { - "$ref": "#/components/schemas/TaskTextBody" + "$ref": "#/components/schemas/ScheduledTaskTextBody" } }, "required": [ @@ -5666,7 +5666,7 @@ "title": "Start" }, "task": { - "$ref": "#/components/schemas/TaskTextBody" + "$ref": "#/components/schemas/ScheduledTaskTextBody" }, "visibility": { "default": "at_completion", @@ -7432,6 +7432,70 @@ "title": "ScheduledTaskOriginalResponse", "type": "object" }, + "ScheduledTaskOverridesBody": { + "additionalProperties": false, + "properties": { + "point_value_minor": { + "maximum": 1000000.0, + "minimum": 0.0, + "title": "Point Value Minor", + "type": "integer" + } + }, + "required": [ + "point_value_minor" + ], + "title": "ScheduledTaskOverridesBody", + "type": "object" + }, + "ScheduledTaskTextBody": { + "additionalProperties": false, + "properties": { + "category_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "description": { + "default": "", + "maxLength": 4000, + "title": "Description", + "type": "string" + }, + "overrides": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScheduledTaskOverridesBody" + }, + { + "type": "null" + } + ] + }, + "priority": { + "$ref": "#/components/schemas/Priority", + "default": "normal" + }, + "title": { + "maxLength": 160, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "title" + ], + "title": "ScheduledTaskTextBody", + "type": "object" + }, "SessionSelectionBody": { "additionalProperties": false, "properties": { @@ -9045,46 +9109,13 @@ "title": "TaskRules", "type": "object" }, - "TaskTextBody": { - "additionalProperties": false, - "properties": { - "category_id": { - "anyOf": [ - { - "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Category Id" - }, - "description": { - "default": "", - "maxLength": 4000, - "title": "Description", - "type": "string" - }, - "priority": { - "$ref": "#/components/schemas/Priority", - "default": "normal" - }, - "title": { - "maxLength": 160, - "minLength": 1, - "title": "Title", - "type": "string" - } - }, - "required": [ - "title" - ], - "title": "TaskTextBody", - "type": "object" - }, "TaskWorkspaceView": { "properties": { + "can_configure": { + "default": false, + "title": "Can Configure", + "type": "boolean" + }, "can_create": { "title": "Can Create", "type": "boolean" diff --git a/docs/evidence/task-base-points-qualification.md b/docs/evidence/task-base-points-qualification.md new file mode 100644 index 0000000..689f089 --- /dev/null +++ b/docs/evidence/task-base-points-qualification.md @@ -0,0 +1,31 @@ +# Task base points qualification + +[Issue #111](https://github.com/vypdev/domus/issues/111) lets task configurators set +the optional base amount during creation. The form uses the existing household default +when the amount is empty. The existing command boundaries store explicit amounts as +exact minor points and bind them to the original creation receipt. + +| Published case | Executable evidence | +| --- | --- | +| `task-base-points-C01` | The [task form tests](../../frontend/tests/task-component.test.ts) exercise the optional control, blank inheritance and explicit zero. [HTTP tests](../../tests/backend/test_task_http.py) verify the current configuration grant and stored value. | +| `task-base-points-C02` | A basic creator retains title-only creation and the effective household default. A forged override and original recovery after role revocation are denied. The form offers to clear a retained amount after the configuration grant changes. | +| `task-base-points-C03` | Both HTTP compositions accept 0, 225 and 1,000,000 minor points and reject over-limit, negative, fractional minor units and coercible values. The [allocation](../../tests/backend/test_point_allocation_http.py) and [scheduled creation tests](../../tests/backend/test_schedule_creation_http.py) verify the same precision rule before saving manual, fixed and relative tasks. | +| `task-base-points-C04` | The [command tests](../../tests/backend/test_task_commands.py) cover replay, concurrent matching requests, changed amount conflicts, rollback and restart. [Restoration tests](../../tests/backend/test_task_restoration.py) verify historical receipts and surviving definitions. The compiled browser journey recovers the original configured task after its response is lost. | +| `task-base-points-C05` | The [allocation tests](../../tests/backend/test_point_allocation_http.py) verify the same exact fractional base in the preview and the posted Points award. The compiled workspace journey exercises the existing Family ledger. | +| `task-base-points-C06` | The [compiled workspace journey](../../frontend/tests/e2e/workspace-navigation.spec.ts) checks English desktop keyboard validation, Spanish narrow touch, the exact draft and the localized recovered amount. | + +The increment uses the existing storage schema, award policies and restoration format. + +Home Assistant Supervisor installed the disposable ARM64 Edge.24 candidate pinned to +runtime `f445f6e0ccffffb9a2fda43b029e1a3c6b908672`. The Ingress journey saved a task +with just a title. In English, pressing Enter with `2.251` displayed the validation +message and retained the draft and input focus. In Spanish at 390 px, the same draft +remained available. Touch submission with `2,25` saved the task and its receipt showed +exactly `2,25` base points. The disposable instance, synthetic data, and temporary +repository source were removed. The original 14 apps and eight sources remained. + +Edge.25 reproduced the allocation review case in Ingress. With conserved allocation +and whole-point precision, the server rejected `2.25` before creating the task and +the form kept the draft. A touch submission after changing the amount to `2` saved +`2,00` in Spanish. That trial showed the guidance needed to move into the focused +field. The temporary instance, data, and repository source were removed after the trial. diff --git a/docs/evidence/task-navigation-integration.json b/docs/evidence/task-navigation-integration.json index cdc657e..3243a6e 100644 --- a/docs/evidence/task-navigation-integration.json +++ b/docs/evidence/task-navigation-integration.json @@ -3,12 +3,22 @@ "historicalTaskHead": "8c0a36c0b9804bdaf33a301a4dc85ae3dc12ee21", "developParent": "5b7bdcdee3a9e98d6669bfd34ce750ae30a8c775", "command": "pnpm build:frontend", - "scope": "Compiled household workspace with upcoming maintenance, due today, overdue, assigned and unclaimed task summaries, products below their replenishment minimum, protected Pantry, shared Shopping, purchase sessions with an optional store, receipt corrections, physical returns, stock locations, transfers and original operation recovery in English and Spanish.", + "scope": "Compiled household workspace with the existing task, Pantry, and Shopping journeys and optional base points during task creation, including localized decimal entry and original operation recovery in English and Spanish.", "actualProductFiles": { - ".vite/manifest.json": "d3e5d88e254b44f115434200d7c06e3786503ddd3dcc54159b12cfa478a0e8d3", + ".vite/manifest.json": "734a1f5ba77da03257115ade8cb5f680b19febd79c853f95a3fa51993e0f2cdb", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "assets/index-DOfU-Xei.js": "58c0a0583f61ddb56f7990abdb08988801cb12ab8cd261424507f6a1d6489a84", - "index.html": "640506dad66475ec8db78b827e96c250a8ce3fbacbc4c2f77aa1173266c51f7d" + "assets/index-CrrA8YHS.js": "c32342fbb48c4d5e7266a3a6272e77d6a4479290440c4e801887750470b99bb2", + "index.html": "1198c1ad6b3475ec0c03041f145522f4454e7782aec020a2779204a4bea2f399" + }, + "preTaskBasePoints": { + "sourceCommit": "ff6b64fec87bab3d156a0c64952387c7a33ceff1", + "scope": "Compiled household workspace before optional task base points, with task and stock summaries, protected Pantry, shared Shopping, purchase sessions with an optional store, receipt corrections, physical returns, stock locations, transfers and original operation recovery in English and Spanish.", + "actualProductFiles": { + ".vite/manifest.json": "d3e5d88e254b44f115434200d7c06e3786503ddd3dcc54159b12cfa478a0e8d3", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "assets/index-DOfU-Xei.js": "58c0a0583f61ddb56f7990abdb08988801cb12ab8cd261424507f6a1d6489a84", + "index.html": "640506dad66475ec8db78b827e96c250a8ce3fbacbc4c2f77aa1173266c51f7d" + } }, "localValidation": { "commit": "9a10d8114869be754962953903e773fe8e12c723", diff --git a/docs/roadmap.md b/docs/roadmap.md index 6718b72..da1fdb4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,7 +26,10 @@ With the recurrence and calendar implementation in develop: - M2 includes task creation, claiming, completion, review and reopening, previously exercised with English/Spanish and persisted task/preferences in the owner's Edge.9 through Ingress. PR #29 wires fixed and relative maintenance, durable cycle closure and successor generation/invalidation, reviewed schedule adoption and changes, protected calendar views, explicit exclusions/additions and private week-start preferences. Its authorized HA event projection shares calendar identities. Current source checks and historical installed observations are recorded separately in the [recurrence evidence](evidence/recurrence-lifecycle-calendar.json). PRs #33, #35, #37 and #39 add household timing controls and the read-only native calendar. The [case qualification audit](evidence/recurrence-calendar-qualification.md) tracks the remaining -implementation, verification and packaging gates. The Family increments now cover protected participation, configurable point distribution and rewards. See [Family qualification](evidence/family-qualification.md). Full task editors and owner acceptance of the installed recurrence experience remain open. +implementation, verification and packaging gates. The Family increments now cover protected participation, configurable point distribution and rewards. See [Family qualification](evidence/family-qualification.md). +[Base points during task creation](evidence/task-base-points-qualification.md) lets task +configurators set the exact amount used by the existing award rules. Full task editors and owner +acceptance of the installed recurrence experience remain open. - M3 starts with the [protected product catalog and stock intake](evidence/inventory-catalog-qualification.md), followed by [movement history, adjustments and intake reversals](evidence/inventory-movement-qualification.md) and [minimum and target diff --git a/docs/user-guide.md b/docs/user-guide.md index 5f7e0b4..f794f1b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -47,6 +47,11 @@ actions your role allows. Use **Retry inventory summary** if the summary could n ## Task workspace - In **Tasks**, open **New task**, enter a title, optional instructions and priority, then choose **Create task**. The title-only flow creates actual work, not a schedule draft. +- If you can configure tasks, use **Base points (optional)** to set the amount for this task. Leave it + empty to use the household default. Enter 0 to 10,000 points with up to two decimal places. The + saved amount appears with the creation result. If the household conserves total points or gives + each participant full points at whole-point precision, use a whole amount or ask a points manager + to select two decimal places. Check **Family** after completing the task for posted points. - Select a task to inspect it. **Claim task** assigns eligible work to your own verified membership. **Mark done now** records you as the performer at the current server-anchored time. - Restricted performers require adult approval by default. An authorized adult can approve or reject the current cycle; rejection and reopening require a reason. Reopening preserves the previous cycle and its verification evidence. - If a task already has linked materials, review its quantities before completion. Decimal input is converted exactly, including a comma separator, and the linked unit's precision is enforced. Zero means unused. Separate-recording tasks do not silently submit planned consumption. diff --git a/frontend/package.json b/frontend/package.json index 028bacc..88d214c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "browser:entry": "playwright test -c playwright.entry.config.ts" }, "dependencies": { - "@vypdev/ha-ui": "0.1.0-next.1", + "@vypdev/ha-ui": "0.1.0-next.2", "lit": "3.3.3" }, "devDependencies": { diff --git a/frontend/src/adapters/task-http.ts b/frontend/src/adapters/task-http.ts index 7167506..26fbb2e 100644 --- a/frontend/src/adapters/task-http.ts +++ b/frontend/src/adapters/task-http.ts @@ -64,7 +64,8 @@ export class HttpTaskServer implements TaskServer { constructor(target: EntryEndpoint, transport: FetchTransport) {this.network = new BoundedHTTP(target, transport);} access(expected: JournalContext) { return this.network.owned(async signal => { - const access = fields(await this.network.get('tasks/access', expected, signal), ['household_id', 'member_id', 'can_create'], ['can_review_schedule','schedule_policy','category_catalog','can_record_for_others','can_manage_points']); + const access = fields(await this.network.get('tasks/access', expected, signal), ['household_id', 'member_id', 'can_create'], + ['can_review_schedule','schedule_policy','category_catalog','can_record_for_others','can_manage_points','can_configure']); const policy = access.schedule_policy == null ? null : fields(access.schedule_policy, ['zone','data_identity','repeated_time','revision'], []); const householdId = identifier(access.household_id); const catalog = access.category_catalog === undefined ? {} : {categoryCatalog: taskCategoryCatalog(access.category_catalog, householdId)}; @@ -72,6 +73,7 @@ export class HttpTaskServer implements TaskServer { canReviewSchedule: access.can_review_schedule === undefined ? false : boolean(access.can_review_schedule), ...access.can_record_for_others === undefined ? {} : {canRecordForOthers: boolean(access.can_record_for_others)}, ...access.can_manage_points === undefined ? {} : {canManagePoints: boolean(access.can_manage_points)}, + ...access.can_configure === undefined ? {} : {canConfigure: boolean(access.can_configure)}, ...policy === null ? {} : {schedulePolicy: taskSchedulePolicy({...policy, revision: responseRevision(policy.revision)})}}); }); } @@ -127,8 +129,13 @@ export class HttpTaskServer implements TaskServer { const method = input.command.action === 'task.category.rename' ? 'PUT' : 'POST'; const reply = await this.network.request(route(input.command), method, expected, body, input.key, token, signal); if (reply.status === 200) return taskReceipt(reply.value, input, false); - if ([401,403,409,422,503].includes(reply.status)) return Object.freeze({kind: 'rejected' as const, - reason: reason(reply.status), correlationId: failure(reply.value)}); + if ([401,403,409,422,503].includes(reply.status)) { + const correlationId = failure(reply.value); + const precisionRequired = reply.status === 422 && input.command.action === 'task.create' + && (reply.value as {code: unknown}).code === 'points_precision_two_required'; + return Object.freeze({kind: 'rejected' as const, + reason: precisionRequired ? 'points_precision_two_required' as const : reason(reply.status), correlationId}); + } } catch {/* An unbound or missing reply cannot establish absence of the committed effect. */} return Object.freeze({kind: 'uncertain' as const}); }).result.catch(() => posted ? Object.freeze({kind: 'uncertain' as const}) diff --git a/frontend/src/adapters/task-values.ts b/frontend/src/adapters/task-values.ts index 9c0f0a6..fa105b6 100644 --- a/frontend/src/adapters/task-values.ts +++ b/frontend/src/adapters/task-values.ts @@ -110,6 +110,11 @@ export function taskCategoryCatalog(value: unknown, householdId: string): TaskCa check(new Set(categories.map(item => item.id)).size === categories.length); return Object.freeze({categories: Object.freeze(categories), canManage: boolean(item.can_manage)}); } +function originalBasePoints(raw: unknown, expected: number | undefined) { + if (expected === undefined) return {}; + check(responseInteger(raw) === expected); + return {basePointsMinor: expected}; +} export function taskReceipt(value: unknown, request: TaskRequest | TaskOriginal, lookup: boolean): TaskReceipt { const command = request.command, categoryCommand = isCategoryCommand(command); const item = fields(value, ['state', 'archived_by', 'operation_id', 'created_at', 'result', 'message', 'action_required', @@ -117,6 +122,8 @@ export function taskReceipt(value: unknown, request: TaskRequest | TaskOriginal, text(item.message, 2048); text(item.action_required, 4096); taskTimestamp(item.created_at); const stage = choice(item.state, ['committed', 'historical']), archivedBy = nullableId(item.archived_by), operationId = identifier(item.operation_id); const schedule = command.action === 'task.create' ? command.payload.schedule : undefined; + const basePoints = command.action === 'task.create' ? command.payload.overrides?.point_value_minor : undefined; + const pointFields = basePoints === undefined ? [] : ['point_value_minor']; check(stage !== 'historical' || archivedBy !== null); if (lookup) { const action = schedule === undefined ? command.action : 'recurrence.' + schedule.draft.kind + '.create'; @@ -138,15 +145,15 @@ export function taskReceipt(value: unknown, request: TaskRequest | TaskOriginal, } if (schedule !== undefined) { const result = fields(item.result, ['schedule_id', 'definition_id', 'revision', 'pattern_revision', - 'state', 'processing', 'first_occurrence_id'], []); + 'state', 'processing', 'first_occurrence_id', ...pointFields], []); identifier(result.schedule_id); identifier(result.definition_id); const revision = responseRevision(result.revision); check(revision === '1' && responseRevision(result.pattern_revision) === '1' && result.state === 'active' && result.processing === 'pending' && result.first_occurrence_id === null); return Object.freeze({kind: 'receipt', stage, action: command.action, operationId, - occurrenceId: null, revision, state: 'active', cycle: null, archivedBy}); + occurrenceId: null, revision, state: 'active', cycle: null, archivedBy, ...originalBasePoints(result.point_value_minor, basePoints)}); } - const result = fields(item.result, ['definition_id', 'definition_revision', 'occurrence_id', 'revision', 'state', 'assignee_ids', 'state_reason', 'prerequisite_ids', 'required_subtask_ids'], + const result = fields(item.result, ['definition_id', 'definition_revision', 'occurrence_id', 'revision', 'state', 'assignee_ids', 'state_reason', 'prerequisite_ids', 'required_subtask_ids', ...pointFields], ['cycle', 'consumer_delivery', 'materials', 'compensates_cycle_id', 'dependency_review_required']); const occurrenceId = identifier(result.occurrence_id), revision = responseRevision(result.revision), state = choice(result.state, TASK_STATES); identifier(result.definition_id); responseRevision(result.definition_revision); ids(result.assignee_ids); ids(result.prerequisite_ids, 64); ids(result.required_subtask_ids, 64); @@ -162,7 +169,8 @@ export function taskReceipt(value: unknown, request: TaskRequest | TaskOriginal, if (command.action === 'task.verify') check(savedCycle.verification === command.payload.decision); else check(result.compensates_cycle_id === command.payload.cycle_id && result.dependency_review_required === true); } - return Object.freeze({kind: 'receipt', stage, action: command.action, operationId, occurrenceId, revision, state, cycle: savedCycle, archivedBy}); + return Object.freeze({kind: 'receipt', stage, action: command.action, operationId, occurrenceId, revision, state, cycle: savedCycle, archivedBy, + ...originalBasePoints(result.point_value_minor, basePoints)}); } /** Only validated decimal lexemes bypass JSON's string quoting; arbitrary text always uses JSON.stringify. */ export function taskCommandBody(value: TaskCommand): string { @@ -172,10 +180,10 @@ export function taskCommandBody(value: TaskCommand): string { return '{"expected_revision":' + expected_revision + ',"performed_at":' + performed_at + ',' + JSON.stringify(rest).slice(1); } if (command.action === 'task.create' && command.payload.schedule !== undefined) { - const {title, description, priority, category_id, schedule} = command.payload; + const {title, description, priority, category_id, overrides, schedule} = command.payload; const {kind: _, ...draft} = schedule.draft; const {revision, ...policy} = schedule.policy; - const fields = JSON.stringify({task: {title, description, priority, category_id}, ...draft, policy}); + const fields = JSON.stringify({task: {title, description, priority, category_id, overrides}, ...draft, policy}); return fields.slice(0, -2) + ',"revision":' + revision + '}}'; } const {expected_revision, ...rest} = command.payload; diff --git a/frontend/src/application/task-controller.ts b/frontend/src/application/task-controller.ts index 9edf5f5..cb474e9 100644 --- a/frontend/src/application/task-controller.ts +++ b/frontend/src/application/task-controller.ts @@ -119,7 +119,7 @@ export class TaskController { if (command.action === 'task.create') { if (command.payload.category_id !== undefined && !access.categoryCatalog?.categories.some(item => item.id === command.payload.category_id)) {this.update({notice: 'category_unavailable'}); return false;} - return access.canCreate; + return access.canCreate && (command.payload.overrides === undefined || access.canConfigure === true); } if (isCategoryCommand(command)) { if (!access.categoryCatalog?.canManage) return false; @@ -178,10 +178,11 @@ export class TaskController { } await this.facts(generation, occurrence); } - create(title: unknown, description: unknown, priority: TaskPriority, draft?: TaskScheduleDraft, categoryId?: string): Promise { + create(title: unknown, description: unknown, priority: TaskPriority, draft?: TaskScheduleDraft, categoryId?: string, basePointsMinor?: number): Promise { const schedule = draft === undefined ? {} : {schedule: {draft, policy: this.state.access?.schedulePolicy}}; return this.execute({action: 'task.create', payload: {expected_revision: 0, title, description, priority, ...schedule, - ...categoryId === undefined ? {} : {category_id: categoryId}}}); + ...categoryId === undefined ? {} : {category_id: categoryId}, + ...basePointsMinor === undefined ? {} : {overrides: {point_value_minor: basePointsMinor}}}}); } createCategory(label: string): Promise { return this.execute({action: 'task.category.create', payload: {expected_revision: 0, label}}); diff --git a/frontend/src/application/task-ports.ts b/frontend/src/application/task-ports.ts index 4e3468a..5ca7635 100644 --- a/frontend/src/application/task-ports.ts +++ b/frontend/src/application/task-ports.ts @@ -4,7 +4,8 @@ import type {CommandResult, EntryRead} from './ports'; export interface TaskRequest {readonly key: string; readonly command: TaskCommand;} export interface TaskOriginal extends TaskRequest {readonly operationId: string | null;} -export type TaskCommandResult = TaskReceipt | Exclude; +export type TaskCommandResult = TaskReceipt | Exclude + | Readonly<{kind: 'rejected'; reason: 'points_precision_two_required'; correlationId: string}>; export interface TaskServer { access(expected: JournalContext): EntryRead; list(expected: JournalContext, cursor: string | null): EntryRead; diff --git a/frontend/src/composition/entry-page.ts b/frontend/src/composition/entry-page.ts index b4a5302..d625909 100644 --- a/frontend/src/composition/entry-page.ts +++ b/frontend/src/composition/entry-page.ts @@ -46,7 +46,7 @@ import {language, message} from '../presentation/messages'; import type {DomusApp, EntryIntent} from '../presentation/domus-app'; import {revisionValue} from '../domain/administration'; import type {TaskIntent} from '../presentation/domus-tasks'; -import {taskQuantity, taskScheduleDraft, TASK_PRIORITIES} from '../domain/tasks'; +import {taskBasePointAmount, taskQuantity, taskScheduleDraft, TASK_PRIORITIES} from '../domain/tasks'; import type {TaskPriority} from '../domain/tasks'; import type {AdministrationIntent} from '../presentation/domus-administration'; import {administrationCommand} from '../domain/administration-commands'; @@ -115,15 +115,16 @@ function reviewedAllocation(value: unknown): ReviewedAllocation { export function decodeTaskIntent(value: unknown): TaskIntent | null { try { - const item = fields(value, ['type'], ['title', 'description', 'priority', 'schedule', 'occurrenceId', 'materialUsages', 'decision', 'reason', 'key', 'categoryId', 'label', 'revision', 'reviewed']); + const item = fields(value, ['type'], ['title', 'description', 'priority', 'schedule', 'occurrenceId', 'materialUsages', 'decision', 'reason', 'key', 'categoryId', 'label', 'revision', 'reviewed', 'basePointsMinor']); const type = item.type; if (type === 'create') { - fields(value, ['type', 'title', 'description', 'priority'], ['schedule', 'categoryId']); + fields(value, ['type', 'title', 'description', 'priority'], ['schedule', 'categoryId', 'basePointsMinor']); check(typeof item.title === 'string' && typeof item.description === 'string' && typeof item.priority === 'string' && TASK_PRIORITIES.includes(item.priority as TaskPriority)); return {type, title: item.title, description: item.description, priority: item.priority as TaskPriority, ...Object.hasOwn(item, 'schedule') ? {schedule: taskScheduleDraft(item.schedule)} : {}, - ...Object.hasOwn(item, 'categoryId') ? {categoryId: identifier(item.categoryId)} : {}}; + ...Object.hasOwn(item, 'categoryId') ? {categoryId: identifier(item.categoryId)} : {}, + ...Object.hasOwn(item, 'basePointsMinor') ? {basePointsMinor: taskBasePointAmount(item.basePointsMinor)} : {}}; } if (type === 'createCategory') { fields(value, ['type', 'label'], []); check(typeof item.label === 'string'); @@ -547,7 +548,8 @@ export function mountEntry(page: EntryPage): PageMount { case 'refresh': await active.refresh(); break; case 'more': await active.taskWork(controller => controller.more()); break; case 'select': await active.taskWork(controller => controller.select(intent.occurrenceId)); break; - case 'create': await active.taskWork(controller => controller.create(intent.title, intent.description, intent.priority, intent.schedule, intent.categoryId)); break; + case 'create': await active.taskWork(controller => controller.create(intent.title, intent.description, intent.priority, + intent.schedule, intent.categoryId, intent.basePointsMinor)); break; case 'createCategory': await active.taskWork(controller => controller.createCategory(intent.label)); break; case 'renameCategory': await active.taskWork(controller => controller.renameCategory(intent.categoryId, intent.revision, intent.label)); break; case 'archiveCategory': await active.taskWork(controller => controller.archiveCategory(intent.categoryId, intent.revision)); break; diff --git a/frontend/src/domain/tasks.ts b/frontend/src/domain/tasks.ts index 7646adb..6a6ff33 100644 --- a/frontend/src/domain/tasks.ts +++ b/frontend/src/domain/tasks.ts @@ -31,6 +31,10 @@ export function taskTime(value: unknown): string { export function taskQuantity(value: unknown): string { requireValue(typeof value === 'string' && /^(?:0|[1-9][0-9]{0,11})(?:\.[0-9]{0,5}[1-9])?$/u.test(value)); return value; } +export function taskBasePointAmount(value: unknown): number { + requireValue(typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 1_000_000); + return value; +} export interface TaskMaterialUsage {readonly line_id: string; readonly quantity: string;} export const TASK_STATES = ['draft', 'available', 'assigned', 'in_progress', 'blocked', 'completed', 'cancelled', 'skipped'] as const; export type TaskState = typeof TASK_STATES[number]; @@ -47,6 +51,7 @@ export interface TaskSchedulePolicy { readonly revision: Revision; } type TaskSchedule = Readonly<{draft: TaskScheduleDraft; policy: TaskSchedulePolicy}>; +type TaskCreationOverrides = Readonly<{point_value_minor: number}>; export const CATEGORY_ACTIONS = ['task.category.create', 'task.category.rename', 'task.category.archive'] as const; export type CategoryCommand = Readonly<{action: 'task.category.create'; payload: Readonly<{expected_revision: 0; label: string}>}> @@ -62,7 +67,8 @@ export function categoryLabel(value: unknown): string { return label; } export type TaskCommand = CategoryCommand | - Readonly<{action: 'task.create'; payload: Readonly<{expected_revision: 0; title: string; description: string; priority: TaskPriority; category_id?: string; schedule?: TaskSchedule}>}> + Readonly<{action: 'task.create'; payload: Readonly<{expected_revision: 0; title: string; description: string; priority: TaskPriority; + category_id?: string; schedule?: TaskSchedule; overrides?: TaskCreationOverrides}>}> | Readonly<{action: 'task.claim'; occurrenceId: string; payload: Readonly<{expected_revision: Revision}>}> | Readonly<{action: 'task.complete'; occurrenceId: string; payload: Readonly<{expected_revision: Revision; performer_ids: readonly string[]; performed_at: string; material_usages: readonly TaskMaterialUsage[] | null; @@ -131,14 +137,23 @@ export function taskCommand(value: unknown): TaskCommand | undefined { } const item = fields(value, action === 'task.create' ? ['action', 'payload'] : ['action', 'occurrenceId', 'payload']); if (action === 'task.create') { - const hasSchedule = typeof item.payload === 'object' && item.payload !== null && Object.hasOwn(item.payload, 'schedule'); - const hasCategory = typeof item.payload === 'object' && item.payload !== null && Object.hasOwn(item.payload, 'category_id'); - const body = fields(item.payload, ['expected_revision', 'title', 'description', 'priority', ...hasSchedule ? ['schedule'] : [], ...hasCategory ? ['category_id'] : []]); + requireValue(typeof item.payload === 'object' && item.payload !== null); + const hasSchedule = Object.hasOwn(item.payload, 'schedule'); + const hasCategory = Object.hasOwn(item.payload, 'category_id'); + const hasOverrides = Object.hasOwn(item.payload, 'overrides'); + const body = fields(item.payload, ['expected_revision', 'title', 'description', 'priority', + ...hasSchedule ? ['schedule'] : [], ...hasCategory ? ['category_id'] : [], ...hasOverrides ? ['overrides'] : []]); requireValue(body.expected_revision === 0); const schedule = hasSchedule ? fields(body.schedule, ['draft', 'policy']) : null; + let overrides: TaskCreationOverrides | undefined; + if (hasOverrides) { + const {point_value_minor} = fields(body.overrides, ['point_value_minor']); + overrides = Object.freeze({point_value_minor: taskBasePointAmount(point_value_minor)}); + } return Object.freeze({action, payload: Object.freeze({expected_revision: 0, title: taskText(body.title, TASK_TEXT_LIMITS.title, true), description: taskText(body.description, TASK_TEXT_LIMITS.description, false, true), priority: choice(body.priority, TASK_PRIORITIES), + ...overrides === undefined ? {} : {overrides}, ...hasCategory ? {category_id: taskIdentifier(body.category_id)} : {}, ...schedule === null ? {} : { schedule: Object.freeze({draft: taskScheduleDraft(schedule.draft), policy: taskSchedulePolicy(schedule.policy)}) }})}); @@ -198,7 +213,7 @@ export interface TaskCategory {readonly id: string; readonly label: string; read export interface TaskCategoryCatalog {readonly categories: readonly TaskCategory[]; readonly canManage: boolean;} export interface TaskAccess { readonly householdId: string; readonly memberId: string; readonly canCreate: boolean; readonly canReviewSchedule: boolean; - readonly canRecordForOthers?: boolean; readonly canManagePoints?: boolean; + readonly canRecordForOthers?: boolean; readonly canManagePoints?: boolean; readonly canConfigure?: boolean; readonly schedulePolicy?: TaskSchedulePolicy; readonly categoryCatalog?: TaskCategoryCatalog; } @@ -207,4 +222,5 @@ export interface TaskReceipt { readonly action: TaskCommand['action']; readonly occurrenceId: string | null; readonly revision: Revision; readonly state: TaskState | 'active' | 'archived'; readonly cycle: TaskCycleSummary | null; readonly archivedBy: string | null; + readonly basePointsMinor?: number; } diff --git a/frontend/src/presentation/domus-tasks.ts b/frontend/src/presentation/domus-tasks.ts index 6ba54ab..a45a58f 100644 --- a/frontend/src/presentation/domus-tasks.ts +++ b/frontend/src/presentation/domus-tasks.ts @@ -1,6 +1,9 @@ import {LitElement, css, html, nothing} from 'lit'; import {live} from 'lit/directives/live.js'; import type {PropertyValues} from 'lit'; +import {registerField} from '@vypdev/ha-ui/field'; +import type {FieldValueEvent} from '@vypdev/ha-ui/field'; +import {textFieldState} from '@vypdev/ha-ui/field-contracts'; import type {TaskView} from '../application/task-controller'; import type {TaskRecord} from '../application/task-ports'; import {categoryLabel, hasTaskText, taskQuantity, taskScheduleDraft, TASK_PRIORITIES, TASK_TEXT_LIMITS} from '../domain/tasks'; @@ -12,14 +15,15 @@ import {taskDeadline, taskInstant, taskMessage} from './task-messages'; import type {TaskMessageKey} from './task-messages'; import {styles} from './styles'; import type {AllocationView} from '../application/allocation-controller'; -import {requiresManualAllocation} from '../domain/point-allocation'; +import {displayPoints, minorPoints, requiresManualAllocation} from '../domain/point-allocation'; import type {ReviewedAllocation} from '../domain/point-allocation'; import type {ParticipantName} from './allocation-details'; import './domus-allocation-preview'; import './domus-allocation-settings'; export type TaskIntent = Readonly<{type: 'refresh' | 'more' | 'claim'}> | Readonly<{type: 'select'; occurrenceId: string}> - | Readonly<{type: 'create'; title: string; description: string; priority: TaskPriority; categoryId?: string; schedule?: TaskScheduleDraft}> + | Readonly<{type: 'create'; title: string; description: string; priority: TaskPriority; + categoryId?: string; schedule?: TaskScheduleDraft; basePointsMinor?: number}> | Readonly<{type: 'createCategory'; label: string}> | Readonly<{type: 'renameCategory'; categoryId: string; revision: Revision; label: string}> | Readonly<{type: 'archiveCategory'; categoryId: string; revision: Revision}> @@ -44,6 +48,7 @@ function quantityInput(value: string, precision: number): string | null { export class DomusTasks extends LitElement { static properties = {allocation: {attribute: false}, participantNames: {attribute: false}, view: {attribute: false}, retired: {attribute: false}, language: {attribute: false}, titleDraft: {state: true}, descriptionDraft: {state: true}, categoryDraft: {state: true}, categoryName: {state: true}, categoryEdit: {state: true}, manageOpen: {state: true}, + basePointsDraft: {state: true}, basePointsError: {state: true}, titleRequired: {state: true}, priorityDraft: {state: true}, reasonDraft: {state: true}, quantities: {state: true}, quantityError: {state: true}, createOpen: {state: true}, scheduleKind: {state: true}, scheduleError: {state: true}, scheduleInput: {state: true}}; static styles = [styles, css`:host {min-height:0;background:transparent;overflow-wrap:anywhere}.task-header {display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap} @@ -53,6 +58,9 @@ export class DomusTasks extends LitElement { padding:10px 12px;background:var(--surface);color:var(--text)} .task-list {list-style:none;padding:0;margin:0}.task-list li {padding:14px 0;border-bottom:1px solid var(--line)} .task-link {border:0;border-radius:8px;padding:4px 0;text-align:start;font-weight:700}.task-meta {display:flex;flex-wrap:wrap;gap:8px;color:var(--muted);font-size:.875rem} .description {white-space:pre-wrap}.task-fields {margin-top:24px}.material {display:grid;gap:6px;margin-bottom:12px}.request {margin-top:16px} + #task-base-points {--vyp-ha-surface:var(--canvas);--vyp-ha-border:var(--line); + --vyp-ha-text:var(--text);--vyp-ha-text-secondary:var(--muted); + --vyp-ha-primary:var(--accent);--vyp-ha-danger:var(--warning)} @media(min-width:800px) {.task-layout {grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.task-layout.single {grid-template-columns:1fr}}`]; declare allocation: AllocationView | null; declare participantNames: readonly ParticipantName[]; @@ -69,6 +77,8 @@ export class DomusTasks extends LitElement { private declare titleRequired: boolean; private declare createOpen: boolean; private declare descriptionDraft: string; + private declare basePointsDraft: string; + private declare basePointsError: 'basePointsInvalid' | 'notice_points_precision_two_required' | null; private declare priorityDraft: TaskPriority; private declare scheduleKind: 'manual' | TaskScheduleDraft['kind']; private declare scheduleError: boolean; @@ -83,6 +93,7 @@ export class DomusTasks extends LitElement { constructor() {super(); this.allocation = null; this.participantNames = []; this.view = null; this.retired = false; this.language = 'en'; this.titleDraft = ''; this.titleRequired = false; this.descriptionDraft = ''; this.priorityDraft = 'normal'; this.scheduleKind = 'manual'; this.scheduleError = false; this.scheduleInput = EMPTY_SCHEDULE_FIELDS; this.createOpen = false; this.reasonDraft = ''; this.quantities = {}; this.quantityError = null; + this.basePointsDraft = ''; this.basePointsError = null; this.categoryDraft = ''; this.categoryName = ''; this.categoryEdit = null; this.manageOpen = false;} private text(key: TaskMessageKey): string {return taskMessage(key, this.language);} private get blocked(): boolean {return this.view?.busy !== false || this.view.readOnly || this.view.records === null @@ -90,9 +101,14 @@ export class DomusTasks extends LitElement { private get staleCategory(): boolean { return this.categoryDraft !== '' && !this.view?.access?.categoryCatalog?.categories.some(item => item.id === this.categoryDraft); } + private creationDraftKey(): string { + return JSON.stringify([this.titleDraft, this.descriptionDraft, this.priorityDraft, this.categoryDraft, + this.basePointsDraft, this.scheduleKind, this.scheduleInput]); + } private reset(): void { this.categoryDraft = ''; this.categoryName = ''; this.categoryEdit = null; this.submittedCategory = null; this.manageOpen = false; this.createOpen = false; this.titleDraft = ''; this.titleRequired = false; this.descriptionDraft = ''; this.priorityDraft = 'normal'; this.reasonDraft = ''; + this.basePointsDraft = ''; this.basePointsError = null; this.scheduleKind = 'manual'; this.scheduleError = false; this.scheduleInput = EMPTY_SCHEDULE_FIELDS; this.quantities = {}; this.quantityError = null; this.detailKey = null; this.focusDetail = false; this.submittedDraft = null; this.draftScope = null;} protected willUpdate(changed: PropertyValues): void { if (changed.has('retired') && this.retired) this.reset(); @@ -107,13 +123,16 @@ export class DomusTasks extends LitElement { if (key !== null && key !== this.detailKey) {this.focusDetail = this.view?.phase === 'ready'; this.detailKey = key; this.reasonDraft = ''; this.quantityError = null; this.quantities = Object.fromEntries(detail!.materials.map(item => [item.lineId, item.plannedQuantity]));} const outcome = this.view?.outcome; + if (outcome?.kind === 'rejected' && outcome.reason === 'points_precision_two_required' + && outcome !== changed.get('view')?.outcome) this.basePointsError = 'notice_points_precision_two_required'; if (outcome?.kind === 'receipt' && outcome.stage === 'committed' && outcome.action === this.submittedCategory?.action && this.categoryName === this.submittedCategory.label) { this.categoryName = ''; this.categoryEdit = null; this.submittedCategory = null; } if (outcome?.kind === 'receipt' && outcome.stage === 'committed' && outcome.action === 'task.create' - && this.submittedDraft === JSON.stringify([this.titleDraft, this.descriptionDraft, this.priorityDraft, this.categoryDraft])) { + && this.submittedDraft === this.creationDraftKey()) { this.categoryDraft = ''; this.createOpen = false; this.titleDraft = ''; this.titleRequired = false; this.descriptionDraft = ''; this.priorityDraft = 'normal'; this.submittedDraft = null; + this.basePointsDraft = ''; this.basePointsError = null; this.scheduleKind = 'manual'; this.scheduleError = false; this.scheduleInput = EMPTY_SCHEDULE_FIELDS; } } @@ -124,6 +143,9 @@ export class DomusTasks extends LitElement { this.shadowRoot?.querySelector(this.lastForm === 'create' ? '#task-title' : this.lastForm === 'category' ? '#task-category-label' : '#task-reason')?.focus(); if (changed.has('view') && this.view?.notice === 'category_unavailable') this.renderRoot.querySelector('#task-category')?.focus(); + const outcome = this.view?.outcome; + if (changed.has('view') && outcome?.kind === 'rejected' && outcome.reason === 'points_precision_two_required') + this.renderRoot.querySelector('#task-base-points')?.focus(); if (changed.has('quantityError') && this.quantityError) this.shadowRoot?.querySelector('[aria-invalid="true"]')?.focus(); } disconnectedCallback(): void {super.disconnectedCallback(); this.reset();} @@ -177,20 +199,30 @@ export class DomusTasks extends LitElement { const titleTooLong = Array.from(this.titleDraft).length > TASK_TEXT_LIMITS.title; const titleMissing = this.titleRequired && !hasTaskText(this.titleDraft); const descriptionInvalid = Array.from(this.descriptionDraft).length > TASK_TEXT_LIMITS.description; + const canSetPoints = this.view!.access!.canConfigure === true; + const stalePoints = !canSetPoints && this.basePointsDraft.trim() !== ''; return html`
{const target = event.target as HTMLDetailsElement; if (target.isConnected) this.createOpen = target.open;}}>

${this.text('newTask')}

{event.preventDefault(); this.lastForm = 'create'; + if (stalePoints) return; this.titleRequired = !hasTaskText(this.titleDraft); if (this.titleRequired || titleTooLong || descriptionInvalid || this.staleCategory) { this.shadowRoot?.querySelector(this.titleRequired || titleTooLong ? '#task-title' : descriptionInvalid ? '#task-description' : '#task-category')?.focus(); return; } + const basePointsMinor = this.basePointsDraft.trim() === '' ? undefined : minorPoints(this.basePointsDraft); + if (basePointsMinor === null) { + this.basePointsError = 'basePointsInvalid'; + this.renderRoot.querySelector('#task-base-points')?.focus(); + return; + } let schedule: TaskScheduleDraft | undefined; try {schedule = this.scheduleDraft(event.currentTarget as HTMLFormElement);} catch {this.scheduleError = true; return;} this.scheduleError = false; - this.submittedDraft = JSON.stringify([this.titleDraft, this.descriptionDraft, this.priorityDraft, this.categoryDraft]); + this.submittedDraft = this.creationDraftKey(); this.intent({type: 'create', title: this.titleDraft, description: this.descriptionDraft, priority: this.priorityDraft, - ...this.categoryDraft ? {categoryId: this.categoryDraft} : {}, ...schedule === undefined ? {} : {schedule}});}}> + ...this.categoryDraft ? {categoryId: this.categoryDraft} : {}, ...schedule === undefined ? {} : {schedule}, + ...basePointsMinor === undefined ? {} : {basePointsMinor}});}}>
{this.titleDraft = (event.target as HTMLInputElement).value;}}>${titleMissing ? this.text('titleRequired') : titleTooLong ? this.text('textTooLong') + ': ' + TASK_TEXT_LIMITS.title : this.text('titleHelp')}
@@ -202,9 +234,22 @@ export class DomusTasks extends LitElement { ${TASK_PRIORITIES.map(priority => html``)} ${this.scheduleFields()} ${this.view?.access?.categoryCatalog ? this.categorySelector() : nothing} - + ${canSetPoints ? this.basePointsField() : nothing} + ${stalePoints ? html`

${this.text('basePointsUnavailable')}

+ ` : nothing} +
`; } + private basePointsField() { + return html` { + this.basePointsDraft = event.detail.value; + this.basePointsError = null; + }}>`; + } private categorySelector() { const categories = this.view!.access!.categoryCatalog!.categories; return html`
@@ -337,8 +382,12 @@ export class DomusTasks extends LitElement { const result = this.view!.outcome; if (result === null) return nothing; const kind = result.kind === 'receipt' ? result.stage === 'committed' ? 'saved' : 'historical' : result.kind; const body = kind === 'saved' && result.kind === 'receipt' && result.state === 'active' ? 'scheduleSaved' : `${kind}Body` as const; - return html`

${this.text(kind)}

${this.text(body)}

- ${result.kind === 'rejected' ? html`

${this.text(`notice_${result.reason}`)}

` : nothing} + const precisionRequired = result.kind === 'rejected' && result.reason === 'points_precision_two_required'; + return html`

${this.text(kind)}

+ ${precisionRequired ? nothing : html`

${this.text(body)}

`} + ${result.kind === 'rejected' && !precisionRequired ? html`

${this.text(`notice_${result.reason}`)}

` : nothing} + ${result.kind === 'receipt' && result.basePointsMinor !== undefined ? html`
${this.text('basePoints')}
+
${displayPoints(result.basePointsMinor, this.language)}
` : nothing} ${result.kind === 'receipt' ? html`
${this.text('technical')}

${this.text('operation')}: ${result.operationId}

` : nothing}
`; } protected render() { @@ -367,5 +416,6 @@ export class DomusTasks extends LitElement {
`; } } +registerField(); customElements.define('domus-tasks', DomusTasks); declare global {interface HTMLElementTagNameMap {'domus-tasks': DomusTasks;}} diff --git a/frontend/src/presentation/task-messages.ts b/frontend/src/presentation/task-messages.ts index 12b99d1..7021278 100644 --- a/frontend/src/presentation/task-messages.ts +++ b/frontend/src/presentation/task-messages.ts @@ -4,6 +4,11 @@ const english = Object.freeze({ title: 'Tasks', intro: 'Plan household work, share it and keep track of what is done.', refresh: 'Refresh tasks', loading: 'Loading your tasks', closed: 'Reopen Domus to view tasks.', newTask: 'New task', taskTitle: 'Title', description: 'Description', priority: 'Priority', create: 'Create task', + basePoints: 'Base points', basePointsOptional: 'Base points (optional)', + basePointsHelp: 'Leave empty to use the household default.', + basePointsInvalid: 'Enter 0 to 10,000 points with up to two decimal places.', + basePointsUnavailable: 'Your current permissions do not allow setting base points. Clear the amount to continue.', + clearBasePoints: 'Clear base points', schedule: 'Scheduling', schedule_manual: 'Manual task', schedule_fixed: 'Fixed calendar', schedule_relative: 'After completion', scheduleDetails: 'Recurrence details', scheduleZone: 'Household time zone', scheduleDate: 'First date', scheduleTime: 'Local time (optional)', scheduleRule: 'Recurrence rule', scheduleInterval: 'Local days between tasks', @@ -49,6 +54,7 @@ const english = Object.freeze({ notice_unavailable: 'Tasks could not be loaded. Your saved work is retained. Refresh when the service is reachable.', notice_invalid_response: 'The response could not be verified. Refresh to check the current task.', notice_invalid_input: 'Review the title, reason and quantities. Required fields must not be empty.', + notice_points_precision_two_required: 'Use whole base points or change the household Points allocation to two decimal places.', notice_conflict: 'This task changed. Review its current details before submitting again.', notice_read_only: 'Changes are blocked during read-only access.', notice_journal_unavailable: 'Browser recovery storage could not be confirmed. Task changes are blocked. Refresh in a supported browser.', notice_context_changed: 'Your account or household changed. Review the current tasks before making a change.', @@ -63,6 +69,11 @@ const spanish: Readonly> = Object.freeze({ title: 'Tareas', intro: 'Organiza el trabajo de casa, compártelo y consulta qué está hecho.', refresh: 'Actualizar tareas', loading: 'Cargando tus tareas', closed: 'Vuelve a abrir Domus para ver las tareas.', newTask: 'Nueva tarea', taskTitle: 'Título', description: 'Descripción', priority: 'Prioridad', create: 'Crear tarea', + basePoints: 'Puntos base', basePointsOptional: 'Puntos base (opcional)', + basePointsHelp: 'Déjalo vacío para usar el valor predeterminado del hogar.', + basePointsInvalid: 'Introduce entre 0 y 10.000 puntos, con hasta dos decimales.', + basePointsUnavailable: 'Tus permisos actuales no permiten fijar puntos base. Borra el importe para continuar.', + clearBasePoints: 'Borrar puntos base', schedule: 'Programación', schedule_manual: 'Tarea manual', schedule_fixed: 'Calendario fijo', schedule_relative: 'Tras completar', scheduleDetails: 'Detalles de recurrencia', scheduleZone: 'Zona horaria del hogar', scheduleDate: 'Primera fecha', scheduleTime: 'Hora local (opcional)', scheduleRule: 'Regla de recurrencia', scheduleInterval: 'Días locales entre tareas', @@ -108,6 +119,7 @@ const spanish: Readonly> = Object.freeze({ notice_unavailable: 'No se han podido cargar las tareas. Tu trabajo guardado se conserva. Actualiza cuando el servicio esté disponible.', notice_invalid_response: 'No se ha podido verificar la respuesta. Actualiza para comprobar la tarea actual.', notice_invalid_input: 'Revisa el título, el motivo y las cantidades. Los campos obligatorios no pueden estar vacíos.', + notice_points_precision_two_required: 'Usa puntos base enteros o cambia el reparto de puntos del hogar a dos decimales.', notice_conflict: 'Esta tarea ha cambiado. Revisa su estado actual antes de volver a enviar el cambio.', notice_read_only: 'Los cambios están bloqueados durante el acceso de solo lectura.', notice_journal_unavailable: 'No se ha podido confirmar el almacenamiento de recuperación del navegador. Los cambios están bloqueados. Actualiza en un navegador compatible.', notice_context_changed: 'Tu cuenta o tu hogar han cambiado. Revisa las tareas actuales antes de modificarlas.', diff --git a/frontend/tests/e2e/workspace-navigation.spec.ts b/frontend/tests/e2e/workspace-navigation.spec.ts index 72245af..2587065 100644 --- a/frontend/tests/e2e/workspace-navigation.spec.ts +++ b/frontend/tests/e2e/workspace-navigation.spec.ts @@ -244,9 +244,59 @@ for (const mode of ['native', 'standalone'] as const) { expect(response.ok(), await response.text()).toBe(true); return (await response.json()).result; }; - const seeded = await post('definitions', 'family-work', { - title: 'Water plants', overrides: {point_value_minor: 225}, + const previousTaskViewport = page.viewportSize()!; + await page.setViewportSize({width: 1120, height: 900}); + await page.emulateMedia({colorScheme: 'light'}); + await page.getByLabel('Idioma', {exact: true}).selectOption('en'); + await tabs.getByRole('link', {name: 'Tasks', exact: true}).click(); + const newTask = tasksRegion.locator('details.new-task:has(#new-task-title)'); + await expect(newTask).toBeVisible(); + if (!await newTask.evaluate((element: HTMLDetailsElement) => element.open)) + await newTask.locator('summary').press('Enter'); + await newTask.getByRole('textbox', {name: 'Title', exact: true}).fill('Water plants'); + const englishPoints = newTask.getByRole('textbox', {name: 'Base points (optional)', exact: true}); + await expect(englishPoints).toHaveAttribute('inputmode', 'decimal'); + await englishPoints.fill('2.251'); + await englishPoints.press('Enter'); + const basePointsError = newTask.getByText('Enter 0 to 10,000 points with up to two decimal places.'); + await expect(basePointsError).toBeVisible(); + await expect(basePointsError).toHaveCSS('color', 'rgb(120, 67, 7)'); + await expect(englishPoints).toBeFocused(); + await newTask.screenshot({path: info.outputPath('task-base-points-en-invalid.png')}); + await page.setViewportSize({width: 390, height: 820}); + await page.emulateMedia({colorScheme: 'dark'}); + await page.getByLabel('Language', {exact: true}).selectOption('es'); + const spanishPoints = newTask.getByRole('textbox', {name: 'Puntos base (opcional)', exact: true}); + await expect(spanishPoints).toHaveAttribute('inputmode', 'decimal'); + await expect(spanishPoints).toHaveValue('2.251'); + await spanishPoints.tap(); + await spanishPoints.fill('2,25'); + await expect(spanishPoints).toHaveCSS('color', 'rgb(237, 244, 250)'); + await expect(newTask.getByText('Déjalo vacío para usar el valor predeterminado del hogar.')) + .toHaveCSS('color', 'rgb(187, 203, 216)'); + await newTask.locator('#task-base-points').screenshot({path: info.outputPath('task-base-points-es-narrow.png')}); + const creationUrl = base + 'api/v1/tasks/definitions'; + const creation: {saved?: {occurrence_id: string; revision: number; point_value_minor: number}} = {}; + await page.route(creationUrl, async route => { + if (route.request().method() !== 'POST') {await route.continue(); return;} + expect(route.request().postDataJSON()).toMatchObject({title: 'Water plants', overrides: {point_value_minor: 225}}); + const committed = await route.fetch({maxRetries: 0, maxRedirects: 0}); + expect(committed.ok(), await committed.text()).toBe(true); + creation.saved = (await committed.json()).result; + await route.abort('connectionclosed'); }); + await newTask.getByRole('button', {name: 'Crear tarea', exact: true}).tap(); + await expect(tasksRegion.getByText('Resultado sin confirmar', {exact: true})).toBeVisible(); + await expect(spanishPoints).toHaveValue('2,25'); + await page.unroute(creationUrl); + const recoverCreation = tasksRegion.getByRole('region', {name: 'Solicitudes guardadas', exact: true}) + .getByRole('button', {name: 'Comprobar solicitud guardada', exact: true}); + await recoverCreation.click(); + await expect(tasksRegion.getByText('Cambio de tarea guardado', {exact: true})).toBeVisible(); + await expect(tasksRegion.getByRole('status').getByText('2,25', {exact: true})).toBeVisible(); + const seeded = creation.saved; + if (seeded?.point_value_minor !== 225) throw Error('The original configured task was not saved'); + await page.setViewportSize(previousTaskViewport); const done = await post('occurrences/' + seeded.occurrence_id + '/complete', 'family-completed', { expected_revision: seeded.revision, performer_ids: [member], performed_at: Date.now() - 500, }); diff --git a/frontend/tests/page.test.ts b/frontend/tests/page.test.ts index 5e65a03..f5542db 100644 --- a/frontend/tests/page.test.ts +++ b/frontend/tests/page.test.ts @@ -378,6 +378,7 @@ test('household-browser-C18: a signed-out language choice has no identity or mut test.each([null, {}, [], {type: 'future'}, {type: 'refresh', actor: 'forged'}, {type: 'create', title: 'Task', description: '', priority: 'normal', householdId: 'forged'}, + {type: 'create', title: 'Task', description: '', priority: 'normal', basePointsMinor: '225'}, {type: 'create', title: 1, description: '', priority: 'normal'}, {type: 'create', title: 'Task', description: '', priority: 'future'}, {type: 'complete', materialUsages: true}, {type: 'complete', materialUsages: [{line_id: 'line', quantity: '1.0'}]}, {type: 'verify', decision: 'pending', reason: 'A'}, {type: 'reopen', reason: 1}, {type: 'select', occurrenceId: '../other'}, @@ -388,6 +389,7 @@ test('closed task events preserve semantic user inputs and exact material quanti const inputs = [{type:'createCategory', label:'Garden'}, {type:'renameCategory', categoryId:'garden', revision:'9007199254740993', label:'Garden'}, {type:'archiveCategory', categoryId:'garden', revision:'9007199254740993'}, {type:'create', title:'Clean', description:'', priority:'normal', categoryId:'garden'}, {type: 'refresh'}, {type: 'more'}, {type: 'claim'}, {type: 'select', occurrenceId: 'work-1'}, + {type: 'create', title: 'Clean', description: '', priority: 'normal', basePointsMinor: 0}, {type: 'create', title: ' Clean 🧹 ', description: '', priority: 'high'}, {type: 'complete', materialUsages: null}, {type: 'complete', materialUsages: [{line_id: 'line-1', quantity: '3.333333'}]}, {type: 'verify', decision: 'approved', reason: ''}, {type: 'verify', decision: 'rejected', reason: 'Not done'}, @@ -411,10 +413,12 @@ test('the mounted task surface routes only owned semantic intents to its applica send({type: 'create', title: 'Forged actor', description: '', priority: 'normal', actor: 'other'}); await Promise.resolve(); expect(spies.create).not.toHaveBeenCalled(); const review = {occurrenceId:'work-1',revision:'1',performerIds:['identity-1','other'],fingerprint:'c'.repeat(64)}; const cases: readonly [unknown, keyof typeof spies, readonly unknown[]][] = [ - [{type: 'create', title: 'Clean', description: 'Wipe', priority: 'high'}, 'create', ['Clean', 'Wipe', 'high', undefined, undefined]], + [{type: 'create', title: 'Clean', description: 'Wipe', priority: 'high'}, 'create', ['Clean', 'Wipe', 'high', undefined, undefined, undefined]], [{type: 'create', title: 'Clean', description: 'Wipe', priority: 'high', schedule: {kind: 'fixed', start: {kind: 'date', local: '2027-01-02'}, rrule: 'FREQ=WEEKLY'}}, - 'create', ['Clean', 'Wipe', 'high', {kind: 'fixed', start: {kind: 'date', local: '2027-01-02'}, rrule: 'FREQ=WEEKLY'}, undefined]], + 'create', ['Clean', 'Wipe', 'high', {kind: 'fixed', start: {kind: 'date', local: '2027-01-02'}, rrule: 'FREQ=WEEKLY'}, undefined, undefined]], + [{type: 'create', title: 'Clean', description: 'Wipe', priority: 'high', basePointsMinor: 225}, + 'create', ['Clean', 'Wipe', 'high', undefined, undefined, 225]], [{type:'createCategory', label:'Garden'}, 'createCategory', ['Garden']], [{type:'renameCategory', categoryId:'garden', revision:'1', label:'Garden'}, 'renameCategory', ['garden', '1', 'Garden']], [{type:'archiveCategory', categoryId:'garden', revision:'1'}, 'archiveCategory', ['garden', '1']], diff --git a/frontend/tests/task-component.test.ts b/frontend/tests/task-component.test.ts index da98cf6..f6511d1 100644 --- a/frontend/tests/task-component.test.ts +++ b/frontend/tests/task-component.test.ts @@ -1,4 +1,5 @@ import {afterEach, expect, test} from 'vitest'; +import type {HaTextField} from '@vypdev/ha-ui/field'; import {taskInstant, taskMessage} from '../src/presentation/task-messages'; import type {TaskRecord} from '../src/application/task-ports'; import type {TaskView} from '../src/application/task-controller'; @@ -32,6 +33,70 @@ test('usable task creation preserves literal input and emits one semantic intent f.root.querySelector('form')!.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true})); expect(f.intents).toEqual([{type: 'create', title: ' Clean 🧹 ', description: 'Wipe then dry', priority: 'normal'}]); expect(f.text()).not.toContain('private-home-id'); expect(f.text()).not.toContain('private-member-id'); + expect(f.root.querySelector('#task-base-points')).toBeNull(); +}); +test.each([ + ['en', 'Base points (optional)', '2.25', 'Enter 0 to 10,000 points'], + ['es', 'Puntos base (opcional)', '2,25', 'Introduce entre 0 y 10.000 puntos'], +] as const)('authorized %s form preserves the draft and submits exact base points', async (locale, label, value, error) => { + const access = {...view().access!, canConfigure: true}; + const f = await mount(view({access}), locale); + f.root.querySelector('details.new-task')!.open = true; + const field = f.root.querySelector('#task-base-points')!; + await field.updateComplete; + const amount = field.shadowRoot!.querySelector('input')!; + expect(amount.getAttribute('aria-label')).toBe(label); + const edit = async (text: string) => { + amount.value = text; + amount.dispatchEvent(new InputEvent('input', {bubbles: true})); + await f.element.updateComplete; + await field.updateComplete; + }; + const submit = () => f.root.querySelector('form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + await f.input('task-title', 'Inspect filter'); + submit(); + expect(f.intents.pop()).toEqual({type: 'create', title: 'Inspect filter', description: '', priority: 'normal'}); + await edit('1,001'); + submit(); await f.element.updateComplete; await field.updateComplete; + expect(field.value).toBe('1,001'); expect(field.shadowRoot!.textContent).toContain(error); + expect(f.root.activeElement).toBe(field); expect(f.intents).toEqual([]); + await edit(value); submit(); + expect(f.intents).toEqual([{type: 'create', title: 'Inspect filter', description: '', priority: 'normal', basePointsMinor: 225}]); + f.element.view = view({access, outcome: {kind: 'rejected', reason: 'points_precision_two_required', correlationId: 'cor_precision'}}); + await f.element.updateComplete; await field.updateComplete; + expect(field.shadowRoot!.textContent).toContain(locale === 'en' ? 'Use whole base points' : 'Usa puntos base enteros'); + expect(amount.getAttribute('aria-invalid')).toBe('true'); + expect(field.value).toBe(value); + expect(f.root.activeElement).toBe(field); + f.element.view = {...f.element.view!}; + await f.element.updateComplete; await field.updateComplete; + expect(amount.getAttribute('aria-invalid')).toBe('true'); + await edit('2'); + expect(amount.getAttribute('aria-invalid')).not.toBe('true'); + await edit(value); submit(); + f.element.view = view({access, outcome: {kind: 'receipt', stage: 'committed', action: 'task.create', + operationId: 'configured', occurrenceId: 'work-1', revision, state: 'available', cycle: null, archivedBy: null, basePointsMinor: 225}}); + await f.element.updateComplete; await field.updateComplete; + expect(f.root.querySelector('section[role=status] dd')?.textContent).toBe(value); + expect(field.value).toBe(''); +}); + +test('revoking configuration in the same scope asks for explicit removal of the draft amount', async () => { + const access = {...view().access!, canConfigure: true}; + const f = await mount(view({access})); + await f.input('task-title', 'Keep the title'); + const field = f.root.querySelector('#task-base-points')!; + field.dispatchEvent(new CustomEvent('ha-value-request', {detail: {value: '0', reason: 'input'}})); + await f.element.updateComplete; + const form = f.root.querySelector('form')!; + const submit = () => form.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + submit(); expect(f.intents.pop()).toMatchObject({basePointsMinor: 0}); + f.element.view = view({access: {...access, canConfigure: false}}); + await f.element.updateComplete; + expect(f.root.querySelector('#task-base-points')).toBeNull(); + expect(f.button('Create task').disabled).toBe(true); submit(); expect(f.intents).toEqual([]); + f.button('Clear base points').click(); await f.element.updateComplete; submit(); + expect(f.intents).toEqual([{type: 'create', title: 'Keep the title', description: '', priority: 'normal'}]); }); test.each([['en','fixed'],['es','relative']] as const)('scheduled task form in %s preserves invalid input and emits a %s draft', async (locale, kind) => { const access = {...view().access!, schedulePolicy: {zone: 'Europe/Madrid', data_identity: 'tzdata:2026.6/iana:2026f', diff --git a/frontend/tests/task-controller.test.ts b/frontend/tests/task-controller.test.ts index 61d41df..237ec96 100644 --- a/frontend/tests/task-controller.test.ts +++ b/frontend/tests/task-controller.test.ts @@ -43,6 +43,24 @@ test('lost task replies block a second new key and recover by read without resen await f.controller.recover('key-1'); expect(f.server.original).toHaveBeenCalledTimes(1); expect(f.server.send).toHaveBeenCalledTimes(1); expect(f.controller.view.outcome).toEqual(saved); }); +test('base points require current configuration authority and survive lost-reply recovery', async () => { + const f = fixture(); + await f.controller.refresh(); + await f.controller.create('Check filter', '', 'normal', undefined, undefined, 225); + expect(f.controller.view.notice).toBe('action_not_allowed'); + expect(f.server.send).not.toHaveBeenCalled(); + f.server.access.mockReturnValue(read({...f.controller.view.access!, canConfigure: true})); + f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); + await f.controller.create('Check filter', '', 'normal', undefined, undefined, 225); + const request = f.server.send.mock.calls[0][0]; + expect(request.command.payload).toMatchObject({overrides: {point_value_minor: 225}}); + expect(f.controller.view.records?.[0]).toMatchObject({command: request.command, stage: 'uncertain'}); + f.server.original.mockReturnValue(read({...saved, basePointsMinor: 225})); + await f.controller.recover('key-1'); + expect(f.server.original).toHaveBeenCalledWith({...request, operationId: null}, context); + expect(f.server.send).toHaveBeenCalledOnce(); + expect(f.controller.view.outcome).toMatchObject({basePointsMinor: 225}); +}); test('scheduled task creation binds the verified household policy and keeps a lost reply under its original key', async () => { const f = fixture(); const draft: TaskScheduleDraft = {kind: 'relative', start: {kind: 'date', local: '2027-01-02'}, diff --git a/frontend/tests/task-http.test.ts b/frontend/tests/task-http.test.ts index a89d378..9ab7863 100644 --- a/frontend/tests/task-http.test.ts +++ b/frontend/tests/task-http.test.ts @@ -40,7 +40,7 @@ function receipt(index: number, lookup = false) { } function encode(value: unknown) {return JSON.stringify(value).replaceAll('"$revision"', REV).replaceAll('"$next"', NEXT);} function reply(value: unknown, status = 200) {return new Response(encode(value), {status, headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}});} -const failure = (status: number) => reply({state: 'blocked', code: 'request_forbidden', message: 'Private server detail.', action_required: 'Review.', correlation_id: 'cor_safe'}, status); +const failure = (status: number, code = 'request_forbidden') => reply({state: 'blocked', code, message: 'Private server detail.', action_required: 'Review.', correlation_id: 'cor_safe'}, status); function fixture(mode: 'native' | 'standalone' = 'standalone') { const target = {deployment: mode, applicationBase: mode === 'native' ? '/api/hassio_ingress/' + 'b'.repeat(43) + '/' : '/', origin: 'https://domus.example'}; const transport = vi.fn(async (_path: string, _init: RequestInit): Promise => reply({})); @@ -60,16 +60,17 @@ test.each(['fixed', 'relative'] as const)('scheduled %s task uses exact policy a const start = kind === 'fixed' ? {kind: 'time', local: '2027-01-02T09:30'} : {kind: 'date', local: '2027-01-02'}; const rule = kind === 'fixed' ? {rrule: 'FREQ=WEEKLY;BYDAY=SA'} : {interval_days: 17, visibility: 'at_verification'}; const command = taskCommand({action: 'task.create', payload: {expected_revision: 0, title: 'Inspect filter', description: '', priority: 'normal', - schedule: {draft: {kind, start, ...rule}, policy: access.schedulePolicy}}})!; + overrides: {point_value_minor: 225}, schedule: {draft: {kind, start, ...rule}, policy: access.schedulePolicy}}})!; const request = {key: 'scheduled-request', command}; const raw = {state: 'committed', archived_by: null, operation_id: 'scheduled-op', created_at: 100, message: 'Saved.', action_required: 'Review.', result: {definition_id: 'definition-1', schedule_id: 'rule-1', - revision: 1, pattern_revision: 1, processing: 'pending', state: 'active', first_occurrence_id: null}}; + revision: 1, pattern_revision: 1, processing: 'pending', state: 'active', first_occurrence_id: null, point_value_minor: 225}}; f.transport.mockResolvedValueOnce(reply({csrf_token: 'a'.repeat(64)})).mockResolvedValueOnce(reply(raw)); - expect(await f.server.send(request, context)).toMatchObject({kind: 'receipt', occurrenceId: null, state: 'active'}); + expect(await f.server.send(request, context)).toMatchObject({kind: 'receipt', occurrenceId: null, state: 'active', basePointsMinor: 225}); const [path, init] = f.transport.mock.calls[2]; expect(path).toBe('/api/v1/tasks/recurrence/creation/' + kind); - expect(init.body).toBe(encode({task: {title: 'Inspect filter', description: '', priority: 'normal'}, start, ...rule, policy: rawPolicy})); + expect(init.body).toBe(encode({task: {title: 'Inspect filter', description: '', priority: 'normal', overrides: {point_value_minor: 225}}, + start, ...rule, policy: rawPolicy})); f.transport.mockResolvedValueOnce(reply({...raw, action: 'recurrence.' + kind + '.create', command_key: request.key, delivery: 'pending'})); expect(await f.server.original({...request, operationId: null}, context).result).toMatchObject({operationId: 'scheduled-op'}); expect(f.transport.mock.calls.at(-1)?.[0]).toBe('/api/v1/tasks/recurrence/creation/requests/recurrence.' + kind + '.create/' + request.key); @@ -80,6 +81,25 @@ test.each(['fixed', 'relative'] as const)('scheduled %s task uses exact policy a } }); +test.each([0, 225])('creation and original recovery bind the exact configured amount %s', async amount => { + const f = fixture(); + const overrides = {point_value_minor: amount}; + const command = taskCommand({...rawCommands[0], payload: {...rawCommands[0].payload, overrides}})!; + const request = {key: 'original-key', command}; + const raw = receipt(0); + f.transport.mockResolvedValueOnce(reply({csrf_token: 'a'.repeat(64)})) + .mockResolvedValueOnce(reply({...raw, result: {...raw.result, ...overrides}})); + expect(await f.server.send(request, context)).toMatchObject({kind: 'receipt', basePointsMinor: amount}); + expect(JSON.parse(String(f.transport.mock.calls[1][1].body))).toMatchObject({overrides}); + const original = receipt(0, true); + f.transport.mockResolvedValueOnce(reply({...original, result: {...original.result, ...overrides}})); + expect(await f.server.original({...request, operationId: 'op-1'}, context).result).toMatchObject({basePointsMinor: amount}); + for (const result of [original.result, {...original.result, point_value_minor: amount + 1}]) { + f.transport.mockResolvedValueOnce(reply({...original, result})); + await expect(f.server.original({...request, operationId: 'op-1'}, context).result).rejects.toThrow('invalid_response'); + } +}); + for (const mode of ['native', 'standalone'] as const) { test(`${mode}: list/detail project literal text and exact values, with server-owned allowed actions`, async () => { const f = fixture(mode); f.transport.mockResolvedValueOnce(reply({items: [summary()], next_cursor: 'cursor/+?'})).mockResolvedValueOnce(reply(detail())); @@ -112,6 +132,15 @@ test.each([401,403,409,422,503])('known task mutation rejection %s preserves onl expect(f.transport).toHaveBeenCalledTimes(2); }); +test.each([[0, 'points_precision_two_required'], [2, 'invalid_input']] as const)( + 'the precision rejection requests a base correction only for creation command %s', async (index, reason) => { + const f = fixture(); + f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})) + .mockResolvedValueOnce(failure(422, 'points_precision_two_required')); + expect(await f.server.send({key: 'key', command: commands[index]}, context)) + .toEqual({kind: 'rejected', reason, correlationId: 'cor_safe'}); + }); + test('the task list and detail retain a date-only deadline without inventing a time', async () => { const f = fixture(); const dated = {...summary(), due_date: '2026-04-02'}; @@ -159,8 +188,8 @@ test('detail rejects another household or task before publishing its content', a for (const mode of ['native', 'standalone'] as const) test(`${mode}: task-specific access uses its own protected contract`, async () => { const f = fixture(mode); f.transport.mockResolvedValueOnce(reply({household_id: 'home', member_id: 'member-1', can_create: false})); expect(await f.server.access(context).result).toEqual({householdId: 'home', memberId: 'member-1', canCreate: false, canReviewSchedule: false}); - f.transport.mockResolvedValueOnce(reply({household_id: 'home', member_id: 'member-1', can_create: true, can_review_schedule: true})); - expect(await f.server.access(context).result).toMatchObject({canReviewSchedule: true}); + f.transport.mockResolvedValueOnce(reply({household_id: 'home', member_id: 'member-1', can_create: true, can_review_schedule: true, can_configure: true})); + expect(await f.server.access(context).result).toMatchObject({canReviewSchedule: true, canConfigure: true}); expect(f.transport.mock.calls[0][0]).toBe(f.target.applicationBase + 'api/v1/tasks/access'); }); diff --git a/frontend/tests/tasks.test.ts b/frontend/tests/tasks.test.ts index 4074a2e..563f80a 100644 --- a/frontend/tests/tasks.test.ts +++ b/frontend/tests/tasks.test.ts @@ -17,6 +17,21 @@ test('task creation preserves literal text and supplies no actor, household or p expect(taskCommand({...raw, householdId: 'other'})).toBeUndefined(); }); +test('task creation journals only an exact bounded override', () => { + const payload = {expected_revision: 0, title: 'Check filter', description: '', priority: 'normal'}; + for (const amount of [0, 225, 1_000_000]) { + const raw = {action: 'task.create', payload: {...payload, overrides: {point_value_minor: amount}}}; + const value = taskCommand(raw); + expect(value).toEqual(raw); + if (value?.action !== 'task.create') throw Error('missing task'); + expect(Object.isFrozen(value.payload.overrides)).toBe(true); + } + for (const overrides of [null, {}, {point_value_minor: -1}, {point_value_minor: 2.25}, + {point_value_minor: '225'}, {point_value_minor: 1_000_001}, {point_value_minor: 225, claim_allowed: false}]) { + expect(taskCommand({action: 'task.create', payload: {...payload, overrides}})).toBeUndefined(); + } +}); + test('completion takes an immutable copy of exact time, revision and material quantities', () => { const value = taskCommand(complete); expect(value).toEqual(complete); expect(value).not.toBe(complete); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfe4bdb..1baac5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: frontend: dependencies: '@vypdev/ha-ui': - specifier: 0.1.0-next.1 - version: 0.1.0-next.1 + specifier: 0.1.0-next.2 + version: 0.1.0-next.2 lit: specifier: 3.3.3 version: 3.3.3 @@ -813,8 +813,8 @@ packages: '@vitest/utils@5.0.5': resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==} - '@vypdev/ha-ui@0.1.0-next.1': - resolution: {integrity: sha512-MfY1sHgx8pbAOOATiPG2Pf5Qhp9T7CzMPBTzXphuqs66G2ck1zf+Y9GzOLwWeBUzZfY8+VAJObkjVWUvP/wd7w==} + '@vypdev/ha-ui@0.1.0-next.2': + resolution: {integrity: sha512-dB1RWDDDcdrcohBEi/TSWR89kXPbm/0/yE1Gz1WQ3bSE0nZOeuMveSPeSNaPv2n7sm97K25MG1c1GjywZchbrA==} '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -2041,7 +2041,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.2.0 - '@vypdev/ha-ui@0.1.0-next.1': + '@vypdev/ha-ui@0.1.0-next.2': dependencies: lit: 3.3.3 diff --git a/specs/CATALOG.md b/specs/CATALOG.md index b6ea304..1a23379 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -12,7 +12,7 @@ debt or convert unknown historic intent into a design decision. |---|---|---|---|---| | `platform-foundation` | Proposed | One local modular application serves the complete household product in both a Supervisor-managed App and standalone Docker from the first release. Home Assistant owns user authentication; Domus owns its business records. | [Local application platform and persistence](./platform-foundation.md) + 2 companion | 189 paths · 2026-11-01 | | `household-identity` | Proposed | Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity. | [Household members, Home Assistant identity and permissions](./household-identity.md) + 4 companion | 222 paths · 2026-11-02 | -| `household-tasks` | Proposed | Members create and perform household responsibilities with explicit assignments, actual performers and history. A recurring definition survives completion of each individual occurrence. | [Task definitions, occurrences and household execution](./household-tasks.md) + 2 companion | 44 paths · 2026-11-11 | +| `household-tasks` | Proposed | Members create and perform household responsibilities with explicit assignments, actual performers and history. A recurring definition survives completion of each individual occurrence. | [Task definitions, occurrences and household execution](./household-tasks.md) + 2 companion | 53 paths · 2026-11-11 | | `recurrence-calendar` | Proposed | A bounded scheduler turns task definitions into stable occurrences and a family calendar distinguishes planned start, deadline and actual completion. Original recurrence outcomes bind receipt status, action, result and source-cycle identity before replay and snapshot acceptance. | [Recurrence engine and family calendar](./recurrence-calendar.md) + 3 companion | 37 paths · 2026-11-09 | | `points-rewards` | Proposed | Task completion produces auditable point entries. Household-configurable collaboration policies determine each share and the actual award total; corrections preserve history and optional rewards never control basic participation. | [Auditable points, participation and optional rewards](./points-rewards.md) | 51 paths · 2026-11-10 | | `shopping-management` | Proposed | Shopping combines manual intent, replenishment and task materials without losing provenance. Buying an item and physically receiving it are separate recorded facts. | [Shared shopping demand, purchases and receipt](./shopping-management.md) | 40 paths · 2026-11-10 | @@ -52,10 +52,10 @@ debt or convert unknown historic intent into a design decision. - Last verified: 2026-11-11 - Specifications: [`specs/household-tasks.md`](./household-tasks.md) · [`specs/task-execution-foundation.md`](./task-execution-foundation.md) · [`specs/household-task-categories.md`](./household-task-categories.md) - Workflows: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) -- Entrypoints: [`backend/src/domus/entrypoints/task_http.py`](../backend/src/domus/entrypoints/task_http.py) -- Core code: [`backend/src/domus/domain/task_values.py`](../backend/src/domus/domain/task_values.py) · [`backend/src/domus/domain/task_policy.py`](../backend/src/domus/domain/task_policy.py) · [`backend/src/domus/domain/tasks.py`](../backend/src/domus/domain/tasks.py) · [`backend/src/domus/application/task_ports.py`](../backend/src/domus/application/task_ports.py) · [`backend/src/domus/infrastructure/sqlite_task_records.py`](../backend/src/domus/infrastructure/sqlite_task_records.py) · [`backend/src/domus/infrastructure/sqlite_task_schema.py`](../backend/src/domus/infrastructure/sqlite_task_schema.py) · [`backend/src/domus/infrastructure/sqlite_tasks.py`](../backend/src/domus/infrastructure/sqlite_tasks.py) · [`backend/src/domus/infrastructure/sqlite_task_integrity.py`](../backend/src/domus/infrastructure/sqlite_task_integrity.py) · [`backend/src/domus/application/task_authorization.py`](../backend/src/domus/application/task_authorization.py) · [`backend/src/domus/application/task_commands.py`](../backend/src/domus/application/task_commands.py) · [`backend/src/domus/application/task_requests.py`](../backend/src/domus/application/task_requests.py) · [`backend/src/domus/application/task_queries.py`](../backend/src/domus/application/task_queries.py) · [`frontend/src/application/task-overview.ts`](../frontend/src/application/task-overview.ts) · [`frontend/src/adapters/task-http.ts`](../frontend/src/adapters/task-http.ts) · [`frontend/src/presentation/domus-task-overview.ts`](../frontend/src/presentation/domus-task-overview.ts) -- Tests: [`tests/backend/test_task_values.py`](../tests/backend/test_task_values.py) · [`tests/backend/test_task_policy.py`](../tests/backend/test_task_policy.py) · [`tests/backend/test_tasks.py`](../tests/backend/test_tasks.py) · [`tests/backend/test_sqlite_task_records.py`](../tests/backend/test_sqlite_task_records.py) · [`tests/backend/test_sqlite_tasks.py`](../tests/backend/test_sqlite_tasks.py) · [`tests/backend/test_sqlite_task_integrity.py`](../tests/backend/test_sqlite_task_integrity.py) · [`tests/backend/test_task_restoration.py`](../tests/backend/test_task_restoration.py) · [`tests/backend/test_task_authorization.py`](../tests/backend/test_task_authorization.py) · [`tests/backend/test_task_commands.py`](../tests/backend/test_task_commands.py) · [`tests/backend/test_task_execution_commands.py`](../tests/backend/test_task_execution_commands.py) · [`tests/backend/test_task_edit_commands.py`](../tests/backend/test_task_edit_commands.py) · [`tests/backend/test_task_overview.py`](../tests/backend/test_task_overview.py) · [`frontend/tests/task-overview.test.ts`](../frontend/tests/task-overview.test.ts) · [`frontend/tests/task-http.test.ts`](../frontend/tests/task-http.test.ts) · [`frontend/tests/e2e/workspace-navigation.spec.ts`](../frontend/tests/e2e/workspace-navigation.spec.ts) -- User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) · [`docs/evidence/task-execution-foundation.json`](../docs/evidence/task-execution-foundation.json) · [`docs/tasks/operator-recovery.md`](../docs/tasks/operator-recovery.md) · [`docs/tasks/migration.md`](../docs/tasks/migration.md) · [`docs/tasks/execution.md`](../docs/tasks/execution.md) · [`docs/evidence/overview-task-qualification.md`](../docs/evidence/overview-task-qualification.md) · [`docs/evidence/overview-deadlines-qualification.md`](../docs/evidence/overview-deadlines-qualification.md) · [`docs/evidence/overview-maintenance-qualification.md`](../docs/evidence/overview-maintenance-qualification.md) +- Entrypoints: [`backend/src/domus/entrypoints/task_http.py`](../backend/src/domus/entrypoints/task_http.py) · [`backend/src/domus/entrypoints/schedule_creation_http.py`](../backend/src/domus/entrypoints/schedule_creation_http.py) +- Core code: [`backend/src/domus/domain/task_values.py`](../backend/src/domus/domain/task_values.py) · [`backend/src/domus/domain/task_policy.py`](../backend/src/domus/domain/task_policy.py) · [`backend/src/domus/domain/tasks.py`](../backend/src/domus/domain/tasks.py) · [`backend/src/domus/application/task_ports.py`](../backend/src/domus/application/task_ports.py) · [`backend/src/domus/infrastructure/sqlite_task_records.py`](../backend/src/domus/infrastructure/sqlite_task_records.py) · [`backend/src/domus/infrastructure/sqlite_task_schema.py`](../backend/src/domus/infrastructure/sqlite_task_schema.py) · [`backend/src/domus/infrastructure/sqlite_tasks.py`](../backend/src/domus/infrastructure/sqlite_tasks.py) · [`backend/src/domus/infrastructure/sqlite_task_integrity.py`](../backend/src/domus/infrastructure/sqlite_task_integrity.py) · [`backend/src/domus/application/task_authorization.py`](../backend/src/domus/application/task_authorization.py) · [`backend/src/domus/application/task_commands.py`](../backend/src/domus/application/task_commands.py) · [`backend/src/domus/application/task_creation.py`](../backend/src/domus/application/task_creation.py) · [`backend/src/domus/application/task_requests.py`](../backend/src/domus/application/task_requests.py) · [`backend/src/domus/application/task_queries.py`](../backend/src/domus/application/task_queries.py) · [`frontend/src/application/task-overview.ts`](../frontend/src/application/task-overview.ts) · [`frontend/src/adapters/task-http.ts`](../frontend/src/adapters/task-http.ts) · [`frontend/src/domain/tasks.ts`](../frontend/src/domain/tasks.ts) · [`frontend/src/application/task-controller.ts`](../frontend/src/application/task-controller.ts) · [`frontend/src/presentation/domus-tasks.ts`](../frontend/src/presentation/domus-tasks.ts) · [`frontend/src/presentation/domus-task-overview.ts`](../frontend/src/presentation/domus-task-overview.ts) +- Tests: [`tests/backend/test_task_values.py`](../tests/backend/test_task_values.py) · [`tests/backend/test_task_policy.py`](../tests/backend/test_task_policy.py) · [`tests/backend/test_tasks.py`](../tests/backend/test_tasks.py) · [`tests/backend/test_sqlite_task_records.py`](../tests/backend/test_sqlite_task_records.py) · [`tests/backend/test_sqlite_tasks.py`](../tests/backend/test_sqlite_tasks.py) · [`tests/backend/test_sqlite_task_integrity.py`](../tests/backend/test_sqlite_task_integrity.py) · [`tests/backend/test_task_restoration.py`](../tests/backend/test_task_restoration.py) · [`tests/backend/test_task_authorization.py`](../tests/backend/test_task_authorization.py) · [`tests/backend/test_task_commands.py`](../tests/backend/test_task_commands.py) · [`tests/backend/test_task_http.py`](../tests/backend/test_task_http.py) · [`tests/backend/test_schedule_creation_http.py`](../tests/backend/test_schedule_creation_http.py) · [`tests/backend/test_task_execution_commands.py`](../tests/backend/test_task_execution_commands.py) · [`tests/backend/test_task_edit_commands.py`](../tests/backend/test_task_edit_commands.py) · [`tests/backend/test_task_overview.py`](../tests/backend/test_task_overview.py) · [`frontend/tests/task-overview.test.ts`](../frontend/tests/task-overview.test.ts) · [`frontend/tests/task-http.test.ts`](../frontend/tests/task-http.test.ts) · [`frontend/tests/task-component.test.ts`](../frontend/tests/task-component.test.ts) · [`frontend/tests/e2e/workspace-navigation.spec.ts`](../frontend/tests/e2e/workspace-navigation.spec.ts) +- User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) · [`docs/evidence/task-execution-foundation.json`](../docs/evidence/task-execution-foundation.json) · [`docs/tasks/operator-recovery.md`](../docs/tasks/operator-recovery.md) · [`docs/tasks/migration.md`](../docs/tasks/migration.md) · [`docs/tasks/execution.md`](../docs/tasks/execution.md) · [`docs/evidence/overview-task-qualification.md`](../docs/evidence/overview-task-qualification.md) · [`docs/evidence/overview-deadlines-qualification.md`](../docs/evidence/overview-deadlines-qualification.md) · [`docs/evidence/overview-maintenance-qualification.md`](../docs/evidence/overview-maintenance-qualification.md) · [`docs/evidence/task-base-points-qualification.md`](../docs/evidence/task-base-points-qualification.md) ### `recurrence-calendar` — Recurrence engine and family calendar diff --git a/specs/catalog.json b/specs/catalog.json index be5177b..fb80a09 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -487,7 +487,8 @@ ".github/workflows/ci_check.yml" ], "entrypoints": [ - "backend/src/domus/entrypoints/task_http.py" + "backend/src/domus/entrypoints/task_http.py", + "backend/src/domus/entrypoints/schedule_creation_http.py" ], "code": [ "backend/src/domus/domain/task_values.py", @@ -500,10 +501,14 @@ "backend/src/domus/infrastructure/sqlite_task_integrity.py", "backend/src/domus/application/task_authorization.py", "backend/src/domus/application/task_commands.py", + "backend/src/domus/application/task_creation.py", "backend/src/domus/application/task_requests.py", "backend/src/domus/application/task_queries.py", "frontend/src/application/task-overview.ts", "frontend/src/adapters/task-http.ts", + "frontend/src/domain/tasks.ts", + "frontend/src/application/task-controller.ts", + "frontend/src/presentation/domus-tasks.ts", "frontend/src/presentation/domus-task-overview.ts" ], "tests": [ @@ -516,11 +521,14 @@ "tests/backend/test_task_restoration.py", "tests/backend/test_task_authorization.py", "tests/backend/test_task_commands.py", + "tests/backend/test_task_http.py", + "tests/backend/test_schedule_creation_http.py", "tests/backend/test_task_execution_commands.py", "tests/backend/test_task_edit_commands.py", "tests/backend/test_task_overview.py", "frontend/tests/task-overview.test.ts", "frontend/tests/task-http.test.ts", + "frontend/tests/task-component.test.ts", "frontend/tests/e2e/workspace-navigation.spec.ts" ], "documentation": [ @@ -535,7 +543,8 @@ "docs/tasks/execution.md", "docs/evidence/overview-task-qualification.md", "docs/evidence/overview-deadlines-qualification.md", - "docs/evidence/overview-maintenance-qualification.md" + "docs/evidence/overview-maintenance-qualification.md", + "docs/evidence/task-base-points-qualification.md" ], "readiness": { "state": "draft", diff --git a/specs/household-tasks.md b/specs/household-tasks.md index c24dc74..e2bb3d3 100644 --- a/specs/household-tasks.md +++ b/specs/household-tasks.md @@ -990,3 +990,196 @@ The roadmap's Overview section asks for upcoming maintenance. Application-experience-R2 defines the overview and current authorized navigation. The recurrence specification defines materialized work and existing effective deadlines. + +## Proposed change: Set base points when creating a task + +- Status: Draft +- Catalog capability: household-tasks + +### 1. Executive summary + +Issue #111 adds an optional base-point amount to task creation for a creator with current task-configuration authority. Leaving it unset preserves the existing household default and title-only workflow. The amount must remain within the existing minor-point bound; retries and later awards must preserve the original amount and existing award rules. + +```text +Authorized creator -> optionally enter base points -> create task +Basic creator -> create task with title only -> household default applies +``` + +### 2. Problem, current behavior, and evidence + +#### 2.1 Problem + +Creators who need a task-specific base amount cannot set it while creating a task. They must rely on the household default or use the existing task-override path later. This delta makes the optional override available at creation while preserving the simpler workflow for other creators. + +#### 2.2 Current behavior + +1. The task creation form accepts a title and other existing creation fields, but exposes no base-point control and sends no base-point amount. See `frontend/src/presentation/domus-tasks.ts`, `frontend/src/application/task-controller.ts`, and `frontend/src/domain/tasks.ts`. +2. The selected task domain supports an optional `point_value_minor` override bounded from 0 through 1,000,000; the task-execution contract identifies the base value as administrator-controlled and snapshotted. See `backend/src/domus/domain/task_values.py` and `specs/task-execution-foundation.md`. +3. The retained Points contract requires current `points.manage` authority for task-definition overrides and defines exact integer minor-point allocation and award behavior. See `specs/points-rewards.md`. + +The gap is in the creation form and its request path. This delta extends the retained `specs/household-tasks.md` contract; it does not replace existing task-creation, recovery, or award requirements. Current task-override bounds and authority are the baseline for review. + +### 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Creator with current task-configuration authority | Set an optional base amount when creating a task | Existing task creation form | Localized form control and creation result | +| Creator without that authority | Create a task without configuring points | Existing task creation form | Title-only path and existing household default | +| Household member | Understand the amount used for a created task and its later award | Task view and existing award history | Existing task and Points surfaces | + +“Base points” means the task’s base amount expressed in exact integer minor points. The existing household default applies when no override is supplied. Current task-configuration authority is checked at the write boundary; a visible control or client-provided identity does not grant permission. The creation control and its amount must be presented in English and Spanish, following the retained owner’s locale conventions. + +### 4. Goals, non-goals, and fixed invariants + +#### 4.1 Goals + +1. Authorized creators can optionally provide a bounded base-point override during task creation; creators without current task-configuration authority retain title-only creation and the household default. +2. Validate the amount against the existing minor-point bound, preserve the original request’s recovery behavior, and apply the existing task award rules to the exact configured base amount. +3. Present the control and exact amount consistently in English and Spanish. + +#### 4.2 Non-goals + +1. Changing household defaults, saved task overrides, allocation policy, award rules, or the existing permission model. +2. Requiring a base-point amount for task creation or changing title-only creation. + +#### 4.3 Fixed product/safety invariants + +1. Omission of the optional amount retains the existing household default; it must not silently become a new default or an invented override. +2. The creation path must enforce the current task-configuration authority and existing amount bounds. Invalid values create no task effect and leave the user able to correct the amount. +3. Recovering the original committed request returns the same task and base amount without creating a duplicate. Later award processing uses that exact amount under existing award and verification rules. +4. Existing task and Points contracts and their cases remain in force; this delta adds the six supplied cases without redefining retained cases. Requirements and cases remain prospective acceptance criteria, not claims of implementation or execution. + +| Requirement | Behavior | +| --- | --- | +| SDD-I111-U1-R1 | Task creation MUST expose an optional base-point override to creators with current task configuration authority, while creators without that authority retain title-only creation and the existing household default. | +| SDD-I111-U1-R2 | The override MUST use the current bounded minor-point amount and preserve original request recovery and task award rules. | +| SDD-I111-U1-R3 | The creation form MUST support exact base-point amounts in English and Spanish. | + +### 5. Current versus proposed product journey + +The existing task journey and retained task contract remain as documented in `specs/household-tasks.md`. This delta adds an optional base-point override to task creation for a creator with current task configuration authority. A creator without that authority keeps title-only creation and the existing household default. + +The creator enters an exact bounded minor-point amount in the creation form. The form presents the control and amount in the selected English or Spanish locale. Invalid out-of-range input is rejected without changing the established creation behavior. After creation, the task retains that exact base amount; existing task award rules use it. If the creation response is lost after commit, recovery with the original request returns the same task and amount without creating a duplicate. + +### 6. Functional behavior and state model + +#### 6.1 Creation flow delta + +1. The creator opens task creation. Current task configuration authority determines whether the optional base-point override is available. +2. An authorized creator may submit an exact amount within the existing bounded minor-point range. A creator without that authority submits the existing title-only form and receives the household default. +3. The system validates and commits the task using the existing creation and request-recovery rules. A lost response is recovered by replaying the original request; the same task and base amount are returned. +4. Later award processing applies the existing task award rules using the task's exact base amount. + +The existing task lifecycle, state transitions, permissions, and award behavior in `specs/household-tasks.md` remain in force. This change adds no new task lifecycle state. Invalid amounts are rejected before task creation; replay of the same request does not create a second task. + +### 7. User-facing configuration + +The creation form gains one optional input: base-point override. Its value is an exact minor-point amount within the current supported bounded range. The recommended default is no override, preserving the household default. The value is scoped to the task being created and persists as that task's base amount. Current task configuration authority governs whether the input is available and accepted; the creator cannot expand their own authority. Invalid or out-of-range values are rejected. The control and amount must be presented consistently in English and Spanish. Other task configuration and precedence rules remain those in `specs/household-tasks.md`. + +### 8. Clean Architecture design + +This delta extends the existing task creation path; it does not introduce a parallel task or points policy. The creation presentation exposes the optional control only when current task configuration authority permits it and submits the amount through the existing task creation boundary. The application and domain validate the bounded minor-point amount and retain it on the created task. Existing request receipt/recovery behavior returns the original committed task for a replay. Existing award processing consumes the task's exact base amount under the established award rules. + +The UI must not be the authorization boundary: the creation command rechecks current authority and validates untrusted submitted amounts before committing. Invalid input creates no task. Recovery and award integration must preserve existing idempotency and ledger rules. English and Spanish presentation belongs to the existing localization boundary. This delta adds no new durable state machine; the task's base amount remains part of the existing task record and award flow. + +### 9. UI/UX and content contract + +#### 9.1 Creation form + +For a creator with current task configuration authority, the task creation form MUST expose an optional base-point control alongside the existing title-only path. A creator without that authority continues to create a task using the household default and does not see an editable override. The control accepts and displays the exact bounded minor-point amount; it must not round or silently substitute a value. The established task contract keeps title-only creation minimal (`specs/household-tasks.md`, `household-tasks-R7`); the task execution contract defines `point_value_minor` as an integer from 0 through 1,000,000 (`specs/task-execution-foundation.md`). + +Label the control with its purpose and unit, provide an accessible name, keyboard operation, visible focus, and field-level feedback that is not conveyed by color alone. English and Spanish must each present the control, unit, entered amount, and validation feedback consistently in the selected language; use the existing English fallback when no supported language matches. Preserve the entered title and amount when validation fails. On narrow screens the title-only save path remains available without opening advanced fields. These outcomes are exercised by the authorized and basic creator cases, amount boundary case, and localization case defined for this delta. + +### 10. Failure, recovery, and cleanup + +Reject a missing-authority override, malformed amount, or amount outside the supported minor-point bounds before task creation; identify the field and retain the draft for correction. A creator without override authority may still submit title-only creation under the existing household default. Do not partially create a task or apply an unrequested amount on rejection. + +If the creation response is lost after commit, recovery through the original request identity returns the same task and exact base amount without creating another task. A changed request under that identity follows the retained task command conflict behavior. Subsequent award processing uses the committed base amount under the existing award and verification rules; a failed or pending projection must not be reported as a failed creation or as points already awarded. These recovery and award outcomes extend the existing actionable validation, conflict, and receipt contract (`specs/household-tasks.md`, `household-tasks-R8`; `specs/task-execution-foundation.md`, `task-execution-foundation-C22`). + +### 11. Security, permissions, and privacy + +The override is available only to a creator with current task configuration authority, checked by the protected task command at commit time. Hiding or disabling a control is not authorization: requests that forge an override or use stale/revoked authority must be rejected without changing task state. A creator lacking that authority retains title-only creation and the existing household default. Keep household scoping, current role checks, and original-operation authorization consistent with the retained task contract (`specs/household-tasks.md`, `household-tasks-R8`). + +Treat the amount and all request fields as untrusted input. Validate the bounded integer on the server; do not accept a client-selected household, permission, award, or ledger effect through this control. Diagnostics may record a safe outcome and correlation reference, but must not include credentials or unrestricted request payloads, consistent with `specs/household-tasks.md`. + +### 12. Observability and operational UX + +The creator must be able to distinguish a saved task from validation failure, authorization denial, conflict, and an unresolved response. On failure, state the impact, safe cause, corrective action, and whether the task was retained or committed; after an uncertain response, direct recovery to the original operation rather than inviting a fresh duplicate request. Show the saved base amount with the task when appropriate, and distinguish it from points awarded: the amount is the base for existing award processing, not evidence that an award has posted. Keep operator evidence limited to sanitized outcome, operation correlation, and the committed task reference; do not create duplicate activity or notifications on recovery. Follow the retained operational contract in `specs/household-tasks.md`. + +### 13. Compatibility, migration, rollout, and rollback + +This delta adds a creation-time way to set the existing task base-point amount. Reuse the existing saved task override and Points rules described by the issue and retained task execution contract; do not define a second points value or change award, verification, replay, or ledger semantics. Existing tasks without an explicit override continue to use the household default, and existing in-flight creation requests retain their original request-recovery behavior. + +The selected owner describes task implementation as prospective and does not establish the creation form's storage representation or a migration requirement. Review implementation evidence before deciding whether persistence changes are needed. If a schema change proves necessary, preserve existing task values and operation receipts atomically, and require a compatible image/data pair for rollback; never discard committed base amounts or replay awards as rollback compensation. Rollout must retain title-only creation for creators without current configuration authority. The concrete migration and rollback procedure remains unverified until the implementation and its evidence are available. + +### 14. Testing strategy and numeric budget + +#### Risk-derived budget and verification + +Retain the immutable owner’s 49-case baseline and add six distinct cases for this delta, for a minimum of 55 distinct cases overall. Each new case counts once; repeated executions do not increase the count. The six cases cover authorized and unauthorized creation-form behavior, supported amount boundaries and rejection, recovery of the original request after a lost response, application of the configured base amount under existing award rules, and English/Spanish presentation. Their concrete outcomes are defined by the supplied case inventory. These additions do not claim semantic completeness or executed acceptance. + +Tests MUST establish that authorized creators can set an optional bounded minor-point amount, unauthorized creators retain title-only creation and the household default, invalid amounts are rejected without changing the existing task contract, and valid amounts are preserved exactly. Recovery MUST return the same created task and amount without duplication. Subsequent awards MUST use the stored amount under existing award rules. Localization checks MUST verify that the control and exact amount are understandable in both languages. Keep deterministic request/recovery and boundary assertions, and test authorization at the command boundary as well as the form. No test run or coverage result is asserted by this draft. The concrete verification invocation and prerequisites remain unverified in the selected source. + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| UI and authorization | 2 | Observable scenarios in the case inventory. | +| Domain and validation | 1 | Observable scenarios in the case inventory. | +| Integration and recovery | 1 | Observable scenarios in the case inventory. | +| Integration and awards | 1 | Observable scenarios in the case inventory. | +| UI and localization | 1 | Observable scenarios in the case inventory. | +| Total | 6 | Distinct inventory cases; repeated executions do not add cases. | + +| Case ID | Area | Requirements | Scenario | +| --- | --- | --- | --- | +| task-base-points-C01 | UI and authorization | SDD-I111-U1-R1 | Given a creator with current task configuration authority, the creation form exposes an optional base-point override. | +| task-base-points-C02 | UI and authorization | SDD-I111-U1-R1 | Given a creator without current task configuration authority, creation remains title-only and uses the existing household default. | +| task-base-points-C03 | Domain and validation | SDD-I111-U1-R2 | Given an override at each supported boundary and an out-of-range amount, valid bounded minor-point amounts are accepted exactly and the invalid amount is rejected. | +| task-base-points-C04 | Integration and recovery | SDD-I111-U1-R2 | Given a creation request whose response is lost after commit, recovering the original request returns the same created task and base amount without creating a duplicate. | +| task-base-points-C05 | Integration and awards | SDD-I111-U1-R2 | Given a task created with a base-point override, subsequent task award processing applies the existing award rules using that exact base amount. | +| task-base-points-C06 | UI and localization | SDD-I111-U1-R3 | Given English or Spanish selected, the creation form presents the base-point control and its exact amount consistently in that language. | + +### 15. Documentation and discoverability + +#### Documentation and discoverability + +This is a bounded update to `specs/household-tasks.md`; the retained owner’s existing task creation, authorization, default, request recovery, and award contracts remain in force. Update that contract to explain the optional base-point control, who can use it, the bounded minor-point amount, the household default for creators without current configuration authority, and the English and Spanish labels and amount presentation. Describe validation and recovery in user-facing terms without promising a new award behavior. + +Document the six supplied cases as the acceptance evidence for this change and link them through the owner’s existing documentation structure. The selected source establishes the owner path; it does not establish new documentation paths or a concrete verification invocation for this delta. Any publication inventory and navigation are host-managed. + +| Path | Change | +| --- | --- | +| specs/household-tasks.md | update | + +[specs/household-tasks.md](household-tasks.md) + +### 16. Acceptance scenarios + +#### Acceptance scenarios + +The six supplied scenarios define the new observable acceptance inventory. With current task configuration authority, a creator sees an optional base-point override during creation. Without that authority, the creator retains title-only creation and the existing household default. At every supported boundary, valid minor-point amounts are accepted exactly and an out-of-range amount is rejected. If the response is lost after commit, recovery of the original request returns the same task and base amount without a duplicate. A task created with an override uses that exact base amount in subsequent processing governed by existing award rules. In English and Spanish, the creation form presents the control and exact amount consistently in the selected language. + +Acceptance MUST assert persisted task state and relevant side effects, including absence of a duplicate on recovery. Existing owner cases remain baseline references and are not redefined by this delta. Human review and execution results remain pending until the implemented artifact is exercised. + +### 17. Requirements traceability + +#### Requirements traceability + +The three supplied requirements map to the cases that exercise them: the authorized override and unauthorized title-only/default behavior map to `task-base-points-C01` and `task-base-points-C02`; bounded exact amounts, original-request recovery, and existing award processing map to `task-base-points-C03`, `task-base-points-C04`, and `task-base-points-C05`; English and Spanish control and amount presentation map to `task-base-points-C06`. These mappings preserve the supplied identities and concrete assertions. Existing task and Points cases remain references to baseline evidence, not new definitions. Reciprocal structural mappings do not establish semantic completeness or passing tests. + +| Requirement | Cases | +|---|---| +| SDD-I111-U1-R1 | task-base-points-C01, task-base-points-C02 | +| SDD-I111-U1-R2 | task-base-points-C03, task-base-points-C04, task-base-points-C05 | +| SDD-I111-U1-R3 | task-base-points-C06 | + +### 18. Implementation sequence + +Extend the immutable retained owner `specs/household-tasks.md` with the optional base-point override during task creation. Preserve its existing contract and cases, and retain the task lifecycle and authorization boundaries in `specs/task-execution-foundation.md` and the category contract in `specs/household-task-categories.md`. Expose the control only to creators with current task configuration authority; preserve title-only creation and the household default for creators without that authority. Validate and persist the exact bounded minor-point amount through the existing task and Points behavior. Preserve recovery of the original request after a lost response and apply the established task award rules. Provide the exact amount control in English and Spanish. + +### 19. Definition of Done + +Done when the six supplied cases establish that authorized creators can use the optional override while other creators retain title-only creation and the household default; supported boundary amounts are accepted exactly and an out-of-range amount is rejected; recovery of the original committed request returns the same task and base amount without a duplicate; and subsequent award processing uses that exact base amount under existing award rules. The creation control and exact amount are presented consistently in English and Spanish. Verify each behavior with observable UI, authorization, domain, recovery, and award evidence, and map each requirement only to cases that exercise it. The specification satisfies the required format; internal and cross-document consistency is checked; reciprocal catalog ownership, paths and evidence agree with the generated view. These publication outcomes are required; the concrete invocation or prerequisite for SDK consistency verification remains unverified in the selected source. + +### 20. References and decisions + +Issue: https://github.com/vypdev/domus/issues/111. This is a bounded update to the immutable retained owner `specs/household-tasks.md` at selected revision `209b396f6fde5dc34b69baa13251247918ab01cc`; its existing contract and cases remain authoritative. `specs/task-execution-foundation.md` retains the task execution context, and `specs/household-task-categories.md` remains an owning companion. The issue describes the protected task command, preview, and ledger as already supporting an exact base amount; that claim is issue-provided and was not independently verified here. The package declares pnpm 10.34.0, and `specs/README.md` documents specification validation and generation invocations, but their referenced definitions and execution prerequisites were not inspected here; no command result or SDK consistency verification is claimed. diff --git a/tests/backend/test_app_package.py b/tests/backend/test_app_package.py index d2e9070..e2604a6 100644 --- a/tests/backend/test_app_package.py +++ b/tests/backend/test_app_package.py @@ -15,7 +15,7 @@ def test_repository_and_edge_manifest_offer_native_ha_entry_without_user_credent manifest = yaml.safe_load((ROOT / "addon-edge/config.yaml").read_text()) assert repository["url"] == "https://github.com/vypdev/domus" assert manifest["slug"] == "domus_edge" and manifest["stage"] == "experimental" - assert manifest["boot"] == "manual" and manifest["version"] == "0.0.0-edge.23" + assert manifest["boot"] == "manual" and manifest["version"] == "0.0.0-edge.26" assert manifest["ingress"] is True and manifest["ingress_port"] == 8099 assert manifest["panel_admin"] is False and manifest["homeassistant_api"] is True assert manifest["options"] == {"public_origins": []} @@ -58,15 +58,15 @@ def test_build_pins_source_checksum_bases_lock_and_nonroot_native_entrypoint() - commits = re.findall(r"^ARG DOMUS_SOURCE_COMMIT=(\w+)$", source, re.MULTILINE) assert len(commits) == 2 and len(set(commits)) == 1 assert re.fullmatch(r"[a-f0-9]{40}", commits[0]) - assert commits[0] == "b72b5e40c0c552ae597ce77c95447c12a8a40cc4" + assert commits[0] == "691496c299ff371763e445acb9d36b9623a81404" assert ( - "DOMUS_SOURCE_SHA256=52bb0b1a109e4cd51a321168ffdefd7d1d56eeda2910e24df28902c0b2145b75" + "DOMUS_SOURCE_SHA256=535c0b95a2db96fe3ebc927596f4e74f6eaadb7b641f2a674c23e672489f0d3b" in source ) - assert "ARG BUILD_VERSION=0.0.0-edge.23" in source + assert "ARG BUILD_VERSION=0.0.0-edge.26" in source candidate = json.loads((ROOT / "addon-edge/artifact.json").read_text()) assert candidate["sourceCommit"] == commits[0] - assert candidate["version"] == "0.0.0-edge.23" + assert candidate["version"] == "0.0.0-edge.26" assert "DOMUS_SOURCE_SHA256=" + candidate["sourceArchiveSHA256"] in source assert re.search(r"^ARG DOMUS_SOURCE_SHA256=[a-f0-9]{64}$", source, re.MULTILINE) assert "sha256sum --check" in source @@ -140,8 +140,8 @@ def test_edge_artifact_declares_exact_reviewed_runtime_without_rebinding_histori "pythonFiles", "uiFiles", } - assert artifact["schemaVersion"] == 1 and artifact["version"] == "0.0.0-edge.23" - assert artifact["sourceCommit"] == "b72b5e40c0c552ae597ce77c95447c12a8a40cc4" + assert artifact["schemaVersion"] == 1 and artifact["version"] == "0.0.0-edge.26" + assert artifact["sourceCommit"] == "691496c299ff371763e445acb9d36b9623a81404" assert artifact["storageSchema"] == 31 for field in ["sourceArchiveSHA256", "wheelSHA256"]: assert re.fullmatch("[a-f0-9]{64}", artifact[field]) diff --git a/tests/backend/test_point_allocation_http.py b/tests/backend/test_point_allocation_http.py index 4cadcd5..32b0c9b 100644 --- a/tests/backend/test_point_allocation_http.py +++ b/tests/backend/test_point_allocation_http.py @@ -1,5 +1,6 @@ """Both compositions apply current allocation authority and preserve frozen pending awards.""" +import pytest from test_recurrence_http import CONTEXT, USER from test_task_http import create from test_task_runtime import api as api @@ -14,6 +15,72 @@ CHILD_CONTEXT = {"X-Domus-Context": CONTEXT["X-Domus-Context"].replace(USER, CHILD)} +def test_created_fractional_base_flows_through_preview_and_posted_award(api): + api.user(USER, True) + configured = api.client.put( + ALLOCATION, + json={"expected_revision": 0, "policy": {"mode": "conserve_total", "precision": 2}}, + headers=api.headers("precise-policy") | CONTEXT, + ) + assert configured.status_code == 200, configured.text + created = create(api, overrides={"point_value_minor": 225})["result"] + member = api.client.get("/api/v1/authorization").json()["member"]["member_id"] + occurrence = created["occurrence_id"] + work = {"expected_revision": created["revision"], "performer_ids": [member]} + preview = api.client.post( + ALLOCATION + "/preview", + json=work | {"occurrence_id": occurrence}, + headers=api.headers("base-preview") | CONTEXT, + ) + assert preview.status_code == 200, preview.text + expected = preview.json() + assert expected["allocation"]["base_minor"] == expected["actual_total"] == 225 + clock = SystemClock() + completed = api.client.post( + TASKS + "/occurrences/" + occurrence + "/complete", + json=work + | {"performed_at": clock.now(), "allocation_fingerprint": expected["fingerprint"]}, + headers=api.headers("base-complete") | CONTEXT, + ) + assert completed.status_code == 200, completed.text + cycle_id = completed.json()["result"]["cycle"]["cycle_id"] + worker = PointsConsumer(api.store, api.store.installation, clock) + assert worker.tick(lambda: True) == 1 and worker.tick(lambda: True) == 0 + (award,) = worker.ledger("home", cycle_id) + assert dict(award.shares) == {member: 225} + + +@pytest.mark.parametrize( + "mode", ["conserve_total", "full_per_participant", "equal_share", "manual"] +) +def test_task_creation_obeys_current_base_precision_without_changing_allocation(api, mode): + api.user(USER, True) + policy = {"mode": mode, "precision": 0} + if mode == "equal_share": + policy["rounding"] = "floor" + settings = api.client.put( + ALLOCATION, + json={"expected_revision": 0, "policy": policy}, + headers=api.headers("whole-policy") | CONTEXT, + ) + assert settings.status_code == 200, settings.text + body = {"title": "Check the filter", "overrides": {"point_value_minor": 225}} + definitions = TASKS + "/definitions" + headers = api.headers("fractional-base") | CONTEXT + reply = api.client.post(definitions, json=body, headers=headers) + if mode in ("conserve_total", "full_per_participant"): + assert reply.status_code == 422 and reply.json()["code"] == "points_precision_two_required" + assert api.client.get(definitions).json()["items"] == [] + assert api.client.get(TASKS + "/requests/task.create/fractional-base").status_code == 404 + body["overrides"]["point_value_minor"] = 200 + reply = api.client.post(definitions, json=body, headers=headers) + assert reply.status_code == 200, reply.text + assert reply.json()["result"]["point_value_minor"] == body["overrides"]["point_value_minor"] + assert ( + api.client.get(ALLOCATION, headers=CONTEXT).json()["household"]["policy"]["precision"] == 0 + ) + + def test_allocation_policy_and_override_remain_frozen_through_approval(api): api.user(USER, True) with api.store.write() as unit: diff --git a/tests/backend/test_schedule_creation_http.py b/tests/backend/test_schedule_creation_http.py index f1124e2..6a2eb45 100644 --- a/tests/backend/test_schedule_creation_http.py +++ b/tests/backend/test_schedule_creation_http.py @@ -36,7 +36,7 @@ def test_atomic_scheduled_creation_and_original_recovery(api, kind): assert workspace.status_code == 200, workspace.text policy = workspace.json()["schedule_policy"] payload = { - "task": {"title": "Recurring inspection"}, + "task": {"title": "Recurring inspection", "overrides": {"point_value_minor": 225}}, "start": {"kind": "date", "local": "2027-01-02"}, "policy": policy, } | ( @@ -54,6 +54,22 @@ def test_atomic_scheduled_creation_and_original_recovery(api, kind): assert api.client.post(path, json=payload | invalid, headers=headers).status_code == 422 forged = payload | {"policy": policy | {"revision": policy["revision"] + 1}} assert api.client.post(path, json=forged, headers=headers).status_code == 409 + allocation = "/api/v1/family/allocation" + for revision, precision in ((0, 0), (1, 2)): + configured = api.client.put( + allocation, + json={ + "expected_revision": revision, + "policy": {"mode": "conserve_total", "precision": precision}, + }, + headers=api.headers(f"policy-{precision}") | CONTEXT, + ) + assert configured.status_code == 200, configured.text + if precision == 0: + rejected = api.client.post(path, json=payload, headers=headers) + assert rejected.status_code == 422 + assert rejected.json()["code"] == "points_precision_two_required" + assert api.client.get(original, headers=CONTEXT).status_code == 404 assert api.client.get(TASKS + "/definitions").json()["items"] == [] committed = api.client.post(path, json=payload, headers=headers) @@ -61,6 +77,7 @@ def test_atomic_scheduled_creation_and_original_recovery(api, kind): receipt = committed.json() assert receipt["result"]["processing"] == "pending" assert receipt["result"]["first_occurrence_id"] is None + assert receipt["result"]["point_value_minor"] == 225 assert api.client.get(TASKS + "/occurrences").json()["items"] == [] with api.store.write() as unit: home = unit.households.get("home") diff --git a/tests/backend/test_task_commands.py b/tests/backend/test_task_commands.py index 8bcbbf5..97f539e 100644 --- a/tests/backend/test_task_commands.py +++ b/tests/backend/test_task_commands.py @@ -100,7 +100,10 @@ def test_title_only_creation_commits_default_policy_definition_occurrence_and_on def test_creation_replay_survives_restart_and_does_not_allocate_more_ids(tmp_path): commands, store, actor, identifiers = fixture(tmp_path) - request = CreateTask(TaskContent("A