feat(scheduler): support timezone-aware commands and task removal

This commit is contained in:
Ashley Venn 2026-07-04 22:16:02 -07:00
parent a24b64179a
commit fd4fdd13d2
27 changed files with 1111 additions and 67 deletions

View file

@ -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

View file

@ -0,0 +1,5 @@
{
"Timezone": "PST",
"LogLevel": "fine",
"LogfileLocation": "~/ADHD_Scheduler/debug/"
}

View file

@ -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<void>? _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();
}
}

View file

@ -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<FocusFlowRuntimeConfig> 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<FocusFlowRuntimeConfig> 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<String, Object?>) {
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<String, Object?> 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<String, Object?> 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 = <String, int>{
'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) {

View file

@ -12,3 +12,6 @@ typedef ReadRefresh = Future<void> 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);

View file

@ -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<void> 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<void> _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);
}

View file

@ -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.

View file

@ -31,6 +31,7 @@ Future<void> main() async {
);
final composition = await PersistentSchedulerComposition.open(
logger: logger,
uiTimeZone: config.timeZone,
);
scheduler_core.logger.info(() => 'FocusFlow startup completed.');
runApp(FocusFlowApp(composition: composition));

View file

@ -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);
}

View file

@ -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,

View file

@ -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;

View file

@ -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<TimelineCardModel>.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<TimelineCardModel> cards;
}

View file

@ -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(
final contents = await File(
'${directory.path}${Platform.pathSeparator}focus_flow.log',
).existsSync(),
isFalse,
);
).readAsString();
expect(contents, isNot(contains('expensive debug state')));
});
test('evaluates lazy messages once the level is enabled', () async {

View file

@ -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(

View file

@ -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<OwnerSettings?>(
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<SchedulerCommandSuccess>());
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<PersistentSchedulerComposition> _openComposition(
required String path,
required CivilDate selectedDate,
required Clock clock,
FocusFlowUiTimeZone? uiTimeZone,
}) {
return _runAsync(
tester,
@ -415,6 +513,7 @@ Future<PersistentSchedulerComposition> _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,
);
}

View file

@ -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';

View file

@ -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,

View file

@ -375,6 +375,54 @@ class V1ApplicationCommandUseCases {
);
}
/// Permanently remove a task from persistence.
Future<ApplicationResult<ApplicationCommandResult>> removeTask({
required ApplicationOperationContext context,
required String taskId,
DateTime? expectedUpdatedAt,
}) {
const commandCode = ApplicationCommandCode.removeTask;
return applicationStore.run<ApplicationCommandResult>(
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 <Task>[],
readHint: ApplicationCommandReadHint(
affectedTaskIds: [taskId],
refreshToday: true,
),
),
);
},
);
}
/// Complete a flexible task.
Future<ApplicationResult<ApplicationCommandResult>> 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<ProjectStatistics>.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,
),
};
}

View file

@ -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<RepositoryMutationResult> 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';

View file

@ -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,
);

View file

@ -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<RepositoryMutationResult> 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;

View file

@ -79,4 +79,11 @@ abstract interface class TaskRepository {
required String ownerId,
required int expectedRevision,
});
/// Delete [taskId] by expected repository revision.
Future<RepositoryMutationResult> deleteIfRevision({
required String taskId,
required String ownerId,
required int expectedRevision,
});
}

View file

@ -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,

View file

@ -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(

View file

@ -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 {

View file

@ -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<core.RepositoryMutationResult> 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<TaskRow?> _taskById(String id) {
return (db.select(db.tasks)..where((table) => table.id.equals(id)))

View file

@ -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 =