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
@@ -11,6 +11,28 @@ enum ChartLabels {
/// A series with at most this many points shows its value labels permanently.
static let alwaysShowThreshold = 12
/// Max permanent labels to render before thinning fewer on the narrow
/// iPhone screen where crammed labels overlap and become unreadable.
static func maxLabels(compact: Bool) -> Int { compact ? 5 : 8 }
/// Stride between labelled points so at most `maxLabels` show.
static func stride(count: Int, compact: Bool) -> Int {
let maxN = maxLabels(compact: compact)
guard count > maxN else { return 1 }
return Int(ceil(Double(count) / Double(maxN)))
}
/// Whether point `index` should carry a permanent label: always the first
/// and last (the endpoints matter most), plus every `stride`-th in between.
static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool {
guard count > 0 else { return false }
if index == 0 || index == count - 1 { return true }
let s = stride(count: count, compact: compact)
// Avoid a label landing right next to the forced last one.
if index >= count - 1 - (s / 2) { return false }
return index % s == 0
}
static func compactCurrency(_ value: Decimal) -> String {
value.compactCurrencyString
}
@@ -492,7 +492,7 @@ struct ChartsContainerView: View {
}
Spacer()
if viewModel.selectedChartType == .performance {
performanceSlider
performancePeriodPicker
.frame(maxWidth: 360)
} else if !ranges.isEmpty {
Picker("Period", selection: $viewModel.selectedTimeRange) {
@@ -508,22 +508,37 @@ struct ChartsContainerView: View {
.padding(.horizontal, 4)
}
private var performanceSlider: some View {
HStack(spacing: 10) {
Text("Period: \(periodLabel(viewModel.performancePeriodMonths))")
.font(.caption.weight(.medium))
.foregroundColor(.secondary)
.frame(width: 92, alignment: .leading)
Slider(
value: Binding(
get: { Double(viewModel.performancePeriodMonths) },
set: { viewModel.performancePeriodMonths = Int($0) }
),
in: 1...60,
step: 1
)
.tint(.appPrimary)
/// Months of history actually available (distinct aggregated months). The
/// Performance period options never exceed this, so the control can't ask
/// for a window with no data.
private var availableHistoryMonths: Int {
max(1, viewModel.evolutionData.count)
}
/// Discrete period options (in months) capped to available data mirrors
/// the segmented period control used by every other chart for consistency.
private var performancePeriodOptions: [(label: String, months: Int)] {
let candidates = [(3, "3M"), (6, "6M"), (12, "12M"), (24, "2Y")]
var opts = candidates.filter { $0.0 < availableHistoryMonths }.map { ($0.1, $0.0) }
opts.append(("All", availableHistoryMonths)) // full history
return opts.map { (label: $0.0, months: $0.1) }
}
private var performancePeriodPicker: some View {
Picker("Period", selection: Binding(
get: {
// Snap to the closest available option.
let m = viewModel.performancePeriodMonths
return performancePeriodOptions.min(by: { abs($0.months - m) < abs($1.months - m) })?.months
?? availableHistoryMonths
},
set: { viewModel.performancePeriodMonths = min($0, availableHistoryMonths) }
)) {
ForEach(performancePeriodOptions, id: \.months) { opt in
Text(opt.label).tag(opt.months)
}
}
.pickerStyle(.segmented)
}
private var shareButton: some View {
@@ -605,10 +620,7 @@ struct ChartsContainerView: View {
private var iPhonePeriodControl: some View {
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
if viewModel.selectedChartType == .performance {
performanceSlider
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.smallCornerRadius)
performancePeriodPicker
} else if !ranges.isEmpty {
Picker("Period", selection: $viewModel.selectedTimeRange) {
ForEach(ranges) { range in
@@ -815,6 +827,9 @@ struct EvolutionChartView: View {
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
@Environment(\.chartImageExport) private var chartImageExport
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var labelsCompact: Bool { labelsSizeClass != .regular }
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@@ -1033,7 +1048,8 @@ struct EvolutionChartView: View {
private var chartMarks: some ChartContent {
switch chartMode {
case .total:
ForEach(data, id: \.date) { item in
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
@@ -1046,9 +1062,9 @@ struct EvolutionChartView: View {
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(data.count <= ChartLabels.alwaysShowThreshold ? 40 : 30)
.symbolSize(30)
.annotation(position: .top, spacing: 3) {
if data.count <= ChartLabels.alwaysShowThreshold {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: item.value.compactCurrencyString)
}
}
@@ -1208,8 +1224,9 @@ struct RollingReturnChartView: View {
let data: [(date: Date, value: Double)]
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, value: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
@@ -1228,7 +1245,8 @@ struct RollingReturnChartView: View {
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Month", item.date),
y: .value("Return", item.value)
@@ -1241,9 +1259,9 @@ struct RollingReturnChartView: View {
y: .value("Return", item.value)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(showAllLabels ? 36 : 24)
.symbolSize(24)
.annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) {
if showAllLabels {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: ChartLabels.percent(item.value))
}
}
@@ -111,11 +111,9 @@ struct ComparisonChartView: View {
.frame(height: 260)
}
/// Permanent labels only when the chart stays readable (few points AND few series).
private var showAllLabels: Bool {
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
return maxPoints <= ChartLabels.alwaysShowThreshold && viewModel.comparisonData.count <= 3
}
/// Multi-series comparison: labelling every point of every series overlaps
/// badly. Instead we label only each series' END point (where it landed),
/// which is the value the comparison is actually about.
private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] {
viewModel.comparisonData.compactMap { series in
@@ -131,6 +129,7 @@ struct ComparisonChartView: View {
Chart {
ForEach(viewModel.comparisonData, id: \.id) { series in
let seriesColor = Color(hex: series.colorHex) ?? .appPrimary
let lastDate = series.points.last?.date
ForEach(series.points, id: \.date) { point in
LineMark(
x: .value("Date", point.date),
@@ -145,9 +144,9 @@ struct ComparisonChartView: View {
y: .value("Value", point.value)
)
.foregroundStyle(seriesColor)
.symbolSize(showAllLabels ? 32 : 16)
.annotation(position: .top, spacing: 2) {
if showAllLabels {
.symbolSize(point.date == lastDate ? 30 : 14)
.annotation(position: .trailing, spacing: 2) {
if point.date == lastDate {
ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor)
}
}
@@ -5,8 +5,9 @@ struct DrawdownChart: View {
let data: [(date: Date, drawdown: Double)]
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, drawdown: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
@@ -46,7 +47,8 @@ struct DrawdownChart: View {
if data.count >= 2 {
Chart {
ForEach(data, id: \.date) { item in
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
AreaMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
@@ -72,9 +74,9 @@ struct DrawdownChart: View {
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.symbolSize(showAllLabels ? 36 : 20)
.symbolSize(20)
.annotation(position: .bottom, spacing: 3) {
if showAllLabels {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed)
}
}
@@ -207,8 +209,9 @@ struct VolatilityChartView: View {
let data: [(date: Date, volatility: Double)]
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, volatility: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
@@ -240,7 +243,8 @@ struct VolatilityChartView: View {
if data.count >= 2 {
Chart {
ForEach(data, id: \.date) { item in
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
@@ -266,9 +270,9 @@ struct VolatilityChartView: View {
y: .value("Volatility", item.volatility)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(showAllLabels ? 36 : 20)
.symbolSize(20)
.annotation(position: .top, spacing: 3) {
if showAllLabels {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: String(format: "%.1f%%", item.volatility))
}
}
@@ -6,10 +6,6 @@ struct PeriodComparisonChartView: View {
@State private var selectedMonth: Int?
/// Period charts are short by design (a handful of months) labels always help.
private var showAllLabels: Bool {
(viewModel.periodComparisonData.map { $0.points.count }.max() ?? 0) <= ChartLabels.alwaysShowThreshold
}
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
viewModel.periodComparisonData.compactMap { series in
guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil }
@@ -217,8 +213,18 @@ struct PeriodComparisonChartView: View {
return result
}
/// Last month offset per series used to label only each series' endpoint.
private var lastOffsetBySeries: [String: Int] {
var m: [String: Int] = [:]
for s in viewModel.periodComparisonData {
m[s.id] = s.points.map(\.monthOffset).max()
}
return m
}
private var periodChart: some View {
let points = flatPoints
let endpoints = lastOffsetBySeries
let seriesIds = viewModel.periodComparisonData.map { $0.id }
let seriesColors: [Color] = viewModel.periodComparisonData.map {
Color(hex: $0.colorHex) ?? .gray
@@ -240,10 +246,10 @@ struct PeriodComparisonChartView: View {
y: .value("Return", point.returnPct)
)
.foregroundStyle(by: .value("Period", point.seriesLabel))
.symbolSize(showAllLabels ? 40 : 30)
.symbolSize(point.monthOffset == endpoints[point.seriesId] ? 38 : 22)
.annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom,
spacing: 3) {
if showAllLabels {
if point.monthOffset == endpoints[point.seriesId] {
ChartValueBubble(
text: ChartLabels.percent(point.returnPct),
color: Color(hex: point.colorHex) ?? .gray
@@ -5,10 +5,9 @@ struct PredictionChartView: View {
let predictions: [Prediction]
let historicalData: [(date: Date, value: Decimal)]
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var showAllLabels: Bool {
historicalData.count + predictions.count <= ChartLabels.alwaysShowThreshold
}
private var labelsCompact: Bool { labelsSizeClass != .regular }
/// Nearest point (historical or predicted) to the selected date.
private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? {
@@ -49,8 +48,9 @@ struct PredictionChartView: View {
if !predictions.isEmpty && historicalData.count >= 2 {
Chart {
// Historical data
ForEach(historicalData, id: \.date) { item in
// Historical data thin labels to at most a handful.
ForEach(Array(historicalData.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
@@ -63,9 +63,9 @@ struct PredictionChartView: View {
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(showAllLabels ? 36 : 24)
.symbolSize(24)
.annotation(position: .top, spacing: 3) {
if showAllLabels {
if ChartLabels.showsLabel(index: pair.offset, count: historicalData.count, compact: labelsCompact) {
ChartValueBubble(text: item.value.compactCurrencyString)
}
}
@@ -95,9 +95,10 @@ struct PredictionChartView: View {
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.symbolSize(showAllLabels ? 36 : 24)
.symbolSize(prediction.id == predictions.last?.id ? 34 : 24)
.annotation(position: .top, spacing: 3) {
if showAllLabels {
// Only the final forecast point (the KPIs cover the rest).
if prediction.id == predictions.last?.id {
ChartValueBubble(text: prediction.predictedValue.compactCurrencyString, color: .appSecondary)
}
}
@@ -93,8 +93,11 @@ struct YearOverYearChartView: View {
// MARK: - Chart
/// Two selected years × 12 months stays readable with permanent labels.
private var showAllLabels: Bool { selectedSeries.count <= 2 }
/// Last month index that has data, per series id label only that endpoint
/// (the year's cumulative return) to avoid 24 overlapping labels.
private func lastValidMonth(_ series: ChartsViewModel.YearSeries) -> Int? {
(0..<12).last { !series.values[$0].isNaN }
}
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
selectedSeries.compactMap { series in
@@ -113,6 +116,7 @@ struct YearOverYearChartView: View {
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let color = lineColors[idx % lineColors.count]
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let endMonth = lastValidMonth(yearSeries)
ForEach(0..<12, id: \.self) { monthIdx in
let value = yearSeries.values[monthIdx]
if !value.isNaN {
@@ -129,9 +133,9 @@ struct YearOverYearChartView: View {
y: .value("Return", value)
)
.foregroundStyle(color)
.symbolSize(showAllLabels ? 32 : 18)
.symbolSize(monthIdx == endMonth ? 34 : 18)
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
if showAllLabels {
if monthIdx == endMonth {
ChartValueBubble(text: ChartLabels.percent(value), color: color)
}
}