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