Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,15 @@ TIMEBOX_CALENDAR_ID=
# Times are HH:MM (24h); durations are minutes. A task that mentions a different
# time overrides the matching default for that day.
TIMEBOX_DAY_START=09:00
TIMEBOX_DAY_END=22:00
TIMEBOX_DAY_END=23:59
TIMEBOX_LUNCH=12:30
TIMEBOX_DINNER=19:00
TIMEBOX_EAT_DURATION=60
# Work window (Office/WFH days only): the narrower band inside the day where
# work tasks live. Work end is preferred (soft); the hard end is never crossed.
TIMEBOX_WORK_START=09:00
TIMEBOX_WORK_END=17:00
TIMEBOX_WORK_END_HARD=18:00
# Commute blocks are only added in Office mode (chosen per session).
TIMEBOX_COMMUTE_MORNING=08:00
TIMEBOX_COMMUTE_EVENING=18:00
Expand Down
7 changes: 6 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ def __init__(self):
# Timebox -> Google Calendar publishing
self.timebox_calendar_id = self._get_timebox_calendar_id()
self.timebox_day_start = self._get_time_of_day('TIMEBOX_DAY_START', '09:00')
self.timebox_day_end = self._get_time_of_day('TIMEBOX_DAY_END', '22:00')
self.timebox_day_end = self._get_time_of_day('TIMEBOX_DAY_END', '23:59')
# Work window (office/wfh only): the narrower band inside the day where
# work tasks live. Soft end is preferred; hard end is never crossed.
self.timebox_work_start = self._get_time_of_day('TIMEBOX_WORK_START', '09:00')
self.timebox_work_end = self._get_time_of_day('TIMEBOX_WORK_END', '17:00')
self.timebox_work_end_hard = self._get_time_of_day('TIMEBOX_WORK_END_HARD', '18:00')
self.timebox_lunch = self._get_time_of_day('TIMEBOX_LUNCH', '12:30')
self.timebox_dinner = self._get_time_of_day('TIMEBOX_DINNER', '19:00')
self.timebox_eat_duration = self._get_duration_minutes('TIMEBOX_EAT_DURATION', 60)
Expand Down
75 changes: 60 additions & 15 deletions src/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
_MAX_ATTEMPTS = 2 # one automatic retry

Mode = Literal["office", "wfh"]
Mode = Literal["office", "wfh", "nonworking"]


@dataclass(frozen=True)
Expand All @@ -29,6 +29,8 @@ class DayConfig:

Times are HH:MM (24h); durations are minutes. The LLM owns placement and
may override any of these when a user task clearly states a different time.
The work window (work_start/work_end/work_end_hard) only applies on working
days (office/wfh); it is ignored on non-working days.
"""

day_start: str
Expand All @@ -39,6 +41,9 @@ class DayConfig:
commute_morning: str
commute_evening: str
commute_duration_min: int
work_start: str
work_end: str
work_end_hard: str


# The prompt is expected to be tuned after a few days of real use β€” keep it here.
Expand All @@ -63,10 +68,32 @@ class DayConfig:
(e.g. "morning", "after 6pm"). Honor them exactly.
- Estimate a sensible duration for any task that has none.
- Time slots must not overlap. Short breathing gaps between tasks are fine.
- Use 24-hour HH:MM times.
{workday_rules}\
- If everything cannot fit in the day, drop the least important / least \
time-pressured tasks until the plan fits. Report every dropped task with a \
one-line reason. Never ask the user questions.
- Use 24-hour HH:MM times.
"""

# Working-day boundaries + focus-slot filling. Injected for office/wfh only;
# non-working days get an empty string here.
_WORKDAY_RULES = """\
- Classify each task as work (job/professional) or non-work (personal, errands, \
leisure) by inferring from its wording.
- Work tasks belong in the work window {work_start}-{work_end}. You may extend \
to {work_end_hard} only when necessary, but avoid the {work_end}-{work_end_hard} \
hour when you can. Never schedule work past {work_end_hard}; if work tasks cannot \
fit by {work_end_hard}, drop the least important and report them.
- Non-work tasks may take at most 60 minutes TOTAL inside the work window \
{work_start}-{work_end}; schedule any remaining non-work tasks after {work_end}.
- Leave no empty stretches in the day β€” fill every gap with placeholder focus \
slots the user decides on the day itself:
- Between {work_start} and {work_end}, fill gaps with 90-minute "Work Focus" \
slots separated by 15-minute "Break" slots.
- Between {work_end} and 23:30, fill gaps with 90-minute "Personal Focus" \
slots separated by 15-minute "Break" slots.
Keep these focus slots even when there is nothing else to schedule. Never \
create a focus slot after 23:30.
"""

_COMMUTE_OFFICE = (
Expand All @@ -75,7 +102,11 @@ class DayConfig:
"- Evening commute at {commute_evening} for {commute_duration} minutes "
'(task name: "Commute").'
)
_COMMUTE_WFH = "- Working from home today: no commute blocks."
_MODE_BLOCK = {
"office": _COMMUTE_OFFICE,
"wfh": "- Working from home today: no commute blocks.",
"nonworking": "- Non-working day (day off): no commute blocks.",
}


class ScheduleItem(BaseModel):
Expand Down Expand Up @@ -125,14 +156,20 @@ class ScheduleGenerationError(Exception):

def _build_system_prompt(target_date: date, day: DayConfig, mode: Mode) -> str:
"""Render the planner system prompt for the given day config and mode."""
if mode == "office":
commute_block = _COMMUTE_OFFICE.format(
commute_morning=day.commute_morning,
commute_evening=day.commute_evening,
commute_duration=day.commute_duration_min,
)
commute_block = _MODE_BLOCK[mode].format(
commute_morning=day.commute_morning,
commute_evening=day.commute_evening,
commute_duration=day.commute_duration_min,
)
# Work-window boundaries and focus filling apply on working days only.
if mode == "nonworking":
workday_rules = ""
else:
commute_block = _COMMUTE_WFH
workday_rules = _WORKDAY_RULES.format(
work_start=day.work_start,
work_end=day.work_end,
work_end_hard=day.work_end_hard,
)
return _SYSTEM_PROMPT.format(
target_date=target_date.isoformat(),
weekday=target_date.strftime("%A"),
Expand All @@ -143,6 +180,7 @@ def _build_system_prompt(target_date: date, day: DayConfig, mode: Mode) -> str:
dinner=day.dinner,
eat_duration=day.eat_duration_min,
commute_block=commute_block,
workday_rules=workday_rules,
)


Expand Down Expand Up @@ -183,9 +221,16 @@ def render_schedule(result: TimeboxResult, target_date: date) -> str:
if item.note:
line += f" ({item.note})"
lines.append(line)
if result.dropped:
lines.append("")
lines.append("Dropped:")
for item in result.dropped:
lines.append(f"- {item.task} β€” {item.reason}")
lines.extend(dropped_notice_lines(result))
return "\n".join(lines)


def dropped_notice_lines(result: TimeboxResult) -> list[str]:
"""Render the shared 'couldn't be scheduled' notice, or nothing if all fit."""
if not result.dropped:
return []
n = len(result.dropped)
lines = ["", f"⚠️ {n} task(s) couldn't be scheduled (ran out of time):"]
for item in result.dropped:
lines.append(f"- {item.task} β€” {item.reason}")
return lines
115 changes: 94 additions & 21 deletions src/timebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@

logger = logging.getLogger(__name__)

CHOOSING_MODE, COLLECTING, CONFIRM_REDO = range(3)
CHOOSING_MODE, COLLECTING, CONFIRM_REDO, ASK_START = range(4)
SESSION_TIMEOUT_SECONDS = 30 * 60
_TASKS_KEY = "timebox_tasks"
_MODE_KEY = "timebox_mode"
_DATE_KEY = "timebox_date"
_START_KEY = "timebox_start"
_DEFAULT_START = "09:00"

_COLLECT_PROMPT = (
"Send tomorrow's tasks, one per message β€” optionally with a duration or "
Expand All @@ -48,20 +50,65 @@
)


def _day_config() -> scheduler.DayConfig:
"""Build the planner's fixed-block config from environment settings."""
def _day_config(start_override: str | None = None) -> scheduler.DayConfig:
"""Build the planner's fixed-block config from environment settings.

start_override replaces the day's start time (used by non-working days,
where the user picks their own start)."""
return scheduler.DayConfig(
day_start=config.timebox_day_start,
day_start=start_override or config.timebox_day_start,
day_end=config.timebox_day_end,
lunch=config.timebox_lunch,
dinner=config.timebox_dinner,
eat_duration_min=config.timebox_eat_duration,
commute_morning=config.timebox_commute_morning,
commute_evening=config.timebox_commute_evening,
commute_duration_min=config.timebox_commute_duration,
work_start=config.timebox_work_start,
work_end=config.timebox_work_end,
work_end_hard=config.timebox_work_end_hard,
)


def _parse_time(text: str) -> str | None:
"""Parse a 24-hour time into "HH:MM", or None if unreadable.

Accepts compact digits (900->09:00, 1300->13:00, 0930->09:30) and colon
form (9:00, 13:30). Rejects fewer than 3 digits (ambiguous) and any
out-of-range hour/minute."""
s = text.strip()
if ":" in s:
parts = s.split(":")
if len(parts) != 2 or not (parts[0].isdigit() and parts[1].isdigit()):
return None
hour, minute = int(parts[0]), int(parts[1])
elif s.isdigit() and 3 <= len(s) <= 4:
hour, minute = int(s[:-2]), int(s[-2:])
else:
return None
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
return f"{hour:02d}:{minute:02d}"


async def _begin_collection(query, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Start task collection. On a non-working day, first ask for a start time."""
context.user_data[_TASKS_KEY] = []
mode = context.user_data.get(_MODE_KEY, "wfh")
if mode == "nonworking":
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(f"Start {_DEFAULT_START}", callback_data="start:default")]]
)
await query.edit_message_text(
"Non-working day β€” what time do you want to start? Send a 24-hour "
"time like 900 or 1330, or tap the button for the default.",
reply_markup=keyboard,
)
return ASK_START
await query.edit_message_text(f"Mode: {mode}. {_COLLECT_PROMPT}")
return COLLECTING


async def start_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Handle /timebox - authenticate, then ask for today's mode."""
token_storage = context.bot_data.get("token_storage")
Expand All @@ -75,13 +122,16 @@ async def start_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i
return ConversationHandler.END

keyboard = InlineKeyboardMarkup(
[[
InlineKeyboardButton("🏒 Office", callback_data="mode:office"),
InlineKeyboardButton("🏠 WFH", callback_data="mode:wfh"),
]]
[
[
InlineKeyboardButton("🏒 Office", callback_data="mode:office"),
InlineKeyboardButton("🏠 WFH", callback_data="mode:wfh"),
],
[InlineKeyboardButton("🌴 Day off", callback_data="mode:nonworking")],
]
)
await update.message.reply_text(
"Timebox session β€” where are you working today?", reply_markup=keyboard
"Timebox session β€” what kind of day is tomorrow?", reply_markup=keyboard
)
logger.info(f"Timebox session started for user {user_id}")
return CHOOSING_MODE
Expand Down Expand Up @@ -149,9 +199,7 @@ async def choose_mode(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int
)
return CONFIRM_REDO

context.user_data[_TASKS_KEY] = []
await query.edit_message_text(f"Mode: {mode}. {_COLLECT_PROMPT}")
return COLLECTING
return await _begin_collection(query, context)


async def confirm_redo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
Expand All @@ -163,9 +211,28 @@ async def confirm_redo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> in
await query.edit_message_text("Keeping your existing schedule. Session ended.")
return ConversationHandler.END

context.user_data[_TASKS_KEY] = []
mode = context.user_data.get(_MODE_KEY, "wfh")
await query.edit_message_text(f"Redoing. Mode: {mode}. {_COLLECT_PROMPT}")
return await _begin_collection(query, context)


async def start_from_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Handle the default-start button on a non-working day."""
query = update.callback_query
await query.answer()
context.user_data[_START_KEY] = _DEFAULT_START
await query.edit_message_text(f"Starting at {_DEFAULT_START}. {_COLLECT_PROMPT}")
return COLLECTING


async def set_start_time(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Parse the user's free-text start time; re-ask on unreadable input."""
parsed = _parse_time(update.message.text)
if parsed is None:
await update.message.reply_text(
"Couldn't read that β€” send a 24-hour time like 900 or 1330."
)
return ASK_START
context.user_data[_START_KEY] = parsed
await update.message.reply_text(f"Starting at {parsed}. {_COLLECT_PROMPT}")
return COLLECTING


Expand Down Expand Up @@ -201,11 +268,7 @@ def _render_published(result, write_results, target_date: date) -> str:
else:
lines.append(f"Added {total} events to your calendar.")

if result.dropped:
lines.append("")
lines.append("Dropped:")
for item in result.dropped:
lines.append(f"- {item.task} β€” {item.reason}")
lines.extend(scheduler.dropped_notice_lines(result))
return "\n".join(lines)


Expand All @@ -224,8 +287,9 @@ async def done(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:

try:
# to_thread: the langchain call is sync; don't block the event loop
day = _day_config(context.user_data.get(_START_KEY))
result = await asyncio.to_thread(
scheduler.generate_schedule, tasks, target_date, _llm(), _day_config(), mode
scheduler.generate_schedule, tasks, target_date, _llm(), day, mode
)
except scheduler.ScheduleGenerationError:
logger.error(f"Schedule generation failed twice for user {user_id}")
Expand All @@ -239,6 +303,7 @@ async def done(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
# No calendar configured: reply text only and end.
if not config.timebox_calendar_id:
context.user_data.pop(_TASKS_KEY, None)
context.user_data.pop(_START_KEY, None)
await update.message.reply_text(scheduler.render_schedule(result, target_date))
return ConversationHandler.END

Expand Down Expand Up @@ -279,6 +344,7 @@ async def done(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
return COLLECTING

context.user_data.pop(_TASKS_KEY, None)
context.user_data.pop(_START_KEY, None)
await update.message.reply_text(reply)
logger.info(
f"Timebox schedule for {target_date} published for user {user_id}: "
Expand All @@ -290,13 +356,15 @@ async def done(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Handle /cancel - abort the session and clear the buffer."""
context.user_data.pop(_TASKS_KEY, None)
context.user_data.pop(_START_KEY, None)
await update.message.reply_text("Timebox session cancelled.")
return ConversationHandler.END


async def expire(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Notify the user when the session times out; the buffer is discarded."""
context.user_data.pop(_TASKS_KEY, None)
context.user_data.pop(_START_KEY, None)
if update.effective_message:
await update.effective_message.reply_text(
"Timebox session expired after 30 minutes of inactivity. "
Expand All @@ -319,6 +387,11 @@ def build_timebox_handler() -> ConversationHandler:
states={
CHOOSING_MODE: [CallbackQueryHandler(choose_mode, pattern=r"^mode:")],
CONFIRM_REDO: [CallbackQueryHandler(confirm_redo, pattern=r"^redo:")],
ASK_START: [
CallbackQueryHandler(start_from_button, pattern=r"^start:"),
CommandHandler("cancel", cancel),
MessageHandler(task_message, set_start_time),
],
COLLECTING: [
CommandHandler("done", done),
CommandHandler("cancel", cancel),
Expand Down
Loading
Loading