Split Daily/Weekly routines, then layered them onto the day timetable
Splitting a 1179-line screen pushed the shared utility out, and that utility brought routine backgrounds to the calendar's day view almost for free.
Split Daily/Weekly routines, then layered them onto the day timetable
Starting point: “Should we split Daily and Weekly Routine?”
The existing list-daily-routine.tsx was a single screen that toggled between Daily and Weekly. The data model already supported both, and it worked.
But Weekly felt buried under the toggle. To give it equal weight, splitting into separate screens seemed cleaner. So I went with that.
1179 lines
The original file was 1179 lines. Daily single-timeline view, Weekly 7-day grid view, single-day view, drag/resize handlers, block overlap resolution, time conversion helpers — all in one file. So this wasn’t going to be a “remove the toggle and call it done” job.
Three options:
- A) Clean split: Extract shared utilities to
utils/routine/util.ts, two independent screens - B) Quick split: Copy the file, keep only each side’s logic (some duplication)
- C) Route alias only: Same component on two routes
Went with A.
What got extracted
Shared utilities:
- Constants:
HOUR_HEIGHT,TIME_LABEL_WIDTH,BLOCK_GAP,HOURS,DAYS, etc. - Type:
Block - Functions:
formatHour,formatTime,snapToGrid,pxToTimeDate,getDayKey,buildBlockFromRoutine,assignOverlapColumns,buildBlocks,buildWeeklyBlocks
buildBlocks is the core function that takes a routine list and resolves time overlaps into a column layout. Pulling that into a utility was the biggest win.
After that:
list-daily-routine.tsx: Weekly-related code removed. 1179 lines → ~370.list-weekly-routine.tsx: New file. Day picker + ALL view + single-day view + Daily background blocks.- Menu: added “Weekly Routine” entry to
menu-calendar.
”This will probably get used somewhere else”
After the split, I noticed the day-detail screen — the one you enter by tapping a date in the calendar (DayTimeTable) — could also benefit from the same routine background idea.
The Weekly Routine view already shows Daily routines as faded backgrounds for context. But the DayTimeTable was missing that piece. To check “what routines are scheduled on Tuesday today,” you had to navigate over to the routine screen.
Since buildBlocks was already a utility, all the DayTimeTable needed was a call to it.
Adding a prop to DailyTimelineView
routineBackgroundBlocks?: RoutineBackgroundBlock[];
The component renders these as faded blocks one z-order below the regular task blocks.
In browse-calendar-date.tsx:
- Fetch the daily routines (they fire every day)
- Fetch the weekly routines, then keep only the ones whose
daymatches the selected date’s weekday - Combine and pass through
buildBlocks - Pass as
routineBackgroundBlocks
One concern: if every entry into the calendar’s day view triggers two fetches, that’s wasteful.
Cache vs. fetch every time
Three options on the table:
- Fetch every time (simple)
- Simple cache + explicit invalidate (atom)
- Proper infrastructure — SQLite + SSE
I started with option 1, but then reconsidered. Went with option 3 in a hybrid form:
- No SQLite (routines aren’t hot data the way task_records are)
- Atom-based cache
- SSE events for invalidation
Server-side event publishing
Added publish_create_reservation_event to the events module, and called it from every reservation mutation endpoint (register, 9 PUT mutations, delete). The pattern was similar enough that I accidentally added it to read endpoints (gather, get) too — had to clean that up.
def publish_create_reservation_event(achiever_id: str, action: str = "update", exclude_connection_id: Optional[str] = None):
publish_event(achiever_id, "create_reservation_updated", {"action": action}, exclude_connection_id)
exclude_connection_id is None, which means the event echoes back to the originating device. This is intentional — if my own device receives my own event and invalidates the cache, I don’t have to call invalidate at every mutation site on the client.
Client side
// atoms.ts
export const dailyRoutinesCacheAtom = atom<CreateReservationModel[] | null>(null);
export const weeklyRoutinesCacheAtom = atom<CreateReservationModel[] | null>(null);
null = not loaded. Once loaded, holds the array.
// (tabs)/_layout.tsx
const handleReservationUpdate = () => {
setDailyRoutinesCache(null);
setWeeklyRoutinesCache(null);
};
sseClient.on("create_reservation_updated", handleReservationUpdate);
When the SSE event arrives, both atoms reset to null. Next time browse-calendar-date reads them, it sees null and fetches.
Text legibility detail
The first version of the routine background had opacity: 0.35 on the parent View. That faded the text inside it too — a faded background with crisp text was the goal, but I got faded everything.
Split:
- Outer View: position and size only, no opacity
- Inner absolute-fill View: background color + opacity → faded fill
- Text: full opacity, crisp
Applied the same pattern to the Weekly Routine view as well — the daily background in both the ALL view and the single-day view.
After that, the text felt “a bit dark.” Stepped down two shades: NEUTRAL700 → NEUTRAL600 → NEUTRAL500. The background is just context, so it should stay quiet.
Weekly Routine defaults to ALL view
When the Weekly Routine screen opens, today’s weekday was auto-selected. But the ALL view shows more information at a glance, so the default became ALL.
// before
const [selectedDay, setSelectedDay] = useState(today's day);
// after
const [selectedDay, setSelectedDay] = useState<CreateReservationDay | null>(null);
null means ALL view.
Wrap-up
- 1179-line single screen → two screens + shared utility
- Once
buildBlockswas a utility, DayTimeTable could use it too - Routine backgrounds in DayTimeTable = you can now see “what routines fire on this date” right in the day view
- SSE-based cache invalidation infrastructure (maybe overkill, but lazily it’s the cleanest)
- Tuned text legibility and default view
Refactors sometimes hand you a bonus you didn’t plan for. I extracted utilities to split Daily and Weekly, and the utility happened to be in the right shape for another screen (DayTimeTable) to consume — so a new feature followed almost on its own.
I started out just trying to split things, and the calendar’s day view UX got upgraded along the way. That’s the real reward of breaking up a monolithic screen, I think — not fewer lines of code, but parts you can reuse next time.