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
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 @@ -455,8 +455,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 @@ -9,10 +9,14 @@ import UIKit
/// never leak protocol bytes.
@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 @@ -30,7 +34,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 @@ -60,7 +64,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 @@ -71,7 +75,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 @@ -88,7 +92,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
58 changes: 58 additions & 0 deletions PulseLoop/Services/DateFormatting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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 {
(dateFormat(fromTemplate: "j", options: 0, locale: locale) ?? "").contains("a")
}
}
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
11 changes: 7 additions & 4 deletions PulseLoop/Views/RecordSummaryComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,17 +165,20 @@ 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.
///
/// The AM/PM marker is carried once, on the end of the range. A 24-hour locale has no marker to
/// carry, so both ends format the same way there.
private var dateRange: String {
let time = DateFormatter(); time.dateFormat = "h:mm"
let timeAmPm = DateFormatter(); timeAmPm.dateFormat = "h:mm a"
let time = DateFormatter.localizedTemplate(DateFormatter.usesTwelveHourClock() ? "hmm" : "jmm")
let timeAmPm = DateFormatter.localizedTemplate("jmm")
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 }
Expand Down
Loading