The one where I learn that dates are hard, positioning is harder, and sometimes you need 6 tries to get something pixel-perfect
Part 2 of an ongoing series documenting my journey building a production-ready task management app. Read Part 1 here for the foundation and setup.
After getting basic CRUD working in Part 1, I dove into what actually makes Sunsama worth using: visual time blocking. Turns out this was way more involved than I thought and took multiple sessions to get right.
What I Was Going For
I wanted to replicate the Sunsama workflow:
- Separate “Today” view for tasks I’m actually doing today
- Time estimates on tasks
- Drag tasks into specific time slots
- Actually see my day blocked out visually
Should be straightforward, right? Hah.
Session 2: Getting Planning to Work
Database Updates
First I needed to track when tasks are planned and how long they take. Updated the schema:
model Task {
id String @id @default(uuid())
title String
description String?
completed Boolean @default(false)
dueDate DateTime?
plannedDate DateTime? // When am I doing this?
timeEstimate Int? // How long will it take?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(...)
}
Ran the migration. Easy enough.
Today View
Built a new page that filters to only show tasks planned for today. The filtering seems obvious but I quickly ran into a timezone bug that would’ve been super annoying in production.
My first try:
const isToday = (dateString: string | null) => {
if (!dateString) return false;
return new Date(dateString).toDateString() === new Date().toDateString();
};
This broke because dates from the database are UTC, but toDateString() uses local time. A task “planned for today” in UTC could show as yesterday in my timezone. Great.
Fixed it by comparing date components directly:
const isToday = (dateString: string | null) => {
if (!dateString) return false;
const taskDate = new Date(dateString);
const today = new Date();
return (
taskDate.getFullYear() === today.getFullYear() &&
taskDate.getMonth() === today.getMonth() &&
taskDate.getDate() === today.getDate()
);
};
Time Estimates
Added a number input to the task form:
<input
type="number"
value={timeEstimate || ''}
onChange={(e) => setTimeEstimate(e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="e.g., 30"
min="5"
step="5"
/>
Basic but functional.
Daily Summary
Built a card showing:
- Total time planned
- Task count
- Percentage of day allocated
export function DailyTimeSummary({ tasks }) {
const totalMinutes = tasks.reduce((sum, task) => sum + (task.timeEstimate || 0), 0);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h2>Daily Summary</h2>
<p>Total Time: {hours}h {minutes}m</p>
<p>Tasks: {tasks.length}</p>
<p>Day Planned: {Math.round(totalMinutes / 480 * 100)}%</p>
</div>
);
}
The 480 is an 8-hour workday in minutes. I’ll make this customizable later because I don’t always work 9-5.
Plan for Today Button
Added a button in All Tasks to move tasks to today:
const handlePlanForToday = (id: string) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
planMutation.mutate({ id, plannedDate: today.toISOString() });
};
Edit Functionality
At this point I cant’t edit tasks after creating them. Built an edit modal that pre-populates a form with existing values:
const [title, setTitle] = useState(task.title);
const [description, setDescription] = useState(task.description || '');
const [dueDate, setDueDate] = useState(
task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ''
);
Standard form stuff.
Navigation
Added tabs to switch views:
const [view, setView] = useState<'today' | 'tasks'>('today');
<button onClick={() => setView('today')}>Today</button>
<button onClick={() => setView('tasks')}>All Tasks</button>
At this point I can plan tasks for today and see them separately. But they were still just a list. Time for the hard part.
Sessions 3-4: The Fun Part (Visual Time Blocking)
This is where things got interesting. By interesting I mean I spent way too much time on CSS positioning.
Adding Start Time
Added a field to track when tasks are scheduled:
model Task {
// ... existing fields
startTime String? // "09:00", "14:30", etc.
}
Timeline Component
Built a timeline showing hourly slots:
export function Timeline({ tasks }) {
const hours = Array.from({ length: 10 }, (_, i) => i + 8); // 8 AM - 5 PM
return (
<div>
{hours.map(hour => (
<TimeSlot key={hour} hour={hour} />
))}
</div>
);
}
Each TimeSlot is just a div with the hour label and a drop zone.
Drag and Drop
Installed @dnd-kit:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Made tasks draggable:
function DraggableTaskItem({ task }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
data: { task }
});
return (
<div ref={setNodeRef} {...listeners} {...attributes}>
{/* Task content */}
</div>
);
}
Made timeline slots droppable:
function TimeSlot({ hour }) {
const { setNodeRef, isOver } = useDroppable({
id: `timeslot-${hour}:00`,
data: { hour, timeSlot: `${hour}:00` }
});
return (
<div
ref={setNodeRef}
className={isOver ? 'bg-blue-50' : ''}
>
{/* Drop zone */}
</div>
);
}
Wired up the drop handler:
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over) return;
const taskId = active.id.toString();
const timeSlot = over.data?.current?.timeSlot;
updateMutation.mutate({ id: taskId, data: { startTime: timeSlot } });
};
And boom – I can now drag tasks into time slots. But they all looked identical in size, which defeated the whole point of visual time blocking.
Visual Task Scaling (Where I Lost Hours (I’m Exaggerating) of My Life)
Goal: make tasks scale based on time estimate. A 15-minute task should look tiny compared to a 2-hour meeting.
Approach: absolute positioning with calculated heights.
function ScheduledTask({ task, startHour }) {
const [hourStr, minuteStr] = task.startTime.split(':');
const taskStartHour = parseInt(hourStr);
const startMinute = parseInt(minuteStr);
const hoursSinceStart = taskStartHour - startHour;
const hourHeight = 64; // Each hour slot is 64px
// Position calculation
const topPosition = (hoursSinceStart * hourHeight) + (startMinute / 60 * hourHeight);
// Height calculation
const totalMinutes = task.timeEstimate || 0;
const calculatedHeight = (totalMinutes / 60) * hourHeight;
// Minimums for readability
const height = totalMinutes <= 15
? Math.max(calculatedHeight, 28)
: Math.max(calculatedHeight, 40);
return (
<div
style={{
position: 'absolute',
top: `${topPosition}px`,
height: `${height}px`
}}
>
{task.title}
</div>
);
}
This. Took. Forever. Here’s what went wrong:
Problem 1: Tasks Weren’t Aligning
Tasks kept appearing slightly above where they should be. I spent an embarrassing amount of time debugging this. I realized I was mixing rendering approaches. Some tasks were rendered inline, while others were absolutely positioned. Once I committed to pure absolute positioning, it worked.
Problem 2: Tiny Tasks Were Unreadable
A 15-minute task at 16px tall is basically invisible. Added minimum heights and adaptive styling:
const isTiny = height < 45;
const padding = isTiny ? 'p-1' : 'p-2';
const textSize = isTiny ? 'text-xs' : 'text-sm';
Problem 3: 15-Minute and 30-Minute Tasks Looked Identical
Both hit the same minimum. Fixed with duration-based minimums:
const height = totalMinutes <= 15
? Math.max(calculatedHeight, 28) // Tiny
: Math.max(calculatedHeight, 40); // Normal
Problem 4: Scaling Was Wrong
Tried multiple approaches before finding what worked:
- No scaling → too small, unreadable
- 2x multiplier → 60-min tasks taking up 2 hours, completely wrong
- 1.5x multiplier → still oversized
- 1.1x multiplier → close but still off
- No multiplier, just smart minimums → finally correct
Lesson learned: sometimes the simplest solution is right.
Multi-Hour Spanning
Long tasks needed to flow across multiple hours instead of being confined to one. The math:
function getTaskSpan(task) {
if (!task.startTime || !task.timeEstimate) return null;
const [hourStr, minuteStr] = task.startTime.split(':');
const startHour = parseInt(hourStr);
const startMinute = parseInt(minuteStr);
const totalMinutes = task.timeEstimate;
return { startHour, startMinute, totalMinutes };
}
Tasks now overlay multiple slots seamlessly.
The Button Problem
Made everything draggable. Immediately broke all the buttons – checkboxes, edit, delete. Because the drag was capturing everything.
Fix: drag handle. Only the handle triggers dragging:
<div className="flex items-start gap-3">
{/* ONLY this part is draggable */}
<div {...listeners} {...attributes} className="cursor-move">
<svg>
<path d="M4 8h16M4 16h16" />
</svg>
</div>
{/* These work fine now */}
<input type="checkbox" onChange={() => onToggle(task.id)} />
<button onClick={() => onEdit(task)}>Edit</button>
</div>
Problem solved.
Session 5: Making It Not Suck
Core functionality worked. Now for the polish that makes it actually usable.
Conflict Detection
Added overlap detection:
function tasksOverlap(task1, task2) {
if (!task1.startTime || !task2.startTime) return false;
const [h1, m1] = task1.startTime.split(':').map(Number);
const start1 = h1 * 60 + m1;
const end1 = start1 + (task1.timeEstimate || 0);
const [h2, m2] = task2.startTime.split(':').map(Number);
const start2 = h2 * 60 + m2;
const end2 = start2 + (task2.timeEstimate || 0);
return start1 < end2 && start2 < end1;
}
Overlapping tasks get orange styling with a warning. Simple but effective – you immediately know when you’ve double-booked yourself.
Current Time Indicator
Red line showing where I am in my day:
export function CurrentTimeIndicator({ startHour, endHour }) {
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 60000);
return () => clearInterval(interval);
}, []);
const hour = currentTime.getHours();
if (hour < startHour || hour >= endHour) return null;
const minutes = currentTime.getMinutes();
const hoursSinceStart = hour - startHour;
const percentageFromTop = ((hoursSinceStart + minutes / 60) / (endHour - startHour)) * 100;
return (
<div style={{ top: `${percentageFromTop}%` }}>
<span>{currentTime.toLocaleTimeString()}</span>
<div className="bg-red-500 h-0.5"></div>
</div>
);
}
Updates every minute. Actually pretty useful for staying on track.
Keyboard Shortcuts
I hate using the mouse for everything. Built a hook for keyboard shortcuts:
export function useKeyboardShortcut(key, callback) {
useEffect(() => {
const handleKeyDown = (event) => {
const target = event.target;
// Don't trigger while typing
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
if (event.key.toLowerCase() === key.toLowerCase()) {
event.preventDefault();
callback();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [key, callback]);
}
Added the shortcuts I actually want:
useKeyboardShortcut('n', () => setShowCreateModal(true));
useKeyboardShortcut('t', () => setView('today'));
useKeyboardShortcut('a', () => setView('tasks'));
useKeyboardShortcut('Escape', () => setShowCreateModal(false));
Also added visual hints:
<button>
Today <kbd className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">T</kbd>
</button>
Makes the whole app feel faster.
Customizable Timeline Hours
Timeline was hardcoded to 8 AM – 6 PM. I don’t always work those hours. Used Zustand to fix it:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useSettingsStore = create(
persist(
(set) => ({
startHour: 8,
endHour: 18,
setStartHour: (hour) => set({ startHour: hour }),
setEndHour: (hour) => set({ endHour: hour }),
}),
{ name: 'timeline-settings' }
)
);
The persist middleware handles localStorage automatically. No extra work.
Built a settings modal with dropdowns for start/end hours. Updated timeline to use the custom hours:
const { startHour, endHour } = useSettingsStore();
const hours = Array.from({ length: endHour - startHour }, (_, i) => i + startHour);
Everything adapts – positioning, current time indicator, percentage calculations. Works perfectly.
Scheduled vs Unscheduled Time
Updated the summary to show the breakdown:
const scheduledTasks = tasks.filter(task => task.startTime);
const scheduledMinutes = scheduledTasks.reduce((sum, t) => sum + (t.timeEstimate || 0), 0);
const unscheduledTasks = tasks.filter(task => !task.startTime);
const unscheduledMinutes = unscheduledTasks.reduce((sum, t) => sum + (t.timeEstimate || 0), 0);
Now I see:
- Total time (everything)
- Scheduled (green) – actually blocked out
- Unscheduled (orange) – still floating
Helps distinguish between “planned” and “organized.”
The Past Task Bug
Found an annoying bug: tasks from yesterday disappeared from Today but had no way to reschedule them. They just sat in All Tasks with no action available.
Fixed it:
const isPlannedForPast = task.plannedDate &&
new Date(task.plannedDate).toDateString() !== new Date().toDateString();
{(!task.plannedDate || isPlannedForPast) && (
<button onClick={() => onPlanForToday(task.id)}>
{isPlannedForPast ? 'Reschedule for Today' : 'Plan for Today'}
</button>
)}
Now old tasks show “Reschedule for Today” instead of being orphaned.
What I Actually Learned
CSS Positioning is a Pain (I always knew this)
Getting task positioning pixel-perfect took way more attempts than I care to admit. The key insight was committing to pure absolute positioning instead of mixing approaches.
Test With Real Data
The timezone bug and past task bug only showed up with actual daily use. Synthetic tests wouldn’t have caught them.
Small Details Matter
Things like drag handles, overlap warnings, and keyboard shortcuts aren’t technically complex. But they’re the difference between “works” and “actually want to use this.”
Don’t Overthink Scaling
I tried multiple complex multipliers for task heights before realizing simple minimums worked best. Sometimes less is more.
Zustand > Redux for This
For simple state like settings, Zustand with persistence is perfect. Way less ceremony than Redux.
Current State
Working features:
- ✅ Task CRUD
- ✅ Daily planning
- ✅ Visual time blocking
- ✅ Drag & drop
- ✅ Task duration scaling
- ✅ Multi-hour spanning
- ✅ Conflict detection
- ✅ Current time indicator
- ✅ Keyboard shortcuts
- ✅ Custom work hours
- ✅ Smart rescheduling
It’s actually usable. I’ve been lightly planning my days with it and it works.
What’s Next
Considering:
- Pomodoro timer
- Week view
- User auth (goodbye temp user)
- Task notes/subtasks
- Google Calendar sync
- Mobile app
But I’m going to use this for a week first and see what I actually need vs what sounds cool.
Stats
- Sessions: 5
- Files: ~15 components
- Lines: ~1500+
- Hours: ~10-12
- Bugs: 4 major
- Features: 15+
The hardest parts weren’t the complex features. They were the edge cases and UX details you only find by using what you build.
Next up: probably Pomodoro timer. I keep wanting to track actual time vs estimates.



Leave a comment