feat(locked-time): add locked block models
This commit is contained in:
parent
de05eafe21
commit
c803b53a71
5 changed files with 349 additions and 18 deletions
|
|
@ -8,6 +8,8 @@ Purpose: Locked blocks are critical MVP. They reserve time, stay hidden by defau
|
|||
|
||||
Recommended Codex level: high
|
||||
|
||||
Status: Completed
|
||||
|
||||
Tasks:
|
||||
|
||||
Create a model for locked blocks with:
|
||||
|
|
@ -31,10 +33,21 @@ Acceptance criteria:
|
|||
- Model supports one-off and recurring locked blocks.
|
||||
- Work-hours style recurrence can be represented.
|
||||
|
||||
Execution notes:
|
||||
|
||||
- Added `ClockTime`, `LockedWeekday`, `LockedBlockRecurrence`, and `LockedBlock`.
|
||||
- Locked blocks include id, name, start/end time, optional recurrence, hidden-by-default flag, optional project id, and created/updated timestamps.
|
||||
- Locked blocks remain scheduling constraints and are not modeled as ordinary tasks.
|
||||
- One-off locked blocks and weekday work-hours recurrence are represented.
|
||||
- Added tests for one-off locked blocks and weekday work-hours recurrence.
|
||||
- `dart analyze` and `dart test` passed.
|
||||
|
||||
## Chunk 5.2 — One-day override model
|
||||
|
||||
Recommended Codex level: high
|
||||
|
||||
Status: Completed
|
||||
|
||||
Tasks:
|
||||
|
||||
Support one-day overrides for recurring locked blocks:
|
||||
|
|
@ -50,6 +63,14 @@ Acceptance criteria:
|
|||
- Test: half-day modifies only one date.
|
||||
- Test: recurring rule remains unchanged.
|
||||
|
||||
Execution notes:
|
||||
|
||||
- Added `LockedBlockOverrideType` and `LockedBlockOverride`.
|
||||
- Overrides support removing one occurrence, replacing one occurrence's times, and adding surprise locked time for one date.
|
||||
- Override objects do not mutate the base recurrence rule.
|
||||
- Added tests for Friday-off removal, half-day replacement, surprise locked time, and unchanged recurrence.
|
||||
- `dart analyze` and `dart test` passed.
|
||||
|
||||
BREAKPOINT: Stop here. Confirm `high` mode before handling recurrence/override expansion.
|
||||
|
||||
## Chunk 5.3 — Expand locked blocks into schedule constraints
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
export 'src/models.dart';
|
||||
export 'src/backlog.dart';
|
||||
export 'src/locked_time.dart';
|
||||
export 'src/quick_capture.dart';
|
||||
export 'src/scheduling_engine.dart';
|
||||
export 'src/task_statistics.dart';
|
||||
|
|
|
|||
208
lib/src/locked_time.dart
Normal file
208
lib/src/locked_time.dart
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import 'models.dart';
|
||||
|
||||
/// Weekday value using DateTime's Monday-first convention.
|
||||
enum LockedWeekday {
|
||||
monday(DateTime.monday),
|
||||
tuesday(DateTime.tuesday),
|
||||
wednesday(DateTime.wednesday),
|
||||
thursday(DateTime.thursday),
|
||||
friday(DateTime.friday),
|
||||
saturday(DateTime.saturday),
|
||||
sunday(DateTime.sunday);
|
||||
|
||||
const LockedWeekday(this.dateTimeValue);
|
||||
|
||||
final int dateTimeValue;
|
||||
}
|
||||
|
||||
/// Time of day without a calendar date.
|
||||
class ClockTime {
|
||||
const ClockTime({
|
||||
required this.hour,
|
||||
required this.minute,
|
||||
}) : assert(hour >= 0 && hour < 24, 'hour must be 0-23'),
|
||||
assert(minute >= 0 && minute < 60, 'minute must be 0-59');
|
||||
|
||||
final int hour;
|
||||
final int minute;
|
||||
|
||||
DateTime onDate(DateTime date) {
|
||||
return DateTime(date.year, date.month, date.day, hour, minute);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recurrence rule for locked time.
|
||||
class LockedBlockRecurrence {
|
||||
const LockedBlockRecurrence.weekly({
|
||||
required this.weekdays,
|
||||
});
|
||||
|
||||
final Set<LockedWeekday> weekdays;
|
||||
|
||||
bool occursOn(DateTime date) {
|
||||
return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduling constraint that reserves time without becoming a task card.
|
||||
class LockedBlock {
|
||||
const LockedBlock({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.recurrence,
|
||||
this.hiddenByDefault = true,
|
||||
this.projectId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final ClockTime startTime;
|
||||
final ClockTime endTime;
|
||||
final LockedBlockRecurrence? recurrence;
|
||||
final bool hiddenByDefault;
|
||||
final String? projectId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
bool get isRecurring => recurrence != null;
|
||||
}
|
||||
|
||||
/// Type of one-day override applied to locked time.
|
||||
enum LockedBlockOverrideType {
|
||||
remove,
|
||||
replace,
|
||||
add,
|
||||
}
|
||||
|
||||
/// One-day change to locked time that leaves the base recurrence unchanged.
|
||||
class LockedBlockOverride {
|
||||
const LockedBlockOverride({
|
||||
required this.id,
|
||||
required this.date,
|
||||
required this.type,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.lockedBlockId,
|
||||
this.name,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.hiddenByDefault = true,
|
||||
this.projectId,
|
||||
});
|
||||
|
||||
/// Removes a recurring occurrence for one date.
|
||||
factory LockedBlockOverride.remove({
|
||||
required String id,
|
||||
required String lockedBlockId,
|
||||
required DateTime date,
|
||||
required DateTime createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return LockedBlockOverride(
|
||||
id: id,
|
||||
lockedBlockId: lockedBlockId,
|
||||
date: date,
|
||||
type: LockedBlockOverrideType.remove,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaces a recurring occurrence with different times for one date.
|
||||
factory LockedBlockOverride.replace({
|
||||
required String id,
|
||||
required String lockedBlockId,
|
||||
required DateTime date,
|
||||
required ClockTime startTime,
|
||||
required ClockTime endTime,
|
||||
required DateTime createdAt,
|
||||
String? name,
|
||||
bool hiddenByDefault = true,
|
||||
String? projectId,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return LockedBlockOverride(
|
||||
id: id,
|
||||
lockedBlockId: lockedBlockId,
|
||||
date: date,
|
||||
type: LockedBlockOverrideType.replace,
|
||||
name: name,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
hiddenByDefault: hiddenByDefault,
|
||||
projectId: projectId,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds surprise locked time for one date.
|
||||
factory LockedBlockOverride.add({
|
||||
required String id,
|
||||
required DateTime date,
|
||||
required String name,
|
||||
required ClockTime startTime,
|
||||
required ClockTime endTime,
|
||||
required DateTime createdAt,
|
||||
bool hiddenByDefault = true,
|
||||
String? projectId,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return LockedBlockOverride(
|
||||
id: id,
|
||||
date: date,
|
||||
type: LockedBlockOverrideType.add,
|
||||
name: name,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
hiddenByDefault: hiddenByDefault,
|
||||
projectId: projectId,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String? lockedBlockId;
|
||||
final DateTime date;
|
||||
final LockedBlockOverrideType type;
|
||||
final String? name;
|
||||
final ClockTime? startTime;
|
||||
final ClockTime? endTime;
|
||||
final bool hiddenByDefault;
|
||||
final String? projectId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
bool appliesTo({
|
||||
required String blockId,
|
||||
required DateTime occurrenceDate,
|
||||
}) {
|
||||
return lockedBlockId == blockId && _sameDate(date, occurrenceDate);
|
||||
}
|
||||
|
||||
TimeInterval? intervalForDate(DateTime targetDate) {
|
||||
final start = startTime;
|
||||
final end = endTime;
|
||||
|
||||
if (start == null || end == null || !_sameDate(date, targetDate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TimeInterval(
|
||||
start: start.onDate(targetDate),
|
||||
end: end.onDate(targetDate),
|
||||
label: name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _sameDate(DateTime first, DateTime second) {
|
||||
return first.year == second.year &&
|
||||
first.month == second.month &&
|
||||
first.day == second.day;
|
||||
}
|
||||
|
|
@ -324,21 +324,3 @@ class TimeInterval {
|
|||
return start.isBefore(other.end) && end.isAfter(other.start);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starter representation of locked time.
|
||||
///
|
||||
/// Later recurring-block chunks should expand this without exposing hidden
|
||||
/// locked time in UI-facing defaults.
|
||||
class LockedBlock {
|
||||
const LockedBlock({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.interval,
|
||||
this.hiddenByDefault = true,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final TimeInterval interval;
|
||||
final bool hiddenByDefault;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -404,6 +404,125 @@ void main() {
|
|||
expect(updated.completedDuringLockedHoursCount, 1);
|
||||
expect(updated.completedDuringLockedHoursMinutes, 15);
|
||||
});
|
||||
|
||||
test('locked block model supports one-off and recurring constraints', () {
|
||||
final oneOff = LockedBlock(
|
||||
id: 'appointment',
|
||||
name: 'Appointment',
|
||||
startTime: const ClockTime(hour: 13, minute: 0),
|
||||
endTime: const ClockTime(hour: 14, minute: 0),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
projectId: 'health',
|
||||
);
|
||||
final workHours = LockedBlock(
|
||||
id: 'work-hours',
|
||||
name: 'Work',
|
||||
startTime: const ClockTime(hour: 9, minute: 0),
|
||||
endTime: const ClockTime(hour: 17, minute: 0),
|
||||
recurrence: const LockedBlockRecurrence.weekly(
|
||||
weekdays: {
|
||||
LockedWeekday.monday,
|
||||
LockedWeekday.tuesday,
|
||||
LockedWeekday.wednesday,
|
||||
LockedWeekday.thursday,
|
||||
LockedWeekday.friday,
|
||||
},
|
||||
),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
expect(oneOff.isRecurring, isFalse);
|
||||
expect(oneOff.hiddenByDefault, isTrue);
|
||||
expect(oneOff.projectId, 'health');
|
||||
expect(workHours.isRecurring, isTrue);
|
||||
expect(workHours.hiddenByDefault, isTrue);
|
||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue);
|
||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 20)), isFalse);
|
||||
});
|
||||
|
||||
test('locked block override removes only one date', () {
|
||||
final override = LockedBlockOverride.remove(
|
||||
id: 'friday-off',
|
||||
lockedBlockId: 'work-hours',
|
||||
date: DateTime(2026, 6, 19),
|
||||
createdAt: now,
|
||||
);
|
||||
|
||||
expect(override.type, LockedBlockOverrideType.remove);
|
||||
expect(
|
||||
override.appliesTo(
|
||||
blockId: 'work-hours',
|
||||
occurrenceDate: DateTime(2026, 6, 19),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
override.appliesTo(
|
||||
blockId: 'work-hours',
|
||||
occurrenceDate: DateTime(2026, 6, 20),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('locked block override modifies one date without recurrence changes',
|
||||
() {
|
||||
final recurrence = const LockedBlockRecurrence.weekly(
|
||||
weekdays: {
|
||||
LockedWeekday.monday,
|
||||
LockedWeekday.tuesday,
|
||||
LockedWeekday.wednesday,
|
||||
LockedWeekday.thursday,
|
||||
LockedWeekday.friday,
|
||||
},
|
||||
);
|
||||
final workHours = LockedBlock(
|
||||
id: 'work-hours',
|
||||
name: 'Work',
|
||||
startTime: const ClockTime(hour: 9, minute: 0),
|
||||
endTime: const ClockTime(hour: 17, minute: 0),
|
||||
recurrence: recurrence,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
final halfDay = LockedBlockOverride.replace(
|
||||
id: 'half-day',
|
||||
lockedBlockId: 'work-hours',
|
||||
date: DateTime(2026, 6, 19),
|
||||
startTime: const ClockTime(hour: 9, minute: 0),
|
||||
endTime: const ClockTime(hour: 12, minute: 0),
|
||||
createdAt: now,
|
||||
);
|
||||
final interval = halfDay.intervalForDate(DateTime(2026, 6, 19));
|
||||
|
||||
expect(interval, isNotNull);
|
||||
expect(interval!.start, DateTime(2026, 6, 19, 9));
|
||||
expect(interval.end, DateTime(2026, 6, 19, 12));
|
||||
expect(workHours.recurrence, same(recurrence));
|
||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue);
|
||||
});
|
||||
|
||||
test('locked block override can add surprise locked time', () {
|
||||
final surprise = LockedBlockOverride.add(
|
||||
id: 'surprise-lock',
|
||||
date: DateTime(2026, 6, 19),
|
||||
name: 'Urgent errand',
|
||||
startTime: const ClockTime(hour: 15, minute: 0),
|
||||
endTime: const ClockTime(hour: 16, minute: 0),
|
||||
createdAt: now,
|
||||
projectId: 'home',
|
||||
);
|
||||
final interval = surprise.intervalForDate(DateTime(2026, 6, 19));
|
||||
|
||||
expect(surprise.type, LockedBlockOverrideType.add);
|
||||
expect(surprise.lockedBlockId, isNull);
|
||||
expect(surprise.projectId, 'home');
|
||||
expect(interval, isNotNull);
|
||||
expect(interval!.start, DateTime(2026, 6, 19, 15));
|
||||
expect(interval.end, DateTime(2026, 6, 19, 16));
|
||||
});
|
||||
});
|
||||
|
||||
group('SchedulingEngine starter behavior', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue