Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,13 @@ let project = Project(
sources: ["Where/WhereUI/Tests/**"],
),
],
// Tuist's autogeneration doesn't emit standalone schemes for these two
// unit-test bundles (only the aggregate `Stuff-Workspace` scheme covers
// them), so declare them explicitly. This lets `tuist test WhereTests` /
// `tuist test WhereUITests` target a single bundle without building the
// whole workspace.
// Tuist's autogeneration doesn't emit working standalone test actions for
// these unit-test bundles (only the aggregate `Stuff-Workspace` scheme
// runs them), so declare them explicitly. This lets `tuist test
// WhereCoreTests` / `tuist test WhereTests` / `tuist test WhereUITests`
// target a single bundle without building the whole workspace.
schemes: [
testScheme(name: "WhereCoreTests"),
testScheme(name: "WhereTests"),
testScheme(name: "WhereUITests"),
],
Expand Down
30 changes: 30 additions & 0 deletions Where/TODOs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# App todos

## Usage
- Tag issues with conventional commit semantics: feat, fix, refactor, perf, test, docs
- Eg "- [ ] feat: Add log viewer to settings page"

## P0s (Must do)
- [ ] Performance pass (How often is the app booting? Can we only do it on changes of say, 1km or more?)
- [ ] Schedule local push notifications if we haven’t recorded for the day yet
- [ ] Add snapshot images to a new test target

## P1s (Should do)
- [ ] Rewrite controller layer to be a state machine so invariants can’t exist
- [ ] SwiftData browser
- [ ] Do we live refresh the Primary UI / Elsewhere UI? Or regularly?
- [ ] Export / import system (JSON? Zip?)
- [ ] Schedule local push notifications if we haven’t recorded for the day yet
- [ ] What’s with all the `.accessibilityIdentifier(…)` modifiers, do we need them?
- [ ] Remove `caption(forRank rank: Int) -> String?`, I don’t want the caption
- [ ] Remove get/set closure-based bindings
- [ ] Add a UI that represents where you currently are? Maybe a border on the current location card?

## P2s (Nice to have)
- [ ] The `guard let controller else { return }` in the WhereModel in WhereUI is weird
- [ ] Raw data browser (similar to SD browser)
- [ ] Clean up and centralize loggers into a logging module? We have several separate loggers
- [ ] Move `let calendar = Calendar.current` into a var on the controller? There’s a few of these
- [ ] Move test only code behind @_spi
- [ ] Add comments to strings in xcstrings files
- [ ] Can we code-gen the strings.swift file somehow?
14 changes: 13 additions & 1 deletion Where/Where/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import UIKit
import UserNotifications
import WhereUI

/// Owns the app's single `WhereModel` and wires location up at process launch
Expand All @@ -12,7 +13,7 @@
/// CoreLocation deliver the pending event, and re-starting GPS continues
/// background tracking when the user intends it and Always is granted.
@MainActor
final class AppDelegate: NSObject, UIApplicationDelegate {
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
let model = WhereModel()

private let logger = Logger(subsystem: "com.stuff.where", category: "AppDelegate")
Expand All @@ -21,15 +22,26 @@
_: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil,
) -> Bool {
if launchOptions?[.location] != nil {

Check warning on line 25 in Where/Where/Sources/AppDelegate.swift

View workflow job for this annotation

GitHub Actions / Build & Test

'location' was deprecated in iOS 26.0: Adopt CLLocationUpdate or CLMonitor, or use CLLocationManagerDelegate from CoreLocation to handle expected location events after scene connection.
logger.info("Relaunched by CoreLocation for a background location event")
}

// Present logging reminders even while the app is foregrounded so the
// nudge isn't silently swallowed when the user already has Where open.
UNUserNotificationCenter.current().delegate = self

// Build the controller + CLLocationManager synchronously so a location
// event delivered during launch isn't lost, then (re)register
// monitoring and reconcile tracking off the main thread.
model.bootstrap()
Task { await model.start() }
return true
}

nonisolated func userNotificationCenter(
_: UNUserNotificationCenter,
willPresent _: UNNotification,
) async -> UNNotificationPresentationOptions {
[.banner, .sound, .badge]
}
}
122 changes: 122 additions & 0 deletions Where/WhereCore/Sources/MissingDays.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import Foundation

/// A maximal run of consecutive calendar days that have no recorded presence —
/// e.g. "Jan 3 – Jan 7, 5 days". Used to surface logging gaps in the UI and to
/// drive the backfill flow.
public struct MissingDayRange: Hashable, Sendable, Identifiable {
/// Start-of-day key of the first missing day in the run.
public let start: Date
/// Start-of-day key of the last missing day in the run.
public let end: Date
public let dayCount: Int

public var id: Date {
start
}

public init(start: Date, end: Date, dayCount: Int) {
self.start = start
self.end = end
self.dayCount = dayCount
}
}

/// Pure rules for finding the calendar days the user *should* have logged but
/// didn't. A day "counts as missed" when it falls in the inclusive window
/// `[Jan 1 of the year, through]` and has no `DayPresence`. No I/O.
public enum MissingDays {
/// Start-of-day keys in `[Jan 1 of year, min(through, Dec 31 of year)]`
/// (inclusive) that are absent from `present`.
///
/// `present` should be the set of start-of-day keys that already have any
/// recorded presence (GPS or manual). It is normalized to start-of-day in
/// `calendar` defensively, so callers don't have to pre-normalize. Pass the
/// same `calendar` (identifier + timezone) that produced the `present`
/// keys, otherwise day boundaries won't line up.
public static func missingDayKeys(
year: Int,
through: Date,
present: Set<Date>,
calendar: Calendar,
) -> [Date] {
guard
let firstOfYear = calendar.date(from: DateComponents(year: year, month: 1, day: 1)),
let firstOfNextYear = calendar.date(
from: DateComponents(year: year + 1, month: 1, day: 1),
),
let lastOfYear = calendar.date(byAdding: .day, value: -1, to: firstOfNextYear)
else { return [] }

let start = calendar.startOfDay(for: firstOfYear)
// Clamp the upper bound to this year so a `through` in a later year
// doesn't pull next year's days into the result.
let requested = calendar.startOfDay(for: through)
let last = min(requested, calendar.startOfDay(for: lastOfYear))
guard start <= last else { return [] }

let normalizedPresent = Set(present.map { calendar.startOfDay(for: $0) })
return start
.calendarDays(through: last, in: calendar)
.filter { !normalizedPresent.contains($0) }
}

/// Collapse a set of start-of-day keys into maximal consecutive runs,
/// sorted ascending by start date. Keys are normalized and de-duplicated in
/// `calendar` first, so adjacent calendar days fold into one range.
public static func ranges(_ keys: [Date], calendar: Calendar) -> [MissingDayRange] {
let sorted = Set(keys.map { calendar.startOfDay(for: $0) }).sorted()
guard var runStart = sorted.first else { return [] }

var previous = runStart
var count = 1
var result: [MissingDayRange] = []
for date in sorted.dropFirst() {
if isConsecutive(previous, date, calendar: calendar) {
previous = date
count += 1
} else {
result.append(MissingDayRange(start: runStart, end: previous, dayCount: count))
runStart = date
previous = date
count = 1
}
}
result.append(MissingDayRange(start: runStart, end: previous, dayCount: count))
return result
}

/// The last day that counts toward the *backlog* of missed days as of
/// `now`: the day before today. Today is still "pending" — the user can log
/// it before the day ends — so it's surfaced by the forward-looking reminder
/// rather than counted as already missed. Pass this as `through` for the
/// badge / banner / backfill so they don't warn about today every morning.
public static func backlogCutoff(asOf now: Date, calendar: Calendar) -> Date {
let today = calendar.startOfDay(for: now)
return calendar.date(byAdding: .day, value: -1, to: today) ?? today
}

/// Convenience: the missing days for a year, already collapsed into ranges.
public static func missingRanges(
year: Int,
through: Date,
present: Set<Date>,
calendar: Calendar,
) -> [MissingDayRange] {
let keys = missingDayKeys(
year: year,
through: through,
present: present,
calendar: calendar,
)
return ranges(keys, calendar: calendar)
}

private static func isConsecutive(
_ previous: Date,
_ candidate: Date,
calendar: Calendar,
) -> Bool {
guard let next = calendar.date(byAdding: .day, value: 1, to: previous) else { return false }
return calendar.isDate(next, inSameDayAs: candidate)
}
}
Loading
Loading