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: {
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' },

View file

@ -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,

View file

@ -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 };
}

View file

@ -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
카테고리 목록 조회

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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();

View file

@ -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 }) {
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3">
<div className="flex items-center gap-1">
<Calendar size={12} />
<span>{formatXDateTime(schedule.datetime)}</span>
<span>{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
</div>
{schedule.channelName && (
<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">
<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>
{/* 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-1">
<Calendar size={12} />
<span>{formatFullDate(schedule.datetime)}</span>
<span>{formatFullDate(schedule.date)}</span>
</div>
{schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && (
{schedule.time && (
<div className="flex items-center gap-1">
<Clock size={12} />
<span>{formatTime(schedule.datetime?.split('T')[1])}</span>
<span>{formatTime(schedule.time)}</span>
</div>
)}
</div>

View file

@ -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() {
</span>
<span>
<span className="text-gray-400">업로드:</span>{" "}
{formatDatetime(schedule.datetime)}
{formatDatetime(schedule.date, schedule.time)}
</span>
</div>
@ -456,7 +454,7 @@ function YouTubeEditForm() {
</span>
<span>
<span className="text-gray-400">업로드:</span>{" "}
{formatDatetime(schedule.datetime)}
{formatDatetime(schedule.date, schedule.time)}
</span>
<div className="flex items-center gap-2">
<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 items-center gap-2">
<Calendar size={16} />
<span>{formatFullDate(schedule.datetime)}</span>
<span>{formatFullDate(schedule.date)}</span>
</div>
{schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && (
{schedule.time && (
<div className="flex items-center gap-2">
<Clock size={16} />
<span>{formatTime(schedule.datetime?.split('T')[1])}</span>
<span>{formatTime(schedule.time)}</span>
</div>
)}
</div>

View file

@ -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 }) {
{/* 날짜/시간 */}
<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>
{/* X에서 보기 버튼 */}

View file

@ -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 }) {
{/* 날짜/시간 */}
<div className="flex items-center gap-1.5 text-gray-500">
<Calendar size={14} />
<span>{formatXDateTime(schedule.datetime)}</span>
<span>{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
</div>
{/* 채널명 */}

View file

@ -4,4 +4,4 @@
// @/utils에서 re-export
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;
};
/**
* 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"

View file

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

View file

@ -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;
}
/**