Skip to content

Commit f9657e7

Browse files
authored
Fix explicit time-block durations (#40)
1 parent 9a6f1f1 commit f9657e7

4 files changed

Lines changed: 236 additions & 4 deletions

File tree

docs/current-work.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ Current implementation focus: harden the model-driven planning layer now that th
5353
- once planner contracts, runtime handlers, and tests no longer depend on `schedule_blocks`, drop the table and its remaining migration/test scaffolding
5454
- Expand Postgres integration coverage for ambiguous scheduling cases, clarification flows, and outbound reply loops.
5555
- Preserve safe scheduling and rescheduling over explicit state boundaries in mutation mode.
56+
- TODO: fix scheduler handling for explicit narrow time blocks so requests like `11:05` to `11:09` preserve the requested duration instead of defaulting to the profile focus block.
57+
- TODO: remove `schedule_blocks` once planner/runtime contracts, repositories, migrations, and tests are fully task-first.
58+
- TODO: fix multi-task schedule proposal generation so newly proposed inserts are considered during the same batch and Atlas cannot emit overlapping new blocks when scheduling more than one open task.
59+
- TODO: when a user asks for an explicit time that is already in the past, clarify that the requested time has already passed instead of silently placing it in the past or rolling it forward.
5660

5761
## Handoff notes
5862

packages/core/src/index.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,127 @@ describe("core package", () => {
598598
expect(result.inserts[0]?.startAt).toBe("2026-03-20T01:17:00.000Z");
599599
});
600600

601+
it("builds schedule proposals from explicit time blocks without falling back to the default duration", async () => {
602+
const profile = buildDefaultUserProfile("user_1");
603+
profile.timezone = "America/Los_Angeles";
604+
605+
const result = await buildScheduleProposal({
606+
userId: "user_1",
607+
openTasks: [
608+
taskSchema.parse({
609+
id: "task-1",
610+
userId: "user_1",
611+
sourceInboxItemId: "inbox-1",
612+
lastInboxItemId: "inbox-1",
613+
title: "Quick check-in",
614+
lifecycleState: "pending_schedule",
615+
externalCalendarEventId: null,
616+
externalCalendarId: null,
617+
scheduledStartAt: null,
618+
scheduledEndAt: null,
619+
calendarSyncStatus: "in_sync",
620+
calendarSyncUpdatedAt: null,
621+
rescheduleCount: 0,
622+
lastFollowupAt: null,
623+
completedAt: null,
624+
archivedAt: null,
625+
priority: "medium",
626+
urgency: "medium"
627+
})
628+
],
629+
userProfile: profile,
630+
existingBlocks: [],
631+
referenceTime: "2026-03-20T16:00:00.000Z",
632+
scheduleConstraint: {
633+
dayReference: "today",
634+
weekday: null,
635+
weekOffset: null,
636+
relativeMinutes: null,
637+
explicitHour: 11,
638+
minute: 5,
639+
endExplicitHour: 11,
640+
endMinute: 9,
641+
preferredWindow: null,
642+
sourceText: "today from 11:05 to 11:09"
643+
}
644+
});
645+
646+
expect(result.inserts[0]?.startAt).toBe("2026-03-20T18:05:00.000Z");
647+
expect(result.inserts[0]?.endAt).toBe("2026-03-20T18:09:00.000Z");
648+
});
649+
650+
it("uses the explicit block duration when checking conflicts for schedule proposals", async () => {
651+
const profile = buildDefaultUserProfile("user_1");
652+
profile.timezone = "America/Los_Angeles";
653+
654+
const result = await buildScheduleProposal({
655+
userId: "user_1",
656+
openTasks: [
657+
taskSchema.parse({
658+
id: "task-1",
659+
userId: "user_1",
660+
sourceInboxItemId: "inbox-1",
661+
lastInboxItemId: "inbox-1",
662+
title: "Quick check-in",
663+
lifecycleState: "pending_schedule",
664+
externalCalendarEventId: null,
665+
externalCalendarId: null,
666+
scheduledStartAt: null,
667+
scheduledEndAt: null,
668+
calendarSyncStatus: "in_sync",
669+
calendarSyncUpdatedAt: null,
670+
rescheduleCount: 0,
671+
lastFollowupAt: null,
672+
completedAt: null,
673+
archivedAt: null,
674+
priority: "medium",
675+
urgency: "medium"
676+
})
677+
],
678+
userProfile: profile,
679+
existingBlocks: [
680+
scheduleBlockSchema.parse({
681+
id: "block-1",
682+
userId: "user_1",
683+
taskId: "task-a",
684+
startAt: "2026-03-20T18:00:00.000Z",
685+
endAt: "2026-03-20T18:05:00.000Z",
686+
confidence: 0.8,
687+
reason: "Existing block",
688+
rescheduleCount: 0,
689+
externalCalendarId: "primary"
690+
}),
691+
scheduleBlockSchema.parse({
692+
id: "block-2",
693+
userId: "user_1",
694+
taskId: "task-b",
695+
startAt: "2026-03-20T18:09:00.000Z",
696+
endAt: "2026-03-20T19:00:00.000Z",
697+
confidence: 0.8,
698+
reason: "Existing block",
699+
rescheduleCount: 0,
700+
externalCalendarId: "primary"
701+
})
702+
],
703+
referenceTime: "2026-03-20T16:00:00.000Z",
704+
scheduleConstraint: {
705+
dayReference: "today",
706+
weekday: null,
707+
weekOffset: null,
708+
relativeMinutes: null,
709+
explicitHour: 11,
710+
minute: 5,
711+
endExplicitHour: 11,
712+
endMinute: 9,
713+
preferredWindow: null,
714+
sourceText: "today from 11:05 to 11:09"
715+
}
716+
});
717+
718+
expect(result.inserts[0]?.startAt).toBe("2026-03-20T18:05:00.000Z");
719+
expect(result.inserts[0]?.endAt).toBe("2026-03-20T18:09:00.000Z");
720+
});
721+
601722
it("builds schedule adjustments from structured move requests", () => {
602723
const result = buildScheduleAdjustment({
603724
block: scheduleBlockSchema.parse({
@@ -660,6 +781,40 @@ describe("core package", () => {
660781
expect(result.newStartAt).toBe("2026-03-20T17:00:00.000Z");
661782
});
662783

784+
it("preserves explicit move-block durations from the structured constraint", () => {
785+
const result = buildScheduleAdjustment({
786+
block: scheduleBlockSchema.parse({
787+
id: "event-1",
788+
userId: "user-1",
789+
taskId: "task-1",
790+
startAt: "2026-03-20T18:00:00.000Z",
791+
endAt: "2026-03-20T19:00:00.000Z",
792+
confidence: 0.8,
793+
reason: "Existing slot",
794+
rescheduleCount: 0,
795+
externalCalendarId: "primary"
796+
}),
797+
userProfile: buildDefaultUserProfile("user-1"),
798+
scheduleConstraint: {
799+
dayReference: null,
800+
weekday: null,
801+
weekOffset: null,
802+
relativeMinutes: null,
803+
explicitHour: 11,
804+
minute: 5,
805+
endExplicitHour: 11,
806+
endMinute: 9,
807+
preferredWindow: null,
808+
sourceText: "11:05 to 11:09"
809+
},
810+
existingBlocks: [],
811+
referenceTime: "2026-03-20T16:00:00.000Z"
812+
});
813+
814+
expect(result.newStartAt).toBe("2026-03-20T18:05:00.000Z");
815+
expect(result.newEndAt).toBe("2026-03-20T18:09:00.000Z");
816+
});
817+
663818
it("interprets preferred-window scheduling in the user's timezone near UTC day boundaries", async () => {
664819
const profile = buildDefaultUserProfile("user_1");
665820
profile.timezone = "America/Los_Angeles";

packages/core/src/index.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,8 @@ export const scheduleConstraintSchema = z.object({
382382
relativeMinutes: z.number().int().positive().max(7 * 24 * 60).nullable().optional(),
383383
explicitHour: z.number().int().min(0).max(23).nullable(),
384384
minute: z.number().int().min(0).max(59).nullable().default(null),
385+
endExplicitHour: z.number().int().min(0).max(23).nullable().optional(),
386+
endMinute: z.number().int().min(0).max(59).nullable().optional(),
385387
preferredWindow: z.enum(["morning", "afternoon", "evening"]).nullable(),
386388
sourceText: z.string().min(1)
387389
}).superRefine((constraint, ctx) => {
@@ -424,6 +426,8 @@ export const scheduleConstraintSchema = z.object({
424426
constraint.weekOffset !== null ||
425427
constraint.explicitHour !== null ||
426428
constraint.minute !== null ||
429+
constraint.endExplicitHour != null ||
430+
constraint.endMinute != null ||
427431
constraint.preferredWindow !== null
428432
) {
429433
ctx.addIssue({
@@ -452,6 +456,30 @@ export const scheduleConstraintSchema = z.object({
452456
});
453457
}
454458

459+
if (constraint.explicitHour === null && constraint.endExplicitHour != null) {
460+
ctx.addIssue({
461+
code: z.ZodIssueCode.custom,
462+
message: "endExplicitHour requires explicitHour.",
463+
path: ["endExplicitHour"]
464+
});
465+
}
466+
467+
if (constraint.endExplicitHour != null && constraint.endMinute == null) {
468+
ctx.addIssue({
469+
code: z.ZodIssueCode.custom,
470+
message: "endMinute is required when endExplicitHour is provided.",
471+
path: ["endMinute"]
472+
});
473+
}
474+
475+
if (constraint.endExplicitHour == null && constraint.endMinute != null) {
476+
ctx.addIssue({
477+
code: z.ZodIssueCode.custom,
478+
message: "endMinute must be null unless endExplicitHour is provided.",
479+
path: ["endMinute"]
480+
});
481+
}
482+
455483
if (constraint.explicitHour === null && constraint.preferredWindow === null && constraint.minute !== null) {
456484
ctx.addIssue({
457485
code: z.ZodIssueCode.custom,
@@ -460,6 +488,32 @@ export const scheduleConstraintSchema = z.object({
460488
});
461489
}
462490

491+
if (constraint.preferredWindow !== null && constraint.endExplicitHour != null) {
492+
ctx.addIssue({
493+
code: z.ZodIssueCode.custom,
494+
message: "endExplicitHour must be null when preferredWindow is used.",
495+
path: ["endExplicitHour"]
496+
});
497+
}
498+
499+
if (
500+
constraint.explicitHour !== null &&
501+
constraint.minute !== null &&
502+
constraint.endExplicitHour != null &&
503+
constraint.endMinute != null
504+
) {
505+
const startMinutes = constraint.explicitHour * 60 + constraint.minute;
506+
const endMinutes = constraint.endExplicitHour * 60 + constraint.endMinute;
507+
508+
if (endMinutes <= startMinutes) {
509+
ctx.addIssue({
510+
code: z.ZodIssueCode.custom,
511+
message: "end time must be after the start time on the same day.",
512+
path: ["endExplicitHour"]
513+
});
514+
}
515+
}
516+
463517
});
464518

465519
export const scheduleConstraintResponseFormatSchema = z.object({
@@ -469,6 +523,8 @@ export const scheduleConstraintResponseFormatSchema = z.object({
469523
relativeMinutes: z.number().int().positive().max(7 * 24 * 60).nullable().optional(),
470524
explicitHour: z.number().int().min(0).max(23).nullable(),
471525
minute: z.number().int().min(0).max(59).nullable(),
526+
endExplicitHour: z.number().int().min(0).max(23).nullable().optional(),
527+
endMinute: z.number().int().min(0).max(59).nullable().optional(),
472528
preferredWindow: z.enum(["morning", "afternoon", "evening"]).nullable(),
473529
sourceText: z.string().min(1)
474530
});
@@ -857,8 +913,8 @@ export function buildScheduleAdjustment(input: {
857913
referenceTime: string;
858914
}) {
859915
const referenceTime = new Date(input.referenceTime);
860-
const durationMinutes =
861-
Math.max(15, Math.round((Date.parse(input.block.endAt) - Date.parse(input.block.startAt)) / 60000)) || 60;
916+
const existingDurationMinutes = Math.round((Date.parse(input.block.endAt) - Date.parse(input.block.startAt)) / 60000);
917+
const durationMinutes = computeConstraintDurationMinutes(input.scheduleConstraint) ?? (existingDurationMinutes || 60);
862918
const startAt = computeStartAt({
863919
referenceTime,
864920
profile: input.userProfile,
@@ -889,9 +945,23 @@ type ComputeStartAtInput = {
889945
slotOffset: number;
890946
};
891947

948+
function computeConstraintDurationMinutes(constraint: ScheduleConstraint | null) {
949+
if (
950+
constraint?.explicitHour == null ||
951+
constraint.minute == null ||
952+
constraint.endExplicitHour == null ||
953+
constraint.endMinute == null
954+
) {
955+
return null;
956+
}
957+
958+
return constraint.endExplicitHour * 60 + constraint.endMinute - (constraint.explicitHour * 60 + constraint.minute);
959+
}
960+
892961
function computeStartAt(input: ComputeStartAtInput) {
893962
const start = new Date(input.referenceTime);
894963
start.setUTCSeconds(0, 0);
964+
const constraintDurationMinutes = computeConstraintDurationMinutes(input.constraint);
895965

896966
if (input.constraint) {
897967
if (input.constraint.relativeMinutes != null) {
@@ -919,7 +989,7 @@ function computeStartAt(input: ComputeStartAtInput) {
919989
hour,
920990
minute: input.constraint.minute ?? 0
921991
}),
922-
input.profile.focusBlockMinutes,
992+
constraintDurationMinutes ?? input.profile.focusBlockMinutes,
923993
input.existingBlocks
924994
);
925995
}
@@ -951,7 +1021,8 @@ function computeStartAt(input: ComputeStartAtInput) {
9511021

9521022
function computeEndAt(input: ComputeStartAtInput) {
9531023
const start = computeStartAt(input);
954-
return new Date(start.getTime() + input.profile.focusBlockMinutes * 60_000);
1024+
const durationMinutes = computeConstraintDurationMinutes(input.constraint) ?? input.profile.focusBlockMinutes;
1025+
return new Date(start.getTime() + durationMinutes * 60_000);
9551026
}
9561027

9571028
function advanceForConflicts(start: Date, durationMinutes: number, existingBlocks: ScheduleBlock[]) {

packages/integrations/src/prompts/planner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const inboxPlannerSystemPrompt = buildPromptSpec([
5656
"Use weekOffset=0 for Friday or this Friday, weekOffset=1 for next Friday, and weekOffset=2 for next next Friday.",
5757
"For time-only phrases like at 3pm, leave dayReference, weekday, and weekOffset null.",
5858
"Use explicitHour and minute for exact times.",
59+
"When the user gives an explicit time block like '11:05 to 11:09' or 'from 2pm to 2:30pm', also set endExplicitHour and endMinute so Atlas preserves that exact duration.",
5960
"Use preferredWindow only for broad phrases like morning, afternoon, or evening, and leave explicitHour null in those cases.",
6061
"If the user gives a soft but usable preference like 'morning but not too early', infer a sensible time instead of asking for an exact hour. For example, 10:30am is a valid interpretation.",
6162
"Do not ask for an exact time when the user has already given a usable broad window or delegated slot choice to Atlas.",
@@ -89,6 +90,7 @@ export const inboxPlannerSystemPrompt = buildPromptSpec([
8990
"in like 15 min -> relativeMinutes=15, dayReference=null, weekday=null, weekOffset=null, explicitHour=null, minute=null, preferredWindow=null.",
9091
"Friday morning -> dayReference='weekday', weekday='friday', weekOffset=0, explicitHour=null, preferredWindow='morning'.",
9192
"at 3pm -> dayReference=null, weekday=null, weekOffset=null, explicitHour=15, minute=0.",
93+
"today from 11:05 to 11:09 -> dayReference='today', explicitHour=11, minute=5, endExplicitHour=11, endMinute=9.",
9294
"If context.referenceTime is Wednesday, March 18, 2026 in America/Los_Angeles, then Friday at 10am -> dayReference='weekday', weekday='friday', weekOffset=0, explicitHour=10, minute=0.",
9395
"If context.referenceTime is Wednesday, March 18, 2026 in America/Los_Angeles, then next Friday at 10am -> dayReference='weekday', weekday='friday', weekOffset=1, explicitHour=10, minute=0.",
9496
"If the user says 'journal is done' and the provided task context includes one journaling task alias, emit exactly one complete_task action for that existing task alias.",

0 commit comments

Comments
 (0)