← Write Ups

Work Blocks: A Pomodoro Scheduler

Early in covid, a lot of people were suddenly working remote with a workday that had bookends but no structure in between. Crystal and I built Work Blocks, an app that scheduled pomodoro-style focus blocks around whatever was already fixed in your day.

The concept

Set your work hours. The app reads Google Calendar for meetings and other unmovable blocks. Everything else is open for “workblocks” — focus chunks with breaks between them.

A task like “Write report” estimated at two and a half hours becomes six 25-minute workblocks with 10-minute breaks (both configurable), scheduled into whatever open time is left in the day. Reminders and start/stop handle the rest.

Built in Python with tkinter.

The tech

tkinter is event-driven, which requires structuring the app around a mainloop dispatching to callbacks instead of running top-to-bottom. Storage was just pickled Python objects to disk; no database, since the app didn’t need one. Calendar reads went through Google’s OAuth, which meant registering Work Blocks as a Google app to get credentials.

The scheduling logic

Given work hours, a set of fixed meetings, and a task with a duration, the scheduler finds the open gaps in the day and packs workblocks (plus breaks) into them in order. That’s the core of the app, and the part worth demoing.

Try it

This is a quick recreation in JS to demo the scheduling logic, not the original app. Set work hours, add or edit a couple of meetings, give it a task and a duration, and hit Schedule.

Meeting Workblock Break Free

Drag the slider to see what the app’s current-block view would show at that point in the schedule.

The matching algorithm

Given the day’s bounds and the fixed meetings, the first pass finds the free gaps by walking the sorted meetings and taking whatever’s left over:

def free_gaps(day_start, day_end, meetings):
    gaps = []
    cursor = day_start
    for start, end, _title in sorted(meetings):
        start = max(start, day_start)
        end = min(end, day_end)
        if start > cursor:
            gaps.append((cursor, start))
        cursor = max(cursor, end)
    if cursor < day_end:
        gaps.append((cursor, day_end))
    return gaps

The second pass walks those gaps and packs in blocks and breaks until the task’s total minutes are used up or the day runs out of room:

def schedule_blocks(gaps, total_minutes, block_len, break_len):
    blocks = []
    remaining = total_minutes
    for gap_start, gap_end in gaps:
        cursor = gap_start
        while remaining > 0 and cursor + min(block_len, remaining) <= gap_end:
            length = min(block_len, remaining)
            blocks.append(("block", cursor, cursor + length))
            cursor += length
            remaining -= length
            if remaining > 0 and cursor + break_len <= gap_end:
                blocks.append(("break", cursor, cursor + break_len))
                cursor += break_len
            else:
                break
        if remaining <= 0:
            break
    return blocks, max(0, remaining)

The scheduler skips a gap that’s too small for even one block entirely, and a task that doesn’t fit in the day comes back with remaining > 0 so the app can flag it instead of silently dropping time.