// 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 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 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 parameters, this.taskId, this.issueCode, this.movementCode, this.conflictCode, }) : parameters = Map.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 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 timelineItems, required this.compactState, required List pendingNotices, this.currentItem, this.nextRequiredItem, this.nextFlexibleItem, }) : timelineItems = List.unmodifiable(timelineItems), pendingNotices = List.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 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 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> 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 = [ 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 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 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 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 items, String? id, ) { if (id == null) { return null; } return _firstOrNull(items.where((item) => item.id == id)); } static List _pendingNoticesFromSnapshots( List 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 = []; 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.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(Iterable values) { final iterator = values.iterator; if (!iterator.moveNext()) { return null; } return iterator.current; }