# Smart Playlist Conditions Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Extend smart playlists to support structured metadata-based conditions (e.g. artist = "Alicia Keys", year > 2015) with a new condition builder sheet, while keeping the existing FTS search-based smart playlists working unchanged. **Architecture:** Add a nullable `conditions: [SmartPlaylistCondition]?` column (stored as JSON TEXT) to the `smart_playlists` table via a new DB migration. When `conditions` is nil the existing FTS path runs; when non-nil a SQL WHERE clause is generated from the conditions. A new `SmartPlaylistBuilderSheet` view handles create and edit, wired into the app menu and playlist context menu. **Tech Stack:** Swift, SwiftUI, GRDB 7.10.0, Swift Testing --- ## File Map | Action | File | Purpose | |--------|------|---------| | Create | `Music/Models/SmartPlaylistCondition.swift` | TrackField, ConditionOperator, ConditionValue, SmartPlaylistCondition | | Modify | `Music/Models/SmartPlaylist.swift` | Add `conditions` property, update fixture | | Modify | `Music/Services/DatabaseService.swift` | Migration v5, buildWhereClause, fetchTracks(conditions:), createSmartPlaylist(name:conditions:), updateSmartPlaylistConditions | | Modify | `Music/ViewModels/PlaylistViewModel.swift` | Branch observeSmartPlaylistTracks, add createSmartPlaylist(name:conditions:), updateSmartPlaylistConditions | | Create | `Music/Views/SmartPlaylistBuilderSheet.swift` | Condition builder sheet UI + ConditionRowView | | Modify | `Music/Views/PlaylistBarView.swift` | Add onEditConditions callback, update context menu | | Modify | `Music/ContentView.swift` | Add showSmartPlaylistBuilder binding, sheet, onEditConditions wiring | | Modify | `Music/MusicApp.swift` | Add showSmartPlaylistBuilder state + "New Smart Playlist…" menu item | | Modify | `MusicTests/SmartPlaylistTests.swift` | Tests for conditions model, query evaluation, JSON round-trip | --- ## Task 1: SmartPlaylistCondition Model **Files:** - Create: `Music/Models/SmartPlaylistCondition.swift` - Test: `MusicTests/SmartPlaylistTests.swift` - [ ] **Step 1: Write the failing test** Add to `MusicTests/SmartPlaylistTests.swift`: ```swift // Encodes and decodes a SmartPlaylistCondition to/from JSON, // verifying that all fields survive the round-trip. @Test func conditionCodableRoundTrip() throws { let condition = SmartPlaylistCondition( field: .artist, op: .equals, value: .string("Miles Davis") ) let data = try JSONEncoder().encode(condition) let decoded = try JSONDecoder().decode(SmartPlaylistCondition.self, from: data) #expect(decoded.field == .artist) #expect(decoded.op == .equals) if case .string(let s) = decoded.value { #expect(s == "Miles Davis") } else { Issue.record("Expected string value") } } // Encodes and decodes an array of conditions with mixed value types. @Test func conditionsArrayCodableRoundTrip() throws { let conditions: [SmartPlaylistCondition] = [ SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("Miles")), SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)), SmartPlaylistCondition(field: .dateAdded, op: .lessThan, value: .date(Date(timeIntervalSince1970: 0))) ] let data = try JSONEncoder().encode(conditions) let decoded = try JSONDecoder().decode([SmartPlaylistCondition].self, from: data) #expect(decoded.count == 3) #expect(decoded[0].field == .artist) #expect(decoded[1].op == .greaterThan) if case .int(let y) = decoded[1].value { #expect(y == 1960) } else { Issue.record("Expected int") } if case .date(let d) = decoded[2].value { #expect(d == Date(timeIntervalSince1970: 0)) } else { Issue.record("Expected date") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/conditionCodableRoundTrip 2>&1 | grep -E "error:|FAILED|PASSED" | head -20` Expected: compile error — `SmartPlaylistCondition` not defined. - [ ] **Step 3: Create `Music/Models/SmartPlaylistCondition.swift`** ```swift import Foundation // Classifies a track field for operator and UI purposes. enum FieldType { case string, int, double, date } // Represents a track column that can be filtered on. // Raw value matches the SQLite column name in the "tracks" table. enum TrackField: String, Codable, CaseIterable, Identifiable, Sendable { case title, artist, albumArtist, album, genre, composer, fileFormat case year, bpm, rating, playCount, trackNumber, discNumber, bitrate, sampleRate case fileSize, duration case dateAdded, dateModified, lastPlayedAt var id: String { rawValue } var displayName: String { switch self { case .title: return "Title" case .artist: return "Artist" case .albumArtist: return "Album Artist" case .album: return "Album" case .genre: return "Genre" case .composer: return "Composer" case .fileFormat: return "File Format" case .year: return "Year" case .bpm: return "BPM" case .rating: return "Rating" case .playCount: return "Play Count" case .trackNumber: return "Track Number" case .discNumber: return "Disc Number" case .bitrate: return "Bitrate" case .sampleRate: return "Sample Rate" case .fileSize: return "File Size" case .duration: return "Duration" case .dateAdded: return "Date Added" case .dateModified: return "Date Modified" case .lastPlayedAt: return "Last Played" } } var fieldType: FieldType { switch self { case .title, .artist, .albumArtist, .album, .genre, .composer, .fileFormat: return .string case .year, .bpm, .rating, .playCount, .trackNumber, .discNumber, .bitrate, .sampleRate, .fileSize: return .int case .duration: return .double case .dateAdded, .dateModified, .lastPlayedAt: return .date } } var validOperators: [ConditionOperator] { switch fieldType { case .string: return [.equals, .startsWith] case .int, .double, .date: return [.equals, .greaterThan, .lessThan] } } var defaultValue: ConditionValue { switch fieldType { case .string: return .string("") case .int: return .int(0) case .double: return .double(0) case .date: return .date(Date()) } } } enum ConditionOperator: String, Codable, Identifiable, Sendable { case equals case startsWith case greaterThan case lessThan var id: String { rawValue } var displayName: String { switch self { case .equals: return "is" case .startsWith: return "starts with" case .greaterThan: return "is greater than" case .lessThan: return "is less than" } } } // Tagged union storing the actual filter value with its type. // Uses custom Codable to survive JSON round-trips cleanly. enum ConditionValue: Equatable, Sendable { case string(String) case int(Int) case double(Double) case date(Date) var isEmpty: Bool { if case .string(let s) = self { return s.trimmingCharacters(in: .whitespaces).isEmpty } return false } } extension ConditionValue: Codable { private enum CodingKeys: String, CodingKey { case type, value } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .string(let s): try container.encode("string", forKey: .type) try container.encode(s, forKey: .value) case .int(let i): try container.encode("int", forKey: .type) try container.encode(i, forKey: .value) case .double(let d): try container.encode("double", forKey: .type) try container.encode(d, forKey: .value) case .date(let date): try container.encode("date", forKey: .type) try container.encode(date.timeIntervalSince1970, forKey: .value) } } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) switch type { case "string": self = .string(try container.decode(String.self, forKey: .value)) case "int": self = .int(try container.decode(Int.self, forKey: .value)) case "double": self = .double(try container.decode(Double.self, forKey: .value)) case "date": self = .date(Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .value))) default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)") } } } nonisolated struct SmartPlaylistCondition: Codable, Equatable, Sendable { var field: TrackField var op: ConditionOperator var value: ConditionValue var isEmpty: Bool { value.isEmpty } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/conditionCodableRoundTrip -only-testing:MusicTests/SmartPlaylistTests/conditionsArrayCodableRoundTrip 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"` Expected: both tests PASSED. - [ ] **Step 5: Commit** ```bash git add Music/Models/SmartPlaylistCondition.swift MusicTests/SmartPlaylistTests.swift git commit -m "feat: add SmartPlaylistCondition model with Codable types" ``` --- ## Task 2: Extend SmartPlaylist Model **Files:** - Modify: `Music/Models/SmartPlaylist.swift` - Modify: `MusicTests/SmartPlaylistTests.swift` - [ ] **Step 1: Write the failing test** Add to `MusicTests/SmartPlaylistTests.swift`: ```swift // Creates a SmartPlaylist fixture with conditions and verifies the conditions // field is preserved and the isSmartPlaylist flag is true. @Test func smartPlaylistWithConditions() throws { let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))] let sp = SmartPlaylist.fixture(conditions: conditions) #expect(sp.conditions?.count == 1) #expect(sp.conditions?[0].field == .artist) #expect(sp.isSmartPlaylist == true) } ``` - [ ] **Step 2: Run test to verify it fails** Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/smartPlaylistWithConditions 2>&1 | grep -E "error:|FAILED|PASSED" | head -10` Expected: compile error — fixture doesn't accept `conditions` parameter. - [ ] **Step 3: Modify `Music/Models/SmartPlaylist.swift`** Add `conditions` property to the struct and update the fixture. Replace the entire file content: ```swift import Foundation import GRDB nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Sendable { var id: Int64? var name: String var searchQuery: String var createdAt: Date var conditions: [SmartPlaylistCondition]? } nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord { static let databaseTableName = "smart_playlists" mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } } extension SmartPlaylist: PlaylistRepresentable { var isSmartPlaylist: Bool { true } } #if DEBUG extension SmartPlaylist { static func fixture( id: Int64? = nil, name: String = "Test Smart Playlist", searchQuery: String = "test query", createdAt: Date = Date(), conditions: [SmartPlaylistCondition]? = nil ) -> SmartPlaylist { SmartPlaylist( id: id, name: name, searchQuery: searchQuery, createdAt: createdAt, conditions: conditions ) } } #endif ``` > **Note on GRDB Codable synthesis:** GRDB 7 automatically encodes/decodes `[SmartPlaylistCondition]?` as a JSON TEXT column named "conditions" — no custom `init(row:)` or `encode(to:)` needed. Existing rows where the column is NULL decode as `nil`. - [ ] **Step 4: Update the memberwise init call in DatabaseService** In `Music/Services/DatabaseService.swift`, find the `createSmartPlaylist(name:searchQuery:)` method (line ~447) and update the init call: Old: ```swift var smartPlaylist = SmartPlaylist( id: nil, name: name, searchQuery: searchQuery, createdAt: Date() ) ``` New: ```swift var smartPlaylist = SmartPlaylist( id: nil, name: name, searchQuery: searchQuery, createdAt: Date(), conditions: nil ) ``` - [ ] **Step 5: Update the memberwise init call in SmartPlaylistTests** In `MusicTests/SmartPlaylistTests.swift`, find `SmartPlaylistTests.smartPlaylistProperties()` (line ~8) and update: Old: ```swift let sp = SmartPlaylist( id: nil, name: "Miles Davis", searchQuery: "miles davis", createdAt: Date() ) ``` New: ```swift let sp = SmartPlaylist( id: nil, name: "Miles Davis", searchQuery: "miles davis", createdAt: Date(), conditions: nil ) ``` - [ ] **Step 6: Run all SmartPlaylistTests to verify they pass** Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"` Expected: all tests PASSED. - [ ] **Step 7: Commit** ```bash git add Music/Models/SmartPlaylist.swift Music/Services/DatabaseService.swift MusicTests/SmartPlaylistTests.swift git commit -m "feat: add conditions field to SmartPlaylist model" ``` --- ## Task 3: DB Migration + Query Evaluation **Files:** - Modify: `Music/Services/DatabaseService.swift` - Test: `MusicTests/SmartPlaylistTests.swift` - [ ] **Step 1: Write failing tests** Add to `MusicTests/SmartPlaylistTests.swift`: ```swift // Creates an in-memory DB and verifies existing smart playlists (conditions = nil) // still load correctly after the v5 migration adds the conditions column. @Test func existingFTSPlaylistSurvivesMigration() throws { // Step 1: Create DB (migration runs automatically, including v5) // Step 2: Create a FTS smart playlist using the old searchQuery path // Step 3: Fetch it back and verify conditions is nil let db = try DatabaseService(inMemory: true) let sp = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz") let all = try db.fetchSmartPlaylists() #expect(all.count == 1) #expect(all[0].searchQuery == "jazz") #expect(all[0].conditions == nil) _ = sp } // Inserts tracks and verifies that fetchTracks(conditions:) with an equals // condition on artist returns only the matching track. @Test func fetchTracksWithEqualsCondition() throws { // Step 1: Insert two tracks with different artists // Step 2: Fetch with artist equals "Miles Davis" // Step 3: Verify only one track is returned let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") try db.insert(&t1) try db.insert(&t2) let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].artist == "Miles Davis") } // Verifies that equals is case-insensitive for string fields. @Test func fetchTracksEqualsIsCaseInsensitive() throws { // Step 1: Insert a track with mixed-case artist // Step 2: Fetch using lowercase artist value // Step 3: Verify it matches let db = try DatabaseService(inMemory: true) var t = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis") try db.insert(&t) let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("miles davis"))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) } // Verifies that startsWith matches tracks whose artist begins with the given prefix, // regardless of case. @Test func fetchTracksWithStartsWithCondition() throws { // Step 1: Insert tracks — one starting with "Miles", one not // Step 2: Fetch with artist startsWith "miles" // Step 3: Verify only the Miles Davis track is returned let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis") var t2 = Track.fixture(fileURL: "/b.mp3", artist: "Eagles") try db.insert(&t1) try db.insert(&t2) let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("miles"))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].artist == "Miles Davis") } // Verifies that greaterThan on an integer field returns tracks where the value // strictly exceeds the condition value. @Test func fetchTracksWithGreaterThanCondition() throws { // Step 1: Insert tracks with years 1990, 2010, 2020 // Step 2: Fetch with year > 2000 // Step 3: Verify 2010 and 2020 are returned; 1990 is not let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990) var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010) var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020) try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let conditions = [SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(2000))] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 2) #expect(results.allSatisfy { ($0.year ?? 0) > 2000 }) } // Verifies that multiple AND conditions filter correctly — only tracks matching // all conditions are returned. @Test func fetchTracksWithMultipleAndConditions() throws { // Step 1: Insert three tracks: two Miles Davis (years 1959, 1970), one Eagles (1975) // Step 2: Fetch with artist = "Miles Davis" AND year > 1960 // Step 3: Verify only the 1970 Miles Davis track is returned let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis", year: 1959) var t2 = Track.fixture(fileURL: "/b.mp3", title: "Bitches Brew", artist: "Miles Davis", year: 1970) var t3 = Track.fixture(fileURL: "/c.mp3", title: "Hotel California", artist: "Eagles", year: 1975) try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let conditions = [ SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")), SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)) ] let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].title == "Bitches Brew") } // Creates a smart playlist with structured conditions, fetches it back, and // verifies the conditions survive the JSON round-trip through the database. @Test func createSmartPlaylistWithConditionsPersists() throws { // Step 1: Create DB and build a conditions-based smart playlist // Step 2: Fetch all smart playlists // Step 3: Verify conditions, field, operator, and value round-tripped correctly let db = try DatabaseService(inMemory: true) let conditions = [ SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")), SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)) ] _ = try db.createSmartPlaylist(name: "Late Miles", conditions: conditions) let all = try db.fetchSmartPlaylists() #expect(all.count == 1) #expect(all[0].conditions?.count == 2) #expect(all[0].conditions?[0].field == .artist) #expect(all[0].conditions?[1].op == .greaterThan) if case .int(let y) = all[0].conditions?[1].value { #expect(y == 1960) } else { Issue.record("Expected int value") } } // Updates the conditions of a structured smart playlist and verifies the new // conditions are persisted. @Test func updateSmartPlaylistConditions() throws { // Step 1: Create a conditions-based playlist // Step 2: Update its conditions to a different set // Step 3: Fetch and verify the updated conditions let db = try DatabaseService(inMemory: true) let sp = try db.createSmartPlaylist( name: "Test", conditions: [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Eagles"))] ) let newConditions = [SmartPlaylistCondition(field: .genre, op: .startsWith, value: .string("Jazz"))] try db.updateSmartPlaylistConditions(id: sp.id!, conditions: newConditions) let all = try db.fetchSmartPlaylists() #expect(all[0].conditions?.count == 1) #expect(all[0].conditions?[0].field == .genre) } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/fetchTracksWithEqualsCondition 2>&1 | grep -E "error:|FAILED|PASSED" | head -10` Expected: compile error — `fetchTracks(conditions:)` not defined. - [ ] **Step 3: Add migration v5 and query methods to `Music/Services/DatabaseService.swift`** **3a — Add migration v5** after the `"v4-drop-artworkData"` migration (before `try migrator.migrate(db)`): ```swift migrator.registerMigration("v5-add-smart-playlist-conditions") { db in try db.alter(table: "smart_playlists") { t in t.add(column: "conditions", .text) } } ``` **3b — Add `buildWhereClause` private method** to `DatabaseService` (place in the `// MARK: - Smart Playlists` section): ```swift private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, arguments: StatementArguments) { guard !conditions.isEmpty else { return ("", StatementArguments()) } var fragments: [String] = [] var args: [DatabaseValueConvertible?] = [] for condition in conditions { let col = condition.field.rawValue switch (condition.op, condition.value) { case (.equals, .string(let s)): fragments.append("LOWER(\(col)) = LOWER(?)") args.append(s) case (.startsWith, .string(let s)): fragments.append("LOWER(\(col)) LIKE LOWER(?) || '%'") args.append(s) case (.equals, .int(let i)): fragments.append("\(col) = ?"); args.append(i) case (.greaterThan, .int(let i)): fragments.append("\(col) > ?"); args.append(i) case (.lessThan, .int(let i)): fragments.append("\(col) < ?"); args.append(i) case (.equals, .double(let d)): fragments.append("\(col) = ?"); args.append(d) case (.greaterThan, .double(let d)): fragments.append("\(col) > ?"); args.append(d) case (.lessThan, .double(let d)): fragments.append("\(col) < ?"); args.append(d) case (.equals, .date(let date)): fragments.append("\(col) = ?"); args.append(date) case (.greaterThan, .date(let date)): fragments.append("\(col) > ?"); args.append(date) case (.lessThan, .date(let date)): fragments.append("\(col) < ?"); args.append(date) default: break } } return (fragments.joined(separator: " AND "), StatementArguments(args)) } ``` **3c — Add public `fetchTracks(conditions:)` methods** after the existing `fetchTracks(db:search:sortColumn:ascending:)` method: ```swift func fetchTracks(conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] { try dbPool.read { db in try self.fetchTracks(db: db, conditions: conditions, sortColumn: sortColumn, ascending: ascending) } } func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] { let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title" let order = ascending ? "ASC" : "DESC" let (whereSQL, args) = buildWhereClause(conditions) if whereSQL.isEmpty { return try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)") } return try Track.fetchAll( db, sql: "SELECT * FROM tracks WHERE \(whereSQL) ORDER BY \(col) COLLATE NOCASE \(order)", arguments: args ) } ``` **3d — Add `createSmartPlaylist(name:conditions:)` overload** after the existing `createSmartPlaylist(name:searchQuery:)` method: ```swift func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws -> SmartPlaylist { try dbPool.write { db in var smartPlaylist = SmartPlaylist( id: nil, name: name, searchQuery: "", createdAt: Date(), conditions: conditions ) try smartPlaylist.insert(db) return smartPlaylist } } ``` **3e — Add `updateSmartPlaylistConditions(id:conditions:)`** after `updateSmartPlaylistQuery`: ```swift func updateSmartPlaylistConditions(id: Int64, conditions: [SmartPlaylistCondition]) throws { let json: String? if let data = try? JSONEncoder().encode(conditions) { json = String(data: data, encoding: .utf8) } else { json = nil } try dbPool.write { db in try db.execute( sql: "UPDATE smart_playlists SET conditions = ? WHERE id = ?", arguments: [json, id] ) } } ``` - [ ] **Step 4: Run all new tests** Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"` Expected: all tests PASSED. - [ ] **Step 5: Commit** ```bash git add Music/Services/DatabaseService.swift MusicTests/SmartPlaylistTests.swift git commit -m "feat: add migration v5 and structured condition query support to DatabaseService" ``` --- ## Task 4: PlaylistViewModel — Branch on Conditions **Files:** - Modify: `Music/ViewModels/PlaylistViewModel.swift` No separate test — the DB-level tests in Task 3 cover the query logic; the ViewModel wiring is validated by running the app in Task 7. - [ ] **Step 1: Rename and update `observeSmartPlaylistTracks`** Replace the existing `observeSmartPlaylistTracks(searchQuery:)` private method with a new signature that takes the full `SmartPlaylist`: Old signature: `private func observeSmartPlaylistTracks(searchQuery: String)` New implementation (replace the entire method): ```swift private func observeSmartPlaylistTracks(for smartPlaylist: SmartPlaylist) { tracksCancellable?.cancel() let col = sortColumn let asc = sortAscending if let conditions = smartPlaylist.conditions { let observation = ValueObservation.tracking { [db] dbAccess in try db.fetchTracks(db: dbAccess, conditions: conditions, sortColumn: col, ascending: asc) } tracksCancellable = observation.start( in: db.dbPool, onError: { error in print("Smart playlist tracks observation error: \(error)") }, onChange: { [weak self] tracks in self?.playlistTracks = tracks } ) } else { let searchQuery = smartPlaylist.searchQuery let observation = ValueObservation.tracking { [db] dbAccess in try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc) } tracksCancellable = observation.start( in: db.dbPool, onError: { error in print("Smart playlist tracks observation error: \(error)") }, onChange: { [weak self] tracks in self?.playlistTracks = tracks } ) } } ``` - [ ] **Step 2: Update all call sites of `observeSmartPlaylistTracks`** **In `selectItem`**, change: ```swift } else if let smart = item as? SmartPlaylist { observeSmartPlaylistTracks(searchQuery: smart.searchQuery) } ``` to: ```swift } else if let smart = item as? SmartPlaylist { observeSmartPlaylistTracks(for: smart) } ``` **In `updateSmartPlaylistQuery`**, change: ```swift if selectedSmartPlaylist?.id == id { observeSmartPlaylistTracks(searchQuery: query) } ``` to: ```swift if selectedSmartPlaylist?.id == id { var updated = smartPlaylist updated.searchQuery = query updated.conditions = nil observeSmartPlaylistTracks(for: updated) } ``` **In `sort`**, change: ```swift if let smart = selectedSmartPlaylist { observeSmartPlaylistTracks(searchQuery: smart.searchQuery) } ``` to: ```swift if let smart = selectedSmartPlaylist { observeSmartPlaylistTracks(for: smart) } ``` - [ ] **Step 3: Add `createSmartPlaylist(name:conditions:)` to PlaylistViewModel** Add after the existing `createSmartPlaylist(searchQuery:)` method: ```swift func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws { _ = try db.createSmartPlaylist(name: name, conditions: conditions) } ``` - [ ] **Step 4: Add `updateSmartPlaylistConditions(_:to:)` to PlaylistViewModel** Add after `updateSmartPlaylistQuery`: ```swift func updateSmartPlaylistConditions(_ smartPlaylist: SmartPlaylist, to conditions: [SmartPlaylistCondition]) throws { guard let id = smartPlaylist.id else { return } try db.updateSmartPlaylistConditions(id: id, conditions: conditions) if selectedSmartPlaylist?.id == id { var updated = smartPlaylist updated.conditions = conditions observeSmartPlaylistTracks(for: updated) } } ``` - [ ] **Step 5: Verify the project still builds** Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"` Expected: `BUILD SUCCEEDED` - [ ] **Step 6: Commit** ```bash git add Music/ViewModels/PlaylistViewModel.swift git commit -m "feat: branch PlaylistViewModel on conditions for structured smart playlist observation" ``` --- ## Task 5: SmartPlaylistBuilderSheet UI **Files:** - Create: `Music/Views/SmartPlaylistBuilderSheet.swift` - [ ] **Step 1: Create `Music/Views/SmartPlaylistBuilderSheet.swift`** ```swift import SwiftUI struct SmartPlaylistBuilderSheet: View { var editingPlaylist: SmartPlaylist? var onSave: (String, [SmartPlaylistCondition]) -> Void var onCancel: () -> Void @State private var name: String @State private var conditions: [SmartPlaylistCondition] init( editingPlaylist: SmartPlaylist? = nil, onSave: @escaping (String, [SmartPlaylistCondition]) -> Void, onCancel: @escaping () -> Void ) { self.editingPlaylist = editingPlaylist self.onSave = onSave self.onCancel = onCancel let defaultCondition = SmartPlaylistCondition(field: .artist, op: .equals, value: .string("")) _name = State(initialValue: editingPlaylist?.name ?? "") _conditions = State(initialValue: editingPlaylist?.conditions ?? [defaultCondition]) } private var canSave: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && conditions.allSatisfy { !$0.isEmpty } } var body: some View { VStack(alignment: .leading, spacing: 16) { Text(editingPlaylist == nil ? "New Smart Playlist" : "Edit Smart Playlist") .font(.headline) VStack(alignment: .leading, spacing: 4) { Text("Name") .font(.caption) .foregroundStyle(.secondary) TextField("Playlist name", text: $name) .textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 6) { Text("Conditions (all must match)") .font(.caption) .foregroundStyle(.secondary) ForEach(conditions.indices, id: \.self) { index in ConditionRowView( condition: $conditions[index], canRemove: conditions.count > 1, onRemove: { conditions.remove(at: index) } ) } Button("+ Add Condition") { conditions.append(SmartPlaylistCondition(field: .artist, op: .equals, value: .string(""))) } .buttonStyle(.plain) .foregroundStyle(.accentColor) .font(.system(size: 12)) } Divider() HStack { Spacer() Button("Cancel", action: onCancel) Button("Save") { onSave(name.trimmingCharacters(in: .whitespaces), conditions) } .disabled(!canSave) .keyboardShortcut(.defaultAction) } } .padding(20) .frame(width: 540) } } private struct ConditionRowView: View { @Binding var condition: SmartPlaylistCondition var canRemove: Bool var onRemove: () -> Void var body: some View { HStack(spacing: 8) { Picker("", selection: $condition.field) { ForEach(TrackField.allCases) { field in Text(field.displayName).tag(field) } } .labelsHidden() .frame(maxWidth: 130) .onChange(of: condition.field) { _, newField in condition.op = newField.validOperators[0] condition.value = newField.defaultValue } Picker("", selection: $condition.op) { ForEach(condition.field.validOperators) { op in Text(op.displayName).tag(op) } } .labelsHidden() .frame(maxWidth: 130) valueField Button(action: onRemove) { Image(systemName: "minus.circle.fill") .foregroundStyle(canRemove ? .secondary : .secondary.opacity(0.3)) } .buttonStyle(.plain) .disabled(!canRemove) } } @ViewBuilder private var valueField: some View { switch condition.field.fieldType { case .string: TextField("Value", text: Binding( get: { if case .string(let s) = condition.value { return s } else { return "" } }, set: { condition.value = .string($0) } )) .textFieldStyle(.roundedBorder) case .int: TextField("Value", text: Binding( get: { if case .int(let i) = condition.value { return String(i) } else { return "0" } }, set: { condition.value = .int(Int($0) ?? 0) } )) .textFieldStyle(.roundedBorder) .frame(maxWidth: 100) case .double: TextField("Value", text: Binding( get: { if case .double(let d) = condition.value { return String(d) } else { return "0" } }, set: { condition.value = .double(Double($0) ?? 0) } )) .textFieldStyle(.roundedBorder) .frame(maxWidth: 100) case .date: DatePicker("", selection: Binding( get: { if case .date(let d) = condition.value { return d } else { return Date() } }, set: { condition.value = .date($0) } ), displayedComponents: .date) .labelsHidden() } } } ``` - [ ] **Step 2: Verify the project builds** Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"` Expected: `BUILD SUCCEEDED` - [ ] **Step 3: Commit** ```bash git add Music/Views/SmartPlaylistBuilderSheet.swift git commit -m "feat: add SmartPlaylistBuilderSheet with ConditionRowView" ``` --- ## Task 6: PlaylistBarView — Context Menu Update **Files:** - Modify: `Music/Views/PlaylistBarView.swift` - [ ] **Step 1: Add `onEditConditions` callback and update the context menu** In `Music/Views/PlaylistBarView.swift`, add the new callback property after `onEditQuery`: ```swift var onEditConditions: (SmartPlaylist) -> Void ``` Then update the context menu block (currently lines 38-46): Old: ```swift .contextMenu { if !isRemoteMode { Button("Rename...") { onRename(item) } if let smart = item as? SmartPlaylist { Button("Edit Search Query...") { onEditQuery(smart) } } Button("Delete") { onDelete(item) } } } ``` New: ```swift .contextMenu { if !isRemoteMode { Button("Rename...") { onRename(item) } if let smart = item as? SmartPlaylist { if smart.conditions != nil { Button("Edit...") { onEditConditions(smart) } } else { Button("Edit Search Query...") { onEditQuery(smart) } } } Button("Delete") { onDelete(item) } } } ``` - [ ] **Step 2: Verify the project builds (PlaylistBarView call site in ContentView will fail — expected)** Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"` Expected: BUILD FAILED with a missing `onEditConditions` argument error in `ContentView.swift` — this is the compile error that Task 7 will fix. - [ ] **Step 3: Do NOT commit yet — commit together with Task 7** --- ## Task 7: ContentView + MusicApp Wiring **Files:** - Modify: `Music/ContentView.swift` - Modify: `Music/MusicApp.swift` - [ ] **Step 1: Add binding and sheet states to ContentView** In `Music/ContentView.swift`, add a new `@Binding` parameter for the smart playlist builder alongside the existing `showNewPlaylistAlert` binding: Add after `@Binding var showNewPlaylistAlert: Bool`: ```swift @Binding var showSmartPlaylistBuilder: Bool ``` Add to the `@State` block (after `showEditQueryAlert`): ```swift @State private var smartPlaylistBuilderEditing: SmartPlaylist? ``` - [ ] **Step 2: Pass `onEditConditions` to PlaylistBarView in ContentView** Find the `PlaylistBarView(...)` call in `ContentView.body` and add the missing `onEditConditions` argument after `onEditQuery`: ```swift onEditConditions: { smart in smartPlaylistBuilderEditing = smart } ``` - [ ] **Step 3: Add sheets to ContentView** Add two `.sheet` modifiers after the existing `.alert` modifiers at the bottom of `ContentView.body`: ```swift .sheet(isPresented: $showSmartPlaylistBuilder) { SmartPlaylistBuilderSheet( editingPlaylist: nil, onSave: { name, conditions in try? playlist.createSmartPlaylist(name: name, conditions: conditions) showSmartPlaylistBuilder = false }, onCancel: { showSmartPlaylistBuilder = false } ) } .sheet(item: $smartPlaylistBuilderEditing) { smart in SmartPlaylistBuilderSheet( editingPlaylist: smart, onSave: { name, conditions in if name != smart.name { try? playlist.renameSmartPlaylist(smart, to: name) } try? playlist.updateSmartPlaylistConditions(smart, to: conditions) smartPlaylistBuilderEditing = nil }, onCancel: { smartPlaylistBuilderEditing = nil } ) } ``` - [ ] **Step 4: Add `showSmartPlaylistBuilder` state and menu item to MusicApp** In `Music/MusicApp.swift`: Add state after `showNewPlaylistAlert`: ```swift @State private var showSmartPlaylistBuilder = false ``` Pass it to ContentView — in the `ContentView(...)` initializer call, add after `showNewPlaylistAlert: $showNewPlaylistAlert`: ```swift showSmartPlaylistBuilder: $showSmartPlaylistBuilder, ``` Add menu item after `"New Playlist..."` button in the `.commands` block: ```swift Button("New Smart Playlist...") { showSmartPlaylistBuilder = true } .keyboardShortcut("n", modifiers: [.command, .shift]) .disabled(remoteClient.connectionState.isConnected) ``` - [ ] **Step 5: Verify the project builds** Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"` Expected: `BUILD SUCCEEDED` - [ ] **Step 6: Run all tests** Run: `xcodebuild test -scheme Music 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed" | tail -20` Expected: all tests PASSED. - [ ] **Step 7: Commit** ```bash git add Music/Views/PlaylistBarView.swift Music/ContentView.swift Music/MusicApp.swift git commit -m "feat: wire SmartPlaylistBuilderSheet into menu and context menu" ```