From fd4fdd13d2fa3c5ba61876c06d6be6015e8c4823 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Sat, 4 Jul 2026 22:16:02 -0700 Subject: [PATCH] feat(scheduler): support timezone-aware commands and task removal --- apps/focus_flow_flutter/README.md | 10 + apps/focus_flow_flutter/config.example.json | 5 + .../app/persistent_scheduler_composition.dart | 50 ++- .../runtime/focus_flow_runtime_config.dart | 375 +++++++++++++++++- .../command/scheduler_command_callbacks.dart | 3 + .../scheduler_command_controller_impl.dart | 41 +- .../controllers/today_screen_controller.dart | 10 +- apps/focus_flow_flutter/lib/main.dart | 1 + .../today/time/time_string_formatter.dart | 40 +- .../lib/models/today/timeline_card_model.dart | 16 +- .../timeline_item_presentation_mapper.dart | 35 +- .../lib/models/today/today_screen_data.dart | 23 +- .../test/app/runtime_config_test.dart | 96 ++++- .../past_task_push_presentation_test.dart | 19 + .../test/persistent_composition_test.dart | 100 +++++ .../src/application/application_commands.dart | 1 + .../codes/application_command_code.dart | 4 + .../v1_application_command_use_cases.dart | 90 ++++- .../tasks/unit_of_work_task_repository.dart | 32 ++ .../v1_app_open_recovery_use_cases.dart | 9 +- .../in_memory/in_memory_task_repository.dart | 52 +++ .../interfaces/task_repository.dart | 7 + .../queries/get_today_state_query.dart | 11 +- .../test/application_commands_test.dart | 49 +++ .../scheduler_core/test/today_state_test.dart | 34 ++ .../sqlite_application_task_repository.dart | 39 ++ .../sqlite_application_unit_of_work_test.dart | 26 +- 27 files changed, 1111 insertions(+), 67 deletions(-) create mode 100644 apps/focus_flow_flutter/config.example.json diff --git a/apps/focus_flow_flutter/README.md b/apps/focus_flow_flutter/README.md index cacbb15..427c026 100644 --- a/apps/focus_flow_flutter/README.md +++ b/apps/focus_flow_flutter/README.md @@ -51,11 +51,21 @@ Supported optional keys: ```json { + "Timezone": "UTC", "LogLevel": "fine", "LogfileLocation": "~/ADHD_Scheduler/debug/" } ``` +`Timezone` accepts a three-letter time-zone code without regard to character +case, such as `UTC`, `GMT`, `PST`, or `PDT`. Common daylight-saving pairs such +as `PST`/`PDT`, `MST`/`MDT`, `CST`/`CDT`, and `EST`/`EDT` are resolved once when +the config loads, so either seasonal abbreviation uses the correct current +offset for that zone during the app session. The setting is an app/UI reference +only: it controls wall-clock timeline display, selected-day startup, and +time-relative actions such as pushing a task to the next slot or tomorrow. Task +instants and all persistence data remain stored as UTC/GMT values. + `LogLevel` accepts `finest`, `finer`, `fine`, `debug`, `info`, `warn`, or `error`. `LogfileLocation` is treated as a directory, and the app writes diff --git a/apps/focus_flow_flutter/config.example.json b/apps/focus_flow_flutter/config.example.json new file mode 100644 index 0000000..311078f --- /dev/null +++ b/apps/focus_flow_flutter/config.example.json @@ -0,0 +1,5 @@ +{ + "Timezone": "PST", + "LogLevel": "fine", + "LogfileLocation": "~/ADHD_Scheduler/debug/" +} diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index 4a25fd7..1133e1e 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -16,6 +16,7 @@ import '../controllers/scheduler_command_controller.dart'; import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; import 'runtime/focus_flow_file_logger.dart'; +import 'runtime/focus_flow_runtime_config.dart'; import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel; import 'scheduler_app_composition.dart'; @@ -30,6 +31,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { required this.date, required this.readAt, required this.logger, + required this.uiTimeZone, + required this.clock, + required this.timeZoneResolver, }); /// Opens the configured SQLite runtime and bootstraps required owner state. @@ -38,11 +42,15 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { Clock? clock, CivilDate? selectedDate, FocusFlowFileLogger? logger, + FocusFlowUiTimeZone? uiTimeZone, }) async { final resolvedLogger = logger ?? FocusFlowFileLogger.disabled(); + final resolvedTimeZone = uiTimeZone ?? FocusFlowUiTimeZone.utc; final resolvedSqlitePath = sqlitePath ?? sqlitePathFromEnvironment(); scheduler_core.logger.info( - () => 'Opening SQLite runtime. sqlitePath=$resolvedSqlitePath', + () => + 'Opening SQLite runtime. sqlitePath=$resolvedSqlitePath ' + 'uiTimeZone=${resolvedTimeZone.code}', ); final runtime = await SqliteApplicationRuntime.open( path: resolvedSqlitePath, @@ -61,29 +69,37 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { } scheduler_core.logger.debug(() => 'SQLite bootstrap completed.'); final resolvedClock = clock ?? const SystemClock(); - final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now()); - final readAt = resolvedClock.now().toUtc(); + final openedAt = resolvedClock.now(); + final date = selectedDate ?? resolvedTimeZone.toLocalCivilDate(openedAt); + final readAt = openedAt.toUtc(); + final resolver = FixedOffsetTimeZoneResolver( + offset: resolvedTimeZone.utcOffset, + ); scheduler_core.logger.info( () => - 'Persistent scheduler composition opened. date=${date.toIsoString()} readAt=${readAt.toIso8601String()}', + 'Persistent scheduler composition opened. date=${date.toIsoString()} ' + 'readAt=${readAt.toIso8601String()} uiTimeZone=${resolvedTimeZone.code}', ); return PersistentSchedulerComposition._( runtime: runtime, todayQuery: GetTodayStateQuery( applicationStore: runtime.applicationStore, - timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + timeZoneResolver: resolver, ), managementUseCases: V1ApplicationManagementUseCases( applicationStore: runtime.applicationStore, ), commandUseCases: V1ApplicationCommandUseCases( applicationStore: runtime.applicationStore, - timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + timeZoneResolver: resolver, ), date: date, readAt: readAt, logger: resolvedLogger, + uiTimeZone: resolvedTimeZone, + clock: resolvedClock, + timeZoneResolver: resolver, ); } @@ -123,6 +139,15 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { /// Optional app runtime logger. final FocusFlowFileLogger logger; + /// UI-local fixed-offset time-zone reference for wall-clock behavior. + final FocusFlowUiTimeZone uiTimeZone; + + /// Runtime clock used to derive current UTC instants. + final Clock clock; + + /// Resolver configured from [uiTimeZone]. + final FixedOffsetTimeZoneResolver timeZoneResolver; + /// Pending close operation, if disposal has started. Future? _disposeFuture; @@ -165,6 +190,8 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { return TodayScreenController( selectedDates: selectedDates, dateLabelFor: dateLabelFor, + now: _utcNow, + timeZoneOffset: uiTimeZone.utcOffset, read: (date) { return todayQuery.execute( GetTodayStateRequest( @@ -194,6 +221,8 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { refreshReads: refreshReads, quickCaptureProjectId: defaultProjectId, operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}', + currentDateFor: uiTimeZone.toLocalCivilDate, + now: _utcNow, ); } @@ -203,9 +232,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { operationId: operationId, ownerTimeZone: OwnerTimeZoneContext( ownerId: SqliteApplicationRuntime.defaultV1OwnerId, - timeZoneId: SqliteApplicationRuntime.defaultV1TimeZoneId, + timeZoneId: uiTimeZone.code, ), - clock: FixedClock(now ?? readAt), + clock: FixedClock((now ?? _utcNow()).toUtc()), idGenerator: SequentialIdGenerator(prefix: operationId), ); } @@ -222,4 +251,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { await runtime.close(); scheduler_core.logger.info(() => 'SQLite runtime closed.'); } + + /// Returns the current runtime instant normalized to UTC. + DateTime _utcNow() { + return clock.now().toUtc(); + } } diff --git a/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart index bcd05ec..b2ed19a 100644 --- a/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart +++ b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart @@ -7,18 +7,29 @@ library; import 'dart:convert'; import 'dart:io'; -import 'package:scheduler_core/scheduler_core.dart' show FocusFlowLogLevel; +import 'package:scheduler_core/scheduler_core.dart' + show CivilDate, FocusFlowLogLevel; import 'package:scheduler_core/scheduler_core.dart' show logger; export 'package:scheduler_core/scheduler_core.dart' show FocusFlowLogLevel; /// File-backed runtime configuration for optional developer diagnostics. final class FocusFlowRuntimeConfig { - /// Creates a runtime configuration with optional logging controls. - const FocusFlowRuntimeConfig({this.logLevel, this.logFileLocation}); + /// Creates a runtime configuration with optional UI and logging controls. + const FocusFlowRuntimeConfig({ + this.logLevel, + this.logFileLocation, + this.timeZone, + }); /// Loads runtime configuration from [path] or the configured default path. - static Future load({String? path}) async { + /// + /// [now] is evaluated once and used to resolve DST-sensitive time-zone codes + /// into one fixed offset for this app session. + static Future load({ + String? path, + DateTime Function()? now, + }) async { final configPath = path ?? configPathFromEnvironment(); final file = File(_expandHome(configPath)); if (!await file.exists()) { @@ -30,11 +41,15 @@ final class FocusFlowRuntimeConfig { logger.debug(() => 'Loading runtime config. path=${file.path}'); final decoded = jsonDecode(await file.readAsString()); if (decoded is Map) { - final config = fromJson(decoded); + final config = fromJson( + decoded, + referenceInstant: (now ?? DateTime.now).call().toUtc(), + ); logger.debug( () => 'Runtime config loaded. hasLogLevel=${config.logLevel != null} ' - 'hasLogFileLocation=${config.logFileLocation != null}', + 'hasLogFileLocation=${config.logFileLocation != null} ' + 'hasTimeZone=${config.timeZone != null}', ); return config; } @@ -76,12 +91,23 @@ final class FocusFlowRuntimeConfig { } /// Builds a runtime configuration from decoded JSON fields. - static FocusFlowRuntimeConfig fromJson(Map json) { + /// + /// [referenceInstant] resolves DST-sensitive time zones once while reading the + /// config so normal app conversions can use the resulting fixed offset. + static FocusFlowRuntimeConfig fromJson( + Map json, { + DateTime? referenceInstant, + }) { final level = FocusFlowLogLevel.tryParse(_optionalString(json['LogLevel'])); final logLocation = _optionalString(json['LogfileLocation']); + final timeZone = FocusFlowUiTimeZone.tryParse( + _optionalString(json['Timezone'] ?? json['TimeZone'] ?? json['timezone']), + referenceInstant: referenceInstant, + ); return FocusFlowRuntimeConfig( logLevel: level, logFileLocation: logLocation == null ? null : _expandHome(logLocation), + timeZone: timeZone, ); } @@ -91,10 +117,345 @@ final class FocusFlowRuntimeConfig { /// Optional directory where the app should append its debug log file. final String? logFileLocation; + /// Optional UI-local time-zone reference for wall-clock display and actions. + final FocusFlowUiTimeZone? timeZone; + /// Whether this configuration has enough settings to enable file logging. bool get enablesFileLogging => logLevel != null && logFileLocation != null; } +/// UI-local fixed-offset time-zone reference loaded from runtime config. +final class FocusFlowUiTimeZone { + /// Creates a UI time-zone reference from a normalized code and UTC offset. + const FocusFlowUiTimeZone._({required this.code, required this.utcOffset}); + + /// Default UI time-zone when no valid config value is provided. + static const utc = FocusFlowUiTimeZone._( + code: 'UTC', + utcOffset: Duration.zero, + ); + + /// Parses a three-letter time-zone code without regard to character case. + /// + /// [referenceInstant] resolves supported daylight-saving aliases, such as + /// `PST`/`PDT`, into the currently active code and offset up front. + static FocusFlowUiTimeZone? tryParse( + String? value, { + DateTime? referenceInstant, + }) { + if (value == null) { + return null; + } + final code = value.trim().toUpperCase(); + if (!_threeLetterTimeZoneCode.hasMatch(code)) { + return null; + } + final resolvedCode = _resolveDaylightSavingCode( + code, + referenceInstant ?? DateTime.now().toUtc(), + ); + final offsetMinutes = _timeZoneOffsetMinutesByCode[resolvedCode]; + if (offsetMinutes == null) { + return null; + } + return FocusFlowUiTimeZone._( + code: resolvedCode, + utcOffset: Duration(minutes: offsetMinutes), + ); + } + + /// Normalized three-letter time-zone code. + final String code; + + /// Fixed offset from UTC used for UI-local wall-clock interpretation. + final Duration utcOffset; + + /// Converts [instant] into a UI-local display `DateTime`. + DateTime toLocalDateTime(DateTime instant) { + return instant.toUtc().add(utcOffset); + } + + /// Converts [instant] into the UI-local civil date. + CivilDate toLocalCivilDate(DateTime instant) { + return CivilDate.fromDateTime(toLocalDateTime(instant)); + } +} + +/// Accepted time-zone code shape for manual UI config values. +final _threeLetterTimeZoneCode = RegExp(r'^[A-Z]{3}$'); + +/// Number of days in one calendar week. +const _daysPerWeek = 7; + +/// Rule family for North American zones with standard/daylight abbreviations. +const _northAmericaDstFamily = 'northAmerica'; + +/// Rule family for British Summer Time. +const _britishDstFamily = 'british'; + +/// DST-linked code groups that should resolve to the active seasonal code. +const _daylightSavingTimeZoneRules = + < + String, + ({ + String standardCode, + String daylightCode, + int standardOffsetMinutes, + int daylightOffsetMinutes, + String family, + }) + >{ + 'AST': ( + standardCode: 'AST', + daylightCode: 'ADT', + standardOffsetMinutes: -4 * 60, + daylightOffsetMinutes: -3 * 60, + family: _northAmericaDstFamily, + ), + 'ADT': ( + standardCode: 'AST', + daylightCode: 'ADT', + standardOffsetMinutes: -4 * 60, + daylightOffsetMinutes: -3 * 60, + family: _northAmericaDstFamily, + ), + 'CST': ( + standardCode: 'CST', + daylightCode: 'CDT', + standardOffsetMinutes: -6 * 60, + daylightOffsetMinutes: -5 * 60, + family: _northAmericaDstFamily, + ), + 'CDT': ( + standardCode: 'CST', + daylightCode: 'CDT', + standardOffsetMinutes: -6 * 60, + daylightOffsetMinutes: -5 * 60, + family: _northAmericaDstFamily, + ), + 'EST': ( + standardCode: 'EST', + daylightCode: 'EDT', + standardOffsetMinutes: -5 * 60, + daylightOffsetMinutes: -4 * 60, + family: _northAmericaDstFamily, + ), + 'EDT': ( + standardCode: 'EST', + daylightCode: 'EDT', + standardOffsetMinutes: -5 * 60, + daylightOffsetMinutes: -4 * 60, + family: _northAmericaDstFamily, + ), + 'MST': ( + standardCode: 'MST', + daylightCode: 'MDT', + standardOffsetMinutes: -7 * 60, + daylightOffsetMinutes: -6 * 60, + family: _northAmericaDstFamily, + ), + 'MDT': ( + standardCode: 'MST', + daylightCode: 'MDT', + standardOffsetMinutes: -7 * 60, + daylightOffsetMinutes: -6 * 60, + family: _northAmericaDstFamily, + ), + 'NST': ( + standardCode: 'NST', + daylightCode: 'NDT', + standardOffsetMinutes: -(3 * 60 + 30), + daylightOffsetMinutes: -(2 * 60 + 30), + family: _northAmericaDstFamily, + ), + 'NDT': ( + standardCode: 'NST', + daylightCode: 'NDT', + standardOffsetMinutes: -(3 * 60 + 30), + daylightOffsetMinutes: -(2 * 60 + 30), + family: _northAmericaDstFamily, + ), + 'PST': ( + standardCode: 'PST', + daylightCode: 'PDT', + standardOffsetMinutes: -8 * 60, + daylightOffsetMinutes: -7 * 60, + family: _northAmericaDstFamily, + ), + 'PDT': ( + standardCode: 'PST', + daylightCode: 'PDT', + standardOffsetMinutes: -8 * 60, + daylightOffsetMinutes: -7 * 60, + family: _northAmericaDstFamily, + ), + 'BST': ( + standardCode: 'GMT', + daylightCode: 'BST', + standardOffsetMinutes: 0, + daylightOffsetMinutes: 60, + family: _britishDstFamily, + ), + }; + +/// Fixed UTC offsets, in minutes, for recognized three-letter zone codes. +const _timeZoneOffsetMinutesByCode = { + 'ADT': -3 * 60, + 'AFT': 4 * 60 + 30, + 'AST': -4 * 60, + 'BDT': 6 * 60, + 'BST': 1 * 60, + 'CAT': 2 * 60, + 'CET': 1 * 60, + 'CDT': -5 * 60, + 'CST': -6 * 60, + 'EAT': 3 * 60, + 'EDT': -4 * 60, + 'EET': 2 * 60, + 'EST': -5 * 60, + 'GMT': 0, + 'GST': 4 * 60, + 'HDT': -9 * 60, + 'HKT': 8 * 60, + 'HST': -10 * 60, + 'ICT': 7 * 60, + 'IST': 5 * 60 + 30, + 'JST': 9 * 60, + 'KST': 9 * 60, + 'MDT': -6 * 60, + 'MMT': 6 * 60 + 30, + 'MSK': 3 * 60, + 'MST': -7 * 60, + 'NDT': -(2 * 60 + 30), + 'NPT': 5 * 60 + 45, + 'NST': -(3 * 60 + 30), + 'PDT': -7 * 60, + 'PHT': 8 * 60, + 'PKT': 5 * 60, + 'PST': -8 * 60, + 'SGT': 8 * 60, + 'TRT': 3 * 60, + 'UCT': 0, + 'UTC': 0, + 'WAT': 1 * 60, + 'WET': 0, + 'WIB': 7 * 60, + 'WIT': 9 * 60, +}; + +/// Resolves [code] to the active seasonal code for DST-linked zones. +String _resolveDaylightSavingCode(String code, DateTime referenceInstant) { + final rule = _daylightSavingTimeZoneRules[code]; + if (rule == null) { + return code; + } + final instant = referenceInstant.toUtc(); + final usesDaylight = _usesDaylightSavingTime(rule, instant); + return usesDaylight ? rule.daylightCode : rule.standardCode; +} + +/// Reports whether [rule] is in daylight-saving time at [instant]. +bool _usesDaylightSavingTime( + ({ + String standardCode, + String daylightCode, + int standardOffsetMinutes, + int daylightOffsetMinutes, + String family, + }) + rule, + DateTime instant, +) { + return switch (rule.family) { + _northAmericaDstFamily => _isNorthAmericaDaylightSavingTime( + instant: instant, + standardOffsetMinutes: rule.standardOffsetMinutes, + daylightOffsetMinutes: rule.daylightOffsetMinutes, + ), + _britishDstFamily => _isBritishSummerTime(instant), + _ => false, + }; +} + +/// Reports whether [instant] falls inside North American daylight saving time. +bool _isNorthAmericaDaylightSavingTime({ + required DateTime instant, + required int standardOffsetMinutes, + required int daylightOffsetMinutes, +}) { + final utc = instant.toUtc(); + final year = utc.year; + final startDay = _nthWeekdayOfMonth( + year: year, + month: DateTime.march, + weekday: DateTime.sunday, + occurrence: 2, + ); + final endDay = _nthWeekdayOfMonth( + year: year, + month: DateTime.november, + weekday: DateTime.sunday, + occurrence: 1, + ); + final startsAtUtc = DateTime.utc( + year, + DateTime.march, + startDay, + 2, + ).subtract(Duration(minutes: standardOffsetMinutes)); + final endsAtUtc = DateTime.utc( + year, + DateTime.november, + endDay, + 2, + ).subtract(Duration(minutes: daylightOffsetMinutes)); + return !utc.isBefore(startsAtUtc) && utc.isBefore(endsAtUtc); +} + +/// Reports whether [instant] falls inside British Summer Time. +bool _isBritishSummerTime(DateTime instant) { + final utc = instant.toUtc(); + final year = utc.year; + final startDay = _lastWeekdayOfMonth( + year: year, + month: DateTime.march, + weekday: DateTime.sunday, + ); + final endDay = _lastWeekdayOfMonth( + year: year, + month: DateTime.october, + weekday: DateTime.sunday, + ); + final startsAtUtc = DateTime.utc(year, DateTime.march, startDay, 1); + final endsAtUtc = DateTime.utc(year, DateTime.october, endDay, 1); + return !utc.isBefore(startsAtUtc) && utc.isBefore(endsAtUtc); +} + +/// Returns the day number for the [occurrence] of [weekday] in a month. +int _nthWeekdayOfMonth({ + required int year, + required int month, + required int weekday, + required int occurrence, +}) { + final firstDay = DateTime.utc(year, month); + final daysUntilWeekday = (weekday - firstDay.weekday) % _daysPerWeek; + return firstDay + .add(Duration(days: daysUntilWeekday + (_daysPerWeek * (occurrence - 1)))) + .day; +} + +/// Returns the day number for the last [weekday] in a month. +int _lastWeekdayOfMonth({ + required int year, + required int month, + required int weekday, +}) { + final lastDay = DateTime.utc(year, month + 1, 0); + final daysSinceWeekday = (lastDay.weekday - weekday) % _daysPerWeek; + return lastDay.subtract(Duration(days: daysSinceWeekday)).day; +} + /// Reads [value] as a non-empty string when present. String? _optionalString(Object? value) { if (value is! String) { diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart index 8da3be4..d7b0eb4 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart @@ -12,3 +12,6 @@ typedef ReadRefresh = Future Function(); /// Reads the owner-local date that commands should target at execution time. typedef SchedulerSelectedDateReader = CivilDate Function(); + +/// Resolves the owner-local civil date for an absolute command instant. +typedef SchedulerCurrentDateResolver = CivilDate Function(DateTime instant); diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 0f01d30..5cd86fd 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -13,8 +13,10 @@ class SchedulerCommandController extends ChangeNotifier { required this.refreshReads, required this.quickCaptureProjectId, this.operationIdPrefix = 'ui-command', + SchedulerCurrentDateResolver? currentDateFor, DateTime Function()? now, - }) : now = now ?? _utcNow; + }) : currentDateFor = currentDateFor ?? _utcCivilDateFor, + now = now ?? _utcNow; /// Scheduler write use cases invoked by this controller. final V1ApplicationCommandUseCases commands; @@ -37,6 +39,9 @@ class SchedulerCommandController extends ChangeNotifier { /// Clock used by direct UI commands such as marking a task complete. final DateTime Function() now; + /// Resolves command instants to the owner-local current civil date. + final SchedulerCurrentDateResolver currentDateFor; + /// Filler title used by the temporary timeline add-task action. static const _genericFlexibleTaskTitle = 'New flexible task'; @@ -238,7 +243,7 @@ class SchedulerCommandController extends ChangeNotifier { action: (operationId) { return commands.pushFlexibleToNextAvailableSlot( context: contextFor(operationId, now: commandAt), - localDate: _civilDateFor(commandAt), + localDate: currentDateFor(commandAt), taskId: taskId, expectedUpdatedAt: expectedUpdatedAt, ); @@ -265,7 +270,7 @@ class SchedulerCommandController extends ChangeNotifier { action: (operationId) { return commands.pushFlexibleToTomorrowTopOfQueue( context: contextFor(operationId, now: commandAt), - targetDate: _civilDateFor(commandAt).addDays(1), + targetDate: currentDateFor(commandAt).addDays(1), taskId: taskId, expectedUpdatedAt: expectedUpdatedAt, ); @@ -299,6 +304,32 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Permanently removes a task from persistence. + Future removeTask({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + final commandAt = now(); + logger.debug( + () => + 'UI command requested: remove task. ' + 'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} ' + 'occurredAt=${commandAt.toIso8601String()}', + ); + await _run( + label: 'Removing task', + successMessage: 'Task removed', + refreshAfterSuccess: true, + action: (operationId) { + return commands.removeTask( + context: contextFor(operationId, now: commandAt), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + /// Runs the `_run` operation and completes after its asynchronous work finishes. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. Future _run({ @@ -371,9 +402,9 @@ class SchedulerCommandController extends ChangeNotifier { /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. static DateTime _utcNow() => DateTime.now().toUtc(); - /// Runs the `_civilDateFor` helper used inside this library. + /// Runs the `_utcCivilDateFor` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. - static CivilDate _civilDateFor(DateTime instant) { + static CivilDate _utcCivilDateFor(DateTime instant) { final utc = instant.toUtc(); return CivilDate(utc.year, utc.month, utc.day); } diff --git a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart index 2eb7e13..0ae5fb9 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -71,6 +71,7 @@ class TodayScreenController extends ChangeNotifier { required this.read, required this.selectedDates, required this.dateLabelFor, + this.timeZoneOffset = Duration.zero, DateTime Function()? now, }) : now = now ?? _utcNow, _state = TodayScreenLoading(dateLabelFor(selectedDates.date)) { @@ -89,6 +90,9 @@ class TodayScreenController extends ChangeNotifier { /// Clock used while mapping time-sensitive presentation state. final DateTime Function() now; + /// UI-local offset used to format timeline instants as wall-clock values. + final Duration timeZoneOffset; + /// Private state stored as `_state` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. TodayScreenState _state; @@ -153,6 +157,7 @@ class TodayScreenController extends ChangeNotifier { final data = TodayScreenData.fromTodayState( result.requireValue, now: now(), + timeZoneOffset: timeZoneOffset, ); _syncSelectedCard(data); _state = data.cards.isEmpty @@ -247,7 +252,10 @@ class TodayScreenController extends ChangeNotifier { /// Runs the `_emptyDataFor` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TodayScreenData _emptyDataFor(CivilDate date) { - return TodayScreenData.empty(dateLabel: dateLabelFor(date)); + return TodayScreenData.empty( + dateLabel: dateLabelFor(date), + timeZoneOffset: timeZoneOffset, + ); } /// Runs the `_syncSelectedCard` helper used inside this library. diff --git a/apps/focus_flow_flutter/lib/main.dart b/apps/focus_flow_flutter/lib/main.dart index b146e3c..085243b 100644 --- a/apps/focus_flow_flutter/lib/main.dart +++ b/apps/focus_flow_flutter/lib/main.dart @@ -31,6 +31,7 @@ Future main() async { ); final composition = await PersistentSchedulerComposition.open( logger: logger, + uiTimeZone: config.timeZone, ); scheduler_core.logger.info(() => 'FocusFlow startup completed.'); runApp(FocusFlowApp(composition: composition)); diff --git a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart index 9225cec..1d923fc 100644 --- a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart +++ b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart @@ -25,7 +25,11 @@ String _dateLabel(CivilDate date) { /// Top-level helper that performs the `_shortDateTimeLabel` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -String _shortDateTimeLabel(DateTime value) { +String _shortDateTimeLabel( + DateTime value, { + Duration timeZoneOffset = Duration.zero, +}) { + final localValue = _displayDateTime(value, timeZoneOffset); const months = [ 'Jan', 'Feb', @@ -40,36 +44,52 @@ String _shortDateTimeLabel(DateTime value) { 'Nov', 'Dec', ]; - return '${months[value.month - 1]} ${value.day}, ${_formatTime(value)}'; + return '${months[localValue.month - 1]} ${localValue.day}, ' + '${_formatTime(value, timeZoneOffset: timeZoneOffset)}'; } /// Top-level helper that performs the `_civilDateForDateTime` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -CivilDate? _civilDateForDateTime(DateTime? value) { +CivilDate? _civilDateForDateTime( + DateTime? value, { + Duration timeZoneOffset = Duration.zero, +}) { if (value == null) { return null; } - return CivilDate(value.year, value.month, value.day); + final localValue = _displayDateTime(value, timeZoneOffset); + return CivilDate(localValue.year, localValue.month, localValue.day); } /// Top-level helper that performs the `_minutesSinceMidnight` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -int _minutesSinceMidnight(DateTime? value) { +int _minutesSinceMidnight( + DateTime? value, { + Duration timeZoneOffset = Duration.zero, +}) { if (value == null) { return 0; } - return value.hour * 60 + value.minute; + final localValue = _displayDateTime(value, timeZoneOffset); + return localValue.hour * 60 + localValue.minute; } /// Top-level helper that performs the `_formatTime` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -String _formatTime(DateTime? value) { +String _formatTime(DateTime? value, {Duration timeZoneOffset = Duration.zero}) { if (value == null) { return ''; } - final period = value.hour >= 12 ? 'PM' : 'AM'; - final rawHour = value.hour % 12; + final localValue = _displayDateTime(value, timeZoneOffset); + final period = localValue.hour >= 12 ? 'PM' : 'AM'; + final rawHour = localValue.hour % 12; final hour = rawHour == 0 ? 12 : rawHour; - final minute = value.minute.toString().padLeft(2, '0'); + final minute = localValue.minute.toString().padLeft(2, '0'); return '$hour:$minute $period'; } + +/// Top-level helper that performs the `_displayDateTime` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +DateTime _displayDateTime(DateTime value, Duration timeZoneOffset) { + return value.toUtc().add(timeZoneOffset); +} diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart index 385ae9e..578d1bf 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart @@ -32,6 +32,7 @@ class TimelineCardModel { factory TimelineCardModel.fromItem( TodayTimelineItem item, { required DateTime now, + Duration timeZoneOffset = Duration.zero, }) { final start = item.start; final end = item.end; @@ -44,9 +45,11 @@ class TimelineCardModel { ? duration == null ? '' : '$duration min' - : '${_formatTime(start)} - ${_formatTime(end)}'; + : '${_formatTime(start, timeZoneOffset: timeZoneOffset)} - ' + '${_formatTime(end, timeZoneOffset: timeZoneOffset)}'; final completionDateContextRequired = - isCompleted && _completionDateContextRequired(item); + isCompleted && + _completionDateContextRequired(item, timeZoneOffset: timeZoneOffset); return TimelineCardModel( id: item.id, title: title, @@ -56,10 +59,14 @@ class TimelineCardModel { visualKind, timeText, completionDateContextRequired: completionDateContextRequired, + timeZoneOffset: timeZoneOffset, ), timeText: timeText, - startMinutes: _minutesSinceMidnight(start), - endMinutes: _minutesSinceMidnight(end), + startMinutes: _minutesSinceMidnight( + start, + timeZoneOffset: timeZoneOffset, + ), + endMinutes: _minutesSinceMidnight(end, timeZoneOffset: timeZoneOffset), taskType: item.taskType, visualKind: visualKind, rewardIconToken: item.item.rewardIconToken, @@ -70,6 +77,7 @@ class TimelineCardModel { ? _completionDisplayText( item, includeDate: completionDateContextRequired, + timeZoneOffset: timeZoneOffset, ) : null, expectedUpdatedAt: item.item.updatedAt, diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart index 3cc954c..683b1db 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart @@ -44,6 +44,7 @@ String _subtitleFor( TaskVisualKind visualKind, String timeText, { required bool completionDateContextRequired, + required Duration timeZoneOffset, }) { if (visualKind == TaskVisualKind.freeSlot) { return 'Intentional rest'; @@ -53,6 +54,7 @@ String _subtitleFor( final completedText = _completionDisplayText( item, includeDate: completionDateContextRequired, + timeZoneOffset: timeZoneOffset, ); if (completedText == null || completedText.isEmpty) { return 'Completed'; @@ -70,15 +72,19 @@ String _subtitleFor( String? _completionDisplayText( TodayTimelineItem item, { required bool includeDate, + required Duration timeZoneOffset, }) { final completionInstant = _completionDisplayInstant(item); if (completionInstant == null) { return null; } if (includeDate) { - return _shortDateTimeLabel(completionInstant); + return _shortDateTimeLabel( + completionInstant, + timeZoneOffset: timeZoneOffset, + ); } - return _formatTime(completionInstant); + return _formatTime(completionInstant, timeZoneOffset: timeZoneOffset); } /// Top-level helper that performs the `_completionDisplayInstant` operation for this file. @@ -89,14 +95,29 @@ DateTime? _completionDisplayInstant(TodayTimelineItem item) { /// Top-level helper that performs the `_completionDateContextRequired` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -bool _completionDateContextRequired(TodayTimelineItem item) { - final scheduledDate = _civilDateForDateTime(item.item.start ?? item.start); +bool _completionDateContextRequired( + TodayTimelineItem item, { + required Duration timeZoneOffset, +}) { + final scheduledDate = _civilDateForDateTime( + item.item.start ?? item.start, + timeZoneOffset: timeZoneOffset, + ); if (scheduledDate == null) { return false; } - final completedAtDate = _civilDateForDateTime(item.item.completedAt); - final actualStartDate = _civilDateForDateTime(item.item.actualStart); - final actualEndDate = _civilDateForDateTime(item.item.actualEnd); + final completedAtDate = _civilDateForDateTime( + item.item.completedAt, + timeZoneOffset: timeZoneOffset, + ); + final actualStartDate = _civilDateForDateTime( + item.item.actualStart, + timeZoneOffset: timeZoneOffset, + ); + final actualEndDate = _civilDateForDateTime( + item.item.actualEnd, + timeZoneOffset: timeZoneOffset, + ); return completedAtDate != null && completedAtDate != scheduledDate || actualStartDate != null && actualStartDate != scheduledDate || actualEndDate != null && actualEndDate != scheduledDate; diff --git a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart index 7c149e2..d14afad 100644 --- a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart +++ b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart @@ -10,15 +10,20 @@ class TodayScreenData { required this.dateLabel, required this.timelineRange, required this.cards, + this.timeZoneOffset = Duration.zero, this.requiredBanner, }); /// Creates an empty Today screen model for [dateLabel]. - factory TodayScreenData.empty({required String dateLabel}) { + factory TodayScreenData.empty({ + required String dateLabel, + Duration timeZoneOffset = Duration.zero, + }) { return TodayScreenData( dateLabel: dateLabel, timelineRange: defaultRange, cards: const [], + timeZoneOffset: timeZoneOffset, ); } @@ -26,10 +31,15 @@ class TodayScreenData { factory TodayScreenData.fromTodayState( TodayState state, { required DateTime now, + Duration timeZoneOffset = Duration.zero, }) { final cards = [ for (final item in state.timelineItems) - TimelineCardModel.fromItem(item, now: now), + TimelineCardModel.fromItem( + item, + now: now, + timeZoneOffset: timeZoneOffset, + ), ]; final nextRequired = state.nextRequiredItem; return TodayScreenData( @@ -40,9 +50,13 @@ class TodayScreenData { : RequiredBannerModel( timelineCardId: nextRequired.id, title: nextRequired.item.displayTitle, - timeText: _formatTime(nextRequired.start), + timeText: _formatTime( + nextRequired.start, + timeZoneOffset: timeZoneOffset, + ), ), cards: List.unmodifiable(cards), + timeZoneOffset: timeZoneOffset, ); } @@ -61,6 +75,9 @@ class TodayScreenData { /// Visible timeline range. final TimelineRangeModel timelineRange; + /// UI-local offset used for wall-clock timeline labels and positions. + final Duration timeZoneOffset; + /// Timeline cards to render. final List cards; } diff --git a/apps/focus_flow_flutter/test/app/runtime_config_test.dart b/apps/focus_flow_flutter/test/app/runtime_config_test.dart index fbb02d2..5d54ed3 100644 --- a/apps/focus_flow_flutter/test/app/runtime_config_test.dart +++ b/apps/focus_flow_flutter/test/app/runtime_config_test.dart @@ -41,16 +41,94 @@ void main() { '${directory.path}${Platform.pathSeparator}config.json', ); await configFile.writeAsString( - jsonEncode({'LogLevel': 'finest', 'LogfileLocation': directory.path}), + jsonEncode({ + 'LogLevel': 'finest', + 'LogfileLocation': directory.path, + 'Timezone': 'pDt', + }), ); - final config = await FocusFlowRuntimeConfig.load(path: configFile.path); + final config = await FocusFlowRuntimeConfig.load( + path: configFile.path, + now: () => DateTime.utc(2026, 7, 3, 12), + ); expect(config.logLevel, FocusFlowLogLevel.finest); expect(config.logFileLocation, directory.path); + expect(config.timeZone?.code, 'PDT'); + expect(config.timeZone?.utcOffset, const Duration(hours: -7)); expect(config.enablesFileLogging, isTrue); }); + test('resolves daylight-saving aliases from the reference instant', () { + final pstInSummer = FocusFlowUiTimeZone.tryParse( + 'PST', + referenceInstant: DateTime.utc(2026, 7, 5, 2), + ); + final pdtInWinter = FocusFlowUiTimeZone.tryParse( + 'pdt', + referenceInstant: DateTime.utc(2026, 1, 5, 12), + ); + final cstInSummer = FocusFlowUiTimeZone.tryParse( + 'CST', + referenceInstant: DateTime.utc(2026, 7, 5, 12), + ); + + expect(pstInSummer?.code, 'PDT'); + expect(pstInSummer?.utcOffset, const Duration(hours: -7)); + expect(pdtInWinter?.code, 'PST'); + expect(pdtInWinter?.utcOffset, const Duration(hours: -8)); + expect(cstInSummer?.code, 'CDT'); + expect(cstInSummer?.utcOffset, const Duration(hours: -5)); + }); + + test('keeps non-DST timezone codes fixed without regard to case', () { + final gmt = FocusFlowUiTimeZone.tryParse( + 'GmT', + referenceInstant: DateTime.utc(2026, 7, 5, 12), + ); + final jst = FocusFlowUiTimeZone.tryParse( + 'jst', + referenceInstant: DateTime.utc(2026, 7, 5, 12), + ); + + expect(gmt?.code, 'GMT'); + expect(gmt?.utcOffset, Duration.zero); + expect(jst?.code, 'JST'); + expect(jst?.utcOffset, const Duration(hours: 9)); + }); + + test('resolves daylight-saving boundaries at config parse time', () { + final beforePacificSpring = FocusFlowUiTimeZone.tryParse( + 'PST', + referenceInstant: DateTime.utc(2026, 3, 8, 9, 59), + ); + final afterPacificSpring = FocusFlowUiTimeZone.tryParse( + 'PST', + referenceInstant: DateTime.utc(2026, 3, 8, 10), + ); + final beforePacificFall = FocusFlowUiTimeZone.tryParse( + 'PDT', + referenceInstant: DateTime.utc(2026, 11, 1, 8, 59), + ); + final afterPacificFall = FocusFlowUiTimeZone.tryParse( + 'PDT', + referenceInstant: DateTime.utc(2026, 11, 1, 9), + ); + + expect(beforePacificSpring?.code, 'PST'); + expect(afterPacificSpring?.code, 'PDT'); + expect(beforePacificFall?.code, 'PDT'); + expect(afterPacificFall?.code, 'PST'); + }); + + test('ignores timezone values that are not valid three-letter codes', () { + expect(FocusFlowUiTimeZone.tryParse('America/Los_Angeles'), isNull); + expect(FocusFlowUiTimeZone.tryParse('PT'), isNull); + expect(FocusFlowUiTimeZone.tryParse('XYZ'), isNull); + expect(FocusFlowUiTimeZone.tryParse(' '), isNull); + }); + test('parses every supported log level', () { expect(FocusFlowLogLevel.tryParse('finest'), FocusFlowLogLevel.finest); expect(FocusFlowLogLevel.tryParse('finer'), FocusFlowLogLevel.finer); @@ -65,10 +143,12 @@ void main() { final config = FocusFlowRuntimeConfig.fromJson({ 'LogLevel': 'debug', 'LogfileLocation': '/tmp/focus-flow-logs', + 'Timezone': 'GMT', }); expect(config.logLevel, FocusFlowLogLevel.debug); expect(config.logFileLocation, '/tmp/focus-flow-logs'); + expect(config.timeZone?.code, 'GMT'); expect(config.enablesFileLogging, isTrue); }); @@ -76,10 +156,12 @@ void main() { final config = FocusFlowRuntimeConfig.fromJson({ 'LogLevel': 'verbose', 'LogfileLocation': ' ', + 'Timezone': 'Pacific', }); expect(config.logLevel, isNull); expect(config.logFileLocation, isNull); + expect(config.timeZone, isNull); expect(config.enablesFileLogging, isFalse); }); }); @@ -140,12 +222,10 @@ void main() { }); expect(evaluated, isFalse); - expect( - File( - '${directory.path}${Platform.pathSeparator}focus_flow.log', - ).existsSync(), - isFalse, - ); + final contents = await File( + '${directory.path}${Platform.pathSeparator}focus_flow.log', + ).readAsString(); + expect(contents, isNot(contains('expensive debug state'))); }); test('evaluates lazy messages once the level is enabled', () async { diff --git a/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart b/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart index 76c05ff..0cac13b 100644 --- a/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart +++ b/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart @@ -30,6 +30,25 @@ void main() { expect(card.pastTaskPushSemanticLabel, 'Push overdue task'); }); + test( + 'configured timezone offset changes wall-clock labels and positions', + () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 16), + end: DateTime.utc(2026, 1, 3, 16, 30), + ), + now: DateTime.utc(2026, 1, 3, 17), + timeZoneOffset: const Duration(hours: -8), + ); + + expect(card.timeText, '8:00 AM - 8:30 AM'); + expect(card.startMinutes, 8 * 60); + expect(card.endMinutes, 8 * 60 + 30); + expect(card.showPastTaskPushButton, isTrue); + }, + ); + test('planned flexible task later today does not show Push state', () { final card = TimelineCardModel.fromItem( _item( diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index 4cc47b0..1d157a6 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart'; import 'package:focus_flow_flutter/app/focus_flow_app.dart'; import 'package:focus_flow_flutter/app/persistent_scheduler_composition.dart'; +import 'package:focus_flow_flutter/app/runtime/focus_flow_runtime_config.dart'; import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart'; import 'package:focus_flow_flutter/models/today_screen_models.dart'; @@ -82,6 +83,43 @@ void main() { }, ); + testWidgets('ui timezone config controls local startup date only', ( + tester, + ) async { + final path = await _runAsync(tester, _tempDatabasePath); + final composition = await _runAsync( + tester, + () => PersistentSchedulerComposition.open( + sqlitePath: path, + clock: FixedClock(DateTime.utc(2026, 1, 3, 2)), + uiTimeZone: FocusFlowUiTimeZone.tryParse( + 'pst', + referenceInstant: DateTime.utc(2026, 1, 3, 2), + ), + ), + ); + addTearDown(() => tester.runAsync(composition.dispose)); + + expect(composition.initialDate, CivilDate(2026, 1, 2)); + expect( + composition.context('timezone-context').ownerTimeZone.timeZoneId, + 'PST', + ); + + final storedSettings = await _runAsync( + tester, + () => composition.runtime.applicationStore.read( + action: (repositories) async { + return ApplicationResult.success( + await repositories.ownerSettings.findByOwnerId('owner-1'), + ); + }, + ), + ); + + expect(storedSettings.requireValue?.timeZoneId, 'UTC'); + }); + testWidgets( 'selected future-day schedule completion and uncomplete persist across reopen', (tester) async { @@ -383,6 +421,65 @@ void main() { isNot(contains(pushBacklogTitle)), ); }); + + testWidgets('push to tomorrow uses ui-local current date at UTC rollover', ( + tester, + ) async { + final path = await _runAsync(tester, _tempDatabasePath); + final currentLocalDate = CivilDate(2026, 7, 4); + final tomorrow = currentLocalDate.addDays(1); + final dayAfterTomorrow = currentLocalDate.addDays(2); + final commandNow = DateTime.utc(2026, 7, 5, 2); + const title = 'Push tomorrow from evening PST'; + + final composition = await _openComposition( + tester, + path: path, + selectedDate: currentLocalDate, + clock: FixedClock(commandNow), + uiTimeZone: FocusFlowUiTimeZone.tryParse( + 'pst', + referenceInstant: commandNow, + ), + ); + addTearDown(() => tester.runAsync(composition.dispose)); + final commandController = _commandController( + composition, + selectedDate: currentLocalDate, + operationIdPrefix: 'local-tomorrow-rollover', + now: () => commandNow, + ); + addTearDown(commandController.dispose); + + await _captureAndSchedule(tester, composition, commandController, title); + final scheduled = await _readTodayItem( + tester, + composition, + currentLocalDate, + title, + ); + + await tester.runAsync( + () => commandController.pushTaskToTomorrow( + taskId: scheduled.id, + expectedUpdatedAt: scheduled.item.updatedAt, + ), + ); + + expect(commandController.state, isA()); + final pushed = await _readTodayItem(tester, composition, tomorrow, title); + expect(pushed.taskStatus, TaskStatus.planned); + expect(pushed.start, DateTime.utc(2026, 7, 5, 7)); + expect(pushed.end, DateTime.utc(2026, 7, 5, 7, 30)); + expect( + (await _readToday( + tester, + composition, + dayAfterTomorrow, + )).timelineItems.map((item) => item.item.displayTitle), + isNot(contains(title)), + ); + }); } /// Fixed date used by persistent Flutter tests. @@ -408,6 +505,7 @@ Future _openComposition( required String path, required CivilDate selectedDate, required Clock clock, + FocusFlowUiTimeZone? uiTimeZone, }) { return _runAsync( tester, @@ -415,6 +513,7 @@ Future _openComposition( sqlitePath: path, selectedDate: selectedDate, clock: clock, + uiTimeZone: uiTimeZone, ), ); } @@ -458,6 +557,7 @@ SchedulerCommandController _commandController( refreshReads: () async {}, quickCaptureProjectId: composition.defaultProjectId, operationIdPrefix: operationIdPrefix, + currentDateFor: composition.uiTimeZone.toLocalCivilDate, now: now, ); } diff --git a/packages/scheduler_core/lib/src/application/application_commands.dart b/packages/scheduler_core/lib/src/application/application_commands.dart index 1b57a32..6d068d9 100644 --- a/packages/scheduler_core/lib/src/application/application_commands.dart +++ b/packages/scheduler_core/lib/src/application/application_commands.dart @@ -22,6 +22,7 @@ import '../scheduling/task_actions.dart'; import '../scheduling/task_lifecycle.dart'; import '../domain/time_contracts.dart'; import '../logging/focus_flow_logger.dart'; +import '../persistence/repositories.dart'; part 'application_commands/codes/application_command_code.dart'; part 'application_commands/messages/application_child_task_draft.dart'; part 'application_commands/messages/application_command_read_hint.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart index 4cc4509..1068181 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart @@ -29,6 +29,10 @@ enum ApplicationCommandCode { /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. moveFlexibleToBacklog, + /// Selects the `removeTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + removeTask, + /// Selects the `completeFlexibleTask` option from this enum. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. completeFlexibleTask, diff --git a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart index e28e8ae..15bc738 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart @@ -375,6 +375,54 @@ class V1ApplicationCommandUseCases { ); } + /// Permanently remove a task from persistence. + Future> removeTask({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.removeTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} taskId=$taskId'); + final record = await repositories.tasks.findRecordById(taskId); + if (record == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(record.value, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final deleteResult = await repositories.tasks.deleteIfRevision( + taskId: taskId, + ownerId: context.ownerTimeZone.ownerId, + expectedRevision: record.revision, + ); + final deleteFailure = deleteResult.failure; + if (deleteFailure != null) { + return ApplicationResult.failure( + _failureForRepositoryMutation(deleteFailure), + ); + } + return ApplicationResult.success( + ApplicationCommandResult( + commandCode: commandCode, + operationId: context.operationId, + changedTasks: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: true, + ), + ), + ); + }, + ); + } + /// Complete a flexible task. Future> completeFlexibleTask({ required ApplicationOperationContext context, @@ -1066,15 +1114,17 @@ class V1ApplicationCommandUseCases { ownerId: ownerId, timeZoneId: context.ownerTimeZone.timeZoneId, ); + final timeZoneId = context.ownerTimeZone.timeZoneId; + final effectiveSettings = settings.copyWith(timeZoneId: timeZoneId); final dayStart = _resolve( date: localDate, - wallTime: settings.dayStart, - timeZoneId: settings.timeZoneId, + wallTime: effectiveSettings.dayStart, + timeZoneId: timeZoneId, ); final dayEnd = _resolve( date: localDate, - wallTime: settings.dayEnd, - timeZoneId: settings.timeZoneId, + wallTime: effectiveSettings.dayEnd, + timeZoneId: timeZoneId, ); final window = SchedulingWindow(start: dayStart, end: dayEnd); final tasks = (await repositories.tasks.findForLocalDay( @@ -1094,7 +1144,7 @@ class V1ApplicationCommandUseCases { )) .items, date: localDate, - timeZoneId: settings.timeZoneId, + timeZoneId: timeZoneId, timeZoneResolver: timeZoneResolver, resolutionOptions: resolutionOptions, ); @@ -1217,3 +1267,33 @@ class V1ApplicationCommandUseCases { return List.unmodifiable(updated); } } + +/// Top-level helper that performs the `_failureForRepositoryMutation` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +ApplicationFailure _failureForRepositoryMutation(RepositoryFailure failure) { + return switch (failure.code) { + RepositoryFailureCode.notFound => ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: failure.entityId, + detailCode: failure.code.name, + ), + RepositoryFailureCode.staleRevision => ApplicationFailure( + code: ApplicationFailureCode.staleRevision, + entityId: failure.entityId, + detailCode: failure.code.name, + ), + RepositoryFailureCode.invalidRevision => ApplicationFailure( + code: ApplicationFailureCode.validation, + entityId: failure.entityId, + detailCode: failure.code.name, + ), + RepositoryFailureCode.ownerMismatch || + RepositoryFailureCode.duplicateId || + RepositoryFailureCode.duplicateOperation => + ApplicationFailure( + code: ApplicationFailureCode.conflict, + entityId: failure.entityId, + detailCode: failure.code.name, + ), + }; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart index 181abe5..f28b9ea 100644 --- a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart @@ -251,6 +251,38 @@ class _UnitOfWorkTaskRepository implements TaskRepository { return RepositoryMutationResult.success(revision: nextRevision); } + /// Runs the `deleteIfRevision` operation and completes after its asynchronous work finishes. + /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. + @override + Future deleteIfRevision({ + required String taskId, + required String ownerId, + required int expectedRevision, + }) async { + if (!_tasksById.containsKey(taskId) || _ownerFor(taskId) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, entityId: taskId), + ); + } + final actualRevision = _revisionFor(taskId); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: taskId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById.remove(taskId); + _ownersById.remove(taskId); + _revisionsById.remove(taskId); + return RepositoryMutationResult.success(revision: nextRevision); + } + /// Runs the `_ownerFor` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; diff --git a/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart b/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart index 7d63291..aacbe44 100644 --- a/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart @@ -70,8 +70,11 @@ class V1AppOpenRecoveryUseCases { repositories, context.ownerTimeZone, ); - final sourceWindow = _windowForDate(sourceDate, settings); - final targetWindow = _windowForDate(targetDate, settings); + final effectiveSettings = settings.copyWith( + timeZoneId: context.ownerTimeZone.timeZoneId, + ); + final sourceWindow = _windowForDate(sourceDate, effectiveSettings); + final targetWindow = _windowForDate(targetDate, effectiveSettings); final tasks = await _loadSourceAndTargetTasks( repositories, sourceWindow: sourceWindow, @@ -90,7 +93,7 @@ class V1AppOpenRecoveryUseCases { blocks: blocks, overrides: overrides, date: targetDate, - timeZoneId: settings.timeZoneId, + timeZoneId: effectiveSettings.timeZoneId, timeZoneResolver: timeZoneResolver, resolutionOptions: resolutionOptions, ); diff --git a/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart index 952608a..1a3435d 100644 --- a/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart +++ b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart @@ -284,6 +284,58 @@ class InMemoryTaskRepository implements TaskRepository { return RepositoryMutationResult.success(revision: nextRevision); } + /// Runs the `deleteIfRevision` operation and completes after its asynchronous work finishes. + /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. + @override + Future deleteIfRevision({ + required String taskId, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: taskId, + expectedRevision: expectedRevision, + ), + ); + } + final current = _tasksById[taskId]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: taskId, + ), + ); + } + if (_ownerFor(taskId) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: taskId, + ), + ); + } + final actualRevision = _revisionFor(taskId); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: taskId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById.remove(taskId); + _ownersById.remove(taskId); + _revisionsById.remove(taskId); + return RepositoryMutationResult.success(revision: nextRevision); + } + /// Runs the `_ownerFor` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; diff --git a/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart index d479d6a..7617a8f 100644 --- a/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart +++ b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart @@ -79,4 +79,11 @@ abstract interface class TaskRepository { required String ownerId, required int expectedRevision, }); + + /// Delete [taskId] by expected repository revision. + Future deleteIfRevision({ + required String taskId, + required String ownerId, + required int expectedRevision, + }); } diff --git a/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart b/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart index 7788883..e7e0036 100644 --- a/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart +++ b/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart @@ -46,15 +46,16 @@ class GetTodayStateQuery { 'Today state query owner settings missing; using defaults. ' 'ownerId=$ownerId timeZoneId=${context.ownerTimeZone.timeZoneId}'); } - final timeZoneId = settings.timeZoneId; + final timeZoneId = context.ownerTimeZone.timeZoneId; + final effectiveSettings = settings.copyWith(timeZoneId: timeZoneId); final dayStart = _resolve( date: request.date, - wallTime: settings.dayStart, + wallTime: effectiveSettings.dayStart, timeZoneId: timeZoneId, ); final dayEnd = _resolve( date: request.date, - wallTime: settings.dayEnd, + wallTime: effectiveSettings.dayEnd, timeZoneId: timeZoneId, ); final window = SchedulingWindow(start: dayStart, end: dayEnd); @@ -169,13 +170,13 @@ class GetTodayStateQuery { readAt: context.now, dayStart: dayStart, dayEnd: dayEnd, - ownerSettings: settings, + ownerSettings: effectiveSettings, timelineItems: selectedItems, currentItem: selectedCurrent, nextRequiredItem: selectedNextRequired, nextFlexibleItem: selectedNextFlexible, compactState: TodayCompactState( - manualCompactModeEnabled: settings.compactModeEnabled, + manualCompactModeEnabled: effectiveSettings.compactModeEnabled, fullTimelineExpanded: request.fullTimelineExpanded, currentItem: selectedCurrent, nextRequiredItem: selectedNextRequired, diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index fea2d50..60bae32 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -168,6 +168,55 @@ void main() { expect(store.currentOperations, isEmpty); }); + test('remove task deletes it from persistence', () async { + final planned = task( + id: 'remove-me', + title: 'Remove me', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [planned]); + + final result = await commands(store).removeTask( + context: appContext('remove-task', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: planned.updatedAt, + ); + + expect(result.isSuccess, isTrue); + expect(store.currentTasks, isEmpty); + expect(result.requireValue.changedTasks, isEmpty); + expect(result.requireValue.readHint.affectedTaskIds, [planned.id]); + expect(store.currentOperations.single.operationId, 'remove-task'); + }); + + test('remove task rejects stale expected update', () async { + final planned = task( + id: 'stale-remove', + title: 'Stale remove', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [planned]); + + final result = await commands(store).removeTask( + context: appContext('stale-remove', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: + planned.updatedAt.subtract(const Duration(minutes: 1)), + ); + + expect(result.failure!.code, ApplicationFailureCode.staleRevision); + expect(taskById(store.currentTasks, planned.id), planned); + expect(store.currentOperations, isEmpty); + }); + test('commit persistence failure rolls back command-side task and activity', () async { final planned = task( diff --git a/packages/scheduler_core/test/today_state_test.dart b/packages/scheduler_core/test/today_state_test.dart index da71eff..9076cb0 100644 --- a/packages/scheduler_core/test/today_state_test.dart +++ b/packages/scheduler_core/test/today_state_test.dart @@ -49,6 +49,40 @@ void main() { expect(store.currentProjectStatistics, isEmpty); }); + test('uses context timezone without rewriting persisted owner settings', + () async { + final localDay = CivilDate(2026, 1, 3); + final store = InMemoryApplicationUnitOfWork( + initialOwnerSettings: [ + OwnerSettings(ownerId: 'owner-1', timeZoneId: 'UTC'), + ], + ); + final query = GetTodayStateQuery( + applicationStore: store, + timeZoneResolver: const FixedOffsetTimeZoneResolver( + offset: Duration(hours: -8), + ), + ); + + final state = (await query.execute( + GetTodayStateRequest( + context: appContext( + operationId: 'read-context-timezone', + now: DateTime.utc(2026, 1, 3, 12), + timeZoneId: 'PST', + ), + date: localDay, + ), + )) + .requireValue; + + expect(state.timeZoneId, 'PST'); + expect(state.ownerSettings.timeZoneId, 'PST'); + expect(state.dayStart, DateTime.utc(2026, 1, 3, 8)); + expect(state.dayEnd, DateTime.utc(2026, 1, 4, 7, 59)); + expect(store.currentOwnerSettings.single.timeZoneId, 'UTC'); + }); + test( 'builds one sorted model for tasks, Free Slots, locked overlays, and notices', () async { diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_task_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_task_repository.dart index 9df51e7..e577ee9 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_task_repository.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_task_repository.dart @@ -274,6 +274,45 @@ final class _SqliteApplicationTaskRepository implements core.TaskRepository { return core.RepositoryMutationResult.success(revision: nextRevision.value); } + /// Deletes [taskId] only when the repository revision matches [expectedRevision]. + @override + Future deleteIfRevision({ + required String taskId, + required String ownerId, + required int expectedRevision, + }) async { + final row = await _taskById(taskId); + final failure = _compareForWrite( + row: row, + ownerId: ownerId, + expectedRevision: expectedRevision, + entityId: taskId, + rowOwnerId: row?.ownerId, + rowRevision: row?.revision, + ); + if (failure != null) { + return failure; + } + final nextRevision = core.Revision(expectedRevision).next(); + final deleted = await (db.delete(db.tasks) + ..where( + (table) => + table.id.equals(taskId) & + table.ownerId.equals(ownerId) & + table.revision.equals(expectedRevision), + )) + .go(); + if (deleted != 1) { + return _mutationFailure( + core.RepositoryFailureCode.staleRevision, + entityId: taskId, + expectedRevision: expectedRevision, + actualRevision: row?.revision, + ); + } + return core.RepositoryMutationResult.success(revision: nextRevision.value); + } + /// Returns a raw task row by id. Future _taskById(String id) { return (db.select(db.tasks)..where((table) => table.id.equals(id))) diff --git a/packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart b/packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart index 8239206..9ad2b16 100644 --- a/packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart +++ b/packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart @@ -113,7 +113,7 @@ void main() { await runtime.close(); runtime = await _openBootstrappedRuntime(path); - addTearDown(runtime.close); + commands = _commands(runtime); todayQuery = _todayQuery(runtime); today = await todayQuery.execute( GetTodayStateRequest( @@ -125,6 +125,30 @@ void main() { .singleWhere((item) => item.taskId == task.id); expect(scheduledTask.taskStatus, TaskStatus.planned); + final removed = await commands.removeTask( + context: _context('remove-task', _instant(8, 40)), + taskId: task.id, + expectedUpdatedAt: scheduledTask.item.updatedAt, + ); + expect(removed.isSuccess, isTrue); + await runtime.close(); + + runtime = await _openBootstrappedRuntime(path); + addTearDown(runtime.close); + todayQuery = _todayQuery(runtime); + today = await todayQuery.execute( + GetTodayStateRequest( + context: _context('read-today-after-remove', _instant(8, 45)), + date: _date, + ), + ); + expect( + today.requireValue.timelineItems.any((item) => item.taskId == task.id), + isFalse, + ); + final taskRows = await runtime.db.select(runtime.db.tasks).get(); + expect(taskRows, isEmpty); + final activityRows = await runtime.db.select(runtime.db.taskActivities).get(); final operationRows =