Skip to content
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
included:
- PulseLoop
- PulseLoopLiveActivity
- PulseLoopWidgets
- PulseLoopTests

excluded:
Expand Down
5 changes: 1 addition & 4 deletions PulseLoop/Coach/Context/CoachContextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,7 @@ enum CoachContextBuilder {
private static func iso(_ date: Date) -> String { isoFormatter.string(from: date) }

private static func localDate(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = .current
return f.string(from: date)
DateFormatter.stableKey("yyyy-MM-dd").string(from: date)
}
}

Expand Down
7 changes: 6 additions & 1 deletion PulseLoop/Coach/Gemini/GeminiClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,19 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable {
let geminiBody = buildGeminiBody(tools: convertTools(tools), textFormat: textFormat)
let geminiData = try JSONSerialization.data(withJSONObject: geminiBody)

let urlStr = "\(baseURL)/\(model):generateContent?key=\(apiKey)"
let urlStr = "\(baseURL)/\(model):generateContent"
guard let url = URL(string: urlStr) else {
throw ResponsesError.decoding("GeminiClient: could not build endpoint URL")
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// The key goes in a header, not `?key=` on the URL. URLs are the part of a request that gets
// written down — URLSession logging, os_log, crash reports, proxies — and this one is the
// user's own API key. Interpolating it also meant a key with a URL-special character failed
// `URL(string:)` and surfaced as a misleading "could not build endpoint URL".
request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")
request.httpBody = geminiData
request.timeoutInterval = 60

Expand Down
9 changes: 4 additions & 5 deletions PulseLoop/Coach/Notifications/CoachNotificationModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,10 @@ final class CoachNotificationRecord {

var slot: CoachNotificationSlot { CoachNotificationSlot(rawValue: slotRaw) ?? .morning }

/// The dedupe key for "has this slot already fired today". `calendar` decides *which* local day
/// a timestamp falls in; the rendering itself is pinned Gregorian so the key stays comparable to
/// the ones already stored (see `DateFormatter.stableKey`).
static func dateKey(for date: Date, calendar: Calendar = .current) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.calendar = calendar
f.timeZone = calendar.timeZone
return f.string(from: date)
DateFormatter.stableKey("yyyy-MM-dd", timeZone: calendar.timeZone).string(from: date)
}
}
2 changes: 1 addition & 1 deletion PulseLoop/Coach/Tools/AnalysisEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ enum AnalysisEngine {
let mean = values.reduce(0, +) / n
let sd = sqrt(values.reduce(0) { $0 + pow($1 - mean, 2) } / n)
guard sd > 0 else { return [] }
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; f.timeZone = .current
let f = DateFormatter.stableKey("yyyy-MM-dd")
return series.compactMap { item in
let z = (item.value - mean) / sd
guard abs(z) >= threshold else { return nil }
Expand Down
20 changes: 4 additions & 16 deletions PulseLoop/Coach/Tools/CoachDataAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,18 @@ enum CoachDataAccess {

static func parseLocalDate(_ value: String) -> Date? {
let trimmed = String(value.prefix(10))
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = .current
if let d = f.date(from: trimmed) { return d }
if let d = DateFormatter.stableKey("yyyy-MM-dd").date(from: trimmed) { return d }
// Fall back to ISO datetime.
let iso = ISO8601DateFormatter()
return iso.date(from: value)
}

static func localDateString(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = .current
return f.string(from: date)
DateFormatter.stableKey("yyyy-MM-dd").string(from: date)
}

static func localTimeString(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "HH:mm"
f.timeZone = .current
return f.string(from: date)
DateFormatter.stableKey("HH:mm").string(from: date)
}

static func isoString(_ date: Date) -> String {
Expand Down Expand Up @@ -177,9 +168,6 @@ enum CoachDataAccess {
}

private static func hourLabel(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:00"
f.timeZone = .current
return f.string(from: date)
DateFormatter.stableKey("yyyy-MM-dd HH:00").string(from: date)
}
}
3 changes: 1 addition & 2 deletions PulseLoop/DesignSystem/Charts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,7 @@ struct SleepHypnogramView: View {
private var ticks: [(offset: Int, label: String)] {
let safe = totalMin > 0 ? totalMin : 1
let offsets = [0, safe / 3, safe * 2 / 3, safe]
let formatter = DateFormatter()
formatter.dateFormat = "h:mm a"
let formatter = DateFormatter.localizedTemplate("jmm")
return offsets.map { offset in
if let start = startTs {
let date = start.addingTimeInterval(Double(offset) * 60)
Expand Down
14 changes: 9 additions & 5 deletions PulseLoop/Diagnostics/DiagnosticsExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import UIKit
/// (the remote-tester workflow for rings nobody on the project has in hand).
@MainActor
enum DiagnosticsExporter {
/// One shared formatter: the log and packet maps below run this once per row, and constructing
/// an `ISO8601DateFormatter` is far more expensive than using one.
private static let iso = ISO8601DateFormatter()

/// Serialize a diagnostics report to pretty-printed JSON.
static func exportJSON(context: ModelContext, maxLogs: Int = 500) -> String {
var root: [String: Any] = [:]
root["generatedAt"] = ISO8601DateFormatter().string(from: Date())
root["generatedAt"] = iso.string(from: Date())
root["app"] = appInfo()
root["device"] = deviceInfo(context: context)
root["logs"] = recentLogs(context: context, limit: maxLogs)
Expand All @@ -31,7 +35,7 @@ enum DiagnosticsExporter {
/// Write the report to a temporary file and return its URL (for a share sheet).
static func exportFile(context: ModelContext) -> URL? {
let json = exportJSON(context: context)
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
let stamp = iso.string(from: Date()).replacingOccurrences(of: ":", with: "-")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("pulseloop-diagnostics-\(stamp).json")
do {
try json.data(using: .utf8)?.write(to: url)
Expand Down Expand Up @@ -61,7 +65,7 @@ enum DiagnosticsExporter {
info["wearableName"] = device.name
info["capabilities"] = device.capabilities.csv
info["firmware"] = device.firmwareVersion ?? "?"
info["lastSyncAt"] = device.lastSyncAt.map { ISO8601DateFormatter().string(from: $0) } ?? ""
info["lastSyncAt"] = device.lastSyncAt.map { iso.string(from: $0) } ?? ""
}
return info
}
Expand All @@ -72,7 +76,7 @@ enum DiagnosticsExporter {
let logs = (try? context.fetch(descriptor)) ?? []
return logs.map { log in
var row: [String: Any] = [
"at": ISO8601DateFormatter().string(from: log.timestamp),
"at": iso.string(from: log.timestamp),
"category": log.categoryRaw,
"level": log.levelRaw,
"message": log.message,
Expand All @@ -89,7 +93,7 @@ enum DiagnosticsExporter {
let packets = (try? context.fetch(descriptor)) ?? []
return packets.map { p in
[
"at": ISO8601DateFormatter().string(from: p.timestamp),
"at": iso.string(from: p.timestamp),
"direction": p.directionRaw,
"hex": p.hexPayload,
"decoded": p.decodedKind ?? "",
Expand Down
3 changes: 1 addition & 2 deletions PulseLoop/Persistence/DataArchiveService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ enum DataArchiveService {
/// Exports to a shareable temp file (`pulseloop-export-<date>.json`) for the share sheet.
static func exportFile(context: ModelContext) async throws -> URL {
let data = try await exportArchive(context: context)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmm"
let formatter = DateFormatter.stableKey("yyyy-MM-dd-HHmm")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("pulseloop-export-\(formatter.string(from: Date())).json")
try data.write(to: url, options: .atomic)
Expand Down
72 changes: 72 additions & 0 deletions PulseLoop/Services/DateFormatting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation

// Two jobs that look identical at the call site and fail in opposite directions when confused.
// Both bugs below are invisible on a default US device, which is why they survived this long.

extension DateFormatter {
/// A formatter for strings that are **identifiers, not display text**: coach summary scope keys,
/// notification dedupe keys, export filenames, and the date arguments the coach emits and parses
/// back.
///
/// A `DateFormatter` takes its calendar from the user's locale. On a device set to the Buddhist
/// or Japanese calendar (Settings → General → Language & Region → Calendar), `"yyyy-MM-dd"`
/// renders 1 Aug 2026 as `2569-08-01` / `8-08-01`. For display that is correct and wanted; for a
/// key it is a bug — the string stops matching keys written before the setting changed, stops
/// sorting chronologically against them, and stops being a date the model can parse back.
///
/// Pinning `en_US_POSIX` + Gregorian is the fix, and the combination `BatteryAlertMonitor` has
/// always used for its own alert-dedupe key.
///
/// `timeZone` defaults to the device's, matching the "local day" these keys have always meant.
static func stableKey(_ format: String, timeZone: TimeZone = .current,
calendar: Calendar = Calendar(identifier: .gregorian)) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = calendar
formatter.timeZone = timeZone
formatter.dateFormat = format
return formatter
}

/// A formatter for text the **user reads**, built from a locale template rather than a literal
/// pattern.
///
/// `"jmm"` resolves to `9:30 PM` or `21:30` according to the device's 24-Hour Time setting,
/// where a hard-coded `"h:mm a"` forces 12-hour on everyone — including the large share of the
/// world that has never used it. Templates also reorder fields per locale, so `"MMMd"` gives
/// `Aug 1` or `1 Aug` as appropriate.
///
/// Field *letters* still matter (`j` hour, `mm` minute, `MMM` abbreviated month); only their
/// order and the 12/24-hour choice are handed to the locale.
static func localizedTemplate(_ template: String, locale: Locale = .current) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = locale
// Must follow the locale assignment: the template is resolved against it.
formatter.setLocalizedDateFormatFromTemplate(template)
return formatter
}

/// Whether `locale` renders times on a 12-hour clock, and so has an AM/PM marker to place.
///
/// Read from the locale's own resolution of the `j` ("locale-preferred hour") template — the
/// same thing Settings → General → Date & Time → 24-Hour Time flips. Callers need this only when
/// the *layout* depends on the marker existing; for plain formatting, `localizedTemplate("jmm")`
/// already does the right thing on both.
static func usesTwelveHourClock(locale: Locale = .current) -> Bool {
locale.hourCycle == .oneToTwelve || locale.hourCycle == .zeroToEleven
}
}

/// Locale-native interval formatting collapses a shared AM/PM marker and retains both
/// when the interval crosses noon or midnight. A `hmm` skeleton cannot suppress AM/PM.
enum WorkoutTimeFormat {
static func range(start: Date, end: Date, locale: Locale = .current,
timeZone: TimeZone = .current) -> String {
let formatter = DateIntervalFormatter()
formatter.locale = locale
formatter.timeZone = timeZone
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter.string(from: start, to: end)
}
}
14 changes: 10 additions & 4 deletions PulseLoop/Services/Repositories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,16 @@ enum MetricsRepository {
/// full-table scan. Feeds the Wearable screen's drainage chart.
@MainActor
static func batterySamples(start: Date, end: Date, limit: Int = 1000, context: ModelContext) -> [BatterySample] {
// Fetched newest-first and reversed, rather than fetched oldest-first: `limit` has to drop
// the *oldest* rows in an over-long window, not the newest. Sorting forward meant a busy
// window past the cap charted the start of the range and silently omitted the recent
// readings the drainage chart exists to show. The returned order is unchanged (oldest-first).
var descriptor = FetchDescriptor<BatterySample>(
predicate: #Predicate { $0.timestamp >= start && $0.timestamp <= end },
sortBy: [SortDescriptor(\.timestamp, order: .forward)]
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
)
descriptor.fetchLimit = limit
return (try? context.fetch(descriptor)) ?? []
return ((try? context.fetch(descriptor)) ?? []).reversed()
}

/// All measurements of one kind, newest-first (demo mode keeps full history, no time window).
Expand Down Expand Up @@ -181,9 +185,11 @@ enum SleepRepository {
}

@MainActor
/// `fetchLimit: 1` — one row, not the whole table (matching `latestMeasurement` above).
static func latestSession(context: ModelContext) -> SleepSession? {
let descriptor = FetchDescriptor<SleepSession>(sortBy: [SortDescriptor(\.startAt, order: .reverse)])
return (try? context.fetch(descriptor))?.first
var descriptor = FetchDescriptor<SleepSession>(sortBy: [SortDescriptor(\.startAt, order: .reverse)])
descriptor.fetchLimit = 1
return try? context.fetch(descriptor).first
}

@MainActor
Expand Down
6 changes: 1 addition & 5 deletions PulseLoop/Services/SleepInsights.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,7 @@ enum SleepFormat {
return "\(h)h \(String(format: "%02d", m))m"
}

private static let clockTimeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "h:mm a"
return f
}()
private static let clockTimeFormatter = DateFormatter.localizedTemplate("jmm")

static func clockTime(_ date: Date) -> String {
clockTimeFormatter.string(from: date)
Expand Down
6 changes: 3 additions & 3 deletions PulseLoop/Sharing/ShareCardModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,9 @@ struct ShareCardModel {
// MARK: - Dates

private static func dateHeadline(_ date: Date) -> String {
let dow = DateFormatter(); dow.dateFormat = "EEE"
let monthDay = DateFormatter(); monthDay.dateFormat = "MMM d"
let time = DateFormatter(); time.dateFormat = "h:mm a"
let dow = DateFormatter.localizedTemplate("EEE")
let monthDay = DateFormatter.localizedTemplate("MMMd")
let time = DateFormatter.localizedTemplate("jmm")
return "\(dow.string(from: date).uppercased()) · \(monthDay.string(from: date).uppercased()) · \(time.string(from: date))"
}

Expand Down
3 changes: 1 addition & 2 deletions PulseLoop/Sharing/ShareCardRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ enum ShareCardRenderer {
let slug = activityLabel.lowercased()
.replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let formatter = DateFormatter.stableKey("yyyy-MM-dd")
return "pulseloop-\(slug.isEmpty ? "workout" : slug)-\(formatter.string(from: date)).png"
}

Expand Down
15 changes: 9 additions & 6 deletions PulseLoop/Views/Nutrition/MealDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,14 @@ struct MealDetailView: View {

// MARK: - Sections

private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE, MMM d · h:mm a"
return f
}()
// Two formatters rather than one pattern: the " · " is ours, but the day and time either side of
// it belong to the locale (field order, and 12- vs 24-hour).
private static let dayFormatter = DateFormatter.localizedTemplate("EEEMMMd")
private static let timeFormatter = DateFormatter.localizedTemplate("jmm")

private static func timestampLabel(_ date: Date) -> String {
"\(dayFormatter.string(from: date)) · \(timeFormatter.string(from: date))"
}

private func titleBlock(_ entry: MealEntry) -> some View {
VStack(alignment: .leading, spacing: 6) {
Expand All @@ -105,7 +108,7 @@ struct MealDetailView: View {
.background(PulseColors.calories.opacity(0.14), in: Capsule())
ProvenanceBadge(source: entry.source, userEdited: entry.userEdited)
}
Text(Self.timeFormatter.string(from: entry.timestamp))
Text(Self.timestampLabel(entry.timestamp))
.font(PulseFont.caption.weight(.regular))
.foregroundStyle(PulseColors.textMuted)
}
Expand Down
10 changes: 5 additions & 5 deletions PulseLoop/Views/RecordSummaryComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,21 +165,21 @@ struct WorkoutMetricsSections: View {
.padding(.top, 8)
}

/// e.g. "Today · 7:32 – 8:05 AM" or "May 28 · 6:10 – 6:48 PM".
/// e.g. "Today · 7:32 – 8:05 AM", or "Today · 07:32 – 08:05" where the device is on 24-hour time.
///
/// Locale-native intervals carry a shared day-period once, or both when crossing noon.
private var dateRange: String {
let time = DateFormatter(); time.dateFormat = "h:mm"
let timeAmPm = DateFormatter(); timeAmPm.dateFormat = "h:mm a"
let day: String
if Calendar.current.isDateInToday(session.startedAt) {
day = "Today"
} else if Calendar.current.isDateInYesterday(session.startedAt) {
day = "Yesterday"
} else {
let d = DateFormatter(); d.dateFormat = "MMM d"
let d = DateFormatter.localizedTemplate("MMMd")
day = d.string(from: session.startedAt)
}
guard let ended = session.endedAt else { return day }
return "\(day) · \(time.string(from: session.startedAt)) – \(timeAmPm.string(from: ended))"
return "\(day) · \(WorkoutTimeFormat.range(start: session.startedAt, end: ended))"
}

@ViewBuilder
Expand Down
Loading