refactor: API 응답에서 datetime을 date와 time으로 분리

- datetime 필드를 date와 time 필드로 분리하여 00:00 시간도 정상 표시되도록 수정
- 백엔드: formatSchedule, Meilisearch 검색 결과, 스키마 업데이트
- 프론트엔드: datetime 파싱 로직 제거, date/time 직접 사용
- 문서: API 응답 예시 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-24 10:11:02 +09:00
parent 5fa9c2a9d0
commit 87a69c0cbd
17 changed files with 98 additions and 92 deletions

View file

@ -25,7 +25,8 @@ export const scheduleResponse = {
properties: { properties: {
id: { type: 'integer' }, id: { type: 'integer' },
title: { type: 'string' }, title: { type: 'string' },
datetime: { type: 'string' }, date: { type: 'string', format: 'date' },
time: { type: 'string', nullable: true },
category: scheduleCategory, category: scheduleCategory,
members: { type: 'array', items: scheduleMember }, members: { type: 'array', items: scheduleMember },
createdAt: { type: 'string', format: 'date-time' }, createdAt: { type: 'string', format: 'date-time' },

View file

@ -8,7 +8,6 @@
import Inko from 'inko'; import Inko from 'inko';
import config, { CATEGORY_IDS } from '../../config/index.js'; import config, { CATEGORY_IDS } from '../../config/index.js';
import { createLogger } from '../../utils/logger.js'; import { createLogger } from '../../utils/logger.js';
import { buildDatetime } from '../schedule.js';
const inko = new Inko(); const inko = new Inko();
const logger = createLogger('Meilisearch'); const logger = createLogger('Meilisearch');
@ -166,7 +165,8 @@ function formatScheduleResponse(hit) {
return { return {
id: hit.id, id: hit.id,
title: hit.title, title: hit.title,
datetime: buildDatetime(hit.date, hit.time), date: hit.date,
time: hit.time || null,
category: { category: {
id: hit.category_id, id: hit.category_id,
name: hit.category_name, name: hit.category_name,

View file

@ -69,7 +69,8 @@ export function formatSchedule(rawSchedule, members = []) {
return { return {
id: rawSchedule.id, id: rawSchedule.id,
title: rawSchedule.title, title: rawSchedule.title,
datetime: buildDatetime(rawSchedule.date, rawSchedule.time), date: normalizeDate(rawSchedule.date),
time: rawSchedule.time || null,
category: { category: {
id: rawSchedule.category_id, id: rawSchedule.category_id,
name: rawSchedule.category_name, name: rawSchedule.category_name,
@ -220,7 +221,8 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
const result = { const result = {
id: s.id, id: s.id,
title: s.title, title: s.title,
datetime: buildDatetime(s.date, s.time), date: normalizeDate(s.date),
time: s.time || null,
category: { category: {
id: s.category_id, id: s.category_id,
name: s.category_name, name: s.category_name,
@ -324,7 +326,8 @@ export async function getMonthlySchedules(db, year, month) {
schedules.push({ schedules.push({
id: `birthday-${member.id}`, id: `birthday-${member.id}`,
title: `HAPPY ${member.name_en} DAY`, title: `HAPPY ${member.name_en} DAY`,
datetime: birthdayDate.toISOString().split('T')[0], date: birthdayDate.toISOString().split('T')[0],
time: null,
category: { category: {
id: CATEGORY_IDS.BIRTHDAY, id: CATEGORY_IDS.BIRTHDAY,
name: '생일', name: '생일',
@ -338,7 +341,7 @@ export async function getMonthlySchedules(db, year, month) {
} }
// 날짜순 정렬 // 날짜순 정렬
schedules.sort((a, b) => a.datetime.localeCompare(b.datetime)); schedules.sort((a, b) => a.date.localeCompare(b.date));
return { schedules }; return { schedules };
} }

View file

@ -50,25 +50,24 @@ Base URL: `/api`
**월별 조회 응답:** **월별 조회 응답:**
```json ```json
{ {
"2026-01-18": {
"categories": [
{ "id": 2, "name": "유튜브", "color": "#ff0033", "count": 3 }
],
"schedules": [ "schedules": [
{ {
"id": 123, "id": 123,
"title": "...", "title": "...",
"date": "2026-01-18",
"time": "19:00:00", "time": "19:00:00",
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
"source": { "source": {
"name": "fromis_9", "name": "fromis_9",
"url": "https://www.youtube.com/watch?v=VIDEO_ID" "url": "https://www.youtube.com/watch?v=VIDEO_ID"
} },
"members": ["송하영"]
} }
] ]
}
} }
``` ```
`time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
```
**source 객체 (카테고리별):** **source 객체 (카테고리별):**
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }` - YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
@ -77,20 +76,22 @@ Base URL: `/api`
**다가오는 일정 응답 (startDate):** **다가오는 일정 응답 (startDate):**
```json ```json
[ {
"schedules": [
{ {
"id": 123, "id": 123,
"title": "...", "title": "...",
"date": "2026-01-18", "date": "2026-01-18",
"time": "19:00:00", "time": "19:00:00",
"category_id": 2, "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
"category_name": "유튜브", "source": { "name": "fromis_9", "url": "https://..." },
"category_color": "#ff0033", "members": ["송하영"]
"members": [{ "name": "송하영" }]
} }
] ]
}
``` ```
※ 현재 활동 멤버 전원인 경우 `[{ "name": "프로미스나인" }]` 반환 (탈퇴 멤버 제외) ※ 현재 활동 멤버 전원인 경우 `["프로미스나인"]` 반환 (탈퇴 멤버 제외)
`time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
**검색 응답:** **검색 응답:**
```json ```json
@ -99,7 +100,8 @@ Base URL: `/api`
{ {
"id": 123, "id": 123,
"title": "...", "title": "...",
"datetime": "2026-01-18T19:00:00", "date": "2026-01-18",
"time": "19:00:00",
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
"source": { "name": "fromis_9", "url": "https://..." }, "source": { "name": "fromis_9", "url": "https://..." },
"members": ["송하영"], "members": ["송하영"],
@ -112,6 +114,8 @@ Base URL: `/api`
"hasMore": true "hasMore": true
} }
``` ```
`time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
```
### GET /schedules/categories ### GET /schedules/categories
카테고리 목록 조회 카테고리 목록 조회

View file

@ -5,29 +5,17 @@ import { fetchAuthApi, fetchFormData } from '@/api/client';
/** /**
* API 응답을 프론트엔드 형식으로 변환 * API 응답을 프론트엔드 형식으로 변환
* - datetime date, time 분리
* - category 객체 category_id, category_name, category_color 플랫화 * - category 객체 category_id, category_name, category_color 플랫화
* - members 배열 member_names 문자열 * - members 배열 member_names 문자열
*/ */
function transformSchedule(schedule) { function transformSchedule(schedule) {
const category = schedule.category || {}; const category = schedule.category || {};
// datetime에서 date와 time 분리
let date = '';
let time = null;
if (schedule.datetime) {
const parts = schedule.datetime.split('T');
date = parts[0];
time = parts[1] || null;
}
// members 배열을 문자열로 (기존 코드 호환성) // members 배열을 문자열로 (기존 코드 호환성)
const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : ''; const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : '';
return { return {
...schedule, ...schedule,
date,
time,
category_id: category.id, category_id: category.id,
category_name: category.name, category_name: category.name,
category_color: category.color, category_color: category.color,

View file

@ -2,27 +2,16 @@
* 스케줄 API * 스케줄 API
*/ */
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client'; import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
import { getTodayKST, dayjs } from '@/utils'; import { getTodayKST } from '@/utils';
/** /**
* API 응답을 프론트엔드 형식으로 변환 * API 응답을 프론트엔드 형식으로 변환
* - datetime date, time 분리
* - category 객체 category_id, category_name, category_color 플랫화 * - category 객체 category_id, category_name, category_color 플랫화
* - members 배열 member_names 문자열 * - members 배열 member_names 문자열
*/ */
function transformSchedule(schedule) { function transformSchedule(schedule) {
const category = schedule.category || {}; const category = schedule.category || {};
// datetime에서 date와 time 분리
let date = '';
let time = null;
if (schedule.datetime) {
const dt = dayjs(schedule.datetime);
date = dt.format('YYYY-MM-DD');
// datetime에 T가 포함되어 있으면 시간이 있는 것
time = schedule.datetime.includes('T') ? dt.format('HH:mm:ss') : null;
}
// members 배열을 문자열로 (기존 코드 호환성) // members 배열을 문자열로 (기존 코드 호환성)
const memberNames = Array.isArray(schedule.members) const memberNames = Array.isArray(schedule.members)
? schedule.members.join(',') ? schedule.members.join(',')
@ -30,8 +19,6 @@ function transformSchedule(schedule) {
return { return {
...schedule, ...schedule,
date,
time,
category_id: category.id, category_id: category.id,
category_name: category.name, category_name: category.name,
category_color: category.color, category_color: category.color,

View file

@ -14,7 +14,7 @@ const ScheduleSearchCard = memo(function ScheduleSearchCard({
delay = 0, delay = 0,
className = '', className = '',
}) { }) {
const scheduleDate = new Date(schedule.date || schedule.datetime); const scheduleDate = new Date(schedule.date);
const categoryInfo = getCategoryInfo(schedule); const categoryInfo = getCategoryInfo(schedule);
const timeStr = getScheduleTime(schedule); const timeStr = getScheduleTime(schedule);
const displayMembers = getDisplayMembers(schedule); const displayMembers = getDisplayMembers(schedule);

View file

@ -12,7 +12,7 @@ function AdminScheduleCard({
onDelete, onDelete,
className = '', className = '',
}) { }) {
const scheduleDate = new Date(schedule.date || schedule.datetime); const scheduleDate = new Date(schedule.date);
const today = new Date(); const today = new Date();
const currentYear = today.getFullYear(); const currentYear = today.getFullYear();
const currentMonth = today.getMonth(); const currentMonth = today.getMonth();

View file

@ -5,7 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react'; import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
import Linkify from 'react-linkify'; import Linkify from 'react-linkify';
import { getSchedule } from '@/api'; import { getSchedule } from '@/api';
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTime } from '@/utils'; import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
/** /**
* 전체화면 자동 가로 회전 (숏츠가 아닐 때만) * 전체화면 자동 가로 회전 (숏츠가 아닐 때만)
@ -88,7 +88,7 @@ function MobileYoutubeSection({ schedule }) {
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3"> <div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar size={12} /> <Calendar size={12} />
<span>{formatXDateTime(schedule.datetime)}</span> <span>{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
</div> </div>
{schedule.channelName && ( {schedule.channelName && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -274,7 +274,7 @@ function MobileXSection({ schedule }) {
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="px-4 py-3 border-t border-gray-100"> <div className="px-4 py-3 border-t border-gray-100">
<span className="text-gray-500 text-sm">{formatXDateTime(schedule.datetime)}</span> <span className="text-gray-500 text-sm">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
</div> </div>
{/* X에서 보기 버튼 */} {/* X에서 보기 버튼 */}
@ -371,12 +371,12 @@ function MobileDefaultSection({ schedule }) {
<div className="flex items-center gap-3 text-xs text-gray-500"> <div className="flex items-center gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar size={12} /> <Calendar size={12} />
<span>{formatFullDate(schedule.datetime)}</span> <span>{formatFullDate(schedule.date)}</span>
</div> </div>
{schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && ( {schedule.time && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock size={12} /> <Clock size={12} />
<span>{formatTime(schedule.datetime?.split('T')[1])}</span> <span>{formatTime(schedule.time)}</span>
</div> </div>
)} )}
</div> </div>

View file

@ -235,19 +235,17 @@ function YouTubeEditForm() {
? `https://www.youtube.com/shorts/${schedule.videoId}` ? `https://www.youtube.com/shorts/${schedule.videoId}`
: `https://www.youtube.com/watch?v=${schedule.videoId}`; : `https://www.youtube.com/watch?v=${schedule.videoId}`;
// (datetime ) //
const formatDatetime = (datetime) => { const formatDatetime = (dateStr, timeStr) => {
if (!datetime) return ""; if (!dateStr) return "";
// datetime: "2025-01-20T14:00:00" "2025-01-20"
const dateStr = datetime.split("T")[0];
const timeStr = datetime.includes("T") ? datetime.split("T")[1]?.slice(0, 5) : "";
const date = new Date(dateStr); const date = new Date(dateStr);
const year = date.getFullYear(); const year = date.getFullYear();
const month = date.getMonth() + 1; const month = date.getMonth() + 1;
const day = date.getDate(); const day = date.getDate();
const dayNames = ["일", "월", "화", "수", "목", "금", "토"]; const dayNames = ["일", "월", "화", "수", "목", "금", "토"];
const dayName = dayNames[date.getDay()]; const dayName = dayNames[date.getDay()];
return `${year}${month}${day}일 (${dayName}) ${timeStr}`; const time = timeStr ? timeStr.slice(0, 5) : "";
return `${year}${month}${day}일 (${dayName}) ${time}`;
}; };
return ( return (
@ -324,7 +322,7 @@ function YouTubeEditForm() {
</span> </span>
<span> <span>
<span className="text-gray-400">업로드:</span>{" "} <span className="text-gray-400">업로드:</span>{" "}
{formatDatetime(schedule.datetime)} {formatDatetime(schedule.date, schedule.time)}
</span> </span>
</div> </div>
@ -456,7 +454,7 @@ function YouTubeEditForm() {
</span> </span>
<span> <span>
<span className="text-gray-400">업로드:</span>{" "} <span className="text-gray-400">업로드:</span>{" "}
{formatDatetime(schedule.datetime)} {formatDatetime(schedule.date, schedule.time)}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-400">유형:</span> <span className="text-gray-400">유형:</span>

View file

@ -14,12 +14,12 @@ function DefaultSection({ schedule }) {
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-6"> <div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar size={16} /> <Calendar size={16} />
<span>{formatFullDate(schedule.datetime)}</span> <span>{formatFullDate(schedule.date)}</span>
</div> </div>
{schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && ( {schedule.time && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock size={16} /> <Clock size={16} />
<span>{formatTime(schedule.datetime?.split('T')[1])}</span> <span>{formatTime(schedule.time)}</span>
</div> </div>
)} )}
</div> </div>

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Linkify from 'react-linkify'; import Linkify from 'react-linkify';
import { decodeHtmlEntities, formatXDateTime } from './utils'; import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils';
import { Lightbox } from '@/components/common'; import { Lightbox } from '@/components/common';
/** /**
@ -130,7 +130,7 @@ function XSection({ schedule }) {
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="px-5 py-4 border-t border-gray-100"> <div className="px-5 py-4 border-t border-gray-100">
<span className="text-gray-500 text-[15px]">{formatXDateTime(schedule.datetime)}</span> <span className="text-gray-500 text-[15px]">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
</div> </div>
{/* X에서 보기 버튼 */} {/* X에서 보기 버튼 */}

View file

@ -1,6 +1,6 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Calendar, Link2 } from 'lucide-react'; import { Calendar, Link2 } from 'lucide-react';
import { decodeHtmlEntities, formatXDateTime } from './utils'; import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils';
/** /**
* 영상 정보 컴포넌트 (공통) * 영상 정보 컴포넌트 (공통)
@ -21,7 +21,7 @@ function VideoInfo({ schedule, isShorts }) {
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="flex items-center gap-1.5 text-gray-500"> <div className="flex items-center gap-1.5 text-gray-500">
<Calendar size={14} /> <Calendar size={14} />
<span>{formatXDateTime(schedule.datetime)}</span> <span>{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
</div> </div>
{/* 채널명 */} {/* 채널명 */}

View file

@ -4,4 +4,4 @@
// @/utils에서 re-export // @/utils에서 re-export
export { decodeHtmlEntities, formatTime } from '@/utils'; export { decodeHtmlEntities, formatTime } from '@/utils';
export { formatFullDate, formatXDateTime } from '@/utils'; export { formatFullDate, formatXDateTime, formatXDateTimeWithTime } from '@/utils';

View file

@ -89,6 +89,31 @@ export const formatXDateTime = (datetime) => {
return datePart; return datePart;
}; };
/**
* X(트위터) 스타일 날짜/시간 포맷팅 (time 필드 분리 버전)
* @param {string} date - 날짜 문자열 (YYYY-MM-DD)
* @param {string|null} time - 시간 문자열 (HH:mm:ss) 또는 null
* @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
*/
export const formatXDateTimeWithTime = (date, time) => {
if (!date) return '';
const d = dayjs(date).tz(TIMEZONE);
const datePart = `${d.year()}${d.month() + 1}${d.date()}`;
// time이 있으면 시간 표시
if (time) {
const [hourStr, minuteStr] = time.split(':');
const hours = parseInt(hourStr, 10);
const minutes = parseInt(minuteStr, 10);
const period = hours < 12 ? '오전' : '오후';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
}
return datePart;
};
/** /**
* datetime 문자열에서 date 추출 * datetime 문자열에서 date 추출
* @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DD" * @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DD"

View file

@ -13,6 +13,7 @@ export {
isToday, isToday,
formatFullDate, formatFullDate,
formatXDateTime, formatXDateTime,
formatXDateTimeWithTime,
extractDate, extractDate,
extractTime, extractTime,
dayjs, dayjs,

View file

@ -28,12 +28,11 @@ export function getCategoryInfo(schedule) {
/** /**
* 스케줄에서 날짜 추출 * 스케줄에서 날짜 추출
* datetime 형식과 date 형식 모두 처리
* @param {object} schedule - 스케줄 객체 * @param {object} schedule - 스케줄 객체
* @returns {string} YYYY-MM-DD 형식 날짜 * @returns {string} YYYY-MM-DD 형식 날짜
*/ */
export function getScheduleDate(schedule) { export function getScheduleDate(schedule) {
return schedule.date || extractDate(schedule.datetime); return schedule.date || '';
} }
/** /**
@ -45,7 +44,7 @@ export function getScheduleTime(schedule) {
if (schedule.time) { if (schedule.time) {
return schedule.time.slice(0, 5); return schedule.time.slice(0, 5);
} }
return extractTime(schedule.datetime); return null;
} }
/** /**