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
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user