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:
@@ -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
|
||||
|
||||
@@ -543,12 +543,16 @@ struct MonthlyCheckInView: View {
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.sources, id: \.objectID) { source in
|
||||
let latestSnapshot = source.latestSnapshot
|
||||
let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot)
|
||||
// Month-aware: "updated" means updated FOR THE VIEWED month.
|
||||
let monthSnapshot = snapshotForViewedMonth(source)
|
||||
let updatedThisCycle = monthSnapshot != nil
|
||||
let latestSnapshot = monthSnapshot ?? source.latestSnapshot
|
||||
let snapshots = source.sortedSnapshotsByDateAscending
|
||||
let previousSnapshot: Snapshot? = {
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
return snapshots[snapshots.count - 2]
|
||||
guard let ref = latestSnapshot,
|
||||
let idx = snapshots.firstIndex(where: { $0.objectID == ref.objectID }),
|
||||
idx > 0 else { return nil }
|
||||
return snapshots[idx - 1]
|
||||
}()
|
||||
let valueDiff: Decimal? = {
|
||||
guard let latest = latestSnapshot, let previous = previousSnapshot else { return nil }
|
||||
@@ -837,6 +841,24 @@ private extension MonthlyCheckInView {
|
||||
return snapshot.date >= Calendar.current.startOfDay(for: last)
|
||||
}
|
||||
|
||||
/// The effective (year, month) of the check-in month currently being viewed.
|
||||
private var viewedMonthComponents: DateComponents {
|
||||
let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate)
|
||||
return Calendar.current.dateComponents([.year, .month], from: effective)
|
||||
}
|
||||
|
||||
/// The source's snapshot that belongs to the VIEWED month (not the global
|
||||
/// latest) — so a past month shows "Updated" for the sources it recorded,
|
||||
/// instead of "Needs update" for everything.
|
||||
func snapshotForViewedMonth(_ source: InvestmentSource) -> Snapshot? {
|
||||
let target = viewedMonthComponents
|
||||
return source.sortedSnapshotsByDateAscending.last { snapshot in
|
||||
let eff = MonthlyCheckInStore.effectiveMonth(for: snapshot.date, relativeTo: snapshot.date)
|
||||
let c = Calendar.current.dateComponents([.year, .month], from: eff)
|
||||
return c.year == target.year && c.month == target.month
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func moodPill(for mood: MonthlyCheckInMood) -> some View {
|
||||
let isSelected = selectedMood == mood
|
||||
|
||||
Reference in New Issue
Block a user