PR #211 · exact candidate 5e478752f826c8569be91e2c5266e83d65693ac6 against base 9676a80c2cae6a3cad6ce0b43806b52bcdea9662 · view on GitHub
DateFilterHelpers.parseDateFilter now rejects successfully parsed absolute ranges where from > to, causing both JSON date fields to return the established INVALID_INPUT response instead of an empty result.VisualIntentError.invalidDate.make test-quick and 46/46 for the focused changed suites; lint and the ownership guard pass; remote review CI passes and threads are 0.Ready
The candidate is clean and exactly matches PR #211's requested head/base. Fresh make lint, make test-quick, and focused date-filter tests pass; semantic-equivalence checks preserve relative precedence, strict calendar parsing, open bounds, equal-day inclusivity, and surface-specific errors. GitHub's latest claude-review check passes, with zero submitted reviews and zero review threads. The prior exact-candidate full-iOS evidence is reused as requested: the only three failures are the known unrelated iOS baseline cases TransitUITests.testClearAll, TransitUITests.testEditViewPreservesTaskMilestone, and DataMaintenanceUITests.testDataMaintenanceGoldenPath. The intervening base commit T-1883 is MCP-only, so it does not invalidate that iOS comparison.
Shown verbatim — the markdown the author wrote, unmodified.
Reject reversed absolute date ranges in QueryTasksIntent for completionDate and lastStatusChangeDate, preserve existing range behavior, add cross-surface regressions, and document the bugfix.
e52989bc16b3fe21a7809f4866792279ce458da1 T-1819: Add reversed date range investigation regressions 663675242d707b0d5a43b4b0165020a919df7a2c T-1819: Reject reversed absolute date ranges 5e478752f826c8569be91e2c5266e83d65693ac6 T-1819: Refresh date filter calendar semantics A JSON date filter with a start date later than its end date used to be accepted and simply match no tasks. The shared parser now treats that input as invalid, so callers receive the normal INVALID_INPUT response.
An empty result and a caller mistake are different outcomes. The fix makes that distinction visible while preserving valid same-day, one-sided, relative, and strict calendar-date behavior.
DateFilterHelpers.parseDateFilter is the shared parser used by both JSON date fields in QueryTasksIntent. The ordering guard runs only after both supplied endpoints parse successfully, before the .absolute value is constructed.
The visual intent accepts native Date values and keeps its established VisualIntentError.invalidDate contract. JSON input uses strict date-only strings and keeps its field-specific INVALID_INPUT contract. Tests verify the shared rule without conflating the public error types.
The final candidate also makes the local-day calendar and formatter computed properties, so long-running processes observe current calendar and time-zone settings. This rebuilds small Foundation objects per operation but avoids stale process-lifetime date semantics.
Relative-token handling returns before absolute endpoint parsing, preserving precedence. Present malformed endpoints fail first; only two successfully parsed endpoints with fromDate > toDate are rejected, so equality remains valid. Since both dates are parsed in the same current local-day calendar, chronological Date comparison is equivalent to requested calendar-day ordering.
QueryTasksIntent.validateDateFilters already maps a nil parsed range to the existing field-specific invalid-input hint. The new JSON regression loops over completionDate and lastStatusChangeDate, while shared and visual tests cover the preserved inclusive and native-error contracts.
The production change is confined to shared date parsing and freshness of date helper state. There is no persistence, schema, networking, UI-layout, or MCP behavior change in this candidate.
Transit/Transit/Intents/Shared/Utilities/DateFilterHelpers.swift
Why it matters. Places the missing range-ordering invariant at the shared parser boundary, fixing both JSON date fields without duplicating field-specific validation.
What to look at. DateFilterHelpers.swift:66-74
Transit/Transit/Intents/Shared/Utilities/DateFilterHelpers.swift
Why it matters. Keeps parsing and day-level matching aligned with the user's current calendar and time zone in long-running processes.
What to look at. DateFilterHelpers.swift:17-35 and 91
Transit/TransitTests/QueryTasksIntentDateFilterTests.swift
Why it matters. Proves both public JSON filters return <code>INVALID_INPUT</code> with their exact field-specific hints.
What to look at. QueryTasksIntentDateFilterTests.swift:295-311
Transit/TransitTests/DateFilterHelpersTests.swift and Transit/TransitTests/FindTasksIntentTests.swift
Why it matters. Guards against over-rejecting equal/open ranges or changing the visual intent's established error values.
What to look at. DateFilterHelpersTests.swift:44-88; FindTasksIntentTests.swift:295-333
specs/bugfixes/query-tasks-intent-accepts-reversed-absolute-date-ranges/report.md
Why it matters. Records root cause, alternatives, preserved behavior, and the known environmental iOS failures needed for review attribution.
What to look at. report.md:24-79; CHANGELOG.md:9-11
Returning nil for fromDate > toDate preserves caller intent and exposes a mistake. Swapping endpoints would silently reinterpret the requested range.
The JSON intent uses the helper for both supported task date fields. Centralizing the invariant avoids duplicated field checks and protects future consumers of the parser.
The visual intent accepts native Date values and reports VisualIntentError.invalidDate, while JSON callers use strict date-only strings and receive INVALID_INPUT. The candidate verifies parity without changing either public contract.
Computed properties ensure current calendar and time-zone settings are honored by parsing and matching in a long-running process. This is a deliberate freshness/performance trade-off documented by the candidate.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | Date helper allocation | Computed calendar/formatter properties rebuild Foundation objects on each parse or absolute-range match. | Skipped as a non-blocking correctness trade-off explicitly intended to avoid stale calendar/time-zone state; the fresh full and focused unit suites pass. |
| minor | Visual validation duplication | FindTasksIntent retains a native-Date ordering guard instead of using the JSON parser directly. | Skipped as non-blocking: the surfaces have different input types and established error contracts, and both exact behaviors are covered. |
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex de0706d..b48b7bc 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -1,90 +1,91 @@ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Fixed - MCP `tools/call` now returns JSON-RPC `-32602 Invalid Params` for unknown tool names and maintenance tools disabled in Settings (T-1883), while retaining useful messages, omitting disabled tools from `tools/list`, and preserving `-32601 Method Not Found` for unsupported top-level methods. Handler and HTTP-route regressions cover the classification boundaries.+- T-1819: `QueryTasksIntent` now rejects reversed absolute `from`/`to` date ranges for both `completionDate` and `lastStatusChangeDate` with `INVALID_INPUT`, matching visual `FindTasksIntent` behavior. One-sided/open bounds, equal same-day inclusive ranges, relative-filter precedence, strict T-1799 calendar-date parsing, and current calendar/time-zone semantics remain unchanged; cross-surface regressions cover both date fields. - T-1799: QueryTasksIntent absolute date filters now require exact ASCII `YYYY-MM-DD` values representing valid dates in the user's current calendar. Local `Calendar.current` parsing, non-lenient formatter round-trip validation, and regressions reject normalized invalid days/months, non-padded values, and non-ASCII input while preserving inclusive ranges and relative filters. - MCP Streamable HTTP now returns `405 Method Not Allowed` with `Allow: POST` for `GET /mcp` when no SSE listening stream is offered, while preserving POST handling, origin validation, and unrelated-route 404 behavior (T-1835). - MCP `query_tasks` now verifies that a well-formed `projectId` identifies an existing project before filtering (T-1783). Nonexistent project IDs return the established project-not-found error, while malformed UUID validation and other filters remain unchanged; regression coverage also checks parity with `QueryTasksIntent`. - `AddTaskIntent` now distinguishes an empty-database/no-selection path (`NO_PROJECTS`) from a stale selected `ProjectEntity` (`PROJECT_NOT_FOUND`) even when the deleted project was the last project in the store (T-1814). The required App Intents project parameter contract is preserved; missing project input against a non-empty store returns `INVALID_INPUT`. Entity resolution happens before task creation, and focused regressions assert exact error cases and no task mutation. - MCP `create_task`, `create_milestone`, `CreateTaskIntent`, and `CreateMilestoneIntent` now distinguish missing required fields from present non-string `name`/`type` values (T-1598). Numeric, boolean, array, object, and null inputs return field-specific `name must be a string` / `type must be a string` validation errors before mutation, while missing-field and invalid-string-type behavior remains unchanged. Symmetric regression coverage verifies all four create paths and confirms malformed requests create no records. - `QueryTasksIntent` now rejects explicit JSON `null` in nested `completionDate` and `lastStatusChangeDate` fields (`relative`, `from`, and `to`) before Codable decoding can erase presence. Malformed nested date-filter shapes retain the established `INVALID_INPUT` contract, omitted fields remain unchanged, and regressions cover both filters and no-broadening behavior (T-1644). - `Color.hexString` now rounds resolved RGB components to the nearest byte after finite-checking and clamping instead of truncating values just below byte boundaries (T-1807). Project colors no longer darken when an editor saves an unchanged color. Regression coverage exercises every `0...255` RGB value, deterministic sRGB resolved components immediately below each boundary, and the existing uppercase six-digit RGB/no-alpha format. - T-1802: Compact-width portrait dashboard layouts now always use the segmented single-column view, including widths at and above 400 points. Landscape, iPad, and Mac continue to use geometry-based column adaptation through a focused layout-selection helper and regression coverage. - MCP `update_task_status` now returns the exact comment created by its atomic status-plus-comment mutation (T-1823). `TaskService.updateStatus` propagates the created `Comment` to the handler for direct serialization instead of refetching every task comment and taking the last `creationDate`; a future-dated existing or concurrently imported comment can no longer replace the mutation result. If the atomic save fails, the newly inserted comment is deleted before the task state is rolled back so a later save cannot resurrect it. Regressions verify exact UUID propagation, future-clock-skew behavior, and service/MCP failure recovery. - Cross-device task and milestone promotion now re-probes committed SwiftData state after display-ID allocation and skips assignment when a peer has already supplied a permanent ID (T-2020). Missing or unreadable records fail closed, save-failure recovery preserves peer-merged values, and deterministic two-context tests pin both record types. Because SwiftData has no field-level compare-and-set, this closes the peer-merge-during-await window but cannot prevent two devices whose final local probes both precede either CloudKit merge from racing; the limitation and owner-reservation alternative are documented. - MCP `initialize` now requires object params with string `protocolVersion`, object `capabilities`, and typed `clientInfo` identity fields (T-1778). Missing or malformed method parameters return JSON-RPC `-32602 Invalid Params`. JSON-RPC request decoding now rejects scalar/null top-level `params` server-wide with `-32600 Invalid Request`. Supported `2025-03-26` requests are echoed and unsupported versions receive the server's latest advertised supported version. Focused handshake tests cover absent params/fields, wrong types, malformed client information, request-shape errors, and both negotiation paths. - MCP server start, stop, and same-port restart now run through one async desired-state lifecycle coordinator (T-1826). Each active listener owns its Hummingbird `ServiceGroup` and run task; teardown explicitly triggers graceful shutdown and awaits the group before rebinding, rather than assuming cancellation completion releases the socket. Graceful shutdown is time-bounded so a stalled request cannot wedge the coordinator, and the group installs no SIGTERM/SIGINT traps (`runService()`'s default) because those change the process's signal disposition permanently. Requests arriving during teardown replace the pending target so a burst converges on the latest state, Settings uses an explicit restart operation, and live loopback regressions cover stop, 20 serialized same-port repetitions, different-port restart, rapid off/on, an occupied port surfacing the bind failure, an invalid port releasing the running listener, bounded shutdown escalation, empty signal configuration, and reusable bind-probe behavior. - Already-cancelled task and milestone creates now stop before an uncontended display-ID allocation, and cancellation during a successful non-cooperative allocation is re-checked before SwiftData insertion (T-1765). `AllocationGate` checks cancellation once it holds the lock and before running the allocation body, while both create services guard their persistence boundary; existing selective `insertOrDelete` save-failure cleanup is unchanged. The gate check is shared by every allocator caller, so a cancelled display-ID promotion or duplicate-cleanup pass now stops at the current record and retries on the next pass. If the counter advance already succeeded, cancellation may leave a harmless gap in the display-ID sequence rather than persisting a ghost record. Four deterministic regressions cover the new timing windows across tasks and milestones, and the two retained queued-cancellation cases now prove that their contender entered and was removed from the gate queue. - The MCP Streamable HTTP endpoint now accepts non-empty JSON-RPC request/notification batches for advertised protocol `2025-03-26` (T-1834). It decodes each member independently, dispatches requests and notifications sequentially, omits notification responses, preserves response-array semantics, returns HTTP 202 with no body for all-notification batches, emits per-member `-32600` errors for invalid entries, handles empty batches as one invalid-request object, and rejects lifecycle-invalid batched `initialize` calls. Route-level regression tests cover each behavior and unchanged single-request responses. - MCP `create_task`, `CreateTaskIntent`, and `AddTaskSheet.persist` now create a task plus optional milestone atomically in one `TaskService` save (T-1768). The relationship is validated and attached before insertion; failed saves delete the pending aggregate instead of relying on a second, fallible compensating deletion. Surface-level failure regressions verify no task remains or is resurrected by a later save. `CreateTaskIntent` maps the merged save's failures per error kind, so a storage failure that was previously reported as `INTERNAL_ERROR` by the separate milestone-assignment step still reports `INTERNAL_ERROR` rather than collapsing into `INVALID_INPUT`; `milestoneProjectMismatch` from the service boundary surfaces as `MILESTONE_PROJECT_MISMATCH`. - Duplicate cleanup now re-probes a task or milestone loser's committed display ID after asynchronous allocation and immediately before mutation (T-2019). Peer changes that land during allocation are preserved and reported as `stale-id`; task cleanup no longer emits a false audit comment for an overwritten peer repair. Deterministic gated regressions cover both record types and the task audit path. - Editor conflict choices now protect against unseen concurrent changes across task, project, and milestone editors (T-1935). **Use Updated Values** rebases the full draft onto current live values while preserving only genuine non-conflicting user edits, and **Keep My Changes** authorizes only the exact conflict field values shown; either action re-alerts when the conflict snapshot changes. - SwiftData test fixtures now retain their backing `ModelContainer` for the full test lifetime (T-2003). `TestModelContainer` is a container-owning fixture instead of a bare-context factory, 207 context-acquisition call sites across 92 test files were migrated, and bespoke context helpers in task-entity, task-creation-result, and report tests now delegate to the shared fixture. An escaped-context lifetime regression plus an executable ownership guard with positive/negative fixtures prevent raw container/context factories from recurring. - Cross-device milestone name conflicts no longer make name-based operations target an arbitrary record (T-1938). `MilestoneService.findByName` now throws on multiple project-scoped matches; all MCP and App Intent callers report the ambiguity (`AMBIGUOUS_MILESTONE` for intents). Launch/foreground/connectivity maintenance deterministically keeps the oldest milestone name and renames other UUID-distinct records with UUID-derived suffixes, preserving records and task assignments. Cross-context tests simulate CloudKit imports and verify ambiguity reporting, reconciliation, idempotence, and data preservation. - Task mutation surfaces no longer hide duplicate task display IDs behind a not-found error (T-1837). `TaskService.findByDisplayID` already threw `duplicateDisplayID` when several tasks share a `permanentDisplayId` (possible under CloudKit, which cannot enforce uniqueness), but MCP `update_task_status` / `update_task` / `add_comment`, `UpdateStatusIntent`, `IntentHelpers.resolveTask`, and the visual `AddCommentIntent` all collapsed it into `TASK_NOT_FOUND` / "Provide either displayId or taskId", so operators could not tell missing data from store corruption needing display-ID maintenance. Duplicates now report `INTERNAL_ERROR` with the hint `Duplicate task identifier for displayId N` (App Intents — matching the milestone paths and the T-1097 `QueryTasksIntent` fix) or an `isError` result reading `Duplicate task identifier detected for displayId N` (MCP — matching the milestone tools). MCP `query_tasks` uses the same wording instead of leaking `Lookup failed: duplicateDisplayID`. No new error code was introduced. The three MCP call sites now share `resolveTaskArgument`, `UpdateStatusIntent` routes through `IntentHelpers.resolveTask` instead of its own copy of the mapping, and `VisualIntentError` gains a `duplicateIdentifier` case (code `INTERNAL_ERROR`). Regression coverage in `DuplicateTaskDisplayIDIntentTests` and `MCPDuplicateTaskDisplayIDTests`. - The MCP `add_comment` handler now distinguishes a missing required field from a present-but-non-string one for `content` and `authorName` (T-1579). Previously `guard let value = args[key] as? String, !value.isEmpty` collapsed numbers, booleans, arrays, and null into `Missing required argument: content` / `Missing required argument: authorName`, misleading callers into thinking they omitted a field they actually supplied with the wrong type. A present non-string value now returns the field-specific `content must be a string` / `authorName must be a string` before any task resolution or comment creation, matching the hardened pattern used by `update_task_status` after T-1205. Extracted a `requiredString(_:key:)` helper to keep `handleAddComment` within the cyclomatic-complexity limit. Regression coverage added in `MCPAddCommentTypeValidationTests` (numeric/boolean/array/null `content` and `authorName`). - `IntentHelpers.resolveMilestone` now rejects a present-but-non-string `name` identifier (number, boolean, null, array) with `INVALID_INPUT` and the field-specific hint `name must be a string`, instead of treating the failed `as? String` cast as an absent identifier and returning the generic `Provide displayId, milestoneId, or name with project` hint (T-1572). This only affects the `UpdateMilestoneIntent` name+project identifier path; no update fields are applied when the identifier is malformed. The MCP `update_milestone` tool is unaffected (it does not support name-based identification). Regression tests added to `MilestoneRenameTypeValidationTests`. - The MCP `update_milestone` handler and `UpdateMilestoneIntent` now support clearing a milestone description by passing an empty or whitespace-only `description` string, mirroring `update_task` and the milestone edit UI (T-1555). Previously a present `description` was applied verbatim, so `""`/whitespace persisted an empty string and the MCP response kept emitting a `description` key. Non-empty values are now trimmed before storage, and a cleared description sets `milestoneDescription` to nil (the MCP `milestoneToDict` serializer already omits the key when nil). For cross-surface parity, `UpdateMilestoneIntent`'s response now also emits `description` when set and omits it when cleared (previously it never emitted the key). T-343 had fixed only the edit-UI path. Cross-surface regression tests added in `MCPMilestoneClearDescriptionTests` and `UpdateMilestoneClearDescIntentTests`. - MCP `update_task` apply/save failure messages are now uniformly worded `"Update failed: \(error)"` (T-650 phase 4). Previously the handler emitted three separate templates (`"Failed to save: ..."`, `"Failed to set milestone: ..."`, `"Failed to clear milestone: ..."`); none of these are referenced by existing tests, but agents matching the old strings will see new wording. Validation, identifier-resolution, and milestone error messages are unchanged. - `QueryTasksIntentTests` no longer fails to compile after T-1146 added a required `milestoneService:` argument to `QueryTasksIntent.execute`; the missing argument is now supplied at the one stale call site so the test target builds. - Milestone status updates are now idempotent: re-sending the current status no longer rewrites `lastStatusChangeDate` and `completionDate`, so old terminal milestones no longer re-enter the current report window via `ReportLogic`'s `completionDate ?? lastStatusChangeDate` fallback. Applied across `MilestoneService.updateStatus`, `UpdateMilestoneIntent.applyUpdate`, and `MCPToolHandler.applyMilestoneUpdate` (T-923). - `DisplayIDMaintenanceService.reassignDuplicates` now surfaces a fetch failure as a counter-advance warning on both record types instead of silently no-oping the run with empty arrays. - `DisplayIDMaintenanceService` group processing no longer fabricates winner identity (`UUID()` / `""`) for the unreachable empty-group case; the invariant is asserted via `preconditionFailure` and the existing `RecordRef` is propagated. - `IntentHelpers.resolveMilestone` now rejects malformed `milestoneId` and `projectId` with `INVALID_INPUT` instead of silently falling back to name-based lookup (T-753) - Sync toggle in Settings now updates `SyncManager` runtime state immediately instead of only taking effect on next launch (T-699) ### Changed - **`update_task` response shape changed (T-650)**. The new `IntentHelpers.taskUpdateResponseDict` builder (used by both the MCP tool and `UpdateTaskIntent`) no longer emits `comments`, `lastStatusChangeDate`, or `completionDate`, and now emits `description` and `metadata` only when non-empty (previously `description` was present as `null` and `metadata` was absent). The new shape is fixed by AC 9.1; clients parsing those removed keys from `update_task` responses must instead call `query_tasks` to fetch them. Identifier-resolution and error envelopes are unchanged. - `DisplayIDMaintenanceService` init no longer takes `taskCounterStore`/`milestoneCounterStore`; the stores are read from each `DisplayIDAllocator.counterStore` (single source of truth). - `IntentHelpers.encodeAsJSONString(_:)` is the shared JSON encoding helper used by `MCPToolHandler` and the maintenance App Intents, replacing three per-site copies of the same `JSONEncoder()` + UTF-8 guard. - `MCPToolHandler` now derives the gated maintenance tool name set from `MCPToolDefinitions.maintenanceToolNames` so the gate stays in sync with the definitions list. - Service-level error messages in `DisplayIDMaintenanceService` use `error.localizedDescription` rather than `"\(error)"` interpolation for more actionable output in the result envelope. - Decision 11 in the duplicate-displayid-cleanup decision log records the choice to use `FetchDescriptor` for the stale-ID guard (instead of `ModelContext.refresh(_:mergeChanges:)`) since the service hands `RecordRef` value types across the scan/reassign boundary rather than `@Model` references. ### Added - Effective-priority invariant cross-surface regression test (T-1463, Verification phase). `EffectivePriorityInvariantTests` creates a legacy task with `priorityRawValue = ""` (set directly, bypassing the `priority` setter) and asserts it reads/serializes as `medium` and is matched by a `medium` priority filter on all five read surfaces — board `DashboardLogic`, `MCPQueryFilters.matches`, `IntentHelpers.taskToDict`, `QueryTasksIntent` `applyFilters`, and `IntentHelpers.taskUpdateResponseDict` — plus the accessor itself. This is the guard against any surface reading `priorityRawValue` directly instead of the computed `priority` accessor and silently breaking Req 1.4. - Task priority in the app UI (T-1463, UI phase). The kanban dashboard gains a `PriorityFilterMenu` (multi-select high/medium/low, displayed high→medium→low, ephemeral state that resets on launch) wired into `DashboardView`'s filter row and `DashboardLogic` predicate, matching on the computed `task.priority` so legacy/unset tasks read as medium. Task cards show a new `PriorityIndicator` glyph for high/low only (medium suppressed, read from `TaskPriority.glyphSymbol`). The create sheet (`AddTaskSheet`) and edit screen (`TaskEditView`) get priority pickers (default medium / current value, both iOS and macOS layouts), and `TaskDetailView` shows a priority row. Adds `TaskPriority.displayOrder` (`[.high, .medium, .low]`) for consistent picker/filter ordering. `DashboardView`'s filter inputs were bundled into a `FilterSelection` value type as part of this. - Task priority across the MCP server and App Intents (T-1463, Automation phase). New dedicated `INVALID_PRIORITY` error code (its own `IntentError` case + hint, shared by both surfaces). `TaskUpdateValidator.validatePriority` mirrors `validateType` and threads an optional, non-clearable priority through `ValidatedTaskUpdate` (Decision 8). MCP `create_task` parses/defaults priority (invalid → error, no task created — Req 5.5) and echoes it; `query_tasks` filters by a multi-value `priority` array (mirrors `status`) via `MCPQueryFilters.priorities`; schemas updated in `MCPToolDefinitions`. App Intents: `CreateTaskIntent` validates optional-with-default priority (absent → medium, invalid → `INVALID_PRIORITY`) and echoes it in the response; `QueryTasksIntent` gains a scalar `priority` filter; `UpdateTaskIntent` sets priority via the validator. `IntentHelpers.taskToDict`/`taskUpdateResponseDict` (and the milestone task sub-dicts) serialize `task.priority.rawValue` — always the computed accessor, never `priorityRawValue`, upholding the effective-priority invariant (Req 1.4). The `createTaskIntentJsonContractRemainsCompatible` contract test was updated for the new `priority` key (existing keys unchanged, so callers ignoring new keys stay compatible). - Task priority foundation (T-1463, Foundation phase): new `TaskPriority` enum (low/medium/high) mirroring `TaskType` — `tintColor` for all three, `glyphSymbol` (`nil` only for medium, the single source of truth for "no card glyph"), and `accessibilityLabel`. `TransitTask` gains a CloudKit-safe `priorityRawValue` stored property (default `medium`, same shape as `statusRawValue`/`typeRawValue`) and a computed `priority` accessor that reads any absent/empty/unrecognized raw value as `.medium` (the effective-priority invariant, Req 1.4 — no migration needed). `TaskService.createTask` (both overloads) accepts a defaulted `priority`, and `updateTask` accepts an optional `priority` where omitting it leaves the value unchanged (Decision 8, non-clearable). Automation and UI wiring follow in later phases. - Spec for the task priority feature (T-1463): requirements, design, decision log, and a 20-task implementation plan written to `specs/task-priority/`, with `specs/OVERVIEW.md` updated. Plans a low/medium/high priority field (default medium) shown as a board-card glyph for high/low (medium suppressed), a board filter, in-app create/edit/detail editing, and MCP + App Intent read/set/filter support — using a default-value + computed-accessor backfill (no migration) and a dedicated `INVALID_PRIORITY` error code. Planning only; not yet implemented. - The dashboard now shows SwiftUI's `ContentUnavailableView.search` empty state ("No Results for '<query>'") when text search is the only active filter and no task matches (T-198). Project/type/milestone filters keep the existing "No matching tasks. Clear filters" message, and an empty database still shows "No tasks yet". The search bar stays visible so the query can be edited or cleared. The choice of empty state is a pure `DashboardLogic.emptyStateKind` function (empty-DB > search-only > generic-filter precedence) with unit-test coverage in `DashboardSearchTests` and UI coverage in `SearchEmptyStateUITests`. - Spec for adding a `ContentUnavailableView.search` empty state to the dashboard when text search is the only active filter and yields no matches (T-198). Includes a smolspec, decision log (search-only-vs-generic-filter precedence, empty-database precedence, and extracting empty-state selection into a pure `DashboardLogic.emptyStateKind` function for unit-testability), and a 4-task implementation plan with distributed testing. Deferred follow-up from the text-filter feature. - `docs/agent-notes/mcp-server.md` Tools Exposed table now describes the extended `update_task` (name / description / type / metadata + milestone) with a gotcha noting the omit-vs-clear distinction and the shared validator + response-builder pipeline both surfaces use (T-650 phase 7). - `UpdateTaskAllFieldsParityTests` (T-650 phase 6): 10 cross-surface parity cases (each new field individually, combined multi-field, no-op echo, milestone assign and clear) call both `MCPToolHandler.handleUpdateTask` and `UpdateTaskIntent.execute` against a shared task fixture and assert byte-equal JSON responses. Verifies AC 8.1 in practice. All cases passed on the first run with zero production diffs — both surfaces route through `TaskUpdateValidator` + `IntentHelpers.taskUpdateResponseDict`, so structural parity is a property of the architecture rather than an emergent coincidence. - `UpdateTaskIntent` (App Intent) now accepts the same `name` / `description` / `type` / `metadata` updates as the MCP tool (T-650 phase 5). `execute()` rewritten as a thin orchestrator over `TaskUpdateValidator` + `IntentHelpers.taskUpdateResponseDict`, mirroring `MCPToolHandler.handleUpdateTask` structurally so success responses are byte-equivalent across surfaces (AC 8.1). The old `applyMilestoneChange` and `buildResponse` helpers are deleted. The `@Parameter` description enumerates every supported field including the empty-string-clears and `{}`-clears semantics. A preflight identifier guard returns `INVALID_INPUT` ("Provide either displayId or taskId") for payloads with no `taskId`/`displayId` — previously these landed as a misleading `TASK_NOT_FOUND` from the downstream resolver, including the pre-existing failing test `noTaskIdentifierReturnsInvalidInput`, which now passes. - MCP `update_task` now accepts name, description (empty/whitespace clears), type, and metadata (`{}` clears) alongside the existing milestone fields (T-650 phase 4). `MCPToolHandler.handleUpdateTask` rewritten as a thin orchestrator over `TaskUpdateValidator` + `IntentHelpers.taskUpdateResponseDict`. The two-stage do/catch (apply with explicit `taskService.rollback()`; save relying on `TaskService.save()`'s built-in `safeRollback`) keeps the task untouched when any field validation, apply, or save step fails — atomic per AC 5.1. Schema (`MCPToolDefinitions.updateTask`) extended with name/description/type/metadata properties; description and metadata prose include `"clear"` so clients know the clear semantics. Identifier-resolution preamble (T-634 / T-808) and milestone error literals are byte-equivalent to the pre-rewrite handler so existing tests pass unchanged. - `IntentHelpers.taskUpdateResponseDict` (T-650 phase 3): shared response builder for `update_task` across MCP and App Intent surfaces. Always emits `taskId`, `name`, `type`, `status`; emits `displayId`, `projectId`, `projectName`, `description`, `milestone` only when non-nil; emits `metadata` only when non-empty. Excludes `comments` and the date fields. 13 tests cover every conditional clause of AC 9.1. - `TaskUpdateValidator.validate` / `apply` / `strictStringMetadata` implementations (T-650 phase 2). `validate` parses raw MCP/Intent argument dicts into a `ValidatedTaskUpdate` covering name (trim + non-empty), description (trim, empty/whitespace clears), type (lowercase enum), metadata (strict-string, `{}` clears), and milestone (precedence `clearMilestone` → `milestoneId` → `milestoneDisplayId` → `milestone`). Milestone error literals are byte-identical to the existing MCP handler so Phase 3 wiring is drop-in. `apply` walks fields in deterministic order with `save: false`, throwing service-layer errors only — callers handle rollback via `TaskService.rollback()`. `TaskUpdateValidatorTests` covers 33 scenarios across all acceptance criteria 1–7. - `TaskUpdateValidator` scaffolding (T-650 phase 1): new `Transit/Transit/Intents/TaskUpdateValidator.swift` declares the `FieldChange<T>`, `MilestoneAction`, `ValidatedTaskUpdate`, and `TaskUpdateValidationError` value types plus stub `validate`/`apply`/`strictStringMetadata` signatures so subsequent phases can write tests against the contract. `TaskService.rollback()` wraps `modelContext.safeRollback()` for handler-level undo after a mid-update throw. - Spec for extending `update_task` MCP tool and `UpdateTaskIntent` to update name, description, type, and metadata atomically alongside the existing milestone fields (T-650). Includes requirements, design (shared `TaskUpdateValidator` with `FieldChange<T>`-based update model, new `IntentHelpers.taskUpdateResponseDict`, `TaskService.rollback` for apply-failure rollback), decision log, and a 12-task implementation plan across 7 phases. - Test for AC 8.1: an allocation failure on one duplicate group does not abort subsequent groups; the loser loop breaks for the failing group only and the next group still reassigns. - `DisplayIDMaintenanceService` is now constructed cross-platform in `TransitApp`, registered via `AppDependencyManager` for App Intents, and exposed via `.environment` on the root `NavigationStack` and `withCoreEnvironments` so `DataMaintenanceView` resolves it on iOS, the macOS Settings window, and the macOS Task Detail window. - `ScanDuplicateDisplayIDsIntent` and `ReassignDuplicateDisplayIDsIntent` App Intents for running duplicate cleanup from Shortcuts. Both reuse the same `JSONEncoder` path as the MCP tools so payloads are byte-equal across surfaces; service errors land inside the JSON envelope rather than thrown. - `DataMaintenanceView` in Settings — scan → confirm (destructive alert) → reassign → result flow, available on iOS and macOS. macOS Settings adds a "Data Maintenance" sidebar category and an "Expose maintenance tools" Toggle in the MCP Server section. - `NavigationDestination.dataMaintenance` case wires the new view into both the iOS settings stack and the macOS settings window. - UI test for the Data Maintenance golden path with seeded duplicates via a new `UITestScenario.duplicateDisplayIds` case; matched accessibility identifiers `dataMaintenance.scanButton/.reassignButton/.confirmButton/.resultList`. - MCP server exposes `scan_duplicate_display_ids` and `reassign_duplicate_display_ids` tools, gated behind a new `MCPSettings.maintenanceToolsEnabled` toggle (UserDefaults `mcpMaintenanceToolsEnabled`, default off). When the toggle is off, both tools are excluded from `tools/list` and `tools/call` returns JSON-RPC `methodNotFound` with a distinct "Tool '<name>' is disabled. Enable maintenance tools in Transit Settings." message. - `MCPToolDefinitions.tools(includingMaintenance:)` helper splits core tools from maintenance tools so the maintenance tools no longer cost MCP context for every agent session. - `DisplayIDAllocator.counterStore` accessor exposes the underlying `CounterStore` so callers needing direct counter access (e.g. `DisplayIDMaintenanceService`'s counter-advance fence) can share the allocator's store. - `DisplayIDMaintenanceService` with `scanDuplicates` and `reassignDuplicates` for repairing tasks/milestones sharing a `permanentDisplayId`. Counter advance happens before loser allocation; reassigned tasks receive a "Transit Maintenance" audit comment recording the old and new display IDs and the date. - `DisplayIDMaintenanceTypes`: `DuplicateReport`, `ReassignmentResult`, `FailureCode`, and Codable encoders matching the JSON shape shared by MCP and App Intents. - `CounterStore.advanceCounter(toAtLeast:retryLimit:)` extension with default implementation that retries on conflict and short-circuits when a racing writer has already moved the counter past the target. - Spec for duplicate display ID cleanup: requirements, design, decision log, and 18-task implementation plan in `specs/duplicate-displayid-cleanup/` - Task detail view shows creation date in secondary style on both iOS and macOS (T-755) - macOS: Task detail opens in a dedicated window instead of a sheet, with share and edit buttons in the toolbar (T-35) - macOS: New Task opens in a dedicated window instead of a sheet (T-35) - macOS: Edit Task replaces detail content in the same window instead of presenting a sheet (T-35) - macOS: `BoardBackground` applied to task detail, edit, and new task windows for consistent styling (T-35) - `TaskDetailWindowView` — macOS-only wrapper managing detail/edit state transitions within a window - `UITestScenario` extracted to its own file for cleaner `TransitApp` organisation - `withCoreEnvironments` helper in `TransitApp` to consolidate shared environment modifiers across window scenes
diff --git a/Transit/Transit/Intents/Shared/Utilities/DateFilterHelpers.swift b/Transit/Transit/Intents/Shared/Utilities/DateFilterHelpers.swiftindex 7d1fbbc..cf1b7ee 100644--- a/Transit/Transit/Intents/Shared/Utilities/DateFilterHelpers.swift+++ b/Transit/Transit/Intents/Shared/Utilities/DateFilterHelpers.swift@@ -1,149 +1,151 @@ import Foundation @MainActor enum DateFilterHelpers { enum DateRange: Equatable { case today case yesterday case thisWeek case lastWeek case thisMonth case lastMonth case thisYear case lastYear case absolute(from: Date?, toDate: Date?) } // Preserve the user's calendar identifier while fixing the locale and timezone used // for machine-readable date-only values. This matches the documented Calendar.current // semantics and keeps absolute comparisons aligned with the parser across DST changes.- private static let localDayCalendar: Calendar = {+ private static var localDayCalendar: Calendar { var calendar = Calendar.current calendar.locale = Locale(identifier: "en_US_POSIX") calendar.timeZone = TimeZone.current return calendar- }()+ } - private static let localDayFormatter: DateFormatter = {+ private static var localDayFormatter: DateFormatter { let formatter = DateFormatter()- formatter.calendar = localDayCalendar- formatter.timeZone = localDayCalendar.timeZone+ let calendar = localDayCalendar+ formatter.calendar = calendar+ formatter.timeZone = calendar.timeZone formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" formatter.isLenient = false return formatter- }()+ } private static let relativeTokens: [String: DateRange] = [ "today": .today, "yesterday": .yesterday, "this-week": .thisWeek, "last-week": .lastWeek, "this-month": .thisMonth, "last-month": .lastMonth, "this-year": .thisYear, "last-year": .lastYear ] static func parseDateFilter(_ json: [String: Any]) -> DateRange? { parseDateFilter( relative: json["relative"] as? String, from: json["from"] as? String, toDateString: json["to"] as? String ) } static func parseDateFilter( relative: String?, from: String?, toDateString: String? ) -> DateRange? { if let relative { return relativeTokens[relative] } let fromDate = from.flatMap(dateFromString) let toDate = toDateString.flatMap(dateFromString) if from != nil && fromDate == nil { return nil } if toDateString != nil && toDate == nil { return nil }+ if let fromDate, let toDate, fromDate > toDate { return nil } if from != nil || toDateString != nil { return .absolute(from: fromDate, toDate: toDate) } return nil } static func dateInRange(_ date: Date, range: DateRange, now: Date = Date()) -> Bool { let calendar = Calendar.current switch range { case .today: return calendar.isDate(date, inSameDayAs: now) case .yesterday: let startOfToday = calendar.startOfDay(for: now) guard let startOfYesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday) else { return false } return date >= startOfYesterday && date < startOfToday case .thisWeek: return dateInCurrentPeriod(date, component: .weekOfYear, now: now, calendar: calendar) case .lastWeek: return dateInPreviousPeriod(date, component: .weekOfYear, now: now, calendar: calendar) case .thisMonth: return dateInCurrentPeriod(date, component: .month, now: now, calendar: calendar) case .lastMonth: return dateInPreviousPeriod(date, component: .month, now: now, calendar: calendar) case .thisYear: return dateInCurrentPeriod(date, component: .year, now: now, calendar: calendar) case .lastYear: return dateInPreviousPeriod(date, component: .year, now: now, calendar: calendar) case .absolute(let from, let toDate): return dateInAbsoluteRange(date, from: from, toDate: toDate, calendar: localDayCalendar) } } /// "This X" ranges: from start of current period to now (inclusive). private static func dateInCurrentPeriod( _ date: Date, component: Calendar.Component, now: Date, calendar: Calendar ) -> Bool { guard let interval = calendar.dateInterval(of: component, for: now) else { return false } return date >= interval.start && date <= now } /// "Last X" ranges: full previous period with exclusive upper bound. private static func dateInPreviousPeriod( _ date: Date, component: Calendar.Component, now: Date, calendar: Calendar ) -> Bool { guard let previousDate = calendar.date(byAdding: component, value: -1, to: now), let interval = calendar.dateInterval(of: component, for: previousDate) else { return false } return date >= interval.start && date < interval.end } private static func dateInAbsoluteRange( _ date: Date, from: Date?, toDate: Date?, calendar: Calendar ) -> Bool { let day = calendar.startOfDay(for: date) if let from { let start = calendar.startOfDay(for: from) if day < start { return false } } if let toDate { let end = calendar.startOfDay(for: toDate) if day > end { return false } } return true
diff --git a/Transit/TransitTests/DateFilterHelpersTests.swift b/Transit/TransitTests/DateFilterHelpersTests.swiftindex d473a40..cb3f554 100644--- a/Transit/TransitTests/DateFilterHelpersTests.swift+++ b/Transit/TransitTests/DateFilterHelpersTests.swift@@ -1,123 +1,169 @@ import Foundation import Testing @testable import Transit @MainActor struct DateFilterHelpersTests { @Test("parse relative filter", arguments: [ ("today", DateFilterHelpers.DateRange.today), ("yesterday", DateFilterHelpers.DateRange.yesterday), ("this-week", DateFilterHelpers.DateRange.thisWeek), ("last-week", DateFilterHelpers.DateRange.lastWeek), ("this-month", DateFilterHelpers.DateRange.thisMonth), ("last-month", DateFilterHelpers.DateRange.lastMonth), ("this-year", DateFilterHelpers.DateRange.thisYear), ("last-year", DateFilterHelpers.DateRange.lastYear) ]) func parseRelativeFilter(relative: String, expected: DateFilterHelpers.DateRange) { let parsed = DateFilterHelpers.parseDateFilter(["relative": relative]) #expect(parsed == expected) } @Test func parseAbsoluteFilterFromAndTo() { let parsed = DateFilterHelpers.parseDateFilter([ "from": "2026-02-01", "to": "2026-02-11" ]) switch parsed { case .absolute(let from, let toDate): #expect(from != nil) #expect(toDate != nil) default: Issue.record("Expected absolute range") } } @Test func parseAbsoluteFilterRejectsInvalidDates() { let parsed = DateFilterHelpers.parseDateFilter([ "from": "2026-99-99" ]) #expect(parsed == nil) } + @Test func parseAbsoluteFilterRejectsReversedRanges() {+ let parsed = DateFilterHelpers.parseDateFilter([+ "from": "2026-07-20",+ "to": "2026-07-01"+ ])+ #expect(parsed == nil)+ }++ @Test func parseAbsoluteFilterUsesCurrentCalendarAndTimeZone() {+ var calendar = Calendar.current+ calendar.locale = Locale(identifier: "en_US_POSIX")+ calendar.timeZone = TimeZone.current+ let expected = calendar.date(from: DateComponents(year: 2026, month: 7, day: 20))!++ guard case .absolute(let from, let toDate) = DateFilterHelpers.parseDateFilter([+ "from": "2026-07-20",+ "to": "2026-07-20"+ ]) else {+ Issue.record("Expected an absolute range")+ return+ }+ #expect(from == expected)+ #expect(toDate == expected)+ }++ @Test func parseAbsoluteFilterAcceptsEqualEndpointsAndOneSidedBounds() {+ let sameDay = DateFilterHelpers.parseDateFilter([+ "from": "2026-07-20",+ "to": "2026-07-20"+ ])+ let fromOnly = DateFilterHelpers.parseDateFilter(["from": "2026-07-20"])+ let toOnly = DateFilterHelpers.parseDateFilter(["to": "2026-07-20"])++ guard case .absolute(let sameDayFrom, let sameDayTo) = sameDay,+ case .absolute(let lowerBound, let openUpperBound) = fromOnly,+ case .absolute(let openLowerBound, let upperBound) = toOnly else {+ Issue.record("Expected absolute ranges")+ return+ }+ #expect(sameDayFrom == sameDayTo)+ #expect(lowerBound != nil)+ #expect(openUpperBound == nil)+ #expect(openLowerBound == nil)+ #expect(upperBound != nil)+ }+ @Test("parse absolute filter accepts real Gregorian leap-day dates") func parseAbsoluteFilterAcceptsLeapDay() { let parsed = DateFilterHelpers.parseDateFilter([ "from": "2024-02-29", "to": "2024-03-01" ]) guard case .absolute(let from, let toDate) = parsed else { Issue.record("Expected absolute range") return } #expect(from != nil) #expect(toDate != nil) } @Test("parse absolute filter rejects non-calendar and non-ASCII date strings", arguments: [ "2023-02-29", // Non-leap year day overflow "2024-02-30", // Leap-year month day overflow "2026-04-31", // Month day overflow "2026-00-01", // Month below lower boundary "2026-13-01", // Month above upper boundary "2026-02-00", // Day below lower boundary "0000-01-01", // Gregorian year below lower boundary "2026-2-01", // Full-width non-ASCII digit "2026−02−01" // Unicode minus separators ]) func parseAbsoluteFilterRejectsInvalidCalendarDateStrings(value: String) { #expect(DateFilterHelpers.parseDateFilter(["from": value]) == nil) } @Test("parse absolute filter rejects non-padded date strings", arguments: [ "2026-2-01", "2026-02-1", "2026-2-1" ]) func parseAbsoluteFilterRejectsNonPaddedDateStrings(value: String) { #expect(DateFilterHelpers.parseDateFilter(["from": value]) == nil) } @Test func absoluteRangeComparisonIsInclusive() { let calendar = Calendar.current let from = calendar.startOfDay(for: Date.now) let toDate = from.addingTimeInterval(2 * 24 * 60 * 60) let range = DateFilterHelpers.DateRange.absolute(from: from, toDate: toDate) #expect(DateFilterHelpers.dateInRange(from, range: range)) #expect(DateFilterHelpers.dateInRange(toDate, range: range)) } @Test func todayRangeIncludesCurrentDayAndExcludesPreviousDay() { let calendar = Calendar.current let now = Date.now let yesterday = calendar.date(byAdding: .day, value: -1, to: now)! #expect(DateFilterHelpers.dateInRange(now, range: .today, now: now)) #expect(!DateFilterHelpers.dateInRange(yesterday, range: .today, now: now)) } @Test func thisWeekStartsAtLocaleWeekBoundary() { let calendar = Calendar.current let now = Date.now let startOfWeek = calendar.dateInterval(of: .weekOfYear, for: now)!.start let dayBeforeWeek = calendar.date(byAdding: .day, value: -1, to: startOfWeek)! #expect(DateFilterHelpers.dateInRange(startOfWeek, range: .thisWeek, now: now)) #expect(!DateFilterHelpers.dateInRange(dayBeforeWeek, range: .thisWeek, now: now)) } @Test func thisMonthStartsAtMonthBoundary() { let calendar = Calendar.current let now = Date.now let monthStart = calendar.dateInterval(of: .month, for: now)!.start let dayBeforeMonth = calendar.date(byAdding: .day, value: -1, to: monthStart)! #expect(DateFilterHelpers.dateInRange(monthStart, range: .thisMonth, now: now)) #expect(!DateFilterHelpers.dateInRange(dayBeforeMonth, range: .thisMonth, now: now)) } // MARK: - Yesterday
diff --git a/Transit/TransitTests/FindTasksIntentTests.swift b/Transit/TransitTests/FindTasksIntentTests.swiftindex f7f7def..f8c82ae 100644--- a/Transit/TransitTests/FindTasksIntentTests.swift+++ b/Transit/TransitTests/FindTasksIntentTests.swift@@ -215,96 +215,120 @@ struct FindTasksIntentTests { let dayMinus1 = calendar.date(byAdding: .day, value: -1, to: today)! let dayMinus2 = calendar.date(byAdding: .day, value: -2, to: today)! _ = makeTask( in: env.context, project: project, seed: FindTaskSeed( name: "In Range", displayID: 1, type: .feature, status: .inProgress, lastStatusChangeDate: dayMinus1 ) ) _ = makeTask( in: env.context, project: project, seed: FindTaskSeed( name: "Out Of Range", displayID: 2, type: .feature, status: .inProgress, lastStatusChangeDate: dayMinus2 ) ) let result = try FindTasksIntent.execute( filters: makeFindFilters( lastStatusChangeDateFilter: .customRange, lastStatusChangeFromDate: dayMinus1, lastStatusChangeToDate: today ), taskService: env.taskService ) #expect(result.count == 1) #expect(result[0].name == "In Range") } @Test func executeLimitsResultsToTwoHundred() throws { let env = try makeEnv() let project = makeProject(in: env.context, name: "Many") let base = Date.now for index in 0..<205 { _ = makeTask( in: env.context, project: project, seed: FindTaskSeed( name: "Task \(index)", displayID: index + 1, type: .feature, status: .idea, lastStatusChangeDate: base.addingTimeInterval(TimeInterval(index)) ) ) } let result = try FindTasksIntent.execute(filters: makeFindFilters(), taskService: env.taskService) #expect(result.count == 200) #expect(result.first?.name == "Task 204") #expect(result.last?.name == "Task 5") } @Test func executeReturnsEmptyArrayWhenNoMatches() throws { let env = try makeEnv() let project = makeProject(in: env.context, name: "Main") _ = makeTask( in: env.context, project: project, seed: FindTaskSeed( name: "Task", displayID: 1, type: .feature, status: .idea, lastStatusChangeDate: .now ) ) let result = try FindTasksIntent.execute( filters: makeFindFilters(status: .abandoned), taskService: env.taskService ) #expect(result.isEmpty) } - @Test func executeThrowsInvalidDateWhenCustomRangeIsInverted() throws {+ @Test func executeThrowsExactInvalidDateForInvertedCompletionRange() throws { let env = try makeEnv() let today = Calendar.current.startOfDay(for: .now) let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!- #expect(throws: VisualIntentError.self) {++ do { _ = try FindTasksIntent.execute( filters: makeFindFilters( completionDateFilter: .customRange, completionFromDate: tomorrow, completionToDate: today ), taskService: env.taskService )+ Issue.record("Expected an inverted completion range to throw")+ } catch let error as VisualIntentError {+ #expect(error == .invalidDate("completionDate from date must be before to date"))+ }+ }++ @Test func executeThrowsExactInvalidDateForInvertedLastStatusChangeRange() throws {+ let env = try makeEnv()+ let today = Calendar.current.startOfDay(for: .now)+ let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!++ do {+ _ = try FindTasksIntent.execute(+ filters: makeFindFilters(+ lastStatusChangeDateFilter: .customRange,+ lastStatusChangeFromDate: tomorrow,+ lastStatusChangeToDate: today+ ),+ taskService: env.taskService+ )+ Issue.record("Expected an inverted last-status-change range to throw")+ } catch let error as VisualIntentError {+ #expect(error == .invalidDate("lastStatusChangeDate from date must be before to date")) } } }
diff --git a/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift b/Transit/TransitTests/QueryTasksIntentDateFilterTests.swiftindex 22e40b5..18efdf7 100644--- a/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift+++ b/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift@@ -176,134 +176,191 @@ struct QueryTasksIntentDateFilterTests { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = TimeZone.current let fromDateString = dateFormatter.string(from: oldFrom) let toDateString = dateFormatter.string(from: oldTo) let input = """ {"completionDate":{"relative":"today","from":"\(fromDateString)","to":"\(toDateString)"}} """ let result = QueryTasksIntent.execute( input: input, projectService: svc.project, taskService: svc.task, milestoneService: svc.milestone ) let parsed = try parseJSONArray(result) #expect(parsed.count == 1) #expect(parsed.first?["name"] as? String == "Today Done") } @Test func completionDateFromOnlyKeepsOpenUpperBound() throws { let svc = try makeServices() let project = makeProject(in: svc.context) let calendar = Calendar.current let today = calendar.startOfDay(for: Date.now) let fromDate = calendar.date(byAdding: .day, value: -1, to: today)! let beforeFrom = calendar.date(byAdding: .day, value: -2, to: today)! makeTask(in: svc.context, project: project, name: "Before From", displayId: 1, completionDate: beforeFrom) makeTask(in: svc.context, project: project, name: "At From", displayId: 2, completionDate: fromDate) makeTask(in: svc.context, project: project, name: "After From", displayId: 3, completionDate: today) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = TimeZone.current let fromDateString = dateFormatter.string(from: fromDate) let result = QueryTasksIntent.execute( input: "{\"completionDate\":{\"from\":\"\(fromDateString)\"}}", projectService: svc.project, taskService: svc.task, milestoneService: svc.milestone ) let names = Set(try parseJSONArray(result).compactMap { $0["name"] as? String }) #expect(names == ["At From", "After From"]) } @Test func completionDateToOnlyKeepsOpenLowerBound() throws { let svc = try makeServices() let project = makeProject(in: svc.context) let calendar = Calendar.current let today = calendar.startOfDay(for: Date.now) let toDate = calendar.date(byAdding: .day, value: -1, to: today)! let beforeTo = calendar.date(byAdding: .day, value: -2, to: today)! makeTask(in: svc.context, project: project, name: "Before To", displayId: 1, completionDate: beforeTo) makeTask(in: svc.context, project: project, name: "At To", displayId: 2, completionDate: toDate) makeTask(in: svc.context, project: project, name: "After To", displayId: 3, completionDate: today) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = TimeZone.current let toDateString = dateFormatter.string(from: toDate) let result = QueryTasksIntent.execute( input: "{\"completionDate\":{\"to\":\"\(toDateString)\"}}", projectService: svc.project, taskService: svc.task, milestoneService: svc.milestone ) let names = Set(try parseJSONArray(result).compactMap { $0["name"] as? String }) #expect(names == ["Before To", "At To"]) } + @Test func absoluteSameDayRangeIncludesTasksOnThatDay() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let calendar = Calendar.current+ let today = calendar.startOfDay(for: Date.now)+ let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!++ makeTask(+ in: svc.context,+ project: project,+ name: "Today",+ displayId: 1,+ completionDate: today.addingTimeInterval(12 * 60 * 60)+ )+ makeTask(+ in: svc.context,+ project: project,+ name: "Yesterday",+ displayId: 2,+ completionDate: yesterday.addingTimeInterval(12 * 60 * 60)+ )++ let dateFormatter = DateFormatter()+ dateFormatter.dateFormat = "yyyy-MM-dd"+ dateFormatter.locale = Locale(identifier: "en_US_POSIX")+ dateFormatter.timeZone = TimeZone.current+ let dateString = dateFormatter.string(from: today)++ let result = QueryTasksIntent.execute(+ input: "{\"completionDate\":{\"from\":\"\(dateString)\",\"to\":\"\(dateString)\"}}",+ projectService: svc.project,+ taskService: svc.task,+ milestoneService: svc.milestone+ )++ let names = Set(try parseJSONArray(result).compactMap { $0["name"] as? String })+ #expect(names == ["Today"])+ }++ @Test("QueryTasksIntent rejects reversed absolute date ranges for both date fields")+ func reversedAbsoluteDateRangesReturnInvalidInput() throws {+ let svc = try makeServices()++ for field in ["completionDate", "lastStatusChangeDate"] {+ let result = QueryTasksIntent.execute(+ input: "{\"\(field)\":{\"from\":\"2026-07-20\",\"to\":\"2026-07-01\"}}",+ projectService: svc.project,+ taskService: svc.task,+ milestoneService: svc.milestone+ )++ let parsed = try parseJSON(result)+ #expect(parsed["error"] as? String == "INVALID_INPUT")+ #expect(parsed["hint"] as? String == "Invalid \(field) filter format")+ }+ }+ @Test func invalidDateFilterReturnsInvalidInputError() throws { let svc = try makeServices() let result = QueryTasksIntent.execute( input: "{\"completionDate\":{\"from\":\"2026-99-99\"}}", projectService: svc.project, taskService: svc.task, milestoneService: svc.milestone ) let parsed = try parseJSON(result) #expect(parsed["error"] as? String == "INVALID_INPUT") } @Test("QueryTasksIntent rejects malformed absolute date strings", arguments: [ "2026-02-30", // Normalized invalid day "2026-13-01", // Invalid month boundary "2026-2-01", // Non-padded month "2026-02-1" // Non-ASCII digit ]) func malformedAbsoluteDateStringsReturnInvalidInput(value: String) throws { let svc = try makeServices() for field in ["completionDate", "lastStatusChangeDate"] { let result = QueryTasksIntent.execute( input: "{\"\(field)\":{\"from\":\"\(value)\"}}", projectService: svc.project, taskService: svc.task, milestoneService: svc.milestone ) let parsed = try parseJSON(result) #expect(parsed["error"] as? String == "INVALID_INPUT") } } @Test func existingQueriesRemainCompatibleWithoutDateFilters() throws { let svc = try makeServices() let project = makeProject(in: svc.context) makeTask(in: svc.context, project: project, name: "Idea A", displayId: 1, status: .idea) makeTask(in: svc.context, project: project, name: "Planning B", displayId: 2, status: .planning) let result = QueryTasksIntent.execute( input: "{\"status\":\"idea\"}", projectService: svc.project, taskService: svc.task, milestoneService: svc.milestone ) let parsed = try parseJSONArray(result) #expect(parsed.count == 1) #expect(parsed.first?["name"] as? String == "Idea A") } }
diff --git a/specs/bugfixes/query-tasks-intent-accepts-reversed-absolute-date-ranges/report.md b/specs/bugfixes/query-tasks-intent-accepts-reversed-absolute-date-ranges/report.mdnew file mode 100644index 0000000..1a242d3--- /dev/null+++ b/specs/bugfixes/query-tasks-intent-accepts-reversed-absolute-date-ranges/report.md@@ -0,0 +1,89 @@+# Bugfix Report: QueryTasksIntent Accepts Reversed Absolute Date Ranges++**Date:** 2026-08-02+**Status:** Fixed++## Description of the Issue++`QueryTasksIntent` accepts an absolute date filter whose `from` date is later than its `to` date. The parser validates each date independently, then returns a range that can never match a task, so callers receive an empty array instead of the established `INVALID_INPUT` response. The visual `FindTasksIntent` already rejects inverted custom ranges.++**Reproduction steps:**+1. Call `QueryTasksIntent` with `{"completionDate":{"from":"2026-07-20","to":"2026-07-01"}}` or the equivalent `lastStatusChangeDate` filter.+2. Observe that the JSON intent returns `[]` instead of an `INVALID_INPUT` error.++**Impact:** Medium. CLI, Shortcuts, and other JSON callers can silently accept caller mistakes and interpret an empty result as valid data. Both task date fields are affected.++## Investigation Summary++The shared date-filter path and the visual path were inspected before implementation.++- **Symptoms examined:** Reversed absolute ranges return no matches; valid one-sided, same-day, relative, and strict calendar-date cases already have regression coverage.+- **Code inspected:** `DateFilterHelpers.parseDateFilter`, `QueryTasksIntent.validateDateFilters`, `QueryTasksIntent.dateRange`, `FindTasksIntent.buildDateRange`, and their tests.+- **Hypotheses tested:** The defect is not caused by date parsing or date inclusivity. T-1799's strict `YYYY-MM-DD` parsing is intact. The missing check is endpoint ordering after both absolute dates parse.++## Discovered Root Cause++`DateFilterHelpers.parseDateFilter` converts `from` and `to` independently and returns `.absolute` whenever either endpoint is present, but it never rejects the case where both parsed endpoints exist and `from > to`. `QueryTasksIntent` treats a non-nil parsed range as valid, while `FindTasksIntent` performs the ordering check for custom ranges in its own builder.++**Defect type:** Missing validation / cross-surface contract mismatch.++**Why it occurred:** Absolute endpoint parsing and range ordering were implemented as separate concerns, and only the visual App Intent added an explicit ordering guard.++**Contributing factors:** The range matcher correctly treats bounds inclusively, so a reversed range naturally produces zero matches without surfacing an error. The shared helper is the correct parity point for JSON and visual consumers, but the visual path currently validates before calling it.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Intents/Shared/Utilities/DateFilterHelpers.swift` - After strict parsing and invalid-endpoint checks, reject an absolute range only when both parsed endpoints exist and `fromDate > toDate`. Resolve the local-day calendar and formatter for each operation so changes to the user's current calendar or time zone are honored consistently by parsing and filtering.+- `Transit/TransitTests/DateFilterHelpersTests.swift` - Verify reversed ranges are rejected, equal endpoints remain valid, and one-sided bounds remain open.+- `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift` - Verify both JSON date fields return `INVALID_INPUT` for reversed ranges and that an equal same-day range remains inclusive, with exact error hints.+- `Transit/TransitTests/FindTasksIntentTests.swift` - Verify the visual surface rejects an inverted custom range for both date fields with the exact established `VisualIntentError` values, complementing the shared-parser coverage.+- `CHANGELOG.md` - Record the cross-surface validation fix and preserved behaviors.++**Approach rationale:** Range ordering belongs at the shared parser boundary because QueryTasksIntent uses it for both date fields and the helper defines the absolute-range contract. The guard is strictly conditional on two parsed endpoints, so one-sided ranges, equal same-day ranges, relative precedence, and T-1799's strict parsing are unaffected. The visual surface retains its established `VisualIntentError` behavior.++**Alternatives considered:**+- Add ordering checks separately in `QueryTasksIntent` for each field - rejected because it duplicates shared range semantics and leaves other consumers vulnerable to the same mismatch.+- Normalize reversed endpoints by swapping them - rejected because caller mistakes must produce `INVALID_INPUT`, matching `FindTasksIntent` rather than silently changing the requested range.++## Regression Test++**Test files:**+- `Transit/TransitTests/DateFilterHelpersTests.swift`+- `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift`+- `Transit/TransitTests/FindTasksIntentTests.swift`++**What it verifies:** Reversed absolute ranges are rejected by the shared parser, produce `INVALID_INPUT` for both `completionDate` and `lastStatusChangeDate` in `QueryTasksIntent`, and remain rejected by the visual intent for both date fields. Existing tests continue to cover open bounds, inclusive same-day/range behavior, relative filters, and T-1799 strict date parsing.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Intents/Shared/Utilities/DateFilterHelpers.swift` | Validate absolute endpoint ordering. |+| `Transit/TransitTests/DateFilterHelpersTests.swift` | Add shared-parser regression. |+| `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift` | Add JSON `INVALID_INPUT` regressions for both fields. |+| `Transit/TransitTests/FindTasksIntentTests.swift` | Add last-status visual parity regression. |+| `CHANGELOG.md` | Document the user-visible validation fix. |++## Verification++**Automated:**+- [x] Regression tests pass - focused macOS run passed `DateFilterHelpersTests`, `QueryTasksIntentDateFilterTests`, and `FindTasksIntentTests`.+- [x] Full unit test suite passes - `make test-quick` and an iOS Simulator run of `-only-testing:TransitTests` both exited successfully.+- [x] Linters/validators pass - `make lint` reported 0 violations in 319 files and all ownership-guard checks passed.+- [ ] Full `make test` target passes - the build and unit tests ran, but the target reported three unrelated UI failures: `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`, alongside repeated simulator `no debugger version` errors. No changed unit test failed.++**Manual verification:**+- Reviewed the final diff to confirm the guard runs only when both strict absolute endpoints parse, rejects only `from > to`, accepts equal endpoints, and leaves one-sided/open ranges, relative precedence, and T-1799 strict parsing unchanged.++## Prevention++- Keep absolute-range ordering validation in the shared date-filter parser used by all surfaces.+- Retain cross-surface tests for both task date fields whenever date-filter contracts change.++## Related++- T-1799: strict absolute calendar-date parsing.+- T-1819: this bugfix.
HEAD is 5e478752f826c8569be91e2c5266e83d65693ac6; PR #211 reports the same head and base 9676a80c2cae6a3cad6ce0b43806b52bcdea9662. The worktree status is clean and git diff --check reports no whitespace errors. The primary dirty checkout was out of scope.
make lint passed, including the model-container ownership guard. The latest macOS result bundle for make test-quick reports 1,678 passed / 0 failed / 0 skipped. The focused DateFilterHelpersTests, QueryTasksIntentDateFilterTests, and FindTasksIntentTests run reports 46 passed / 0 failed / 0 skipped.
Per request, the prior exact-candidate full-iOS evidence is reused rather than rerunning the known-environment failure. It reports 1,198 passed / 3 failed / 0 skipped; the only failures are TransitUITests.testClearAll, TransitUITests.testEditViewPreservesTaskMilestone, and DataMaintenanceUITests.testDataMaintenanceGoldenPath. The branch report records no changed date-filter test failure. Base commit 9676a80 is T-1883 and changes only MCP handler/tests/docs, so it cannot affect iOS date-filter semantics.
GitHub PR #211 is open and mergeable. The latest remote claude-review check passes at run 30739499762. GitHub API verification reports 0 submitted reviews, 0 diff comments, 0 total review threads, and 0 unresolved threads.
The guard is ordered after strict endpoint parsing and after relative-token precedence. It rejects only from > to; malformed inputs remain invalid, equal endpoints remain valid, one-sided bounds remain open, and day-level comparisons continue to use the current local calendar/time zone. The visual intent's native error remains unchanged.