1e6e3ef361
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
610 lines
24 KiB
Swift
610 lines
24 KiB
Swift
import Foundation
|
|
import Combine
|
|
import CoreData
|
|
import UserNotifications
|
|
import UIKit
|
|
|
|
class NotificationService: ObservableObject {
|
|
static let shared = NotificationService()
|
|
|
|
@Published var isAuthorized = false
|
|
@Published var pendingCount = 0
|
|
|
|
private let center = UNUserNotificationCenter.current()
|
|
|
|
private init() {
|
|
checkAuthorizationStatus()
|
|
}
|
|
|
|
// MARK: - Authorization
|
|
|
|
func checkAuthorizationStatus() {
|
|
center.getNotificationSettings { [weak self] settings in
|
|
DispatchQueue.main.async {
|
|
self?.isAuthorized = settings.authorizationStatus == .authorized
|
|
}
|
|
}
|
|
}
|
|
|
|
func requestAuthorization() async -> Bool {
|
|
do {
|
|
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
|
await MainActor.run {
|
|
self.isAuthorized = granted
|
|
}
|
|
if granted {
|
|
scheduleMonthlyPerformanceSummary()
|
|
}
|
|
return granted
|
|
} catch {
|
|
print("Notification authorization error: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Schedule Notifications
|
|
|
|
func scheduleReminder(for source: InvestmentSource) {
|
|
guard let nextDate = source.nextReminderDate else { return }
|
|
guard source.frequency != .never else { return }
|
|
|
|
// Remove existing notification for this source
|
|
cancelReminder(for: source)
|
|
|
|
// Get notification time from settings
|
|
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
|
|
let notificationTime = settings.defaultNotificationTime ?? defaultNotificationTime()
|
|
|
|
// Combine date and time
|
|
let calendar = Calendar.current
|
|
var components = calendar.dateComponents([.year, .month, .day], from: nextDate)
|
|
let timeComponents = calendar.dateComponents([.hour, .minute], from: notificationTime)
|
|
components.hour = timeComponents.hour
|
|
components.minute = timeComponents.minute
|
|
|
|
guard let triggerDate = calendar.date(from: components) else { return }
|
|
|
|
// Create notification content
|
|
let content = UNMutableNotificationContent()
|
|
content.title = "Investment Update Reminder"
|
|
content.body = "Time to update \(source.name). Tap to add a new snapshot."
|
|
content.sound = .default
|
|
content.badge = NSNumber(value: pendingCount + 1)
|
|
content.userInfo = [
|
|
"sourceId": source.id.uuidString,
|
|
"sourceName": source.name
|
|
]
|
|
|
|
// Create trigger
|
|
let triggerComponents = calendar.dateComponents(
|
|
[.year, .month, .day, .hour, .minute],
|
|
from: triggerDate
|
|
)
|
|
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false)
|
|
|
|
// Create request
|
|
let request = UNNotificationRequest(
|
|
identifier: notificationIdentifier(for: source),
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Schedule
|
|
center.add(request) { error in
|
|
if let error = error {
|
|
print("Failed to schedule notification: \(error)")
|
|
} else {
|
|
print("Scheduled reminder for \(source.name) on \(triggerDate)")
|
|
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
|
|
}
|
|
}
|
|
}
|
|
|
|
func scheduleAllReminders(for sources: [InvestmentSource]) {
|
|
for source in sources {
|
|
scheduleReminder(for: source)
|
|
}
|
|
}
|
|
|
|
// MARK: - Cancel Notifications
|
|
|
|
func cancelReminder(for source: InvestmentSource) {
|
|
center.removePendingNotificationRequests(
|
|
withIdentifiers: [notificationIdentifier(for: source)]
|
|
)
|
|
}
|
|
|
|
func cancelAllReminders() {
|
|
center.removeAllPendingNotificationRequests()
|
|
}
|
|
|
|
// MARK: - Badge Management
|
|
|
|
func updateBadgeCount() {
|
|
let repository = InvestmentSourceRepository()
|
|
let needsUpdate = repository.fetchSourcesNeedingUpdate()
|
|
pendingCount = needsUpdate.count
|
|
|
|
center.setBadgeCount(pendingCount) { _ in }
|
|
}
|
|
|
|
func clearBadge() {
|
|
pendingCount = 0
|
|
center.setBadgeCount(0) { _ in }
|
|
}
|
|
|
|
// MARK: - Pending Notifications
|
|
|
|
func getPendingNotifications() async -> [UNNotificationRequest] {
|
|
await center.pendingNotificationRequests()
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func notificationIdentifier(for source: InvestmentSource) -> String {
|
|
"investment_reminder_\(source.id.uuidString)"
|
|
}
|
|
|
|
private func defaultNotificationTime() -> Date {
|
|
var components = DateComponents()
|
|
components.hour = 9
|
|
components.minute = 0
|
|
return Calendar.current.date(from: components) ?? Date()
|
|
}
|
|
}
|
|
|
|
// MARK: - Deep Link Handler
|
|
|
|
extension NotificationService {
|
|
func handleNotificationResponse(_ response: UNNotificationResponse) {
|
|
let userInfo = response.notification.request.content.userInfo
|
|
|
|
// Handle batch update deep link from monthly check-in
|
|
if let action = userInfo["action"] as? String, action == "batchUpdate" {
|
|
NotificationCenter.default.post(name: .openBatchUpdate, object: nil)
|
|
return
|
|
}
|
|
|
|
// Handle openDashboard deep link from monthly summary notification
|
|
if let action = userInfo["action"] as? String, action == "openDashboard" {
|
|
NotificationCenter.default.post(name: .openDashboard, object: nil)
|
|
return
|
|
}
|
|
|
|
guard let sourceIdString = userInfo["sourceId"] as? String,
|
|
let sourceId = UUID(uuidString: sourceIdString) else {
|
|
return
|
|
}
|
|
|
|
// Post notification for deep linking
|
|
NotificationCenter.default.post(
|
|
name: .openSourceDetail,
|
|
object: nil,
|
|
userInfo: ["sourceId": sourceId]
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Names
|
|
|
|
extension Notification.Name {
|
|
static let openSourceDetail = Notification.Name("openSourceDetail")
|
|
static let didResetData = Notification.Name("didResetData")
|
|
static let openBatchUpdate = Notification.Name("openBatchUpdate")
|
|
static let openDashboard = Notification.Name("openDashboard")
|
|
static let openQuickUpdate = Notification.Name("openQuickUpdate")
|
|
static let openAddFirstSource = Notification.Name("openAddFirstSource")
|
|
}
|
|
|
|
// MARK: - Re-engagement Notifications
|
|
|
|
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()
|
|
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)
|
|
|
|
center.add(request) { error in
|
|
if let error = error {
|
|
print("Re-engagement notification error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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() {
|
|
guard isAuthorized else { return }
|
|
|
|
let identifier = "monthly_checkin"
|
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
|
|
|
|
// Walk forward from next month to find the first month whose check-in is not done
|
|
for offset in 1...13 {
|
|
guard let targetStart = calendar.date(byAdding: .month, value: offset, to: thisMonthStart) else { break }
|
|
let isDone = MonthlyCheckInStore.completionDate(for: targetStart.adding(days: 1)) != nil
|
|
if isDone { continue }
|
|
|
|
var components = calendar.dateComponents([.year, .month], from: targetStart)
|
|
components.day = 1
|
|
components.hour = 9
|
|
components.minute = 0
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = String(localized: "monthly_checkin_notification_title")
|
|
content.body = String(localized: "monthly_checkin_notification_body")
|
|
content.sound = .default
|
|
content.userInfo = ["action": "batchUpdate"]
|
|
|
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
|
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
|
|
|
center.add(request) { error in
|
|
if let error = error {
|
|
print("Monthly check-in notification error: \(error)")
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
/// Streak protection: reminds the user on the 25th of the CURRENT month if this
|
|
/// month's check-in is still pending — loss aversion beats the day-1 nudge alone.
|
|
/// Safe to call on every app activation (no-op if already done or date passed).
|
|
func scheduleStreakProtectionReminder() {
|
|
guard isAuthorized else { return }
|
|
|
|
let identifier = "streak_protection"
|
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
|
|
|
|
// Already checked in this month → nothing to protect
|
|
guard MonthlyCheckInStore.completionDate(for: thisMonthStart.adding(days: 1)) == nil else { return }
|
|
|
|
var components = calendar.dateComponents([.year, .month], from: thisMonthStart)
|
|
components.day = 25
|
|
components.hour = 18
|
|
components.minute = 0
|
|
guard let fireDate = calendar.date(from: components), fireDate > now else { return }
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = String(localized: "streak_protection_notification_title")
|
|
content.body = String(localized: "streak_protection_notification_body")
|
|
content.sound = .default
|
|
content.userInfo = ["action": "batchUpdate"]
|
|
|
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
|
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
|
|
|
center.add(request) { error in
|
|
if let error = error {
|
|
print("Streak protection notification error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Schedules a monthly portfolio summary notification on the 5th of each month at 9am.
|
|
func scheduleMonthlyPerformanceSummary() {
|
|
guard isAuthorized else { return }
|
|
|
|
let identifier = "monthly_summary"
|
|
center.getPendingNotificationRequests { [weak self] requests in
|
|
guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return }
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = String(localized: "monthly_summary_notification_title")
|
|
content.body = String(localized: "monthly_summary_notification_body")
|
|
content.sound = .default
|
|
content.userInfo = ["action": "openDashboard"]
|
|
|
|
var components = DateComponents()
|
|
components.day = 5
|
|
components.hour = 9
|
|
components.minute = 0
|
|
|
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
|
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
|
|
|
self.center.add(request) { error in
|
|
if let error = error {
|
|
print("Monthly summary notification error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fires a one-time local notification celebrating goal achievement.
|
|
func scheduleGoalAchievedNotification(goalName: String) {
|
|
guard isAuthorized else { return }
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = String(localized: "goal_achieved_notification_title")
|
|
content.body = String(format: String(localized: "goal_achieved_notification_body"), goalName)
|
|
content.sound = .default
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
|
let id = "goal_achieved_\(UUID().uuidString)"
|
|
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
|
|
|
|
center.add(request) { error in
|
|
if let error = error {
|
|
print("Goal achieved notification error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fires a notification when the portfolio crosses a round milestone for the first time.
|
|
func checkAndScheduleMilestoneNotification(portfolioValue: Decimal) {
|
|
guard isAuthorized else { return }
|
|
|
|
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
|
100000, 250000, 500000, 1_000_000]
|
|
let notifiedKey = "lastNotifiedMilestone"
|
|
let lastNotified = UserDefaults.standard.double(forKey: notifiedKey)
|
|
let current = NSDecimalNumber(decimal: portfolioValue).doubleValue
|
|
|
|
for milestone in milestones.reversed() {
|
|
let ms = NSDecimalNumber(decimal: milestone).doubleValue
|
|
if current >= ms {
|
|
if ms > lastNotified {
|
|
UserDefaults.standard.set(ms, forKey: notifiedKey)
|
|
let milestoneStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = String(localized: "notification_milestone_title")
|
|
content.body = String(format: String(localized: "notification_milestone_body"), milestoneStr)
|
|
content.sound = .default
|
|
content.userInfo = ["action": "openDashboard"]
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
|
let request = UNNotificationRequest(
|
|
identifier: "portfolio_milestone_\(Int(ms))",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
center.add(request) { _ in }
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
func performBackgroundRefresh() {
|
|
updateBadgeCount()
|
|
|
|
// Reschedule any missed notifications
|
|
let repository = InvestmentSourceRepository()
|
|
let sources = repository.fetchActiveSources()
|
|
scheduleAllReminders(for: sources)
|
|
}
|
|
}
|