forked from eva/focus-flow
feat(scheduler): support timezone-aware commands and task removal
This commit is contained in:
parent
a24b64179a
commit
fd4fdd13d2
27 changed files with 1111 additions and 67 deletions
|
|
@ -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
|
||||
|
|
|
|||
5
apps/focus_flow_flutter/config.example.json
Normal file
5
apps/focus_flow_flutter/config.example.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"Timezone": "PST",
|
||||
"LogLevel": "fine",
|
||||
"LogfileLocation": "~/ADHD_Scheduler/debug/"
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in a new issue