Feedback TestFlight build 58 (build 59): labels, needs-update, period selector, Home masonry

De 7 comentarios de TestFlight:
- Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out
  (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie
  (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie.
  Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling,
  Drawdown, Volatility, Compare, Period, YoY, Prediction.
- 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth
  — el estado es por el MES QUE SE VE (effective month de referenceDate), no
  contra la última completion global. Diff usa el snapshot de ese mes.
- Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado
  consistente con el resto, opciones capadas a los meses de datos disponibles
  (availableHistoryMonths). Ya no deja pedir ventanas sin datos.
- Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho
  (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView.

Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK
→ es el subset del partial-failure, no filtro ni esquema; necesita códigos
internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4).

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 20:36:15 +02:00
parent 1abffdb7bb
commit 86c95a9e73
10 changed files with 215 additions and 119 deletions
@@ -25,15 +25,17 @@ struct DashboardView: View {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 16) {
// Calm 2.0: three zones state (hero), action (check-in),
// context (everything else). The old top clutter (streak
// badge, pending banner, insight chips) folded into them.
if viewModel.hasData {
if horizontalSizeClass == .regular {
iPadDashboardLayout
} else {
// Calm 2.0: three zones state (hero), action (check-in),
// context (everything else). The old top clutter (streak badge,
// pending banner, insight chips) folded into them.
if viewModel.hasData {
if horizontalSizeClass == .regular {
// iPad/Mac provides its own GeometryReader + ScrollView so
// the masonry can size columns to the window width.
iPadDashboardLayout
} else {
ScrollView {
VStack(spacing: 16) {
ForEach(visibleSections) { config in
sectionView(for: config)
if config.id == DashboardSection.monthlyCheckIn.id,
@@ -42,15 +44,18 @@ struct DashboardView: View {
}
}
}
} else {
EmptyDashboardView(
onAddSource: { showingAddSource = true },
onImport: { showingImport = true },
onLoadSample: { SampleDataService.shared.seedSampleData() }
)
.padding()
}
}
.padding()
} else {
ScrollView {
EmptyDashboardView(
onAddSource: { showingAddSource = true },
onImport: { showingImport = true },
onLoadSample: { SampleDataService.shared.seedSampleData() }
)
.padding()
}
}
}
.navigationTitle("Home")
@@ -177,44 +182,59 @@ struct DashboardView: View {
return leadIds.compactMap { id in visibleSections.first { $0.id == id } }
}
/// Remaining context cards, split into two balanced masonry columns so the
/// dashboard fills the iPad width without the row-height coupling (and gaps)
/// a LazyVGrid produced.
private var iPadContextColumns: ([DashboardSectionConfig], [DashboardSectionConfig]) {
/// Rough relative height per section drives greedy column balancing so the
/// masonry columns end up similar heights instead of one long, one short.
private func sectionWeight(_ id: String) -> Int {
switch DashboardSection(rawValue: id) {
case .momentumStreaks, .evolution: return 3
case .categoryBreakdown, .goals: return 2
default: return 1
}
}
/// Distributes the context cards into `count` masonry columns, greedily
/// placing each into the currently-shortest column (by accumulated weight).
private func iPadContextColumns(count: Int) -> [[DashboardSectionConfig]] {
let leadIds = Set([DashboardSection.totalValue.id, DashboardSection.monthlyCheckIn.id])
let rest = visibleSections.filter { !leadIds.contains($0.id) }
var left: [DashboardSectionConfig] = []
var right: [DashboardSectionConfig] = []
for (i, cfg) in rest.enumerated() {
if i.isMultiple(of: 2) { left.append(cfg) } else { right.append(cfg) }
var columns = Array(repeating: [DashboardSectionConfig](), count: count)
var weights = Array(repeating: 0, count: count)
for cfg in rest {
let target = weights.enumerated().min(by: { $0.element < $1.element })?.offset ?? 0
columns[target].append(cfg)
weights[target] += sectionWeight(cfg.id)
}
return (left, right)
return columns
}
@ViewBuilder
private var iPadDashboardLayout: some View {
let columns = iPadContextColumns
VStack(spacing: 16) {
ForEach(iPadLeadSections) { config in
sectionView(for: config)
if config.id == DashboardSection.monthlyCheckIn.id, !viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
}
}
GeometryReader { geo in
// Scale column count with available width so a wide Mac window fills
// (3 columns) while an iPad stays at 2. Lead cards span full width.
let columnCount = geo.size.width >= 1250 ? 3 : 2
let columns = iPadContextColumns(count: columnCount)
ScrollView {
VStack(spacing: 16) {
ForEach(iPadLeadSections) { config in
sectionView(for: config)
if config.id == DashboardSection.monthlyCheckIn.id, !viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
}
}
HStack(alignment: .top, spacing: 16) {
VStack(spacing: 16) {
ForEach(columns.0) { sectionView(for: $0) }
HStack(alignment: .top, spacing: 16) {
ForEach(columns.indices, id: \.self) { i in
VStack(spacing: 16) {
ForEach(columns[i]) { sectionView(for: $0) }
}
.frame(maxWidth: .infinity)
}
}
}
.frame(maxWidth: .infinity)
VStack(spacing: 16) {
ForEach(columns.1) { sectionView(for: $0) }
}
.frame(maxWidth: .infinity)
.padding()
}
}
.frame(maxWidth: 1100)
.frame(maxWidth: .infinity)
}
@ViewBuilder