Making the Desktop Calendar Feel Alive
Drag-and-drop across three views, guide lines that follow your cursor, optimistic updates, and the design shift that made everything more readable.
Making the Desktop Calendar Feel Alive
The desktop version of Fecit’s calendar had the basics: month view, week view, task bars. But it felt like a picture of a schedule, not a schedule you could work with. I wanted to reach into it. Move things. Feel the day change shape under my hands.
One Pixel That Ruins Everything
The first thing that bothered me was the task blocks. Each one was a solid rectangle filled with its point color. It looked fine — until it didn’t. Midnight blue with white text? Readable. Lemon yellow with white text? Gone. Every color needed its own text treatment, and even when the contrast was technically sufficient, the visual weight felt inconsistent. A wall of colored blocks at different brightnesses doesn’t read as a unified interface. It reads as noise.
The mobile app had already solved this months ago. Neutral background. A thin 4px bar on the left edge carries the color. The star icon picks up the same hue. Title is always dark. Time is always gray. It works with every single one of the twenty point colors, no exceptions, no conditional logic.
Porting this to desktop took ten minutes. The readability improvement was instant and dramatic. But getting the border between the block and the calendar background right — that took longer. The blocks use NEUTRAL50, and the calendar background is also NEUTRAL50. Identical. The blocks disappeared into the grid. I tried white backgrounds, but that breaks dark theme. Ended up with a 1px solid NEUTRAL300 border and a subtle box-shadow. The shadow is rgba(0,0,0,0.06) — barely visible, but enough to lift the block off the surface. Remove it, and the blocks feel printed on the page. Add too much, and it looks like a card UI from 2018. There’s exactly one right amount.
The Day View Nobody Asked For
There was no day view on desktop. You could see a day’s tasks in a modal overlay, but there was no way to see the shape of a day — to see at a glance that your morning is packed and your afternoon is empty, or that you have a two-hour gap between meetings.
I built CalendarDayView as a full takeover. When you hover a date cell in the month view, a maximize icon appears in the top-right corner. Click it, and the month view slides away. You’re inside the day now: 24 hours, vertical timeline, tasks as blocks with the new left-border design.
The back button uses a proper back.svg icon, not a text label. The date sits in the center with left/right arrows. Generate and create buttons on the right. It looks simple, but the layout went through three iterations. First version: back button left, date left, arrows right. Too heavy on the left. Second: back button left, date center, arrows center, close button right. The close button felt redundant next to the back button. Final version: back left, date+arrows centered, action buttons right. Balanced.
One detail I almost shipped without: scroll position preservation. You scroll down to week 47, click into a day, browse around, click back — and you’re at the top of the calendar. All context lost. The fix was saving scrollTop to a ref when entering the day view and restoring it in a useLayoutEffect when returning. Without this, the feature is technically complete but practically frustrating. The kind of thing users would never praise but would immediately notice if missing.
Guide Lines: The Difference Between Guessing and Knowing
The week view already had drag-and-drop. You could long-press a block and move it. But during the drag, you were guessing. Where exactly will this land? What time does this vertical position correspond to?
On mobile, guide lines solve this. Two horizontal lines — one at the projected top, one at the projected bottom — stretch across the timeline. A small pill shows “09:30 – 10:45.” You know before you let go.
I added the same to desktop, then spent an unreasonable amount of time on the tooltip.
First problem: the tooltip text wasn’t vertically centered in its pill. At fontSize: 10, the default line-height is roughly 1.4, and the extra space distributes unevenly — more above the baseline than below. The letter “0” appeared to float slightly high. Setting lineHeight: 1 helped, but then paddingBlock: 2 made the top gap feel larger than the bottom. The ascender of the font is taller than its descender, so equal padding looks unequal. Final values: paddingTop: 1, paddingBottom: 1, with lineHeight: 1. The text now sits where your eyes expect it. I verified it at 2x zoom.
Second problem: the guide lines stopped at the timeline column edge. They didn’t extend into the time label area on the left. This meant the guide line and the time labels existed in disconnected visual spaces. Extending the lines to left: 0 — the full width of the container — made everything feel connected. The tooltip moved to left: 4 to sit in the time label zone. Small change, but it unified the whole drag experience.
Third problem: font-variant-numeric: tabular-nums. Without it, the tooltip width jitters as you drag past “09” → “10” because the digit widths differ in proportional fonts. With tabular nums, every digit occupies the same width. The tooltip holds still. You don’t notice it when it’s there, but you feel the instability when it’s not.
The Bug That Wasted an Hour
After implementing drag-and-drop, it didn’t work. You’d drag a block, release it, and nothing happened. The block snapped back.
The issue was a stale closure. handleUp was registered as a mouseup listener inside a useEffect. It captured dragDelta from state at the time the effect ran — which was the beginning of the drag, when dragDelta was 0. Every subsequent mousemove updated dragDelta via setState, but the handleUp closure still held 0.
The fix: useRef. Store the latest delta in dragDeltaRef.current on every move, read from the ref in handleUp. This is a well-known React pattern, but it’s the kind of thing you learn by losing an hour. I hit the same bug in three different drag implementations today and fixed it the same way each time.
A related issue: after dragging, the click event fires. mouseup → click. The block’s onClick opens the detail modal. So every drag ended with an unwanted modal. The fix was requestAnimationFrame: set didDragRef.current = true during drag, check it in onClick, and reset it in a requestAnimationFrame callback from handleUp. The rAF ensures the reset happens after the click event fires. Timing bugs like this are invisible in code review but immediately obvious in use.
The Flash
Drag worked now, but with a visual glitch. Release the mouse, and the block snaps back to its original position for one frame, then jumps to the new position when the API responds. One frame. Maybe 16 milliseconds. But it’s visible, and it feels like the app is uncertain about what just happened.
Optimistic updates fix this. On mouseup, before calling the API, I clone the task object with the new dates and update local state immediately. The block moves to its target position in the same frame as the mouse release. When the API responds, the state updates again with the server’s data — usually identical. If the API fails, I revert to the original.
I apply this pattern everywhere now. Every drag, every state change, every date modification. The user should never see the app wait. If the server is slow, the UI has already moved on. If the server fails, the UI rolls back gracefully. The intermediate state where the app is “thinking” should not be visible.
Month View: A Different Kind of Drag
Timeline drag moves blocks through hours. Month view drag moves tasks between days. Different geometry, different math, same user expectation: pick it up, put it down somewhere else.
Long-press (150ms) on a task bar to start. A ghost follows the cursor — the task’s point color, opacity: 0.5, border: 2px dashed. I tried cloning the original DOM element for the ghost, but abandoned it. Cloned nodes carry over child elements, event listeners, complex CSS. A simple colored rectangle with a dashed border communicates the same thing with none of the baggage.
Mapping cursor position to a date: the scroll container holds week rows at 190px each, 7 columns. clientX gives me the column (day of week), clientY + scrollTop divided by row height gives me the row (which week). From column + row, I derive the target date. When the mouse releases, I calculate the day difference between origin and target, then shift the task’s start and end dates by that offset. Duration is preserved — a 3-hour task stays 3 hours, just on a different day.
State Buttons on Calendar Blocks
On mobile, every task has a state toggle: empty circle (registered), animated dots (started), filled check (completed). Tap to cycle. I wanted this on every calendar block too.
I extracted the logic from TaskRecordItem into a standalone TaskStateButton. Same state machine, same celebration animation, same encouragement bubble. But the bubble positioning needed work.
The state button sits at the right edge of a task block. The encouragement bubble should appear above the star icon at the left edge. Inside TaskStateButton, when a completion triggers the bubble, I traverse the DOM upward from the button to find a [data-star] attribute — which I added to every star icon across all three views. The star’s getBoundingClientRect() gives me the exact position. If no star is found (shouldn’t happen, but defensive coding), the bubble falls back to the button’s own position.
The bubble itself went through refinements. The font was too large for a transient notification: 14/13/11 scaled down to 12/11/10. Padding was visually uneven due to the same ascender/descender issue as the drag tooltip — paddingTop: 1, paddingBottom: 3 for optical centering. The gap between the star and the bubble was too tight at 0px, so I added an 8px offset. These are the kinds of adjustments that don’t have a “correct” value — you just keep looking at it until it stops bothering you.
The Chip Tag Problem
Template recommendations in the create-task modal show titles that can contain <chip key="..." value="..."/> tags. On mobile, a native ChipTextInput module renders these as styled pills. On desktop (web), the native module doesn’t exist. The raw XML was showing in the dropdown: Buy <chip key="grocery" value="groceries"/> for dinner.
The fix was a regex parser that splits the title into text segments and chip segments, then renders chips as inline <span> elements with PRIMARY100 background and PRIMARY700 text. Same visual language as the mobile chips. It’s not a full implementation of the native module — no editing, no deletion, no keyboard interaction — but for a read-only display in a dropdown, it’s exactly what’s needed. Nothing more.
Closing Thought
There’s a version of today’s work that ships in two hours: add drag handlers, call the API on drop, move on. It would work. Tests would pass. The feature would be “done.”
But the guide lines, the tooltip centering, the ghost that follows your cursor, the preserved scroll position, the optimistic updates, the encouragement bubble that finds the star icon — these aren’t features. They’re the difference between software that functions and software that someone actually wants to use. Working solo, there’s no one to tell me “that’s good enough, move on.” Which is a curse and a gift. I spent twenty minutes on 1px of tooltip padding today. No one asked me to. But I know what it looks like when it’s wrong, and I can’t unsee it.