From c8730e0671d7c3d5e1334d1f3a700773caccd3b2 Mon Sep 17 00:00:00 2001 From: "copilot-action[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:07:21 +0100 Subject: [PATCH 1/9] docs(sdd): specify issue contract in specs/household-tasks.md --- specs/household-tasks.md | 193 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) 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. From 8b1ea4b58c03b6a2b7181b0c28423db32ba4844f Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Wed, 11 Nov 2026 18:27:37 +0100 Subject: [PATCH 2/9] feat: let authorized creators set task base points --- .../domus/application/recurrence_commands.py | 7 +- .../relative_recurrence_commands.py | 7 +- .../src/domus/application/task_commands.py | 6 +- .../src/domus/application/task_creation.py | 9 ++ backend/src/domus/application/task_queries.py | 5 +- .../entrypoints/schedule_creation_http.py | 21 +++- backend/src/domus/entrypoints/task_dto.py | 5 +- docs/contracts/household-api.openapi.json | 111 +++++++++++------- .../native-household-api.openapi.json | 111 +++++++++++------- .../task-base-points-qualification.md | 17 +++ docs/roadmap.md | 5 +- docs/user-guide.md | 4 + frontend/src/adapters/task-http.ts | 4 +- frontend/src/adapters/task-values.ts | 20 +++- frontend/src/application/task-controller.ts | 7 +- frontend/src/composition/entry-page.ts | 12 +- frontend/src/domain/tasks.ts | 26 +++- frontend/src/presentation/domus-tasks.ts | 55 ++++++++- frontend/src/presentation/task-messages.ts | 10 ++ .../tests/e2e/workspace-navigation.spec.ts | 52 +++++++- frontend/tests/page.test.ts | 8 +- frontend/tests/task-component.test.ts | 53 +++++++++ frontend/tests/task-controller.test.ts | 18 +++ frontend/tests/task-http.test.ts | 32 ++++- frontend/tests/tasks.test.ts | 15 +++ specs/CATALOG.md | 10 +- specs/catalog.json | 13 +- tests/backend/test_point_allocation_http.py | 35 ++++++ tests/backend/test_schedule_creation_http.py | 3 +- tests/backend/test_task_commands.py | 31 ++++- tests/backend/test_task_http.py | 79 +++++++++++++ tests/backend/test_task_restoration.py | 6 +- 32 files changed, 652 insertions(+), 145 deletions(-) create mode 100644 docs/evidence/task-base-points-qualification.md 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..afdb46b 100644 --- a/backend/src/domus/application/task_creation.py +++ b/backend/src/domus/application/task_creation.py @@ -10,10 +10,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 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/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..8b0cda7 --- /dev/null +++ b/docs/evidence/task-base-points-qualification.md @@ -0,0 +1,17 @@ +# 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 [scheduled creation tests](../../tests/backend/test_schedule_creation_http.py) exercise the same amount in fixed and relative creation. | +| `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. 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..ed5966e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -47,6 +47,10 @@ 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. Check **Family** after completing the task for + points posted under the household's award rules. - 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/src/adapters/task-http.ts b/frontend/src/adapters/task-http.ts index 7167506..d321c5a 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)})}}); }); } 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/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..a3a0a17 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: boolean; 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 = false; 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 = false; 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(); @@ -112,8 +128,9 @@ export class DomusTasks extends LitElement { 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 = false; this.scheduleKind = 'manual'; this.scheduleError = false; this.scheduleInput = EMPTY_SCHEDULE_FIELDS; } } @@ -177,20 +194,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 = true; + 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 +229,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 = false; + }}>`; + } private categorySelector() { const categories = this.view!.access!.categoryCatalog!.categories; return html`
@@ -339,6 +379,8 @@ export class DomusTasks extends LitElement { 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} + ${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 +409,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..415d2d8 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', @@ -63,6 +68,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', diff --git a/frontend/tests/e2e/workspace-navigation.spec.ts b/frontend/tests/e2e/workspace-navigation.spec.ts index 72245af..02790a2 100644 --- a/frontend/tests/e2e/workspace-navigation.spec.ts +++ b/frontend/tests/e2e/workspace-navigation.spec.ts @@ -244,9 +244,57 @@ 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 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).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..ee5a540 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,58 @@ 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: '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..b5e8b6d 100644 --- a/frontend/tests/task-http.test.ts +++ b/frontend/tests/task-http.test.ts @@ -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())); @@ -159,8 +179,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/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/tests/backend/test_point_allocation_http.py b/tests/backend/test_point_allocation_http.py index 4cadcd5..224b838 100644 --- a/tests/backend/test_point_allocation_http.py +++ b/tests/backend/test_point_allocation_http.py @@ -14,6 +14,41 @@ 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} + + 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..c5eeb06 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, } | ( @@ -61,6 +61,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