Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(status): Address review findings from code review

- Replace tuple with TypeBreakdown struct for Sendable conformance
- Narrow catch to CLIError.notInitialized to avoid swallowing future cases
- Single dictionary lookup in computeBackupStats
- Hoist NumberFormatter to file-level constant
- Avoid intermediate array in computeS3Info via max(by:)
- Fix typo in test name (s3InfoDerives)

+29 -15
+1 -1
Sources/AtticCLI/StatusCommand.swift
··· 24 24 let manifest = try await Dependencies.loadManifest(store: manifestStore) 25 25 backup = StatusStats.computeBackupStats(assets: assets, manifest: manifest) 26 26 s3 = StatusStats.computeS3Info(bucket: config.bucket, manifest: manifest) 27 - } catch is CLIError { 27 + } catch CLIError.notInitialized { 28 28 // No config — show library only with init hint 29 29 } 30 30
+8 -4
Sources/AtticCLI/StatusRenderer.swift
··· 133 133 134 134 // MARK: - Number Formatting 135 135 136 + private let numberFormatter: NumberFormatter = { 137 + let f = NumberFormatter() 138 + f.numberStyle = .decimal 139 + f.groupingSeparator = "," 140 + return f 141 + }() 142 + 136 143 private func format(_ number: Int) -> String { 137 - let formatter = NumberFormatter() 138 - formatter.numberStyle = .decimal 139 - formatter.groupingSeparator = "," 140 - return formatter.string(from: NSNumber(value: number)) ?? "\(number)" 144 + numberFormatter.string(from: NSNumber(value: number)) ?? "\(number)" 141 145 }
+19 -9
Sources/AtticCore/StatusStats.swift
··· 3 3 4 4 // MARK: - Dashboard Data Models 5 5 6 + public struct TypeBreakdown: Sendable, Equatable { 7 + public let uti: String 8 + public let percentage: Double 9 + 10 + public init(uti: String, percentage: Double) { 11 + self.uti = uti 12 + self.percentage = percentage 13 + } 14 + } 15 + 6 16 public struct DashboardData: Sendable { 7 17 public let version: String 8 18 public let library: LibraryStats 9 19 public let backup: BackupStats? 10 20 public let s3: S3Info? 11 - public let types: [(uti: String, percentage: Double)] 21 + public let types: [TypeBreakdown] 12 22 13 23 public init( 14 24 version: String, 15 25 library: LibraryStats, 16 26 backup: BackupStats?, 17 27 s3: S3Info?, 18 - types: [(uti: String, percentage: Double)], 28 + types: [TypeBreakdown], 19 29 ) { 20 30 self.version = version 21 31 self.library = library ··· 89 99 public static func computeUTIBreakdown( 90 100 _ assets: [AssetInfo], 91 101 topN: Int = 5, 92 - ) -> [(uti: String, percentage: Double)] { 102 + ) -> [TypeBreakdown] { 93 103 guard !assets.isEmpty else { return [] } 94 104 var counts: [String: Int] = [:] 95 105 for asset in assets { ··· 99 109 } 100 110 let sorted = counts.sorted { $0.value > $1.value } 101 111 let total = Double(assets.count) 102 - var result: [(uti: String, percentage: Double)] = [] 112 + var result: [TypeBreakdown] = [] 103 113 var shown = 0.0 104 114 105 115 for (index, entry) in sorted.enumerated() { 106 116 let pct = Double(entry.value) / total * 100 107 117 if index < topN { 108 - result.append((uti: entry.key, percentage: pct)) 118 + result.append(TypeBreakdown(uti: entry.key, percentage: pct)) 109 119 shown += pct 110 120 } else { 111 121 break ··· 114 124 115 125 let otherPct = 100.0 - shown 116 126 if otherPct > 0.5 { 117 - result.append((uti: "Other", percentage: otherPct)) 127 + result.append(TypeBreakdown(uti: "Other", percentage: otherPct)) 118 128 } 119 129 120 130 return result ··· 123 133 public static func computeBackupStats(assets: [AssetInfo], manifest: Manifest) -> BackupStats { 124 134 var backedUp = 0, backedUpBytes = 0 125 135 for asset in assets { 126 - if manifest.isBackedUp(asset.uuid) { 136 + if let entry = manifest.entries[asset.uuid] { 127 137 backedUp += 1 128 - backedUpBytes += manifest.entries[asset.uuid]?.size ?? 0 138 + backedUpBytes += entry.size ?? 0 129 139 } 130 140 } 131 141 return BackupStats( ··· 138 148 139 149 public static func computeS3Info(bucket: String, manifest: Manifest) -> S3Info { 140 150 let lastBackup = manifest.entries.values 151 + .max(by: { $0.backedUpAt < $1.backedUpAt }) 141 152 .map(\.backedUpAt) 142 - .max() 143 153 let displayDate: String? = lastBackup.flatMap { iso in 144 154 let trimmed = String(iso.prefix(10)) 145 155 return trimmed.count == 10 ? trimmed : iso
+1 -1
Tests/AtticCoreTests/StatusStatsTests.swift
··· 162 162 163 163 // MARK: - S3 Info 164 164 165 - @Test func s3InfoDeriesLastBackup() { 165 + @Test func s3InfoDerivesLastBackup() { 166 166 let manifest = Manifest(entries: [ 167 167 "a": ManifestEntry(uuid: "a", s3Key: "k", checksum: "c", backedUpAt: "2026-01-15T10:00:00Z"), 168 168 "b": ManifestEntry(uuid: "b", s3Key: "k", checksum: "c", backedUpAt: "2026-04-03T14:22:00Z"),