Fixes UI iPad + diagnóstico iCloud (build 57)

Feedback del usuario en iPad:
- Allocation: gráfica de tarta → semicírculo estilo hemiciclo (parlamento).
  SemicircleAllocation con arcos dibujados a mano (SemicircleArc Shape),
  segmentos por cuota en 180°, total/selección en el centro, tap por segmento.
- Charts de Analyze (Compare, Period vs Period, Year vs Year) ahora SÍ muestran
  KPIs en la cabecera de iPad como el resto: Compare (líder/rezagado por modo),
  Period (retorno final por periodo), YoY (retorno final por año seleccionado).
- Momentum & Streaks en el sidebar del Journal (iPad, 320pt): variante compact
  — tiles en fila sin subtítulos que rompían a 3 líneas, sin bloque de logros.

iCloud:
- CKErrorPartialFailure (CKErrorDomain code 2) mostraba un mensaje opaco.
  CoreDataStack.hint(for:) recorre CKPartialErrorsByItemIDKey y clasifica el
  código dominante → hint accionable (esquema/quota/red). Settings muestra el
  hint legible sobre el detalle técnico. lastSyncErrorHint publicado. Strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
alexandrev-tibco
2026-07-10 14:16:24 +02:00
parent b76b8f81f0
commit 18f2609d87
14 changed files with 288 additions and 76 deletions
@@ -123,6 +123,8 @@ class CoreDataStack: ObservableObject {
@Published private(set) var lastExportDate: Date?
@Published private(set) var isSyncing = false
@Published private(set) var lastSyncError: String?
/// Human-readable, actionable explanation of the last sync error (nil if none).
@Published private(set) var lastSyncErrorHint: String?
var lastSyncDate: Date? { lastImportDate }
@@ -343,6 +345,7 @@ class CoreDataStack: ObservableObject {
} else if let error = event.error {
let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup"
self.lastSyncError = "\(typeLabel): \(Self.describeError(error))"
self.lastSyncErrorHint = Self.hint(for: error)
print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)")
}
}
@@ -376,6 +379,44 @@ class CoreDataStack: ObservableObject {
return parts.joined(separator: "\n")
}
/// Turns an opaque CloudKit error into a human-readable, actionable hint by
/// digging into CKPartialErrorsByItemIDKey the per-record failures hidden
/// inside a CKErrorPartialFailure (code 2). The dominant underlying code
/// tells us the real problem: schema not deployed (unknownItem/invalidArguments)
/// vs. quota vs. transient network.
static func hint(for error: Error) -> String? {
let ns = error as NSError
// Collect all leaf CKError codes across a possible partial-failure tree.
var codes: [Int] = []
func collect(_ e: NSError) {
if e.domain == "CKErrorDomain", e.code != 2 { codes.append(e.code) }
if let partial = e.userInfo["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
for case let sub as NSError in partial.values { collect(sub) }
}
for value in e.userInfo.values {
if let sub = value as? NSError, sub !== e { collect(sub) }
}
}
collect(ns)
// CKError codes: 14 = unknownItem, 12 = invalidArguments, 6 = partialFailure,
// 25 = quotaExceeded, 4 = networkFailure/unavailable, 3 = networkUnavailable,
// 15 = serverRejectedRequest, 26 = operationCancelled.
if codes.contains(14) || codes.contains(12) || codes.contains(15) {
return String(localized: "icloud_hint_schema")
}
if codes.contains(25) {
return String(localized: "icloud_hint_quota")
}
if codes.contains(3) || codes.contains(4) {
return String(localized: "icloud_hint_network")
}
if ns.code == 2 {
return String(localized: "icloud_hint_partial_generic")
}
return nil
}
func forceReload() {
viewContext.perform { [weak self] in
self?.viewContext.refreshAllObjects()