Re-engagement con KPIs reales (goal ETA, YoY, mes, milestone, racha) + rotación (build 64)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
@@ -200,15 +201,28 @@ extension Notification.Name {
|
||||
extension NotificationService {
|
||||
/// Schedules a re-engagement notification 7 days from now.
|
||||
/// Call this every time the app becomes active to reset the timer.
|
||||
///
|
||||
/// Instead of a fixed generic message, the body is built from the user's real
|
||||
/// KPIs (goal ETA, YoY, monthly gain, milestone proximity, update streak) to
|
||||
/// spark curiosity. Among the hooks that currently apply we rotate one per call
|
||||
/// so the copy stays fresh instead of repeating the same line every week.
|
||||
func scheduleReEngagementNotification() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"])
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "reengagement_title")
|
||||
content.body = String(localized: "reengagement_body")
|
||||
let hooks = buildReEngagementHooks()
|
||||
if let hook = hooks.isEmpty ? nil : hooks[nextReEngagementRotationIndex(count: hooks.count)] {
|
||||
content.title = hook.title
|
||||
content.body = hook.body
|
||||
} else {
|
||||
// No data yet (brand-new / empty portfolio) → generic fallback.
|
||||
content.title = String(localized: "reengagement_title")
|
||||
content.body = String(localized: "reengagement_body")
|
||||
}
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "openDashboard"]
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 7 * 24 * 3600, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "re_engagement", content: content, trigger: trigger)
|
||||
@@ -220,6 +234,16 @@ extension NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances and returns the rotation index over the eligible hooks so a different
|
||||
/// one surfaces each time we (re)schedule.
|
||||
private func nextReEngagementRotationIndex(count: Int) -> Int {
|
||||
guard count > 0 else { return 0 }
|
||||
let key = "reengagementRotationIndex"
|
||||
let stored = UserDefaults.standard.integer(forKey: key)
|
||||
UserDefaults.standard.set(stored &+ 1, forKey: key)
|
||||
return ((stored % count) + count) % count
|
||||
}
|
||||
|
||||
/// Schedules a non-repeating monthly check-in notification for the 1st of the next month
|
||||
/// that doesn't have a completed check-in. Safe to call on every app activation.
|
||||
func scheduleMonthlyCheckIn() {
|
||||
@@ -386,6 +410,191 @@ extension NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Re-engagement KPI copy
|
||||
|
||||
private extension NotificationService {
|
||||
|
||||
struct ReEngagementHook {
|
||||
let title: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
static let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000]
|
||||
|
||||
static let monthYearFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
static let percentFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.minimumFractionDigits = 0
|
||||
f.maximumFractionDigits = 1
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Builds the list of KPI-driven hooks that currently apply. A neutral
|
||||
/// curiosity hook is always appended (when the user has data) so the message
|
||||
/// never has to fall back to the bland generic copy and never states a loss.
|
||||
func buildReEngagementHooks() -> [ReEngagementHook] {
|
||||
let ctx = CoreDataStack.shared.viewContext
|
||||
|
||||
let sourceReq: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
sourceReq.predicate = NSPredicate(format: "isActive == YES")
|
||||
let sources = (try? ctx.fetch(sourceReq)) ?? []
|
||||
guard !sources.isEmpty else { return [] }
|
||||
|
||||
let summary = CalculationService.shared.calculatePortfolioSummary(from: sources, snapshots: [])
|
||||
let evolution = monthlyEvolution(from: sources)
|
||||
|
||||
var hooks: [ReEngagementHook] = []
|
||||
|
||||
// 1. Goal ETA — "at this pace you'll reach '<goal>' by <month year>"
|
||||
if let goalHook = goalEtaHook(ctx: ctx, sources: sources) {
|
||||
hooks.append(goalHook)
|
||||
}
|
||||
|
||||
// 2. Year-over-year growth
|
||||
if summary.yearChangePercentage >= 5,
|
||||
let pct = Self.percentFormatter.string(from: NSNumber(value: summary.yearChangePercentage)) {
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_yoy_title"),
|
||||
body: String(format: String(localized: "reengagement_yoy_body"), "\(pct)%")))
|
||||
}
|
||||
|
||||
// 3. Positive monthly change
|
||||
if summary.monthChange > 0 {
|
||||
let amount = CurrencyFormatter.format(summary.monthChange, style: .currency, maximumFractionDigits: 0)
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_month_title"),
|
||||
body: String(format: String(localized: "reengagement_month_body"), amount)))
|
||||
}
|
||||
|
||||
// 4. Close to the next round milestone (within 10% below it)
|
||||
if let msHook = milestoneProximityHook(totalValue: summary.totalValue) {
|
||||
hooks.append(msHook)
|
||||
}
|
||||
|
||||
// 5. Update streak of 3+ consecutive months
|
||||
if let streak = monthlyStreak(evolution: evolution), streak >= 3 {
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_streak_title"),
|
||||
body: String(format: String(localized: "reengagement_streak_body"), streak)))
|
||||
}
|
||||
|
||||
// 6. Neutral curiosity — always available, never states a loss.
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_neutral_title"),
|
||||
body: String(localized: "reengagement_neutral_body")))
|
||||
|
||||
return hooks
|
||||
}
|
||||
|
||||
/// Portfolio total at the end of each month that has any snapshot, ascending.
|
||||
func monthlyEvolution(from sources: [InvestmentSource]) -> [(date: Date, value: Decimal)] {
|
||||
let cal = Calendar.current
|
||||
var monthStarts = Set<Date>()
|
||||
for source in sources {
|
||||
for snap in source.sortedSnapshotsByDateAscending {
|
||||
if let m = cal.date(from: cal.dateComponents([.year, .month], from: snap.date)) {
|
||||
monthStarts.insert(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return monthStarts.sorted().map { monthStart in
|
||||
let monthEnd = cal.date(byAdding: DateComponents(month: 1, day: -1), to: monthStart) ?? monthStart
|
||||
var total = Decimal.zero
|
||||
for source in sources {
|
||||
if let snap = source.sortedSnapshotsByDateAscending.last(where: { $0.date <= monthEnd }) {
|
||||
total += snap.decimalValue
|
||||
}
|
||||
}
|
||||
return (monthStart, total)
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects the first active goal reachable on the current 6-month trend and
|
||||
/// returns a hook naming its ETA. Mirrors GoalsViewModel.estimateCompletionDate.
|
||||
func goalEtaHook(ctx: NSManagedObjectContext, sources: [InvestmentSource]) -> ReEngagementHook? {
|
||||
let req: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
req.predicate = NSPredicate(format: "isActive == YES")
|
||||
let goals = (try? ctx.fetch(req)) ?? []
|
||||
guard !goals.isEmpty else { return nil }
|
||||
|
||||
let cal = Calendar.current
|
||||
for goal in goals {
|
||||
let goalSources: [InvestmentSource]
|
||||
if let accId = goal.account?.id {
|
||||
goalSources = sources.filter { $0.account?.id == accId }
|
||||
} else {
|
||||
goalSources = sources
|
||||
}
|
||||
|
||||
let evolution = monthlyEvolution(from: goalSources)
|
||||
guard evolution.count >= 3,
|
||||
let first = evolution.suffix(6).first,
|
||||
let last = evolution.suffix(6).last else { continue }
|
||||
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else { continue }
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else { continue }
|
||||
|
||||
let remaining = goal.targetDecimal - last.value
|
||||
guard remaining > 0 else { continue } // already reached → skip
|
||||
let monthsToGo = Int(ceil(NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue))
|
||||
guard monthsToGo > 0, monthsToGo <= 600,
|
||||
let eta = cal.date(byAdding: .month, value: monthsToGo, to: last.date) else { continue }
|
||||
|
||||
let etaStr = Self.monthYearFormatter.string(from: eta)
|
||||
return ReEngagementHook(
|
||||
title: String(localized: "reengagement_goal_title"),
|
||||
body: String(format: String(localized: "reengagement_goal_body"), goal.name, etaStr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Hook when the portfolio is within 10% below the next round milestone.
|
||||
func milestoneProximityHook(totalValue: Decimal) -> ReEngagementHook? {
|
||||
let current = NSDecimalNumber(decimal: totalValue).doubleValue
|
||||
guard current > 0 else { return nil }
|
||||
|
||||
for milestone in Self.milestones {
|
||||
let target = NSDecimalNumber(decimal: milestone).doubleValue
|
||||
if current < target {
|
||||
guard current >= target * 0.9 else { return nil } // next one up is too far
|
||||
let msStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
|
||||
return ReEngagementHook(
|
||||
title: String(localized: "reengagement_milestone_title"),
|
||||
body: String(format: String(localized: "reengagement_milestone_body"), msStr))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Number of consecutive months (ending at the most recent one with data) that
|
||||
/// have a snapshot. `evolution` already holds one entry per month.
|
||||
func monthlyStreak(evolution: [(date: Date, value: Decimal)]) -> Int? {
|
||||
guard !evolution.isEmpty else { return nil }
|
||||
let cal = Calendar.current
|
||||
let months = Set(evolution.map { $0.date })
|
||||
guard var cursor = months.max() else { return nil }
|
||||
|
||||
var count = 0
|
||||
while months.contains(cursor) {
|
||||
count += 1
|
||||
guard let prev = cal.date(byAdding: .month, value: -1, to: cursor) else { break }
|
||||
cursor = prev
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
extension NotificationService {
|
||||
|
||||
Reference in New Issue
Block a user