TL;DR

Using the same substring approach for both weekly schedules (LocalTime “09:00:00”) and temporary assignments (LocalDateTime “2025-01-12T09:00:00”) breaks everything. Weekly schedules have no timezone, while temporary assignments are in UTC and need KST conversion. Branch your formatting logic by type.


The Bug: Missing Times in Seat Monitoring Popover

We have a popover that shows today’s schedule when hovering over a student’s seat in the seating chart. It displays both weekly schedules and temporary study time assignments, but weekly schedule times weren’t showing up.

The Problem

// Buggy code
<div className="text-gray-500 text-[10px] mt-0.5">
  {schedule.startTime.substring(11, 16)} - {schedule.endTime.substring(11, 16)}
</div>

This only works for LocalDateTime format ("2025-01-12T09:00:00"):

  • substring(11, 16) → "09:00"

But for LocalTime format ("09:00:00"):

  • substring(11, 16) → "" (empty string, only 8 chars total) ❌

Backend DTO Structure Differences

Weekly Schedule (WeeklyScheduleResponse)

@JsonFormat(pattern = "HH:mm")
private LocalTime startTime;  // "09:00:00"

@JsonFormat(pattern = "HH:mm")
private LocalTime endTime;    // "17:00:00"
  • Type: LocalTime
  • Format: "HH:mm:ss" (time only, no date)
  • Timezone: None (just time information)

Temporary Assignment (AssignedStudyTime)

private LocalDateTime startTime;  // "2025-01-12T00:00:00"
private LocalDateTime endTime;    // "2025-01-12T09:00:00"
  • Type: LocalDateTime
  • Format: "YYYY-MM-DDTHH:mm:ss"
  • Timezone: UTC (server stores in UTC)
  • Note: Frontend needs to convert to KST (UTC+9)

Solution: formatTime Helper Function

Created a helper function that handles both formats and supports UTC→KST conversion.

/**
 * Convert LocalTime(HH:mm:ss) or LocalDateTime(YYYY-MM-DDTHH:mm:ss) to HH:mm format
 * @param timeString - LocalTime or LocalDateTime string
 * @param isUtc - if true, convert UTC to KST (for temporary assignments)
 */
const formatTime = (timeString: string, isUtc: boolean = false): string => {
  if (!timeString) return '';

  // LocalDateTime format (contains T)
  if (timeString.includes('T')) {
    // UTC -> KST conversion needed (temporary assignments)
    if (isUtc) {
      const utcDate = new Date(timeString);
      // KST is UTC+9
      const kstDate = new Date(utcDate.getTime() + 9 * 60 * 60 * 1000);
      const hours = String(kstDate.getUTCHours()).padStart(2, '0');
      const minutes = String(kstDate.getUTCMinutes()).padStart(2, '0');
      return `${hours}:${minutes}`;
    }
    // No UTC conversion needed
    return timeString.substring(11, 16); // "2025-01-12T09:00:00" -> "09:00"
  }

  // LocalTime format (HH:mm:ss) - weekly schedules
  return timeString.substring(0, 5); // "09:00:00" -> "09:00"
};

Key Logic Breakdown

1. LocalDateTime Detection

if (timeString.includes('T'))

If it contains T, it’s LocalDateTime format (ISO 8601 standard).

2. UTC → KST Conversion

if (isUtc) {
  const utcDate = new Date(timeString);
  const kstDate = new Date(utcDate.getTime() + 9 * 60 * 60 * 1000);
  // ...
}
  • Parse UTC time as JavaScript Date object
  • Add 9 hours (9 × 60 × 60 × 1000ms)
  • Extract time using getUTCHours() (KST offset already applied)

3. LocalTime Handling

return timeString.substring(0, 5); // "09:00:00" -> "09:00"

Just take first 5 characters to get “HH:mm” format.


Usage Example

const allSchedules = [
  ...todayWeeklySchedules.map((s) => ({
    title: s.title,
    activityName: s.activityName,
    startTime: s.startTime,  // "09:00:00"
    endTime: s.endTime,      // "17:00:00"
    source: 'WEEKLY_SCHEDULE' as const,
  })),
  ...(assignedStudyTimes || []).map((s) => ({
    title: s.title,
    activityName: s.activityName,
    startTime: s.startTime,  // "2025-01-12T00:00:00" (UTC)
    endTime: s.endTime,      // "2025-01-12T09:00:00" (UTC)
    source: 'ASSIGNED_STUDY_TIME' as const,
  })),
];

// Rendering
{allSchedules.map((schedule, idx) => (
  <div key={idx}>
    <div>{schedule.title}</div>
    <div>{schedule.activityName}</div>
    <div>
      {formatTime(schedule.startTime, schedule.source === 'ASSIGNED_STUDY_TIME')}
      -
      {formatTime(schedule.endTime, schedule.source === 'ASSIGNED_STUDY_TIME')}
    </div>
  </div>
))}

UTC conversion is automatically determined by source value:

  • WEEKLY_SCHEDULEisUtc: false (LocalTime as-is)
  • ASSIGNED_STUDY_TIMEisUtc: true (UTC → KST conversion)

Lessons Learned

1. Always Check Backend DTO Types

Don’t assume “it’s a string, so substring will work” - check the backend DTO definition first.

// Look at this to understand the format
private LocalTime startTime;      // HH:mm:ss
private LocalDateTime startTime;  // YYYY-MM-DDTHH:mm:ss

2. Be Timezone Aware

If the server stores in UTC, frontend needs to convert.

  • LocalTime: No timezone (just time value)
  • LocalDateTime: Server stores in UTC → KST conversion needed

3. Branch Logic by Type

When mixing multiple types, use a discriminator like source to branch processing logic.


Conclusion

“It’s a time string, so substring should work” is a dangerous assumption. LocalTime and LocalDateTime are completely different formats, and it gets even more complex when timezone conversion is involved. Branch clearly by type and handle UTC→KST conversion properly.

Essential Checklist:

  • Check backend DTO type (LocalTime vs LocalDateTime)
  • Verify timezone (UTC or local?)
  • Branch formatting logic (check for T)
  • Apply timezone offset when converting UTC

Now the weekly schedule times show up correctly in the seat monitoring popover! 🎉