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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user