From 7c975f7c7de3c38ac6aa4e3ec1d3fb66beb31adc Mon Sep 17 00:00:00 2001 From: rmqg Date: Thu, 9 Jul 2026 04:06:36 -0400 Subject: [PATCH 01/10] Add configurable day start time (day boundary hour) Dayflow's "day" rolled over at a hard-coded 4 AM. That boundary was duplicated across ~15 call sites (timeline day bucketing, daily recap scheduling, day reprocessing, journal activity checks, weekly ranges, chat/dashboard date tools, and timeline review), so there was no single place to change it. This adds a user-configurable day start hour so people with non-standard schedules can attribute late-night activity to the correct day. Changes: - Add DayBoundaryPreferences: a UserDefaults-backed hour (key "dayBoundaryHour"), clamped to 0-23, defaulting to 4 to preserve the current behavior. - Route every day-boundary computation through DayBoundaryPreferences.boundaryHour instead of the literal 4. - Add a "Day start time" picker to Settings -> Other. The default is unchanged (4 AM), so existing users see no difference until they pick a different hour. Co-Authored-By: Claude Opus 4.8 --- .../Dayflow/Core/AI/ChatToolExecutor.swift | 2 +- .../Dayflow/Core/AI/DailyRecapScheduler.swift | 2 +- .../GeminiDirectProvider+DashboardTools.swift | 4 +- .../Core/Recording/StorageDateHelpers.swift | 37 ++++++++++++++++--- .../Recording/StorageManager+Journal.swift | 6 +-- .../StorageManager+Reprocessing.swift | 12 +++--- .../StorageManager+TimelineCards.swift | 16 ++++---- .../StorageManager+TimelineReview.swift | 2 +- .../Dayflow/Core/Weekly/WeeklyDateRange.swift | 10 ++--- .../Dayflow/Views/UI/MainView/Support.swift | 2 +- .../UI/MainView/TimelineActivityLoader.swift | 4 +- .../UI/MainView/TimelineShellModels.swift | 2 +- .../UI/Settings/OtherSettingsViewModel.swift | 7 ++++ .../UI/Settings/SettingsOtherTabView.swift | 28 ++++++++++++++ .../Views/UI/TimelineReviewTypes.swift | 4 +- 15 files changed, 99 insertions(+), 39 deletions(-) diff --git a/Dayflow/Dayflow/Core/AI/ChatToolExecutor.swift b/Dayflow/Dayflow/Core/AI/ChatToolExecutor.swift index de1c21189..62f1b0df7 100644 --- a/Dayflow/Dayflow/Core/AI/ChatToolExecutor.swift +++ b/Dayflow/Dayflow/Core/AI/ChatToolExecutor.swift @@ -267,7 +267,7 @@ final class ChatToolExecutor { let calendar = Calendar.current var startComponents = calendar.dateComponents([.year, .month, .day], from: date) - startComponents.hour = 4 + startComponents.hour = DayBoundaryPreferences.boundaryHour startComponents.minute = 0 startComponents.second = 0 diff --git a/Dayflow/Dayflow/Core/AI/DailyRecapScheduler.swift b/Dayflow/Dayflow/Core/AI/DailyRecapScheduler.swift index ce9adce55..10a14d281 100644 --- a/Dayflow/Dayflow/Core/AI/DailyRecapScheduler.swift +++ b/Dayflow/Dayflow/Core/AI/DailyRecapScheduler.swift @@ -76,7 +76,7 @@ final class DailyRecapScheduler: @unchecked Sendable { let now = Date() let hour = Calendar.current.component(.hour, from: now) - guard hour >= 4 else { + guard hour >= DayBoundaryPreferences.boundaryHour else { return } diff --git a/Dayflow/Dayflow/Core/AI/GeminiDirectProvider+DashboardTools.swift b/Dayflow/Dayflow/Core/AI/GeminiDirectProvider+DashboardTools.swift index 0e3274542..153c1595c 100644 --- a/Dayflow/Dayflow/Core/AI/GeminiDirectProvider+DashboardTools.swift +++ b/Dayflow/Dayflow/Core/AI/GeminiDirectProvider+DashboardTools.swift @@ -271,7 +271,7 @@ extension GeminiDirectProvider { let calendar = Calendar.current var startComponents = calendar.dateComponents([.year, .month, .day], from: dayDate) - startComponents.hour = 4 + startComponents.hour = DayBoundaryPreferences.boundaryHour startComponents.minute = 0 startComponents.second = 0 guard let dayStart = calendar.date(from: startComponents) else { @@ -282,7 +282,7 @@ extension GeminiDirectProvider { throw DashboardToolArgError.invalidDate(dateString) } var endComponents = calendar.dateComponents([.year, .month, .day], from: nextDay) - endComponents.hour = 4 + endComponents.hour = DayBoundaryPreferences.boundaryHour endComponents.minute = 0 endComponents.second = 0 guard let dayEnd = calendar.date(from: endComponents) else { diff --git a/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift b/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift index 47bf17675..a8ff1b377 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift @@ -16,23 +16,48 @@ extension DateFormatter { }() } +/// User-configurable hour (0–23, local time) at which Dayflow rolls over to a +/// new "day". Historically this was hard-coded to 4 AM; it is now adjustable so +/// people with non-standard schedules can attribute late-night activity to the +/// correct day. Defaults to 4 AM to preserve the original behavior. +enum DayBoundaryPreferences { + static let dayBoundaryHourKey = "dayBoundaryHour" + static let defaultHour = 4 + + /// Hour (0–23) at which a new day begins. Reads/writes `UserDefaults`, + /// clamped to a valid hour and falling back to `defaultHour` when unset. + static var boundaryHour: Int { + get { + guard let stored = UserDefaults.standard.object(forKey: dayBoundaryHourKey) as? Int else { + return defaultHour + } + return min(max(stored, 0), 23) + } + set { + UserDefaults.standard.set(min(max(newValue, 0), 23), forKey: dayBoundaryHourKey) + } + } +} + extension Date { - /// Calculates the "day" based on a 4 AM start time. + /// Calculates the "day" this date belongs to, using the configurable day + /// boundary hour (`DayBoundaryPreferences.boundaryHour`, default 4 AM). /// Returns the date string (YYYY-MM-DD) and the Date objects for the start and end of that day. func getDayInfoFor4AMBoundary() -> (dayString: String, startOfDay: Date, endOfDay: Date) { let calendar = Calendar.current - guard let fourAMToday = calendar.date(bySettingHour: 4, minute: 0, second: 0, of: self) else { - print("Error: Could not calculate 4 AM for date \(self). Falling back to standard day.") + let boundaryHour = DayBoundaryPreferences.boundaryHour + guard let boundaryStartToday = calendar.date(bySettingHour: boundaryHour, minute: 0, second: 0, of: self) else { + print("Error: Could not calculate day boundary (hour \(boundaryHour)) for date \(self). Falling back to standard day.") let start = calendar.startOfDay(for: self) let end = calendar.date(byAdding: .day, value: 1, to: start)! return (DateFormatter.yyyyMMdd.string(from: start), start, end) } let startOfDay: Date - if self < fourAMToday { - startOfDay = calendar.date(byAdding: .day, value: -1, to: fourAMToday)! + if self < boundaryStartToday { + startOfDay = calendar.date(byAdding: .day, value: -1, to: boundaryStartToday)! } else { - startOfDay = fourAMToday + startOfDay = boundaryStartToday } let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! let dayString = DateFormatter.yyyyMMdd.string(from: startOfDay) diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager+Journal.swift b/Dayflow/Dayflow/Core/Recording/StorageManager+Journal.swift index 78dca4162..ef91cdaf2 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager+Journal.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager+Journal.swift @@ -231,16 +231,16 @@ extension StorageManager { let calendar = Calendar.current - // Get 4 AM boundaries + // Get day-boundary bounds (boundary hour to boundary hour next day) var startComponents = calendar.dateComponents([.year, .month, .day], from: dayDate) - startComponents.hour = 4 + startComponents.hour = DayBoundaryPreferences.boundaryHour startComponents.minute = 0 startComponents.second = 0 guard let dayStart = calendar.date(from: startComponents) else { return false } guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayDate) else { return false } var endComponents = calendar.dateComponents([.year, .month, .day], from: nextDay) - endComponents.hour = 4 + endComponents.hour = DayBoundaryPreferences.boundaryHour endComponents.minute = 0 endComponents.second = 0 guard let dayEnd = calendar.date(from: endComponents) else { return false } diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager+Reprocessing.swift b/Dayflow/Dayflow/Core/Recording/StorageManager+Reprocessing.swift index 72b603ad6..fbf301b90 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager+Reprocessing.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager+Reprocessing.swift @@ -12,17 +12,17 @@ extension StorageManager { let calendar = Calendar.current - // Get 4 AM of the given day as the start + // Get the day-boundary hour of the given day as the start var startComponents = calendar.dateComponents([.year, .month, .day], from: dayDate) - startComponents.hour = 4 + startComponents.hour = DayBoundaryPreferences.boundaryHour startComponents.minute = 0 startComponents.second = 0 guard let dayStart = calendar.date(from: startComponents) else { return [] } - // Get 4 AM of the next day as the end + // Get the day-boundary hour of the next day as the end guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayDate) else { return [] } var endComponents = calendar.dateComponents([.year, .month, .day], from: nextDay) - endComponents.hour = 4 + endComponents.hour = DayBoundaryPreferences.boundaryHour endComponents.minute = 0 endComponents.second = 0 guard let dayEnd = calendar.date(from: endComponents) else { return [] } @@ -117,7 +117,7 @@ extension StorageManager { guard let dayDate = formatter.date(from: day) else { return [] } let calendar = Calendar.current - guard let startOfDay = calendar.date(bySettingHour: 4, minute: 0, second: 0, of: dayDate) else { + guard let startOfDay = calendar.date(bySettingHour: DayBoundaryPreferences.boundaryHour, minute: 0, second: 0, of: dayDate) else { return [] } let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! @@ -197,7 +197,7 @@ extension StorageManager { guard let dayDate = formatter.date(from: day) else { return [] } let calendar = Calendar.current - guard let startOfDay = calendar.date(bySettingHour: 4, minute: 0, second: 0, of: dayDate) else { + guard let startOfDay = calendar.date(bySettingHour: DayBoundaryPreferences.boundaryHour, minute: 0, second: 0, of: dayDate) else { return [] } let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift index 63b708ece..653d2958e 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift @@ -36,7 +36,7 @@ extension StorageManager { // If the parsed time is between midnight and 4 AM, and it's earlier than baseDate, // disambiguate whether it's same day (before batch) or next day (after midnight crossing) - if startHour < 4 && startDate < baseDate { + if startHour < DayBoundaryPreferences.boundaryHour && startDate < baseDate { let nextDayStartDate = calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate // Pick whichever is closer to batch start time @@ -59,7 +59,7 @@ extension StorageManager { calendar.date(bySettingHour: endHour, minute: endMinute, second: 0, of: baseDate) ?? baseDate // Disambiguate end time day using same logic as start time - if endHour < 4 && endDate < baseDate { + if endHour < DayBoundaryPreferences.boundaryHour && endDate < baseDate { let nextDayEndDate = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate let sameDayDistance = abs(endDate.timeIntervalSince(baseDate)) @@ -398,17 +398,17 @@ extension StorageManager { let calendar = Calendar.current - // Get 4 AM of the given day as the start + // Get the day-boundary hour of the given day as the start var startComponents = calendar.dateComponents([.year, .month, .day], from: dayDate) - startComponents.hour = 4 + startComponents.hour = DayBoundaryPreferences.boundaryHour startComponents.minute = 0 startComponents.second = 0 guard let dayStart = calendar.date(from: startComponents) else { return [] } - // Get 4 AM of the next day as the end + // Get the day-boundary hour of the next day as the end guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayDate) else { return [] } var endComponents = calendar.dateComponents([.year, .month, .day], from: nextDay) - endComponents.hour = 4 + endComponents.hour = DayBoundaryPreferences.boundaryHour endComponents.minute = 0 endComponents.second = 0 guard let dayEnd = calendar.date(from: endComponents) else { return [] } @@ -557,8 +557,8 @@ extension StorageManager { // Find the Monday of the week containing this date var weekStart = calendar.date( from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))! - // Set to 4 AM on that Monday - weekStart = calendar.date(bySettingHour: 4, minute: 0, second: 0, of: weekStart) ?? weekStart + // Set to the day-boundary hour on that Monday + weekStart = calendar.date(bySettingHour: DayBoundaryPreferences.boundaryHour, minute: 0, second: 0, of: weekStart) ?? weekStart // If current date is before 4 AM Monday, go back one week if date < weekStart { diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineReview.swift b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineReview.swift index cc0d895ff..3ffc1f6ce 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineReview.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineReview.swift @@ -136,7 +136,7 @@ extension StorageManager { { guard let dayDate = dateFormatter.date(from: day) else { return 0 } let calendar = Calendar.current - guard let dayStart = calendar.date(bySettingHour: 4, minute: 0, second: 0, of: dayDate) else { + guard let dayStart = calendar.date(bySettingHour: DayBoundaryPreferences.boundaryHour, minute: 0, second: 0, of: dayDate) else { return 0 } let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyDateRange.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyDateRange.swift index 8a4d86bba..fd03686c1 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyDateRange.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyDateRange.swift @@ -51,13 +51,13 @@ struct WeeklyDateRange: Equatable, Sendable { let baseWeekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) ?? date - let mondayAtFourAM = - calendar.date(bySettingHour: 4, minute: 0, second: 0, of: baseWeekStart) ?? baseWeekStart + let mondayAtBoundary = + calendar.date(bySettingHour: DayBoundaryPreferences.boundaryHour, minute: 0, second: 0, of: baseWeekStart) ?? baseWeekStart - if date < mondayAtFourAM { - return calendar.date(byAdding: .day, value: -7, to: mondayAtFourAM) ?? mondayAtFourAM + if date < mondayAtBoundary { + return calendar.date(byAdding: .day, value: -7, to: mondayAtBoundary) ?? mondayAtBoundary } - return mondayAtFourAM + return mondayAtBoundary } } diff --git a/Dayflow/Dayflow/Views/UI/MainView/Support.swift b/Dayflow/Dayflow/Views/UI/MainView/Support.swift index f2d46462c..979717413 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Support.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Support.swift @@ -429,7 +429,7 @@ func timelineDisplayDate(from date: Date, now: Date = Date()) -> Date { let normalizedNow = normalizedTimelineDate(now) let nowHour = calendar.component(.hour, from: now) - if nowHour < 4 && calendar.isDate(normalizedDate, inSameDayAs: normalizedNow) { + if nowHour < DayBoundaryPreferences.boundaryHour && calendar.isDate(normalizedDate, inSameDayAs: normalizedNow) { normalizedDate = calendar.date(byAdding: .day, value: -1, to: normalizedDate) ?? normalizedDate } diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift index 9c37b059a..3d0038e8d 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift @@ -105,11 +105,11 @@ enum TimelineActivityLoader { var adjustedStartDate = startDate var adjustedEndDate = endDate - if calendar.component(.hour, from: startDate) < 4 { + if calendar.component(.hour, from: startDate) < DayBoundaryPreferences.boundaryHour { adjustedStartDate = calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate } - if calendar.component(.hour, from: endDate) < 4 { + if calendar.component(.hour, from: endDate) < DayBoundaryPreferences.boundaryHour { adjustedEndDate = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate } diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineShellModels.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineShellModels.swift index 234931222..5ae65f783 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/TimelineShellModels.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/TimelineShellModels.swift @@ -86,7 +86,7 @@ struct TimelineWeekRange: Equatable, Sendable { calendar.date(byAdding: .day, value: -daysFromWeekStart, to: anchorDay) ?? anchorDay let weekStart = - calendar.date(bySettingHour: 4, minute: 0, second: 0, of: weekStartDay) ?? weekStartDay + calendar.date(bySettingHour: DayBoundaryPreferences.boundaryHour, minute: 0, second: 0, of: weekStartDay) ?? weekStartDay let weekEnd = calendar.date(byAdding: .day, value: 7, to: weekStart) ?? weekStart return TimelineWeekRange(weekStart: weekStart, weekEnd: weekEnd) } diff --git a/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift index a62e10953..de4d2ec74 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift @@ -36,6 +36,12 @@ final class OtherSettingsViewModel: ObservableObject { TimelapsePreferences.saveAllTimelapsesToDisk = saveAllTimelapsesToDisk } } + @Published var dayBoundaryHour: Int { + didSet { + guard dayBoundaryHour != oldValue else { return } + DayBoundaryPreferences.boundaryHour = dayBoundaryHour + } + } @Published var outputLanguageOverride: String @Published var isOutputLanguageOverrideSaved: Bool = true @@ -57,6 +63,7 @@ final class OtherSettingsViewModel: ObservableObject { UserDefaults.standard.object(forKey: "showTimelineAppIcons") as? Bool ?? true showDailyGoalPopups = DayGoalPreferences.showDailyGoalPopups saveAllTimelapsesToDisk = TimelapsePreferences.saveAllTimelapsesToDisk + dayBoundaryHour = DayBoundaryPreferences.boundaryHour outputLanguageOverride = LLMOutputLanguagePreferences.override exportStartDate = timelineDisplayDate(from: Date()) exportEndDate = timelineDisplayDate(from: Date()) diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift index a8ec1b370..986a8a911 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift @@ -59,6 +59,21 @@ struct SettingsOtherTabView: View { SettingsToggle(isOn: $viewModel.showDailyGoalPopups) } + SettingsRow( + label: "Day start time", + subtitle: + "The hour a new day begins. Activity before this time is attributed to the previous day. Affects the timeline, daily recap, weekly, and journal views." + ) { + Picker("", selection: $viewModel.dayBoundaryHour) { + ForEach(0..<24, id: \.self) { hour in + Text(SettingsOtherTabView.formattedHour(hour)).tag(hour) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 110) + } + SettingsRow( label: "Save all timelapses to disk", subtitle: @@ -112,4 +127,17 @@ struct SettingsOtherTabView: View { } } } + + /// Formats an hour (0–23) as a locale-stable 12-hour label, e.g. "4 AM". + static func formattedHour(_ hour: Int) -> String { + let calendar = Calendar.current + let base = calendar.startOfDay(for: Date()) + guard let date = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: base) else { + return "\(hour):00" + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "h a" + return formatter.string(from: date) + } } diff --git a/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift b/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift index c05356e91..fb1e96605 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift @@ -193,11 +193,11 @@ func makeTimelineActivities(from cards: [TimelineCard], for date: Date) var adjustedStartDate = finalStartDate var adjustedEndDate = finalEndDate - if calendar.component(.hour, from: finalStartDate) < 4 { + if calendar.component(.hour, from: finalStartDate) < DayBoundaryPreferences.boundaryHour { adjustedStartDate = calendar.date(byAdding: .day, value: 1, to: finalStartDate) ?? finalStartDate } - if calendar.component(.hour, from: finalEndDate) < 4 { + if calendar.component(.hour, from: finalEndDate) < DayBoundaryPreferences.boundaryHour { adjustedEndDate = calendar.date(byAdding: .day, value: 1, to: finalEndDate) ?? finalEndDate } if adjustedEndDate < adjustedStartDate { From fcfa3d5454656f39f5e034f7419adfda45da05df Mon Sep 17 00:00:00 2001 From: rmqg Date: Thu, 9 Jul 2026 04:35:07 -0400 Subject: [PATCH 02/10] Respect day boundary in timeline axes and weekly/daily aggregation The first pass covered day *bucketing* but missed several places that independently hard-coded the 4 AM boundary for the visual hour axis and for minute-based day-window math: - Timeline hour axis: CanvasTimelineDataView and WeekTimelineGridView (startHour/endHour now derive from the boundary hour; the axis still spans a full 24h). - Minute-based day window / midnight-wrap normalization: WeeklyDonutBuilder, WeeklyOverviewBuilder, WeeklyDashboardBuilder (+HeatmapWorkflow), DailyWorkflowComputation, DaySummaryStats -- the "< 240 (4*60)" wrap threshold and the [4*60, 28*60] window now use DayBoundaryPreferences. - Add DayBoundaryPreferences.boundaryMinutes helper. Verified with a clean xcodebuild (Xcode 26.6): BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift | 3 +++ .../Weekly/WeeklyDashboardBuilder+HeatmapWorkflow.swift | 4 ++-- Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift | 4 ++-- Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift | 4 ++-- Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift | 4 ++-- Dayflow/Dayflow/Views/Components/DaySummaryStats.swift | 2 +- Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift | 6 ++++-- Dayflow/Dayflow/Views/UI/DailyWorkflowComputation.swift | 4 ++-- .../Dayflow/Views/UI/MainView/WeekTimelineGridView.swift | 6 ++++-- 9 files changed, 22 insertions(+), 15 deletions(-) diff --git a/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift b/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift index a8ff1b377..c24a19aa5 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift @@ -37,6 +37,9 @@ enum DayBoundaryPreferences { UserDefaults.standard.set(min(max(newValue, 0), 23), forKey: dayBoundaryHourKey) } } + + /// The day boundary expressed as minutes since midnight (`boundaryHour * 60`). + static var boundaryMinutes: Int { boundaryHour * 60 } } extension Date { diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder+HeatmapWorkflow.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder+HeatmapWorkflow.swift index fc1323fa6..699bde022 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder+HeatmapWorkflow.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder+HeatmapWorkflow.swift @@ -420,8 +420,8 @@ extension WeeklyDashboardBuilder { ) { let fallbackStart = 9.0 * 60.0 let fallbackEnd = 22.0 * 60.0 - let dayStart = 4.0 * 60.0 - let dayEnd = 28.0 * 60.0 + let dayStart = Double(DayBoundaryPreferences.boundaryMinutes) + let dayEnd = Double(DayBoundaryPreferences.boundaryMinutes + 1440) let padding = 30.0 let snapMinutes = 15.0 diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift index a48d4f061..878a27198 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift @@ -468,8 +468,8 @@ enum WeeklyDashboardBuilder { static func normalizedMinuteRange(start: Double, end: Double) -> ( start: Double, end: Double ) { - let adjustedStart = start < 240 ? start + 1440 : start - var adjustedEnd = end < 240 ? end + 1440 : end + let adjustedStart = start < Double(DayBoundaryPreferences.boundaryMinutes) ? start + 1440 : start + var adjustedEnd = end < Double(DayBoundaryPreferences.boundaryMinutes) ? end + 1440 : end if adjustedEnd <= adjustedStart { adjustedEnd += 1440 } diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift index 25b8971a7..7da1e247a 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift @@ -165,8 +165,8 @@ enum WeeklyDonutBuilder { private static func normalizedMinuteRange(start: Double, end: Double) -> ( start: Double, end: Double ) { - let adjustedStart = start < 240 ? start + 1440 : start - var adjustedEnd = end < 240 ? end + 1440 : end + let adjustedStart = start < Double(DayBoundaryPreferences.boundaryMinutes) ? start + 1440 : start + var adjustedEnd = end < Double(DayBoundaryPreferences.boundaryMinutes) ? end + 1440 : end if adjustedEnd <= adjustedStart { adjustedEnd += 1440 diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift index 3d3559f79..74abc6816 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift @@ -458,8 +458,8 @@ enum WeeklyOverviewBuilder { private static func normalizedMinuteRange(start: Double, end: Double) -> ( start: Double, end: Double ) { - let adjustedStart = start < 240 ? start + 1440 : start - var adjustedEnd = end < 240 ? end + 1440 : end + let adjustedStart = start < Double(DayBoundaryPreferences.boundaryMinutes) ? start + 1440 : start + var adjustedEnd = end < Double(DayBoundaryPreferences.boundaryMinutes) ? end + 1440 : end if adjustedEnd <= adjustedStart { adjustedEnd += 1440 } diff --git a/Dayflow/Dayflow/Views/Components/DaySummaryStats.swift b/Dayflow/Dayflow/Views/Components/DaySummaryStats.swift index 362c9747c..dccefaca7 100644 --- a/Dayflow/Dayflow/Views/Components/DaySummaryStats.swift +++ b/Dayflow/Dayflow/Views/Components/DaySummaryStats.swift @@ -19,7 +19,7 @@ enum DaySummaryStats { } private static let focusGapMinutes: Int = 5 - private static let timelineDayStartMinutes: Int = 4 * 60 + private static var timelineDayStartMinutes: Int { DayBoundaryPreferences.boundaryMinutes } private static let minutesPerDay: Int = 24 * 60 private static func normalizedCategoryName(_ name: String) -> String { diff --git a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift index 717931b12..25b7fd34c 100644 --- a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift +++ b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift @@ -13,8 +13,10 @@ private let cachedTimeFormatter: DateFormatter = { private struct CanvasConfig { static let timeColumnWidth: CGFloat = 60 - static let startHour: Int = 4 // 4 AM baseline - static let endHour: Int = 28 // 4 AM next day + /// Visual start of the timeline axis = the configured day-boundary hour + /// (defaults to 4 AM). The axis always spans a full 24 hours from there. + static var startHour: Int { DayBoundaryPreferences.boundaryHour } + static var endHour: Int { startHour + 24 } } private struct TimelineCardsLayerFramePreferenceKey: PreferenceKey { diff --git a/Dayflow/Dayflow/Views/UI/DailyWorkflowComputation.swift b/Dayflow/Dayflow/Views/UI/DailyWorkflowComputation.swift index 33002089d..99c59733a 100644 --- a/Dayflow/Dayflow/Views/UI/DailyWorkflowComputation.swift +++ b/Dayflow/Dayflow/Views/UI/DailyWorkflowComputation.swift @@ -403,8 +403,8 @@ func isDistractionCategoryKey(_ key: String) -> Bool { } func normalizedMinuteRange(start: Double, end: Double) -> (start: Double, end: Double) { - let s = start < 240 ? start + 1440 : start - var e = end < 240 ? end + 1440 : end + let s = start < Double(DayBoundaryPreferences.boundaryMinutes) ? start + 1440 : start + var e = end < Double(DayBoundaryPreferences.boundaryMinutes) ? end + 1440 : end if e <= s { e += 1440 } return (s, e) } diff --git a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridView.swift b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridView.swift index a94b798eb..4969b32ba 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridView.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridView.swift @@ -7,8 +7,10 @@ enum WeekTimelineConfig { static let hourHeight: CGFloat = 111 static let pixelsPerMinute: CGFloat = hourHeight / 60 static let timeColumnWidth: CGFloat = 48 - static let startHour: Int = 4 - static let endHour: Int = 28 + /// Visual start of the week grid's hour axis = the configured day-boundary + /// hour (defaults to 4 AM). The axis always spans a full 24 hours from there. + static var startHour: Int { DayBoundaryPreferences.boundaryHour } + static var endHour: Int { startHour + 24 } static let weekHeaderHeight: CGFloat = 22 static let headerSpacing: CGFloat = 2 static let weekdayInlineSpacing: CGFloat = 6 From 144bfbdccdf69a37819468fe7e7d691d415286e2 Mon Sep 17 00:00:00 2001 From: rmqg Date: Thu, 9 Jul 2026 05:07:32 -0400 Subject: [PATCH 03/10] Fix timeline indicator and day stats at non-default day boundaries Two spots still assumed a pre-noon (4 AM) boundary and broke when the day start is later than noon (e.g. 4 PM): - normalizedTimelineDate() anchored the timeline's representative time to noon. Composed with getDayInfoFor4AMBoundary() (as DaySummaryView's timelineDayInfo and the recording projection window both do), a noon anchor with a boundary later than noon rolled the day back one extra day -- so "Your day so far" fetched the wrong (empty) day and the live "generating next card" indicator was clamped to the bottom of the axis. Anchor to the boundary hour instead. - nowCenteredTargetHourIndex() still hard-coded the 4 AM baseline for the scroll-to-now target; derive it from CanvasConfig.startHour. Verified with a clean xcodebuild (Xcode 26.6): BUILD SUCCEEDED, and confirmed at runtime with the day start set to 4 PM (stats populate and the live indicator sits at the current time). Co-Authored-By: Claude Opus 4.8 --- Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift | 6 ++++-- Dayflow/Dayflow/Views/UI/MainView/Support.swift | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift index 25b7fd34c..0d22e45d2 100644 --- a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift +++ b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift @@ -111,8 +111,10 @@ struct CanvasTimelineDataView: View { // own copy of this calculation). private func nowCenteredTargetHourIndex() -> Int { let currentHour = Calendar.current.component(.hour, from: Date()) - let hoursSince4AM = currentHour >= 4 ? currentHour - 4 : (24 - 4) + currentHour - return max(0, hoursSince4AM - 2) + let startHour = CanvasConfig.startHour + let hoursSinceStart = + currentHour >= startHour ? currentHour - startHour : (24 - startHour) + currentHour + return max(0, hoursSinceStart - 2) } private func scrollToNowCenteredHour(with proxy: ScrollViewProxy, animated: Bool = false) { diff --git a/Dayflow/Dayflow/Views/UI/MainView/Support.swift b/Dayflow/Dayflow/Views/UI/MainView/Support.swift index 979717413..03f8211c6 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Support.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Support.swift @@ -416,11 +416,15 @@ func canNavigateForward(from date: Date, now: Date = Date()) -> Bool { func normalizedTimelineDate(_ date: Date) -> Date { let calendar = Calendar.current - if let normalized = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: date) { + // Anchor to the day-boundary hour (not noon) so that composing this with + // `getDayInfoFor4AMBoundary()` stays on the same logical day for any boundary + // hour — including boundaries later than noon (e.g. 4 PM). + let boundaryHour = DayBoundaryPreferences.boundaryHour + if let normalized = calendar.date(bySettingHour: boundaryHour, minute: 0, second: 0, of: date) { return normalized } let startOfDay = calendar.startOfDay(for: date) - return calendar.date(byAdding: DateComponents(hour: 12), to: startOfDay) ?? date + return calendar.date(byAdding: DateComponents(hour: boundaryHour), to: startOfDay) ?? date } func timelineDisplayDate(from date: Date, now: Date = Date()) -> Date { From b7e4c2f8e71b313fda1883af9199ef874c7f34cc Mon Sep 17 00:00:00 2001 From: rmqg Date: Fri, 10 Jul 2026 00:38:41 -0400 Subject: [PATCH 04/10] Add configurable logical day labels --- Dayflow/Dayflow/App/DayflowApp.swift | 3 + .../Dayflow/Core/AI/ChatToolExecutor.swift | 13 ++- .../GeminiDirectProvider+DashboardTools.swift | 18 ++-- .../Core/Recording/JournalDayManager.swift | 5 +- .../Core/Recording/StorageDateHelpers.swift | 53 +++++++++++ .../Core/Weekly/WeeklyDashboardBuilder.swift | 2 +- .../Dayflow/Core/Weekly/WeeklyDateRange.swift | 37 ++++---- .../Core/Weekly/WeeklyDonutBuilder.swift | 2 +- .../Core/Weekly/WeeklyOverviewBuilder.swift | 2 +- .../TimelineClipboardFormatter.swift | 20 +++-- .../Dayflow/Views/UI/DailyView+Standup.swift | 7 +- .../Dayflow/Views/UI/DailyView+Workflow.swift | 8 +- Dayflow/Dayflow/Views/UI/DailyView.swift | 3 + .../Dayflow/Views/UI/MainView/Actions.swift | 10 ++- .../UI/MainView/DateNavigationControls.swift | 4 +- .../Views/UI/MainView/Layout+Panels.swift | 5 ++ .../UI/MainView/Layout+TimelineHeader.swift | 10 ++- .../Dayflow/Views/UI/MainView/Layout.swift | 23 +++-- .../Dayflow/Views/UI/MainView/MainView.swift | 7 ++ .../Dayflow/Views/UI/MainView/Support.swift | 4 +- .../UI/MainView/TimelineActivityLoader.swift | 3 +- .../UI/MainView/TimelineCalendarPopover.swift | 3 +- .../UI/MainView/TimelineShellModels.swift | 25 +++++- .../UI/MainView/WeekTimelineGridPreview.swift | 1 + .../UI/MainView/WeekTimelineGridView.swift | 36 +++++--- .../UI/Settings/OtherSettingsViewModel.swift | 9 ++ .../UI/Settings/SettingsDataTabView.swift | 15 +++- .../UI/Settings/SettingsOtherTabView.swift | 14 +++ .../Dayflow/Views/UI/TimelineDataModels.swift | 2 +- .../Dayflow/Views/UI/Weekly/WeeklyView.swift | 25 ++++-- Dayflow/DayflowTests/DayFramingTests.swift | 90 +++++++++++++++++++ 31 files changed, 370 insertions(+), 89 deletions(-) create mode 100644 Dayflow/DayflowTests/DayFramingTests.swift diff --git a/Dayflow/Dayflow/App/DayflowApp.swift b/Dayflow/Dayflow/App/DayflowApp.swift index 17cb9bef6..36441fc47 100644 --- a/Dayflow/Dayflow/App/DayflowApp.swift +++ b/Dayflow/Dayflow/App/DayflowApp.swift @@ -320,6 +320,9 @@ extension Notification.Name { static let navigateToDaily = Notification.Name("navigateToDaily") static let navigateToWeekly = Notification.Name("navigateToWeekly") static let timelineDataUpdated = Notification.Name("timelineDataUpdated") + /// Posted when day-framing settings (boundary hour or start/end label mode) + /// change, so the timeline re-renders/reloads immediately without a restart. + static let dayFramingChanged = Notification.Name("dayFramingChanged") static let showTimelineFailureToast = Notification.Name("showTimelineFailureToast") static let showScreenRecordingPermissionNotice = Notification.Name( "showScreenRecordingPermissionNotice") diff --git a/Dayflow/Dayflow/Core/AI/ChatToolExecutor.swift b/Dayflow/Dayflow/Core/AI/ChatToolExecutor.swift index 62f1b0df7..4fbcd73f8 100644 --- a/Dayflow/Dayflow/Core/AI/ChatToolExecutor.swift +++ b/Dayflow/Dayflow/Core/AI/ChatToolExecutor.swift @@ -136,7 +136,10 @@ final class ChatToolExecutor { return .failure(tool: .fetchTimeline, error: "Invalid date format. Use YYYY-MM-DD.") } - let cards = StorageManager.shared.fetchTimelineCards(forDay: dateString) + guard let storageDayString = storageDayString(forLabel: dateString) else { + return .failure(tool: .fetchTimeline, error: "Could not parse date") + } + let cards = StorageManager.shared.fetchTimelineCards(forDay: storageDayString) if cards.isEmpty { return ChatToolResult( @@ -263,7 +266,8 @@ final class ChatToolExecutor { /// Convert "YYYY-MM-DD" to day boundaries (4 AM to 4 AM next day) private func dayBoundaries(for dateString: String) -> (start: Date, end: Date)? { - guard let date = chatToolDayFormatter.date(from: dateString) else { return nil } + guard let labelDate = chatToolDayFormatter.date(from: dateString) else { return nil } + let date = labelDate.timelineDateFromLabel() let calendar = Calendar.current var startComponents = calendar.dateComponents([.year, .month, .day], from: date) @@ -280,6 +284,11 @@ final class ChatToolExecutor { return (dayStart, dayEnd) } + private func storageDayString(forLabel dateString: String) -> String? { + guard let labelDate = chatToolDayFormatter.date(from: dateString) else { return nil } + return chatToolDayFormatter.string(from: labelDate.timelineDateFromLabel()) + } + /// Format date for human-readable display private func formatDateForDisplay(_ dateString: String) -> String { guard let date = chatToolDayFormatter.date(from: dateString) else { return dateString } diff --git a/Dayflow/Dayflow/Core/AI/GeminiDirectProvider+DashboardTools.swift b/Dayflow/Dayflow/Core/AI/GeminiDirectProvider+DashboardTools.swift index 153c1595c..3a9eee1db 100644 --- a/Dayflow/Dayflow/Core/AI/GeminiDirectProvider+DashboardTools.swift +++ b/Dayflow/Dayflow/Core/AI/GeminiDirectProvider+DashboardTools.swift @@ -49,8 +49,9 @@ extension GeminiDirectProvider { let dateRange = try parseDashboardDateRange(args: args) let cards: [TimelineCard] - if dateRange.mode == "date", let date = dateRange.date { - cards = StorageManager.shared.fetchTimelineCards(forDay: date) + if dateRange.mode == "date" { + let storageDay = dashboardDateFormatter.string(from: dateRange.from) + cards = StorageManager.shared.fetchTimelineCards(forDay: storageDay) } else { cards = StorageManager.shared.fetchTimelineCardsByTimeRange( from: dateRange.from, to: dateRange.to) @@ -65,7 +66,7 @@ extension GeminiDirectProvider { var items: [[String: Any]] = limitedCards.map { card in var item: [String: Any] = [ - "day": card.day, + "day": dashboardLabelDayString(forStorageDay: card.day), "startTime": card.startTimestamp, "endTime": card.endTimestamp, "title": card.title, @@ -187,7 +188,8 @@ extension GeminiDirectProvider { for observation in observations { let start = Date(timeIntervalSince1970: TimeInterval(observation.startTs)) let end = Date(timeIntervalSince1970: TimeInterval(observation.endTs)) - let day = start.getDayInfoFor4AMBoundary().dayString + let storageDay = start.getDayInfoFor4AMBoundary().dayString + let day = dashboardLabelDayString(forStorageDay: storageDay) let item: [String: Any] = [ "startTime": dashboardTimeFormatter.string(from: start), "endTime": dashboardTimeFormatter.string(from: end), @@ -265,9 +267,10 @@ extension GeminiDirectProvider { } func dashboardDayBounds(for dateString: String) throws -> (start: Date, end: Date) { - guard let dayDate = dashboardDateFormatter.date(from: dateString) else { + guard let labelDate = dashboardDateFormatter.date(from: dateString) else { throw DashboardToolArgError.invalidDate(dateString) } + let dayDate = labelDate.timelineDateFromLabel() let calendar = Calendar.current var startComponents = calendar.dateComponents([.year, .month, .day], from: dayDate) @@ -292,6 +295,11 @@ extension GeminiDirectProvider { return (dayStart, dayEnd) } + func dashboardLabelDayString(forStorageDay dayString: String) -> String { + guard let storageDate = dashboardDateFormatter.date(from: dayString) else { return dayString } + return dashboardDateFormatter.string(from: storageDate.timelineLabelDate()) + } + func dashboardFetchDateDescription(for dateRange: DashboardDateRange) -> String { if dateRange.mode == "date", let date = dateRange.date { return formattedDashboardSingleDate(date) ?? date diff --git a/Dayflow/Dayflow/Core/Recording/JournalDayManager.swift b/Dayflow/Dayflow/Core/Recording/JournalDayManager.swift index a15376948..28f59250d 100644 --- a/Dayflow/Dayflow/Core/Recording/JournalDayManager.swift +++ b/Dayflow/Dayflow/Core/Recording/JournalDayManager.swift @@ -256,14 +256,15 @@ final class JournalDayManager: ObservableObject { let formatter = DateFormatter() + let labelDate = date.timelineLabelDate() if isToday { // "Today, November 24" - no day of week needed formatter.dateFormat = "MMMM d" - return "Today, \(formatter.string(from: date))" + return "Today, \(formatter.string(from: labelDate))" } else { // "Monday, November 24" - day of week helps orient for past days formatter.dateFormat = "EEEE, MMMM d" - return formatter.string(from: date) + return formatter.string(from: labelDate) } } diff --git a/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift b/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift index c24a19aa5..ae5c92380 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift @@ -42,6 +42,59 @@ enum DayBoundaryPreferences { static var boundaryMinutes: Int { boundaryHour * 60 } } +/// Whether a logical day that crosses midnight is labeled by the calendar date +/// it STARTS on or the date it ENDS on. This is display-only — storage and +/// fetch keys always use the start date, so switching modes needs no migration. +enum DayLabelMode: String { + case startDate + case endDate +} + +enum DayLabelPreferences { + static let dayLabelModeKey = "dayLabelMode" + + static var mode: DayLabelMode { + get { + DayLabelMode(rawValue: UserDefaults.standard.string(forKey: dayLabelModeKey) ?? "") + ?? .startDate + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: dayLabelModeKey) } + } + + static var usesEndDate: Bool { + mode == .endDate && DayBoundaryPreferences.boundaryHour > 0 + } +} + +extension Date { + /// Converts a timeline "representative" date (as returned by + /// `timelineDisplayDate`, anchored on the day's START calendar date) into the + /// calendar date to SHOW the user, honoring `DayLabelPreferences`. In end-date + /// mode with a boundary after midnight this is the following calendar date. + /// Display only — does not affect storage or fetch keys. + func timelineLabelDate() -> Date { + guard DayLabelPreferences.usesEndDate else { + return self + } + return Calendar.current.date(byAdding: .day, value: 1, to: self) ?? self + } + + /// Inverse of `timelineLabelDate()`: maps a user-facing (label) date back to + /// the internal timeline representative date used for navigation and fetches. + func timelineDateFromLabel() -> Date { + guard DayLabelPreferences.usesEndDate else { + return self + } + return Calendar.current.date(byAdding: .day, value: -1, to: self) ?? self + } + + /// Storage keys remain anchored to the logical day's start date. This is + /// intentionally the inverse of the user-facing label mapping. + var timelineStorageDayStringForLabel: String { + DateFormatter.yyyyMMdd.string(from: timelineDateFromLabel()) + } +} + extension Date { /// Calculates the "day" this date belongs to, using the configurable day /// boundary hour (`DayBoundaryPreferences.boundaryHour`, default 4 AM). diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift index 878a27198..ee80bec79 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyDashboardBuilder.swift @@ -339,7 +339,7 @@ enum WeeklyDashboardBuilder { return WeeklyDayDescriptor( id: dayID(for: offset), label: dayLabel(for: offset), - dayString: DateFormatter.yyyyMMdd.string(from: date), + dayString: date.timelineStorageDayStringForLabel, order: offset ) } diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyDateRange.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyDateRange.swift index fd03686c1..ab8cc3c72 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyDateRange.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyDateRange.swift @@ -17,9 +17,21 @@ struct WeeklyDateRange: Equatable, Sendable { }() static func containing(_ date: Date, calendar: Calendar = Self.calendar) -> WeeklyDateRange { - let mondayAtFourAM = mondayBoundary(containing: date, calendar: calendar) - let weekEnd = calendar.date(byAdding: .day, value: 7, to: mondayAtFourAM) ?? mondayAtFourAM - return WeeklyDateRange(weekStart: mondayAtFourAM, weekEnd: weekEnd) + let labelDate = timelineDisplayDate(from: date, now: date).timelineLabelDate() + return containingLabelDate(labelDate, calendar: calendar) + } + + static func containingLabelDate( + _ date: Date, calendar: Calendar = Self.calendar + ) -> WeeklyDateRange { + let monday = calendar.date( + from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) ?? date + let weekStart = + calendar.date( + bySettingHour: DayBoundaryPreferences.boundaryHour, minute: 0, second: 0, of: monday) + ?? monday + let weekEnd = calendar.date(byAdding: .day, value: 7, to: weekStart) ?? weekStart + return WeeklyDateRange(weekStart: weekStart, weekEnd: weekEnd) } func shifted(byWeeks weeks: Int, calendar: Calendar = Self.calendar) -> WeeklyDateRange { @@ -32,6 +44,12 @@ struct WeeklyDateRange: Equatable, Sendable { weekStart < Self.containing(Date()).weekStart } + var storageRange: (start: Date, end: Date) { + let start = normalizedTimelineDate(weekStart.timelineDateFromLabel()) + let end = Self.calendar.date(byAdding: .day, value: 7, to: start) ?? start + return (start, end) + } + var title: String { let displayWeekEnd = Self.calendar.date(byAdding: .day, value: 6, to: weekStart) ?? weekStart let startText = Self.titleStartFormatter.string(from: weekStart) @@ -47,17 +65,4 @@ struct WeeklyDateRange: Equatable, Sendable { return calendar }() - private static func mondayBoundary(containing date: Date, calendar: Calendar) -> Date { - let baseWeekStart = - calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) - ?? date - let mondayAtBoundary = - calendar.date(bySettingHour: DayBoundaryPreferences.boundaryHour, minute: 0, second: 0, of: baseWeekStart) ?? baseWeekStart - - if date < mondayAtBoundary { - return calendar.date(byAdding: .day, value: -7, to: mondayAtBoundary) ?? mondayAtBoundary - } - - return mondayAtBoundary - } } diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift index 7da1e247a..a490ab999 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyDonutBuilder.swift @@ -147,7 +147,7 @@ enum WeeklyDonutBuilder { guard let date = calendar.date(byAdding: .day, value: offset, to: weekStart) else { return nil } - return DateFormatter.yyyyMMdd.string(from: date) + return date.timelineStorageDayStringForLabel } } diff --git a/Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift b/Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift index 74abc6816..2e3412fd5 100644 --- a/Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift +++ b/Dayflow/Dayflow/Core/Weekly/WeeklyOverviewBuilder.swift @@ -445,7 +445,7 @@ enum WeeklyOverviewBuilder { return WeeklyOverviewDay( label: labels.short, weekdayName: labels.full, - dayString: DateFormatter.yyyyMMdd.string(from: dayDate) + dayString: dayDate.timelineStorageDayStringForLabel ) } } diff --git a/Dayflow/Dayflow/Utilities/TimelineClipboardFormatter.swift b/Dayflow/Dayflow/Utilities/TimelineClipboardFormatter.swift index bab6ad167..8bf8707ee 100644 --- a/Dayflow/Dayflow/Utilities/TimelineClipboardFormatter.swift +++ b/Dayflow/Dayflow/Utilities/TimelineClipboardFormatter.swift @@ -38,10 +38,10 @@ struct TimelineClipboardFormatter { let cardsByDay = Dictionary(grouping: cards, by: \.day) let sections = weekRange.days.compactMap { day -> String? in - guard let dayCards = cardsByDay[day.dayString], !dayCards.isEmpty else { return nil } + guard let dayCards = cardsByDay[day.storageDayString], !dayCards.isEmpty else { return nil } let sortedCards = dayCards.sorted(by: cardSort) - let heading = formattedTimelineDay(day.date, now: now) + let heading = formattedLabelDay(day.date, now: now) let entries = sortedCards.enumerated().map { index, card in textEntry(for: card, index: index) } @@ -157,16 +157,26 @@ struct TimelineClipboardFormatter { private static func formattedTimelineDay(_ date: Date, now: Date) -> String { let calendar = Calendar.current - let timelineToday = timelineDisplayDate(from: now, now: now) + let labelDate = date.timelineLabelDate() + let timelineToday = timelineDisplayDate(from: now, now: now).timelineLabelDate() let formatter = DateFormatter() - if calendar.isDate(date, inSameDayAs: timelineToday) { + if calendar.isDate(labelDate, inSameDayAs: timelineToday) { formatter.dateFormat = "'Today,' MMM d" } else { formatter.dateFormat = "EEEE, MMM d" } - return formatter.string(from: date) + return formatter.string(from: labelDate) + } + + private static func formattedLabelDay(_ labelDate: Date, now: Date) -> String { + let calendar = Calendar.current + let today = timelineDisplayDate(from: now, now: now).timelineLabelDate() + let formatter = DateFormatter() + formatter.dateFormat = calendar.isDate(labelDate, inSameDayAs: today) + ? "'Today,' MMM d" : "EEEE, MMM d" + return formatter.string(from: labelDate) } private static func textEntry(for card: TimelineCard, index: Int) -> String { diff --git a/Dayflow/Dayflow/Views/UI/DailyView+Standup.swift b/Dayflow/Dayflow/Views/UI/DailyView+Standup.swift index 4cba96fab..48691c177 100644 --- a/Dayflow/Dayflow/Views/UI/DailyView+Standup.swift +++ b/Dayflow/Dayflow/Views/UI/DailyView+Standup.swift @@ -744,6 +744,7 @@ extension DailyView { func standupDayLabelText(for date: Date) -> String { let calendar = Calendar.current let displayDate = normalizedTimelineDate(date) + let labelDate = displayDate.timelineLabelDate() let timelineToday = timelineDisplayDate(from: Date()) if calendar.isDate(displayDate, inSameDayAs: timelineToday) { @@ -752,7 +753,7 @@ extension DailyView { guard let timelineYesterday = calendar.date(byAdding: .day, value: -1, to: timelineToday) else { - return dailyOtherDayDisplayFormatter.string(from: displayDate) + return dailyOtherDayDisplayFormatter.string(from: labelDate) } if calendar.isDate(displayDate, inSameDayAs: timelineYesterday) { @@ -761,9 +762,9 @@ extension DailyView { let daysAgo = calendar.dateComponents([.day], from: displayDate, to: timelineToday).day ?? 99 if (2...6).contains(daysAgo) { - return "Last \(dailyStandupWeekdayFormatter.string(from: displayDate))" + return "Last \(dailyStandupWeekdayFormatter.string(from: labelDate))" } - return dailyOtherDayDisplayFormatter.string(from: displayDate) + return dailyOtherDayDisplayFormatter.string(from: labelDate) } } diff --git a/Dayflow/Dayflow/Views/UI/DailyView+Workflow.swift b/Dayflow/Dayflow/Views/UI/DailyView+Workflow.swift index f440a69ce..6bafef32f 100644 --- a/Dayflow/Dayflow/Views/UI/DailyView+Workflow.swift +++ b/Dayflow/Dayflow/Views/UI/DailyView+Workflow.swift @@ -122,7 +122,7 @@ extension DailyView { } else if isYesterdaySelection(selectedDate) { headingText = "Your workflow yesterday" } else { - let displayDate = timelineDisplayDate(from: selectedDate) + let displayDate = timelineDisplayDate(from: selectedDate).timelineLabelDate() headingText = "Your workflow on \(dailyStandupSectionDayFormatter.string(from: displayDate))" } @@ -389,8 +389,8 @@ extension DailyView { selectedDate = normalizedTimelineDate(shifted) } func dailyDateTitle(for date: Date) -> String { - let displayDate = timelineDisplayDate(from: date) - let timelineToday = timelineDisplayDate(from: Date()) + let displayDate = timelineDisplayDate(from: date).timelineLabelDate() + let timelineToday = timelineDisplayDate(from: Date()).timelineLabelDate() if Calendar.current.isDate(displayDate, inSameDayAs: timelineToday) { return dailyTodayDisplayFormatter.string(from: displayDate) } @@ -404,7 +404,7 @@ extension DailyView { return "Yesterday's total" } - let displayDate = timelineDisplayDate(from: date) + let displayDate = timelineDisplayDate(from: date).timelineLabelDate() return "Total for \(dailyStandupSectionDayFormatter.string(from: displayDate))" } func formatDuration(minutes: Double) -> String { diff --git a/Dayflow/Dayflow/Views/UI/DailyView.swift b/Dayflow/Dayflow/Views/UI/DailyView.swift index 307ed0485..d74fe698a 100644 --- a/Dayflow/Dayflow/Views/UI/DailyView.swift +++ b/Dayflow/Dayflow/Views/UI/DailyView.swift @@ -77,6 +77,9 @@ struct DailyView: View { { _ in checkNotificationAuthorizationForUnlock() } + .onReceive(NotificationCenter.default.publisher(for: .dayFramingChanged)) { _ in + refreshWorkflowData() + } .onChange(of: isUnlocked) { _, newValue in guard !newValue else { return } accessFlowStep = .intro diff --git a/Dayflow/Dayflow/Views/UI/MainView/Actions.swift b/Dayflow/Dayflow/Views/UI/MainView/Actions.swift index 413ba4072..7a4d2d152 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Actions.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Actions.swift @@ -79,6 +79,7 @@ extension MainView { func loadWeeklyTrackedMinutes(trigger: String = "unspecified") { let requestedWeekRange = timelineWeekRange + let storageRange = requestedWeekRange.storageRange let requestedWeekStartDay = dayString(requestedWeekRange.weekStart) timelinePerfLog( "weeklyTrackedMinutes.schedule trigger=\(trigger) week=\(requestedWeekStartDay)" @@ -87,8 +88,8 @@ extension MainView { Task.detached(priority: .userInitiated) { let fetchStart = CFAbsoluteTimeGetCurrent() let minutes = StorageManager.shared.fetchTotalMinutesTracked( - from: requestedWeekRange.weekStart, - to: requestedWeekRange.weekEnd + from: storageRange.start, + to: storageRange.end ) let fetchMs = Int((CFAbsoluteTimeGetCurrent() - fetchStart) * 1000) @@ -193,9 +194,10 @@ extension MainView { case .week: let weekRange = timelineWeekRange + let storageRange = weekRange.storageRange let cards = StorageManager.shared.fetchTimelineCardsByTimeRange( - from: weekRange.weekStart, - to: weekRange.weekEnd + from: storageRange.start, + to: storageRange.end ) clipboardText = TimelineClipboardFormatter.makeClipboardText( for: weekRange, diff --git a/Dayflow/Dayflow/Views/UI/MainView/DateNavigationControls.swift b/Dayflow/Dayflow/Views/UI/MainView/DateNavigationControls.swift index 25d8a0fb5..438141fc6 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/DateNavigationControls.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/DateNavigationControls.swift @@ -86,8 +86,8 @@ struct DateNavigationControls: View { let now = Date() let calendar = Calendar.current - let displayDate = timelineDisplayDate(from: date, now: now) - let timelineToday = timelineDisplayDate(from: now, now: now) + let displayDate = timelineDisplayDate(from: date, now: now).timelineLabelDate() + let timelineToday = timelineDisplayDate(from: now, now: now).timelineLabelDate() if calendar.isDate(displayDate, inSameDayAs: timelineToday) { return cachedTodayDisplayFormatter.string(from: displayDate) diff --git a/Dayflow/Dayflow/Views/UI/MainView/Layout+Panels.swift b/Dayflow/Dayflow/Views/UI/MainView/Layout+Panels.swift index 24647b8cd..6aa2426ea 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Layout+Panels.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Layout+Panels.swift @@ -216,6 +216,10 @@ extension MainView { hasAnyActivities: $hasAnyActivities, refreshTrigger: $refreshActivitiesTrigger, weekRange: timelineWeekRange, + onSelectDay: { date in + setSelectedDate(date) + setTimelineMode(.day) + }, onSelectActivity: selectTimelineActivity, onClearSelection: { clearTimelineSelection() }, weeklyHoursFrame: weeklyHoursFrame, @@ -234,6 +238,7 @@ extension MainView { .environmentObject(categoryStore) .opacity(contentOpacity) .animation(timelineModeContentAnimation, value: timelineMode) + .id(dayFramingID) } .frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } diff --git a/Dayflow/Dayflow/Views/UI/MainView/Layout+TimelineHeader.swift b/Dayflow/Dayflow/Views/UI/MainView/Layout+TimelineHeader.swift index 15c9ced32..918e2ae13 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Layout+TimelineHeader.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Layout+TimelineHeader.swift @@ -371,13 +371,14 @@ extension MainView { TimelineCalendarPopover( isPresented: $showTimelineCalendarPopover, - selectedDate: selectedDate, + selectedDate: timelineDisplayDate(from: selectedDate).timelineLabelDate(), canSelectFutureDates: false, highlightsSelectedWeek: timelineMode == .week, onSelect: { date in - let tappedDay = dayString(date) + let timelineDate = date.timelineDateFromLabel() + let tappedDay = dayString(timelineDate) let currentDay = dayString(selectedDate) - let tappedWeek = TimelineWeekRange.containing(date) + let tappedWeek = TimelineWeekRange.containingLabelDate(date) let currentWeek = timelineWeekRange let weekChanged = tappedWeek != currentWeek @@ -388,7 +389,7 @@ extension MainView { timelinePerfLog( "calendarPopover.navigateDispatch tapped=\(tappedDay) mode=\(timelineMode.rawValue) weekChanged=\(weekChanged)" ) - navigateTimeline(to: date, method: "picker") + navigateTimeline(to: timelineDate, method: "picker") DispatchQueue.main.async { closeTimelineCalendarPopover() } @@ -493,6 +494,7 @@ extension MainView { private var timelineHeaderDateLabel: some View { Text(timelineTitleText) + .id(dayFramingID) .font(.custom("InstrumentSerif-Regular", size: 26)) .foregroundColor(Color.black) .lineLimit(1) diff --git a/Dayflow/Dayflow/Views/UI/MainView/Layout.swift b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift index e61f5b071..ca6cff6d3 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Layout.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift @@ -39,10 +39,10 @@ extension MainView { .sheet(isPresented: $showDatePicker) { DatePickerSheet( selectedDate: Binding( - get: { selectedDate }, + get: { timelineDisplayDate(from: selectedDate).timelineLabelDate() }, set: { lastDateNavMethod = "picker" - setSelectedDate($0) + setSelectedDate($0.timelineDateFromLabel()) } ), isPresented: $showDatePicker @@ -97,6 +97,11 @@ extension MainView { .onReceive(NotificationCenter.default.publisher(for: .timelineDataUpdated)) { handleTimelineDataUpdatedNotification($0) } + .onReceive(NotificationCenter.default.publisher(for: .dayFramingChanged)) { _ in + cachedTimelineWeekRange = TimelineWeekRange.containingLabelDate( + cachedTimelineWeekRange.weekStart) + refreshActivitiesTrigger &+= 1 + } .onReceive( NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) ) { _ in @@ -285,10 +290,16 @@ extension MainView { private func handleTimelineDataUpdatedNotification(_ notification: Notification) { guard selectedIcon == .timeline else { return } if let refreshedDay = notification.userInfo?["dayString"] as? String { - let selectedTimelineDay = DateFormatter.yyyyMMdd.string( - from: timelineDisplayDate(from: selectedDate, now: Date()) - ) - guard refreshedDay == selectedTimelineDay else { return } + switch timelineMode { + case .day: + let selectedTimelineDay = DateFormatter.yyyyMMdd.string( + from: timelineDisplayDate(from: selectedDate, now: Date()) + ) + guard refreshedDay == selectedTimelineDay else { return } + case .week: + guard timelineWeekRange.days.contains(where: { $0.storageDayString == refreshedDay }) + else { return } + } } updateCardsToReviewCount() loadWeeklyTrackedMinutes() diff --git a/Dayflow/Dayflow/Views/UI/MainView/MainView.swift b/Dayflow/Dayflow/Views/UI/MainView/MainView.swift index c69795c56..bd41e5153 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/MainView.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/MainView.swift @@ -33,6 +33,13 @@ struct MainView: View { @State var scrollToNowTick: Int = 0 @State var hasAnyActivities: Bool = true @State var refreshActivitiesTrigger: Int = 0 + // Observe the day-framing settings directly so the timeline re-renders even + // when the change is made from the Settings tab (where the timeline isn't the + // active view). Combined into `dayFramingID` and used as an `.id()` to force + // the header/labels/columns to rebuild consistently. + @AppStorage("dayLabelMode") private var dayLabelModeSetting = "startDate" + @AppStorage("dayBoundaryHour") private var dayBoundaryHourSetting = 4 + var dayFramingID: String { "\(dayLabelModeSetting)|\(dayBoundaryHourSetting)" } @ObservedObject var inactivity = InactivityMonitor.shared @ObservedObject var pauseManager = PauseManager.shared diff --git a/Dayflow/Dayflow/Views/UI/MainView/Support.swift b/Dayflow/Dayflow/Views/UI/MainView/Support.swift index 03f8211c6..967c8ca7a 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Support.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Support.swift @@ -194,8 +194,8 @@ extension MainView { let now = Date() let calendar = Calendar.current - let displayDate = timelineDisplayDate(from: date, now: now) - let timelineToday = timelineDisplayDate(from: now, now: now) + let displayDate = timelineDisplayDate(from: date, now: now).timelineLabelDate() + let timelineToday = timelineDisplayDate(from: now, now: now).timelineLabelDate() if calendar.isDate(displayDate, inSameDayAs: timelineToday) { return cachedTodayDisplayFormatter.string(from: displayDate) diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift index 3d0038e8d..21b085005 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift @@ -35,8 +35,9 @@ enum TimelineActivityLoader { in weekRange: TimelineWeekRange, storageManager: StorageManaging = StorageManager.shared ) -> [TimelineActivity] { + let storageRange = weekRange.storageRange let cards = storageManager.fetchTimelineCardsByTimeRange( - from: weekRange.weekStart, to: weekRange.weekEnd) + from: storageRange.start, to: storageRange.end) return buildActivities(from: displayableCards(cards, storageManager: storageManager)) } diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineCalendarPopover.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineCalendarPopover.swift index 6bdee340d..5cb58317c 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/TimelineCalendarPopover.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/TimelineCalendarPopover.swift @@ -194,7 +194,8 @@ struct TimelineCalendarPopover: View { let showsSelectedDayCircle = isSelected && !highlightsSelectedWeek let isDisabled: Bool = { guard !canSelectFutureDates else { return false } - let todayStart = calendar.startOfDay(for: Date()) + let todayStart = calendar.startOfDay( + for: timelineDisplayDate(from: Date()).timelineLabelDate()) let cellStart = calendar.startOfDay(for: day.date) return cellStart > todayStart }() diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineShellModels.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineShellModels.swift index 5ae65f783..4bd6b80f9 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/TimelineShellModels.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/TimelineShellModels.swift @@ -19,6 +19,7 @@ enum TimelineMode: String, CaseIterable, Identifiable { struct TimelineWeekDay: Identifiable, Equatable, Sendable { let date: Date let dayString: String + let storageDayString: String let weekdayLabel: String let dayNumber: String @@ -71,6 +72,7 @@ struct TimelineWeekRange: Equatable, Sendable { return TimelineWeekDay( date: normalizedDate, dayString: DateFormatter.yyyyMMdd.string(from: normalizedDate), + storageDayString: normalizedDate.timelineStorageDayStringForLabel, weekdayLabel: Self.weekdayFormatter.string(from: normalizedDate), dayNumber: Self.dayNumberFormatter.string(from: normalizedDate) ) @@ -79,7 +81,13 @@ struct TimelineWeekRange: Equatable, Sendable { static func containing(_ date: Date, calendar: Calendar = Self.calendar) -> TimelineWeekRange { let timelineDate = timelineDisplayDate(from: date, now: date) - let anchorDay = calendar.startOfDay(for: timelineDate) + return containingLabelDate(timelineDate.timelineLabelDate(), calendar: calendar) + } + + static func containingLabelDate( + _ date: Date, calendar: Calendar = Self.calendar + ) -> TimelineWeekRange { + let anchorDay = calendar.startOfDay(for: date) let weekday = calendar.component(.weekday, from: anchorDay) let daysFromWeekStart = (weekday - calendar.firstWeekday + 7) % 7 let weekStartDay = @@ -91,6 +99,13 @@ struct TimelineWeekRange: Equatable, Sendable { return TimelineWeekRange(weekStart: weekStart, weekEnd: weekEnd) } + /// Time range whose storage-day keys populate this fixed natural week. + var storageRange: (start: Date, end: Date) { + let start = normalizedTimelineDate(weekStart.timelineDateFromLabel()) + let end = Self.calendar.date(byAdding: .day, value: 7, to: start) ?? start + return (start, end) + } + func shifted(byWeeks weeks: Int, calendar: Calendar = Self.calendar) -> TimelineWeekRange { let shiftedStart = calendar.date(byAdding: .day, value: weeks * 7, to: weekStart) ?? weekStart let shiftedEnd = calendar.date(byAdding: .day, value: 7, to: shiftedStart) ?? shiftedStart @@ -112,8 +127,10 @@ struct TimelineWeekRange: Equatable, Sendable { } func contains(_ date: Date) -> Bool { - let timelineDate = timelineDisplayDate(from: date, now: date) - let dayStart = timelineDate.getDayInfoFor4AMBoundary().startOfDay - return dayStart >= weekStart && dayStart < weekEnd + let labelDate = timelineDisplayDate(from: date, now: date).timelineLabelDate() + let labelDay = Self.calendar.startOfDay(for: labelDate) + let visibleStart = Self.calendar.startOfDay(for: weekStart) + let visibleEnd = Self.calendar.startOfDay(for: weekEnd) + return labelDay >= visibleStart && labelDay < visibleEnd } } diff --git a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift index 046ebd082..876e81b67 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift @@ -32,6 +32,7 @@ private struct WeekTimelineHoverPrototypeHarness: View { hasAnyActivities: $hasAny, refreshTrigger: $refresh, weekRange: Self.weekRange, + onSelectDay: { selectedDate = $0 }, onSelectActivity: { selectedActivity = $0 }, onClearSelection: { selectedActivity = nil }, previewPositionedActivities: Self.mockActivities() diff --git a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridView.swift b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridView.swift index 4969b32ba..3181dee9c 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridView.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridView.swift @@ -65,6 +65,7 @@ struct WeekTimelineGridView: View { @Binding var refreshTrigger: Int let weekRange: TimelineWeekRange + let onSelectDay: (Date) -> Void let onSelectActivity: (TimelineActivity) -> Void let onClearSelection: () -> Void @@ -110,11 +111,13 @@ struct WeekTimelineGridView: View { } private var todayDayString: String { - DateFormatter.yyyyMMdd.string(from: timelineDisplayDate(from: Date())) + DateFormatter.yyyyMMdd.string( + from: timelineDisplayDate(from: Date()).timelineLabelDate()) } private var selectedDayString: String { - DateFormatter.yyyyMMdd.string(from: timelineDisplayDate(from: selectedDate)) + DateFormatter.yyyyMMdd.string( + from: timelineDisplayDate(from: selectedDate).timelineLabelDate()) } private var weekAutoScrollKey: String { @@ -252,14 +255,23 @@ struct WeekTimelineGridView: View { .frame(width: WeekTimelineConfig.timeColumnWidth) ForEach(weekRange.days) { day in - HStack(spacing: WeekTimelineConfig.weekdayInlineSpacing) { - Text(day.weekdayLabel) - .font(.custom("Figtree", size: 12).weight(.medium)) - .foregroundColor(Color(hex: "333333")) - - dayNumberBadge(for: day) + let isFuture = day.date > timelineDisplayDate(from: Date()).timelineLabelDate() + Button { + onSelectDay(day.date.timelineDateFromLabel()) + } label: { + HStack(spacing: WeekTimelineConfig.weekdayInlineSpacing) { + Text(day.weekdayLabel) + .font(.custom("Figtree", size: 12).weight(.medium)) + .foregroundColor(Color(hex: "333333")) + + dayNumberBadge(for: day) + } + .frame(width: dayWidth, alignment: .center) } - .frame(width: dayWidth, alignment: .center) + .buttonStyle(.plain) + .disabled(isFuture) + .opacity(isFuture ? 0.45 : 1) + .pointingHandCursor(enabled: !isFuture) } } } @@ -533,7 +545,7 @@ struct WeekTimelineGridView: View { let fetchMs = Int((CFAbsoluteTimeGetCurrent() - fetchStart) * 1000) let weekDays = requestedWeekRange.days let dayLookup = Dictionary( - uniqueKeysWithValues: weekDays.enumerated().map { ($1.dayString, $0) }) + uniqueKeysWithValues: weekDays.enumerated().map { ($1.storageDayString, $0) }) let positioningStart = CFAbsoluteTimeGetCurrent() var positioned: [WeekPositionedActivity] = [] @@ -541,7 +553,7 @@ struct WeekTimelineGridView: View { for day in weekDays { let dayActivities = activities.filter { - $0.startTime.getDayInfoFor4AMBoundary().dayString == day.dayString + $0.startTime.getDayInfoFor4AMBoundary().dayString == day.storageDayString } let segments = TimelineActivityLoader.resolveDisplaySegments(from: dayActivities) @@ -556,7 +568,7 @@ struct WeekTimelineGridView: View { WeekPositionedActivity( id: segment.activity.id, activity: segment.activity, - columnIndex: dayLookup[day.dayString] ?? 0, + columnIndex: dayLookup[day.storageDayString] ?? 0, yPosition: calculateYPosition(for: segment.start) + 1, height: height, durationMinutes: durationMinutes, diff --git a/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift index de4d2ec74..d746afce1 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift @@ -40,6 +40,14 @@ final class OtherSettingsViewModel: ObservableObject { didSet { guard dayBoundaryHour != oldValue else { return } DayBoundaryPreferences.boundaryHour = dayBoundaryHour + NotificationCenter.default.post(name: .dayFramingChanged, object: nil) + } + } + @Published var dayLabelMode: DayLabelMode { + didSet { + guard dayLabelMode != oldValue else { return } + DayLabelPreferences.mode = dayLabelMode + NotificationCenter.default.post(name: .dayFramingChanged, object: nil) } } @Published var outputLanguageOverride: String @@ -64,6 +72,7 @@ final class OtherSettingsViewModel: ObservableObject { showDailyGoalPopups = DayGoalPreferences.showDailyGoalPopups saveAllTimelapsesToDisk = TimelapsePreferences.saveAllTimelapsesToDisk dayBoundaryHour = DayBoundaryPreferences.boundaryHour + dayLabelMode = DayLabelPreferences.mode outputLanguageOverride = LLMOutputLanguagePreferences.override exportStartDate = timelineDisplayDate(from: Date()) exportEndDate = timelineDisplayDate(from: Date()) diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsDataTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsDataTabView.swift index 5d268ced4..8eb62e959 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsDataTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsDataTabView.swift @@ -138,7 +138,7 @@ struct SettingsDataTabView: View { if isReprocessDatePickerExpanded { inlineCalendar( - date: $viewModel.reprocessDayDate, + date: timelineLabelDateBinding($viewModel.reprocessDayDate), disabled: viewModel.isReprocessingDay, onDateSelected: { withAnimation(.easeOut(duration: 0.2)) { @@ -268,16 +268,23 @@ struct SettingsDataTabView: View { // MARK: - Helpers private func formattedTimelineDate(_ date: Date) -> String { - Self.dateLabelFormatter.string(from: timelineDisplayDate(from: date)) + Self.dateLabelFormatter.string(from: timelineDisplayDate(from: date).timelineLabelDate()) } private func exportDateBinding(for picker: ExportDatePicker) -> Binding { switch picker { - case .start: return $viewModel.exportStartDate - case .end: return $viewModel.exportEndDate + case .start: return timelineLabelDateBinding($viewModel.exportStartDate) + case .end: return timelineLabelDateBinding($viewModel.exportEndDate) } } + private func timelineLabelDateBinding(_ storageDate: Binding) -> Binding { + Binding( + get: { timelineDisplayDate(from: storageDate.wrappedValue).timelineLabelDate() }, + set: { storageDate.wrappedValue = normalizedTimelineDate($0.timelineDateFromLabel()) } + ) + } + private static let dateLabelFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift index 986a8a911..98e35f79d 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift @@ -74,6 +74,20 @@ struct SettingsOtherTabView: View { .frame(width: 110) } + SettingsRow( + label: "Day label", + subtitle: + "For a day that crosses midnight, name it after the date it starts on or the date it ends on. Display only — your data isn't affected." + ) { + Picker("", selection: $viewModel.dayLabelMode) { + Text("Start date").tag(DayLabelMode.startDate) + Text("End date").tag(DayLabelMode.endDate) + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 130) + } + SettingsRow( label: "Save all timelapses to disk", subtitle: diff --git a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift index 0a1881118..1a7d2076a 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift @@ -103,7 +103,7 @@ struct DatePickerSheet: View { DatePicker( "", selection: $selectedDate, - in: ...Date(), // Only allow past dates and today + in: ...timelineDisplayDate(from: Date()).timelineLabelDate(), displayedComponents: .date ) .datePickerStyle(.graphical) diff --git a/Dayflow/Dayflow/Views/UI/Weekly/WeeklyView.swift b/Dayflow/Dayflow/Views/UI/Weekly/WeeklyView.swift index 506614e29..19943788a 100644 --- a/Dayflow/Dayflow/Views/UI/Weekly/WeeklyView.swift +++ b/Dayflow/Dayflow/Views/UI/Weekly/WeeklyView.swift @@ -74,6 +74,12 @@ struct WeeklyView: View { .onReceive(Timer.publish(every: 30, on: .main, in: .common).autoconnect()) { _ in refreshWeeklyAccessState() } + .onReceive(NotificationCenter.default.publisher(for: .dayFramingChanged)) { _ in + weekRange = WeeklyDateRange.containingLabelDate(weekRange.weekStart) + Task { + await loadWeeklyData(for: weekRange, categories: categoryStore.categories) + } + } .onChange(of: scenePhase) { _, phase in guard phase == .active else { return } refreshWeeklyAccessState() @@ -283,10 +289,11 @@ struct WeeklyView: View { var range = WeeklyDateRange.containing(Date()) for _ in 0.. Date { + var components = DateComponents() + components.calendar = Calendar.current + components.timeZone = Calendar.current.timeZone + components.year = year + components.month = month + components.day = day + components.hour = hour + return components.date! + } +} From dfdce4abcdc62e93bc7e4e8b2b08be396aff5cbc Mon Sep 17 00:00:00 2001 From: rmqg Date: Fri, 10 Jul 2026 00:38:50 -0400 Subject: [PATCH 05/10] Capture all connected displays --- .../Core/Recording/ScreenRecorder.swift | 149 +++++++++++++----- .../ScreenCaptureCompositeLayoutTests.swift | 35 ++++ 2 files changed, 144 insertions(+), 40 deletions(-) create mode 100644 Dayflow/DayflowTests/ScreenCaptureCompositeLayoutTests.swift diff --git a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift index 292ea8cf9..1d8823be7 100644 --- a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift +++ b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift @@ -52,6 +52,37 @@ private enum InputIdleSnapshot { } } +struct ScreenCaptureCompositeLayout: Equatable { + let canvasSize: CGSize + let destinationRects: [CGRect] + + init?(displayFrames: [CGRect], targetDisplayHeight: CGFloat) { + guard !displayFrames.isEmpty, targetDisplayHeight > 0 else { return nil } + let desktopBounds = displayFrames.dropFirst().reduce(displayFrames[0]) { $0.union($1) } + let tallestDisplay = displayFrames.map(\.height).max() ?? 0 + guard desktopBounds.width > 0, desktopBounds.height > 0, tallestDisplay > 0 else { return nil } + + let scale = targetDisplayHeight / tallestDisplay + canvasSize = CGSize( + width: Self.evenPixelDimension(desktopBounds.width * scale), + height: Self.evenPixelDimension(desktopBounds.height * scale) + ) + destinationRects = displayFrames.map { frame in + CGRect( + x: (frame.minX - desktopBounds.minX) * scale, + y: (desktopBounds.maxY - frame.maxY) * scale, + width: frame.width * scale, + height: frame.height * scale + ).integral + } + } + + private static func evenPixelDimension(_ value: CGFloat) -> CGFloat { + let roundedUp = Int(ceil(value)) + return CGFloat(roundedUp.isMultiple(of: 2) ? roundedUp : roundedUp + 1) + } +} + // MARK: - Debug Logging private let recorderDebugLogging = false @@ -339,10 +370,6 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { dbg("captureScreenshot skipped - state: \(state.description)") return } - guard let display = cachedDisplay else { - dbg("captureScreenshot skipped - no display") - return - } guard ScreenRecordingPermissionNotice.isGranted else { handleMissingScreenRecordingPermission(reason: "captureScreenshot") return @@ -352,14 +379,33 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { let idleSecondsAtCapture = InputIdleSnapshot.currentIdleSeconds() do { - let captureSize = scaledCaptureSize(for: display) + // Display topology can change while recording. Refresh on every cycle so + // a newly attached/removed display is reflected in the next saved frame. + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: true) + guard !content.displays.isEmpty else { throw ScreenRecorderError.noDisplay } + cachedContent = content + + let displays = content.displays.sorted { lhs, rhs in + if lhs.frame.minY == rhs.frame.minY { return lhs.frame.minX < rhs.frame.minX } + return lhs.frame.minY < rhs.frame.minY + } + guard + let layout = ScreenCaptureCompositeLayout( + displayFrames: displays.map(\.frame), + targetDisplayHeight: Config.targetHeight + ) + else { + throw ScreenRecorderError.noDisplay + } + if let blockedApplication = await MainActor.run(body: { RecordingPrivacyPreferences.frontmostBlockedApplication() }) { guard let jpegData = await MainActor.run(body: { RecordingPrivacyPlaceholder.jpegData( - size: CGSize(width: captureSize.width, height: captureSize.height), + size: layout.canvasSize, quality: Config.jpegQuality, applicationName: blockedApplication.name ) @@ -376,40 +422,40 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { return } - // 1. Create content filter for the display let excludedApplications = - cachedContent.map { - RecordingPrivacyPreferences.blockedScreenCaptureApplications(in: $0) - } ?? [] - let filter = - excludedApplications.isEmpty - ? SCContentFilter(display: display, excludingWindows: []) - : SCContentFilter( - display: display, - excludingApplications: excludedApplications, - exceptingWindows: [] - ) - - // 2. Configure screenshot - let config = SCStreamConfiguration() - - config.width = captureSize.width - config.height = captureSize.height - config.scalesToFit = true - config.showsCursor = true + RecordingPrivacyPreferences.blockedScreenCaptureApplications(in: content) + var capturedDisplays: [CGImage] = [] + capturedDisplays.reserveCapacity(displays.count) + + for (display, destinationRect) in zip(displays, layout.destinationRects) { + let filter = + excludedApplications.isEmpty + ? SCContentFilter(display: display, excludingWindows: []) + : SCContentFilter( + display: display, + excludingApplications: excludedApplications, + exceptingWindows: [] + ) + let config = SCStreamConfiguration() + config.width = max(2, Int(destinationRect.width.rounded())) + config.height = max(2, Int(destinationRect.height.rounded())) + config.scalesToFit = true + config.showsCursor = true + capturedDisplays.append( + try await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: config + )) + } - // 3. Capture screenshot - let image = try await SCScreenshotManager.captureImage( - contentFilter: filter, - configuration: config - ) + guard let image = compositeImage(capturedDisplays, layout: layout) else { + throw ScreenRecorderError.imageConversionFailed + } - // 4. Convert to JPEG guard let jpegData = jpegData(from: image, quality: Config.jpegQuality) else { throw ScreenRecorderError.imageConversionFailed } - // 5. Save to disk and register in the database let fileURL = try saveScreenshotData( jpegData, capturedAt: captureTime, @@ -434,13 +480,36 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { } } - private func scaledCaptureSize(for display: SCDisplay) -> (width: Int, height: Int) { - let aspectRatio = Double(display.width) / Double(display.height) - var targetWidth = Int(Double(Config.targetHeight) * aspectRatio) - if targetWidth % 2 != 0 { targetWidth += 1 } - var targetHeight = Int(Config.targetHeight) - if targetHeight % 2 != 0 { targetHeight += 1 } - return (targetWidth, targetHeight) + private func compositeImage( + _ images: [CGImage], + layout: ScreenCaptureCompositeLayout + ) -> CGImage? { + guard images.count == layout.destinationRects.count else { return nil } + if images.count == 1 { return images[0] } + + let width = Int(layout.canvasSize.width) + let height = Int(layout.canvasSize.height) + guard + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + return nil + } + + context.setFillColor(CGColor(gray: 0, alpha: 1)) + context.fill(CGRect(origin: .zero, size: layout.canvasSize)) + for (image, destinationRect) in zip(images, layout.destinationRects) { + context.draw(image, in: destinationRect) + } + return context.makeImage() } private func saveScreenshotData( diff --git a/Dayflow/DayflowTests/ScreenCaptureCompositeLayoutTests.swift b/Dayflow/DayflowTests/ScreenCaptureCompositeLayoutTests.swift new file mode 100644 index 000000000..1b71c3c72 --- /dev/null +++ b/Dayflow/DayflowTests/ScreenCaptureCompositeLayoutTests.swift @@ -0,0 +1,35 @@ +import XCTest + +@testable import Dayflow + +final class ScreenCaptureCompositeLayoutTests: XCTestCase { + func testSideBySideDisplaysPreserveNegativeCoordinates() throws { + let layout = try XCTUnwrap( + ScreenCaptureCompositeLayout( + displayFrames: [ + CGRect(x: -1920, y: 0, width: 1920, height: 1080), + CGRect(x: 0, y: 0, width: 2560, height: 1440), + ], + targetDisplayHeight: 1080 + )) + + XCTAssertEqual(layout.canvasSize, CGSize(width: 3360, height: 1080)) + XCTAssertEqual(layout.destinationRects[0], CGRect(x: 0, y: 270, width: 1440, height: 810)) + XCTAssertEqual(layout.destinationRects[1], CGRect(x: 1440, y: 0, width: 1920, height: 1080)) + } + + func testVerticallyStackedDisplaysIncreaseCanvasHeight() throws { + let layout = try XCTUnwrap( + ScreenCaptureCompositeLayout( + displayFrames: [ + CGRect(x: 0, y: -1080, width: 1920, height: 1080), + CGRect(x: 0, y: 0, width: 1920, height: 1080), + ], + targetDisplayHeight: 1080 + )) + + XCTAssertEqual(layout.canvasSize, CGSize(width: 1920, height: 2160)) + XCTAssertEqual(layout.destinationRects[0], CGRect(x: 0, y: 1080, width: 1920, height: 1080)) + XCTAssertEqual(layout.destinationRects[1], CGRect(x: 0, y: 0, width: 1920, height: 1080)) + } +} From 4f258c866b9a86dade18aa550d80eb8d94ae954d Mon Sep 17 00:00:00 2001 From: rmqg Date: Fri, 10 Jul 2026 00:38:59 -0400 Subject: [PATCH 06/10] Request screen capture access after resets --- Dayflow/Dayflow/App/AppDelegate.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dayflow/Dayflow/App/AppDelegate.swift b/Dayflow/Dayflow/App/AppDelegate.swift index 460154c86..ee855c0ce 100644 --- a/Dayflow/Dayflow/App/AppDelegate.swift +++ b/Dayflow/Dayflow/App/AppDelegate.swift @@ -97,7 +97,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Try to start recording, but handle permission failures gracefully Task { [weak self] in guard let self else { return } - guard ScreenRecordingPermissionNotice.isGranted else { + let hasScreenRecordingPermission = + ScreenRecordingPermissionNotice.isGranted || CGRequestScreenCaptureAccess() + guard hasScreenRecordingPermission else { await MainActor.run { AppState.shared.setRecording( false, From 7e6a0fae88a5587f5959da9590a189d86b359084 Mon Sep 17 00:00:00 2001 From: rmqg Date: Fri, 10 Jul 2026 00:39:07 -0400 Subject: [PATCH 07/10] Add reproducible macOS development workflow --- README.md | 13 +++++ scripts/dev.sh | 143 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100755 scripts/dev.sh diff --git a/README.md b/README.md index 3a36bb7ea..afe389a93 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,19 @@ open Dayflow/Dayflow.xcodeproj Select the Dayflow scheme in Xcode and run it. +For a reproducible command-line development setup: + +```bash +./scripts/dev.sh doctor +./scripts/dev.sh test +./scripts/dev.sh install +``` + +The development script keeps DerivedData under `build/dev`, uses a stable local +code-signing requirement, and replaces `/Applications/Dayflow.app`. The first +install after changing signing identities resets the stale Screen & System Audio +Recording approval; subsequent local installs preserve the same app identity. + ## Contributing Issues and pull requests are welcome. If you are planning a larger change, open an issue first so the scope is clear. diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 000000000..c764739e4 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) + +APP_NAME=${APP_NAME:-Dayflow} +BUNDLE_ID=${BUNDLE_ID:-teleportlabs.com.Dayflow} +PROJECT=${PROJECT:-"$REPO_ROOT/Dayflow/Dayflow.xcodeproj"} +SCHEME=${SCHEME:-Dayflow} +CONFIG=${CONFIG:-Debug} +DERIVED_DATA=${DERIVED_DATA:-"$REPO_ROOT/build/dev"} +DESTINATION=${DESTINATION:-platform=macOS,arch=arm64} +INSTALL_PATH=${INSTALL_PATH:-"/Applications/$APP_NAME.app"} +APP_PATH="$DERIVED_DATA/Build/Products/$CONFIG/$APP_NAME.app" +STABLE_REQUIREMENT="=designated => identifier \"$BUNDLE_ID\"" + +usage() { + cat < + +Commands: + doctor Check the local Xcode development environment. + build Build a locally signed Debug app. + test Run the Dayflow unit tests. + install Build, replace /Applications/Dayflow.app, and launch it. + open Launch the installed app. + +Environment overrides: DERIVED_DATA, DESTINATION, INSTALL_PATH, CONFIG. +EOF +} + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "ERROR: Missing required command: $1" >&2 + exit 1 + } +} + +designated_requirement() { + local app=$1 + codesign -dr - "$app" 2>&1 | sed -n 's/^# designated => //p' +} + +sign_with_stable_local_identity() { + codesign --force \ + --sign - \ + --preserve-metadata=entitlements,flags,runtime \ + --requirements "$STABLE_REQUIREMENT" \ + "$APP_PATH" + codesign --verify --deep --strict "$APP_PATH" +} + +build_app() { + xcodebuild build \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIG" \ + -destination "$DESTINATION" \ + -derivedDataPath "$DERIVED_DATA" \ + CODE_SIGN_IDENTITY=- \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + DEVELOPMENT_TEAM= + + [[ -d "$APP_PATH" ]] || { + echo "ERROR: Built app not found at $APP_PATH" >&2 + exit 1 + } + sign_with_stable_local_identity +} + +run_tests() { + xcodebuild test \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIG" \ + -destination "platform=macOS" \ + -derivedDataPath "$DERIVED_DATA" \ + -only-testing:DayflowTests \ + CODE_SIGNING_ALLOWED=NO +} + +quit_installed_app() { + osascript -e "tell application id \"$BUNDLE_ID\" to quit" 2>/dev/null || true + for _ in {1..20}; do + pgrep -f "$INSTALL_PATH/Contents/MacOS/$APP_NAME" >/dev/null || return + sleep 0.25 + done + pkill -TERM -f "$INSTALL_PATH/Contents/MacOS/$APP_NAME" 2>/dev/null || true +} + +install_app() { + build_app + + local old_requirement="" + local new_requirement + if [[ -d "$INSTALL_PATH" ]]; then + old_requirement=$(designated_requirement "$INSTALL_PATH" || true) + fi + new_requirement=$(designated_requirement "$APP_PATH") + + quit_installed_app + rm -rf "$INSTALL_PATH" + ditto --noextattr --norsrc "$APP_PATH" "$INSTALL_PATH" + xattr -dr com.apple.quarantine "$INSTALL_PATH" 2>/dev/null || true + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -f "$INSTALL_PATH" + codesign --verify --deep --strict "$INSTALL_PATH" + + if [[ -n "$old_requirement" && "$old_requirement" != "$new_requirement" ]]; then + echo "Local signing identity changed; resetting stale screen-capture approval once." + tccutil reset ScreenCapture "$BUNDLE_ID" + fi + + open -a "$INSTALL_PATH" + echo "Installed and launched $INSTALL_PATH" +} + +doctor() { + require xcodebuild + require codesign + require ditto + require tccutil + [[ -d "$PROJECT" ]] || { + echo "ERROR: Xcode project not found at $PROJECT" >&2 + exit 1 + } + xcodebuild -version + echo "Project: $PROJECT" + echo "DerivedData: $DERIVED_DATA" + echo "Install path: $INSTALL_PATH" + echo "Bundle ID: $BUNDLE_ID" +} + +case "${1:-}" in + doctor) doctor ;; + build) build_app ;; + test) run_tests ;; + install) install_app ;; + open) open -a "$INSTALL_PATH" ;; + *) usage; exit 1 ;; +esac From aad80bf10031b8c2e1b9d6583c4cc474728459ab Mon Sep 17 00:00:00 2001 From: rmqg Date: Sat, 11 Jul 2026 03:23:35 -0400 Subject: [PATCH 08/10] Keep settings dates aligned with day labels --- .../Dayflow/Core/AI/ChatPromptBuilder.swift | 36 ++++++++++++++++--- .../Core/Recording/StorageDateHelpers.swift | 6 ++++ .../UI/Settings/OtherSettingsViewModel.swift | 12 +++++-- .../UI/Settings/SettingsDataTabView.swift | 6 ++-- Dayflow/DayflowTests/DayFramingTests.swift | 20 +++++++++++ 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/Dayflow/Dayflow/Core/AI/ChatPromptBuilder.swift b/Dayflow/Dayflow/Core/AI/ChatPromptBuilder.swift index 5a2d468e1..36d6d9cc5 100644 --- a/Dayflow/Dayflow/Core/AI/ChatPromptBuilder.swift +++ b/Dayflow/Dayflow/Core/AI/ChatPromptBuilder.swift @@ -30,8 +30,9 @@ private let chatServiceDayFormatter: DateFormatter = { enum ChatPromptBuilder { static func cliSystemPrompt() -> String { let now = Date() - let currentDate = chatServiceLongDateFormatter.string(from: now) + let currentDate = chatServiceLongDateFormatter.string(from: currentTimelineLabelDate(now)) let currentTime = chatServiceTimeFormatter.string(from: now) + let dayFraming = dayFramingPrompt() // Use full path (~ doesn't expand in sqlite3) let dbPath = NSHomeDirectory() + "/Library/Application Support/Dayflow/chunks.sqlite" @@ -44,7 +45,7 @@ enum ChatPromptBuilder { Current date: \(currentDate) Current time: \(currentTime) - Day boundary: Days start at 4:00 AM (not midnight) + \(dayFraming) ## DATA INTEGRITY (CRITICAL) @@ -208,8 +209,9 @@ enum ChatPromptBuilder { static func geminiSystemPrompt() -> String { let now = Date() - let currentDate = chatServiceLongDateFormatter.string(from: now) + let currentDate = chatServiceLongDateFormatter.string(from: currentTimelineLabelDate(now)) let currentTime = chatServiceTimeFormatter.string(from: now) + let dayFraming = dayFramingPrompt() let languageSection = languageOverrideSection() let languageBlock: String @@ -224,7 +226,7 @@ enum ChatPromptBuilder { Current date: \(currentDate) Current time: \(currentTime) - Day boundary: Days start at 4:00 AM (not midnight). "Yesterday" means the previous 4 AM–4 AM window. + \(dayFraming) --- @@ -350,8 +352,32 @@ enum ChatPromptBuilder { """ } + private static func currentTimelineLabelDate(_ now: Date) -> Date { + timelineDisplayDate(from: now, now: now).timelineLabelDate() + } + + private static func dayFramingPrompt() -> String { + let boundaryHour = DayBoundaryPreferences.boundaryHour + let boundaryTime = chatServiceTimeFormatter.string( + from: Calendar.current.date( + bySettingHour: boundaryHour, minute: 0, second: 0, of: Date()) ?? Date() + ) + let window = "\(boundaryTime)-to-\(boundaryTime)" + + if DayLabelPreferences.usesEndDate { + return """ + Day framing: Logical days run \(window) and are labeled by their END date. The database `day` column remains keyed by the START date, so subtract one calendar day from a user-facing date before filtering `day`. "Today" and "yesterday" refer to consecutive labeled logical-day windows. + """ + } + + return """ + Day framing: Logical days run \(window) and are labeled by their START date. The database `day` column uses the same date label. "Today" and "yesterday" refer to consecutive logical-day windows. + """ + } + private static func todayDate() -> String { - chatServiceDayFormatter.string(from: Date()) + let now = Date() + return chatServiceDayFormatter.string(from: timelineDisplayDate(from: now, now: now)) } private static func categoryColorsSection() -> String { diff --git a/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift b/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift index ae5c92380..c3b2e5b66 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageDateHelpers.swift @@ -93,6 +93,12 @@ extension Date { var timelineStorageDayStringForLabel: String { DateFormatter.yyyyMMdd.string(from: timelineDateFromLabel()) } + + /// Canonical user-facing day label. Call this on an internal timeline date; + /// storage keys must continue to use the unshifted date. + var timelineLabelDayString: String { + DateFormatter.yyyyMMdd.string(from: timelineLabelDate()) + } } extension Date { diff --git a/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift index d746afce1..67762851b 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift @@ -162,16 +162,20 @@ final class OtherSettingsViewModel: ObservableObject { let normalizedDate = timelineDisplayDate(from: reprocessDayDate) let dayString = DateFormatter.yyyyMMdd.string(from: normalizedDate) + let displayDayString = normalizedDate.timelineLabelDayString isReprocessingDay = true reprocessErrorMessage = nil - reprocessStatusMessage = "Starting reprocess for \(dayString)…" + reprocessStatusMessage = "Starting reprocess for \(displayDayString)…" AnalysisManager.shared.reprocessDay( dayString, progressHandler: { [weak self] message in Task { @MainActor in - self?.reprocessStatusMessage = message + self?.reprocessStatusMessage = message.replacingOccurrences( + of: dayString, + with: displayDayString + ) } }, completion: { [weak self] result in @@ -200,12 +204,14 @@ final class OtherSettingsViewModel: ObservableObject { ) { let dayFormatter = DateFormatter() dayFormatter.dateFormat = "yyyy-MM-dd" + let displayStartDate = timelineDisplayDate(from: startDate).timelineLabelDate() + let displayEndDate = timelineDisplayDate(from: endDate).timelineLabelDate() let savePanel = NSSavePanel() savePanel.title = "Export timeline" savePanel.prompt = "Export" savePanel.nameFieldStringValue = - "Dayflow timeline \(dayFormatter.string(from: startDate)) to \(dayFormatter.string(from: endDate)).md" + "Dayflow timeline \(dayFormatter.string(from: displayStartDate)) to \(dayFormatter.string(from: displayEndDate)).md" savePanel.allowedContentTypes = [.text, .plainText] savePanel.canCreateDirectories = true diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsDataTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsDataTabView.swift index 8eb62e959..da067aeb9 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsDataTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsDataTabView.swift @@ -115,7 +115,7 @@ struct SettingsDataTabView: View { private var reprocessSection: some View { let normalizedDate = timelineDisplayDate(from: viewModel.reprocessDayDate) - let dayString = DateFormatter.yyyyMMdd.string(from: normalizedDate) + let displayDayString = normalizedDate.timelineLabelDayString return SettingsSection( title: "Reprocess day", @@ -149,7 +149,7 @@ struct SettingsDataTabView: View { .transition(.move(edge: .top).combined(with: .opacity)) } - Text(dayString) + Text(displayDayString) .font(.custom("Figtree", size: 12)) .foregroundColor(SettingsStyle.meta) @@ -193,7 +193,7 @@ struct SettingsDataTabView: View { Button("Reprocess", role: .destructive) { viewModel.reprocessSelectedDay() } } message: { Text( - "This will delete existing timeline cards for \(dayString) and re-run analysis. It can consume many API calls." + "This will delete existing timeline cards for \(displayDayString) and re-run analysis. It can consume many API calls." ) } } diff --git a/Dayflow/DayflowTests/DayFramingTests.swift b/Dayflow/DayflowTests/DayFramingTests.swift index db69a0a76..9708acda6 100644 --- a/Dayflow/DayflowTests/DayFramingTests.swift +++ b/Dayflow/DayflowTests/DayFramingTests.swift @@ -77,6 +77,26 @@ final class DayFramingTests: XCTestCase { XCTAssertEqual(DateFormatter.yyyyMMdd.string(from: range.storageRange.start), "2026-07-06") } + func testUserFacingDayStringNeverLeaksEndDateStorageKey() { + DayBoundaryPreferences.boundaryHour = 16 + DayLabelPreferences.mode = .endDate + let storageDate = date(2026, 7, 10, hour: 16) + + XCTAssertEqual(DateFormatter.yyyyMMdd.string(from: storageDate), "2026-07-10") + XCTAssertEqual(storageDate.timelineLabelDayString, "2026-07-11") + } + + func testChatPromptDescribesConfiguredEndDateStorageMapping() { + DayBoundaryPreferences.boundaryHour = 16 + DayLabelPreferences.mode = .endDate + + let prompt = ChatPromptBuilder.cliSystemPrompt() + XCTAssertTrue(prompt.contains("4:00 PM-to-4:00 PM")) + XCTAssertTrue(prompt.contains("labeled by their END date")) + XCTAssertTrue(prompt.contains("subtract one calendar day")) + XCTAssertFalse(prompt.contains("Days start at 4:00 AM")) + } + private func date(_ year: Int, _ month: Int, _ day: Int, hour: Int) -> Date { var components = DateComponents() components.calendar = Calendar.current From 742be84708a44ebe8a9edeea2653cc87528c9c41 Mon Sep 17 00:00:00 2001 From: rmqg Date: Sat, 11 Jul 2026 03:23:35 -0400 Subject: [PATCH 09/10] Fix referral bindings for Xcode 27 --- .../Dayflow/Views/Components/ReferralSurveyView.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Dayflow/Dayflow/Views/Components/ReferralSurveyView.swift b/Dayflow/Dayflow/Views/Components/ReferralSurveyView.swift index cea20b0c0..639bcd1c6 100644 --- a/Dayflow/Dayflow/Views/Components/ReferralSurveyView.swift +++ b/Dayflow/Dayflow/Views/Components/ReferralSurveyView.swift @@ -25,18 +25,23 @@ struct ReferralSurveyView: View { customReferral: Binding? = nil, onSubmit: @escaping (_ option: ReferralOption, _ detail: String?) -> Void = { _, _ in } ) { + let internalSelectedReferral = State(initialValue: nil) + let internalCustomReferral = State(initialValue: "") + self.prompt = prompt self.submitLabel = submitLabel self.showsThankYou = showsThankYou self.showSubmitButton = showSubmitButton self.onSubmit = onSubmit + _internalSelectedReferral = internalSelectedReferral + _internalCustomReferral = internalCustomReferral if let selectedReferral = selectedReferral, let customReferral = customReferral { _selectedReferral = selectedReferral _customReferral = customReferral } else { - _selectedReferral = _internalSelectedReferral.projectedValue - _customReferral = _internalCustomReferral.projectedValue + _selectedReferral = internalSelectedReferral.projectedValue + _customReferral = internalCustomReferral.projectedValue } } From 9e1377b536f529efa0b834d4e4cabcf8869caa8d Mon Sep 17 00:00:00 2001 From: rmqg Date: Sat, 11 Jul 2026 03:23:35 -0400 Subject: [PATCH 10/10] Discover installed Xcode in development script --- scripts/dev.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/dev.sh b/scripts/dev.sh index c764739e4..c33262d33 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -4,6 +4,16 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) +if [[ -z "${DEVELOPER_DIR:-}" ]]; then + XCODE_APP=/Applications/Xcode.app + if [[ ! -d "$XCODE_APP/Contents/Developer" ]] && command -v mdfind >/dev/null 2>&1; then + XCODE_APP=$(mdfind 'kMDItemCFBundleIdentifier == "com.apple.dt.Xcode"' | head -n 1) + fi + if [[ -d "$XCODE_APP/Contents/Developer" ]]; then + export DEVELOPER_DIR="$XCODE_APP/Contents/Developer" + fi +fi + APP_NAME=${APP_NAME:-Dayflow} BUNDLE_ID=${BUNDLE_ID:-teleportlabs.com.Dayflow} PROJECT=${PROJECT:-"$REPO_ROOT/Dayflow/Dayflow.xcodeproj"}