1.6.0: auto-rellenar huecos (snapshots estimados) + widgets/lock screen enriquecidos

Auto-relleno (#5): SnapshotRepository.autoFillGap crea snapshots interpolados
linealmente entre los dos snapshots que bordean el hueco, marcados isEstimated.
deleteEstimatedSnapshots para borrado en bloque. Botón 'Autocompletar N meses'
(wand.and.stars) en DataGapsSheet + 'Añadir manualmente'. Idempotente. Strings 7 idiomas.

Widgets/Lock Screen (#8, vía agente): familias enriquecidas — systemSmall/Medium/Large
con net worth + cambio desde check-in + racha + próximo check-in + progreso de objetivo;
lock screen accessoryCircular (gauge objetivo/milestone), accessoryRectangular, accessoryInline.
Deep link quickupdate preservado. Solo InvestmentWidget.swift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
alexandrev-tibco
2026-07-25 12:13:51 +02:00
parent 1a3fce0df4
commit 922710c53f
10 changed files with 524 additions and 74 deletions
+398 -66
View File
@@ -76,6 +76,26 @@ struct InvestmentWidgetEntry: TimelineEntry {
let currencyCode: String
let insightTitle: String
let insightValue: String
// Engagement + progress metrics (v1.6.0)
let streak: Int
let nextCheckInDate: Date?
/// Nearest active goal (soonest target date, else smallest target) if any.
let nearestGoal: GoalSummary?
/// Progress [0...1] of totalValue toward the nearest goal's target.
let nearestGoalProgress: Double
/// Fraction [0...1] toward the next net-worth milestone (fallback gauge when no goal).
let milestoneProgress: Double
/// The next milestone amount used for the fallback gauge (0 when none).
let nextMilestone: Decimal
/// True when the store has no usable portfolio data yet.
var hasData: Bool { totalValue != 0 || !trendPoints.isEmpty || !topSources.isEmpty }
/// A gauge value [0...1]: nearest-goal progress if a goal exists, else milestone progress.
var primaryGaugeProgress: Double {
nearestGoal != nil ? nearestGoalProgress : milestoneProgress
}
}
struct CategorySeries: Identifiable {
@@ -143,7 +163,13 @@ struct InvestmentWidgetProvider: TimelineProvider {
],
currencyCode: "EUR",
insightTitle: "Milestone ahead",
insightValue: "€5K from €50K"
insightValue: "€5K from €50K",
streak: 4,
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 6, to: Date()),
nearestGoal: GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil),
nearestGoalProgress: 0.66,
milestoneProgress: 0.5,
nextMilestone: 100000
)
}
@@ -182,7 +208,13 @@ struct InvestmentWidgetProvider: TimelineProvider {
goals: [],
currencyCode: currencyCode,
insightTitle: "",
insightValue: ""
insightValue: "",
streak: 0,
nextCheckInDate: nil,
nearestGoal: nil,
nearestGoalProgress: 0,
milestoneProgress: 0,
nextMilestone: 0
)
}
@@ -368,6 +400,84 @@ struct InvestmentWidgetProvider: TimelineProvider {
}
}
// MARK: Streak consecutive months (year+month) that have snapshot data,
// counting back from the most recent. Mirrors DashboardViewModel.computeStreak.
let streak: Int = {
let monthDates = Set(sortedTotals.map { pair -> DateComponents in
let c = calendar.dateComponents([.year, .month], from: pair.0)
return DateComponents(year: c.year, month: c.month)
})
let sortedMonthsDesc = monthDates
.compactMap { calendar.date(from: $0) }
.sorted(by: >)
guard let mostRecent = sortedMonthsDesc.first else { return 0 }
var count = 1
var current = mostRecent
for i in 1..<max(sortedMonthsDesc.count, 1) {
guard i < sortedMonthsDesc.count,
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonthsDesc[i]
let pc = calendar.dateComponents([.year, .month], from: prev)
let ec = calendar.dateComponents([.year, .month], from: expected)
if pc.year == ec.year && pc.month == ec.month {
count += 1
current = prev
} else {
break
}
}
return count
}()
// MARK: Next check-in date from the most recent completed JournalEntry.
// Mirrors DashboardView.nextCheckInDate (common case, without grace-period edge).
let nextCheckInDate: Date? = {
let journalRequest = NSFetchRequest<NSManagedObject>(entityName: "JournalEntry")
journalRequest.predicate = NSPredicate(format: "completionTime != nil")
journalRequest.sortDescriptors = [NSSortDescriptor(key: "completionTime", ascending: false)]
journalRequest.fetchLimit = 1
let lastCompletion = (try? context.fetch(journalRequest))?.first?
.value(forKey: "completionTime") as? Date
let base = lastCompletion ?? Date()
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: base)) ?? base
// For a prior check-in, next is the end of the following month; otherwise
// the end of the current month.
let targetMonthStart: Date
if lastCompletion != nil {
targetMonthStart = calendar.date(byAdding: .month, value: 1, to: startOfMonth) ?? startOfMonth
} else {
targetMonthStart = startOfMonth
}
guard let nextMonthStart = calendar.date(byAdding: .month, value: 1, to: targetMonthStart) else {
return targetMonthStart
}
return calendar.date(byAdding: .day, value: -1, to: nextMonthStart)
}()
// MARK: Nearest goal + progress toward target from current total value.
let nearestGoal = goals.first
var nearestGoalProgress = 0.0
if let goal = nearestGoal {
let target = NSDecimalNumber(decimal: goal.targetAmount).doubleValue
if target > 0 {
nearestGoalProgress = min(max(NSDecimalNumber(decimal: totalValue).doubleValue / target, 0), 1)
}
}
// MARK: Milestone gauge (fallback when there's no goal).
let milestoneLadder: [Double] = [1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1_000_000, 2_500_000, 5_000_000]
let totalForMilestone = NSDecimalNumber(decimal: totalValue).doubleValue
var milestoneProgress = 0.0
var nextMilestone: Decimal = 0
if let nextMs = milestoneLadder.first(where: { $0 > totalForMilestone }) {
let prevMs = milestoneLadder.last(where: { $0 <= totalForMilestone }) ?? 0
let span = nextMs - prevMs
milestoneProgress = span > 0 ? min(max((totalForMilestone - prevMs) / span, 0), 1) : 0
nextMilestone = Decimal(nextMs)
} else if totalForMilestone > 0 {
milestoneProgress = 1
}
// Compute top insight
var insightTitle = ""
var insightValue = ""
@@ -417,55 +527,231 @@ struct InvestmentWidgetProvider: TimelineProvider {
goals: goals,
currencyCode: currencyCode,
insightTitle: insightTitle,
insightValue: insightValue
insightValue: insightValue,
streak: streak,
nextCheckInDate: nextCheckInDate,
nearestGoal: nearestGoal,
nearestGoalProgress: nearestGoalProgress,
milestoneProgress: milestoneProgress,
nextMilestone: nextMilestone
)
}
}
// MARK: - Shared Formatting Helpers
private func checkInText(for date: Date?) -> String {
guard let date = date else { return "" }
let cal = Calendar.current
let days = cal.dateComponents([.day], from: Date().startOfWidgetDay, to: date.startOfWidgetDay).day ?? 0
if days < 0 { return "Overdue" }
if days == 0 { return "Due today" }
if days == 1 { return "Due tomorrow" }
if days <= 21 { return "In \(days)d" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: date)
}
private func checkInIsUrgent(_ date: Date?) -> Bool {
guard let date = date else { return false }
let cal = Calendar.current
let days = cal.dateComponents([.day], from: Date().startOfWidgetDay, to: date.startOfWidgetDay).day ?? 0
return days <= 3
}
private extension Date {
var startOfWidgetDay: Date { Calendar.current.startOfDay(for: self) }
}
// MARK: - Change Line (shared)
struct ChangeLine: View {
let entry: InvestmentWidgetEntry
var compact: Bool = false
var body: some View {
HStack(spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption.weight(.medium))
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
.font(.caption2)
.foregroundColor(compact ? (entry.dayChange >= 0 ? .green : .red) : .secondary)
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
// MARK: - Metric Pills (shared)
struct StreakPill: View {
let streak: Int
var body: some View {
HStack(spacing: 3) {
Image(systemName: "flame.fill")
.font(.caption2)
.foregroundColor(.orange)
Text("\(streak) mo")
.font(.caption2.weight(.semibold))
}
}
}
struct CheckInPill: View {
let date: Date?
var body: some View {
HStack(spacing: 3) {
Image(systemName: "calendar.badge.clock")
.font(.caption2)
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
Text(checkInText(for: date))
.font(.caption2.weight(.medium))
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
}
.lineLimit(1)
}
}
// MARK: - Goal Progress Row (shared)
struct GoalProgressRow: View {
let entry: InvestmentWidgetEntry
var body: some View {
if let goal = entry.nearestGoal {
let pct = entry.nearestGoalProgress
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "target")
.font(.caption2)
.foregroundColor(widgetSecondaryColor)
Text(goal.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
Spacer()
Text("\(Int((pct * 100).rounded()))%")
.font(.caption.weight(.bold))
.foregroundColor(widgetSecondaryColor)
}
ProgressView(value: pct)
.tint(widgetSecondaryColor)
HStack {
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text(goal.targetAmount.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
}
}
} else if entry.nextMilestone > 0 {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "flag.checkered")
.font(.caption2)
.foregroundColor(widgetSecondaryColor)
Text("Next milestone")
.font(.caption.weight(.semibold))
Spacer()
Text("\(Int((entry.milestoneProgress * 100).rounded()))%")
.font(.caption.weight(.bold))
.foregroundColor(widgetSecondaryColor)
}
ProgressView(value: entry.milestoneProgress)
.tint(widgetSecondaryColor)
HStack {
Spacer()
Text(entry.nextMilestone.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Small Widget View
struct SmallWidgetView: View {
let entry: InvestmentWidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Total Value")
.font(.caption)
.foregroundColor(.secondary)
Group {
if entry.hasData {
VStack(alignment: .leading, spacing: 6) {
Text("Total Value")
.font(.caption)
.foregroundColor(.secondary)
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.title2.weight(.bold))
.minimumScaleFactor(0.7)
.lineLimit(1)
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.title2.weight(.bold))
.minimumScaleFactor(0.7)
.lineLimit(1)
VStack(alignment: .leading, spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
ChangeLine(entry: entry, compact: true)
Text("since last check-in")
.font(.caption2)
.foregroundColor(.secondary)
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption.weight(.medium))
Spacer(minLength: 0)
Text(String(format: "%.1f%% since last", entry.dayChangePercentage))
.font(.caption2)
.foregroundColor(.secondary)
HStack {
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
Spacer()
CheckInPill(date: entry.nextCheckInDate)
}
}
} else {
EmptyStateView()
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
Spacer()
}
.padding()
.containerBackground(.background, for: .widget)
}
}
// MARK: - Empty State
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.title)
.foregroundColor(widgetPrimaryColor)
Text("No data yet")
.font(.subheadline.weight(.semibold))
Text("Add a snapshot to start tracking.")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Medium Widget View
struct MediumWidgetView: View {
let entry: InvestmentWidgetEntry
var body: some View {
if !entry.hasData {
EmptyStateView()
.padding()
.containerBackground(.background, for: .widget)
} else {
content
}
}
private var content: some View {
HStack(spacing: 16) {
// Left side - Total value
// Left side - Total value + engagement metrics
VStack(alignment: .leading, spacing: 8) {
Text("Portfolio")
.font(.caption)
@@ -476,18 +762,12 @@ struct MediumWidgetView: View {
.minimumScaleFactor(0.7)
.lineLimit(1)
HStack(spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
ChangeLine(entry: entry)
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption.weight(.medium))
Spacer(minLength: 4)
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
.font(.caption)
.foregroundColor(.secondary)
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
CheckInPill(date: entry.nextCheckInDate)
}
Spacer()
@@ -574,7 +854,17 @@ struct LargeWidgetView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if !entry.hasData {
EmptyStateView()
.padding()
.containerBackground(.background, for: .widget)
} else {
content
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text("Portfolio")
@@ -586,27 +876,19 @@ struct LargeWidgetView: View {
.minimumScaleFactor(0.7)
.lineLimit(1)
HStack(spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption.weight(.medium))
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
.font(.caption2)
.foregroundColor(.secondary)
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
ChangeLine(entry: entry)
}
Spacer()
Text("Category Evolution")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
VStack(alignment: .trailing, spacing: 6) {
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
CheckInPill(date: entry.nextCheckInDate)
}
}
GoalProgressRow(entry: entry)
if entry.isPremium {
if hasCategoryTrend {
CombinedCategoryChartView(
@@ -615,10 +897,10 @@ struct LargeWidgetView: View {
goal: entry.goals.first,
currencyCode: entry.currencyCode
)
.frame(height: 98)
.frame(height: 84)
VStack(alignment: .leading, spacing: 8) {
ForEach(entry.categoryTotals.prefix(4), id: \.name) { category in
VStack(alignment: .leading, spacing: 6) {
ForEach(entry.categoryTotals.prefix(3), id: \.name) { category in
HStack {
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: category.color) ?? .gray)
@@ -667,21 +949,49 @@ struct AccessoryCircularView: View {
let entry: InvestmentWidgetEntry
var body: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption)
Text(String(format: "%.1f%%", entry.dayChangePercentage))
.font(.caption2.weight(.semibold))
Group {
if entry.hasData && (entry.nearestGoal != nil || entry.nextMilestone > 0) {
// Gauge toward nearest goal (or next milestone as fallback).
Gauge(value: entry.primaryGaugeProgress) {
Image(systemName: entry.nearestGoal != nil ? "target" : "flag.checkered")
} currentValueLabel: {
Text("\(Int((entry.primaryGaugeProgress * 100).rounded()))%")
.font(.system(size: 13, weight: .semibold))
}
.gaugeStyle(.accessoryCircularCapacity)
} else {
// Fallback: change since last check-in.
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption)
Text(String(format: "%.1f%%", entry.dayChangePercentage))
.font(.caption2.weight(.semibold))
}
}
}
}
.containerBackground(.background, for: .widget)
}
}
// MARK: - Accessory Inline View (Lock Screen)
struct AccessoryInlineView: View {
let entry: InvestmentWidgetEntry
var body: some View {
// e.g. "50K · +0.5%"
let arrow = entry.dayChange >= 0 ? "arrow.up" : "arrow.down"
Label {
Text("\(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode)) · \(String(format: "%+.1f%%", entry.dayChangePercentage))")
} icon: {
Image(systemName: arrow)
}
}
}
// MARK: - Accessory Rectangular View (Lock Screen)
struct AccessoryRectangularView: View {
@@ -929,7 +1239,8 @@ struct InvestmentWidget: Widget {
.systemMedium,
.systemLarge,
.accessoryCircular,
.accessoryRectangular
.accessoryRectangular,
.accessoryInline
])
}
}
@@ -957,6 +1268,9 @@ struct InvestmentWidgetEntryView: View {
case .accessoryRectangular:
AccessoryRectangularView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .accessoryInline:
AccessoryInlineView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
default:
SmallWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
@@ -992,7 +1306,13 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
goals: [],
currencyCode: "EUR",
insightTitle: "",
insightValue: ""
insightValue: "",
streak: 5,
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 8, to: Date()),
nearestGoal: nil,
nearestGoalProgress: 0,
milestoneProgress: 0.5,
nextMilestone: 100000
)
}
@@ -1017,7 +1337,13 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
goals: [],
currencyCode: "EUR",
insightTitle: "Year to date",
insightValue: "+12.4%"
insightValue: "+12.4%",
streak: 7,
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 2, to: Date()),
nearestGoal: nil,
nearestGoalProgress: 0,
milestoneProgress: 0.5,
nextMilestone: 100000
)
}
@@ -1066,11 +1392,17 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
("Real Estate", 18000, "#F59E0B")
],
goals: [
GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil)
GoalSummary(name: "House deposit", targetAmount: 120000, targetDate: nil)
],
currencyCode: "EUR",
insightTitle: "Milestone ahead",
insightValue: "€5K from €100K"
insightValue: "€5K from €100K",
streak: 9,
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 4, to: Date()),
nearestGoal: GoalSummary(name: "House deposit", targetAmount: 120000, targetDate: nil),
nearestGoalProgress: 0.79,
milestoneProgress: 0.6,
nextMilestone: 100000
)
}
extension Decimal {