Adds a low/medium/high task priority field across the data model, services, MCP server, App Intents, and the kanban dashboard — using a default-value + computed-accessor backfill (no CloudKit migration) and a dedicated INVALID_PRIORITY error code.
TaskPriority enum owns its presentation (color, card glyph, accessibility label, display order); medium has no glyph (single source of truth).create_task/query_tasks/update_task and the JSON App Intents all read/set/filter priority; dedicated INVALID_PRIORITY code.CreateTaskIntent JSON contract test needed updating for the new priority key (existing keys unchanged → backward-compatible).CLAUDE.md spots, updated the MCP tools note, and added a displayOrder ordering test.Ready to push
Four parallel review agents (reuse, quality, efficiency, spec/docs) found no correctness, reuse, or efficiency issues. The implementation faithfully mirrors the existing type field, and the load-bearing effective-priority invariant (Req 1.4) holds on every read surface, guarded by a dedicated cross-surface regression test. The only actionable findings were documentation-accuracy gaps in CLAUDE.md and an untested ordering constant — all fixed in this review. make test-quick is green (1356 checks) and make lint is clean.
f2cc3ae T-1463: add implementation.md (three-level explanation + completeness) e1cc72c T-1463: pre-push review fixes — doc accuracy + displayOrder test 74387b5 T-1463: add agent-note on sandbox xcodebuild build/test wedge 2e6e9f6 T-1463: changelog for Verification phase; mark spec Done 8c4c491 T-1463: Add effective-priority invariant cross-surface regression test c6aca5f T-1463: changelog for UI phase 6443723 T-1463: Add task priority UI (filter, card glyph, pickers, detail row) 628245e T-1463: changelog for Automation phase 0a00739 T-1463: update CreateTaskIntent contract test for priority key fa5b218 [merge]: phase Automation stream 1 9cec15b T-1463: Add task priority to MCP and App Intent surfaces (Automation phase) e77edc4 T-1463: mark task-priority spec In Progress d707fe3 T-1463: changelog for Foundation phase de0c464 T-1463: Add task priority foundation (model, enum, service) 0778314 T-1463: Add task priority spec (requirements, design, tasks) Every task now has a priority: low, medium, or high (new tasks default to medium). Task cards show a red up-arrow for high and a blue down-arrow for low; medium shows nothing so the board stays clean. You can filter the board by priority, set it when creating/editing a task, and read/set it from automation (MCP + Shortcuts).
Before this, every task looked equally urgent. Priority lets you (and your agents) see what to do next at a glance and filter the board to what matters.
The guiding principle was mirror the existing type field exactly: an enum owning its presentation, a *RawValue stored property with a computed accessor, service params, MCP/intent plumbing, a filter menu, a picker, and a detail row. A reviewer who knows type already knows priority.
TaskPriority enum; TransitTask.priorityRawValue (CloudKit-safe default "medium") + computed priority accessor; TaskService create/update params.INVALID_PRIORITY; TaskUpdateValidator.validatePriority in the shared validate/apply pipeline used by both MCP update_task and UpdateTaskIntent; create/query/update wired across MCP and App Intents.PriorityFilterMenu (mirrors TypeFilterMenu), PriorityIndicator card glyph, pickers, detail row.Default-value backfill avoids a risky CloudKit schema migration, at the cost of "effective priority" being a computed concept that every read surface must honor (enforced by test, not types). Priority is non-clearable on update (omit = unchanged), simpler than a tri-state.
Because legacy tasks store no priority, priority is defined as TaskPriority(rawValue: priorityRawValue) ?? .medium, computed at every read. The failure mode is a copy-paste reading priorityRawValue directly on one surface, which would drop legacy tasks from a medium filter. Mitigation is structural: the raw value is read only in the accessor/init/setter; EffectivePriorityInvariantTests asserts an empty-raw-value task reads/serializes/filters as medium across all five read surfaces plus the accessor.
TaskPriority.glyphSymbol returns nil for medium; the view never re-decides.INVALID_INPUT; bad level → INVALID_PRIORITY), matching validateType exactly.update_task/UpdateTaskIntent parity is automatic via the shared validator + response builder (byte-equivalent responses; UpdateTaskAllFieldsParityTests).priority key; existing keys unchanged.IntentHelpers.swift / TaskUpdateValidator.swift now carry file_length disables and trend toward a future split.AppEnum/Sendable conformance — visual App Intents / TaskEntity deliberately omit priority for now.Transit/Transit/Models/TaskPriority.swift
Why it matters. Central type for the whole feature. Owns color, card glyph, accessibility label, and display order, so every surface asks the enum rather than re-deciding.
What to look at. TaskPriority.swift:1-41
Transit/Transit/Models/TransitTask.swift
Why it matters. The load-bearing design choice. Legacy/empty/unknown stored values read as medium, avoiding a CloudKit migration — but only if every read goes through this accessor.
What to look at. TransitTask.swift: priorityRawValue + computed priority
Transit/Transit/Intents/TaskUpdateValidator.swift
Why it matters. Gives MCP update_task and UpdateTaskIntent identical, byte-equivalent priority-update semantics for free.
What to look at. TaskUpdateValidator.swift: validatePriority + ValidatedTaskUpdate.priority
Transit/Transit/MCP/MCPHelperTypes.swift
Why it matters. Five read surfaces (board, MCP filter/serialize, intent filter/serialize) must all reflect the effective priority.
What to look at. MCPHelperTypes.swift priorities filter; IntentHelpers taskToDict/taskUpdateResponseDict
Transit/Transit/Views/Dashboard/DashboardView.swift
Why it matters. Adds the only new hot-path logic (per-task filter predicate) and restructures the filter inputs.
What to look at. PriorityFilterMenu.swift + DashboardView FilterSelection / matchesFilters
Transit/TransitTests/EffectivePriorityInvariantTests.swift
Why it matters. Turns the otherwise type-unenforced invariant into a guarded property.
What to look at. EffectivePriorityInvariantTests.swift:1-162
Stored priorityRawValue: String = "medium" plus a computed accessor that reads empty/unknown as medium, instead of a schema migration. CloudKit migrations are add-only and risky; this is migration-free and reversible. Cost: 'effective priority' is computed, so every read surface must use the accessor.
Priority gets its own error code rather than reusing INVALID_TYPE, wired into both IntentError and the validator's MCP/intent error mapping, so callers can distinguish a bad priority from a bad type.
updateTask's priority is Optional where nil means 'leave unchanged', not 'reset to default'. There is no way to unset priority — it is always one of the three values.
TaskPriority.glyphSymbol returns nil for medium; PriorityIndicator renders nothing when nil. The view never re-decides suppression.
Pickers and the board filter iterate a dedicated displayOrder array, not allCases (source order), so the most-actionable value reads first. Now locked in by a test.
A near-verbatim copy with the intended divergence (displayOrder, per-case dot color). Two similar menus don't justify a generic EnumFilterMenu<E>; a third would.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | CLAUDE.md:204 (Key Design Decisions) | Stated 'no priority field ... in V1' — now false; directly contradicts shipped behavior. | Removed the stale claim; added a positive description of the priority field (default/backfill/surfaces). |
| minor | CLAUDE.md:127 (error codes) | Error-code list omitted INVALID_PRIORITY while listing every other code. | Added INVALID_PRIORITY to the list. |
| minor | CLAUDE.md:83,118 (filter/intent lists) | Filter-menu list and QueryTasksIntent filter list omitted priority. | Added priority to both. |
| nit | docs/agent-notes/mcp-server.md (Tools Exposed) | create_task/update_task/query_tasks descriptions didn't mention priority, against the table's kept-current precedent. | Updated all three rows to note priority semantics. |
| nit | TaskPriority.displayOrder | User-visible high→medium→low ordering was only covered by construction, not a direct test. | Added displayOrderIsHighToLow test. |
| nit | TaskUpdateValidator.validatePriority | A non-string priority surfaces as INVALID_INPUT, not INVALID_PRIORITY. | Intentional parity with validateType across all four surfaces — left as-is. |
| nit | IntentHelpers.swift / TaskUpdateValidator.swift | Both now carry swiftlint file_length disables and are growing by accretion. | Pre-existing trend, out of scope for this feature; noted for a future split. |
| nit | UI accessibility identifiers | New filter/card/picker accessibility IDs aren't asserted by a UI test. | Consistent with the project's existing practice of not UI-testing SwiftUI view internals. |
Click to expand.
diff --git a/Transit/Transit/Intents/CreateTaskIntent.swift b/Transit/Transit/Intents/CreateTaskIntent.swiftindex cb90695..36effc5 100644--- a/Transit/Transit/Intents/CreateTaskIntent.swift+++ b/Transit/Transit/Intents/CreateTaskIntent.swift@@ -22,6 +22,7 @@ struct CreateTaskIntent: AppIntent { JSON object with task details. Required fields: "name" (string), "type" (bug | feature | chore | \ research | documentation), and at least one of "projectId" (UUID) or "project" (name) to identify \ the project. Optional: "description" (string), \+ "priority" (low | medium | high; defaults to medium), \ "metadata" (object with string values; non-string values are ignored), "milestone" (name), \ "milestoneDisplayId" (integer). \ Example: {"name": "Fix login", "type": "bug", "project": "Alpha", "milestoneDisplayId": 1}@@ -33,6 +34,7 @@ struct CreateTaskIntent: AppIntent { JSON object with task details. Required fields: "name" (string), "type" (bug | feature | chore | \ research | documentation), and at least one of "projectId" (UUID) or "project" (name) to identify \ the project. Optional: "description" (string), \+ "priority" (low | medium | high; defaults to medium), \ "metadata" (object with string values; non-string values are ignored), "milestone" (name), \ "milestoneDisplayId" (integer). \ Example: {"name": "Fix login", "type": "bug", "project": "Alpha", "milestoneDisplayId": 1}@@ -63,7 +65,7 @@ struct CreateTaskIntent: AppIntent { // MARK: - Logic (testable without @Dependency) @MainActor- // swiftlint:disable:next cyclomatic_complexity+ // swiftlint:disable:next cyclomatic_complexity function_body_length static func execute( input: String, taskService: TaskService,@@ -81,6 +83,15 @@ struct CreateTaskIntent: AppIntent { let typeRaw = json["type"] as! String // swiftlint:disable:this force_cast let taskType = TaskType(rawValue: typeRaw)! // swiftlint:disable:this force_unwrapping + // Priority is optional and defaults to medium. Unlike the required `type`+ // force-unwrap above, this is an optional-with-default path: absent -> .medium;+ // present-and-invalid -> INVALID_PRIORITY (Decision 9); present-and-valid -> parse.+ let priority: TaskPriority+ switch parsePriority(json) {+ case .failure(let error): return error.json+ case .success(let parsed): priority = parsed+ }+ // Resolve project: projectId takes precedence over project name. // Validate key presence separately from string/UUID parsing so non-string // values (numbers, bools, etc.) are rejected too [T-743, T-788].@@ -117,7 +128,8 @@ struct CreateTaskIntent: AppIntent { description: json["description"] as? String, type: taskType, project: project,- metadata: IntentHelpers.stringMetadata(from: json["metadata"])+ metadata: IntentHelpers.stringMetadata(from: json["metadata"]),+ priority: priority ) } catch { return IntentError.invalidInput(hint: "Task creation failed").json@@ -145,7 +157,9 @@ struct CreateTaskIntent: AppIntent { private static func buildResponse(_ task: TransitTask) -> String { var response: [String: Any] = [ "taskId": task.id.uuidString,- "status": task.statusRawValue+ "status": task.statusRawValue,+ // Effective-priority invariant (Req 1.4): echo the computed accessor.+ "priority": task.priority.rawValue ] if let displayId = task.permanentDisplayId { response["displayId"] = displayId@@ -212,6 +226,20 @@ struct CreateTaskIntent: AppIntent { return (nil, nil) } + /// Parses the optional `priority` field. Absent -> `.medium`;+ /// present-but-non-string -> INVALID_INPUT; present-but-unknown -> INVALID_PRIORITY+ /// (Decision 9). Priority is non-clearable, so there is no "absent = clear" case.+ private static func parsePriority(_ json: [String: Any]) -> Result<TaskPriority, IntentError> {+ guard let raw = json["priority"] else { return .success(.medium) }+ guard let str = raw as? String else {+ return .failure(.invalidInput(hint: "priority must be a string"))+ }+ guard let priority = TaskPriority(rawValue: str) else {+ return .failure(.invalidPriority(hint: "Unknown priority: \(str)"))+ }+ return .success(priority)+ }+ private static func validateInput(_ json: [String: Any]) -> IntentError? { guard let name = json["name"] as? String, !name.isEmpty else { return .invalidInput(hint: "Missing required field: name")
diff --git a/Transit/Transit/Intents/IntentError.swift b/Transit/Transit/Intents/IntentError.swiftindex ac34e92..ca92786 100644--- a/Transit/Transit/Intents/IntentError.swift+++ b/Transit/Transit/Intents/IntentError.swift@@ -6,6 +6,7 @@ nonisolated enum IntentError: Error { case ambiguousProject(hint: String) case invalidStatus(hint: String) case invalidType(hint: String)+ case invalidPriority(hint: String) case invalidInput(hint: String) case milestoneNotFound(hint: String) case duplicateMilestoneName(hint: String)@@ -19,6 +20,7 @@ nonisolated enum IntentError: Error { case .ambiguousProject: "AMBIGUOUS_PROJECT" case .invalidStatus: "INVALID_STATUS" case .invalidType: "INVALID_TYPE"+ case .invalidPriority: "INVALID_PRIORITY" case .invalidInput: "INVALID_INPUT" case .milestoneNotFound: "MILESTONE_NOT_FOUND" case .duplicateMilestoneName: "DUPLICATE_MILESTONE_NAME"@@ -34,6 +36,7 @@ nonisolated enum IntentError: Error { .ambiguousProject(let hint), .invalidStatus(let hint), .invalidType(let hint),+ .invalidPriority(let hint), .invalidInput(let hint), .milestoneNotFound(let hint), .duplicateMilestoneName(let hint),
diff --git a/Transit/Transit/Intents/IntentHelpers.swift b/Transit/Transit/Intents/IntentHelpers.swiftindex 40fef1f..b838bf3 100644--- a/Transit/Transit/Intents/IntentHelpers.swift+++ b/Transit/Transit/Intents/IntentHelpers.swift@@ -1,7 +1,7 @@ import CoreFoundation import Foundation -// swiftlint:disable type_body_length+// swiftlint:disable type_body_length file_length /// Shared utilities for App Intent JSON parsing and response encoding. /// Nonisolated because these are pure functions that only use Foundation types. nonisolated enum IntentHelpers {@@ -265,6 +265,9 @@ nonisolated enum IntentHelpers { "name": task.name, "status": task.statusRawValue, "type": task.typeRawValue,+ // Effective-priority invariant (Req 1.4): serialize the computed accessor,+ // NOT priorityRawValue, so legacy tasks read as "medium".+ "priority": task.priority.rawValue, "lastStatusChangeDate": formatter.string(from: task.lastStatusChangeDate) ] if let displayId = task.permanentDisplayId {@@ -310,6 +313,8 @@ nonisolated enum IntentHelpers { "taskId": task.id.uuidString, "name": task.name, "type": task.typeRawValue,+ // Effective-priority invariant (Req 1.4): computed accessor, NOT priorityRawValue.+ "priority": task.priority.rawValue, "status": task.statusRawValue ] if let displayId = task.permanentDisplayId {@@ -395,4 +400,4 @@ nonisolated enum IntentHelpers { return nil } }-// swiftlint:enable type_body_length+// swiftlint:enable type_body_length file_length
diff --git a/Transit/Transit/Intents/QueryMilestonesIntent.swift b/Transit/Transit/Intents/QueryMilestonesIntent.swiftindex b570993..0ac6a80 100644--- a/Transit/Transit/Intents/QueryMilestonesIntent.swift+++ b/Transit/Transit/Intents/QueryMilestonesIntent.swift@@ -206,7 +206,9 @@ struct QueryMilestonesIntent: AppIntent { "taskId": task.id.uuidString, "name": task.name, "status": task.statusRawValue,- "type": task.typeRawValue+ "type": task.typeRawValue,+ // Effective-priority invariant (Req 1.4): computed accessor, NOT priorityRawValue.+ "priority": task.priority.rawValue ] if let displayId = task.permanentDisplayId { taskDict["displayId"] = displayId
diff --git a/Transit/Transit/Intents/QueryTasksIntent.swift b/Transit/Transit/Intents/QueryTasksIntent.swiftindex 1aa9517..0f8a38a 100644--- a/Transit/Transit/Intents/QueryTasksIntent.swift+++ b/Transit/Transit/Intents/QueryTasksIntent.swift@@ -23,6 +23,7 @@ private struct QueryFilters: Codable { var displayId: Int? var status: String? var type: String?+ var priority: String? var projectId: String? var search: String? var completionDate: DateRangeFilter?@@ -34,6 +35,7 @@ private struct QueryFilters: Codable { displayId: Int? = nil, status: String? = nil, type: String? = nil,+ priority: String? = nil, projectId: String? = nil, search: String? = nil, completionDate: DateRangeFilter? = nil,@@ -44,6 +46,7 @@ private struct QueryFilters: Codable { self.displayId = displayId self.status = status self.type = type+ self.priority = priority self.projectId = projectId self.search = search self.completionDate = completionDate@@ -72,7 +75,8 @@ struct QueryTasksIntent: AppIntent { JSON object with optional filters: "displayId" (integer, for single-task lookup with detailed output \ including description and metadata), "status" (idea | planning | spec | ready-for-implementation | \ in-progress | ready-for-review | done | abandoned), "type" (bug | feature | chore | research | \- documentation), "projectId" (UUID), "search" (case-insensitive substring match on name and description), \+ documentation), "priority" (low | medium | high), "projectId" (UUID), \+ "search" (case-insensitive substring match on name and description), \ "milestone" (name), "milestoneDisplayId" (integer), "completionDate", "lastStatusChangeDate". \ Date filters accept {"relative":"today|this-week|this-month"} or {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"} \ (from/to optional and inclusive; relative takes precedence if both are present). \@@ -247,6 +251,9 @@ struct QueryTasksIntent: AppIntent { if let type = filters.type, TaskType(rawValue: type) == nil { return .invalidType(hint: "Unknown type: \(type)") }+ if let priority = filters.priority, TaskPriority(rawValue: priority) == nil {+ return .invalidPriority(hint: "Unknown priority: \(priority)")+ } return nil } @@ -287,6 +294,11 @@ struct QueryTasksIntent: AppIntent { if let type = filters.type, task.typeRawValue != type { continue }+ // Effective-priority invariant (Req 1.4): compare the computed accessor so a+ // legacy task with no stored priority matches a "medium" filter.+ if let priority = filters.priority, task.priority.rawValue != priority {+ continue+ } if let search = effectiveSearch { let nameMatch = task.name.localizedCaseInsensitiveContains(search) let descMatch = task.taskDescription?.localizedCaseInsensitiveContains(search) ?? false
diff --git a/Transit/Transit/Intents/TaskUpdateValidator.swift b/Transit/Transit/Intents/TaskUpdateValidator.swiftindex 55e5941..7b08cf6 100644--- a/Transit/Transit/Intents/TaskUpdateValidator.swift+++ b/Transit/Transit/Intents/TaskUpdateValidator.swift@@ -1,5 +1,7 @@ import Foundation +// swiftlint:disable file_length+ /// Shared validator and applier for `update_task` across the MCP tool and /// `UpdateTaskIntent`. Both surfaces parse JSON into `[String: Any]`, resolve /// the target task, then call `validate` to produce a `ValidatedTaskUpdate`@@ -9,8 +11,9 @@ import Foundation @MainActor enum TaskUpdateValidator { + // swiftlint:disable cyclomatic_complexity /// Walks the JSON args in a deterministic order (name → description → type- /// → metadata → milestone) and produces a fully-validated update. Pure:+ /// → priority → metadata → milestone) and produces a fully-validated update. Pure: /// never mutates `task` or the model context. Milestone resolution uses /// `milestoneService` for read-only lookups. The walk order is not part /// of the public contract (AC 5.2) — callers MUST NOT depend on which@@ -41,6 +44,13 @@ enum TaskUpdateValidator { case .success(let value): type = value } + // priority+ let priority: TaskPriority?+ switch validatePriority(args) {+ case .failure(let error): return .failure(error)+ case .success(let value): priority = value+ }+ // metadata let metadata: FieldChange<[String: String]> switch strictStringMetadata(from: args["metadata"]) {@@ -60,11 +70,13 @@ enum TaskUpdateValidator { name: name, description: description, type: type,+ priority: priority, metadata: metadata, milestoneAction: milestoneAction ) ) }+ // swiftlint:enable cyclomatic_complexity /// Applies a validated update to the task in memory via the service layer. /// Calls `taskService.updateTask(..., save: false)` once with every@@ -80,7 +92,7 @@ enum TaskUpdateValidator { taskService: TaskService, milestoneService: MilestoneService ) throws {- // Walk order: name → description → type → metadata → milestone.+ // Walk order: name → description → type → priority → metadata → milestone. // Only call updateTask if at least one of its fields changes — otherwise // we'd issue a no-op service call that still adds nothing observable but // is wasted work.@@ -103,6 +115,7 @@ enum TaskUpdateValidator { let hasFieldChange = update.name != nil || update.description.isChange || update.type != nil+ || update.priority != nil || update.metadata.isChange if hasFieldChange {@@ -113,6 +126,7 @@ enum TaskUpdateValidator { clearDescription: clearDesc, type: update.type, metadata: metadataArg,+ priority: update.priority, save: false ) }@@ -204,6 +218,24 @@ enum TaskUpdateValidator { return .success(type) } + /// Validates the optional `priority` field. Mirrors `validateType`'s shape but+ /// surfaces a bad raw value as `.invalidPriority` (a dedicated INVALID_PRIORITY+ /// code, Decision 9) rather than the generic `.invalidInput`. Priority is+ /// non-clearable (Decision 8): an absent key means "no change".+ private static func validatePriority(+ _ args: [String: Any]+ ) -> Result<TaskPriority?, TaskUpdateValidationError> {+ guard let raw = args["priority"] else { return .success(nil) }+ guard let str = raw as? String else {+ return .failure(.invalidInput("priority must be a string"))+ }+ guard let priority = TaskPriority(rawValue: str) else {+ let valid = TaskPriority.allCases.map(\.rawValue).joined(separator: ", ")+ return .failure(.invalidPriority("Invalid priority: \(str). Must be one of: \(valid)"))+ }+ return .success(priority)+ }+ /// Mirrors the precedence in the existing MCP handler and /// `UpdateTaskIntent.applyMilestoneChange`: clearMilestone → milestoneDisplayId → milestone (name). /// `clearMilestone: true` is emitted as `.clear` unconditionally — even when@@ -301,7 +333,7 @@ enum TaskUpdateValidator { /// A pure data carrier for a fully-validated update. Field representation /// makes invalid states unrepresentable:-/// - Non-clearable fields (`name`, `type`) use `Optional<T>` — `nil` = no change.+/// - Non-clearable fields (`name`, `type`, `priority`) use `Optional<T>` — `nil` = no change. /// - Clearable fields (`description`, `metadata`) use `FieldChange<T>` to /// distinguish "set" from "clear" cleanly. /// - Milestone uses its own enum because "assign" carries a `Milestone`@@ -310,6 +342,7 @@ struct ValidatedTaskUpdate { let name: String? let description: FieldChange<String> let type: TaskType?+ let priority: TaskPriority? let metadata: FieldChange<[String: String]> let milestoneAction: MilestoneAction? @@ -317,6 +350,7 @@ struct ValidatedTaskUpdate { name != nil || description.isChange || type != nil+ || priority != nil || metadata.isChange || milestoneAction != nil }@@ -355,6 +389,7 @@ enum MilestoneAction { /// across surfaces (AC 5.2 carve-out). enum TaskUpdateValidationError: Error { case invalidInput(String)+ case invalidPriority(String) case milestoneNotFound(message: String) case duplicateMilestoneDisplayID(message: String) case milestoneProjectMismatch@@ -366,6 +401,7 @@ enum TaskUpdateValidationError: Error { var mcpMessage: String { switch self { case .invalidInput(let message),+ .invalidPriority(let message), .milestoneNotFound(let message), .duplicateMilestoneDisplayID(let message): return message@@ -383,6 +419,8 @@ enum TaskUpdateValidationError: Error { switch self { case .invalidInput(let hint): return .invalidInput(hint: hint)+ case .invalidPriority(let hint):+ return .invalidPriority(hint: hint) case .milestoneNotFound(let message): return .milestoneNotFound(hint: message) case .duplicateMilestoneDisplayID(let message):@@ -398,3 +436,5 @@ enum TaskUpdateValidationError: Error { } } }++// swiftlint:enable file_length
diff --git a/Transit/Transit/Intents/UpdateTaskIntent.swift b/Transit/Transit/Intents/UpdateTaskIntent.swiftindex 0c43562..7f4d2cb 100644--- a/Transit/Transit/Intents/UpdateTaskIntent.swift+++ b/Transit/Transit/Intents/UpdateTaskIntent.swift@@ -31,6 +31,7 @@ struct UpdateTaskIntent: AppIntent { "name" (string, trimmed, non-empty); \ "description" (string, trimmed; pass "" or whitespace-only to clear); \ "type" (string, one of: bug, feature, chore, research, documentation); \+ "priority" (string, one of: low, medium, high; omit to leave unchanged — not clearable); \ "metadata" (object with string values; replaces entire metadata dict; pass {} to clear all). \ Milestone fields: "milestoneDisplayId" (integer), "milestone" (name within task's project), \ or "clearMilestone" (boolean, true to remove). \@@ -46,6 +47,7 @@ struct UpdateTaskIntent: AppIntent { "name" (string, trimmed, non-empty); \ "description" (string, trimmed; pass "" or whitespace-only to clear); \ "type" (string, one of: bug, feature, chore, research, documentation); \+ "priority" (string, one of: low, medium, high; omit to leave unchanged — not clearable); \ "metadata" (object with string values; replaces entire metadata dict; pass {} to clear all). \ Milestone fields: "milestoneDisplayId" (integer), "milestone" (name within task's project), \ or "clearMilestone" (boolean, true to remove). \
diff --git a/Transit/Transit/MCP/MCPHelperTypes.swift b/Transit/Transit/MCP/MCPHelperTypes.swiftindex 5b68066..815e82d 100644--- a/Transit/Transit/MCP/MCPHelperTypes.swift+++ b/Transit/Transit/MCP/MCPHelperTypes.swift@@ -7,6 +7,7 @@ struct MCPQueryFilters { let statuses: [String]? let notStatuses: [String]? let type: String?+ let priorities: [String]? let projectId: UUID? let search: String? let milestoneIds: Set<UUID>?@@ -42,6 +43,18 @@ struct MCPQueryFilters { } else { notStatusesArg = nil }+ // priority mirrors status: schema declares an array, but accept a single+ // string defensively (MCP args are untyped). handleQueryTasks rejects+ // malformed values before reaching here.+ let priorities: [String]?+ if let array = args["priority"] as? [String] {+ priorities = array+ } else if let single = args["priority"] as? String {+ priorities = [single]+ } else {+ priorities = nil+ }+ // Use the strict parser so numeric/string/null values never coerce to a // boolean. `handleQueryTasks` rejects malformed values before reaching // here; this defaults to false for an absent (or, defensively, malformed)@@ -58,7 +71,8 @@ struct MCPQueryFilters { return MCPQueryFilters( statuses: statuses, notStatuses: resolvedNotStatuses,- type: type, projectId: projectId, search: search, milestoneIds: milestoneIds+ type: type, priorities: priorities, projectId: projectId,+ search: search, milestoneIds: milestoneIds ) } @@ -66,6 +80,11 @@ struct MCPQueryFilters { if let statuses, !statuses.isEmpty, !statuses.contains(task.statusRawValue) { return false } if let notStatuses, !notStatuses.isEmpty, notStatuses.contains(task.statusRawValue) { return false } if let type, task.typeRawValue != type { return false }+ // Effective-priority invariant (Req 1.4): compare the computed accessor so a+ // legacy task with no stored priority matches a `["medium"]` filter.+ if let priorities, !priorities.isEmpty, !priorities.contains(task.priority.rawValue) {+ return false+ } if let projectId, task.project?.id != projectId { return false } // Invariant: callers in MCPToolHandler.handleQueryTasks return early when // matchingIds is empty, so milestoneIds is always non-empty here.
diff --git a/Transit/Transit/MCP/MCPToolDefinitions.swift b/Transit/Transit/MCP/MCPToolDefinitions.swiftindex 5c26953..7d99382 100644--- a/Transit/Transit/MCP/MCPToolDefinitions.swift+++ b/Transit/Transit/MCP/MCPToolDefinitions.swift@@ -60,6 +60,10 @@ nonisolated enum MCPToolDefinitions { "Task type (required)", values: TaskType.allCases.map(\.rawValue) ),+ "priority": .stringEnum(+ "Task priority (optional, defaults to medium)",+ values: TaskPriority.allCases.map(\.rawValue)+ ), "projectId": .string( "Project UUID. At least one of projectId or project is required; projectId takes precedence." ),@@ -97,7 +101,7 @@ nonisolated enum MCPToolDefinitions { ) // swiftlint:disable:next line_length- private static let queryTasksDescription = "Search and filter tasks. All filters are optional — omit all to return every task. Use displayId for single-task lookup with full details. Use project for case-insensitive name filtering. status accepts an array of statuses to include. not_status accepts an array of statuses to exclude. unfinished=true excludes done and abandoned tasks (merged with not_status if both provided). Use search for case-insensitive substring matching on task name and description."+ private static let queryTasksDescription = "Search and filter tasks. All filters are optional — omit all to return every task. Use displayId for single-task lookup with full details. Use project for case-insensitive name filtering. status accepts an array of statuses to include. not_status accepts an array of statuses to exclude. priority accepts an array of priorities to include. unfinished=true excludes done and abandoned tasks (merged with not_status if both provided). Use search for case-insensitive substring matching on task name and description." static let queryTasks = MCPToolDefinition( name: "query_tasks",@@ -118,6 +122,10 @@ nonisolated enum MCPToolDefinitions { "Filter by type", values: TaskType.allCases.map(\.rawValue) ),+ "priority": .array(+ "Filter by priority (include tasks matching any listed priority)",+ enumValues: TaskPriority.allCases.map(\.rawValue)+ ), "projectId": .string("Filter by project UUID"), "project": .string("Project name (optional, case-insensitive)"), "search": .string("Text search on task name and description (case-insensitive substring match)"),@@ -243,6 +251,10 @@ nonisolated enum MCPToolDefinitions { "New task type (exact lowercase match required)", values: TaskType.allCases.map(\.rawValue) ),+ "priority": .stringEnum(+ "New task priority (exact lowercase match required; omit to leave unchanged)",+ values: TaskPriority.allCases.map(\.rawValue)+ ), "metadata": .object( "Replaces the entire metadata dictionary. Pass {} to clear all metadata. Values must be strings." ),
diff --git a/Transit/Transit/MCP/MCPToolHandler.swift b/Transit/Transit/MCP/MCPToolHandler.swiftindex 05f2727..c464dcf 100644--- a/Transit/Transit/MCP/MCPToolHandler.swift+++ b/Transit/Transit/MCP/MCPToolHandler.swift@@ -197,6 +197,22 @@ final class MCPToolHandler { return errorResult("Invalid type: \(typeRaw). Must be one of: \(valid)") } + // Priority is optional and defaults to medium. A present-but-invalid value+ // is rejected before any task is created (Req 5.5).+ let priority: TaskPriority+ if let priorityRaw = args["priority"] {+ guard let priorityStr = priorityRaw as? String else {+ return errorResult("priority must be a string")+ }+ guard let parsed = TaskPriority(rawValue: priorityStr) else {+ let valid = TaskPriority.allCases.map(\.rawValue).joined(separator: ", ")+ return errorResult("Invalid priority: \(priorityStr). Must be one of: \(valid)")+ }+ priority = parsed+ } else {+ priority = .medium+ }+ // Reject malformed or non-string projectId when the key is present // [T-743, T-788]. let projectId: UUID?@@ -260,7 +276,8 @@ final class MCPToolHandler { description: args["description"] as? String, type: taskType, project: project,- metadata: IntentHelpers.stringMetadata(from: args["metadata"])+ metadata: IntentHelpers.stringMetadata(from: args["metadata"]),+ priority: priority ) } catch { return errorResult("Task creation failed: \(error)")@@ -278,7 +295,8 @@ final class MCPToolHandler { var response: [String: Any] = [ "taskId": task.id.uuidString,- "status": task.statusRawValue+ "status": task.statusRawValue,+ "priority": task.priority.rawValue ] if let displayId = task.permanentDisplayId { response["displayId"] = displayId@@ -432,6 +450,10 @@ final class MCPToolHandler { if let error = validateEnumFilter(args, key: "type", type: TaskType.self, allowArray: false) { return error }+ // priority is a multi-value filter (schema declares an array, mirroring status).+ if let error = validateEnumFilter(args, key: "priority", type: TaskPriority.self, allowArray: true) {+ return error+ } // Reject a present-but-non-boolean `unfinished` flag [T-1095]. A plain // `as? Bool` would silently coerce "true"/1/null to false, returning@@ -1136,7 +1158,9 @@ extension MCPToolHandler { "taskId": task.id.uuidString, "name": task.name, "status": task.statusRawValue,- "type": task.typeRawValue+ "type": task.typeRawValue,+ // Effective-priority invariant (Req 1.4): computed accessor, NOT priorityRawValue.+ "priority": task.priority.rawValue ] if let displayId = task.permanentDisplayId { taskDict["displayId"] = displayId } return taskDict
diff --git a/Transit/Transit/Models/TaskPriority.swift b/Transit/Transit/Models/TaskPriority.swiftnew file mode 100644index 0000000..5795468--- /dev/null+++ b/Transit/Transit/Models/TaskPriority.swift@@ -0,0 +1,41 @@+import SwiftUI++enum TaskPriority: String, Codable, CaseIterable {+ case low+ case medium+ case high++ /// Order for pickers and the filter menu: most actionable value first.+ /// `allCases` is source order (low-first); display surfaces iterate this+ /// instead so high reads first.+ static let displayOrder: [TaskPriority] = [.high, .medium, .low]++ /// Color used for the filter dot (all three) and the card glyph (low/high).+ var tintColor: Color {+ switch self {+ case .high: .red+ case .medium: .orange+ case .low: .blue+ }+ }++ /// SF Symbol shown on the board card. `nil` for medium is the single source+ /// of truth for "no card glyph" (Decision 6); the card reads it rather than+ /// re-deciding suppression.+ var glyphSymbol: String? {+ switch self {+ case .high: "arrow.up.circle.fill"+ case .medium: nil+ case .low: "arrow.down.circle.fill"+ }+ }++ /// VoiceOver label naming the priority level (e.g. "High priority").+ var accessibilityLabel: String {+ switch self {+ case .high: "High priority"+ case .medium: "Medium priority"+ case .low: "Low priority"+ }+ }+}
diff --git a/Transit/Transit/Models/TransitTask.swift b/Transit/Transit/Models/TransitTask.swiftindex bf1865d..921b239 100644--- a/Transit/Transit/Models/TransitTask.swift+++ b/Transit/Transit/Models/TransitTask.swift@@ -9,6 +9,7 @@ final class TransitTask { var taskDescription: String? var statusRawValue: String = "idea" var typeRawValue: String = "feature"+ var priorityRawValue: String = "medium" var creationDate: Date = Date() var lastStatusChangeDate: Date = Date() var completionDate: Date?@@ -30,6 +31,14 @@ final class TransitTask { set { typeRawValue = newValue.rawValue } } + /// Effective priority. Legacy tasks (absent/empty/unrecognized raw value)+ /// read as `.medium` (the invariant — Req 1.4). No production code reads+ /// `priorityRawValue` outside this accessor, the init, and the setter.+ var priority: TaskPriority {+ get { TaskPriority(rawValue: priorityRawValue) ?? .medium }+ set { priorityRawValue = newValue.rawValue }+ }+ var displayID: DisplayID { if let id = permanentDisplayId { return .permanent(id)@@ -105,12 +114,14 @@ final class TransitTask { type: TaskType, project: Project, displayID: DisplayID,- metadata: [String: String]? = nil+ metadata: [String: String]? = nil,+ priority: TaskPriority = .medium ) { self.id = UUID() self.name = name self.taskDescription = description self.typeRawValue = type.rawValue+ self.priorityRawValue = priority.rawValue self.project = project self.creationDate = Date.now self.lastStatusChangeDate = Date.now
diff --git a/Transit/Transit/Services/TaskService.swift b/Transit/Transit/Services/TaskService.swiftindex 9a48e5e..77eb828 100644--- a/Transit/Transit/Services/TaskService.swift+++ b/Transit/Transit/Services/TaskService.swift@@ -53,7 +53,8 @@ final class TaskService { description: String?, type: TaskType, projectID: UUID,- metadata: [String: String]? = nil+ metadata: [String: String]? = nil,+ priority: TaskPriority = .medium ) async throws -> TransitTask { let descriptor = FetchDescriptor<Project>( predicate: #Predicate { $0.id == projectID }@@ -66,7 +67,8 @@ final class TaskService { description: description, type: type, project: project,- metadata: metadata+ metadata: metadata,+ priority: priority ) } @@ -79,6 +81,7 @@ final class TaskService { type: TaskType, project: Project, metadata: [String: String]? = nil,+ priority: TaskPriority = .medium, save: (ModelContext) throws -> Void = { try $0.save() } ) async throws -> TransitTask { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)@@ -105,7 +108,8 @@ final class TaskService { type: type, project: project, displayID: displayID,- metadata: metadata+ metadata: metadata,+ priority: priority ) StatusEngine.initializeNewTask(task) @@ -261,6 +265,7 @@ final class TaskService { clearDescription: Bool = false, type: TaskType? = nil, metadata: [String: String]? = nil,+ priority: TaskPriority? = nil, save: Bool = true ) throws { if let name {@@ -277,6 +282,7 @@ final class TaskService { } if let type { task.type = type } if let metadata { task.metadata = metadata }+ if let priority { task.priority = priority } guard save else { return }
diff --git a/Transit/Transit/Views/AddTask/AddTaskSheet+Save.swift b/Transit/Transit/Views/AddTask/AddTaskSheet+Save.swiftindex 18be658..d617095 100644--- a/Transit/Transit/Views/AddTask/AddTaskSheet+Save.swift+++ b/Transit/Transit/Views/AddTask/AddTaskSheet+Save.swift@@ -15,6 +15,7 @@ extension AddTaskSheet { name: trimmedName, description: description.isEmpty ? nil : description, type: selectedType,+ priority: selectedPriority, projectID: project.id, milestone: selectedMilestone )@@ -39,6 +40,7 @@ extension AddTaskSheet { let name: String let description: String? let type: TaskType+ let priority: TaskPriority let projectID: UUID let milestone: Milestone? }@@ -61,7 +63,8 @@ extension AddTaskSheet { name: draft.name, description: draft.description, type: draft.type,- projectID: draft.projectID+ projectID: draft.projectID,+ priority: draft.priority ) guard let milestone = draft.milestone else { return } do {
diff --git a/Transit/Transit/Views/AddTask/AddTaskSheet.swift b/Transit/Transit/Views/AddTask/AddTaskSheet.swiftindex 1ee0163..3fec062 100644--- a/Transit/Transit/Views/AddTask/AddTaskSheet.swift+++ b/Transit/Transit/Views/AddTask/AddTaskSheet.swift@@ -17,6 +17,7 @@ struct AddTaskSheet: View { @State var name = "" @State var taskDescription = "" @State var selectedType: TaskType = .feature+ @State var selectedPriority: TaskPriority = .medium @State private var selectedProjectID: UUID? @State var selectedMilestone: Milestone? @State private var selectedDetent: PresentationDetent = .large@@ -118,6 +119,12 @@ struct AddTaskSheet: View { } } + Picker("Priority", selection: $selectedPriority) {+ ForEach(TaskPriority.displayOrder, id: \.self) { priority in+ Text(priority.rawValue.capitalized).tag(priority)+ }+ }+ Picker("Project", selection: $selectedProjectID) { ForEach(projects) { project in HStack {@@ -179,6 +186,17 @@ struct AddTaskSheet: View { .fixedSize() } + FormRow("Priority", labelWidth: Self.labelWidth) {+ Picker("", selection: $selectedPriority) {+ ForEach(TaskPriority.displayOrder, id: \.self) { priority in+ Text(priority.rawValue.capitalized).tag(priority)+ }+ }+ .labelsHidden()+ .pickerStyle(.menu)+ .fixedSize()+ }+ FormRow("Project", labelWidth: Self.labelWidth) { Picker("", selection: $selectedProjectID) { ForEach(projects) { project in@@ -239,6 +257,7 @@ struct AddTaskSheet: View { name = defaults.name taskDescription = defaults.description selectedType = defaults.type+ selectedPriority = defaults.priority selectedMilestone = defaults.milestone selectedProjectID = AddTaskFormResetLogic.defaultProjectID( from: projects, current: selectedProjectID@@ -264,6 +283,7 @@ enum AddTaskFormResetLogic { let name: String let description: String let type: TaskType+ let priority: TaskPriority let milestone: Milestone? } @@ -271,6 +291,7 @@ enum AddTaskFormResetLogic { name: "", description: "", type: .feature,+ priority: .medium, milestone: nil )
diff --git a/Transit/Transit/Views/Dashboard/DashboardView.swift b/Transit/Transit/Views/Dashboard/DashboardView.swiftindex ea6095d..a1605a3 100644--- a/Transit/Transit/Views/Dashboard/DashboardView.swift+++ b/Transit/Transit/Views/Dashboard/DashboardView.swift@@ -8,6 +8,7 @@ struct DashboardView: View { @State private var selectedProjectIDs: Set<UUID> = [] @State private var selectedTypes: Set<TaskType> = [] @State private var selectedMilestones: Set<UUID> = []+ @State private var selectedPriorities: Set<TaskPriority> = [] @State private var sortOrder: DashboardLogic.ColumnSortOrder = .recent @State private var selectedColumn: DashboardColumn = .inProgress // [req 13.3] @State private var selectedTask: TransitTask?@@ -37,10 +38,7 @@ struct DashboardView: View { } private var hasAnyFilter: Bool {- !selectedProjectIDs.isEmpty- || !selectedTypes.isEmpty- || !selectedMilestones.isEmpty- || !effectiveSearchText.isEmpty+ hasOtherFilters || !effectiveSearchText.isEmpty } /// Excludes search text (unlike `hasAnyFilter`) so the empty-state logic can@@ -49,6 +47,7 @@ struct DashboardView: View { !selectedProjectIDs.isEmpty || !selectedTypes.isEmpty || !selectedMilestones.isEmpty+ || !selectedPriorities.isEmpty } private var filteredColumns: [DashboardColumn: [TransitTask]] {@@ -57,6 +56,7 @@ struct DashboardView: View { selectedProjectIDs: selectedProjectIDs, selectedTypes: selectedTypes, selectedMilestones: selectedMilestones,+ selectedPriorities: selectedPriorities, searchText: effectiveSearchText, sortOrder: sortOrder )@@ -114,6 +114,7 @@ struct DashboardView: View { selectedProjectIDs: $selectedProjectIDs ) TypeFilterMenu(selectedTypes: $selectedTypes)+ PriorityFilterMenu(selectedPriorities: $selectedPriorities) MilestoneFilterMenu( projects: projects, selectedProjectIDs: selectedProjectIDs,@@ -194,6 +195,7 @@ struct DashboardView: View { selectedProjectIDs.removeAll() selectedTypes.removeAll() selectedMilestones.removeAll()+ selectedPriorities.removeAll() searchText = "" } label: { Label("Clear All", systemImage: "xmark.circle.fill")@@ -392,21 +394,21 @@ enum DashboardLogic { selectedProjectIDs: Set<UUID>, selectedTypes: Set<TaskType> = [], selectedMilestones: Set<UUID> = [],+ selectedPriorities: Set<TaskPriority> = [], searchText: String = "", sortOrder: ColumnSortOrder = .recent, now: Date = .now ) -> [DashboardColumn: [TransitTask]] { let trimmedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)+ let selection = FilterSelection(+ projectIDs: selectedProjectIDs,+ types: selectedTypes,+ milestones: selectedMilestones,+ priorities: selectedPriorities,+ searchText: trimmedSearch+ ) - let filtered = allTasks.filter { task in- matchesFilters(- task: task,- selectedProjectIDs: selectedProjectIDs,- selectedTypes: selectedTypes,- selectedMilestones: selectedMilestones,- searchText: trimmedSearch- )- }+ let filtered = allTasks.filter { matchesFilters(task: $0, selection: selection) } var grouped = Dictionary(grouping: filtered) { $0.status.column } @@ -464,40 +466,57 @@ enum DashboardLogic { return lhs.lastStatusChangeDate > rhs.lastStatusChangeDate } - private static func matchesFilters(- task: TransitTask,- selectedProjectIDs: Set<UUID>,- selectedTypes: Set<TaskType>,- selectedMilestones: Set<UUID>,- searchText: String- ) -> Bool {+ /// The board's active filter selections, bundled so the predicate stays+ /// under SwiftLint's parameter-count limit as filter dimensions grow.+ /// `searchText` is expected to be already trimmed by the caller.+ struct FilterSelection {+ var projectIDs: Set<UUID> = []+ var types: Set<TaskType> = []+ var milestones: Set<UUID> = []+ var priorities: Set<TaskPriority> = []+ var searchText: String = ""+ }++ private static func matchesFilters(task: TransitTask, selection: FilterSelection) -> Bool { guard task.project != nil else { return false } - if !selectedProjectIDs.isEmpty {+ if !selection.projectIDs.isEmpty { guard let projectId = task.project?.id,- selectedProjectIDs.contains(projectId) else { return false }+ selection.projectIDs.contains(projectId) else { return false }+ }++ if !selection.types.isEmpty {+ guard selection.types.contains(task.type) else { return false } } - if !selectedTypes.isEmpty {- guard selectedTypes.contains(task.type) else { return false }+ // Computed accessor (not priorityRawValue) so legacy tasks read as+ // medium and match a `[.medium]` filter. [req 1.4, 3.2]+ if !selection.priorities.isEmpty {+ guard selection.priorities.contains(task.priority) else { return false } } // In-memory milestone filter (can't use #Predicate for optional relationships)- if !selectedMilestones.isEmpty {+ if !selection.milestones.isEmpty { guard let milestoneId = task.milestone?.id,- selectedMilestones.contains(milestoneId) else { return false }+ selection.milestones.contains(milestoneId) else { return false } } - if !searchText.isEmpty {- let nameMatch = task.name.localizedCaseInsensitiveContains(searchText)- let descMatch = task.taskDescription?.localizedCaseInsensitiveContains(searchText) ?? false- let displayIdMatch = task.displayID.formatted.localizedCaseInsensitiveContains(searchText)- guard nameMatch || descMatch || displayIdMatch else { return false }- }+ guard matchesSearch(task: task, searchText: selection.searchText) else { return false } return true } + /// Matches a task against the search text (name, description, display ID).+ /// An empty search matches everything. Extracted from `matchesFilters` to+ /// keep that function under SwiftLint's cyclomatic-complexity limit.+ private static func matchesSearch(task: TransitTask, searchText: String) -> Bool {+ guard !searchText.isEmpty else { return true }+ let nameMatch = task.name.localizedCaseInsensitiveContains(searchText)+ let descMatch = task.taskDescription?.localizedCaseInsensitiveContains(searchText) ?? false+ let displayIdMatch = task.displayID.formatted.localizedCaseInsensitiveContains(searchText)+ return nameMatch || descMatch || displayIdMatch+ }+ /// Whether the bare "t" key shortcut should open the Add Task sheet. /// Returns `true` only when no sheet is currently presented. static func shouldHandleNewTaskShortcut(
diff --git a/Transit/Transit/Views/Dashboard/PriorityFilterMenu.swift b/Transit/Transit/Views/Dashboard/PriorityFilterMenu.swiftnew file mode 100644index 0000000..b6ff188--- /dev/null+++ b/Transit/Transit/Views/Dashboard/PriorityFilterMenu.swift@@ -0,0 +1,99 @@+import SwiftUI++struct PriorityFilterMenu: View {+ @Binding var selectedPriorities: Set<TaskPriority>+ @Environment(\.horizontalSizeClass) private var sizeClass++ @State private var showPopover = false++ var body: some View {+ Button { showPopover.toggle() } label: { filterLabel }+ .accessibilityIdentifier("dashboard.filter.priorities")+ .accessibilityLabel(Self.accessibilityLabel(for: selectedPriorities.count))+ #if os(macOS)+ .popover(isPresented: $showPopover) {+ List {+ Section {+ toggleContent+ }+ clearSection+ }+ .frame(minWidth: 220, minHeight: 200)+ }+ #else+ .sheet(isPresented: $showPopover) {+ NavigationStack {+ List {+ toggleContent+ clearSection+ }+ .navigationTitle("Priorities")+ .toolbarTitleDisplayMode(.inline)+ .toolbar {+ ToolbarItem(placement: .confirmationAction) {+ Button("Done") { showPopover = false }+ }+ }+ }+ .presentationDetents([.medium])+ .presentationDragIndicator(.visible)+ }+ #endif+ }++ @ViewBuilder+ private var toggleContent: some View {+ // Iterate displayOrder (high → medium → low), not allCases (low-first),+ // so the most actionable value reads first.+ ForEach(TaskPriority.displayOrder, id: \.self) { priority in+ Button {+ $selectedPriorities.contains(priority).wrappedValue.toggle()+ } label: {+ HStack {+ Circle()+ .fill(priority.tintColor)+ .frame(width: 12, height: 12)+ Text(priority.rawValue.capitalized)+ .foregroundStyle(.primary)+ Spacer()+ if selectedPriorities.contains(priority) {+ Image(systemName: "checkmark")+ .foregroundStyle(.tint)+ }+ }+ .contentShape(Rectangle())+ }+ .buttonStyle(.plain)+ .accessibilityIdentifier("filter.priority.\(priority.rawValue)")+ }+ }++ @ViewBuilder+ private var clearSection: some View {+ if !selectedPriorities.isEmpty {+ Section {+ Button("Clear", role: .destructive) {+ selectedPriorities.removeAll()+ }+ }+ }+ }++ @ViewBuilder+ private var filterLabel: some View {+ let count = selectedPriorities.count+ if sizeClass == .compact {+ Image(systemName: count > 0 ? "flag.fill" : "flag")+ .badge(count)+ } else {+ Label(+ count > 0 ? "Priorities (\(count))" : "Priorities",+ systemImage: count > 0 ? "flag.fill" : "flag"+ )+ }+ }++ static func accessibilityLabel(for count: Int) -> String {+ "Priority filter, \(count) selected"+ }+}
diff --git a/Transit/Transit/Views/Dashboard/TaskCardView.swift b/Transit/Transit/Views/Dashboard/TaskCardView.swiftindex 4a7bd98..67ba168 100644--- a/Transit/Transit/Views/Dashboard/TaskCardView.swift+++ b/Transit/Transit/Views/Dashboard/TaskCardView.swift@@ -58,6 +58,8 @@ struct TaskCardView: View { TypeBadge(type: task.type) + PriorityIndicator(priority: task.priority)+ if let milestone = task.milestone { Text(milestone.name) .font(.caption2)
diff --git a/Transit/Transit/Views/Shared/PriorityIndicator.swift b/Transit/Transit/Views/Shared/PriorityIndicator.swiftnew file mode 100644index 0000000..5845e7e--- /dev/null+++ b/Transit/Transit/Views/Shared/PriorityIndicator.swift@@ -0,0 +1,18 @@+import SwiftUI++/// Board-card priority glyph. Renders a bare SF Symbol for high/low and nothing+/// for medium. Suppression is read from `TaskPriority.glyphSymbol` (nil for+/// medium, the single source of truth — Decision 6), not re-decided here.+struct PriorityIndicator: View {+ let priority: TaskPriority++ var body: some View {+ if let symbol = priority.glyphSymbol {+ Image(systemName: symbol)+ .font(.caption)+ .foregroundStyle(priority.tintColor)+ .accessibilityLabel(priority.accessibilityLabel)+ .accessibilityIdentifier("dashboard.taskCard.priority")+ }+ }+}
diff --git a/Transit/Transit/Views/TaskDetail/TaskDetailView.swift b/Transit/Transit/Views/TaskDetail/TaskDetailView.swiftindex 2695a6d..b118601 100644--- a/Transit/Transit/Views/TaskDetail/TaskDetailView.swift+++ b/Transit/Transit/Views/TaskDetail/TaskDetailView.swift@@ -54,6 +54,7 @@ struct TaskDetailView: View { Section { LabeledContent("Name", value: task.name) LabeledContent("Type") { TypeBadge(type: task.type) }+ LabeledContent("Priority", value: task.priority.rawValue.capitalized) LabeledContent("Status", value: task.status.displayName) if let project = task.project { LabeledContent("Project") {@@ -116,6 +117,10 @@ struct TaskDetailView: View { TypeBadge(type: task.type) } + FormRow("Priority", labelWidth: Self.labelWidth) {+ Text(task.priority.rawValue.capitalized)+ }+ FormRow("Status", labelWidth: Self.labelWidth) { Text(task.status.displayName) }
diff --git a/Transit/Transit/Views/TaskDetail/TaskEditView.swift b/Transit/Transit/Views/TaskDetail/TaskEditView.swiftindex d49a641..22c8be5 100644--- a/Transit/Transit/Views/TaskDetail/TaskEditView.swift+++ b/Transit/Transit/Views/TaskDetail/TaskEditView.swift@@ -14,6 +14,7 @@ struct TaskEditView: View { @State private var name: String = "" @State private var taskDescription: String = "" @State private var selectedType: TaskType = .feature+ @State private var selectedPriority: TaskPriority = .medium @State private var selectedStatus: TaskStatus = .idea @State private var selectedProjectID: UUID? @State private var selectedMilestone: Milestone?@@ -98,6 +99,12 @@ struct TaskEditView: View { } } + Picker("Priority", selection: $selectedPriority) {+ ForEach(TaskPriority.displayOrder, id: \.self) { priority in+ Text(priority.rawValue.capitalized).tag(priority)+ }+ }+ Picker("Project", selection: $selectedProjectID) { ForEach(projects) { project in HStack {@@ -161,6 +168,17 @@ struct TaskEditView: View { .fixedSize() } + FormRow("Priority", labelWidth: Self.labelWidth) {+ Picker("", selection: $selectedPriority) {+ ForEach(TaskPriority.displayOrder, id: \.self) { priority in+ Text(priority.rawValue.capitalized).tag(priority)+ }+ }+ .labelsHidden()+ .pickerStyle(.menu)+ .fixedSize()+ }+ FormRow("Project", labelWidth: Self.labelWidth) { Picker("", selection: $selectedProjectID) { ForEach(projects) { project in@@ -275,6 +293,7 @@ extension TaskEditView { name = task.name taskDescription = task.taskDescription ?? "" selectedType = task.type+ selectedPriority = task.priority selectedStatus = task.status selectedProjectID = task.project?.id selectedMilestone = task.milestone@@ -309,6 +328,7 @@ extension TaskEditView { clearDescription: trimmedDesc.isEmpty, type: selectedType, metadata: metadata,+ priority: selectedPriority, save: false )
diff --git a/docs/agent-notes/build-sandbox-wedge.md b/docs/agent-notes/build-sandbox-wedge.mdnew file mode 100644index 0000000..44e1ae5--- /dev/null+++ b/docs/agent-notes/build-sandbox-wedge.md@@ -0,0 +1,40 @@+# Build/Test Sandbox Wedge (Claude Code background sessions)++## Symptom+`make build` / `make test` / `make test-quick` (anything invoking `xcodebuild`) hangs+**forever at 0% CPU with zero output**. The `xcodebuild test` process shows state+`SN` (sleeping, blocked) and never progresses — it looks identical to an+XCBBuildService crash or a system overload, but it is neither. One run sat wedged+for 19 hours.++## Root cause+The Claude Code session shell runs **sandboxed**, and the sandbox blocks+`xcodebuild`'s XPC connection to **XCBBuildService**. The build action blocks on+the handshake that never completes.++Tell-tale: things that do NOT need the build-service XPC work fine sandboxed —+`make lint` (SwiftLint), `xcodebuild -version`, `xcrun --find swiftc`. Only+`build`/`test` actions wedge.++## Fix+Run every build/test command with the Bash tool parameter+`dangerouslyDisableSandbox: true`. With the sandbox off, `make test-quick`+compiles and runs normally (~1300+ checks, "Test Succeeded").++When delegating build/test work to a subagent, tell it this explicitly — a subagent+that runs `make test-quick` the normal way will wedge for a long time. Safety valve:+if any build command produces no output for ~3 minutes, `pkill -9 -f xcodebuild` and+re-run with the sandbox disabled.++## What does NOT help (don't waste time on these)+Killing strays, clearing the module cache, wiping `DerivedData`, killing+XCBBuildService, quitting the Xcode GUI, reducing system load — none of it fixes the+wedge, because the wedge is the sandbox, not the build state. Repeatedly+`kill -9`-ing XCBBuildService can additionally trip launchd's respawn backoff and+make things look worse.++## Note on pipe exit codes+`make test-quick` pipes `xcodebuild ... | xcbeautify`. `xcbeautify` exits 0 even when+tests fail, so `$?`/`PIPESTATUS` after the pipe can read 0 on failure. Confirm+success by grepping the captured log for `Test Succeeded` vs `** TEST FAILED **` /+`Failing tests:`, not by exit code.
diff --git a/specs/task-priority/decision_log.md b/specs/task-priority/decision_log.mdnew file mode 100644index 0000000..e721cfa--- /dev/null+++ b/specs/task-priority/decision_log.md@@ -0,0 +1,292 @@+# Decision Log: Task Priority++## Decision 1: Three fixed priority levels with medium default++**Date**: 2026-06-05+**Status**: accepted++### Context++The feature needs a way to express relative task importance so the board can signal what to focus on. The range of values must be simple enough to read at a glance and to map onto a single glyph.++### Decision++Priority is one of exactly three values — low, medium, high — with medium as the default for new and unspecified tasks.++### Rationale++Three levels are the minimum that distinguish "now / normal / later" without forcing the user to make fine-grained judgments. Medium as the default means a task that no one has triaged sits in the neutral middle rather than implying either urgency or deferral.++### Alternatives Considered++- **Numeric scale (e.g. 1–5)**: More granularity - Rejected as harder to glyph and over-precise for a single-user tracker.+- **Two levels (normal / high)**: Simpler - Rejected because the user explicitly wants a "can wait" signal, which needs a low tier.+- **A fourth "none/unset" level**: Distinguishes untriaged from medium - Rejected; medium-as-default already serves the untriaged case and a fourth value complicates the glyph and filter.++### Consequences++**Positive:**+- Maps cleanly to a three-state glyph and a three-option filter.+- The "unset" case is resolved silently by the accessor default (see Decision 2 and AC 1.3) rather than surfacing as a distinct value users must handle.++**Negative:**+- No way to mark a task as deliberately un-prioritized distinct from medium.++---++## Decision 2: Default-value backfill, no migration code++**Date**: 2026-06-05+**Status**: accepted++### Context++Transit uses SwiftData with CloudKit, where post-deployment schema changes must be add-only and all properties must be optional or carry a default. Existing tasks predate the priority field and need a sensible value.++### Decision++Store priority as a raw value with a default of medium, and have the computed accessor return medium when the stored value is absent. No explicit migration or backfill pass is run.++### Rationale++This mirrors the existing `typeRawValue` / `statusRawValue` pattern on the task model, satisfies the CloudKit add-only constraint, and makes every pre-existing task read as medium automatically. A migration pass would add risk and code for no behavioral gain.++### Alternatives Considered++- **One-time backfill routine on launch**: Explicitly writes medium to every old task - Rejected as unnecessary; the computed default already yields medium and avoids a write storm and sync churn.++### Consequences++**Positive:**+- Zero migration code; consistent with established model patterns.+- No CloudKit sync burst from rewriting existing records.++**Negative:**+- Old records carry no stored priority until next edited; correctness depends on the accessor's default rather than the data itself.++---++## Decision 3: Priority affects display and filtering only, not ordering++**Date**: 2026-06-05+**Status**: accepted++### Context++The board sorts tasks within each column by `lastStatusChangeDate` (with an "Organized" toggle). Priority could plausibly influence ordering so high-priority tasks float to the top.++### Decision++Priority is shown as a glyph and is filterable, but does not change task ordering within columns. Existing sort behavior is unchanged.++### Rationale++The stated goal — seeing what to focus on — is met by the glyph and filter. Folding priority into the sort is a larger, riskier change to existing ordering logic and the Organized toggle, and was not requested. Filtering to "high only" achieves the focus use case directly.++### Alternatives Considered++- **Priority as a sort tie-break or new sort mode**: Surfaces high-priority work automatically - Deferred; larger UI/logic change to existing sort, can be added later if filtering proves insufficient.++### Consequences++**Positive:**+- No change to existing, tested sort logic.+- Smaller, lower-risk scope.++**Negative:**+- High-priority tasks are not automatically promoted; the user must apply a filter to isolate them.++---++## Decision 4: Priority editable in the in-app create and edit screens++**Date**: 2026-06-05+**Status**: accepted++### Context++Tasks can be created and edited inside the app (AddTaskSheet, TaskEditView) as well as via MCP and App Intents. Priority could be settable only through automation, or also in the app.++### Decision++Add a priority control to both the in-app task creation screen and the task edit screen, in addition to MCP and App Intent support.++### Rationale++A user working directly in the app should not have to drop to MCP or Shortcuts to set priority. Including it in the existing create/edit forms keeps the feature usable for the primary in-app workflow and matches how `type` is already handled.++### Alternatives Considered++- **MCP/Shortcuts only**: Smaller scope - Rejected; it would make priority unsettable for someone using the app directly, undermining the focus goal.++### Consequences++**Positive:**+- Priority is settable through every task entry point (app, MCP, intents).++**Negative:**+- Adds a control to two more screens and their tests.++---++## Decision 5: Priority query filtering — multi-value on MCP, single-value on the intent++**Date**: 2026-06-05+**Status**: accepted++### Context++The explicit ask for MCP was to return priority and allow setting it. The existing query surfaces filter by `status` and `type`, and their cardinality differs *per surface*: the MCP `query_tasks` tool reads an untyped `[String: Any]` and accepts `status` as an array (`statuses: [String]?`), while the App Intent `QueryTasksIntent` decodes a typed `Codable` `QueryFilters` struct in which **every** filter — `status` and `type` — is scalar (`String?`). There is no array filter on the intent surface.++### Decision++`query_tasks` (MCP) filters by one or more priority values (multi-value array, mirroring the MCP `status` array). The query-tasks **intent** filters by a single priority value (scalar `String?`, mirroring its own `status`/`type` fields). Both reading and setting priority are supported on both surfaces; only the query-filter cardinality differs.++### Rationale++Each surface mirrors its own existing convention, which is the least surprising and lowest-risk choice. On MCP, accepting an array is free (untyped args, defensive single-or-array parsing already used for `status`). On the intent, every filter is a scalar field on a `Codable` struct; adding a multi-value `priority` would require custom single-or-array `Codable` decoding (a bare `"high"` would otherwise throw a generic decode error before priority-specific validation runs). That complexity is not justified — an agent needing multi-value filtering can use MCP. The resulting MCP-array / intent-scalar split is not a new inconsistency: it is exactly how `status` already behaves across the two surfaces. An earlier framing claimed the intent's `status` filter was multi-value; that was factually wrong and is corrected here.++### Alternatives Considered++- **Multi-value on the intent too**: Literal "one or more" everywhere - Rejected; needs a custom `StringOrStringArray` decoder (the riskiest new code in the feature) for a use case MCP already covers.+- **Return/set only, no query filter**: Matches the literal MCP request - Rejected; server-side filtering directly serves the focus goal and matches how `status` already works.++### Consequences++**Positive:**+- Each surface follows its own established filter convention; no custom decoding.+- MCP agents can request a priority subset in one call.++**Negative:**+- The intent filters one priority at a time (an agent wanting "high or medium" via Shortcuts must issue two queries or use MCP).+- MCP-array vs intent-scalar asymmetry persists — but it matches the existing `status` filter, so it introduces nothing new.++---++## Decision 9: Dedicated `INVALID_PRIORITY` error code++**Date**: 2026-06-05+**Status**: accepted++### Context++When a priority value outside {low, medium, high} reaches the MCP tools or the JSON intents, an error must be returned. The codebase already defines dedicated per-enum error codes `INVALID_TYPE` and `INVALID_STATUS`, used in exactly the validation flows priority joins (e.g. `CreateTaskIntent`, `QueryTasksIntent`).++### Decision++Add a dedicated `INVALID_PRIORITY` error code to the intent `IntentError` enum and the MCP error-code set, used for every invalid-priority condition (create, update, query filter).++### Rationale++Every other enum field gets its own error code; folding priority into the generic `INVALID_INPUT` would be the inconsistent choice and would deny clients the code-based handling they already get for `type` and `status`. Req 6.4 requires handling "consistent with the existing invalid-type handling," which means a dedicated code.++### Alternatives Considered++- **Reuse generic `INVALID_INPUT`**: Avoids adding an enum case - Rejected; breaks the established per-enum-code pattern and AC 6.4's consistency requirement.++### Consequences++**Positive:**+- Clients parse priority validation errors the same way as type/status errors.++**Negative:**+- One more error code to document (in CLAUDE.md's error list and the MCP/intent error tables).++---++## Decision 6: Board card shows a glyph only for low and high, not medium++**Date**: 2026-06-05+**Status**: accepted++### Context++The board card needs a priority glyph. Medium is the default, so the large majority of cards will be medium. The original idea was a glyph on every card (circle for medium, arrows for high/low).++### Decision++The board card displays a priority glyph only for low and high priority tasks. Medium tasks show no priority glyph. The glyph distinguishes low from high by both symbol shape and color (not color alone). Finalized glyphs: high = `arrow.up.circle.fill` tinted red; low = `arrow.down.circle.fill` tinted blue; medium = no glyph. The filter menu and detail view still represent medium (orange dot / text label), so medium remains discoverable there even though it is suppressed on the card.++### Rationale++A glyph on every card — most reading "medium" — adds visual noise across the entire board for no signal. Marking only low and high means the glyphs that appear actually indicate something worth noticing ("focus now" / "can wait"), while the neutral default stays clean. Distinguishing by shape as well as color keeps the indicator legible for colorblind users and lets VoiceOver name the level.++### Alternatives Considered++- **Glyph on all three (circle/up/down)**: The literal original description - Rejected; the medium glyph is noise on the majority of cards and dilutes the signal.+- **Color-only dot per level**: Compact - Rejected; color alone fails accessibility and is harder to read at a glance than shape + color.++### Consequences++**Positive:**+- Board stays uncluttered; high/low cards stand out.+- Shape + color differentiation supports accessibility.++**Negative:**+- A glance cannot distinguish "medium" from "priority not yet considered" on the card (both show no glyph); the detail view and edit screen show the explicit value.++---++## Decision 7: Priority shown on the detail view, excluded from reports++**Date**: 2026-06-05+**Status**: accepted++### Context++Beyond the board card and create/edit screens, priority could also appear on the task detail view and in generated reports of completed/abandoned tasks.++### Decision++The task detail view displays priority for all three levels (including medium). Generated reports are unchanged — priority is not included.++### Rationale++The detail view shows a single task's full data, so omitting priority there (when the card shows it) would be inconsistent; and unlike the dense board, a detail screen has room to show medium explicitly. Reports summarize completed/abandoned work where priority — a "what to do next" signal — has limited value, so it is left out to avoid scope and noise. Reports can add priority later if a need arises.++### Alternatives Considered++- **Include priority in reports**: Completeness - Rejected; priority is a forward-looking focus signal with little value in a completed-work summary, and it widens scope.+- **Board card only (no detail view)**: Smallest scope - Rejected; showing priority on the card but not the detail view would be inconsistent and confusing.++### Consequences++**Positive:**+- Priority is visible wherever a user inspects a task interactively.+- Report logic and format are untouched.++**Negative:**+- Completed-task reports carry no record of priority.++---++## Decision 8: Priority is non-clearable; omitting it on update leaves it unchanged++**Date**: 2026-06-05+**Status**: accepted++### Context++On the MCP `update_task` tool and the update-task intent, every field has explicit "omitted = no change" semantics (the existing per-field contract in `TaskUpdateValidator`). Priority is always one of three values with medium as the default, so there is no meaningful "no priority" state to clear to.++### Decision++Omitting priority on an update leaves the task's current priority unchanged. Priority cannot be cleared to an empty/absent value; it can only be set to one of low, medium, or high.++### Rationale++Without this rule, an implementation could default priority to medium on every update, silently downgrading a high-priority task during an unrelated edit. Making omit mean "unchanged" follows the codebase's established per-field update contract and prevents that data-loss footgun. Since medium is both the default and a real selectable value, "set to medium" must be expressible explicitly and distinct from "leave unchanged."++### Alternatives Considered++- **Default to medium on every update**: Simpler to implement - Rejected; it silently resets priority on unrelated edits, losing user/agent intent.+- **Allow clearing priority to absent**: Symmetry with nullable fields - Rejected; there is no "no priority" state (the accessor always yields a value), so a clear operation has no meaning.++### Consequences++**Positive:**+- Unrelated updates never disturb priority.+- Matches the existing per-field update semantics, so the API behaves predictably.++**Negative:**+- Callers must send an explicit `medium` to set medium; "unchanged" and "set to medium" are different operations they must distinguish.
diff --git a/specs/task-priority/design.md b/specs/task-priority/design.mdnew file mode 100644index 0000000..5fc2fc6--- /dev/null+++ b/specs/task-priority/design.md@@ -0,0 +1,151 @@+# Design: Task Priority (T-1463)++## Overview++Add a `priority` field (low/medium/high, default medium) to `TransitTask`, mirroring the existing `type` field, and surface it on the board card (glyph), the board filter, the in-app create/edit/detail screens, the MCP server, and the JSON App Intents. No data migration: legacy tasks read as medium through a computed accessor.++## Architecture++The field threads through the same layers as `type`. The governing rule:++> **Effective-priority invariant (Req [1.4](requirements.md#1.4)):** every read — display, filter predicate, serialization — goes through the computed `task.priority` accessor (which falls back to medium), never the raw `priorityRawValue` string. This makes legacy tasks (absent/empty raw value) behave as medium everywhere, including matching a "medium" filter.+>+> **This invariant runs *against* the local convention.** The analogous `type`/`status` reads at the serialization and query-predicate sites use the raw value directly (`task.typeRawValue`, `task.statusRawValue`). Priority must **not** copy that. Every priority read site below is flagged "use `task.priority(.rawValue)`, NOT `priorityRawValue`." A faithful copy-paste of the neighbouring line is the one way to silently break Req 1.4, so the parity table calls out each such site explicitly, and the testing strategy has a dedicated legacy regression test.++Deviations from a literal `type` copy, and why:++| Aspect | `type` does | `priority` does | Why |+|---|---|---|---|+| Query filter cardinality | scalar on both surfaces | **MCP: multi-value array; intent: scalar** | Mirrors the existing `status` filter's actual per-surface cardinality (array on MCP, scalar on intent). Req [5.4](requirements.md#5.4) / [6.3](requirements.md#6.3), Decision 5 |+| Card rendering | text capsule (`TypeBadge`) | **bare SF Symbol glyph**, low/high only | Req [2.1](requirements.md#2.1)/[2.2](requirements.md#2.2); medium suppressed (Decision 6) |+| Organized-sort tie-break | participates (`compareOrganized`) | **does not** | Decision 3 — priority never affects ordering |+| Update semantics | Optional (omit = no change) | same (Optional, **not** `FieldChange`) | Decision 8 — non-clearable |+| Invalid-value error code | `INVALID_TYPE` | **`INVALID_PRIORITY`** (new, dedicated) | Decision 9 — parity with `INVALID_TYPE`/`INVALID_STATUS` |++### Pattern-extension parity audit++Sites that consume `type`/`typeRawValue`/`TaskType` and the priority decision for each. The **read-via-accessor** column flags sites where the invariant forces `task.priority`, against the local raw-value convention.++| Site | Priority change | Read via accessor? |+|---|---|---|+| `TransitTask.swift` `typeRawValue` (L11), `type` accessor (L28–31), `init` (L102–129) | **Yes** — `priorityRawValue` (default `"medium"`), `priority` accessor, defaulted init param | — (this *is* the accessor) |+| `Models/TaskType.swift` | **Yes** — new `Models/TaskPriority.swift` | — |+| `Views/Shared/TypeBadge.swift` | **Yes (new, different)** — `PriorityIndicator` (symbol, not capsule) | reads `task.priority` |+| `TaskCardView.swift` badges HStack (L48–75) | **Yes** — add `PriorityIndicator` | reads `task.priority` |+| `DashboardView.swift` `selectedTypes` plumbing (L9,41,50,58,116,195,393,470,481–483) | **Yes** — `selectedPriorities` + predicate | **Yes** — `selectedPriorities.contains(task.priority)` |+| `DashboardView.swift` `compareOrganized` (L452–455) | **No** — Decision 3 | — |+| `TypeFilterMenu.swift` | **Yes (new)** — `PriorityFilterMenu` | — |+| `AddTaskSheet.swift` (L19,115–119,171–180,263–275) + `+Save.swift` (L41,60–65) | **Yes** — state, Pickers, reset default, `TaskDraft.priority`, create call | — |+| `TaskEditView.swift` (L16,95–99,153–162,277,305–313) | **Yes** — state, Pickers, `load = task.priority`, save | reads `task.priority` on load |+| `TaskDetailView.swift` (L56,115–117) | **Yes** — priority row (text, all 3 levels) | reads `task.priority` |+| `TaskService.swift` `createTask` ×2, `updateTask` (L54,79,262,278) | **Yes** — `priority` param on all three | — |+| `MCPToolDefinitions.swift` create/update `.stringEnum` (L59,242) | **Yes** — `priority` enum prop | — |+| `MCPToolDefinitions.swift` query `status` `.array` (L108) | **Yes** — `priority` as `.array` (MCP only) | — |+| `MCPToolDefinitions.swift` query `type` `.stringEnum` (L117) | **No** — MCP priority query is array, not scalar | — |+| `MCPToolHandler.swift` handleCreateTask (L192–198,258–264) | **Yes** — parse (optional, default medium)/validate/apply | — |+| `MCPToolHandler.swift` create-task response (~L271) | **Yes** — response echoes `priority` (via `taskToDict`) | **Yes** |+| `MCPToolHandler.swift` handleUpdateTask (L836–891) | **No** — delegates to `TaskUpdateValidator` | — |+| `MCPToolHandler.swift` handleQueryTasks (L431–454) | **Yes** — validate array (`allowArray: true`), pass to filters | — |+| `MCPHelperTypes.swift` `MCPQueryFilters` (L9 add field; predicate beside L66) | **Yes** — `priorities: [String]?` + array predicate | **Yes** — compare `task.priority.rawValue`, NOT `priorityRawValue` |+| `Intents/CreateTaskIntent.swift` (L21–39,219–224, create call) | **Yes** — docs ×2, optional-with-default validation, create | — |+| `Intents/UpdateTaskIntent.swift` (L27–53) | **Yes** — docs ×2 (logic via validator) | — |+| `Intents/QueryTasksIntent.swift` `QueryFilters` scalar (L22–54,247–249,287–289,69–82) | **Yes** — scalar `priority: String?`, validate, apply, docs | **Yes** — compare `task.priority.rawValue`, NOT `priorityRawValue` |+| `Intents/TaskUpdateValidator.swift` (L193–205,312,319,103–117) | **Yes** — `validatePriority` (mirror `validateType`); add term to `apply`'s `hasFieldChange` (L103) in the **same** edit | — |+| `Intents/IntentHelpers.swift` `taskToDict` (L267), `taskUpdateResponseDict` (L312) | **Yes** — add `"priority"` | **Yes** — `task.priority.rawValue`, NOT `priorityRawValue` |+| `MCPToolHandler.swift` `milestoneToDict` task sub-dict (L1139); `QueryMilestonesIntent.swift` (L209) | **Yes** — add `"priority"` (serialization consistency) | **Yes** — `task.priority.rawValue`, NOT `priorityRawValue` |+| `Intents/Shared/Enums/TaskTypeAppEnum.swift`; Visual `AddTaskIntent`/`FindTasksIntent`; `TaskEntity` | **No** — JSON intents use the raw enum directly; Visual intents + `TaskPriorityAppEnum` out of scope | — |+| `Reports/*` (`taskType`); `TransitTask.shareText` (L62) | **No** — Non-Goal (priority not in reports/share text) | — |++## Components and Interfaces++### New: `Models/TaskPriority.swift`+```swift+import SwiftUI++enum TaskPriority: String, Codable, CaseIterable {+ case low, medium, high++ var tintColor: Color { … } // high .red, medium .orange, low .blue+ var glyphSymbol: String? { … } // high "arrow.up.circle.fill", low "arrow.down.circle.fill", medium nil+ var accessibilityLabel: String { … } // "High priority" / "Medium priority" / "Low priority"+}+```+- `glyphSymbol == nil` for medium is the single source of truth for "no card glyph" (Decision 6); the card reads it rather than re-deciding suppression.+- `tintColor` is defined for **all three** (medium `.orange`) because `PriorityFilterMenu` renders a colored dot per case, including medium.+- Display order in pickers and the filter menu is **high → medium → low** (iterate an explicit ordered array, not `allCases`, which is low-first), so the most actionable value reads first.++### Model: `TransitTask`+```swift+var priorityRawValue: String = "medium" // CloudKit-safe default (same shape as statusRawValue/typeRawValue)+var priority: TaskPriority { // effective accessor (the invariant)+ get { TaskPriority(rawValue: priorityRawValue) ?? .medium }+ set { priorityRawValue = newValue.rawValue }+}+// init gains: priority: TaskPriority = .medium → self.priorityRawValue = priority.rawValue+```+No production code reads `priorityRawValue` outside this accessor, the init, and the setter. (Enforced by review + the invariant test.)++### Service: `TaskService`+- `createTask(... , priority: TaskPriority = .medium, ...)` on both overloads → into `TransitTask` init.+- `updateTask(... , priority: TaskPriority? = nil, ...)` → `if let priority { task.priority = priority }` (omit = unchanged, Decision 8).++### UI+- **`PriorityIndicator`** (new, `Views/Shared/`): when `priority.glyphSymbol` is non-nil, an `Image(systemName:)` at `.font(.caption)`, `.foregroundStyle(priority.tintColor)`, `.accessibilityLabel(priority.accessibilityLabel)`, identifier `dashboard.taskCard.priority`; medium renders nothing. It is a bare symbol (the card's comment-count `Label` is the symbol precedent), **not** a `TypeBadge`-style text capsule. Slots into the `TaskCardView` badges HStack (L48–75). It reacts to `task.priority`, satisfying Req [2.3](requirements.md#2.3) (the glyph appears/disappears when priority crosses to/from medium via normal SwiftUI re-render). Medium has no glyph and so is **not** VoiceOver-discoverable from the card — by design (Req 2.2); the detail and edit screens expose medium explicitly.+- **`PriorityFilterMenu`** (new, `Views/Dashboard/`): mirrors `TypeFilterMenu` — `@Binding selectedPriorities: Set<TaskPriority>`, iterates the ordered [high, medium, low] with `Circle().fill(priority.tintColor)` + checkmark, identifiers `dashboard.filter.priorities` and `filter.priority.<raw>` (Req [3.7](requirements.md#3.7)). Added to `DashboardView` toolbar next to `TypeFilterMenu` (L116), with `@State selectedPriorities` (resets on launch → Req [3.5](requirements.md#3.5) ephemeral), inclusion in `hasAnyFilter`/`hasOtherFilters` (L41,50), clear-all (L195), and threading into `buildFilteredColumns`/`matchesFilters`.+- **Filter predicate** (in `matchesFilters`, beside the type predicate L481–483):+ ```swift+ if !selectedPriorities.isEmpty {+ guard selectedPriorities.contains(task.priority) else { return false } // computed accessor+ }+ ```+- **`AddTaskSheet`** / **`TaskEditView`**: `@State selectedPriority` (default `.medium`; edit loads `task.priority`), a `Picker("Priority", selection:)` over the ordered cases mirroring the existing Type picker on both iOS and macOS, threaded through `TaskDraft`/`createTask` and the `updateTask` call respectively.+- **`TaskDetailView`**: a Priority row in both layouts, rendered as **text for all three levels** (`task.priority.rawValue.capitalized`, like the Status row) so medium is visible (Req [4.4](requirements.md#4.4)).++### MCP+- **Definitions**: `priority` as `.stringEnum` on `create_task`/`update_task`; `priority` as `.array` on `query_tasks` (mirror `status`, L108).+- **handleCreateTask**: `args["priority"] as? String` defaulting to `"medium"` when absent; present-but-invalid → `INVALID_PRIORITY` error result, **no task created** (Req [5.5](requirements.md#5.5)); valid → pass to `createTask`. The create response serializes through `taskToDict`, so it echoes the resolved `priority`.+- **handleUpdateTask**: unchanged — `TaskUpdateValidator` carries priority.+- **handleQueryTasks**: validate via `validateEnumFilter(..., key: "priority", allowArray: true)` (like `status`), pass the parsed array to `MCPQueryFilters.from`.+- **`MCPQueryFilters`**: add `priorities: [String]?`, parsed in `from(args:)` accepting either a single string or an array (defensive, mirroring `status` L28–44 — this works because MCP reads an untyped `[String: Any]`). Predicate (beside status L66):+ ```swift+ if let priorities, !priorities.isEmpty, !priorities.contains(task.priority.rawValue) { return false }+ ```+ Compares the **computed** `task.priority.rawValue` (invariant), so legacy tasks match a `["medium"]` filter.+- **Serialization**: `IntentHelpers.taskToDict` and `taskUpdateResponseDict` add `"priority": task.priority.rawValue`; the `milestoneToDict`/`QueryMilestonesIntent` task sub-dicts add the same. All use the computed accessor.++### App Intents (JSON)+- **CreateTaskIntent**: add `priority` to both the `inputParameterDescription` and the `@Parameter` literal (lock-step, T-1170). Validation is an **optional-with-default** path — *not* a copy of the required-`type` force-unwrap: absent → `.medium`; present-and-invalid → `INVALID_PRIORITY` JSON error; present-and-valid → parse. Pass to `createTask`.+- **UpdateTaskIntent**: document `priority` in both literals; field logic via `TaskUpdateValidator`.+- **QueryTasksIntent**: add a **scalar** `priority: String?` to `QueryFilters` (matching the struct's existing scalar `status`/`type` — no custom `Codable` needed), a `validateEnumFilters` clause (→ `INVALID_PRIORITY`), an `applyFilters` clause (`task.priority.rawValue != priority`, computed accessor), and `@Parameter` docs.+- **TaskUpdateValidator**: `validatePriority` mirrors `validateType` exactly — absent → `.success(nil)`; non-string → invalid-input; bad raw → `INVALID_PRIORITY` listing `TaskPriority.allCases`; carried as `ValidatedTaskUpdate.priority: TaskPriority?`, counted in `hasChanges`, and — in the **same edit** — added to `apply`'s `hasFieldChange` check (L103) and passed to `updateTask(priority:)`. (The L99–102 comment warns that a field added to the struct but not to `apply` validates but never applies.) Use `Optional`, **not** `FieldChange` (non-clearable).++## Data Models++One new stored attribute (`priorityRawValue: String = "medium"`) on `TransitTask`. CloudKit add-only: non-optional with a default, no `@Attribute(.unique)`, no relationship — the identical shape to the already-shipping `statusRawValue`/`typeRawValue`, which sync through CloudKit in production today. New records get the schema default; the computed accessor's `?? .medium` covers any record whose raw value is empty or an unrecognized string. No `VersionedSchema`/migration entry is required.++## Error Handling++New dedicated error code **`INVALID_PRIORITY`**, added to the intent `IntentError` enum and the MCP error-code set, parallel to `INVALID_TYPE`/`INVALID_STATUS` (Decision 9).++| Condition | Surface | Behavior |+|---|---|---|+| Invalid priority on `create_task` | MCP | `INVALID_PRIORITY` error result, no task created (Req [5.5](requirements.md#5.5)) |+| Invalid priority on create/update intent | Intent | JSON `INVALID_PRIORITY`, no mutation (Req [6.4](requirements.md#6.4)) |+| Invalid priority in query filter | MCP/Intent | `INVALID_PRIORITY` validation error |+| Omitted priority on update | MCP/Intent | unchanged (Decision 8) |++Validation messages list `low, medium, high`; matching is exact-lowercase (Req Value Format).++## Testing Strategy++Swift Testing, `@MainActor @Suite(.serialized)`, fresh context via `TestModelContainer.newContext()`.++- **Enum / model**: `TaskPriority` raw values, `allCases`, `tintColor` (all three), `glyphSymbol` (nil only for medium); accessor fallback — empty/unknown `priorityRawValue` → `.medium`.+- **Effective-priority invariant (Req 1.4) — the legacy-backfill regression test**: a task with `priorityRawValue = ""` is (a) serialized as `"medium"` by `taskToDict` and `taskUpdateResponseDict`, and (b) included by a `["medium"]`/`"medium"` filter on every read surface — board `matchesFilters`, `MCPQueryFilters.matches`, and intent `applyFilters`. This is the guard against a raw-value copy-paste.+- **Service**: create defaults to medium and honors explicit value; update sets priority; update omitting priority leaves it unchanged.+- **`TaskUpdateValidator`**: absent → no change; invalid → `INVALID_PRIORITY`; valid → applied (proves the `hasFieldChange` term was added). Extend `UpdateTaskAllFieldsParityTests` so MCP and intent updates stay in lockstep on priority.+- **MCP**: create with/without priority (response echoes it); create with invalid priority → `INVALID_PRIORITY`, no creation; query returns priority; query filters by a single value and by an array; invalid filter value rejected.+- **Intents**: create default/explicit/invalid; update set/omit; query scalar filter (single value; invalid rejected); JSON error shape carries `INVALID_PRIORITY`.+- **Dashboard filter** (`DashboardFilterTests`): single and multi priority selection; empty set = all; intersection with project and type filters.++PBT is not used: the only universal property (raw-value round-trip) is over three cases and is covered exhaustively by example tests.
diff --git a/specs/task-priority/implementation.md b/specs/task-priority/implementation.mdnew file mode 100644index 0000000..3954d98--- /dev/null+++ b/specs/task-priority/implementation.md@@ -0,0 +1,222 @@+# Implementation Explanation: Task Priority (T-1463)++Three-level walkthrough of the task-priority feature as implemented on+`T-1463/task-priority`, followed by a completeness assessment.++---++## Beginner Level++### What This Does++Every task in Transit now has a **priority**: low, medium, or high. New tasks+default to medium. You can:++- See a small coloured arrow on a task card — a red up-arrow for high, a blue+ down-arrow for low. Medium shows nothing (so the board isn't cluttered with+ arrows on the most common case).+- Filter the board to show only tasks of certain priorities.+- Set priority when creating or editing a task, and see it on the task detail+ screen.+- Read and set priority from automation — both the MCP server (used by agents)+ and Shortcuts/CLI App Intents.++### Why It Matters++Before this, every task looked equally urgent. Priority lets a single user (and+their agents) tell at a glance what to do next, and filter the board down to the+things that matter most.++### Key Concepts++- **Enum**: a fixed set of named choices. `TaskPriority` is an enum with exactly+ three values: `low`, `medium`, `high`. You can't accidentally set priority to+ "urgent" or "P1" — only those three.+- **Default value**: existing tasks created before this feature have no priority+ stored. Rather than run a database migration, the code treats any+ missing/blank value as `medium`. So old tasks "just work" and read as medium.+- **CloudKit**: Transit syncs across devices via Apple's CloudKit. CloudKit+ requires every new field to have a default value — here, the stored priority+ string defaults to `"medium"`.++---++## Intermediate Level++### Changes Overview++The feature was built in four layers, each verified before the next:++1. **Foundation** (`Models/`, `Services/`)+ - New `TaskPriority` enum (`Models/TaskPriority.swift`): `String, Codable,+ CaseIterable`, mirroring the existing `TaskType` enum. It owns its own+ presentation: `tintColor` (red/orange/blue), `glyphSymbol` (the SF Symbol+ for the card; `nil` for medium), `accessibilityLabel`, and a `displayOrder`+ array (`[.high, .medium, .low]`) for pickers/filters.+ - `TransitTask` gains a stored `priorityRawValue: String = "medium"` and a+ computed `priority: TaskPriority` accessor that falls back to `.medium` for+ any absent/empty/unrecognized raw value. The initializer takes+ `priority: TaskPriority = .medium`.+ - `TaskService.createTask` (both overloads) accepts a defaulted priority;+ `updateTask` accepts an optional `priority` where omitting it leaves the+ stored value unchanged.++2. **Automation** (`Intents/`, `MCP/`)+ - A dedicated `INVALID_PRIORITY` error code (`IntentError`), shared by MCP and+ App Intents.+ - `TaskUpdateValidator.validatePriority` (mirrors `validateType`) threads an+ optional priority through the shared validate/apply pipeline that both+ `update_task` (MCP) and `UpdateTaskIntent` use.+ - MCP `create_task` parses/defaults priority and echoes it; `query_tasks`+ filters by a multi-value priority array; `update_task` accepts priority;+ schemas updated in `MCPToolDefinitions`.+ - App Intents: `CreateTaskIntent` (optional-with-default parse + echo),+ `QueryTasksIntent` (scalar priority filter), `UpdateTaskIntent` (via the+ validator). Serialization (`IntentHelpers.taskToDict` /+ `taskUpdateResponseDict`) emits `priority`.++3. **UI** (`Views/`)+ - `PriorityFilterMenu` (mirrors `TypeFilterMenu`) — multi-select, ordered+ high→medium→low, wired into `DashboardView`'s ephemeral filter state and the+ `DashboardLogic` predicate.+ - `PriorityIndicator` — the card glyph, rendered for high/low only.+ - Priority pickers in `AddTaskSheet` and `TaskEditView`; a priority row in+ `TaskDetailView`.++4. **Verification** (`TransitTests/`)+ - `EffectivePriorityInvariantTests` — one cross-surface regression test+ proving a legacy (`priorityRawValue = ""`) task reads/serializes/filters as+ medium on every read surface.++### Implementation Approach++The guiding principle was **mirror the existing `type` field exactly**. `type`+already had this shape — an enum owning its presentation, a `*RawValue` stored+property with a computed accessor, service params, MCP/intent plumbing, a filter+menu, a picker, a detail row — so `priority` follows the same template+throughout. This keeps the change idiomatic and low-risk: a reviewer who knows+how `type` works already knows how `priority` works.++The one deliberate deviation from `type`: priority gets its **own** error code+(`INVALID_PRIORITY`) rather than reusing `INVALID_TYPE`, and pickers/filters+iterate `displayOrder` (high-first) rather than `allCases` (source order).++### Trade-offs++- **Default-value backfill vs. migration**: chosen to avoid a CloudKit schema+ migration (which is add-only and risky). Cost: the "effective priority" is a+ computed concept, not a stored one, so *every* read surface must go through the+ computed accessor — a discipline enforced by the regression test rather than+ the type system.+- **Non-clearable priority on update** (Decision 8): `updateTask`'s priority is+ `Optional` where `nil` means "don't change," not "clear to default." There's no+ way to "unset" priority back to absent — it's always one of the three values.+ Simpler than a tri-state (`FieldChange`) and matches user expectations.+- **`PriorityFilterMenu` as a near-copy of `TypeFilterMenu`** rather than a+ generic `EnumFilterMenu<E>`: two similar menus don't justify the abstraction;+ a third would.++---++## Expert Level++### Technical Deep Dive++**The effective-priority invariant (Req 1.4) is the load-bearing design choice.**+Because legacy tasks have no stored priority, "priority" is defined as+`TaskPriority(rawValue: priorityRawValue) ?? .medium`, computed at every read.+The failure mode this guards against is a copy-paste that reads `priorityRawValue`+directly on one surface — that surface would then treat a legacy task as having+an empty/invalid priority and drop it from a `medium` filter, silently breaking+sync-era tasks. The mitigation is structural:++- `priorityRawValue` is read *only* in the accessor getter, the initializer, and+ the setter. Every other site — board `matchesFilters`, `MCPQueryFilters.matches`,+ `IntentHelpers.taskToDict`/`taskUpdateResponseDict`, `QueryTasksIntent`'s+ `applyFilters`, the milestone sub-dicts — goes through `task.priority` /+ `task.priority.rawValue`.+- `EffectivePriorityInvariantTests` asserts a `priorityRawValue = ""` task+ reads/serializes/filters as medium across all five read surfaces plus the+ accessor. This is the regression guard; the invariant is a property of test+ coverage, not the compiler.++**Glyph suppression has a single source of truth** (Decision 6):+`TaskPriority.glyphSymbol` returns `nil` for medium, and `PriorityIndicator`+renders nothing when it's `nil`. The view never re-decides "is this medium?" — it+asks the enum. Adding a fourth priority later would change one switch, not the+view.++**Error-code surfacing is asymmetric and intentional.** A *non-string* priority+in an update surfaces as `INVALID_INPUT`, while a *string that isn't a valid+level* surfaces as `INVALID_PRIORITY`. This exactly matches `validateType`'s+behaviour, so the four surfaces (MCP create/update, intent create/update) are+consistent with each other and with the pre-existing `type` handling.++**The `update_task`/`UpdateTaskIntent` parity** is preserved: both route through+`TaskUpdateValidator` + `IntentHelpers.taskUpdateResponseDict`, so adding priority+to the validation pipeline automatically gave both surfaces identical+priority-update semantics and byte-equivalent responses (covered by+`UpdateTaskAllFieldsParityTests`).++### Architecture Impact++- **No new coupling.** Priority rides the existing `type` pathways; no service or+ view gained a new dependency. `DashboardView`'s filter inputs were bundled into+ a `DashboardLogic.FilterSelection` value type to absorb the new dimension+ without growing the predicate's parameter list — a net readability improvement.+- **Contract evolution, not break.** The `CreateTaskIntent` JSON response gained a+ `priority` key. Existing keys are unchanged, so consumers that ignore unknown+ keys stay compatible; the `createTaskIntentJsonContractRemainsCompatible` test+ was updated to assert the new key (count 3→4).+- **Extensibility.** A new priority level is a one-line enum change plus its+ presentation (`tintColor`/`glyphSymbol`/`accessibilityLabel`) and a+ `displayOrder` entry. All read/filter/serialize sites pick it up automatically.++### Potential Issues to Monitor++- **The invariant is test-enforced, not type-enforced.** Any future surface that+ reads tasks must use the computed accessor. The regression test catches the+ five current surfaces; a new surface needs its own assertion (or to be added to+ the invariant test).+- **`IntentHelpers.swift` and `TaskUpdateValidator.swift`** now carry+ `swiftlint:disable file_length` and are growing by accretion. Not a defect, but+ they're trending toward a future split.+- **No `AppEnum`/`Sendable` conformance** was added for `TaskPriority` — the+ visual App Intents (`AddTaskIntent`/`FindTasksIntent`) and `TaskEntity`+ deliberately do not expose priority yet. If a future task wants priority in the+ Shortcuts visual UI, that conformance (and the `@MainActor`-isolation handling+ the project's Swift rules document) will be needed.++---++## Completeness Assessment++**Fully implemented** (all of requirements 1–6, verified by `make test-quick` —+1356 checks passing, and `make lint` — 0 violations):++- Model: stored `priorityRawValue` + computed accessor + init param; medium+ default; no migration (Req 1).+- Effective-priority invariant on every read surface (Req 1.4), with a dedicated+ cross-surface regression test.+- Board card glyph for high/low, medium suppressed (Req 2).+- Board priority filter — multi-select, high→medium→low, ephemeral, in clear-all+ (Req 3).+- In-app create/edit pickers and detail row (Req 4).+- MCP `create_task` (default/echo/invalid-rejects), `query_tasks` (array filter),+ `update_task` (omit-leaves-unchanged) (Req 5).+- App Intents `CreateTaskIntent`/`QueryTasksIntent`/`UpdateTaskIntent` with+ `INVALID_PRIORITY` and lock-step `@Parameter` docs (Req 6).++**Partially implemented / deliberately scoped out** (documented in the decision+log and design parity table, not gaps):++- Priority is **not** exposed in the visual App Intents (`AddTaskIntent`,+ `FindTasksIntent`) or `TaskEntity` — intentional; those keep their current+ surface.+- Priority is **not** part of report output or task share text — out of scope.+- Priority does **not** affect the "Organized" sort (Decision 3) — sort behaviour+ is unchanged.++**Missing**: nothing required by the spec. The only non-spec items are UI-level+assertions for the new accessibility identifiers (consistent with the project's+existing practice of not UI-testing SwiftUI view internals).
diff --git a/specs/task-priority/requirements.md b/specs/task-priority/requirements.mdnew file mode 100644index 0000000..7c5fa9f--- /dev/null+++ b/specs/task-priority/requirements.md@@ -0,0 +1,95 @@+# Requirements: Task Priority (T-1463)++## Introduction++Transit tasks currently have no way to express relative importance, so there is nothing on the board that signals what to focus on now versus what can wait. This feature adds a single `priority` field to every task with three values — low, medium, high — defaulting to medium. Priority is shown on board cards as a glyph (for non-default priorities), is filterable on the board, is shown on the task detail view, is editable in the app's create/edit screens, and is readable, settable, and filterable through the MCP server and App Intents.++## Value Format++Across the model, MCP, and App Intents, a priority value is one of the exact lowercase strings `low`, `medium`, or `high`. Matching is exact and case-sensitive (no trimming or case-folding), consistent with the existing task `type` handling.++## Non-Goals++- No priority levels beyond low / medium / high (no "urgent", "none", or numeric scale).+- Priority does not change task ordering or sorting on the board — filtering is the intended focus mechanism.+- No priority glyph on the board card for medium-priority tasks (the default majority); only low and high are marked.+- No priority-driven notifications, reminders, or escalation.+- No per-project or per-type default priority configuration; the default is always medium.+- No bulk / multi-select priority editing on the board.+- No history or audit trail of priority changes.+- No data migration or write-backfill pass for existing tasks; they read as medium via the model default and computed fallback (see Requirement 1, which makes this an explicit, testable behavior rather than an omission).+- Priority is not shown in generated reports of completed/abandoned tasks.+- The new field does not read, migrate, or relate to any informal `priority=` convention previously placed in a task's description or freeform metadata; the freeform metadata field is not a priority source of truth.++## Requirements++### 1. Priority on the task model (including existing tasks)++**User Story:** As a user, I want every task — including ones created before this feature — to carry a priority of low, medium, or high, so that I can distinguish what to focus on.++**Acceptance Criteria:**++1. <a name="1.1"></a>The system SHALL associate every task with exactly one priority value from the set {low, medium, high}. +2. <a name="1.2"></a>WHEN a task is created without an explicit priority, the system SHALL assign it medium priority. +3. <a name="1.3"></a>The system SHALL treat any existing task that has no stored priority value as medium priority, WITHOUT performing a data migration or write to those tasks. +4. <a name="1.4"></a>All priority reads — board display, detail view, filtering, and MCP/intent serialization — SHALL use the task's effective priority (medium when unset), so that filtering for medium includes tasks that have no stored priority value. +5. <a name="1.5"></a>The system SHALL persist a task's priority so that it syncs across the user's devices. ++### 2. Priority glyph on the board card++**User Story:** As a user, I want each board card to show its priority as a glyph, so that I can see at a glance what to focus on.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN a task is high or low priority, the board card SHALL display a priority glyph that distinguishes the two levels by both symbol shape and color (not color alone). +2. <a name="2.2"></a>WHEN a task is medium priority, the board card SHALL NOT display a priority glyph. +3. <a name="2.3"></a>The priority glyph SHALL update to reflect the task's current priority whenever it changes, including appearing or disappearing as the priority crosses to or from medium. +4. <a name="2.4"></a>The priority glyph SHALL expose a VoiceOver accessibility label naming the priority level (e.g. "High priority") and a stable accessibility identifier, consistent with existing card elements. ++### 3. Priority filter on the board++**User Story:** As a user, I want to filter the board by priority, so that I can narrow the board to the tasks that matter now.++**Acceptance Criteria:**++1. <a name="3.1"></a>The board's existing filter affordance SHALL offer a priority filter with low, medium, and high options. +2. <a name="3.2"></a>WHEN one or more priorities are selected, the board SHALL display only tasks whose priority is among the selected values. +3. <a name="3.3"></a>WHEN no priority is selected, the board SHALL display tasks of all priorities. +4. <a name="3.4"></a>The priority filter SHALL combine with the existing project and type filters by intersection (a task must match every active filter dimension). +5. <a name="3.5"></a>The priority filter selection SHALL be ephemeral, resetting on app launch, consistent with the existing filters. +6. <a name="3.6"></a>The priority filter SHALL match the interaction pattern of the existing board type filter (multi-select toggle within the filter popover). +7. <a name="3.7"></a>The priority filter control SHALL expose accessibility identifiers consistent with the existing filter controls so it is reachable in UI tests and by VoiceOver. ++### 4. In-app priority display and editing++**User Story:** As a user, I want to see and set a task's priority inside the app, so that I can manage focus without external tools.++**Acceptance Criteria:**++1. <a name="4.1"></a>The task creation screen SHALL provide a priority control defaulting to medium. +2. <a name="4.2"></a>The task edit screen SHALL provide a priority control pre-set to the task's current priority and allowing selection of any of the three values. +3. <a name="4.3"></a>WHEN the user changes priority on the edit screen and saves, the system SHALL persist the new priority. +4. <a name="4.4"></a>The task detail screen SHALL display the task's current priority for all three levels (including medium). ++### 5. MCP server support++**User Story:** As an agent, I want to read, set, and filter by task priority through the MCP server, so that automation can manage focus.++**Acceptance Criteria:**++1. <a name="5.1"></a>The `create_task` tool SHALL accept an optional priority value and SHALL default to medium when it is omitted. +2. <a name="5.2"></a>The `update_task` tool SHALL accept a priority value and apply it to the identified task; WHEN priority is omitted the task's priority SHALL be left unchanged, and priority SHALL NOT be clearable to an empty value. +3. <a name="5.3"></a>The `query_tasks` tool SHALL include each task's priority in its result; this addition SHALL be backward compatible so existing clients can ignore the field. +4. <a name="5.4"></a>The `query_tasks` tool SHALL support filtering by one or more priority values (multi-value, matching the existing `status` filter); WHEN no priority filter is supplied, results SHALL include all priorities. +5. <a name="5.5"></a>WHEN an MCP tool receives a priority value outside {low, medium, high}, the system SHALL return a validation error and SHALL NOT create or modify any task. ++### 6. App Intents support++**User Story:** As a Shortcuts/CLI user, I want priority available in the task intents, so that automation through Shortcuts matches the MCP capability.++**Acceptance Criteria:**++1. <a name="6.1"></a>The create-task intent SHALL accept an optional priority value and SHALL default to medium when it is omitted. +2. <a name="6.2"></a>The update-task intent SHALL accept a priority value and apply it; WHEN priority is omitted the task's priority SHALL be left unchanged, and priority SHALL NOT be clearable to an empty value. +3. <a name="6.3"></a>The query-tasks intent SHALL include priority in returned task data and SHALL support filtering by a single priority value, matching the intent's existing single-value `status` and `type` filters. (Multi-value priority filtering is provided by the MCP `query_tasks` tool, AC 5.4.) +4. <a name="6.4"></a>WHEN a task intent receives a priority value outside {low, medium, high}, the system SHALL return a JSON-encoded validation error consistent with the existing invalid-type handling, and SHALL NOT create or modify any task.
diff --git a/specs/task-priority/tasks.md b/specs/task-priority/tasks.mdnew file mode 100644index 0000000..5ec07fd--- /dev/null+++ b/specs/task-priority/tasks.md@@ -0,0 +1,191 @@+---+references:+ - specs/task-priority/requirements.md+ - specs/task-priority/design.md+ - specs/task-priority/decision_log.md+---+# Task Priority (T-1463)++## Foundation++- [x] 1. Write tests for TaskPriority enum <!-- id:3dib3xi -->+ - Raw values low/medium/high; allCases ordering+ - tintColor for all three (high .red, medium .orange, low .blue)+ - glyphSymbol returns nil ONLY for medium; arrow.up.circle.fill for high, arrow.down.circle.fill for low+ - accessibilityLabel strings: 'High priority' etc.+ - Stream: 1+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.4](requirements.md#2.4)+ - References: specs/task-priority/design.md++- [x] 2. Implement TaskPriority enum in Models/TaskPriority.swift <!-- id:3dib3xj -->+ - String, Codable, CaseIterable; import SwiftUI for Color+ - Mirror TaskType shape+ - Blocked-by: 3dib3xi (Write tests for TaskPriority enum)+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.4](requirements.md#2.4)+ - References: specs/task-priority/design.md++- [x] 3. Write tests for TransitTask priority accessor <!-- id:3dib3xk -->+ - Default priorityRawValue is medium+ - Empty or unrecognized raw value reads as .medium via computed accessor+ - Setter writes rawValue; init param sets priorityRawValue+ - Blocked-by: 3dib3xj (Implement TaskPriority enum in Models/TaskPriority.swift)+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5)+ - References: specs/task-priority/design.md++- [x] 4. Add priorityRawValue stored property, priority computed accessor, and init param to TransitTask <!-- id:3dib3xl -->+ - priorityRawValue: String = medium (CloudKit-safe default, same shape as statusRawValue/typeRawValue)+ - init gains priority: TaskPriority = .medium+ - No production reads of priorityRawValue outside accessor/init/setter+ - Blocked-by: 3dib3xk (Write tests for TransitTask priority accessor)+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5)+ - References: specs/task-priority/design.md, specs/task-priority/decision_log.md++- [x] 5. Write tests for TaskService priority create/update <!-- id:3dib3xm -->+ - createTask defaults to medium and honors explicit value+ - updateTask sets priority+ - updateTask omitting priority leaves it unchanged (Decision 8)+ - Blocked-by: 3dib3xl (Add priorityRawValue stored property, priority computed accessor, and init param to TransitTask)+ - Stream: 1+ - Requirements: [1.2](requirements.md#1.2), [5.2](requirements.md#5.2)+ - References: specs/task-priority/design.md++- [x] 6. Add priority param to TaskService createTask (both overloads) and updateTask <!-- id:3dib3xn -->+ - createTask(..., priority: TaskPriority = .medium); thread into TransitTask init+ - updateTask(..., priority: TaskPriority? = nil) -> if let priority { task.priority = priority }+ - Blocked-by: 3dib3xm (Write tests for TaskService priority create/update)+ - Stream: 1+ - Requirements: [1.2](requirements.md#1.2), [5.2](requirements.md#5.2)+ - References: specs/task-priority/design.md++## Automation++- [x] 7. Add INVALID_PRIORITY error code to IntentError enum and MCP error-code set <!-- id:3dib3xo -->+ - Parallel to INVALID_TYPE/INVALID_STATUS (Decision 9)+ - Wiring task; consumed by validator, MCP handler, and intents+ - Blocked-by: 3dib3xj (Implement TaskPriority enum in Models/TaskPriority.swift)+ - Stream: 1+ - Requirements: [5.5](requirements.md#5.5), [6.4](requirements.md#6.4)+ - References: specs/task-priority/decision_log.md++- [x] 8. Write tests for TaskUpdateValidator priority validation <!-- id:3dib3xp -->+ - Absent priority -> no change (.success(nil))+ - Invalid raw -> INVALID_PRIORITY+ - Valid -> applied (proves hasFieldChange term added so it cannot validate-but-not-apply)+ - Blocked-by: 3dib3xn (Add priority param to TaskService createTask (both overloads) and updateTask), 3dib3xo (Add INVALID_PRIORITY error code to IntentError enum and MCP error-code set)+ - Stream: 1+ - Requirements: [5.2](requirements.md#5.2), [6.2](requirements.md#6.2), [6.4](requirements.md#6.4)+ - References: specs/task-priority/design.md++- [x] 9. Implement validatePriority in TaskUpdateValidator <!-- id:3dib3xq -->+ - Mirror validateType exactly: Optional<TaskPriority>, NOT FieldChange (non-clearable)+ - Add ValidatedTaskUpdate.priority, hasChanges term, apply's hasFieldChange term (L103), pass to updateTask(priority:)+ - Covers BOTH MCP update_task and update intent+ - Blocked-by: 3dib3xp (Write tests for TaskUpdateValidator priority validation)+ - Stream: 1+ - Requirements: [5.2](requirements.md#5.2), [6.2](requirements.md#6.2), [6.4](requirements.md#6.4)+ - References: specs/task-priority/design.md, specs/task-priority/decision_log.md++- [x] 10. Write tests for MCP create_task and query_tasks priority <!-- id:3dib3xr -->+ - create with/without priority (response echoes resolved value); invalid priority -> INVALID_PRIORITY and NO task created+ - query_tasks returns priority; filters by single value and by array (multi-value)+ - Invalid query filter value rejected+ - Blocked-by: 3dib3xn (Add priority param to TaskService createTask (both overloads) and updateTask), 3dib3xo (Add INVALID_PRIORITY error code to IntentError enum and MCP error-code set)+ - Stream: 1+ - Requirements: [5.1](requirements.md#5.1), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5)+ - References: specs/task-priority/design.md++- [x] 11. Implement MCP priority support <!-- id:3dib3xs -->+ - MCPToolDefinitions: priority .stringEnum on create_task/update_task; priority .array on query_tasks (mirror status)+ - handleCreateTask: parse optional default medium, validate, apply; create response via taskToDict echoes priority+ - handleQueryTasks: validateEnumFilter allowArray:true; MCPQueryFilters gains priorities:[String]? + predicate comparing task.priority.rawValue (computed, NOT priorityRawValue)+ - IntentHelpers.taskToDict + taskUpdateResponseDict + milestoneToDict task sub-dict serialize task.priority.rawValue+ - Blocked-by: 3dib3xq (Implement validatePriority in TaskUpdateValidator), 3dib3xr (Write tests for MCP create_task and query_tasks priority)+ - Stream: 1+ - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [1.4](requirements.md#1.4)+ - References: specs/task-priority/design.md++- [x] 12. Write tests for JSON App Intents priority <!-- id:3dib3xt -->+ - Create: default medium / explicit / invalid -> INVALID_PRIORITY+ - Update: set and omit-unchanged; extend UpdateTaskAllFieldsParityTests for MCP/intent lockstep+ - Query: scalar single-value filter; invalid rejected+ - taskUpdateResponseDict carries priority+ - Blocked-by: 3dib3xq (Implement validatePriority in TaskUpdateValidator), 3dib3xs (Implement MCP priority support)+ - Stream: 1+ - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4)+ - References: specs/task-priority/design.md++- [x] 13. Implement JSON App Intents priority support <!-- id:3dib3xu -->+ - CreateTaskIntent: add to inputParameterDescription AND @Parameter literal (lockstep, T-1170); optional-with-default validation (absent->medium, invalid->INVALID_PRIORITY), pass to createTask+ - UpdateTaskIntent: document in both literals; logic via TaskUpdateValidator+ - QueryTasksIntent: scalar priority:String? on QueryFilters, validateEnumFilters clause, applyFilters compares task.priority.rawValue (computed), @Parameter docs+ - Blocked-by: 3dib3xt (Write tests for JSON App Intents priority)+ - Stream: 1+ - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4)+ - References: specs/task-priority/design.md, specs/task-priority/decision_log.md++## UI++- [x] 14. Write tests for dashboard priority filter predicate <!-- id:3dib3xv -->+ - DashboardLogic.buildFilteredColumns / matchesFilters: single and multi priority selection+ - Empty selection = all priorities pass+ - Intersection with project and type filters+ - Predicate uses task.priority (computed)+ - Blocked-by: 3dib3xl (Add priorityRawValue stored property, priority computed accessor, and init param to TransitTask)+ - Stream: 2+ - Requirements: [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4)+ - References: specs/task-priority/design.md++- [x] 15. Implement PriorityFilterMenu and wire into DashboardView <!-- id:3dib3xw -->+ - New Views/Dashboard/PriorityFilterMenu.swift mirroring TypeFilterMenu; iterate ordered [high,medium,low] with Circle().fill(tintColor)+checkmark; ids dashboard.filter.priorities and filter.priority.<raw>+ - DashboardView: @State selectedPriorities; toolbar next to TypeFilterMenu; include in hasAnyFilter/hasOtherFilters; clear-all; thread into buildFilteredColumns/matchesFilters predicate+ - Ephemeral @State resets on launch+ - Blocked-by: 3dib3xv (Write tests for dashboard priority filter predicate)+ - Stream: 2+ - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7)+ - References: specs/task-priority/design.md++- [x] 16. Implement PriorityIndicator and add to TaskCardView <!-- id:3dib3xx -->+ - New Views/Shared/PriorityIndicator.swift: Image(systemName: glyphSymbol) for high/low, nothing for medium; .font(.caption), tintColor, accessibilityLabel, id dashboard.taskCard.priority+ - Bare symbol, NOT a TypeBadge text capsule; slot into TaskCardView badges HStack+ - Medium has no card glyph (by design; not VoiceOver-discoverable from card)+ - Blocked-by: 3dib3xl (Add priorityRawValue stored property, priority computed accessor, and init param to TransitTask)+ - Stream: 2+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)+ - References: specs/task-priority/design.md++- [x] 17. Add priority picker to AddTaskSheet <!-- id:3dib3xy -->+ - @State selectedPriority = .medium; Picker over ordered cases on iOS and macOS mirroring the Type picker+ - Add to AddTaskFormResetLogic.Defaults; thread through TaskDraft.priority into createTask+ - Blocked-by: 3dib3xn (Add priority param to TaskService createTask (both overloads) and updateTask)+ - Stream: 2+ - Requirements: [4.1](requirements.md#4.1)+ - References: specs/task-priority/design.md++- [x] 18. Add priority picker to TaskEditView <!-- id:3dib3xz -->+ - @State selectedPriority; load = task.priority; Picker iOS+macOS; save passes priority to updateTask(save:false) before the atomic save+ - Blocked-by: 3dib3xn (Add priority param to TaskService createTask (both overloads) and updateTask)+ - Stream: 2+ - Requirements: [4.2](requirements.md#4.2), [4.3](requirements.md#4.3)+ - References: specs/task-priority/design.md++- [x] 19. Add priority row to TaskDetailView <!-- id:3dib3y0 -->+ - Priority row in both iOS (LabeledContent) and macOS (FormRow) layouts+ - Render as text for all three levels (task.priority.rawValue.capitalized), like the Status row, so medium is visible+ - Blocked-by: 3dib3xl (Add priorityRawValue stored property, priority computed accessor, and init param to TransitTask)+ - Stream: 2+ - Requirements: [4.4](requirements.md#4.4)+ - References: specs/task-priority/design.md++## Verification++- [x] 20. Write effective-priority invariant cross-surface regression test <!-- id:3dib3y1 -->+ - A task with priorityRawValue = empty string (legacy) reads/serializes as medium and is matched by a medium filter on every read surface+ - Surfaces: board matchesFilters, MCPQueryFilters.matches, MCP taskToDict serialization, intent applyFilters, intent taskUpdateResponseDict+ - This is the guard against a raw-value copy-paste breaking Req 1.4+ - Blocked-by: 3dib3xs (Implement MCP priority support), 3dib3xu (Implement JSON App Intents priority support), 3dib3xw (Implement PriorityFilterMenu and wire into DashboardView)+ - Stream: 1+ - Requirements: [1.3](requirements.md#1.3), [1.4](requirements.md#1.4)+ - References: specs/task-priority/design.md
diff --git a/Transit/TransitTests/AddTaskSheetResetTests.swift b/Transit/TransitTests/AddTaskSheetResetTests.swiftindex 3a4f175..3619504 100644--- a/Transit/TransitTests/AddTaskSheetResetTests.swift+++ b/Transit/TransitTests/AddTaskSheetResetTests.swift@@ -23,6 +23,7 @@ struct AddTaskSheetResetTests { #expect(defaults.name.isEmpty) #expect(defaults.description.isEmpty) #expect(defaults.type == .feature)+ #expect(defaults.priority == .medium) #expect(defaults.milestone == nil) }
diff --git a/Transit/TransitTests/AddTaskSheetSaveErrorTests.swift b/Transit/TransitTests/AddTaskSheetSaveErrorTests.swiftindex 2f21a2c..61d44e8 100644--- a/Transit/TransitTests/AddTaskSheetSaveErrorTests.swift+++ b/Transit/TransitTests/AddTaskSheetSaveErrorTests.swift@@ -172,6 +172,7 @@ struct AddTaskSheetSaveErrorTests { name: "Orphan Candidate", description: nil, type: .bug,+ priority: .medium, projectID: projectA.id, milestone: milestoneInB )@@ -199,6 +200,7 @@ struct AddTaskSheetSaveErrorTests { name: "Happy Task", description: nil, type: .feature,+ priority: .medium, projectID: project.id, milestone: milestone )@@ -224,6 +226,7 @@ struct AddTaskSheetSaveErrorTests { name: "No Milestone Task", description: nil, type: .feature,+ priority: .medium, projectID: project.id, milestone: nil )
diff --git a/Transit/TransitTests/CreateTaskIntentTests.swift b/Transit/TransitTests/CreateTaskIntentTests.swiftindex 3a405a1..d20b5e9 100644--- a/Transit/TransitTests/CreateTaskIntentTests.swift+++ b/Transit/TransitTests/CreateTaskIntentTests.swift@@ -3,6 +3,9 @@ import SwiftData import Testing @testable import Transit +// swiftlint:disable file_length+// swiftlint:disable type_body_length+ @MainActor @Suite(.serialized) struct CreateTaskIntentTests { @@ -170,6 +173,65 @@ struct CreateTaskIntentTests { #expect(parsed["error"] as? String == "INVALID_INPUT") } + // MARK: - Priority (Req 6.1, 6.4)++ @Test func defaultsToMediumPriority() async throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)++ let input = """+ {"projectId":"\(project.id.uuidString)","name":"Task","type":"feature"}+ """++ let result = await CreateTaskIntent.execute(+ input: input, taskService: svc.task, projectService: svc.project+ )++ let parsed = try parseJSON(result)+ #expect(parsed["priority"] as? String == "medium")++ let tasks = try svc.task.fetchAllTasks()+ #expect(tasks.first?.priority == .medium)+ }++ @Test func honorsExplicitPriority() async throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)++ let input = """+ {"projectId":"\(project.id.uuidString)","name":"Task","type":"feature","priority":"high"}+ """++ let result = await CreateTaskIntent.execute(+ input: input, taskService: svc.task, projectService: svc.project+ )++ let parsed = try parseJSON(result)+ #expect(parsed["priority"] as? String == "high")++ let tasks = try svc.task.fetchAllTasks()+ #expect(tasks.first?.priority == .high)+ }++ @Test func invalidPriorityReturnsInvalidPriorityAndCreatesNoTask() async throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)++ let input = """+ {"projectId":"\(project.id.uuidString)","name":"Task","type":"feature","priority":"urgent"}+ """++ let result = await CreateTaskIntent.execute(+ input: input, taskService: svc.task, projectService: svc.project+ )++ let parsed = try parseJSON(result)+ #expect(parsed["error"] as? String == "INVALID_PRIORITY")++ let tasks = try svc.task.fetchAllTasks()+ #expect(tasks.isEmpty)+ }+ @Test func invalidTypeReturnsInvalidType() async throws { let svc = try makeServices() let project = makeProject(in: svc.context)@@ -342,3 +404,6 @@ struct CreateTaskIntentTests { #expect((parsed["hint"] as? String)?.contains("projectId") == true) } }++// swiftlint:enable type_body_length+// swiftlint:enable file_length
diff --git a/Transit/TransitTests/DashboardPriorityFilterTests.swift b/Transit/TransitTests/DashboardPriorityFilterTests.swiftnew file mode 100644index 0000000..ef870a8--- /dev/null+++ b/Transit/TransitTests/DashboardPriorityFilterTests.swift@@ -0,0 +1,125 @@+import Foundation+import Testing+@testable import Transit++/// Dashboard priority-filter predicate tests. Kept separate from+/// `DashboardFilterTests` so neither file exceeds SwiftLint's length limits.+@MainActor+struct DashboardPriorityFilterTests {++ // MARK: - Helpers++ private func makeProject(name: String = "Test", colorHex: String = "FF0000") -> Project {+ Project(name: name, description: "Description", gitRepo: nil, colorHex: colorHex)+ }++ private func makeTask(+ name: String = "Task",+ type: TaskType = .feature,+ priority: TaskPriority = .medium,+ project: Project+ ) -> TransitTask {+ let task = TransitTask(name: name, type: type, project: project, displayID: .provisional)+ task.statusRawValue = TaskStatus.idea.rawValue+ task.priority = priority+ return task+ }++ // MARK: - Priority filter [req 3.2, 3.3, 3.4]++ @Test func priorityFilterReducesToSelectedPriorities() {+ let project = makeProject()+ let lowTask = makeTask(name: "Low", priority: .low, project: project)+ let mediumTask = makeTask(name: "Medium", priority: .medium, project: project)+ let highTask = makeTask(name: "High", priority: .high, project: project)++ let columns = DashboardLogic.buildFilteredColumns(+ allTasks: [lowTask, mediumTask, highTask],+ selectedProjectIDs: [],+ selectedPriorities: [.high],+ now: .now+ )++ let ideaTasks = columns[.idea] ?? []+ #expect(ideaTasks.count == 1)+ #expect(ideaTasks[0].name == "High")+ }++ @Test func priorityFilterWithMultiplePrioritiesReturnsAllMatching() {+ let project = makeProject()+ let lowTask = makeTask(name: "Low", priority: .low, project: project)+ let mediumTask = makeTask(name: "Medium", priority: .medium, project: project)+ let highTask = makeTask(name: "High", priority: .high, project: project)++ let columns = DashboardLogic.buildFilteredColumns(+ allTasks: [lowTask, mediumTask, highTask],+ selectedProjectIDs: [],+ selectedPriorities: [.high, .low],+ now: .now+ )++ let ideaTasks = columns[.idea] ?? []+ #expect(ideaTasks.count == 2)+ let names = Set(ideaTasks.map(\.name))+ #expect(names == ["High", "Low"])+ }++ @Test func emptyPriorityFilterShowsAllPriorities() {+ let project = makeProject()+ let lowTask = makeTask(name: "Low", priority: .low, project: project)+ let mediumTask = makeTask(name: "Medium", priority: .medium, project: project)+ let highTask = makeTask(name: "High", priority: .high, project: project)++ let columns = DashboardLogic.buildFilteredColumns(+ allTasks: [lowTask, mediumTask, highTask],+ selectedProjectIDs: [],+ selectedPriorities: [],+ now: .now+ )++ let ideaTasks = columns[.idea] ?? []+ #expect(ideaTasks.count == 3)+ }++ /// A task with no stored priority (legacy) reads as medium via the computed+ /// accessor, so a `[.medium]` filter must include it. [req 1.4, 3.2]+ @Test func priorityFilterUsesComputedAccessorForLegacyTasks() {+ let project = makeProject()+ let legacyTask = makeTask(name: "Legacy", project: project)+ legacyTask.priorityRawValue = ""++ let columns = DashboardLogic.buildFilteredColumns(+ allTasks: [legacyTask],+ selectedProjectIDs: [],+ selectedPriorities: [.medium],+ now: .now+ )++ let ideaTasks = columns[.idea] ?? []+ #expect(ideaTasks.count == 1)+ #expect(ideaTasks[0].name == "Legacy")+ }++ // MARK: - Intersection with project and type filters [req 3.4]++ @Test func combinedPriorityProjectAndTypeFilterReturnsIntersection() {+ let projectA = makeProject(name: "A")+ let projectB = makeProject(name: "B")+ let match = makeTask(name: "Match", type: .bug, priority: .high, project: projectA)+ let wrongPriority = makeTask(name: "WrongPriority", type: .bug, priority: .low, project: projectA)+ let wrongType = makeTask(name: "WrongType", type: .feature, priority: .high, project: projectA)+ let wrongProject = makeTask(name: "WrongProject", type: .bug, priority: .high, project: projectB)++ let columns = DashboardLogic.buildFilteredColumns(+ allTasks: [match, wrongPriority, wrongType, wrongProject],+ selectedProjectIDs: [projectA.id],+ selectedTypes: [.bug],+ selectedPriorities: [.high],+ now: .now+ )++ let ideaTasks = columns[.idea] ?? []+ #expect(ideaTasks.count == 1)+ #expect(ideaTasks[0].name == "Match")+ }+}
diff --git a/Transit/TransitTests/EffectivePriorityInvariantTests.swift b/Transit/TransitTests/EffectivePriorityInvariantTests.swiftnew file mode 100644index 0000000..c63a077--- /dev/null+++ b/Transit/TransitTests/EffectivePriorityInvariantTests.swift@@ -0,0 +1,162 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++/// Cross-surface regression test for the effective-priority invariant (Req 1.4).+///+/// A legacy/pre-feature task — one whose `priorityRawValue` is the empty string+/// `""` — has no stored priority. The computed `task.priority` accessor must+/// resolve it to `.medium`, and EVERY read surface (display, filter predicate,+/// serialization) must go through that accessor rather than reading+/// `priorityRawValue` directly. This single test guards all five read surfaces+/// at once, so a raw-value copy-paste (the one way to silently break Req 1.4 on+/// a single surface) fails here:+///+/// 1. Board — `DashboardLogic.buildFilteredColumns` / `matchesFilters`+/// 2. MCP query filter — `MCPQueryFilters.matches`+/// 3. MCP serialization — `IntentHelpers.taskToDict`+/// 4. Intent query filter — `QueryTasksIntent` `applyFilters` (via `execute`)+/// 5. Intent update response — `IntentHelpers.taskUpdateResponseDict`+@MainActor @Suite(.serialized)+struct EffectivePriorityInvariantTests {++ // MARK: - Helpers++ private struct Services {+ let task: TaskService+ let project: ProjectService+ let milestone: MilestoneService+ let context: ModelContext+ }++ private func makeServices() throws -> Services {+ let context = try TestModelContainer.newContext()+ let store = InMemoryCounterStore()+ let allocator = DisplayIDAllocator(store: store)+ return Services(+ task: TaskService(modelContext: context, displayIDAllocator: allocator),+ project: ProjectService(modelContext: context),+ milestone: MilestoneService(modelContext: context, displayIDAllocator: allocator),+ context: context+ )+ }++ @discardableResult+ private func makeProject(in context: ModelContext, name: String = "Test Project") -> Project {+ let project = Project(name: name, description: "A test project", gitRepo: nil, colorHex: "#FF0000")+ context.insert(project)+ return project+ }++ /// Creates a legacy task: a normal task whose stored priority is then forced+ /// to the empty string. We set `priorityRawValue` directly (NOT the `priority`+ /// setter, which would write `"medium"`) because the empty raw value is the+ /// whole point of the regression.+ @discardableResult+ private func makeLegacyTask(+ in context: ModelContext, project: Project, displayId: Int = 1+ ) -> TransitTask {+ let task = TransitTask(name: "Legacy", type: .feature, project: project, displayID: .permanent(displayId))+ StatusEngine.initializeNewTask(task)+ task.priorityRawValue = ""+ context.insert(task)+ return task+ }++ private func parseJSONArray(_ string: String) throws -> [[String: Any]] {+ let data = try #require(string.data(using: .utf8))+ return try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]])+ }++ // MARK: - Accessor (the invariant's source of truth)++ /// Sanity check: the computed accessor resolves an empty raw value to medium.+ @Test func legacyTaskReadsAsMediumThroughAccessor() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let legacy = makeLegacyTask(in: svc.context, project: project)++ #expect(legacy.priorityRawValue == "")+ #expect(legacy.priority == .medium)+ }++ // MARK: - Surface 1: Board filter (DashboardLogic.matchesFilters)++ @Test func boardMediumFilterIncludesLegacyTask() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let legacy = makeLegacyTask(in: svc.context, project: project)++ let columns = DashboardLogic.buildFilteredColumns(+ allTasks: [legacy],+ selectedProjectIDs: [],+ selectedPriorities: [.medium],+ now: .now+ )++ let ideaTasks = columns[.idea] ?? []+ #expect(ideaTasks.count == 1)+ #expect(ideaTasks.first?.name == "Legacy")+ }++ // MARK: - Surface 3: MCP serialization (IntentHelpers.taskToDict)++ @Test func taskToDictSerializesLegacyAsMedium() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let legacy = makeLegacyTask(in: svc.context, project: project)++ let dict = IntentHelpers.taskToDict(legacy, formatter: ISO8601DateFormatter())++ #expect(dict["priority"] as? String == "medium")+ }++ // MARK: - Surface 4: Intent query filter (QueryTasksIntent.applyFilters)++ @Test func intentMediumFilterIncludesLegacyTask() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ makeLegacyTask(in: svc.context, project: project)++ let result = QueryTasksIntent.execute(+ input: "{\"priority\":\"medium\"}",+ projectService: svc.project,+ taskService: svc.task,+ milestoneService: svc.milestone+ )++ let parsed = try parseJSONArray(result)+ #expect(parsed.count == 1)+ #expect(parsed.first?["name"] as? String == "Legacy")+ #expect(parsed.first?["priority"] as? String == "medium")+ }++ // MARK: - Surface 5: Intent update response (IntentHelpers.taskUpdateResponseDict)++ @Test func taskUpdateResponseDictSerializesLegacyAsMedium() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let legacy = makeLegacyTask(in: svc.context, project: project)++ let dict = IntentHelpers.taskUpdateResponseDict(legacy)++ #expect(dict["priority"] as? String == "medium")+ }++ // MARK: - Surface 2: MCP query filter (MCPQueryFilters.matches) — macOS only++ #if os(macOS)+ @Test func mcpMediumFilterIncludesLegacyTask() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let legacy = makeLegacyTask(in: svc.context, project: project)++ let filters = MCPQueryFilters.from(+ args: ["priority": "medium"], type: nil, projectId: nil+ )++ #expect(filters.matches(legacy))+ }+ #endif+}
diff --git a/Transit/TransitTests/IntentCompatibilityAndDiscoverabilityTests.swift b/Transit/TransitTests/IntentCompatibilityAndDiscoverabilityTests.swiftindex 9584a74..b51f454 100644--- a/Transit/TransitTests/IntentCompatibilityAndDiscoverabilityTests.swift+++ b/Transit/TransitTests/IntentCompatibilityAndDiscoverabilityTests.swift@@ -108,7 +108,11 @@ struct IntentCompatibilityTests { #expect(parsed["taskId"] is String) #expect(parsed["status"] as? String == "idea") #expect(parsed["displayId"] is Int)- #expect(parsed.keys.count == 3)+ // Priority (T-1463) is now echoed in the create response, defaulting to+ // medium when the input omits it. Existing keys are unchanged, so the+ // contract stays backward-compatible for callers that ignore new keys.+ #expect(parsed["priority"] as? String == "medium")+ #expect(parsed.keys.count == 4) } @Test func updateStatusIntentJsonContractRemainsCompatible() throws {
diff --git a/Transit/TransitTests/IntentErrorTests.swift b/Transit/TransitTests/IntentErrorTests.swiftindex ea7ba6f..4af9d9e 100644--- a/Transit/TransitTests/IntentErrorTests.swift+++ b/Transit/TransitTests/IntentErrorTests.swift@@ -32,6 +32,11 @@ struct IntentErrorTests { #expect(error.code == "INVALID_TYPE") } + @Test func invalidPriorityCode() {+ let error = IntentError.invalidPriority(hint: "Unknown priority: urgent")+ #expect(error.code == "INVALID_PRIORITY")+ }+ @Test func invalidInputCode() { let error = IntentError.invalidInput(hint: "Missing required field: name") #expect(error.code == "INVALID_INPUT")
diff --git a/Transit/TransitTests/IntentHelpersTaskUpdateResponseDictTests.swift b/Transit/TransitTests/IntentHelpersTaskUpdateResponseDictTests.swiftindex 335650d..1cc01a8 100644--- a/Transit/TransitTests/IntentHelpersTaskUpdateResponseDictTests.swift+++ b/Transit/TransitTests/IntentHelpersTaskUpdateResponseDictTests.swift@@ -138,6 +138,28 @@ struct IntentHelpersTaskUpdateResponseDictTests { #expect(dict["type"] as? String == "chore") } + @Test func priorityAlwaysPresent() throws {+ let fix = try makeFixture()+ let task = makeTask(in: fix.context, project: fix.project)+ task.priority = .high++ let dict = IntentHelpers.taskUpdateResponseDict(task)++ #expect(dict["priority"] as? String == "high")+ }++ /// Effective-priority invariant (Req 1.4): a task with no stored priority+ /// serializes as "medium" through the computed accessor.+ @Test func priorityDefaultsToMediumWhenUnset() throws {+ let fix = try makeFixture()+ let task = makeTask(in: fix.context, project: fix.project)+ task.priorityRawValue = ""++ let dict = IntentHelpers.taskUpdateResponseDict(task)++ #expect(dict["priority"] as? String == "medium")+ }+ // MARK: - Conditionally-present keys (omitted when nil/empty) @Test func noDisplayId_omitsDisplayId() throws {
diff --git a/Transit/TransitTests/MCPPriorityTests.swift b/Transit/TransitTests/MCPPriorityTests.swiftnew file mode 100644index 0000000..940b9b7--- /dev/null+++ b/Transit/TransitTests/MCPPriorityTests.swift@@ -0,0 +1,176 @@+#if os(macOS)+import Foundation+import SwiftData+import Testing+@testable import Transit++/// MCP `create_task` / `query_tasks` priority coverage (Req 5.1, 5.3, 5.4, 5.5).+/// Priority filtering is multi-value on the MCP surface (mirrors `status`), and+/// every serialized task echoes its effective priority through the computed+/// accessor (Req 1.4).+@MainActor @Suite(.serialized)+struct MCPPriorityTests {++ // MARK: - create_task++ @Test func createTaskDefaultsToMediumAndEchoesIt() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "create_task",+ arguments: ["name": "Task", "type": "feature", "projectId": project.id.uuidString]+ ))++ let result = try MCPTestHelpers.decodeResult(response)+ #expect(result["priority"] as? String == "medium")+ }++ @Test func createTaskHonorsExplicitPriority() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "create_task",+ arguments: [+ "name": "Task", "type": "feature",+ "projectId": project.id.uuidString, "priority": "high"+ ]+ ))++ let result = try MCPTestHelpers.decodeResult(response)+ #expect(result["priority"] as? String == "high")++ // The created task itself must carry the requested priority.+ let tasks = try env.taskService.fetchAllTasks()+ #expect(tasks.count == 1)+ #expect(tasks.first?.priority == .high)+ }++ @Test func createTaskInvalidPriorityReturnsErrorAndCreatesNoTask() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "create_task",+ arguments: [+ "name": "Task", "type": "feature",+ "projectId": project.id.uuidString, "priority": "urgent"+ ]+ ))++ #expect(try MCPTestHelpers.isError(response))+ let message = try MCPTestHelpers.errorText(response)+ #expect(message.contains("priority"))++ // Req 5.5: no task is created on a validation failure.+ let tasks = try env.taskService.fetchAllTasks()+ #expect(tasks.isEmpty)+ }++ // MARK: - query_tasks serialization (Req 5.3)++ @Test func queryReturnsPriorityForEachTask() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)+ _ = try await env.taskService.createTask(+ name: "High", description: nil, type: .feature, project: project, priority: .high+ )+ _ = try await env.taskService.createTask(+ name: "Default", description: nil, type: .feature, project: project+ )++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "query_tasks", arguments: [:]+ ))++ let results = try MCPTestHelpers.decodeArrayResult(response)+ let byName = Dictionary(uniqueKeysWithValues: results.compactMap { dict -> (String, String)? in+ guard let name = dict["name"] as? String, let priority = dict["priority"] as? String else {+ return nil+ }+ return (name, priority)+ })+ #expect(byName["High"] == "high")+ #expect(byName["Default"] == "medium")+ }++ // MARK: - query_tasks filtering (Req 5.4)++ @Test func queryFiltersBySinglePriorityString() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)+ _ = try await env.taskService.createTask(+ name: "High", description: nil, type: .feature, project: project, priority: .high+ )+ _ = try await env.taskService.createTask(+ name: "Low", description: nil, type: .feature, project: project, priority: .low+ )++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "query_tasks", arguments: ["priority": "high"]+ ))++ let results = try MCPTestHelpers.decodeArrayResult(response)+ #expect(results.count == 1)+ #expect(results.first?["name"] as? String == "High")+ }++ @Test func queryFiltersByPriorityArray() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)+ _ = try await env.taskService.createTask(+ name: "High", description: nil, type: .feature, project: project, priority: .high+ )+ _ = try await env.taskService.createTask(+ name: "Medium", description: nil, type: .feature, project: project, priority: .medium+ )+ _ = try await env.taskService.createTask(+ name: "Low", description: nil, type: .feature, project: project, priority: .low+ )++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "query_tasks", arguments: ["priority": ["high", "low"]]+ ))++ let results = try MCPTestHelpers.decodeArrayResult(response)+ let names = Set(results.compactMap { $0["name"] as? String })+ #expect(names == ["High", "Low"])+ }++ @Test func queryWithoutPriorityFilterReturnsAllPriorities() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)+ _ = try await env.taskService.createTask(+ name: "High", description: nil, type: .feature, project: project, priority: .high+ )+ _ = try await env.taskService.createTask(+ name: "Low", description: nil, type: .feature, project: project, priority: .low+ )++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "query_tasks", arguments: [:]+ ))++ let results = try MCPTestHelpers.decodeArrayResult(response)+ #expect(results.count == 2)+ }++ @Test func queryInvalidPriorityFilterReturnsError() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)+ _ = try await env.taskService.createTask(+ name: "Task", description: nil, type: .feature, project: project+ )++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "query_tasks", arguments: ["priority": "urgent"]+ ))++ #expect(try MCPTestHelpers.isError(response))+ let message = try MCPTestHelpers.errorText(response)+ #expect(message.contains("priority"))+ }+}++#endif
diff --git a/Transit/TransitTests/QueryTasksIntentTests.swift b/Transit/TransitTests/QueryTasksIntentTests.swiftindex ed1a14d..a41340b 100644--- a/Transit/TransitTests/QueryTasksIntentTests.swift+++ b/Transit/TransitTests/QueryTasksIntentTests.swift@@ -3,6 +3,8 @@ import SwiftData import Testing @testable import Transit +// swiftlint:disable type_body_length+ @MainActor @Suite(.serialized) struct QueryTasksIntentTests { @@ -177,6 +179,65 @@ struct QueryTasksIntentTests { #expect(parsed.first?["type"] as? String == "bug") } + // MARK: - Priority Filter (Req 6.3 — scalar single-value)++ @Test func priorityFilterReturnsMatchingTasks() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let high = makeTask(in: svc.context, project: project, name: "High", displayId: 1)+ high.priority = .high+ let low = makeTask(in: svc.context, project: project, name: "Low", displayId: 2)+ low.priority = .low++ let result = QueryTasksIntent.execute(+ input: "{\"priority\":\"high\"}", projectService: svc.project, taskService: svc.task,+ milestoneService: svc.milestone+ )++ let parsed = try parseJSONArray(result)+ #expect(parsed.count == 1)+ #expect(parsed.first?["name"] as? String == "High")+ #expect(parsed.first?["priority"] as? String == "high")+ }++ @Test func responseIncludesPriorityForEachTask() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let high = makeTask(in: svc.context, project: project, name: "High", displayId: 1)+ high.priority = .high+ // Default (medium) task to confirm the effective accessor is serialized.+ makeTask(in: svc.context, project: project, name: "Default", displayId: 2)++ let result = QueryTasksIntent.execute(+ input: "{}", projectService: svc.project, taskService: svc.task,+ milestoneService: svc.milestone+ )++ let parsed = try parseJSONArray(result)+ let byName = Dictionary(uniqueKeysWithValues: parsed.compactMap { dict -> (String, String)? in+ guard let name = dict["name"] as? String, let priority = dict["priority"] as? String else {+ return nil+ }+ return (name, priority)+ })+ #expect(byName["High"] == "high")+ #expect(byName["Default"] == "medium")+ }++ @Test func invalidPriorityFilterReturnsInvalidPriority() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ makeTask(in: svc.context, project: project, name: "Task", displayId: 1)++ let result = QueryTasksIntent.execute(+ input: "{\"priority\":\"urgent\"}", projectService: svc.project, taskService: svc.task,+ milestoneService: svc.milestone+ )++ let parsed = try parseJSON(result)+ #expect(parsed["error"] as? String == "INVALID_PRIORITY")+ }+ // MARK: - DisplayId Lookup @Test func displayIdLookupReturnsDetailedTask() throws {@@ -294,3 +355,5 @@ struct QueryTasksIntentTests { #expect(item.keys.contains("lastStatusChangeDate")) } }++// swiftlint:enable type_body_length
diff --git a/Transit/TransitTests/TaskPriorityTests.swift b/Transit/TransitTests/TaskPriorityTests.swiftnew file mode 100644index 0000000..0c112d6--- /dev/null+++ b/Transit/TransitTests/TaskPriorityTests.swift@@ -0,0 +1,59 @@+import SwiftUI+import Testing+@testable import Transit++@MainActor+struct TaskPriorityTests {++ // MARK: - Raw values++ @Test func rawValuesMatchSpec() {+ #expect(TaskPriority.low.rawValue == "low")+ #expect(TaskPriority.medium.rawValue == "medium")+ #expect(TaskPriority.high.rawValue == "high")+ }++ @Test func rawValueInitializerRoundTrips() {+ #expect(TaskPriority(rawValue: "low") == .low)+ #expect(TaskPriority(rawValue: "medium") == .medium)+ #expect(TaskPriority(rawValue: "high") == .high)+ }++ // MARK: - allCases++ @Test func allCasesContainsThreeLevels() {+ #expect(TaskPriority.allCases == [.low, .medium, .high])+ }++ // MARK: - displayOrder++ @Test func displayOrderIsHighToLow() {+ // Pickers and the board filter iterate displayOrder (most-actionable+ // first), not allCases (source order). This ordering is user-visible.+ #expect(TaskPriority.displayOrder == [.high, .medium, .low])+ }++ // MARK: - tintColor++ @Test func tintColorMatchesSpec() {+ #expect(TaskPriority.high.tintColor == .red)+ #expect(TaskPriority.medium.tintColor == .orange)+ #expect(TaskPriority.low.tintColor == .blue)+ }++ // MARK: - glyphSymbol++ @Test func glyphSymbolIsNilOnlyForMedium() {+ #expect(TaskPriority.medium.glyphSymbol == nil)+ #expect(TaskPriority.high.glyphSymbol == "arrow.up.circle.fill")+ #expect(TaskPriority.low.glyphSymbol == "arrow.down.circle.fill")+ }++ // MARK: - accessibilityLabel++ @Test func accessibilityLabelsNamePriorityLevel() {+ #expect(TaskPriority.high.accessibilityLabel == "High priority")+ #expect(TaskPriority.medium.accessibilityLabel == "Medium priority")+ #expect(TaskPriority.low.accessibilityLabel == "Low priority")+ }+}
diff --git a/Transit/TransitTests/TaskServicePriorityTests.swift b/Transit/TransitTests/TaskServicePriorityTests.swiftnew file mode 100644index 0000000..f86c077--- /dev/null+++ b/Transit/TransitTests/TaskServicePriorityTests.swift@@ -0,0 +1,88 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++@MainActor @Suite(.serialized)+struct TaskServicePriorityTests {++ // MARK: - Helpers++ private func makeService() throws -> (TaskService, ModelContext) {+ let context = try TestModelContainer.newContext()+ let store = InMemoryCounterStore()+ let allocator = DisplayIDAllocator(store: store)+ let service = TaskService(modelContext: context, displayIDAllocator: allocator)+ return (service, context)+ }++ private func makeProject(in context: ModelContext) -> Project {+ let project = Project(name: "Test Project", description: "", gitRepo: nil, colorHex: "#FF0000")+ context.insert(project)+ return project+ }++ // MARK: - createTask++ @Test func createTaskDefaultsToMediumPriority() async throws {+ let (service, context) = try makeService()+ let project = makeProject(in: context)++ let task = try await service.createTask(+ name: "Task", description: nil, type: .feature, project: project+ )++ #expect(task.priority == .medium)+ }++ @Test func createTaskHonorsExplicitPriority() async throws {+ let (service, context) = try makeService()+ let project = makeProject(in: context)++ let task = try await service.createTask(+ name: "Task", description: nil, type: .feature, project: project, priority: .high+ )++ #expect(task.priority == .high)+ }++ @Test func createTaskByProjectIDHonorsExplicitPriority() async throws {+ let (service, context) = try makeService()+ let project = makeProject(in: context)++ let task = try await service.createTask(+ name: "Task", description: nil, type: .feature, projectID: project.id, priority: .low+ )++ #expect(task.priority == .low)+ }++ // MARK: - updateTask++ @Test func updateTaskSetsPriority() async throws {+ let (service, context) = try makeService()+ let project = makeProject(in: context)+ let task = try await service.createTask(+ name: "Task", description: nil, type: .feature, project: project+ )++ try service.updateTask(task, priority: .high)++ #expect(task.priority == .high)+ }++ // Decision 8: omitting priority on update leaves it unchanged.+ @Test func updateTaskOmittingPriorityLeavesItUnchanged() async throws {+ let (service, context) = try makeService()+ let project = makeProject(in: context)+ let task = try await service.createTask(+ name: "Task", description: nil, type: .feature, project: project, priority: .high+ )++ // An unrelated update that omits priority must not reset it to medium.+ try service.updateTask(task, name: "Renamed")++ #expect(task.name == "Renamed")+ #expect(task.priority == .high)+ }+}
diff --git a/Transit/TransitTests/TaskUpdateValidatorTests.swift b/Transit/TransitTests/TaskUpdateValidatorTests.swiftindex 82e49f9..73ecf3e 100644--- a/Transit/TransitTests/TaskUpdateValidatorTests.swift+++ b/Transit/TransitTests/TaskUpdateValidatorTests.swift@@ -122,6 +122,14 @@ struct TaskUpdateValidatorTests { return false } + private func isInvalidPriority(_ error: TaskUpdateValidationError, contains substring: String? = nil) -> Bool {+ if case .invalidPriority(let message) = error {+ guard let substring else { return true }+ return message.contains(substring)+ }+ return false+ }+ // MARK: - Name (AC 1.1–1.4) @Test func validate_nameSet_trimsAndApplies() throws {@@ -296,6 +304,131 @@ struct TaskUpdateValidatorTests { } } + // MARK: - Priority (Decision 8/9; Req 5.2, 6.2, 6.4)++ @Test func validate_priorityAbsent_noChange() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, project: project)++ let result = TaskUpdateValidator.validate(+ ["name": "x"], task: task, milestoneService: svc.milestone+ )+ let update = try unwrapSuccess(result)+ // Priority omitted -> no change carried.+ #expect(update.priority == nil)+ }++ @Test func validate_priorityValid_setsValue() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, project: project)++ let result = TaskUpdateValidator.validate(+ ["priority": "high"], task: task, milestoneService: svc.milestone+ )+ let update = try unwrapSuccess(result)+ #expect(update.priority == .high)+ #expect(update.hasChanges == true)+ }++ @Test func validate_priorityInvalid_returnsInvalidPriority() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, project: project)++ let result = TaskUpdateValidator.validate(+ ["priority": "urgent"], task: task, milestoneService: svc.milestone+ )+ let error = try unwrapFailure(result)+ #expect(+ isInvalidPriority(error, contains: "urgent"),+ "Expected .invalidPriority listing the bad value, got \(error)"+ )+ // Must map to the dedicated INVALID_PRIORITY code on the intent surface (Decision 9).+ #expect(error.intentError.code == "INVALID_PRIORITY")+ }++ @Test func validate_priorityNonString_rejects() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, project: project)++ let args = try jsonRoundTrip("{\"priority\": 1}")+ let result = TaskUpdateValidator.validate(args, task: task, milestoneService: svc.milestone)+ let error = try unwrapFailure(result)+ #expect(+ isInvalidInput(error, contains: "priority"),+ "Expected priority-specific invalidInput, got \(error)"+ )+ }++ /// Exact lowercase match required (Req Value Format) — capitalized values rejected.+ @Test func validate_priorityCapitalized_rejects() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, project: project)++ for value in ["High", "MEDIUM", "Low"] {+ let result = TaskUpdateValidator.validate(+ ["priority": value], task: task, milestoneService: svc.milestone+ )+ let error = try unwrapFailure(result)+ #expect(+ isInvalidPriority(error, contains: value),+ "Expected capitalized priority '\(value)' to be rejected, got \(error)"+ )+ }+ }++ /// Proves the `apply` `hasFieldChange` term was added — a priority-only update+ /// that validates but is never applied would leave the task at medium. [Decision 8]+ @Test func apply_setsPriority_inMemory() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, project: project)+ #expect(task.priority == .medium)+ try svc.context.save()+ #expect(svc.context.hasChanges == false)++ let result = TaskUpdateValidator.validate(+ ["priority": "high"], task: task, milestoneService: svc.milestone+ )+ let update = try unwrapSuccess(result)++ try TaskUpdateValidator.apply(+ update, to: task,+ taskService: svc.task, milestoneService: svc.milestone+ )++ #expect(task.priority == .high)+ // apply must not save — the context should still be dirty.+ #expect(svc.context.hasChanges == true)+ }++ /// Decision 8: omitting priority on an update leaves the stored value untouched.+ @Test func apply_priorityOmitted_leavesUnchanged() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, name: "Old", type: .bug, project: project)+ task.priority = .high+ try svc.context.save()++ let result = TaskUpdateValidator.validate(+ ["name": "New"], task: task, milestoneService: svc.milestone+ )+ let update = try unwrapSuccess(result)+ #expect(update.priority == nil)++ try TaskUpdateValidator.apply(+ update, to: task,+ taskService: svc.task, milestoneService: svc.milestone+ )++ #expect(task.name == "New")+ #expect(task.priority == .high, "Priority must be unchanged when omitted")+ }+ // MARK: - Metadata (AC 4.1–4.4) @Test func validate_metadataDict_replaces() throws {
diff --git a/Transit/TransitTests/TransitTaskPriorityTests.swift b/Transit/TransitTests/TransitTaskPriorityTests.swiftnew file mode 100644index 0000000..b288f42--- /dev/null+++ b/Transit/TransitTests/TransitTaskPriorityTests.swift@@ -0,0 +1,72 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++@MainActor @Suite(.serialized)+struct TransitTaskPriorityTests {++ private func makeProject(in context: ModelContext) -> Project {+ let project = Project(name: "Test Project", description: "", gitRepo: nil, colorHex: "#FF0000")+ context.insert(project)+ return project+ }++ // MARK: - Default++ @Test func defaultPriorityRawValueIsMedium() throws {+ let context = try TestModelContainer.newContext()+ let project = makeProject(in: context)+ let task = TransitTask(name: "Task", type: .feature, project: project, displayID: .provisional)++ #expect(task.priorityRawValue == "medium")+ #expect(task.priority == .medium)+ }++ // MARK: - Accessor fallback (effective-priority invariant)++ @Test func emptyRawValueReadsAsMedium() throws {+ let context = try TestModelContainer.newContext()+ let project = makeProject(in: context)+ let task = TransitTask(name: "Task", type: .feature, project: project, displayID: .provisional)++ task.priorityRawValue = ""+ #expect(task.priority == .medium)+ }++ @Test func unrecognizedRawValueReadsAsMedium() throws {+ let context = try TestModelContainer.newContext()+ let project = makeProject(in: context)+ let task = TransitTask(name: "Task", type: .feature, project: project, displayID: .provisional)++ task.priorityRawValue = "urgent"+ #expect(task.priority == .medium)+ }++ // MARK: - Setter++ @Test func setterWritesRawValue() throws {+ let context = try TestModelContainer.newContext()+ let project = makeProject(in: context)+ let task = TransitTask(name: "Task", type: .feature, project: project, displayID: .provisional)++ task.priority = .high+ #expect(task.priorityRawValue == "high")++ task.priority = .low+ #expect(task.priorityRawValue == "low")+ }++ // MARK: - Init param++ @Test func initParamSetsPriorityRawValue() throws {+ let context = try TestModelContainer.newContext()+ let project = makeProject(in: context)+ let task = TransitTask(+ name: "Task", type: .feature, project: project, displayID: .provisional, priority: .high+ )++ #expect(task.priorityRawValue == "high")+ #expect(task.priority == .high)+ }+}
diff --git a/Transit/TransitTests/UpdateTaskAllFieldsParityTests.swift b/Transit/TransitTests/UpdateTaskAllFieldsParityTests.swiftindex efe10ed..058acc0 100644--- a/Transit/TransitTests/UpdateTaskAllFieldsParityTests.swift+++ b/Transit/TransitTests/UpdateTaskAllFieldsParityTests.swift@@ -186,6 +186,20 @@ struct UpdateTaskAllFieldsParityTests { ) } + @Test func updatePriority_parity() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)+ let task = try await env.taskService.createTask(+ name: "Task", description: nil, type: .feature, project: project+ )+ let taskDisplayId = try #require(task.permanentDisplayId)++ try await runParityCase(+ env: env,+ arguments: ["displayId": taskDisplayId, "priority": "high"]+ )+ }+ @Test func updateMetadata_parity() async throws { let env = try MCPTestHelpers.makeEnv() let project = MCPTestHelpers.makeProject(in: env.context)@@ -243,6 +257,7 @@ struct UpdateTaskAllFieldsParityTests { "name": "Renamed", "description": "new desc", "type": "chore",+ "priority": "high", "metadata": ["new": "val"], "milestoneDisplayId": milestoneDisplayId ]
diff --git a/Transit/TransitTests/UpdateTaskIntentTests.swift b/Transit/TransitTests/UpdateTaskIntentTests.swiftindex 211c154..4b97265 100644--- a/Transit/TransitTests/UpdateTaskIntentTests.swift+++ b/Transit/TransitTests/UpdateTaskIntentTests.swift@@ -158,6 +158,68 @@ struct UpdateTaskIntentTests { #expect(task.milestone != nil) } + // MARK: - Priority (Req 6.2, Decision 8)++ @Test func setPriorityViaUpdate() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, name: "Task 1", project: project, displayId: 10)+ #expect(task.priority == .medium)++ let input = """+ {"displayId":10,"priority":"high"}+ """++ let result = UpdateTaskIntent.execute(+ input: input, taskService: svc.task, milestoneService: svc.milestone+ )++ let parsed = try parseJSON(result)+ #expect(parsed["priority"] as? String == "high")+ #expect(task.priority == .high)+ }++ @Test func omittingPriorityLeavesItUnchanged() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, name: "Task 1", project: project, displayId: 10)+ task.priority = .high++ // An unrelated rename must not reset priority to medium (Decision 8).+ let input = """+ {"displayId":10,"name":"Renamed"}+ """++ let result = UpdateTaskIntent.execute(+ input: input, taskService: svc.task, milestoneService: svc.milestone+ )++ let parsed = try parseJSON(result)+ #expect(parsed["name"] as? String == "Renamed")+ #expect(parsed["priority"] as? String == "high")+ #expect(task.priority == .high)+ }++ @Test func invalidPriorityReturnsInvalidPriority() throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let task = makeTask(in: svc.context, name: "Task 1", project: project, displayId: 10)+ task.priority = .low++ let input = """+ {"displayId":10,"priority":"urgent"}+ """++ let result = UpdateTaskIntent.execute(+ input: input, taskService: svc.task, milestoneService: svc.milestone+ )++ let parsed = try parseJSON(result)+ #expect(parsed["error"] as? String == "INVALID_PRIORITY")+ // No mutation on validation failure.+ #expect(task.priority == .low)+ }+ // MARK: - Error Cases @Test func unknownTaskReturnsTaskNotFound() throws {
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 1553185..8fd6b68 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -27,6 +27,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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).
diff --git a/CLAUDE.md b/CLAUDE.mdindex d56ad76..9e50fda 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -80,7 +80,7 @@ Idea | Planning | Spec | In Progress | Done/Abandoned - Sort: `lastStatusChangeDate` descending within each column, with a toggle for "Organized" sort - Cross-column drag changes status; no vertical reorder within columns - Search bar supports name, description, and display ID matching (e.g. "T-42")-- Filter menus for project, type, and milestone+- Filter menus for project, type, milestone, and priority ### Platform Layout @@ -115,7 +115,7 @@ Intents exposed as Shortcuts, each accepting/returning structured JSON via `@Par - **CreateTaskIntent** — create task with optional milestone assignment - **UpdateStatusIntent** — move task to new status with optional comment-- **QueryTasksIntent** — filter by project/status/type/milestone/search/date range+- **QueryTasksIntent** — filter by project/status/type/priority/milestone/search/date range - **UpdateTaskIntent** — update task properties (milestone assignment) - **CreateMilestoneIntent** — create milestone within a project - **QueryMilestonesIntent** — filter milestones@@ -124,7 +124,7 @@ Intents exposed as Shortcuts, each accepting/returning structured JSON via `@Par - **AddCommentIntent** — add comment to a task - **GenerateReportIntent** — generate markdown report of completed/abandoned tasks -Error responses are JSON-encoded in the return string (not thrown) so CLI callers get parseable output. Error codes: `TASK_NOT_FOUND`, `PROJECT_NOT_FOUND`, `AMBIGUOUS_PROJECT`, `INVALID_STATUS`, `INVALID_TYPE`, `INVALID_INPUT`, `MILESTONE_NOT_FOUND`, `DUPLICATE_MILESTONE_NAME`, `MILESTONE_PROJECT_MISMATCH`, `INTERNAL_ERROR`.+Error responses are JSON-encoded in the return string (not thrown) so CLI callers get parseable output. Error codes: `TASK_NOT_FOUND`, `PROJECT_NOT_FOUND`, `AMBIGUOUS_PROJECT`, `INVALID_STATUS`, `INVALID_TYPE`, `INVALID_PRIORITY`, `INVALID_INPUT`, `MILESTONE_NOT_FOUND`, `DUPLICATE_MILESTONE_NAME`, `MILESTONE_PROJECT_MISMATCH`, `INTERNAL_ERROR`. **Visual intents** (in `Intents/Visual/`) use native App Intent entities and queries for Shortcuts UI integration: `AddTaskIntent`, `FindTasksIntent`. @@ -201,7 +201,8 @@ Services follow a consistent pattern: mutate in memory, then `save()`, rolling b ## Key Design Decisions -- No task dependencies, no priority field, no full status history in V1+- No task dependencies, no full status history in V1+- Tasks have a low/medium/high priority field (default medium), stored as a CloudKit-safe `priorityRawValue` with a computed `TaskPriority` accessor that reads any absent/empty/legacy value as medium (no migration). Shown as a board-card glyph for high/low (medium suppressed), filterable on the board, editable in create/edit/detail, and read/settable via MCP and App Intents - New tasks always start in Idea status - Abandoned tasks restore to Idea (not previous status) - Filter state is ephemeral (resets on launch)
diff --git a/docs/agent-notes/mcp-server.md b/docs/agent-notes/mcp-server.mdindex e65e87c..958a879 100644--- a/docs/agent-notes/mcp-server.md+++ b/docs/agent-notes/mcp-server.md@@ -36,10 +36,10 @@ Key challenge: Hummingbird runs on SwiftNIO event loops (nonisolated), but servi | Tool | Description | |------|-------------|-| `create_task` | Create a new task (name and type required; at least one of project / projectId required to identify the project; description and metadata optional) |+| `create_task` | Create a new task (name and type required; at least one of project / projectId required to identify the project; description, metadata, and priority optional — priority defaults to medium, invalid priority rejects with no task created) | | `update_task_status` | Change task status (by displayId or taskId) |-| `update_task` | Update a task's mutable fields — any combination of `name`, `description`, `type`, `metadata`, and milestone assignment (`milestone` / `milestoneDisplayId` / `clearMilestone`) — in a single atomic call. Identify task by displayId or taskId. |-| `query_tasks` | List tasks with optional status/type/project filters; includes comments |+| `update_task` | Update a task's mutable fields — any combination of `name`, `description`, `type`, `priority`, `metadata`, and milestone assignment (`milestone` / `milestoneDisplayId` / `clearMilestone`) — in a single atomic call. Priority is non-clearable: omit to leave unchanged. Identify task by displayId or taskId. |+| `query_tasks` | List tasks with optional status/type/priority/project filters (priority accepts an array of values to include); includes comments | | `add_comment` | Add a comment to a task (by displayId or taskId); always sets `isAgent: true` | ### Maintenance tools (gated, default off)
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex b4038b8..b4ff86a 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -32,6 +32,7 @@ | [Duplicate Display ID Cleanup](#duplicate-display-id-cleanup) | 2026-04-25 | Done | Scan and reassign tasks/milestones sharing a permanentDisplayId | | [Update Task All Fields](#update-task-all-fields) | 2026-05-22 | Done | Extend update_task MCP tool and App Intent to update name, description, type, and metadata | | [Search Empty State](#search-empty-state) | 2026-05-31 | Done | Show ContentUnavailableView.search empty state when dashboard text search has no matches |+| [Task Priority](#task-priority) | 2026-06-06 | Done | Add a low/medium/high priority field with board glyph, filter, and MCP/Intent support | --- @@ -302,3 +303,13 @@ Show a `ContentUnavailableView.search` empty state on the dashboard when text se - [implementation.md](search-empty-state/implementation.md) - [smolspec.md](search-empty-state/smolspec.md) - [tasks.md](search-empty-state/tasks.md)++## Task Priority++Add a low/medium/high priority field (default medium) to tasks, shown as a board-card glyph for high/low, filterable on the board, editable in the create/edit/detail screens, and readable/settable via the MCP server and App Intents (T-1463).++- [decision_log.md](task-priority/decision_log.md)+- [design.md](task-priority/design.md)+- [implementation.md](task-priority/implementation.md)+- [requirements.md](task-priority/requirements.md)+- [tasks.md](task-priority/tasks.md)
The effective-priority invariant is enforced by EffectivePriorityInvariantTests across the five current read surfaces. Any future surface that reads tasks must go through task.priority (never priorityRawValue) and add its own assertion.
The create response gained a priority key (3→4 keys). Existing keys are unchanged so consumers ignoring unknown keys stay compatible, but any consumer asserting an exact key set will need updating.