feat(locked-time): add locked block models

This commit is contained in:
Ashley Venn 2026-06-19 16:16:26 -07:00
parent de05eafe21
commit c803b53a71
5 changed files with 349 additions and 18 deletions

View file

@ -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

View file

@ -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
View 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;
}

View file

@ -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;
}

View file

@ -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', () {