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:
@@ -541,7 +541,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -582,7 +582,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -739,7 +739,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -774,7 +774,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -805,7 +805,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -829,7 +829,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -852,7 +852,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -875,7 +875,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -899,7 +899,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||||
@@ -927,7 +927,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 58;
|
CURRENT_PROJECT_VERSION = 59;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
|||||||
@@ -11,6 +11,28 @@ enum ChartLabels {
|
|||||||
/// A series with at most this many points shows its value labels permanently.
|
/// A series with at most this many points shows its value labels permanently.
|
||||||
static let alwaysShowThreshold = 12
|
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 {
|
static func compactCurrency(_ value: Decimal) -> String {
|
||||||
value.compactCurrencyString
|
value.compactCurrencyString
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -492,7 +492,7 @@ struct ChartsContainerView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if viewModel.selectedChartType == .performance {
|
if viewModel.selectedChartType == .performance {
|
||||||
performanceSlider
|
performancePeriodPicker
|
||||||
.frame(maxWidth: 360)
|
.frame(maxWidth: 360)
|
||||||
} else if !ranges.isEmpty {
|
} else if !ranges.isEmpty {
|
||||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||||
@@ -508,22 +508,37 @@ struct ChartsContainerView: View {
|
|||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var performanceSlider: some View {
|
/// Months of history actually available (distinct aggregated months). The
|
||||||
HStack(spacing: 10) {
|
/// Performance period options never exceed this, so the control can't ask
|
||||||
Text("Period: \(periodLabel(viewModel.performancePeriodMonths))")
|
/// for a window with no data.
|
||||||
.font(.caption.weight(.medium))
|
private var availableHistoryMonths: Int {
|
||||||
.foregroundColor(.secondary)
|
max(1, viewModel.evolutionData.count)
|
||||||
.frame(width: 92, alignment: .leading)
|
}
|
||||||
Slider(
|
|
||||||
value: Binding(
|
/// Discrete period options (in months) capped to available data — mirrors
|
||||||
get: { Double(viewModel.performancePeriodMonths) },
|
/// the segmented period control used by every other chart for consistency.
|
||||||
set: { viewModel.performancePeriodMonths = Int($0) }
|
private var performancePeriodOptions: [(label: String, months: Int)] {
|
||||||
),
|
let candidates = [(3, "3M"), (6, "6M"), (12, "12M"), (24, "2Y")]
|
||||||
in: 1...60,
|
var opts = candidates.filter { $0.0 < availableHistoryMonths }.map { ($0.1, $0.0) }
|
||||||
step: 1
|
opts.append(("All", availableHistoryMonths)) // full history
|
||||||
)
|
return opts.map { (label: $0.0, months: $0.1) }
|
||||||
.tint(.appPrimary)
|
}
|
||||||
|
|
||||||
|
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 {
|
private var shareButton: some View {
|
||||||
@@ -605,10 +620,7 @@ struct ChartsContainerView: View {
|
|||||||
private var iPhonePeriodControl: some View {
|
private var iPhonePeriodControl: some View {
|
||||||
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
||||||
if viewModel.selectedChartType == .performance {
|
if viewModel.selectedChartType == .performance {
|
||||||
performanceSlider
|
performancePeriodPicker
|
||||||
.padding(12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
|
||||||
} else if !ranges.isEmpty {
|
} else if !ranges.isEmpty {
|
||||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||||
ForEach(ranges) { range in
|
ForEach(ranges) { range in
|
||||||
@@ -815,6 +827,9 @@ struct EvolutionChartView: View {
|
|||||||
let categoryData: [CategoryEvolutionPoint]
|
let categoryData: [CategoryEvolutionPoint]
|
||||||
let goals: [Goal]
|
let goals: [Goal]
|
||||||
@Environment(\.chartImageExport) private var chartImageExport
|
@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 selectedDataPoint: (date: Date, value: Decimal)?
|
||||||
@State private var chartMode: ChartMode = .total
|
@State private var chartMode: ChartMode = .total
|
||||||
@@ -1033,7 +1048,8 @@ struct EvolutionChartView: View {
|
|||||||
private var chartMarks: some ChartContent {
|
private var chartMarks: some ChartContent {
|
||||||
switch chartMode {
|
switch chartMode {
|
||||||
case .total:
|
case .total:
|
||||||
ForEach(data, id: \.date) { item in
|
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||||
|
let item = pair.element
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Date", item.date),
|
x: .value("Date", item.date),
|
||||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||||
@@ -1046,9 +1062,9 @@ struct EvolutionChartView: View {
|
|||||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appPrimary)
|
.foregroundStyle(Color.appPrimary)
|
||||||
.symbolSize(data.count <= ChartLabels.alwaysShowThreshold ? 40 : 30)
|
.symbolSize(30)
|
||||||
.annotation(position: .top, spacing: 3) {
|
.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)
|
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1208,8 +1224,9 @@ struct RollingReturnChartView: View {
|
|||||||
let data: [(date: Date, value: Double)]
|
let data: [(date: Date, value: Double)]
|
||||||
@State private var zoom = ChartZoomModel()
|
@State private var zoom = ChartZoomModel()
|
||||||
@State private var selectedDate: Date?
|
@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)? {
|
private var selectedPoint: (date: Date, value: Double)? {
|
||||||
guard let selectedDate else { return nil }
|
guard let selectedDate else { return nil }
|
||||||
return data.min(by: {
|
return data.min(by: {
|
||||||
@@ -1228,7 +1245,8 @@ struct RollingReturnChartView: View {
|
|||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
} else {
|
} else {
|
||||||
Chart {
|
Chart {
|
||||||
ForEach(data, id: \.date) { item in
|
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||||
|
let item = pair.element
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Month", item.date),
|
x: .value("Month", item.date),
|
||||||
y: .value("Return", item.value)
|
y: .value("Return", item.value)
|
||||||
@@ -1241,9 +1259,9 @@ struct RollingReturnChartView: View {
|
|||||||
y: .value("Return", item.value)
|
y: .value("Return", item.value)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appPrimary)
|
.foregroundStyle(Color.appPrimary)
|
||||||
.symbolSize(showAllLabels ? 36 : 24)
|
.symbolSize(24)
|
||||||
.annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) {
|
.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))
|
ChartValueBubble(text: ChartLabels.percent(item.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,11 +111,9 @@ struct ComparisonChartView: View {
|
|||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permanent labels only when the chart stays readable (few points AND few series).
|
/// Multi-series comparison: labelling every point of every series overlaps
|
||||||
private var showAllLabels: Bool {
|
/// badly. Instead we label only each series' END point (where it landed),
|
||||||
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
|
/// which is the value the comparison is actually about.
|
||||||
return maxPoints <= ChartLabels.alwaysShowThreshold && viewModel.comparisonData.count <= 3
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] {
|
private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] {
|
||||||
viewModel.comparisonData.compactMap { series in
|
viewModel.comparisonData.compactMap { series in
|
||||||
@@ -131,6 +129,7 @@ struct ComparisonChartView: View {
|
|||||||
Chart {
|
Chart {
|
||||||
ForEach(viewModel.comparisonData, id: \.id) { series in
|
ForEach(viewModel.comparisonData, id: \.id) { series in
|
||||||
let seriesColor = Color(hex: series.colorHex) ?? .appPrimary
|
let seriesColor = Color(hex: series.colorHex) ?? .appPrimary
|
||||||
|
let lastDate = series.points.last?.date
|
||||||
ForEach(series.points, id: \.date) { point in
|
ForEach(series.points, id: \.date) { point in
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Date", point.date),
|
x: .value("Date", point.date),
|
||||||
@@ -145,9 +144,9 @@ struct ComparisonChartView: View {
|
|||||||
y: .value("Value", point.value)
|
y: .value("Value", point.value)
|
||||||
)
|
)
|
||||||
.foregroundStyle(seriesColor)
|
.foregroundStyle(seriesColor)
|
||||||
.symbolSize(showAllLabels ? 32 : 16)
|
.symbolSize(point.date == lastDate ? 30 : 14)
|
||||||
.annotation(position: .top, spacing: 2) {
|
.annotation(position: .trailing, spacing: 2) {
|
||||||
if showAllLabels {
|
if point.date == lastDate {
|
||||||
ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor)
|
ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ struct DrawdownChart: View {
|
|||||||
let data: [(date: Date, drawdown: Double)]
|
let data: [(date: Date, drawdown: Double)]
|
||||||
@State private var zoom = ChartZoomModel()
|
@State private var zoom = ChartZoomModel()
|
||||||
@State private var selectedDate: Date?
|
@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)? {
|
private var selectedPoint: (date: Date, drawdown: Double)? {
|
||||||
guard let selectedDate else { return nil }
|
guard let selectedDate else { return nil }
|
||||||
return data.min(by: {
|
return data.min(by: {
|
||||||
@@ -46,7 +47,8 @@ struct DrawdownChart: View {
|
|||||||
|
|
||||||
if data.count >= 2 {
|
if data.count >= 2 {
|
||||||
Chart {
|
Chart {
|
||||||
ForEach(data, id: \.date) { item in
|
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||||
|
let item = pair.element
|
||||||
AreaMark(
|
AreaMark(
|
||||||
x: .value("Date", item.date),
|
x: .value("Date", item.date),
|
||||||
y: .value("Drawdown", item.drawdown)
|
y: .value("Drawdown", item.drawdown)
|
||||||
@@ -72,9 +74,9 @@ struct DrawdownChart: View {
|
|||||||
y: .value("Drawdown", item.drawdown)
|
y: .value("Drawdown", item.drawdown)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.negativeRed)
|
.foregroundStyle(Color.negativeRed)
|
||||||
.symbolSize(showAllLabels ? 36 : 20)
|
.symbolSize(20)
|
||||||
.annotation(position: .bottom, spacing: 3) {
|
.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)
|
ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,8 +209,9 @@ struct VolatilityChartView: View {
|
|||||||
let data: [(date: Date, volatility: Double)]
|
let data: [(date: Date, volatility: Double)]
|
||||||
@State private var zoom = ChartZoomModel()
|
@State private var zoom = ChartZoomModel()
|
||||||
@State private var selectedDate: Date?
|
@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)? {
|
private var selectedPoint: (date: Date, volatility: Double)? {
|
||||||
guard let selectedDate else { return nil }
|
guard let selectedDate else { return nil }
|
||||||
return data.min(by: {
|
return data.min(by: {
|
||||||
@@ -240,7 +243,8 @@ struct VolatilityChartView: View {
|
|||||||
|
|
||||||
if data.count >= 2 {
|
if data.count >= 2 {
|
||||||
Chart {
|
Chart {
|
||||||
ForEach(data, id: \.date) { item in
|
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||||
|
let item = pair.element
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Date", item.date),
|
x: .value("Date", item.date),
|
||||||
y: .value("Volatility", item.volatility)
|
y: .value("Volatility", item.volatility)
|
||||||
@@ -266,9 +270,9 @@ struct VolatilityChartView: View {
|
|||||||
y: .value("Volatility", item.volatility)
|
y: .value("Volatility", item.volatility)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appPrimary)
|
.foregroundStyle(Color.appPrimary)
|
||||||
.symbolSize(showAllLabels ? 36 : 20)
|
.symbolSize(20)
|
||||||
.annotation(position: .top, spacing: 3) {
|
.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))
|
ChartValueBubble(text: String(format: "%.1f%%", item.volatility))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ struct PeriodComparisonChartView: View {
|
|||||||
@State private var selectedMonth: Int?
|
@State private var selectedMonth: Int?
|
||||||
|
|
||||||
/// Period charts are short by design (a handful of months) — labels always help.
|
/// 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)] {
|
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
|
||||||
viewModel.periodComparisonData.compactMap { series in
|
viewModel.periodComparisonData.compactMap { series in
|
||||||
guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil }
|
guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil }
|
||||||
@@ -217,8 +213,18 @@ struct PeriodComparisonChartView: View {
|
|||||||
return result
|
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 {
|
private var periodChart: some View {
|
||||||
let points = flatPoints
|
let points = flatPoints
|
||||||
|
let endpoints = lastOffsetBySeries
|
||||||
let seriesIds = viewModel.periodComparisonData.map { $0.id }
|
let seriesIds = viewModel.periodComparisonData.map { $0.id }
|
||||||
let seriesColors: [Color] = viewModel.periodComparisonData.map {
|
let seriesColors: [Color] = viewModel.periodComparisonData.map {
|
||||||
Color(hex: $0.colorHex) ?? .gray
|
Color(hex: $0.colorHex) ?? .gray
|
||||||
@@ -240,10 +246,10 @@ struct PeriodComparisonChartView: View {
|
|||||||
y: .value("Return", point.returnPct)
|
y: .value("Return", point.returnPct)
|
||||||
)
|
)
|
||||||
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
.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,
|
.annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom,
|
||||||
spacing: 3) {
|
spacing: 3) {
|
||||||
if showAllLabels {
|
if point.monthOffset == endpoints[point.seriesId] {
|
||||||
ChartValueBubble(
|
ChartValueBubble(
|
||||||
text: ChartLabels.percent(point.returnPct),
|
text: ChartLabels.percent(point.returnPct),
|
||||||
color: Color(hex: point.colorHex) ?? .gray
|
color: Color(hex: point.colorHex) ?? .gray
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ struct PredictionChartView: View {
|
|||||||
let predictions: [Prediction]
|
let predictions: [Prediction]
|
||||||
let historicalData: [(date: Date, value: Decimal)]
|
let historicalData: [(date: Date, value: Decimal)]
|
||||||
@State private var selectedDate: Date?
|
@State private var selectedDate: Date?
|
||||||
|
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||||
|
|
||||||
private var showAllLabels: Bool {
|
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||||
historicalData.count + predictions.count <= ChartLabels.alwaysShowThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nearest point (historical or predicted) to the selected date.
|
/// Nearest point (historical or predicted) to the selected date.
|
||||||
private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? {
|
private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? {
|
||||||
@@ -49,8 +48,9 @@ struct PredictionChartView: View {
|
|||||||
|
|
||||||
if !predictions.isEmpty && historicalData.count >= 2 {
|
if !predictions.isEmpty && historicalData.count >= 2 {
|
||||||
Chart {
|
Chart {
|
||||||
// Historical data
|
// Historical data — thin labels to at most a handful.
|
||||||
ForEach(historicalData, id: \.date) { item in
|
ForEach(Array(historicalData.enumerated()), id: \.element.date) { pair in
|
||||||
|
let item = pair.element
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Date", item.date),
|
x: .value("Date", item.date),
|
||||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||||
@@ -63,9 +63,9 @@ struct PredictionChartView: View {
|
|||||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appPrimary)
|
.foregroundStyle(Color.appPrimary)
|
||||||
.symbolSize(showAllLabels ? 36 : 24)
|
.symbolSize(24)
|
||||||
.annotation(position: .top, spacing: 3) {
|
.annotation(position: .top, spacing: 3) {
|
||||||
if showAllLabels {
|
if ChartLabels.showsLabel(index: pair.offset, count: historicalData.count, compact: labelsCompact) {
|
||||||
ChartValueBubble(text: item.value.compactCurrencyString)
|
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,9 +95,10 @@ struct PredictionChartView: View {
|
|||||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appSecondary)
|
.foregroundStyle(Color.appSecondary)
|
||||||
.symbolSize(showAllLabels ? 36 : 24)
|
.symbolSize(prediction.id == predictions.last?.id ? 34 : 24)
|
||||||
.annotation(position: .top, spacing: 3) {
|
.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)
|
ChartValueBubble(text: prediction.predictedValue.compactCurrencyString, color: .appSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,11 @@ struct YearOverYearChartView: View {
|
|||||||
|
|
||||||
// MARK: - Chart
|
// MARK: - Chart
|
||||||
|
|
||||||
/// Two selected years × 12 months stays readable with permanent labels.
|
/// Last month index that has data, per series id — label only that endpoint
|
||||||
private var showAllLabels: Bool { selectedSeries.count <= 2 }
|
/// (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)] {
|
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
|
||||||
selectedSeries.compactMap { series in
|
selectedSeries.compactMap { series in
|
||||||
@@ -113,6 +116,7 @@ struct YearOverYearChartView: View {
|
|||||||
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||||
let color = lineColors[idx % lineColors.count]
|
let color = lineColors[idx % lineColors.count]
|
||||||
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||||
|
let endMonth = lastValidMonth(yearSeries)
|
||||||
ForEach(0..<12, id: \.self) { monthIdx in
|
ForEach(0..<12, id: \.self) { monthIdx in
|
||||||
let value = yearSeries.values[monthIdx]
|
let value = yearSeries.values[monthIdx]
|
||||||
if !value.isNaN {
|
if !value.isNaN {
|
||||||
@@ -129,9 +133,9 @@ struct YearOverYearChartView: View {
|
|||||||
y: .value("Return", value)
|
y: .value("Return", value)
|
||||||
)
|
)
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
.symbolSize(showAllLabels ? 32 : 18)
|
.symbolSize(monthIdx == endMonth ? 34 : 18)
|
||||||
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
|
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
|
||||||
if showAllLabels {
|
if monthIdx == endMonth {
|
||||||
ChartValueBubble(text: ChartLabels.percent(value), color: color)
|
ChartValueBubble(text: ChartLabels.percent(value), color: color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,15 +25,17 @@ struct DashboardView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
AppBackground()
|
AppBackground()
|
||||||
|
|
||||||
ScrollView {
|
// Calm 2.0: three zones — state (hero), action (check-in),
|
||||||
VStack(spacing: 16) {
|
// context (everything else). The old top clutter (streak badge,
|
||||||
// Calm 2.0: three zones — state (hero), action (check-in),
|
// pending banner, insight chips) folded into them.
|
||||||
// context (everything else). The old top clutter (streak
|
if viewModel.hasData {
|
||||||
// badge, pending banner, insight chips) folded into them.
|
if horizontalSizeClass == .regular {
|
||||||
if viewModel.hasData {
|
// iPad/Mac provides its own GeometryReader + ScrollView so
|
||||||
if horizontalSizeClass == .regular {
|
// the masonry can size columns to the window width.
|
||||||
iPadDashboardLayout
|
iPadDashboardLayout
|
||||||
} else {
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
ForEach(visibleSections) { config in
|
ForEach(visibleSections) { config in
|
||||||
sectionView(for: config)
|
sectionView(for: config)
|
||||||
if config.id == DashboardSection.monthlyCheckIn.id,
|
if config.id == DashboardSection.monthlyCheckIn.id,
|
||||||
@@ -42,15 +44,18 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
.padding()
|
||||||
EmptyDashboardView(
|
|
||||||
onAddSource: { showingAddSource = true },
|
|
||||||
onImport: { showingImport = true },
|
|
||||||
onLoadSample: { SampleDataService.shared.seedSampleData() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
EmptyDashboardView(
|
||||||
|
onAddSource: { showingAddSource = true },
|
||||||
|
onImport: { showingImport = true },
|
||||||
|
onLoadSample: { SampleDataService.shared.seedSampleData() }
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Home")
|
.navigationTitle("Home")
|
||||||
@@ -177,44 +182,59 @@ struct DashboardView: View {
|
|||||||
return leadIds.compactMap { id in visibleSections.first { $0.id == id } }
|
return leadIds.compactMap { id in visibleSections.first { $0.id == id } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remaining context cards, split into two balanced masonry columns so the
|
/// Rough relative height per section — drives greedy column balancing so the
|
||||||
/// dashboard fills the iPad width without the row-height coupling (and gaps)
|
/// masonry columns end up similar heights instead of one long, one short.
|
||||||
/// a LazyVGrid produced.
|
private func sectionWeight(_ id: String) -> Int {
|
||||||
private var iPadContextColumns: ([DashboardSectionConfig], [DashboardSectionConfig]) {
|
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 leadIds = Set([DashboardSection.totalValue.id, DashboardSection.monthlyCheckIn.id])
|
||||||
let rest = visibleSections.filter { !leadIds.contains($0.id) }
|
let rest = visibleSections.filter { !leadIds.contains($0.id) }
|
||||||
var left: [DashboardSectionConfig] = []
|
var columns = Array(repeating: [DashboardSectionConfig](), count: count)
|
||||||
var right: [DashboardSectionConfig] = []
|
var weights = Array(repeating: 0, count: count)
|
||||||
for (i, cfg) in rest.enumerated() {
|
for cfg in rest {
|
||||||
if i.isMultiple(of: 2) { left.append(cfg) } else { right.append(cfg) }
|
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
|
@ViewBuilder
|
||||||
private var iPadDashboardLayout: some View {
|
private var iPadDashboardLayout: some View {
|
||||||
let columns = iPadContextColumns
|
GeometryReader { geo in
|
||||||
VStack(spacing: 16) {
|
// Scale column count with available width so a wide Mac window fills
|
||||||
ForEach(iPadLeadSections) { config in
|
// (3 columns) while an iPad stays at 2. Lead cards span full width.
|
||||||
sectionView(for: config)
|
let columnCount = geo.size.width >= 1250 ? 3 : 2
|
||||||
if config.id == DashboardSection.monthlyCheckIn.id, !viewModel.insights.isEmpty {
|
let columns = iPadContextColumns(count: columnCount)
|
||||||
InsightsRow(insights: viewModel.insights)
|
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) {
|
HStack(alignment: .top, spacing: 16) {
|
||||||
VStack(spacing: 16) {
|
ForEach(columns.indices, id: \.self) { i in
|
||||||
ForEach(columns.0) { sectionView(for: $0) }
|
VStack(spacing: 16) {
|
||||||
|
ForEach(columns[i]) { sectionView(for: $0) }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.padding()
|
||||||
VStack(spacing: 16) {
|
|
||||||
ForEach(columns.1) { sectionView(for: $0) }
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 1100)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -543,12 +543,16 @@ struct MonthlyCheckInView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(viewModel.sources, id: \.objectID) { source in
|
ForEach(viewModel.sources, id: \.objectID) { source in
|
||||||
let latestSnapshot = source.latestSnapshot
|
// Month-aware: "updated" means updated FOR THE VIEWED month.
|
||||||
let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot)
|
let monthSnapshot = snapshotForViewedMonth(source)
|
||||||
|
let updatedThisCycle = monthSnapshot != nil
|
||||||
|
let latestSnapshot = monthSnapshot ?? source.latestSnapshot
|
||||||
let snapshots = source.sortedSnapshotsByDateAscending
|
let snapshots = source.sortedSnapshotsByDateAscending
|
||||||
let previousSnapshot: Snapshot? = {
|
let previousSnapshot: Snapshot? = {
|
||||||
guard snapshots.count >= 2 else { return nil }
|
guard let ref = latestSnapshot,
|
||||||
return snapshots[snapshots.count - 2]
|
let idx = snapshots.firstIndex(where: { $0.objectID == ref.objectID }),
|
||||||
|
idx > 0 else { return nil }
|
||||||
|
return snapshots[idx - 1]
|
||||||
}()
|
}()
|
||||||
let valueDiff: Decimal? = {
|
let valueDiff: Decimal? = {
|
||||||
guard let latest = latestSnapshot, let previous = previousSnapshot else { return nil }
|
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)
|
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
|
@ViewBuilder
|
||||||
func moodPill(for mood: MonthlyCheckInMood) -> some View {
|
func moodPill(for mood: MonthlyCheckInMood) -> some View {
|
||||||
let isSelected = selectedMood == mood
|
let isSelected = selectedMood == mood
|
||||||
|
|||||||
Reference in New Issue
Block a user