# Per-Track Upload State Indicators — Shaped Brief > Approved: 2026-03-19 > Designer: @designer provided mockups and icon vocabulary > Depends on: Cloud Upload v1 (briefs/cloud-upload-v1.md) — shipped 2026-03-18 ## Problem Cloud upload v1 tracks state globally (idle/uploading/success/error) but there's no per-track visual indicator. User can't tell which local tracks have been uploaded to the cloud, which are uploading, or which failed. ## Goal Per-track upload state indicators on TrackRow, symmetrical with the existing download state pattern. Also fix two existing gaps: downloading and download error states aren't rendered in TrackRow today. ## Non-goals (v1) - Batch upload queue UI - Per-row inline progress bar (just state icon — orange=uploading, not a ring) - Upload history table (separate from Track model) - Full lifecycle transitions (local→cloud after file deletion) - Server-side cloudTrackId reconciliation after beets reorganizes ## Acceptance Criteria - [ ] Local tracks that have been uploaded show green ↑ (`arrow.up.circle.fill`) after title - [ ] Track currently uploading shows orange ↑ (`arrow.up.circle.fill`) - [ ] Failed upload shows red ⚠ (`exclamationmark.circle.fill`), clears on retry - [ ] Upload state persists across app restarts (SwiftData) - [ ] Cloud tracks retain existing download state icons unchanged - [ ] NEW: downloading cloud tracks show hollow ↓ (`arrow.down.circle`) in accent color - [ ] NEW: download error shows red ⚠ (`exclamationmark.circle.fill`) - [ ] The v1 `uploadingFileName` hack in TrackRow is removed — replaced by track.uploadState - [ ] "Upload to Cloud" context menu sets track.uploadState through the lifecycle - [ ] After successful upload, `cloudTrackId` from server response is stored on the Track (Option A linking — enables future dedup in cloud browser) - [ ] **Group-level**: right-click a group header → "Upload All to Cloud" uploads all eligible local tracks in that group sequentially - [ ] **Group-level**: group header shows aggregate upload state indicator after track count: - Any uploading → orange ↑ with count (e.g., "2/18") - Any errors (none uploading) → red ⚠ with count - All local tracks uploaded → green ↑ (no count) - Some uploaded, some not → hollow ↑ (`arrow.up.circle`) secondary color with count - No tracks have upload state → no indicator ## Appetite Small-Medium — existing patterns, no new architecture: - 1 new enum (UploadState, mirrors DownloadState) - 1 new property on Track model (uploadStateRaw) - Modify TrackRow (unified icon resolver replaces current if/else chain) - Modify UploadService (accept Track, set uploadState + cloudTrackId) - Modify GroupHeaderView (aggregate state icon + "Upload All" context menu) - Sequential group upload (reuse UploadService per-track, queued) - ~5-6 files changed, existing patterns only ## Architecture ### Data Model ```swift enum UploadState: String, Codable { case none case uploading case uploaded case error } // On Track model (mirrors downloadStateRaw pattern): var uploadStateRaw: String = UploadState.none.rawValue var uploadState: UploadState { get { UploadState(rawValue: uploadStateRaw) ?? .none } set { uploadStateRaw = newValue.rawValue } } ``` ### Visual Design — Single Icon Slot, Priority-Ordered One icon after the title text. Priority determines which state wins: | Priority | State | Icon | Color | |----------|-------|------|-------| | 1 | Uploading | `arrow.up.circle.fill` | `.orange` | | 2 | Upload error | `exclamationmark.circle.fill` | `.red` | | 3 | Downloading | `arrow.down.circle` (hollow) | `.accentColor` | | 4 | Download error | `exclamationmark.circle.fill` | `.red` | | 5 | Downloaded | `arrow.down.circle.fill` | `.green` | | 6 | Uploaded | `arrow.up.circle.fill` | `.green` | | 7 | Cloud only | `cloud.fill` | `.accentColor` | | 8 | Local only | none | — | All icons 11pt, `.foregroundStyle` only, inline in `HStack(spacing: 4)`. ### Identity Linking (Option A, minimal) - After successful upload, server returns `track_id` in JSON response - UploadService stores this as `track.cloudTrackId` - Cloud browser already deduplicates by `cloudTrackId` (existing code in `bulkInsertCloudTracks` and `addToPlaylist`) - If user deletes local Track from SwiftData, link is lost — acceptable for v1 ### UploadService Changes - `startUpload(fileURL:apiClient:)` → `startUpload(track:apiClient:)` (accept Track) - Set `track.uploadState = .uploading` on start - Set `track.uploadState = .uploaded` + `track.cloudTrackId` on success - Set `track.uploadState = .error` on failure - Remove the `uploadingFileName` concept entirely ### Group-Level Upload (Designer: @designer) **Aggregate state indicator on GroupHeaderView** (after track count): ``` 🎵 Only Built 4 Cuban Linx... (1995) (18) ↑ 12/18 ↑ aggregate icon + count ``` Priority ordering (same hierarchy as track-level): | Priority | Condition | Icon | Color | Count | |----------|-----------|------|-------|-------| | 1 | Any track uploading | `arrow.up.circle.fill` | `.orange` | "2/18" | | 2 | Any error (none uploading) | `exclamationmark.circle.fill` | `.red` | "15/18" | | 3 | All local tracks uploaded | `arrow.up.circle.fill` | `.green` | none | | 4 | Some uploaded, some not | `arrow.up.circle` (hollow) | `.secondary` | "12/18" | | 5 | No upload state on any track | — | — | — | Count uses 10pt monospaced to match BPM/key/duration pill styling. Icon is 11pt. **"Upload All to Cloud" context menu** on group header (right-click): - Shows when: ≥1 track in group is local, has file on disk, upload state is `.none` or `.error`, Chad Music configured - Uploads eligible tracks sequentially (reuse UploadService, one at a time) - "Retry Failed Uploads" as separate item when any track has `.error` state **GroupHeaderView changes**: - Add `tracks: [Track]` parameter (all tracks in the group) - Compute `GroupUploadSummary` from tracks (priority logic + count) - Add `.contextMenu` with upload/retry actions **Sequential group upload**: - UploadService gets `uploadBatch(tracks:apiClient:)` method - Iterates tracks, uploads one at a time, skips already-uploaded - Each track's `uploadState` updates live → both track rows and group header react via SwiftUI observation ## Lifecycle — Upload → Delete → Re-download ``` 1. Local, no cloud → no icon 2. User uploads → ↑ orange (uploading) 3. Upload completes → ↑ green (uploaded, cloudTrackId set) 4. Beets reorganizes → ↑ green (unchanged, cloudTrackId may be stale) 5. User deletes local → Track gone from SwiftData, link lost 6. Browse cloud → ☁ accent (new ChadTrack, no local Track) 7. Download → ↓ hollow (downloading, new Track created) 8. Downloaded → ↓ green (isCloud=true, localCachePath set) ``` ## Dependencies & Blockers - Cloud Upload v1 server endpoint must return `track_id` in its response (requires server-side change: include the new track's ID in the JSON) - SwiftData lightweight migration must handle the new property ## Risks 1. SwiftData migration: adding `uploadStateRaw` with a default value should trigger automatic lightweight migration. Test with existing data. 2. Beets track ID staleness: after beets moves/renames the file, the `cloudTrackId` stored on the local Track becomes invalid. Optimistic for now — errors will surface when user tries to interact via the stale ID. ## Deferred to v2 - Server-side cloudTrackId reconciliation (SyncWatcher validates links) - Upload history table (separate from Track model, survives Track deletion) - Full local→cloud Track identity transition on file deletion - Retry/dismiss from error state via icon tap