Files
InvestmentTrackerApp/PortfolioJournal/Utilities/SnapshotGapDetector.swift
T
alexandrev-tibco 84d9240ee6 1.5.0: aviso de huecos + resumen mensual compartible + import con vista previa
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
2026-07-24 09:36:50 +02:00

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 }
}
}