84d9240ee6
Feature 1 — Aviso de huecos: SnapshotGapDetector detecta huecos internos por source (meses sin datos entre dos snapshots). Banner desechable en Dashboard + DataGapsSheet para rellenar (abre AddSnapshotView con el mes que falta). Complementa la interpolación de gráficas: la línea se ve suave pero el usuario sabe que faltan datos reales. Feature 2 — Resumen mensual compartible: MonthlySummaryShareView (tarjeta con marca: valor, variación desde el check-in, racha, mood/rating) renderizada con ImageRenderer y compartida vía ShareService. Botón en la fila "check-in done" del Dashboard. Feature 3 — Import con vista previa: ImportPreview + previewImportAsync (dry-run READ-ONLY, no muta nada) cuenta qué se creará/actualizará usando los mismos checks de existencia que applyImport. ImportDataView muestra la confirmación antes de importar. El path real de import (applyImport/importDataAsync) NO se toca. Localización en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
88 lines
3.5 KiB
Swift
88 lines
3.5 KiB
Swift
import Foundation
|
|
import CoreData
|
|
|
|
/// Detects INTERNAL gaps in a source's snapshot history: two consecutive
|
|
/// snapshots more than one calendar month apart, i.e. one or more calendar
|
|
/// months between them have no data at all.
|
|
///
|
|
/// This complements `ChartGapFill`, which only smooths the *display* by
|
|
/// interpolating/carrying values across those empty months. The chart looks
|
|
/// continuous, but real data is missing — this surfaces that so the user can
|
|
/// fill it in. Read-only: it never mutates Core Data.
|
|
enum SnapshotGapDetector {
|
|
|
|
/// A single internal gap for one source.
|
|
struct Gap: Identifiable, Equatable {
|
|
let sourceId: UUID
|
|
let sourceName: String
|
|
/// The month of the snapshot before the gap (start of that month).
|
|
let fromMonth: Date
|
|
/// The month of the snapshot after the gap (start of that month).
|
|
let toMonth: Date
|
|
/// Number of calendar months with no data between `fromMonth` and `toMonth`.
|
|
let missingMonths: Int
|
|
/// The individual missing months (start-of-month dates), oldest first.
|
|
let missingMonthDates: [Date]
|
|
|
|
var id: String { "\(sourceId.uuidString)-\(fromMonth.timeIntervalSince1970)" }
|
|
}
|
|
|
|
/// Computes all internal gaps across the provided sources' snapshots.
|
|
/// Only months strictly between two known snapshots count — a source's
|
|
/// trailing "not updated in a while" is handled elsewhere (pending updates).
|
|
static func detectGaps(sources: [InvestmentSource], snapshots: [Snapshot]) -> [Gap] {
|
|
let calendar = Calendar.current
|
|
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
|
|
var gaps: [Gap] = []
|
|
|
|
for source in sources {
|
|
let sourceId = source.id
|
|
guard let sourceSnapshots = snapshotsBySource[sourceId], sourceSnapshots.count >= 2 else {
|
|
continue
|
|
}
|
|
|
|
// One value per calendar month (keep the latest in each), sorted ascending.
|
|
var monthStarts: [Date] = sourceSnapshots
|
|
.map { $0.date.startOfMonth }
|
|
monthStarts = Array(Set(monthStarts)).sorted()
|
|
|
|
guard monthStarts.count >= 2 else { continue }
|
|
|
|
for i in 0..<(monthStarts.count - 1) {
|
|
let from = monthStarts[i]
|
|
let to = monthStarts[i + 1]
|
|
let monthsApart = from.monthsBetween(to)
|
|
guard monthsApart > 1 else { continue }
|
|
|
|
var missingDates: [Date] = []
|
|
var cursor = from.adding(months: 1).startOfMonth
|
|
while cursor < to {
|
|
missingDates.append(cursor)
|
|
cursor = cursor.adding(months: 1).startOfMonth
|
|
}
|
|
guard !missingDates.isEmpty else { continue }
|
|
|
|
gaps.append(Gap(
|
|
sourceId: sourceId,
|
|
sourceName: source.name,
|
|
fromMonth: from,
|
|
toMonth: to,
|
|
missingMonths: missingDates.count,
|
|
missingMonthDates: missingDates
|
|
))
|
|
}
|
|
}
|
|
|
|
// Most recent gaps first (by the month after the gap), then by source name.
|
|
return gaps.sorted {
|
|
if $0.toMonth != $1.toMonth { return $0.toMonth > $1.toMonth }
|
|
return $0.sourceName < $1.sourceName
|
|
}
|
|
}
|
|
|
|
/// Total count of missing months across all gaps.
|
|
static func totalMissingMonths(_ gaps: [Gap]) -> Int {
|
|
gaps.reduce(0) { $0 + $1.missingMonths }
|
|
}
|
|
}
|