609 lines
18 KiB
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;
|
|
}
|