focus-flow/lib/src/today_state.dart

609 lines
18 KiB
Dart

// Application-level Today read model.
//
// This module builds UI-independent state for the Today and compact views. It
// is read-only: it does not run rollover, create activities, update statistics,
// or persist operation records.
import 'application_layer.dart';
import 'locked_time.dart';
import 'models.dart';
import 'repositories.dart';
import 'scheduling_engine.dart';
import 'timeline_state.dart';
import 'time_contracts.dart';
/// Source category for one Today timeline item.
enum TodayTimelineItemSource {
task,
lockedOccurrence,
}
/// Request for the V1 Today read model.
class GetTodayStateRequest {
const GetTodayStateRequest({
required this.context,
required this.date,
this.revealHiddenLockedBlocks = false,
this.includeNextFlexibleItem = true,
this.fullTimelineExpanded = false,
});
/// Operation/read context. [ApplicationOperationContext.now] is the read
/// instant and is never recomputed during the query.
final ApplicationOperationContext context;
/// Owner-local day to read.
final CivilDate date;
/// Whether hidden locked-time overlays should be present in the item list.
final bool revealHiddenLockedBlocks;
/// Whether compact projection should expose a next flexible task.
final bool includeNextFlexibleItem;
/// Whether compact mode is currently showing the full timeline.
final bool fullTimelineExpanded;
}
/// One item in the Today read model, with task status/source metadata.
class TodayTimelineItem {
TodayTimelineItem({
required this.item,
required this.source,
this.taskStatus,
this.taskId,
this.lockedBlockId,
this.lockedOverrideId,
this.hiddenByDefault = false,
this.isCurrent = false,
this.isNextRequired = false,
this.isNextFlexible = false,
});
/// Display-ready framework-neutral item.
final TimelineItem item;
/// Repository/domain source for this item.
final TodayTimelineItemSource source;
/// Source task status. Null for locked occurrences.
final TaskStatus? taskStatus;
/// Source task id, when [source] is [TodayTimelineItemSource.task].
final String? taskId;
/// Source locked block id, when this is a locked occurrence.
final String? lockedBlockId;
/// Source override id, when this occurrence was replaced or added.
final String? lockedOverrideId;
/// Whether the locked source is normally hidden.
final bool hiddenByDefault;
/// Whether this item is the current selected item for the read instant.
final bool isCurrent;
/// Whether this item is the next required item.
final bool isNextRequired;
/// Whether this item is the optional next flexible item.
final bool isNextFlexible;
/// Stable item id.
String get id => item.id;
/// Timeline start.
DateTime? get start => item.start;
/// Timeline end.
DateTime? get end => item.end;
/// Source task behavior type.
TaskType get taskType => item.taskType;
/// Project/border color token.
String get projectColorToken => item.projectColorToken;
/// Structured quick actions available for this item.
List<TimelineQuickAction> get quickActions => item.quickActions;
/// Return a copy with selection flags changed.
TodayTimelineItem copyWith({
bool? isCurrent,
bool? isNextRequired,
bool? isNextFlexible,
}) {
return TodayTimelineItem(
item: item,
source: source,
taskStatus: taskStatus,
taskId: taskId,
lockedBlockId: lockedBlockId,
lockedOverrideId: lockedOverrideId,
hiddenByDefault: hiddenByDefault,
isCurrent: isCurrent ?? this.isCurrent,
isNextRequired: isNextRequired ?? this.isNextRequired,
isNextFlexible: isNextFlexible ?? this.isNextFlexible,
);
}
}
/// Compact projection derived from the same Today item list.
class TodayCompactState {
const TodayCompactState({
required this.manualCompactModeEnabled,
required this.fullTimelineExpanded,
this.currentItem,
this.nextRequiredItem,
this.nextFlexibleItem,
});
/// User-controlled compact mode flag.
final bool manualCompactModeEnabled;
/// Whether the full timeline is expanded while compact mode is active.
final bool fullTimelineExpanded;
/// Current visible item, if one occupies the read instant.
final TodayTimelineItem? currentItem;
/// Next critical or inflexible item.
final TodayTimelineItem? nextRequiredItem;
/// Optional next flexible item.
final TodayTimelineItem? nextFlexibleItem;
/// Whether callers should show the full timeline.
bool get fullTimelineVisible {
return !manualCompactModeEnabled || fullTimelineExpanded;
}
/// Items selected for compact rendering.
List<TodayTimelineItem> get compactItems {
if (!manualCompactModeEnabled) {
return const [];
}
return [
if (currentItem != null) currentItem!,
if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id)
nextRequiredItem!,
if (nextFlexibleItem != null &&
nextFlexibleItem!.id != currentItem?.id &&
nextFlexibleItem!.id != nextRequiredItem?.id)
nextFlexibleItem!,
];
}
}
/// Pending notice data surfaced through Today state.
class TodayPendingNotice {
TodayPendingNotice({
required this.snapshotId,
required this.capturedAt,
required this.message,
required this.type,
required Map<String, Object?> parameters,
this.taskId,
this.issueCode,
this.movementCode,
this.conflictCode,
}) : parameters = Map<String, Object?>.unmodifiable(parameters);
/// Source snapshot id.
final String snapshotId;
/// Snapshot capture time.
final DateTime capturedAt;
/// Existing neutral scheduler message. UI may localize from codes later.
final String message;
/// Notice category.
final SchedulingNoticeType type;
/// Related task id, if any.
final String? taskId;
/// Stable issue code, if any.
final SchedulingIssueCode? issueCode;
/// Stable movement code, if any.
final SchedulingMovementCode? movementCode;
/// Stable conflict code, if any.
final SchedulingConflictCode? conflictCode;
/// Structured notice parameters.
final Map<String, Object?> parameters;
}
/// Complete V1 Today state for one owner-local date.
class TodayState {
TodayState({
required this.date,
required this.ownerId,
required this.timeZoneId,
required this.readAt,
required this.dayStart,
required this.dayEnd,
required this.ownerSettings,
required List<TodayTimelineItem> timelineItems,
required this.compactState,
required List<TodayPendingNotice> pendingNotices,
this.currentItem,
this.nextRequiredItem,
this.nextFlexibleItem,
}) : timelineItems = List<TodayTimelineItem>.unmodifiable(timelineItems),
pendingNotices = List<TodayPendingNotice>.unmodifiable(pendingNotices);
/// Owner-local date represented by this state.
final CivilDate date;
/// Authorization-neutral owner scope.
final String ownerId;
/// Time-zone id used for local-day expansion.
final String timeZoneId;
/// Read instant from the request context.
final DateTime readAt;
/// Resolved start instant for [date].
final DateTime dayStart;
/// Resolved end instant for [date].
final DateTime dayEnd;
/// Effective owner settings used by the query.
final OwnerSettings ownerSettings;
/// Full sorted UI-independent timeline projection.
final List<TodayTimelineItem> timelineItems;
/// Current item, if one occupies [readAt].
final TodayTimelineItem? currentItem;
/// Next required item after [readAt].
final TodayTimelineItem? nextRequiredItem;
/// Optional next flexible item after [readAt].
final TodayTimelineItem? nextFlexibleItem;
/// Compact-mode projection from [timelineItems].
final TodayCompactState compactState;
/// Pending scheduling notices stored for the day.
final List<TodayPendingNotice> pendingNotices;
}
/// Builds Today state from application repositories.
class GetTodayStateQuery {
const GetTodayStateQuery({
required this.applicationStore,
required this.timeZoneResolver,
this.resolutionOptions = const TimeZoneResolutionOptions(),
});
/// Read boundary for repository access.
final ApplicationUnitOfWork applicationStore;
/// Explicit local-time resolver.
final TimeZoneResolver timeZoneResolver;
/// DST/nonexistent/repeated local time policy.
final TimeZoneResolutionOptions resolutionOptions;
/// Execute the read-only Today query.
Future<ApplicationResult<TodayState>> execute(
GetTodayStateRequest request,
) {
return applicationStore.read(
action: (repositories) async {
final context = request.context;
final ownerId = context.ownerTimeZone.ownerId;
final storedSettings =
await repositories.ownerSettings.findByOwnerId(ownerId);
final settings = storedSettings ??
OwnerSettings(
ownerId: ownerId,
timeZoneId: context.ownerTimeZone.timeZoneId,
);
final timeZoneId = settings.timeZoneId;
final dayStart = _resolve(
date: request.date,
wallTime: settings.dayStart,
timeZoneId: timeZoneId,
);
final dayEnd = _resolve(
date: request.date,
wallTime: settings.dayEnd,
timeZoneId: timeZoneId,
);
final window = SchedulingWindow(start: dayStart, end: dayEnd);
final projects = await repositories.projects.findAll();
final tasks = await repositories.tasks.findScheduledInWindow(window);
final blocks = await repositories.lockedBlocks.findAllBlocks();
final overrides = await repositories.lockedBlocks.findAllOverrides();
final snapshots =
await repositories.schedulingSnapshots.findInWindow(window);
final lockedExpansion = expandLockedBlocksForDay(
blocks: blocks,
overrides: overrides,
date: request.date,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
final mapper = TimelineItemMapper.fromProjects(projects);
final baseItems = <TodayTimelineItem>[
for (final task in tasks)
if (_taskAppearsInToday(task, request.revealHiddenLockedBlocks))
TodayTimelineItem(
item: mapper.fromTask(task),
source: TodayTimelineItemSource.task,
taskStatus: task.status,
taskId: task.id,
hiddenByDefault: task.isLocked,
),
for (final occurrence in lockedExpansion.occurrences)
if (mapper.fromLockedOccurrence(
occurrence,
revealHiddenLockedBlocks: request.revealHiddenLockedBlocks,
occurrenceDate: request.date,
)
case final lockedItem?)
TodayTimelineItem(
item: lockedItem,
source: TodayTimelineItemSource.lockedOccurrence,
lockedBlockId: occurrence.lockedBlockId,
lockedOverrideId: occurrence.overrideId,
hiddenByDefault: occurrence.hiddenByDefault,
),
]..sort(_compareTodayItems);
final current = _currentItem(baseItems, context.now);
final nextRequired = _nextRequiredItem(baseItems, context.now);
final nextFlexible = request.includeNextFlexibleItem
? _nextFlexibleItem(
baseItems,
context.now,
currentTaskId: current?.taskId,
)
: null;
final selectedItems = baseItems
.map(
(item) => item.copyWith(
isCurrent: item.id == current?.id,
isNextRequired: item.id == nextRequired?.id,
isNextFlexible: item.id == nextFlexible?.id,
),
)
.toList(growable: false);
final selectedCurrent = _findById(selectedItems, current?.id);
final selectedNextRequired = _findById(selectedItems, nextRequired?.id);
final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id);
final pendingNotices = _pendingNoticesFromSnapshots(snapshots);
return ApplicationResult.success(
TodayState(
date: request.date,
ownerId: ownerId,
timeZoneId: timeZoneId,
readAt: context.now,
dayStart: dayStart,
dayEnd: dayEnd,
ownerSettings: settings,
timelineItems: selectedItems,
currentItem: selectedCurrent,
nextRequiredItem: selectedNextRequired,
nextFlexibleItem: selectedNextFlexible,
compactState: TodayCompactState(
manualCompactModeEnabled: settings.compactModeEnabled,
fullTimelineExpanded: request.fullTimelineExpanded,
currentItem: selectedCurrent,
nextRequiredItem: selectedNextRequired,
nextFlexibleItem: selectedNextFlexible,
),
pendingNotices: pendingNotices,
),
);
},
);
}
DateTime _resolve({
required CivilDate date,
required WallTime wallTime,
required String timeZoneId,
}) {
return timeZoneResolver
.resolve(
date: date,
wallTime: wallTime,
timeZoneId: timeZoneId,
options: resolutionOptions,
)
.instant;
}
static bool _taskAppearsInToday(Task task, bool revealHiddenLockedBlocks) {
if (task.status == TaskStatus.backlog ||
task.status == TaskStatus.cancelled ||
task.status == TaskStatus.noLongerRelevant) {
return false;
}
if (task.isLocked && !revealHiddenLockedBlocks) {
return false;
}
return task.scheduledStart != null && task.scheduledEnd != null;
}
static TodayTimelineItem? _currentItem(
List<TodayTimelineItem> items,
DateTime now,
) {
final active = _firstOrNull(
items.where((item) {
return item.source == TodayTimelineItemSource.task &&
item.taskStatus == TaskStatus.active;
}),
);
if (active != null) {
return active;
}
return _firstOrNull(
items.where((item) {
return item.source == TodayTimelineItemSource.task &&
item.taskStatus == TaskStatus.planned &&
_containsTime(item, now);
}),
);
}
static TodayTimelineItem? _nextRequiredItem(
List<TodayTimelineItem> items,
DateTime now,
) {
return _firstOrNull(
items.where((item) {
return item.source == TodayTimelineItemSource.task &&
_isActionable(item.taskStatus) &&
_isRequired(item.taskType) &&
_startsAtOrAfter(item, now);
}),
);
}
static TodayTimelineItem? _nextFlexibleItem(
List<TodayTimelineItem> items,
DateTime now, {
required String? currentTaskId,
}) {
return _firstOrNull(
items.where((item) {
return item.source == TodayTimelineItemSource.task &&
item.taskId != currentTaskId &&
_isActionable(item.taskStatus) &&
item.taskType == TaskType.flexible &&
_startsAtOrAfter(item, now);
}),
);
}
static bool _isActionable(TaskStatus? status) {
return status == TaskStatus.planned || status == TaskStatus.active;
}
static bool _isRequired(TaskType type) {
return type == TaskType.inflexible || type == TaskType.critical;
}
static bool _containsTime(TodayTimelineItem item, DateTime now) {
final start = item.start;
final end = item.end;
if (start == null || end == null) {
return false;
}
return !now.isBefore(start) && now.isBefore(end);
}
static bool _startsAtOrAfter(TodayTimelineItem item, DateTime now) {
final start = item.start;
return start != null && !start.isBefore(now);
}
static TodayTimelineItem? _findById(
List<TodayTimelineItem> items,
String? id,
) {
if (id == null) {
return null;
}
return _firstOrNull(items.where((item) => item.id == id));
}
static List<TodayPendingNotice> _pendingNoticesFromSnapshots(
List<SchedulingStateSnapshot> snapshots,
) {
final sortedSnapshots = [...snapshots]..sort((a, b) {
final capturedComparison = a.capturedAt.compareTo(b.capturedAt);
if (capturedComparison != 0) {
return capturedComparison;
}
return a.id.compareTo(b.id);
});
final notices = <TodayPendingNotice>[];
for (final snapshot in sortedSnapshots) {
for (final notice in snapshot.notices) {
notices.add(
TodayPendingNotice(
snapshotId: snapshot.id,
capturedAt: snapshot.capturedAt,
message: notice.message,
type: notice.type,
taskId: notice.taskId,
issueCode: notice.issueCode,
movementCode: notice.movementCode,
conflictCode: notice.conflictCode,
parameters: notice.parameters,
),
);
}
}
return List<TodayPendingNotice>.unmodifiable(notices);
}
}
int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) {
final startComparison = _compareNullableInstants(left.start, right.start);
if (startComparison != 0) {
return startComparison;
}
final endComparison = _compareNullableInstants(left.end, right.end);
if (endComparison != 0) {
return endComparison;
}
final sourceComparison = left.source.index.compareTo(right.source.index);
if (sourceComparison != 0) {
return sourceComparison;
}
return left.id.compareTo(right.id);
}
int _compareNullableInstants(DateTime? left, DateTime? right) {
if (left == null && right == null) {
return 0;
}
if (left == null) {
return 1;
}
if (right == null) {
return -1;
}
return left.compareTo(right);
}
T? _firstOrNull<T>(Iterable<T> values) {
final iterator = values.iterator;
if (!iterator.moveNext()) {
return null;
}
return iterator.current;
}