From 87a69c0cbddc44d8f48fe70333417d5caf6b3c40 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 24 Jan 2026 10:11:02 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20API=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20datetime=EC=9D=84=20date=EC=99=80=20time=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - datetime 필드를 date와 time 필드로 분리하여 00:00 시간도 정상 표시되도록 수정 - 백엔드: formatSchedule, Meilisearch 검색 결과, 스키마 업데이트 - 프론트엔드: datetime 파싱 로직 제거, date/time 직접 사용 - 문서: API 응답 예시 업데이트 Co-Authored-By: Claude Opus 4.5 --- backend/src/schemas/schedule.js | 3 +- backend/src/services/meilisearch/index.js | 4 +- backend/src/services/schedule.js | 11 ++-- docs/api.md | 66 ++++++++++--------- frontend/src/api/admin/schedules.js | 12 ---- frontend/src/api/public/schedules.js | 15 +---- .../mobile/schedule/ScheduleSearchCard.jsx | 2 +- .../pc/admin/schedule/AdminScheduleCard.jsx | 2 +- .../pages/mobile/schedule/ScheduleDetail.jsx | 12 ++-- .../admin/schedules/edit/YouTubeEditForm.jsx | 16 ++--- .../schedule/sections/DefaultSection.jsx | 6 +- .../pc/public/schedule/sections/XSection.jsx | 4 +- .../schedule/sections/YoutubeSection.jsx | 4 +- .../pc/public/schedule/sections/utils.js | 2 +- frontend/src/utils/date.js | 25 +++++++ frontend/src/utils/index.js | 1 + frontend/src/utils/schedule.js | 5 +- 17 files changed, 98 insertions(+), 92 deletions(-) diff --git a/backend/src/schemas/schedule.js b/backend/src/schemas/schedule.js index efd01e1..bc4614e 100644 --- a/backend/src/schemas/schedule.js +++ b/backend/src/schemas/schedule.js @@ -25,7 +25,8 @@ export const scheduleResponse = { properties: { id: { type: 'integer' }, title: { type: 'string' }, - datetime: { type: 'string' }, + date: { type: 'string', format: 'date' }, + time: { type: 'string', nullable: true }, category: scheduleCategory, members: { type: 'array', items: scheduleMember }, createdAt: { type: 'string', format: 'date-time' }, diff --git a/backend/src/services/meilisearch/index.js b/backend/src/services/meilisearch/index.js index fc601f6..308c10b 100644 --- a/backend/src/services/meilisearch/index.js +++ b/backend/src/services/meilisearch/index.js @@ -8,7 +8,6 @@ import Inko from 'inko'; import config, { CATEGORY_IDS } from '../../config/index.js'; import { createLogger } from '../../utils/logger.js'; -import { buildDatetime } from '../schedule.js'; const inko = new Inko(); const logger = createLogger('Meilisearch'); @@ -166,7 +165,8 @@ function formatScheduleResponse(hit) { return { id: hit.id, title: hit.title, - datetime: buildDatetime(hit.date, hit.time), + date: hit.date, + time: hit.time || null, category: { id: hit.category_id, name: hit.category_name, diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index e3d06ad..4b70a72 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -69,7 +69,8 @@ export function formatSchedule(rawSchedule, members = []) { return { id: rawSchedule.id, title: rawSchedule.title, - datetime: buildDatetime(rawSchedule.date, rawSchedule.time), + date: normalizeDate(rawSchedule.date), + time: rawSchedule.time || null, category: { id: rawSchedule.category_id, name: rawSchedule.category_name, @@ -220,7 +221,8 @@ export async function getScheduleDetail(db, id, getXProfile = null) { const result = { id: s.id, title: s.title, - datetime: buildDatetime(s.date, s.time), + date: normalizeDate(s.date), + time: s.time || null, category: { id: s.category_id, name: s.category_name, @@ -324,7 +326,8 @@ export async function getMonthlySchedules(db, year, month) { schedules.push({ id: `birthday-${member.id}`, title: `HAPPY ${member.name_en} DAY`, - datetime: birthdayDate.toISOString().split('T')[0], + date: birthdayDate.toISOString().split('T')[0], + time: null, category: { id: CATEGORY_IDS.BIRTHDAY, 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 }; } diff --git a/docs/api.md b/docs/api.md index d5ece60..07429b3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -50,25 +50,24 @@ Base URL: `/api` **월별 조회 응답:** ```json { - "2026-01-18": { - "categories": [ - { "id": 2, "name": "유튜브", "color": "#ff0033", "count": 3 } - ], - "schedules": [ - { - "id": 123, - "title": "...", - "time": "19:00:00", - "category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, - "source": { - "name": "fromis_9", - "url": "https://www.youtube.com/watch?v=VIDEO_ID" - } - } - ] - } + "schedules": [ + { + "id": 123, + "title": "...", + "date": "2026-01-18", + "time": "19:00:00", + "category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, + "source": { + "name": "fromis_9", + "url": "https://www.youtube.com/watch?v=VIDEO_ID" + }, + "members": ["송하영"] + } + ] } ``` +※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환 +``` **source 객체 (카테고리별):** - YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }` @@ -77,20 +76,22 @@ Base URL: `/api` **다가오는 일정 응답 (startDate):** ```json -[ - { - "id": 123, - "title": "...", - "date": "2026-01-18", - "time": "19:00:00", - "category_id": 2, - "category_name": "유튜브", - "category_color": "#ff0033", - "members": [{ "name": "송하영" }] - } -] +{ + "schedules": [ + { + "id": 123, + "title": "...", + "date": "2026-01-18", + "time": "19:00:00", + "category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, + "source": { "name": "fromis_9", "url": "https://..." }, + "members": ["송하영"] + } + ] +} ``` -※ 현재 활동 멤버 전원인 경우 `[{ "name": "프로미스나인" }]` 반환 (탈퇴 멤버 제외) +※ 현재 활동 멤버 전원인 경우 `["프로미스나인"]` 반환 (탈퇴 멤버 제외) +※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환 **검색 응답:** ```json @@ -99,7 +100,8 @@ Base URL: `/api` { "id": 123, "title": "...", - "datetime": "2026-01-18T19:00:00", + "date": "2026-01-18", + "time": "19:00:00", "category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, "source": { "name": "fromis_9", "url": "https://..." }, "members": ["송하영"], @@ -112,6 +114,8 @@ Base URL: `/api` "hasMore": true } ``` +※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환 +``` ### GET /schedules/categories 카테고리 목록 조회 diff --git a/frontend/src/api/admin/schedules.js b/frontend/src/api/admin/schedules.js index d7855c8..455a50e 100644 --- a/frontend/src/api/admin/schedules.js +++ b/frontend/src/api/admin/schedules.js @@ -5,29 +5,17 @@ import { fetchAuthApi, fetchFormData } from '@/api/client'; /** * API 응답을 프론트엔드 형식으로 변환 - * - datetime → date, time 분리 * - category 객체 → category_id, category_name, category_color 플랫화 * - members 배열 → member_names 문자열 */ function transformSchedule(schedule) { 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 배열을 문자열로 (기존 코드 호환성) const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : ''; return { ...schedule, - date, - time, category_id: category.id, category_name: category.name, category_color: category.color, diff --git a/frontend/src/api/public/schedules.js b/frontend/src/api/public/schedules.js index 18a9739..b43748d 100644 --- a/frontend/src/api/public/schedules.js +++ b/frontend/src/api/public/schedules.js @@ -2,27 +2,16 @@ * 스케줄 API */ import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client'; -import { getTodayKST, dayjs } from '@/utils'; +import { getTodayKST } from '@/utils'; /** * API 응답을 프론트엔드 형식으로 변환 - * - datetime → date, time 분리 * - category 객체 → category_id, category_name, category_color 플랫화 * - members 배열 → member_names 문자열 */ function transformSchedule(schedule) { 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 배열을 문자열로 (기존 코드 호환성) const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') @@ -30,8 +19,6 @@ function transformSchedule(schedule) { return { ...schedule, - date, - time, category_id: category.id, category_name: category.name, category_color: category.color, diff --git a/frontend/src/components/mobile/schedule/ScheduleSearchCard.jsx b/frontend/src/components/mobile/schedule/ScheduleSearchCard.jsx index 527b091..0f6f93d 100644 --- a/frontend/src/components/mobile/schedule/ScheduleSearchCard.jsx +++ b/frontend/src/components/mobile/schedule/ScheduleSearchCard.jsx @@ -14,7 +14,7 @@ const ScheduleSearchCard = memo(function ScheduleSearchCard({ delay = 0, className = '', }) { - const scheduleDate = new Date(schedule.date || schedule.datetime); + const scheduleDate = new Date(schedule.date); const categoryInfo = getCategoryInfo(schedule); const timeStr = getScheduleTime(schedule); const displayMembers = getDisplayMembers(schedule); diff --git a/frontend/src/components/pc/admin/schedule/AdminScheduleCard.jsx b/frontend/src/components/pc/admin/schedule/AdminScheduleCard.jsx index 3f30a0a..12d3884 100644 --- a/frontend/src/components/pc/admin/schedule/AdminScheduleCard.jsx +++ b/frontend/src/components/pc/admin/schedule/AdminScheduleCard.jsx @@ -12,7 +12,7 @@ function AdminScheduleCard({ onDelete, className = '', }) { - const scheduleDate = new Date(schedule.date || schedule.datetime); + const scheduleDate = new Date(schedule.date); const today = new Date(); const currentYear = today.getFullYear(); const currentMonth = today.getMonth(); diff --git a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx index a35ce68..3490ad2 100644 --- a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx +++ b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx @@ -5,7 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react'; import Linkify from 'react-linkify'; 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 }) {
- {formatXDateTime(schedule.datetime)} + {formatXDateTimeWithTime(schedule.date, schedule.time)}
{schedule.channelName && (
@@ -274,7 +274,7 @@ function MobileXSection({ schedule }) { {/* 날짜/시간 */}
- {formatXDateTime(schedule.datetime)} + {formatXDateTimeWithTime(schedule.date, schedule.time)}
{/* X에서 보기 버튼 */} @@ -371,12 +371,12 @@ function MobileDefaultSection({ schedule }) {
- {formatFullDate(schedule.datetime)} + {formatFullDate(schedule.date)}
- {schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && ( + {schedule.time && (
- {formatTime(schedule.datetime?.split('T')[1])} + {formatTime(schedule.time)}
)}
diff --git a/frontend/src/pages/pc/admin/schedules/edit/YouTubeEditForm.jsx b/frontend/src/pages/pc/admin/schedules/edit/YouTubeEditForm.jsx index 25bc715..2f8c71f 100644 --- a/frontend/src/pages/pc/admin/schedules/edit/YouTubeEditForm.jsx +++ b/frontend/src/pages/pc/admin/schedules/edit/YouTubeEditForm.jsx @@ -235,19 +235,17 @@ function YouTubeEditForm() { ? `https://www.youtube.com/shorts/${schedule.videoId}` : `https://www.youtube.com/watch?v=${schedule.videoId}`; - // 날짜 포맷팅 함수 (datetime 문자열 파싱) - const formatDatetime = (datetime) => { - if (!datetime) 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 formatDatetime = (dateStr, timeStr) => { + if (!dateStr) return ""; const date = new Date(dateStr); const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); const dayNames = ["일", "월", "화", "수", "목", "금", "토"]; 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 ( @@ -324,7 +322,7 @@ function YouTubeEditForm() { 업로드:{" "} - {formatDatetime(schedule.datetime)} + {formatDatetime(schedule.date, schedule.time)}
@@ -456,7 +454,7 @@ function YouTubeEditForm() { 업로드:{" "} - {formatDatetime(schedule.datetime)} + {formatDatetime(schedule.date, schedule.time)}
유형: diff --git a/frontend/src/pages/pc/public/schedule/sections/DefaultSection.jsx b/frontend/src/pages/pc/public/schedule/sections/DefaultSection.jsx index 060b414..902de00 100644 --- a/frontend/src/pages/pc/public/schedule/sections/DefaultSection.jsx +++ b/frontend/src/pages/pc/public/schedule/sections/DefaultSection.jsx @@ -14,12 +14,12 @@ function DefaultSection({ schedule }) {
- {formatFullDate(schedule.datetime)} + {formatFullDate(schedule.date)}
- {schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && ( + {schedule.time && (
- {formatTime(schedule.datetime?.split('T')[1])} + {formatTime(schedule.time)}
)}
diff --git a/frontend/src/pages/pc/public/schedule/sections/XSection.jsx b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx index 73d704f..891c58d 100644 --- a/frontend/src/pages/pc/public/schedule/sections/XSection.jsx +++ b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { motion } from 'framer-motion'; import Linkify from 'react-linkify'; -import { decodeHtmlEntities, formatXDateTime } from './utils'; +import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils'; import { Lightbox } from '@/components/common'; /** @@ -130,7 +130,7 @@ function XSection({ schedule }) { {/* 날짜/시간 */}
- {formatXDateTime(schedule.datetime)} + {formatXDateTimeWithTime(schedule.date, schedule.time)}
{/* X에서 보기 버튼 */} diff --git a/frontend/src/pages/pc/public/schedule/sections/YoutubeSection.jsx b/frontend/src/pages/pc/public/schedule/sections/YoutubeSection.jsx index 7bdb54f..2cd8846 100644 --- a/frontend/src/pages/pc/public/schedule/sections/YoutubeSection.jsx +++ b/frontend/src/pages/pc/public/schedule/sections/YoutubeSection.jsx @@ -1,6 +1,6 @@ import { motion } from 'framer-motion'; import { Calendar, Link2 } from 'lucide-react'; -import { decodeHtmlEntities, formatXDateTime } from './utils'; +import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils'; /** * 영상 정보 컴포넌트 (공통) @@ -21,7 +21,7 @@ function VideoInfo({ schedule, isShorts }) { {/* 날짜/시간 */}
- {formatXDateTime(schedule.datetime)} + {formatXDateTimeWithTime(schedule.date, schedule.time)}
{/* 채널명 */} diff --git a/frontend/src/pages/pc/public/schedule/sections/utils.js b/frontend/src/pages/pc/public/schedule/sections/utils.js index 6459626..f1ff508 100644 --- a/frontend/src/pages/pc/public/schedule/sections/utils.js +++ b/frontend/src/pages/pc/public/schedule/sections/utils.js @@ -4,4 +4,4 @@ // @/utils에서 re-export export { decodeHtmlEntities, formatTime } from '@/utils'; -export { formatFullDate, formatXDateTime } from '@/utils'; +export { formatFullDate, formatXDateTime, formatXDateTimeWithTime } from '@/utils'; diff --git a/frontend/src/utils/date.js b/frontend/src/utils/date.js index d8dfa56..c7569ec 100644 --- a/frontend/src/utils/date.js +++ b/frontend/src/utils/date.js @@ -89,6 +89,31 @@ export const formatXDateTime = (datetime) => { 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 추출 * @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DD" diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 2cf48e3..42bb58d 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -13,6 +13,7 @@ export { isToday, formatFullDate, formatXDateTime, + formatXDateTimeWithTime, extractDate, extractTime, dayjs, diff --git a/frontend/src/utils/schedule.js b/frontend/src/utils/schedule.js index 1936d42..449f5c7 100644 --- a/frontend/src/utils/schedule.js +++ b/frontend/src/utils/schedule.js @@ -28,12 +28,11 @@ export function getCategoryInfo(schedule) { /** * 스케줄에서 날짜 추출 - * datetime 형식과 date 형식 모두 처리 * @param {object} schedule - 스케줄 객체 * @returns {string} YYYY-MM-DD 형식 날짜 */ export function getScheduleDate(schedule) { - return schedule.date || extractDate(schedule.datetime); + return schedule.date || ''; } /** @@ -45,7 +44,7 @@ export function getScheduleTime(schedule) { if (schedule.time) { return schedule.time.slice(0, 5); } - return extractTime(schedule.datetime); + return null; } /**