From 2a7259d59766642b29ffd2ef57a0f3f0eb9a69da Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 10 Mar 2026 02:03:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=8B=A4=EC=9D=8C=20=EC=A3=BC?= =?UTF-8?q?=EA=B8=B0=20=ED=91=9C=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/popup.css | 11 +++++++ public/popup.html | 4 +++ src/api.js | 13 ++++++++ src/handlers/notification.js | 5 ++-- src/popup.js | 19 ++++++++++++ src/ui/elements.js | 4 +++ src/utils.js | 57 ++++++++++++++++++++++++++++++++++++ 7 files changed, 111 insertions(+), 2 deletions(-) diff --git a/public/popup.css b/public/popup.css index 57a2253..7b5248b 100644 --- a/public/popup.css +++ b/public/popup.css @@ -167,6 +167,17 @@ body { color: #666; } +/* 다음 리뷰 정보 */ +.next-review-info { + margin-bottom: 12px; + padding: 8px 12px; + background-color: #eaf4fb; + border-radius: 6px; + font-size: 12px; + color: #2980b9; + text-align: center; +} + /* 저장 결과 */ .result { margin-top: 16px; diff --git a/public/popup.html b/public/popup.html index e5b1515..a7235f2 100644 --- a/public/popup.html +++ b/public/popup.html @@ -38,6 +38,10 @@

Recycle Study

+ +
diff --git a/src/api.js b/src/api.js index ecd7784..bcba780 100644 --- a/src/api.js +++ b/src/api.js @@ -176,3 +176,16 @@ export async function updateNotificationTime(identifier, hour, minute) { body: { notificationTime: [hour, minute] } }); } + +/** + * 다음 리뷰 정보 조회 + * @param {string} identifier - 디바이스 식별자 + * @returns {Promise} { scheduledAt, count } + */ +export async function getNextReview(identifier) { + return await sendApiRequest({ + endpoint: '/api/v1/reviews/next', + method: 'GET', + headers: { 'X-Device-Id': identifier } + }); +} diff --git a/src/handlers/notification.js b/src/handlers/notification.js index 2e7df38..7567458 100644 --- a/src/handlers/notification.js +++ b/src/handlers/notification.js @@ -13,6 +13,7 @@ import { showMessage, handleApiError } from '../ui/index.js'; +import { utcTimeStringToLocal, localTimeStringToUtcArray } from '../utils.js'; /** * 알림 시간 설정 버튼 클릭 핸들러 (toggle) @@ -31,7 +32,7 @@ export async function handleShowNotificationTime() { const result = await getNotificationTime(storageData.identifier); if (result.notificationTime) { - elements.notificationTimeInput.value = result.notificationTime.substring(0, 5); + elements.notificationTimeInput.value = utcTimeStringToLocal(result.notificationTime); } else { elements.notificationTimeInput.value = ''; } @@ -58,7 +59,7 @@ export async function handleUpdateNotificationTime() { try { showLoading(); const storageData = await validateStorageForAuth(); - const [hour, minute] = timeValue.split(':').map(Number); + const [hour, minute] = localTimeStringToUtcArray(timeValue); await updateNotificationTime(storageData.identifier, hour, minute); showMessage('알림 시간이 저장되었습니다.', 'success'); } catch (error) { diff --git a/src/popup.js b/src/popup.js index 12292ff..7e47004 100644 --- a/src/popup.js +++ b/src/popup.js @@ -12,6 +12,8 @@ import { showMessage, hideCycleModal } from './ui/index.js'; +import { getNextReview } from './api.js'; +import { formatNextReviewDate } from './utils.js'; import { handleRegister, handleCheckAuth, @@ -98,6 +100,7 @@ async function initialize() { showView('main'); // 주기 옵션 로드 handleLoadCycleOptions(); + loadNextReview(storageData.identifier); } else if (storageData.email && storageData.identifier) { elements.emailDisplay.textContent = storageData.email; showView('pending'); @@ -111,6 +114,22 @@ async function initialize() { } } +/** + * 다음 리뷰 정보 로드 및 표시 + */ +async function loadNextReview(identifier) { + try { + const result = await getNextReview(identifier); + if (result.scheduledAt && result.count > 0) { + elements.nextReviewText.textContent = + `다음 리뷰: ${formatNextReviewDate(result.scheduledAt)} / 대기 중 ${result.count}개`; + elements.nextReviewInfo.classList.remove('hidden'); + } + } catch (error) { + console.debug('다음 리뷰 정보 조회 실패:', error); + } +} + /** * DOM 로드 후 실행 */ diff --git a/src/ui/elements.js b/src/ui/elements.js index 0564a44..fc26363 100644 --- a/src/ui/elements.js +++ b/src/ui/elements.js @@ -24,6 +24,8 @@ export const elements = { // 메인 화면 userEmail: null, + nextReviewInfo: null, + nextReviewText: null, cycleSelect: null, saveUrlBtn: null, saveResult: null, @@ -72,6 +74,8 @@ export function initializeElements() { elements.resetBtn = document.getElementById('reset-btn'); elements.userEmail = document.getElementById('user-email'); + elements.nextReviewInfo = document.getElementById('next-review-info'); + elements.nextReviewText = document.getElementById('next-review-text'); elements.cycleSelect = document.getElementById('cycle-select'); elements.saveUrlBtn = document.getElementById('save-url-btn'); elements.saveResult = document.getElementById('save-result'); diff --git a/src/utils.js b/src/utils.js index d68d51c..6c35592 100644 --- a/src/utils.js +++ b/src/utils.js @@ -20,6 +20,30 @@ export function formatDate(dateString) { }); } +/** + * 다음 리뷰 날짜 포맷팅 (월/일/시:분) + * @param {string} dateString - ISO 형식 날짜 문자열 + * @returns {string} 포맷된 날짜 문자열 (예: "3월 6일 09:00") + */ +export function formatNextReviewDate(dateString) { + // 타임존 정보가 없으면 UTC로 가정 (서버 버그 방어) + const hasTimezone = /Z$|[+-]\d{2}:\d{2}$/.test(dateString); + const normalized = hasTimezone ? dateString : dateString + 'Z'; + + const date = new Date(normalized); + const parts = new Intl.DateTimeFormat('ko-KR', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + // timeZone 미지정 → 브라우저 로컬 타임존 자동 사용 (글로벌 대응) + }).formatToParts(date); + + const get = (type) => parts.find(p => p.type === type).value; + return `${get('month')}월 ${get('day')}일 ${get('hour')}:${get('minute')}`; +} + /** * 이메일 형식 검증 * @param {string} email - 검증할 이메일 @@ -28,4 +52,37 @@ export function formatDate(dateString) { export function isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); +} + +/** + * UTC 시간 문자열 → 로컬 HH:MM 변환 + * 예: "00:00:00" (UTC) → "09:00" (서울 UTC+9) + * @param {string} utcTimeString - "HH:MM:SS" 형식의 UTC 시간 문자열 + * @returns {string} 로컬 시간 "HH:MM" 문자열 + */ +export function utcTimeStringToLocal(utcTimeString) { + const [h, m] = utcTimeString.split(':'); + const today = new Date().toISOString().slice(0, 10); + const utcDate = new Date(`${today}T${h}:${m}:00Z`); + return utcDate.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); +} + +/** + * 로컬 HH:MM → UTC [hour, minute] 배열 변환 + * 예: "09:00" (서울 UTC+9) → [0, 0] (UTC) + * @param {string} localTimeString - "HH:MM" 형식의 로컬 시간 문자열 + * @returns {number[]} UTC 기준 [hour, minute] 배열 + */ +export function localTimeStringToUtcArray(localTimeString) { + const [h, m] = localTimeString.split(':').map(Number); + const today = new Date().toISOString().slice(0, 10); + // 타임존 suffix 없는 형식은 로컬 시간으로 파싱됨 (ECMAScript 표준) + const localDate = new Date( + `${today}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00` + ); + return [localDate.getUTCHours(), localDate.getUTCMinutes()]; } \ No newline at end of file From e0ac709cba1c35eb9d8581f52453eba9e488a5af Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 10 Mar 2026 02:05:42 +0900 Subject: [PATCH 2/2] =?UTF-8?q?style:=20=EA=B0=9C=ED=96=89=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.js b/src/utils.js index 6c35592..dcf2d91 100644 --- a/src/utils.js +++ b/src/utils.js @@ -85,4 +85,4 @@ export function localTimeStringToUtcArray(localTimeString) { `${today}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00` ); return [localDate.getUTCHours(), localDate.getUTCMinutes()]; -} \ No newline at end of file +}