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
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Create a model for locked blocks with:
|
Create a model for locked blocks with:
|
||||||
|
|
@ -31,10 +33,21 @@ Acceptance criteria:
|
||||||
- Model supports one-off and recurring locked blocks.
|
- Model supports one-off and recurring locked blocks.
|
||||||
- Work-hours style recurrence can be represented.
|
- 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
|
## Chunk 5.2 — One-day override model
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Support one-day overrides for recurring locked blocks:
|
Support one-day overrides for recurring locked blocks:
|
||||||
|
|
@ -50,6 +63,14 @@ Acceptance criteria:
|
||||||
- Test: half-day modifies only one date.
|
- Test: half-day modifies only one date.
|
||||||
- Test: recurring rule remains unchanged.
|
- 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.
|
BREAKPOINT: Stop here. Confirm `high` mode before handling recurrence/override expansion.
|
||||||
|
|
||||||
## Chunk 5.3 — Expand locked blocks into schedule constraints
|
## Chunk 5.3 — Expand locked blocks into schedule constraints
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
export 'src/models.dart';
|
export 'src/models.dart';
|
||||||
export 'src/backlog.dart';
|
export 'src/backlog.dart';
|
||||||
|
export 'src/locked_time.dart';
|
||||||
export 'src/quick_capture.dart';
|
export 'src/quick_capture.dart';
|
||||||
export 'src/scheduling_engine.dart';
|
export 'src/scheduling_engine.dart';
|
||||||
export 'src/task_statistics.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);
|
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.completedDuringLockedHoursCount, 1);
|
||||||
expect(updated.completedDuringLockedHoursMinutes, 15);
|
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', () {
|
group('SchedulingEngine starter behavior', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue