From c10f8ad8b2195551454c95365cff4caa90c66101 Mon Sep 17 00:00:00 2001 From: cristoforows Date: Fri, 3 Jul 2026 01:56:12 +0800 Subject: [PATCH 1/3] Add work-window config and extend day to midnight New TIMEBOX_WORK_START/END/END_HARD bound the working band inside the day; day end moves to 23:59 so the evening is plannable. --- .env.example | 7 ++++++- src/config.py | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 7dda021..663fc1b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/config.py b/src/config.py index 943bba5..ccefabb 100644 --- a/src/config.py +++ b/src/config.py @@ -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) From d6601eb823ebb3e3d98766aaf05c6ac2e19166a3 Mon Sep 17 00:00:00 2001 From: cristoforows Date: Fri, 3 Jul 2026 01:56:12 +0800 Subject: [PATCH 2/3] Add non-working mode and workday boundary rules to planner - Mode gains 'nonworking'; commute line switches via a _MODE_BLOCK map - DayConfig carries the work window; working days get boundary + focus-fill rules (work tasks inside the window, <=60m non-work inside it, Work/Personal Focus placeholders filling gaps to 23:30), injected for office/wfh only - Dropped tasks now render a shared 'couldn't be scheduled' notice --- src/scheduler.py | 75 ++++++++++++++++++++++++++++++++--------- tests/test_scheduler.py | 26 ++++++++++++-- 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/scheduler.py b/src/scheduler.py index 09e7da4..9dee6eb 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -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) @@ -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 @@ -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. @@ -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 = ( @@ -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): @@ -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"), @@ -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, ) @@ -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 diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 1c22af0..7be40be 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -25,6 +25,9 @@ commute_morning="08:00", commute_evening="18:00", commute_duration_min=45, + work_start="09:00", + work_end="17:00", + work_end_hard="18:00", ) @@ -112,7 +115,7 @@ def test_render_includes_dropped_section_with_reasons(): _result(dropped=[DroppedTask(task="organize garage", reason="no time left in the day")]), date(2026, 6, 12), ) - assert "Dropped:" in text + assert "couldn't be scheduled" in text assert "organize garage" in text and "no time left in the day" in text @@ -159,7 +162,26 @@ def test_wfh_mode_omits_commute_blocks(): generate_schedule(["gym"], date(2026, 6, 12), llm, DAY, "wfh") system_text = llm.structured.messages[0][1] assert "no commute" in system_text.lower() - assert "08:00" not in system_text and "18:00" not in system_text + # morning commute anchor absent (18:00 now legitimately appears as the work + # window's hard end) + assert "08:00" not in system_text + + +def test_working_modes_include_work_window_and_focus_rules(): + llm = FakeLLM(_result()) + generate_schedule(["email"], date(2026, 6, 12), llm, DAY, "wfh") + system_text = llm.structured.messages[0][1] + assert "Work Focus" in system_text and "Personal Focus" in system_text + assert "17:00" in system_text # work-window soft end + + +def test_nonworking_mode_omits_work_window_and_commute(): + llm = FakeLLM(_result()) + generate_schedule(["read a book"], date(2026, 6, 12), llm, DAY, "nonworking") + system_text = llm.structured.messages[0][1] + assert "Non-working day" in system_text + assert "Work Focus" not in system_text + assert "17:00" not in system_text # work window not injected def test_single_failure_is_retried_transparently(): From e46e4276ca49f550d017e41a2da585f909a33895 Mon Sep 17 00:00:00 2001 From: cristoforows Date: Fri, 3 Jul 2026 01:56:12 +0800 Subject: [PATCH 3/3] Add non-working-day start-time step to /timebox Picking 'Day off' asks for a start time (24h free text like 900/1330, or a 09:00 default button) before collecting tasks, overriding the day start. --- src/timebox.py | 115 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 21 deletions(-) diff --git a/src/timebox.py b/src/timebox.py index fd8f29a..bdd7d73 100644 --- a/src/timebox.py +++ b/src/timebox.py @@ -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 " @@ -48,10 +50,13 @@ ) -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, @@ -59,9 +64,51 @@ def _day_config() -> scheduler.DayConfig: 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") @@ -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 @@ -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: @@ -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 @@ -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) @@ -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}") @@ -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 @@ -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}: " @@ -290,6 +356,7 @@ 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 @@ -297,6 +364,7 @@ async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: 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. " @@ -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),