feat(scheduling): push flexible tasks to next slot
This commit is contained in:
parent
5b20e46987
commit
ae346c4ad0
3 changed files with 382 additions and 4 deletions
|
|
@ -39,8 +39,8 @@ Execution notes:
|
||||||
- Expanded `SchedulingResult` and `SchedulingNotice` so operation results can report changed task placements, overlap details, and typed notices.
|
- Expanded `SchedulingResult` and `SchedulingNotice` so operation results can report changed task placements, overlap details, and typed notices.
|
||||||
- Added `SchedulingEngine.analyzeSchedule` for deterministic in-memory schedule analysis without moving tasks or touching persistence.
|
- Added `SchedulingEngine.analyzeSchedule` for deterministic in-memory schedule analysis without moving tasks or touching persistence.
|
||||||
- Added tests for in-memory task grouping, overlap reporting, and exact movement-change representation.
|
- Added tests for in-memory task grouping, overlap reporting, and exact movement-change representation.
|
||||||
- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment.
|
- Initial Dart verification was blocked while the SDK was unavailable; after repair, `dart analyze` and `dart test` passed.
|
||||||
- Commit was deferred until the repository was initialized and repaired.
|
- Committed after the repository was initialized and repaired.
|
||||||
|
|
||||||
## Chunk 3.2 — Insert backlog task into next available flexible slot
|
## Chunk 3.2 — Insert backlog task into next available flexible slot
|
||||||
|
|
||||||
|
|
@ -74,8 +74,8 @@ Execution notes:
|
||||||
- Successful insertion returns updated tasks, movement notices, and exact `SchedulingChange` records.
|
- Successful insertion returns updated tasks, movement notices, and exact `SchedulingChange` records.
|
||||||
- No-fit and overflow cases return the original task list with a typed notice.
|
- No-fit and overflow cases return the original task list with a typed notice.
|
||||||
- Added tests for open-slot insertion, bumping a later flexible task, skipping locked time, preserving inflexible/critical blocks, and overflow/no-fit.
|
- Added tests for open-slot insertion, bumping a later flexible task, skipping locked time, preserving inflexible/critical blocks, and overflow/no-fit.
|
||||||
- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment.
|
- Initial Dart verification was blocked while the SDK was unavailable; after repair, `dart analyze` and `dart test` passed.
|
||||||
- Commit was deferred until the repository was initialized and repaired.
|
- Committed after the repository was initialized and repaired.
|
||||||
|
|
||||||
BREAKPOINT: Stop here. Confirm `extra high` mode before starting this chunk if not already set.
|
BREAKPOINT: Stop here. Confirm `extra high` mode before starting this chunk if not already set.
|
||||||
|
|
||||||
|
|
@ -83,6 +83,8 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before starting this chunk if n
|
||||||
|
|
||||||
Recommended Codex level: extra high
|
Recommended Codex level: extra high
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Implement push behavior:
|
Implement push behavior:
|
||||||
|
|
@ -99,6 +101,16 @@ Acceptance criteria:
|
||||||
- Test: locked intervals are respected.
|
- Test: locked intervals are respected.
|
||||||
- Test: required items remain fixed and visible.
|
- Test: required items remain fixed and visible.
|
||||||
|
|
||||||
|
Execution notes:
|
||||||
|
|
||||||
|
- Added `SchedulingEngine.pushFlexibleTaskToNextAvailableSlot`.
|
||||||
|
- Push requires a planned flexible task with an existing scheduled interval and positive duration.
|
||||||
|
- The selected task moves after its current slot; downstream planned flexible tasks shift later in their existing order.
|
||||||
|
- Locked, inflexible, critical, non-planned, and earlier flexible intervals remain fixed or untouched.
|
||||||
|
- Successful push returns updated tasks, movement notices, and exact `SchedulingChange` records.
|
||||||
|
- Added tests for moving after the current slot, bumping downstream flexible tasks, respecting locked intervals, and preserving required visible items.
|
||||||
|
- `dart analyze` and `dart test` passed.
|
||||||
|
|
||||||
## Chunk 3.4 — Push flexible task to tomorrow top of queue
|
## Chunk 3.4 — Push flexible task to tomorrow top of queue
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,86 @@ class SchedulingEngine {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push a planned flexible task to the next available slot today.
|
||||||
|
///
|
||||||
|
/// The selected task moves after its current slot. Planned flexible tasks
|
||||||
|
/// after it may shift later, preserving their relative order.
|
||||||
|
SchedulingResult pushFlexibleTaskToNextAvailableSlot({
|
||||||
|
required SchedulingInput input,
|
||||||
|
required String taskId,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
final task = _taskById(input.tasks, taskId);
|
||||||
|
if (task == null) {
|
||||||
|
return _unchangedResult(
|
||||||
|
input,
|
||||||
|
SchedulingNotice(
|
||||||
|
'Task was not found.',
|
||||||
|
type: SchedulingNoticeType.noFit,
|
||||||
|
taskId: taskId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
||||||
|
return _unchangedResult(
|
||||||
|
input,
|
||||||
|
SchedulingNotice(
|
||||||
|
'Only planned flexible tasks can be pushed.',
|
||||||
|
type: SchedulingNoticeType.noFit,
|
||||||
|
taskId: task.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentInterval = _scheduledIntervalFor(task);
|
||||||
|
if (currentInterval == null || !input.window.contains(currentInterval)) {
|
||||||
|
return _unchangedResult(
|
||||||
|
input,
|
||||||
|
SchedulingNotice(
|
||||||
|
'Task needs a current scheduled slot before pushing.',
|
||||||
|
type: SchedulingNoticeType.noFit,
|
||||||
|
taskId: task.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentInterval.duration.inMicroseconds <= 0) {
|
||||||
|
return _unchangedResult(
|
||||||
|
input,
|
||||||
|
SchedulingNotice(
|
||||||
|
'Task needs a positive scheduled duration before pushing.',
|
||||||
|
type: SchedulingNoticeType.noFit,
|
||||||
|
taskId: task.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final placement = _planFlexiblePush(
|
||||||
|
input: input,
|
||||||
|
task: task,
|
||||||
|
currentInterval: currentInterval,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (placement == null) {
|
||||||
|
return _unchangedResult(
|
||||||
|
input,
|
||||||
|
SchedulingNotice(
|
||||||
|
'No available flexible slot today.',
|
||||||
|
type: SchedulingNoticeType.overflow,
|
||||||
|
taskId: task.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _applyPushPlacement(
|
||||||
|
input: input,
|
||||||
|
pushedTask: task,
|
||||||
|
placement: placement,
|
||||||
|
updatedAt: updatedAt ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Analyze the current in-memory schedule without moving tasks.
|
/// Analyze the current in-memory schedule without moving tasks.
|
||||||
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
||||||
final overlaps = <SchedulingOverlap>[];
|
final overlaps = <SchedulingOverlap>[];
|
||||||
|
|
@ -471,6 +551,86 @@ _BacklogInsertionPlan? _planBacklogInsertion({
|
||||||
return _BacklogInsertionPlan(placements: placements);
|
return _BacklogInsertionPlan(placements: placements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_BacklogInsertionPlan? _planFlexiblePush({
|
||||||
|
required SchedulingInput input,
|
||||||
|
required Task task,
|
||||||
|
required TimeInterval currentInterval,
|
||||||
|
}) {
|
||||||
|
final fixedBlocks = <TimeInterval>[
|
||||||
|
...input.blockedIntervals,
|
||||||
|
];
|
||||||
|
final queue = <_PlacementItem>[
|
||||||
|
_PlacementItem(
|
||||||
|
task: task,
|
||||||
|
duration: currentInterval.duration,
|
||||||
|
earliestStart: currentInterval.end,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final scheduledFlexibleTasks = input.flexibleTasks
|
||||||
|
.where((flexibleTask) => flexibleTask.id != task.id)
|
||||||
|
.toList(growable: false)
|
||||||
|
..sort((a, b) {
|
||||||
|
final aStart = a.scheduledStart ?? input.window.end;
|
||||||
|
final bStart = b.scheduledStart ?? input.window.end;
|
||||||
|
|
||||||
|
return aStart.compareTo(bStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final flexibleTask in scheduledFlexibleTasks) {
|
||||||
|
final interval = _scheduledIntervalFor(flexibleTask);
|
||||||
|
if (interval == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final startsBeforePushPoint = interval.start.isBefore(currentInterval.end);
|
||||||
|
final startsAfterWindow = interval.start.isAfter(input.window.end) ||
|
||||||
|
interval.start.isAtSameMomentAs(input.window.end);
|
||||||
|
|
||||||
|
if (startsBeforePushPoint || startsAfterWindow) {
|
||||||
|
fixedBlocks.add(interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flexibleTask.status != TaskStatus.planned) {
|
||||||
|
fixedBlocks.add(interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.add(
|
||||||
|
_PlacementItem(
|
||||||
|
task: flexibleTask,
|
||||||
|
duration: interval.duration,
|
||||||
|
earliestStart: interval.start,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
|
||||||
|
|
||||||
|
var cursor = currentInterval.end;
|
||||||
|
final placements = <String, TimeInterval>{};
|
||||||
|
|
||||||
|
for (final item in queue) {
|
||||||
|
final earliestStart = _laterOf(cursor, item.earliestStart);
|
||||||
|
final interval = _firstOpenIntervalFrom(
|
||||||
|
earliestStart: earliestStart,
|
||||||
|
windowEnd: input.window.end,
|
||||||
|
duration: item.duration,
|
||||||
|
blocked: fixedBlocks,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (interval == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
placements[item.task.id] = interval;
|
||||||
|
cursor = interval.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _BacklogInsertionPlan(placements: placements);
|
||||||
|
}
|
||||||
|
|
||||||
SchedulingResult _applyPlacement({
|
SchedulingResult _applyPlacement({
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
required Task insertedTask,
|
required Task insertedTask,
|
||||||
|
|
@ -536,6 +696,69 @@ SchedulingResult _applyPlacement({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SchedulingResult _applyPushPlacement({
|
||||||
|
required SchedulingInput input,
|
||||||
|
required Task pushedTask,
|
||||||
|
required _BacklogInsertionPlan placement,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
}) {
|
||||||
|
final changes = <SchedulingChange>[];
|
||||||
|
final notices = <SchedulingNotice>[];
|
||||||
|
final updatedTasks = <Task>[];
|
||||||
|
|
||||||
|
for (final task in input.tasks) {
|
||||||
|
final interval = placement.placements[task.id];
|
||||||
|
if (interval == null) {
|
||||||
|
updatedTasks.add(task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final moved = !_sameDateTime(task.scheduledStart, interval.start) ||
|
||||||
|
!_sameDateTime(task.scheduledEnd, interval.end);
|
||||||
|
|
||||||
|
if (!moved) {
|
||||||
|
updatedTasks.add(task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isPushedTask = task.id == pushedTask.id;
|
||||||
|
final updatedTask = task.copyWith(
|
||||||
|
scheduledStart: interval.start,
|
||||||
|
scheduledEnd: interval.end,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
stats: isPushedTask
|
||||||
|
? task.stats.incrementManualPush()
|
||||||
|
: task.stats.incrementAutoPush(),
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedTasks.add(updatedTask);
|
||||||
|
changes.add(
|
||||||
|
SchedulingChange(
|
||||||
|
taskId: task.id,
|
||||||
|
previousStart: task.scheduledStart,
|
||||||
|
previousEnd: task.scheduledEnd,
|
||||||
|
nextStart: interval.start,
|
||||||
|
nextEnd: interval.end,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
notices.add(
|
||||||
|
SchedulingNotice(
|
||||||
|
isPushedTask
|
||||||
|
? 'Flexible task pushed to next available slot.'
|
||||||
|
: 'Flexible task moved to make room.',
|
||||||
|
type: SchedulingNoticeType.moved,
|
||||||
|
taskId: task.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SchedulingResult(
|
||||||
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||||
|
notices: List<SchedulingNotice>.unmodifiable(notices),
|
||||||
|
changes: List<SchedulingChange>.unmodifiable(changes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
DateTime _laterOf(DateTime first, DateTime second) {
|
DateTime _laterOf(DateTime first, DateTime second) {
|
||||||
if (first.isAfter(second)) {
|
if (first.isAfter(second)) {
|
||||||
return first;
|
return first;
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,149 @@ void main() {
|
||||||
expect(backlogTask.status, TaskStatus.backlog);
|
expect(backlogTask.status, TaskStatus.backlog);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('push flexible task moves it after current slot', () {
|
||||||
|
final task = flexibleTask().copyWith(
|
||||||
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
||||||
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
||||||
|
);
|
||||||
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [task],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 13),
|
||||||
|
end: DateTime(2026, 6, 19, 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
taskId: 'task-1',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final pushed = result.tasks.single;
|
||||||
|
|
||||||
|
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
|
||||||
|
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
|
||||||
|
expect(pushed.stats.manuallyPushedCount, 1);
|
||||||
|
expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13));
|
||||||
|
expect(result.changes.single.nextStart, DateTime(2026, 6, 19, 13, 15));
|
||||||
|
expect(result.notices.single.type, SchedulingNoticeType.moved);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('push flexible task bumps downstream flexible tasks in order', () {
|
||||||
|
final first = flexibleTask().copyWith(
|
||||||
|
id: 'flexible-1',
|
||||||
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
||||||
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
||||||
|
);
|
||||||
|
final second = flexibleTask().copyWith(
|
||||||
|
id: 'flexible-2',
|
||||||
|
durationMinutes: 30,
|
||||||
|
scheduledStart: DateTime(2026, 6, 19, 13, 15),
|
||||||
|
scheduledEnd: DateTime(2026, 6, 19, 13, 45),
|
||||||
|
);
|
||||||
|
final third = flexibleTask().copyWith(
|
||||||
|
id: 'flexible-3',
|
||||||
|
scheduledStart: DateTime(2026, 6, 19, 13, 45),
|
||||||
|
scheduledEnd: DateTime(2026, 6, 19, 14),
|
||||||
|
);
|
||||||
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [first, second, third],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 13),
|
||||||
|
end: DateTime(2026, 6, 19, 14, 30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
taskId: 'flexible-1',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final pushed = taskById(result.tasks, 'flexible-1');
|
||||||
|
final bumpedSecond = taskById(result.tasks, 'flexible-2');
|
||||||
|
final bumpedThird = taskById(result.tasks, 'flexible-3');
|
||||||
|
|
||||||
|
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
|
||||||
|
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
|
||||||
|
expect(bumpedSecond.scheduledStart, DateTime(2026, 6, 19, 13, 30));
|
||||||
|
expect(bumpedSecond.scheduledEnd, DateTime(2026, 6, 19, 14));
|
||||||
|
expect(bumpedThird.scheduledStart, DateTime(2026, 6, 19, 14));
|
||||||
|
expect(bumpedThird.scheduledEnd, DateTime(2026, 6, 19, 14, 15));
|
||||||
|
expect(bumpedSecond.stats.autoPushedCount, 1);
|
||||||
|
expect(bumpedThird.stats.autoPushedCount, 1);
|
||||||
|
expect(result.changes.map((change) => change.taskId), [
|
||||||
|
'flexible-1',
|
||||||
|
'flexible-2',
|
||||||
|
'flexible-3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('push flexible task respects locked intervals', () {
|
||||||
|
final task = flexibleTask().copyWith(
|
||||||
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
||||||
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
||||||
|
);
|
||||||
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [task],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 13),
|
||||||
|
end: DateTime(2026, 6, 19, 14),
|
||||||
|
),
|
||||||
|
lockedIntervals: [
|
||||||
|
TimeInterval(
|
||||||
|
start: DateTime(2026, 6, 19, 13, 15),
|
||||||
|
end: DateTime(2026, 6, 19, 13, 30),
|
||||||
|
label: 'locked-block',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
taskId: 'task-1',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final pushed = result.tasks.single;
|
||||||
|
|
||||||
|
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30));
|
||||||
|
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('push flexible task leaves required items fixed and visible', () {
|
||||||
|
final task = flexibleTask().copyWith(
|
||||||
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
||||||
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
||||||
|
);
|
||||||
|
final inflexibleTask = flexibleTask().copyWith(
|
||||||
|
id: 'inflexible-1',
|
||||||
|
type: TaskType.inflexible,
|
||||||
|
scheduledStart: DateTime(2026, 6, 19, 13, 15),
|
||||||
|
scheduledEnd: DateTime(2026, 6, 19, 13, 45),
|
||||||
|
);
|
||||||
|
final criticalTask = flexibleTask().copyWith(
|
||||||
|
id: 'critical-1',
|
||||||
|
type: TaskType.critical,
|
||||||
|
scheduledStart: DateTime(2026, 6, 19, 14),
|
||||||
|
scheduledEnd: DateTime(2026, 6, 19, 14, 30),
|
||||||
|
);
|
||||||
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [task, inflexibleTask, criticalTask],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 13),
|
||||||
|
end: DateTime(2026, 6, 19, 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
taskId: 'task-1',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final pushed = taskById(result.tasks, 'task-1');
|
||||||
|
final inflexible = taskById(result.tasks, 'inflexible-1');
|
||||||
|
final critical = taskById(result.tasks, 'critical-1');
|
||||||
|
|
||||||
|
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 45));
|
||||||
|
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 14));
|
||||||
|
expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13, 15));
|
||||||
|
expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
|
||||||
|
expect(critical.scheduledStart, DateTime(2026, 6, 19, 14));
|
||||||
|
expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30));
|
||||||
|
expect(result.changes.map((change) => change.taskId), ['task-1']);
|
||||||
|
});
|
||||||
|
|
||||||
test('moveToBacklog clears active schedule placement', () {
|
test('moveToBacklog clears active schedule placement', () {
|
||||||
final task = flexibleTask().copyWith(
|
final task = flexibleTask().copyWith(
|
||||||
scheduledStart: DateTime(2026, 6, 19, 18),
|
scheduledStart: DateTime(2026, 6, 19, 18),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue