This document describes the implementation of the TV Program Guide component.
app/
├── components/TVGuide/
│ ├── desktop/
│ │ ├── TVGuide.tsx Main desktop container
│ │ ├── TimelineWithScrollButtons.tsx Timeline wrapper with scroll buttons
│ │ ├── ProgramTrack.tsx Channel track with program cards
│ │ └── CurrentTimeIndicator.tsx Red line indicator for current time
│ ├── mobile/
│ │ ├── TVGuideMobile.tsx Main mobile container
│ │ ├── ProgramListMobile.tsx Vertical program list
│ │ ├── ChannelRowMobile.tsx Individual channel row in mobile list
│ │ ├── CurrentTimeIndicatorMobile.tsx
│ │ ├── CenteredTimeIndicator.tsx Shows time at viewport center
│ │ └── CenteredTimeContext.tsx Context for centered time tracking
│ └── shared/
│ ├── timeline/
│ │ ├── TimelineConfig.ts Timeline configuration interface
│ │ ├── TimelineContext.tsx Context + all utility hooks
│ │ └── Timeline.tsx Unified Timeline component
│ ├── DateNavigation.tsx Shared date picker + Jump to Now button
│ ├── ProgramDetailDialog.tsx Shared modal dialog for program details
│ ├── SkeletonLoader.tsx Shared loading state skeletons
│ ├── ScrollContainerContext.tsx Shared context for scroll container (desktop only)
│ ├── ChannelStack.tsx Unused component (logos integrated in TVGuide)
│ └── const.ts Layout constants
├── hooks/
│ └── useIsMobile.ts Mobile detection hook (used in route.tsx)
├── lib/
│ ├── types.ts TypeScript interfaces
│ ├── testData.ts Test data generator with faker
│ └── utils.ts Utility functions (cn helper)
└── routes/_index/
└── route.tsx Route with conditional rendering (desktop/mobile)
Route-Level Decision:
app/routes/_index/route.tsxusesuseIsMobile()hook to conditionally render components- Breakpoint:
window.innerWidth <= 640px(Tailwindsmbreakpoint) - Desktop: Renders
TVGuidecomponent fromdesktop/directory - Mobile: Renders
TVGuideMobilecomponent frommobile/directory - Both components share identical props interface:
channels,programs,startTime,selectedDate,onDateChange - Selection happens at route level, not within components (clean separation of concerns)
Implementation:
- Minute-based grid: Grid columns calculated dynamically from time range via
useGridColumns()hook (typically 24 * 60 - 1 = 1439 columns for 24 hours) - Grid gap:
DESKTOP_TIMELINE_GRID_GAP = 7pxper minute column (desktop),MOBILE_TIMELINE_GRID_GAP = 3px(mobile) - Timeline marks: Displayed at 30-minute intervals using
eachMinuteOfIntervalwithstep: 30 - Program positioning: Calculated using
useTimeToGridColumn()hook to determine grid column start/end - Rationale: Minute-based grid allows programs to start/end at any minute, providing precise positioning without rounding
CSS Grid Layout:
gridTemplateRows: `auto auto`
gridTemplateColumns: `${CHANNEL_LOGO_WIDTH}px auto`Component Hierarchy:
- Row 1 (Timeline): Sticky top, spans full width with left margin for channel logos
- Row 2 (Content):
- Column 1: Channel logos (sticky left, z-20)
- Column 2: Program tracks (z-10, overflow-hidden)
Sticky Positioning:
- Timeline:
sticky top-0 z-30 - Channel logos:
sticky left-0 z-20 - Scroll buttons:
sticky left-2andsticky right-2 z-30 - Current time indicator:
sticky top-0 bottom-0 z-40
Layout Pattern:
- Vertical split layout (timeline on top, program list below)
- Two separate scroll containers (independent scrolling)
- Fixed height container:
h-[500px]withoverflow-hidden
Component Hierarchy:
-
Timeline Section (top, horizontal scroll):
TimelineMobileWrapper(internal to TVGuideMobile): Wraps unified Timeline with mobile padding and scroll trackingCurrentTimeIndicatorMobile: Red line for current timeCenteredTimeIndicator: Shows time at viewport center
-
Program List Section (bottom, vertical scroll):
ProgramListMobile: Vertical list of channels- Each channel rendered as
ChannelRowMobilewith programs
Key Differences from Desktop:
- No channel logo column (logos in mobile list items)
- List-based rendering instead of grid tracks
- Separate scroll containers (timeline vs programs)
- Centered time indicator for navigation reference
- Grid gap:
MOBILE_TIMELINE_GRID_GAP = 3px(vs 7px desktop)
Unified Timeline Component:
- Single
Timelinecomponent inshared/timeline/Timeline.tsx - Takes optional
configprop, falls back toTimelineContext - Mark generation is internal (hardcoded 30-minute step, "HH:mm" format)
- Calculates grid columns from time range:
differenceInMinutes(end, start) - Renders timeline marks with proper grid positioning
Timeline Configuration:
TimelineConfiginterface:{ gridGap: number, start: Date, end: Date }- Helper function:
createTimelineConfig(selectedDate, startTime, gridGap) - Desktop:
gridGap = 7px, Mobile:gridGap = 3px
Timeline Context & Utilities:
TimelineContextexported directly (no Provider component)- All utility hooks in
TimelineContext.tsx:useTimeline()- Get config from contextuseTimeToPosition()- Convert time to pixelsuseTimeToGridColumn()- Convert time to grid column (1-based)usePositionToTime()- Convert pixels to timeuseGridColumns()- Get total grid columnsuseTimelineWidth()- Get timeline width in pixels
- Components use hooks for all time/position conversions
TimelineWithScrollButtons Wrapper:
- Wraps
Timelinecomponent with scroll button logic - Forward/backward scroll buttons (3-hour increments:
DESKTOP_SCROLL_INCREMENT = 3 * 60 * 7px = 1260px) - Buttons disabled at scroll boundaries (start/end)
- Buttons positioned absolutely over timeline
- Uses
useScrollPositionhook for boundary detection
Scroll Behavior:
- Uses
scrollBywithbehavior: "smooth"for animated scrolling - Scroll position tracked via
useScrollPositionhook - Boundary detection with 1px tolerance
- Buttons automatically disable/enable based on scroll position
TimelineMobileWrapper:
- Wraps
Timelinecomponent with mobile-specific behavior - Applies 50% padding (left/right) for centered time indicator
- Handles scroll tracking to update
CenteredTimeContext - Uses
usePositionToTimehook to convert scroll position to time - Independent scroll container (separate from program list)
Centered Time Indicator:
- Shows which time is currently centered in viewport
- Updates on scroll via
CenteredTimeContext - Used for time-based navigation reference
- Position calculated from scroll position and viewport center
Grid Layout:
- Each track uses CSS Grid matching timeline columns
- Grid columns calculated via
useGridColumns()hook - Grid gap from config:
config.gridGap(7px for desktop) - Program cards positioned using
gridColumn: ${gridColumnStart} / ${gridColumnEnd} - Cards filtered: only programs intersecting timeline range are rendered
- Uses
useTimeToGridColumn()hook for program positioning
Program Card Features:
- Thumbnail: 160px width, object-cover
- Content: Time range (HH:mm - HH:mm), title (line-clamp-2), genre tags
- Hover Expansion: Uses Motion (
motion/react) for smooth animations - Click Interaction: Opens program detail dialog
Hover Expansion Logic:
- Expansion only when card is clipped or wider than visible area
- Calculates optimal position to keep content visible
- Handles edge cases:
- Cards wider than viewport: centers with margins
- Clipping on left: aligns left edge to visible area
- Clipping on right: expands leftward to fit
- No clipping: expands to minWidth (420px) if needed
- Uses
useMemofor position calculations based on card/container rects - Animation duration: 0.3s with linear easing
List-Based Rendering:
- Vertical list of channels (
ProgramListMobile) - Each channel rendered as
ChannelRowMobilecomponent - Programs displayed in channel-specific sections
- Scroll container independent from timeline scroll
Channel Row Structure:
- Channel logo and name header
- Programs listed vertically within channel section
- Programs filtered by timeline range (same logic as desktop)
- Click interaction opens program detail dialog (shared component)
Desktop Implementation:
- Red vertical line (
bg-destructive) with triangle indicator at top - Position calculated using
useTimeToGridColumn(currentTime)hook - Grid columns from
useGridColumns()hook - Updates every 60 seconds via
setInterval - Hidden when current time is outside timeline range
- Uses Motion
layoutprop for smooth position updates - Positioned in grid column matching timeline structure
Mobile Implementation:
CurrentTimeIndicatorMobile: Similar red line indicator in timeline- Uses
useTimeToGridColumn()anduseGridColumns()hooks - Timeline width from
useTimelineWidth()hook CenteredTimeIndicator: Additional indicator showing time at viewport center- Centered time updates on scroll (not time-based)
- Both indicators use same visual style as desktop
Features:
- Format: "DayName, DD.MM.YYYY" (e.g., "Today, 27.11.2025")
- Previous/next day buttons with ChevronLeft/ChevronRight icons (lucide-react)
- Calendar popover for date selection (Radix UI Popover + Calendar)
- "Now" button: Jumps to today and centers current time in viewport
Jump to Now Logic:
- Calculates target date (yesterday if current time < startTime)
- Calculates current time position in pixels
- Scrolls to center current time:
scrollPosition = totalCurrentTimePosition - viewportWidth / 2 - Uses
startTransitionfor non-blocking scroll - Accounts for channel logo column width (116px) in position calculation
Implementation:
- Uses Radix UI Dialog component
- Mobile: Bottom sheet (
max-sm:bottom-0 max-sm:rounded-t-2xl) - Desktop: Centered modal (
max-w-2xl) - Displays: title, time range, thumbnail, genre tags, description
- Dismissible: outside click, close button, ESC key
- Dialog structure uses compound component pattern (Root, Trigger, Content)
TimelineContext (Shared):
- Exported directly:
TimelineContext(no Provider component) - Provides
config: TimelineConfigcontaining{ gridGap, start, end } - Config created via
createTimelineConfig(selectedDate, startTime, gridGap) - Timeline spans 24 hours from start time
- Used by: Timeline, ProgramTrack, CurrentTimeIndicator (both variants), all timeline utilities
- Hook:
useTimeline()returnsTimelineConfig, throws error if used outside provider - All utility hooks consume
TimelineContextinternally:useTimeToPosition()- Time → pixels conversionuseTimeToGridColumn()- Time → grid column conversionusePositionToTime()- Pixels → time conversionuseGridColumns()- Get total grid columnsuseTimelineWidth()- Get timeline width
- Shared between desktop and mobile implementations
- Components create config and provide via
<TimelineContext.Provider value={{ config }}>
ScrollContainerContext (Desktop Only):
- Provides shared scroll container ref
- Used for:
- Scroll position tracking
- Programmatic scrolling (Jump to Now, scroll buttons)
- Hover expansion calculations
- Context provider wraps scroll container in TVGuide component
- Not used in mobile (separate scroll containers)
CenteredTimeContext (Mobile Only):
- Tracks which time is currently centered in viewport
- Provides
centeredTimeDate andsetCenteredTimefunction - Used by:
CenteredTimeIndicator,TimelineMobileWrapper(in TVGuideMobile) - Context provider:
CenteredTimeProvider - Hook:
useCenteredTime()throws error if used outside provider - Updates on timeline scroll to reflect viewport center
- Initial value: current time if within range, otherwise timeline start
Route Integration:
- Uses React Router
clientLoaderfor data fetching - URL parameter:
?date=yyyy-MM-ddfor date selection - Simulated loading delay: 500ms
- Suspense boundary with SkeletonLoader fallback
- Generates 12 channels and 36 hours of programs
Test Data:
- Uses
@faker-js/fakerfor realistic data - Seeded based on date for reproducibility:
faker.seed(dateSeed) - Programs: 15min-4hr duration, non-overlapping within channel
- Includes gaps and overlaps between programs:
- 30% consecutive (no gap)
- 40% gap (5min-2hr gap)
- 30% overlap (5-30min overlap)
- Channel logos: Picsum photos with seeded URLs
- Program thumbnails: Picsum photos with program ID seeds
// Shared constants
CHANNEL_LOGO_WIDTH = 116px
TRACK_HEIGHT = 106px
// Note: Grid columns calculated dynamically via useGridColumns() hook
// Desktop-specific
DESKTOP_TIMELINE_GRID_GAP = 7px (per minute)
DESKTOP_SCROLL_INCREMENT = 3 * 60 * 7 = 1260px (3 hours)
// Mobile-specific
MOBILE_TIMELINE_GRID_GAP = 3px (per minute)- Decision: Use minute-based grid for precise positioning
- Rationale: Allows programs to start/end at any minute, not just 30-minute boundaries
- Trade-off: More grid columns (1439 vs ~48), but enables accurate positioning
- Benefit: Programs can have exact start/end times without rounding
- Decision: Use
motion/react(Motion library) instead of CSS transitions - Rationale: More control over animation timing and layout animations
- Usage: Program card hover expansion, current time indicator position updates
- Package:
motion@^12.23.24(imported asmotionfrommotion/react)
- Decision: Use React Context for scroll container, timeline config, and centered time
- Rationale: Avoids prop drilling, enables components to access shared refs/state
- Components: Timeline, ProgramTrack, CurrentTimeIndicator all use contexts
- Pattern: Context exported directly, components provide via
<TimelineContext.Provider> - Contexts: Three contexts total - TimelineContext (shared, provides config + hooks), ScrollContainerContext (desktop), CenteredTimeContext (mobile)
- Utility Hooks: All timeline conversion utilities are hooks in TimelineContext.tsx
- Timeline Component: Uses config prop primarily, falls back to context for flexibility
- Decision: Extensive use of CSS Grid for timeline and tracks
- Rationale: Natural alignment, no manual pixel calculations for positioning
- Benefits: Responsive, maintainable, precise alignment
- Implementation: Grid columns match timeline structure exactly
- Grid Columns: Calculated dynamically from time range via
useGridColumns()hook - Consistency: All components use same grid structure via shared hooks
- Decision: Filter programs by timeline range before rendering
- Rationale: Only render visible programs, improves performance
- Implementation:
usePositionInTrackGridreturnsnullif program outside range - Calculation: Uses
differenceInMinutesto check if program intersects timeline
- Decision: Integrate channel logos directly into main grid layout (desktop)
- Rationale: Simplifies layout, ensures perfect alignment with tracks
- Implementation: Logos in first column, sticky left positioning
- Mobile: Logos integrated into
ChannelRowMobilecomponent headers - Note:
shared/ChannelStack.tsxexists but is not used (logos integrated in TVGuide.tsx)
- Decision: Fully separate mobile component (
TVGuideMobile) vs responsive CSS - Rationale: Mobile UX requires fundamentally different layout (list vs grid, separate scrolls)
- Trade-off: Code duplication, but enables optimized mobile experience
- Benefits: Independent optimization, cleaner mobile code, better performance
- Pattern: Route-level selection using
useIsMobile()hook
- Decision: Unified Timeline component with configuration-driven approach
- Rationale: Eliminates duplication, centralizes timeline logic, enables shared utilities
- Implementation:
- Single
Timelinecomponent inshared/timeline/ - All utility hooks in
TimelineContext.tsx - Desktop/Mobile use wrappers for platform-specific behavior
- Single
- Benefits:
- Single source of truth for timeline calculations
- Shared utilities for time/position conversions
- Easy to maintain and extend
- Type-safe with TypeScript
- Pattern: Config prop first, context fallback for flexibility
Desktop:
- Memoization:
programsByChannelcomputed withuseMemo - Program Filtering: Only programs intersecting timeline are rendered
- Hover Calculations:
useMemofor position calculations, only recalculates on hover state change - Scroll Tracking: Efficient boundary detection with event listeners
- Time Updates: Current time indicator updates every 60 seconds (not every second)
- Grid Rendering: CSS Grid handles layout efficiently without JavaScript calculations
Mobile:
- Separate Scroll Containers: Timeline and program list scroll independently, reducing layout thrashing
- List-Based Rendering:
ProgramListMobileuses vertical list (more efficient than grid for vertical scrolling) - CenteredTimeIndicator: Updates only on scroll events (not time-based), reducing re-renders
- Program Filtering: Same filtering logic as desktop (only visible programs rendered)
- Memoization:
programsByChannelcomputed withuseMemo(same as desktop)
- Modern browsers with CSS Grid support
- CSS
stickypositioning required - Smooth scroll behavior supported
- ES2020+ features (optional chaining, nullish coalescing)
react@^19.1.1react-router@^7.9.2date-fns@^4.1.0@faker-js/faker@^10.1.0@radix-ui/react-dialog@^1.1.15@radix-ui/react-popover@^1.1.15motion@^12.23.24(Motion library)lucide-react@^0.553.0(icons)tailwindcss@^4.1.13clsx@^2.1.1&tailwind-merge@^3.3.1(utility functions)
typescript@^5.9.2@react-router/dev@^7.9.2vite@^7.1.7
Desktop:
- Test data generation is deterministic (seeded by date)
- Programs span midnight boundaries correctly
- Scroll boundaries work correctly
- Hover expansion handles all edge cases
- Current time indicator updates correctly
- Date navigation updates URL and triggers data reload
- Channel logos align perfectly with tracks
- Timeline and tracks scroll together synchronously
Mobile:
- Component selection works correctly (desktop vs mobile based on viewport width)
- Timeline scrolls independently from program list
- CenteredTimeIndicator updates correctly on scroll
- CurrentTimeIndicatorMobile shows correct position
- Program list renders correctly with channel grouping
- Jump to Now works correctly in mobile layout
- Date navigation updates URL and triggers data reload (same as desktop)
- ✅ TypeScript strict mode enabled
- ✅ No
anytypes used - ✅ Proper error handling in context hooks
- ✅ Memoization where appropriate
- ✅ Event handlers properly cleaned up
- ✅ Accessibility: ARIA labels on buttons
- ✅ Semantic HTML structure
- ✅ Shared props interface between desktop/mobile components
- ✅ Type safety maintained across component variants
Potential improvements:
- Virtual Scrolling: For very large datasets (50+ channels, 2000+ programs)
- Keyboard Navigation: Enhanced keyboard support for accessibility
- Program Filtering: Filter by genre, channel, or time range
- Search: Search programs by title
- Favorites: Save favorite programs
- Program Reminders: Set reminders for upcoming programs
- Live Indicator: Visual indicator for currently airing programs
- Mobile Optimizations: Further optimize mobile scroll performance, consider intersection observer for program visibility
- Timeline Enhancements: Add configurable mark intervals, custom time formats, or timezone support