diff --git a/package.json b/package.json index 1aa73c9..214d33d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "check:content-scripts": "node scripts/checkContentScripts.js", "lint": "eslint .", "test:timetable": "node --experimental-strip-types --test tests/timetable/*.test.ts", + "test:todo": "node --experimental-strip-types --test tests/todo/*.test.ts", "preview": "vite preview", "deploy": "git subtree push --prefix gh-pages origin gh-pages" }, diff --git a/src/apis/client.ts b/src/apis/client.ts index 4b9e038..c6eebbd 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -6,6 +6,7 @@ import type { ApiResponse, RequestConfig } from "../types/api"; import { BackgroundMessageType } from "../background/types"; import type { SilentReauthResponse } from "../background/types"; +import { getChromeApi, getStorage, removeStorage } from "../utils/chrome"; import { debugLog, errorLog, getErrorLogDetails, warnLog } from "@/utils/logger"; /** @@ -79,13 +80,12 @@ export const ENDPOINTS = { * Using chrome.storage.local for persistent token storage */ async function getAccessToken(): Promise { - const result = await chrome.storage.local.get(["accessToken"]); - const token = result.accessToken; + const token = await getStorage("accessToken"); return typeof token === "string" ? token : null; } async function clearAccessToken(): Promise { - await chrome.storage.local.remove([ + await removeStorage([ "accessToken", "refreshToken", "guestToken", @@ -107,10 +107,15 @@ async function handleTokenExpired(): Promise { debugLog("[API Client] Token expired (5004), attempting silent reauth..."); + const chromeApi = getChromeApi(); + if (!chromeApi?.runtime?.sendMessage) { + return false; + } + isReauthenticating = true; reauthPromise = (async () => { try { - const response = await chrome.runtime.sendMessage< + const response = await chromeApi.runtime.sendMessage< { type: BackgroundMessageType.SILENT_REAUTH }, SilentReauthResponse >({ diff --git a/src/apis/external/ecampus.ts b/src/apis/external/ecampus.ts index 8fb8196..6c34332 100644 --- a/src/apis/external/ecampus.ts +++ b/src/apis/external/ecampus.ts @@ -5,6 +5,8 @@ import { ECampusTodoItem } from '@/types/todo'; import { errorLog } from '@/utils/logger'; +import { isExtensionEnvironment } from '@/utils/chrome'; +import { calculateDDay } from '@/utils/todo/dateFormat'; export interface ECampusLoginResponse { success: boolean; @@ -36,6 +38,98 @@ export interface ECampusGoLectureResponse { error?: string; } +const LOCAL_SAMPLE_LECTURE_URL = '__LOCAL_SAMPLE_ECAMPUS_TODO__'; + +const isLocalSampleMode = () => { + return import.meta.env.MODE === 'development' && !isExtensionEnvironment(); +}; + +const pad = (value: number) => String(value).padStart(2, '0'); + +const formatECampusDueDate = (date: Date) => { + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = date.getHours(); + const minutes = pad(date.getMinutes()); + const period = hours < 12 ? '오전' : '오후'; + const twelveHour = hours % 12 === 0 ? 12 : hours % 12; + + return `${year}.${month}.${day} ${period} ${twelveHour}:${minutes}`; +}; + +const createLocalSampleTodo = ( + id: string, + title: string, + subject: string, + dueAt: Date, + kj: string, + seq: string, + gubun: string +): ECampusTodoItem => { + const dueDate = formatECampusDueDate(dueAt); + const dDay = calculateDDay( + `${dueAt.getFullYear()}.${pad(dueAt.getMonth() + 1)}.${pad(dueAt.getDate())}`, + `${pad(dueAt.getHours())}:${pad(dueAt.getMinutes())}` + ); + + return { + type: 'ecampus', + id, + title, + subject, + dDay, + dueDate, + kj, + gubun, + seq, + }; +}; + +const getLocalSampleTodos = (): ECampusTodoItem[] => { + const now = new Date(); + + const urgentDue = new Date(now.getTime() + 2 * 60 * 60 * 1000); + urgentDue.setSeconds(0, 0); + + const todayDue = new Date(now); + todayDue.setHours(23, 59, 0, 0); + + const tomorrowMorningDue = new Date(now); + tomorrowMorningDue.setDate(tomorrowMorningDue.getDate() + 1); + tomorrowMorningDue.setHours(10, 0, 0, 0); + + return [ + createLocalSampleTodo( + 'ecampus-local-1', + '캡스톤디자인 발표 자료 제출', + '캡스톤디자인', + urgentDue, + 'local-kj-1', + 'local-seq-1', + 'report' + ), + createLocalSampleTodo( + 'ecampus-local-2', + '운영체제 퀴즈 응시', + '운영체제', + todayDue, + 'local-kj-2', + 'local-seq-2', + 'quiz' + ), + createLocalSampleTodo( + 'ecampus-local-3', + '자료구조 5주차 강의 시청', + '자료구조', + tomorrowMorningDue, + 'local-kj-3', + 'local-seq-3', + 'lecture_weeks' + ), + ]; +}; + /** * Login to eCampus * @param userId User ID @@ -46,6 +140,20 @@ export async function eCampusLoginAPI( userId: string, userPw: string ): Promise { + if (isLocalSampleMode()) { + return { + success: true, + data: { + isError: false, + message: `${userId || 'local-user'} 로컬 로그인 성공`, + count: 0, + returnURL: '/local/ecampus', + ids_yn: 'Y', + VERIFY: 'LOCAL_SAMPLE_MODE', + }, + }; + } + try { const response = await fetch( 'https://ecampus.konkuk.ac.kr/ilos/lo/login.acl?data=jsonLogin', @@ -89,6 +197,15 @@ export async function eCampusLoginAPI( * @returns Todo list response */ export async function eCampusTodoListAPI(): Promise { + if (isLocalSampleMode()) { + return { + success: true, + data: { + todoList: getLocalSampleTodos(), + }, + }; + } + try { const response = await fetch( 'https://ecampus.konkuk.ac.kr/ilos/mp/todo_list.acl', @@ -102,6 +219,16 @@ export async function eCampusTodoListAPI(): Promise { credentials: 'include', } ); + + if (!response.ok) { + return { + success: false, + error: new Error( + `eCampus todo request failed: ${response.status} ${response.statusText}`, + ), + }; + } + const htmlText = await response.text(); // Parse HTML using DOM parser @@ -160,7 +287,7 @@ export async function eCampusTodoListAPI(): Promise { }; } catch (error) { errorLog('Failed to fetch todo list:', error); - return { success: false, needLogin: true, error }; + return { success: false, error }; } } @@ -176,6 +303,14 @@ export async function eCampusGoLectureAPI( seq: string, gubun: string ): Promise { + if (isLocalSampleMode()) { + return { + success: true, + isError: false, + message: LOCAL_SAMPLE_LECTURE_URL, + }; + } + try { const lectureUrl = `/ilos/mp/todo_list_connect.acl?SEQ=${seq}&gubun=${gubun}&KJKEY=${kj}`; @@ -192,3 +327,5 @@ export async function eCampusGoLectureAPI( }; } } + +export { LOCAL_SAMPLE_LECTURE_URL }; diff --git a/src/background/index.ts b/src/background/index.ts index 6d0a39a..28ccd43 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -22,6 +22,11 @@ import type { TimetableImportResponse, } from "./types"; import { handleGoogleLogin } from "./handlers/oauth"; +import { + formatTodoBadgeCount, + TODO_BADGE_BACKGROUND_COLOR, + TODO_BADGE_TEXT_COLOR, +} from "@/utils/todo/badge"; import { handlePendingImportTabRemoved, handlePendingImportTabUpdated, @@ -212,10 +217,14 @@ chrome.tabs.onRemoved.addListener((tabId) => { * Badge update for todo count */ function updateBadge(count: number) { - if (count > 0) { - chrome.action.setBadgeText({ text: count > 99 ? "99+" : String(count) }); - chrome.action.setBadgeBackgroundColor({ color: "#00913A" }); - chrome.action.setBadgeTextColor({ color: "#FFFFFF" }); + const badgeText = formatTodoBadgeCount(count); + + if (badgeText) { + chrome.action.setBadgeText({ text: badgeText }); + chrome.action.setBadgeBackgroundColor({ + color: TODO_BADGE_BACKGROUND_COLOR, + }); + chrome.action.setBadgeTextColor({ color: TODO_BADGE_TEXT_COLOR }); } else { chrome.action.setBadgeText({ text: "" }); } diff --git a/src/components/EmailVerificationDialog.tsx b/src/components/EmailVerificationDialog.tsx index 13a0aa5..3bc2437 100644 --- a/src/components/EmailVerificationDialog.tsx +++ b/src/components/EmailVerificationDialog.tsx @@ -23,6 +23,7 @@ import { } from '@/utils/formValidation'; import { errorLog } from '@/utils/logger'; import { sendAuthEmailVerificationStart, sendAuthEmailVerificationSuccess } from '@/utils/analytics'; +import { setStorage } from '@/utils/chrome'; interface EmailVerificationDialogProps { open: boolean; @@ -107,7 +108,7 @@ export function EmailVerificationDialog({ if (response.success) { toast.success('이메일 인증이 완료되었습니다!'); // Store verified email - await chrome.storage.local.set({ kuMail }); + await setStorage({ kuMail }); sendAuthEmailVerificationSuccess('konkuk.ac.kr'); // Trigger re-login to get member token onVerificationComplete(); diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 0b002c4..7a8314f 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -19,6 +19,7 @@ import { sendAuthLogout, sendSettingsCredentialsSaved, sendSettingsCredentialsDeleted, + sendSettingChange, } from "@/utils/analytics"; import { saveECampusCredentials, @@ -33,10 +34,22 @@ import { isGuestUser, UserProfile, } from "@/utils/oauth"; -import { eCampusLoginAPI } from "@/apis"; -import { Info, Palette, LogOut, Mail, User } from "lucide-react"; +import { Info, Palette, LogOut, Mail, User, Timer } from "lucide-react"; import { toast } from "sonner"; +import { getChromeApi, getStorage, setStorage } from "@/utils/chrome"; import { EmailVerificationDialog } from "@/components/EmailVerificationDialog"; +import TodoDeadlineBadge from "@/components/Tabs/TodoList/TodoDeadlineBadge"; +import { calculateDDay } from "@/utils/todo/dateFormat"; +import { + invalidateECampusTodosCache, + isECampusAccountCurrent, + loginECampusAccount, + notifyECampusTodosChange, +} from "@/utils/ecampus/todos"; +import { + clearECampusTodoCount, + refreshTodoCount, +} from "@/utils/todo/count"; import { errorLog } from '@/utils/logger'; interface SettingsDialogProps { @@ -49,6 +62,7 @@ const ECampusCredential = () => { const [savedPassword, setSavedPassword] = useState(""); const [hasCredentials, setHasCredentials] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [isSaving, setIsSaving] = useState(false); // 설정 페이지 열릴 때 저장된 계정 정보 불러오기 useEffect(() => { @@ -86,26 +100,52 @@ const ECampusCredential = () => { return; } + setIsSaving(true); + try { - // 1. 암호화 및 저장 + const loginAttempt = await loginECampusAccount(savedId, savedPassword); + if (loginAttempt.superseded) { + toast.error("다른 계정 변경으로 저장을 완료하지 않았습니다."); + return; + } + + if (!loginAttempt.result.success) { + toast.error( + loginAttempt.result.data?.message ?? + "eCampus 로그인에 실패했습니다. ID와 비밀번호를 확인해주세요.", + ); + return; + } + + // 검증에 성공한 계정만 브라우저에 저장한다. await saveECampusCredentials(savedId, savedPassword); + if (!isECampusAccountCurrent(loginAttempt.requestGeneration)) { + return; + } setHasCredentials(true); sendSettingsCredentialsSaved(); - toast.success("인증 정보가 저장되었습니다."); - // 2. 로그인 검증 (백그라운드) - const loginResult = await eCampusLoginAPI(savedId, savedPassword); + await clearECampusTodoCount().catch((countError) => { + errorLog("[Settings] Failed to clear eCampus todo count:", countError); + }); + if (!isECampusAccountCurrent(loginAttempt.requestGeneration)) { + return; + } - // 2-1. 검증 결과 별도 toast - if (loginResult.success) { - toast.success("eCampus 로그인 성공"); - } else { - toast.error("eCampus 로그인 실패"); + notifyECampusTodosChange("clear"); + await refreshTodoCount(loginAttempt.requestGeneration); + if (!isECampusAccountCurrent(loginAttempt.requestGeneration)) { + return; } + + notifyECampusTodosChange("refresh"); + toast.success("인증 정보를 저장하고 eCampus 로그인을 확인했습니다."); } catch (error) { errorLog("[Settings] Save credentials error:", error); toast.error("인증 정보 저장에 실패했습니다."); + } finally { + setIsSaving(false); } }; @@ -113,8 +153,15 @@ const ECampusCredential = () => { const deleteCredentials = async () => { if (!confirm("저장된 인증 정보를 삭제하시겠습니까?")) return; + setIsSaving(true); + try { await clearECampusCredentials(); + invalidateECampusTodosCache(); + await clearECampusTodoCount().catch((countError) => { + errorLog("[Settings] Failed to clear eCampus todo count:", countError); + }); + notifyECampusTodosChange("clear"); setSavedId(""); setSavedPassword(""); setHasCredentials(false); @@ -123,6 +170,8 @@ const ECampusCredential = () => { } catch (error) { errorLog("[Settings] Delete credentials error:", error); toast.error("인증 정보 삭제에 실패했습니다."); + } finally { + setIsSaving(false); } }; @@ -141,6 +190,7 @@ const ECampusCredential = () => { value={savedId} onChange={(e) => setSavedId(e.target.value)} placeholder="아이디 입력" + disabled={isSaving} /> @@ -155,10 +205,12 @@ const ECampusCredential = () => { value={savedPassword} onChange={(e) => setSavedPassword(e.target.value)} placeholder="비밀번호 입력" + disabled={isSaving} /> - @@ -242,9 +294,9 @@ const GoogleOAuthSection = () => { setUserProfile(profile); // Load verified email if exists - const storage = await chrome.storage.local.get(['kuMail']); - if (typeof storage.kuMail === 'string') { - setVerifiedEmail(storage.kuMail); + const kuMail = await getStorage('kuMail'); + if (kuMail) { + setVerifiedEmail(kuMail); } } }; @@ -302,9 +354,9 @@ const GoogleOAuthSection = () => { setUserProfile(result.response.profile); // Load verified email - const storage = await chrome.storage.local.get(['kuMail']); - if (typeof storage.kuMail === 'string') { - setVerifiedEmail(storage.kuMail); + const kuMail = await getStorage('kuMail'); + if (kuMail) { + setVerifiedEmail(kuMail); } toast.success("회원가입 완료!", { @@ -454,10 +506,18 @@ const GoogleOAuthSection = () => { const TemplateEditorSection = () => { const handleOpenEditor = () => { - // 새 탭에서 템플릿 에디터 열기 - chrome.tabs.create({ - url: chrome.runtime.getURL('index.html#/editor') - }); + sendButtonClick("open_template_editor", "settings_dialog"); + + const chromeApi = getChromeApi(); + const editorUrl = chromeApi?.runtime?.getURL + ? chromeApi.runtime.getURL('index.html#/editor') + : `${window.location.origin}/#/editor`; + + if (chromeApi?.tabs?.create) { + chromeApi.tabs.create({ url: editorUrl }); + } else { + window.open(editorUrl, "_blank"); + } toast.success("템플릿 에디터를 새 탭에서 열었습니다."); }; @@ -465,10 +525,16 @@ const TemplateEditorSection = () => { const handleOpenTemplateList = () => { sendButtonClick("open_template_list", "settings_dialog"); - // 새 탭에서 템플릿 목록 열기 - chrome.tabs.create({ - url: chrome.runtime.getURL('index.html#/templates') - }); + const chromeApi = getChromeApi(); + const templateListUrl = chromeApi?.runtime?.getURL + ? chromeApi.runtime.getURL('index.html#/templates') + : `${window.location.origin}/#/templates`; + + if (chromeApi?.tabs?.create) { + chromeApi.tabs.create({ url: templateListUrl }); + } else { + window.open(templateListUrl, "_blank"); + } toast.success("템플릿 목록을 새 탭에서 열었습니다."); }; @@ -505,10 +571,128 @@ const TemplateEditorSection = () => { ); }; +const RealtimeTimer = () => { + const [enabled, setEnabled] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [previewDeadline] = useState(() => { + const deadline = new Date(); + deadline.setDate(deadline.getDate() + 1); + const pad = (value: number) => String(value).padStart(2, "0"); + + return { + date: `${deadline.getFullYear()}.${pad(deadline.getMonth() + 1)}.${pad(deadline.getDate())}`, + time: `${pad(deadline.getHours())}:${pad(deadline.getMinutes())}`, + }; + }); + const previewDDay = calculateDDay( + previewDeadline.date, + previewDeadline.time, + ); + + // 설정 페이지 열릴 때 저장된 설정 불러오기 + useEffect(() => { + let isMounted = true; + + getStorage("realtimeTimerEnabled") + .then((saved) => { + if (isMounted) { + setEnabled(saved ?? true); + } + }) + .catch((error) => { + errorLog("[Settings] Load timer setting error:", error); + }); + + return () => { + isMounted = false; + }; + }, []); + + const handleToggle = async () => { + if (isSaving) return; + + const newValue = !enabled; + setIsSaving(true); + + try { + await setStorage({ realtimeTimerEnabled: newValue }); + setEnabled(newValue); + sendSettingChange("realtime_timer", newValue ? "enabled" : "disabled"); + toast.success( + newValue + ? "실시간 타이머가 활성화되었습니다." + : "실시간 타이머가 비활성화되었습니다." + ); + } catch (error) { + errorLog("[Settings] Save timer setting error:", error); + toast.error("설정 저장에 실패했습니다."); + } finally { + setIsSaving(false); + } + }; + + return ( + <> +
+

+ + 실시간 TODO 타이머 +

+ +
+
+
+

타이머 표시

+

+ 24시간 이하 남은 Todo에 실시간 카운트다운 표시 +

+
+ +
+ +
+

+ 타이머 미리보기 +

+
+ 마감 임박 Todo + +
+
+
+
+ + ); +}; + const SettingsDialog = ({ open, onOpenChange }: SettingsDialogProps) => { return ( - + 설정 설정 @@ -529,6 +713,9 @@ const SettingsDialog = ({ open, onOpenChange }: SettingsDialogProps) => { +
+ +
@@ -541,5 +728,6 @@ const SettingsDialog = ({ open, onOpenChange }: SettingsDialogProps) => { SettingsDialog.GoogleOAuth = GoogleOAuthSection; SettingsDialog.ECampusCredential = ECampusCredential; SettingsDialog.TemplateEditor = TemplateEditorSection; +SettingsDialog.RealtimeTimer = RealtimeTimer; export default SettingsDialog; diff --git a/src/components/Tabs/TodoList/LoginDialog.tsx b/src/components/Tabs/TodoList/LoginDialog.tsx index 7589805..7f87492 100644 --- a/src/components/Tabs/TodoList/LoginDialog.tsx +++ b/src/components/Tabs/TodoList/LoginDialog.tsx @@ -1,42 +1,50 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; + import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, + DialogDescription, + DialogFooter, DialogHeader, DialogTitle, - DialogFooter, - DialogDescription, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { eCampusLoginAPI, ECampusLoginResponse } from "@/apis"; -import { saveECampusCredentials } from "@/utils/credentials"; -import { errorLog } from '@/utils/logger'; +import { + clearECampusCredentials, + saveECampusCredentials, +} from "@/utils/credentials"; +import { + isECampusAccountCurrent, + loginECampusAccount, + notifyECampusTodosChange, +} from "@/utils/ecampus/todos"; +import { errorLog } from "@/utils/logger"; +import { clearECampusTodoCount } from "@/utils/todo/count"; interface LoginDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - onLoginSuccess: () => Promise; - isLoading: boolean; - setIsLoading: (loading: boolean) => void; - error: string; - setError: (error: string) => void; + onLoginSuccess: (expectedGeneration: number) => Promise; } const LoginDialog = ({ isOpen, onOpenChange, onLoginSuccess, - isLoading, - setIsLoading, - error, - setError, }: LoginDialogProps) => { const [userId, setUserId] = useState(""); const [userPw, setUserPw] = useState(""); - const [rememberLogin, setRememberLogin] = useState(false); + const [rememberLogin, setRememberLogin] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!isOpen) { + setError(""); + } + }, [isOpen]); - // 사용자 입력으로 로그인 처리 const handleLogin = async () => { if (!userId || !userPw) { setError("ID와 비밀번호를 모두 입력해주세요."); @@ -44,41 +52,65 @@ const LoginDialog = ({ } setError(""); - setIsLoading(true); + setIsSubmitting(true); try { - // 로그인 시도 - const loginResult: ECampusLoginResponse = await eCampusLoginAPI( + const loginAttempt = await loginECampusAccount( userId, - userPw + userPw, ); - if (loginResult.success) { - // 인증 정보 저장 (rememberLogin이 true일 때만) - if (rememberLogin) { - try { - await saveECampusCredentials(userId, userPw); - } catch (error) { - errorLog("Failed to save credentials:", error); - } - } + if (loginAttempt.superseded) { + setError("다른 eCampus 계정 변경으로 로그인 요청이 취소되었습니다."); + return; + } - // 로그인 모달 닫기 - onOpenChange(false); + const loginResult = loginAttempt.result; - // Todo 목록 다시 로드 - await onLoginSuccess(); + if (!loginResult.success) { + setError( + loginResult.data?.message ?? + "로그인에 실패했습니다. 인증 정보를 확인해주세요.", + ); + return; + } + + if (rememberLogin) { + try { + await saveECampusCredentials(userId, userPw); + } catch (saveError) { + errorLog("Failed to save credentials:", saveError); + } } else { - const errorMsg = - loginResult.data?.message || - "로그인에 실패했습니다. 인증 정보를 확인해주세요."; - setError(errorMsg); + await clearECampusCredentials(); + } + + if (!isECampusAccountCurrent(loginAttempt.requestGeneration)) { + setError("다른 eCampus 계정 변경으로 로그인 요청이 취소되었습니다."); + return; } - } catch (error) { - errorLog("Login error:", error); + + await clearECampusTodoCount().catch((countError) => { + errorLog("[LoginDialog] Failed to clear eCampus todo count:", countError); + }); + if (!isECampusAccountCurrent(loginAttempt.requestGeneration)) { + setError("다른 eCampus 계정 변경으로 로그인 요청이 취소되었습니다."); + return; + } + + notifyECampusTodosChange("clear"); + const loadError = await onLoginSuccess(loginAttempt.requestGeneration); + if (loadError) { + setError(loadError); + return; + } + + onOpenChange(false); + } catch (loginError) { + errorLog("Login error:", loginError); setError("오류가 발생했습니다. 다시 시도해주세요."); } finally { - setIsLoading(false); + setIsSubmitting(false); } }; @@ -100,9 +132,12 @@ const LoginDialog = ({ setUserId(e.target.value)} + onChange={(event) => setUserId(event.target.value)} placeholder="아이디 입력" - onKeyDown={(e) => e.key === "Enter" && handleLogin()} + disabled={isSubmitting} + onKeyDown={(event) => + event.key === "Enter" && void handleLogin() + } /> @@ -114,9 +149,12 @@ const LoginDialog = ({ id="userPw" type="password" value={userPw} - onChange={(e) => setUserPw(e.target.value)} + onChange={(event) => setUserPw(event.target.value)} placeholder="비밀번호 입력" - onKeyDown={(e) => e.key === "Enter" && handleLogin()} + disabled={isSubmitting} + onKeyDown={(event) => + event.key === "Enter" && void handleLogin() + } /> @@ -126,21 +164,22 @@ const LoginDialog = ({ type="checkbox" className="h-4 w-4 mr-2 text-primary border-gray-300 rounded focus:ring-primary" checked={rememberLogin} - onChange={(e) => setRememberLogin(e.target.checked)} + onChange={(event) => setRememberLogin(event.target.checked)} + disabled={isSubmitting} /> - {error && ( + {error ? (

{error}

- )} + ) : null} -
diff --git a/src/components/Tabs/TodoList/TodoAddDialog.tsx b/src/components/Tabs/TodoList/TodoAddDialog.tsx index c33b6fb..d6a9a11 100644 --- a/src/components/Tabs/TodoList/TodoAddDialog.tsx +++ b/src/components/Tabs/TodoList/TodoAddDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -21,12 +21,38 @@ interface TodoAddDialogProps { } const TodoAddDialog = ({ open, onOpenChange, onSuccess }: TodoAddDialogProps) => { + // 로컬 날짜와 시간으로 초기화 + const getLocalDate = () => { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + const getLocalTime = () => { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + return `${hours}:${minutes}`; + }; + const [title, setTitle] = useState(""); const [subject, setSubject] = useState(""); - const [dueDate, setDueDate] = useState(new Date().toISOString().split('T')[0]); - const [dueTime, setDueTime] = useState(new Date().toTimeString().slice(0, 5)); + const [dueDate, setDueDate] = useState(getLocalDate()); + const [dueTime, setDueTime] = useState(getLocalTime()); const [isSubmitting, setIsSubmitting] = useState(false); + // 모달이 열릴 때마다 현재 날짜와 시간으로 초기화 + useEffect(() => { + if (open) { + setDueDate(getLocalDate()); + setDueTime(getLocalTime()); + setTitle(""); + setSubject(""); + } + }, [open]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -60,13 +86,7 @@ const TodoAddDialog = ({ open, onOpenChange, onSuccess }: TodoAddDialogProps) => sendTodoItemCreate("dialog", Boolean(dueDate)); toast.success("할 일이 추가되었습니다."); - // 폼 초기화 - setTitle(""); - setSubject(""); - setDueDate(new Date().toISOString().split('T')[0]); - setDueTime(new Date().toTimeString().slice(0, 5)); - - // 다이얼로그 닫기 + // 다이얼로그 닫기 (폼 초기화는 useEffect에서 처리) onOpenChange(false); // 부모 컴포넌트에 성공 알림 diff --git a/src/components/Tabs/TodoList/TodoControlBar.tsx b/src/components/Tabs/TodoList/TodoControlBar.tsx new file mode 100644 index 0000000..eee0207 --- /dev/null +++ b/src/components/Tabs/TodoList/TodoControlBar.tsx @@ -0,0 +1,67 @@ +import { Button } from "@/components/ui/button"; +import { ArrowUpDown, ListFilter } from "lucide-react"; +import { TodoItem } from "@/types/todo"; +import type { FilterMode, SortMethod } from "@/hooks/useTodoSettings"; +import TodoAddButton from "./TodoAddButton"; +import TodoExportButton from "./TodoExportButton"; + +interface TodoControlBarProps { + sortMethod: SortMethod; + filterMode: FilterMode; + todoItems: TodoItem[]; + onSortMethodChange: () => void; + onFilterModeChange: () => void; + onTodoAdded: () => void; +} + +/** + * Todo 목록 상단 제어 바 + * 추가, 필터, 정렬, 복사 버튼을 포함 + */ +const TodoControlBar = ({ + sortMethod, + filterMode, + todoItems, + onSortMethodChange, + onFilterModeChange, + onTodoAdded, +}: TodoControlBarProps) => { + return ( +
+ +
+ + + +
+
+ ); +}; + +export default TodoControlBar; diff --git a/src/components/Tabs/TodoList/TodoCountBadge.tsx b/src/components/Tabs/TodoList/TodoCountBadge.tsx index 376b13f..6cce240 100644 --- a/src/components/Tabs/TodoList/TodoCountBadge.tsx +++ b/src/components/Tabs/TodoList/TodoCountBadge.tsx @@ -1,42 +1,60 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; + import { Badge } from "@/components/ui/badge"; +import { addStorageChangeListener } from "@/utils/chrome"; +import { + formatTodoBadgeCount, + TODO_BADGE_BACKGROUND_COLOR, + TODO_BADGE_TEXT_COLOR, +} from "@/utils/todo/badge"; +import { refreshTodoCount } from "@/utils/todo/count"; const TodoCountBadge = () => { - const [todoCount, setTodoCount] = useState(0); + const [todoCount, setTodoCount] = useState(0); useEffect(() => { - // Initial load of count from storage - chrome?.storage?.local?.get("todoCount", (data) => { - const count = typeof data.todoCount === "number" ? data.todoCount : 0; - setTodoCount(count); - }); + let isMounted = true; - // Listen for changes to todoCount in storage - const handleStorageChange = ( - changes: Record, - namespace: string - ) => { - if (namespace === "local" && changes.todoCount) { - const count = - typeof changes.todoCount.newValue === "number" - ? changes.todoCount.newValue - : 0; + const updateTodoCount = async () => { + const count = await refreshTodoCount(); + if (isMounted) { setTodoCount(count); } }; - chrome?.storage?.onChanged?.addListener(handleStorageChange); + void updateTodoCount(); + + const removeListener = addStorageChangeListener((changes, namespace) => { + if (namespace !== "local" || !changes.todoCount) { + return; + } + + const nextCount = changes.todoCount.newValue; + setTodoCount(typeof nextCount === "number" ? nextCount : 0); + }); return () => { - chrome?.storage?.onChanged?.removeListener(handleStorageChange); + isMounted = false; + removeListener(); }; }, []); - if (todoCount === 0) return null; + const badgeText = formatTodoBadgeCount(todoCount); + + if (!badgeText) { + return null; + } return ( - - {todoCount} + + {badgeText} ); }; diff --git a/src/components/Tabs/TodoList/TodoCountdown.tsx b/src/components/Tabs/TodoList/TodoCountdown.tsx new file mode 100644 index 0000000..83482e8 --- /dev/null +++ b/src/components/Tabs/TodoList/TodoCountdown.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from 'react'; +import { calculateTimeLeft, formatTimeLeft, isUrgent } from '@/utils/todo/timer'; +import { calculateDDay } from '@/utils/todo/dateFormat'; +import { subscribeSecondTick } from '@/utils/todo/secondTicker'; + +interface TodoCountdownProps { + dueDate: string; // "YYYY.MM.DD" + dueTime: string; // "HH:mm" + onExpired?: (dDay: string) => void; // 타이머 만료 시 콜백 +} + +/** + * 실시간 카운트다운 타이머 컴포넌트 + * 1초마다 업데이트되며, 12시간 이하 남으면 빨간색 표시 + * 타이머가 만료되면 D-Day 형식으로 표시 + */ +const TodoCountdown = ({ dueDate, dueTime, onExpired }: TodoCountdownProps) => { + const [timeDisplay, setTimeDisplay] = useState(''); + const [urgent, setUrgent] = useState(false); + const [isExpired, setIsExpired] = useState(false); + + useEffect(() => { + const updateTimer = (): boolean => { + const timeLeft = calculateTimeLeft(dueDate, dueTime); + + if (!timeLeft) { + const dDay = calculateDDay(dueDate, dueTime); + setTimeDisplay(dDay); + setUrgent(false); + setIsExpired(true); + + if (onExpired) { + onExpired(dDay); + } + return true; + } + + setTimeDisplay(formatTimeLeft(timeLeft)); + setUrgent(isUrgent(timeLeft)); + setIsExpired(false); + return false; + }; + + const isExpiredNow = updateTimer(); + if (isExpiredNow) { + return; + } + + let unsubscribe: (() => void) | null = null; + + unsubscribe = subscribeSecondTick(() => { + const shouldStop = updateTimer(); + if (shouldStop && unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + }); + + return () => { + if (unsubscribe) { + unsubscribe(); + } + }; + }, [dueDate, dueTime, onExpired]); + + const badgeClassName = isExpired + ? timeDisplay === '마감' || timeDisplay.startsWith('D+') + ? 'px-2 py-1 bg-gray-900/10 text-gray-900 rounded-full text-xs font-normal' + : 'px-2 py-1 bg-main/10 text-main rounded-full text-xs font-normal' + : `px-2 py-1 bg-main/10 rounded-full text-xs font-mono ${ + urgent ? 'text-red-500 font-semibold' : 'text-main' + }`; + + return ( + + {timeDisplay} + + ); +}; + +export default TodoCountdown; diff --git a/src/components/Tabs/TodoList/TodoDeadlineBadge.tsx b/src/components/Tabs/TodoList/TodoDeadlineBadge.tsx new file mode 100644 index 0000000..ca379b3 --- /dev/null +++ b/src/components/Tabs/TodoList/TodoDeadlineBadge.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + calculateDDay, + parseTodoDateTime, +} from "@/utils/todo/dateFormat"; +import { shouldShowTimer } from "@/utils/todo/timer"; + +import TodoCountdown from "./TodoCountdown"; + +interface TodoDeadlineBadgeProps { + dDay: string; + dueDate?: string; + dueTime?: string; + timerEnabled: boolean; +} + +const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; +const MAX_TIMEOUT_MILLISECONDS = 2_147_000_000; + +const getDdayBadgeClassName = (dDay: string) => { + if (dDay === "마감" || dDay.startsWith("D+")) { + return "px-2 py-1 bg-gray-900/10 text-gray-900 rounded-full text-xs"; + } + + return "px-2 py-1 bg-main/10 text-main rounded-full text-xs"; +}; + +const getNextMidnightTimestamp = () => { + const nextMidnight = new Date(); + nextMidnight.setDate(nextMidnight.getDate() + 1); + nextMidnight.setHours(0, 0, 0, 0); + return nextMidnight.getTime(); +}; + +/** + * D-Day와 실시간 카운트다운 전환을 한 곳에서 관리한다. + * 24시간 진입과 자정 경계에서만 다시 렌더링하고, 초 ticker는 countdown 중에만 구독한다. + */ +const TodoDeadlineBadge = ({ + dDay, + dueDate, + dueTime, + timerEnabled, +}: TodoDeadlineBadgeProps) => { + const [boundaryRevision, setBoundaryRevision] = useState(0); + const deadline = + dueDate && dueTime ? parseTodoDateTime(dueDate, dueTime) : null; + const deadlineTimestamp = deadline?.getTime() ?? null; + const calculatedDDay = + deadline && dueDate && dueTime + ? calculateDDay(dueDate, dueTime) + : dDay; + const showCountdown = + timerEnabled && + dueDate !== undefined && + dueTime !== undefined && + shouldShowTimer(dueDate, dueTime); + + const refreshAtBoundary = useCallback(() => { + setBoundaryRevision((revision) => revision + 1); + }, []); + + useEffect(() => { + if (deadlineTimestamp === null || showCountdown) return; + + const now = Date.now(); + const boundaryTimestamps = [getNextMidnightTimestamp()]; + const countdownStart = deadlineTimestamp - DAY_IN_MILLISECONDS; + + if (timerEnabled && countdownStart > now) { + boundaryTimestamps.push(countdownStart); + } + + const delay = Math.min( + Math.max(100, Math.min(...boundaryTimestamps) - now + 50), + MAX_TIMEOUT_MILLISECONDS, + ); + const timeoutId = window.setTimeout(refreshAtBoundary, delay); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [ + boundaryRevision, + deadlineTimestamp, + refreshAtBoundary, + showCountdown, + timerEnabled, + ]); + + if (showCountdown && dueDate && dueTime) { + return ( + + ); + } + + return ( + + {calculatedDDay} + + ); +}; + +export default TodoDeadlineBadge; diff --git a/src/components/Tabs/TodoList/TodoItem.tsx b/src/components/Tabs/TodoList/TodoItem.tsx index d1be57e..f5483f5 100644 --- a/src/components/Tabs/TodoList/TodoItem.tsx +++ b/src/components/Tabs/TodoList/TodoItem.tsx @@ -1,17 +1,21 @@ import { TodoItem as TodoItemType } from "@/types/todo"; import { Trash2 } from "lucide-react"; -import { formatTodoDateTime } from "@/utils/todo/dateFormat"; +import { formatTodoDateTime, parseECampusToTimerFormat } from "@/utils/todo/dateFormat"; +import TodoDeadlineBadge from "./TodoDeadlineBadge"; interface TodoItemProps { todo: TodoItemType; + timerEnabled?: boolean; onToggle?: (id: string) => void; onDelete?: (id: string) => void; onClick?: () => void; } -const TodoItem = ({ todo, onToggle, onDelete, onClick }: TodoItemProps) => { +const TodoItem = ({ todo, timerEnabled = false, onToggle, onDelete, onClick }: TodoItemProps) => { if (todo.type === 'ecampus') { - // 이캠퍼스 Todo - 진초록색 테두리 + // eCampus Todo - 메인 색상 테두리 + const parsed = parseECampusToTimerFormat(todo.dueDate); + return (
{ >

{todo.title}

- - {todo.dDay} - +
+ +

{todo.subject}

{todo.dueDate}

); } else { - // 사용자 정의 Todo - 검은색 테두리 (eCampus와 동일한 레이아웃) + // 사용자 정의 Todo - 검정색 테두리로 구분 const formattedDateTime = formatTodoDateTime(todo.dueDate, todo.dueTime); return ( -
+
{/* 클릭 가능한 컨텐츠 영역 - 완료 상태 토글 */}
onToggle?.(todo.id)} @@ -43,9 +52,14 @@ const TodoItem = ({ todo, onToggle, onDelete, onClick }: TodoItemProps) => {

{todo.title}

- - {todo.dDay} - +
+ +
{todo.subject && (

@@ -57,17 +71,35 @@ const TodoItem = ({ todo, onToggle, onDelete, onClick }: TodoItemProps) => {

- {/* 우하단 삭제 버튼 */} - + {/* 우하단 체크박스와 삭제 버튼 */} +
+ + +
); } diff --git a/src/components/Tabs/TodoList/TodoList.tsx b/src/components/Tabs/TodoList/TodoList.tsx index cd313c5..f7a3d15 100644 --- a/src/components/Tabs/TodoList/TodoList.tsx +++ b/src/components/Tabs/TodoList/TodoList.tsx @@ -1,258 +1,32 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from "react"; -import { - eCampusTodoListAPI, - eCampusGoLectureAPI, - eCampusLoginAPI, - ECampusTodoResponse, -} from "@/apis"; -import { TodoItem as TodoItemType, ECampusTodoItem } from "@/types/todo"; -import { getStorage, setStorage } from "@/utils/chrome"; -import { loadECampusCredentials } from "@/utils/credentials"; -import { - getCustomTodos, - deleteCustomTodo, - toggleCustomTodo, -} from "@/utils/todo/customTodo"; -import TodoItem from "./TodoItem"; -import TodoAddButton from "./TodoAddButton"; -import LoginDialog from "./LoginDialog"; -import TodoExportButton from "./TodoExportButton"; -import { Button } from "@/components/ui/button"; -import { ArrowUpDown } from "lucide-react"; -import { toast } from "sonner"; -import KUGoodjob from "@/assets/KU_goodjob.png"; -import { errorLog } from '@/utils/logger'; -import { sendTodoView, sendTodoItemComplete, sendTodoItemDelete } from '@/utils/analytics'; - -type SortMethod = 'dday-asc' | 'dday-desc'; - -const SORT_METHOD_KEY = "todoSortMethod"; - -/** - * D-Day 문자열을 숫자로 변환 - * "D-3" → -3, "D-Day" → 0, "D+2" → 2 - */ -function parseDDay(dDay: string): number { - if (dDay === "D-Day") return 0; +import { LogIn } from "lucide-react"; - const match = dDay.match(/D([+-])(\d+)/); - if (!match) return 0; - - const sign = match[1] === '+' ? 1 : -1; - const value = parseInt(match[2], 10); +import KUGoodjob from "@/assets/KU_goodjob.png"; +import { Button } from "@/components/ui/button"; +import { useTodoListData } from "@/hooks/useTodoListData"; - return sign * value; -} +import LoginDialog from "./LoginDialog"; +import TodoControlBar from "./TodoControlBar"; +import TodoItem from "./TodoItem"; const TodoList = () => { - const viewOpenSentRef = useRef(false); - const [isLoading, setIsLoading] = useState(true); - const [ecampusTodos, setECampusTodos] = useState([]); - const [customTodos, setCustomTodos] = useState([]); - const [showLoginModal, setShowLoginModal] = useState(false); - const [error, setError] = useState(""); - const [sortMethod, setSortMethod] = useState('dday-desc'); - - // 정렬 방식에 따라 전체 Todo 목록 정렬 - const allTodos: TodoItemType[] = useMemo(() => { - const combined = [...ecampusTodos, ...customTodos]; - - return combined.sort((a, b) => { - if (sortMethod === 'dday-asc') { - // D-Day 오름차순: 가장 적게 남은 것부터 - return parseDDay(a.dDay) - parseDDay(b.dDay); - } else { - // D-Day 내림차순: 가장 많이 남은 것부터 - return parseDDay(b.dDay) - parseDDay(a.dDay); - } - }); - }, [ecampusTodos, customTodos, sortMethod]); - - // Save todo count to Chrome storage - const saveTodoCount = useCallback(async (count: number) => { - await setStorage({ todoCount: count }); - }, []); - - // 사용자 정의 Todo 불러오기 - const loadCustomTodos = useCallback(async () => { - try { - const todos = await getCustomTodos(); - setCustomTodos(todos); - } catch (error) { - errorLog("Error loading custom todos:", error); - } - }, []); - - // 이캠퍼스 Todo 목록을 가져오는 함수 - const fetchTodoList = useCallback(async (): Promise => { - try { - const result: ECampusTodoResponse = await eCampusTodoListAPI(); - - if (result.success && result.data?.todoList) { - setECampusTodos(result.data.todoList); - // Save todo count to storage (이캠퍼스 + 사용자 정의) - saveTodoCount(result.data.todoList.length + customTodos.length); - return true; - } - - if (result.needLogin) { - setError("로그인이 필요합니다."); - return false; // 로그인 필요 - } - - setError("Todo 목록을 불러오는데 실패했습니다."); - return false; - } catch (error) { - errorLog("Error fetching todo list:", error); - setError("Todo 목록을 불러오는 중 오류가 발생했습니다."); - return false; - } - }, [saveTodoCount, customTodos.length]); - - // 저장된 인증 정보로 로그인 시도 - const tryLoginWithSavedCredentials = useCallback(async (): Promise => { - try { - const credentials = await loadECampusCredentials(); - - if (!credentials) { - return false; - } - - const loginResult = await eCampusLoginAPI( - credentials.id, - credentials.password - ); - - if (loginResult.success) { - const todoFetched = await fetchTodoList(); - return todoFetched; - } - - return false; - } catch (error) { - errorLog("Error with saved credentials:", error); - return false; - } - }, [fetchTodoList]); - - // 전체 Todo 로드 프로세스 - const loadTodoList = useCallback(async () => { - setIsLoading(true); - setError(""); - - try { - // 1. 사용자 정의 Todo 먼저 불러오기 (로그인 불필요) - await loadCustomTodos(); - - // 2. 이캠퍼스 Todo 목록 요청 시도 - const todoFetched = await fetchTodoList(); - - // 3. 성공하면 완료 - if (todoFetched) { - setIsLoading(false); - return; - } - - // 4. 실패하면 저장된 인증 정보로 로그인 시도 - const loggedInWithSaved = await tryLoginWithSavedCredentials(); - - // 5. 저장된 인증으로도 실패하면 로그인 모달 표시 - if (!loggedInWithSaved) { - setShowLoginModal(true); - } - } catch (error) { - errorLog("Error loading todo list:", error); - setError("오류가 발생했습니다. 다시 시도해주세요."); - setShowLoginModal(true); - } finally { - setIsLoading(false); - } - }, [fetchTodoList, tryLoginWithSavedCredentials, loadCustomTodos]); - - // 초기 로드 시 Todo 목록 가져오기 - useEffect(() => { - loadTodoList(); - }, [loadTodoList]); - - // 탭 진입 이벤트 — 로딩 완료 후 1회만 전송 - useEffect(() => { - if (!isLoading && !viewOpenSentRef.current) { - viewOpenSentRef.current = true; - sendTodoView(ecampusTodos.length + customTodos.length); - } - }, [isLoading, ecampusTodos.length, customTodos.length]); - - // 정렬 방식 불러오기 - useEffect(() => { - const loadSortMethod = async () => { - const savedMethod = await getStorage(SORT_METHOD_KEY); - if (savedMethod) { - setSortMethod(savedMethod); - } - }; - loadSortMethod(); - }, []); - - // 정렬 방식 변경 및 저장 - const handleSortMethodChange = async () => { - const nextMethod: SortMethod = - sortMethod === 'dday-asc' ? 'dday-desc' : 'dday-asc'; - - setSortMethod(nextMethod); - await setStorage({ [SORT_METHOD_KEY]: nextMethod }); - }; - - // 사용자 정의 Todo 완료 상태 토글 - const handleToggleTodo = async (id: string) => { - try { - await toggleCustomTodo(id); - await loadCustomTodos(); - sendTodoItemComplete("custom"); - } catch (error) { - errorLog("Failed to toggle todo:", error); - toast.error("상태 변경에 실패했습니다."); - } - }; - - // 사용자 정의 Todo 삭제 - const handleDeleteTodo = async (id: string) => { - try { - await deleteCustomTodo(id); - await loadCustomTodos(); - sendTodoItemDelete("custom"); - toast.success("할 일이 삭제되었습니다."); - } catch (error) { - errorLog("Failed to delete todo:", error); - toast.error("삭제에 실패했습니다."); - } - }; - - // Todo 추가 성공 시 목록 새로고침 - const handleTodoAdded = () => { - loadCustomTodos(); - }; - - // Todo 항목 클릭 처리 - const handleTodoItemClick = async ( - seq: string, - kj: string, - gubun: string - ) => { - try { - setIsLoading(true); - const result = await eCampusGoLectureAPI(seq, kj, gubun); - - if (result.success) { - // 성공 시 해당 URL로 이동 (이캠퍼스 페이지 열기) - const lectureUrl = `https://ecampus.konkuk.ac.kr${result.message}`; - window.open(lectureUrl, "_blank"); - } - } catch (error) { - errorLog("Failed to navigate to lecture:", error); - } finally { - setIsLoading(false); - } - }; + const { + allTodos, + ecampusError, + ecampusNeedsLogin, + filterMode, + handleDeleteTodo, + handleOpenECampusLogin, + handleTodoAdded, + handleTodoItemClick, + handleToggleTodo, + isECampusLoading, + isLoading, + loginDialogProps, + sortMethod, + timerEnabled, + toggleFilterMode, + toggleSortMethod, + } = useTodoListData(); return (
{ className="p-4 border-t overflow-y-auto h-[500px]" style={{ scrollbarWidth: "thin" }} > + + {isLoading ? (
-
+
) : (
- {allTodos.length > 0 ? ( - <> -
- -
- - -
+ + + {ecampusNeedsLogin ? ( +
+
+

+ eCampus Todo를 보려면 로그인이 필요합니다. +

+

+ custom Todo는 계속 사용할 수 있고, 필요할 때만 eCampus를 + 연결하면 됩니다. +

- {allTodos.map((item) => ( - handleTodoItemClick(item.kj, item.seq, item.gubun) - : undefined - } - /> - ))} - - ) : ( + +
+ ) : null} + + {!ecampusNeedsLogin && ecampusError ? ( +
+

+ {ecampusError} +

+

+ custom Todo는 계속 사용할 수 있습니다. +

+
+ ) : null} + + {isECampusLoading ? ( +
+

+ eCampus Todo를 불러오는 중입니다. +

+
+ ) : null} + + {allTodos.length > 0 ? ( + allTodos.map((item) => ( + + handleTodoItemClick(item.kj, item.seq, item.gubun) + : undefined + } + /> + )) + ) : !isECampusLoading ? (

할 일이 없습니다

-
-
- KU Good Job - - {/* TODO : 둥근 텍스트 - 위쪽 부채꼴 영역에만 // 넣어보려 했으나 생각보다 시간이 많이 걸리는 관계로 다음 기회에.. */} - {/*
- - - - - - - 참 잘 했 다 KU ! - - - -
*/} -
-
+ KU Good job
- )} + ) : null}
)} - - {/* 로그인 모달 컴포넌트 */} -
); }; diff --git a/src/hooks/useECampusAuth.ts b/src/hooks/useECampusAuth.ts new file mode 100644 index 0000000..3ba3142 --- /dev/null +++ b/src/hooks/useECampusAuth.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from "react"; + +import { toast } from "sonner"; + +import { + loadECampusTodos as loadECampusTodosWithLogin, + type LoadECampusTodosOptions, + type LoadECampusTodosResult, +} from "@/utils/ecampus/todos"; + +interface UseECampusAuthOptions extends LoadECampusTodosOptions { + openLoginModal?: boolean; +} + +export function useECampusAuth() { + const [showLoginModal, setShowLoginModal] = useState(false); + + const handleLoginModalOpenChange = useCallback((open: boolean) => { + setShowLoginModal(open); + }, []); + + const openLoginModal = useCallback(() => { + setShowLoginModal(true); + }, []); + + const closeLoginModal = useCallback(() => { + setShowLoginModal(false); + }, []); + + const loadECampusTodos = useCallback( + async ( + options: UseECampusAuthOptions = {}, + ): Promise => { + const { + allowAutoLogin = true, + openLoginModal: shouldOpenLoginModal = true, + expectedGeneration, + } = options; + const result = await loadECampusTodosWithLogin({ + allowAutoLogin, + clearExpiredCredentials: true, + expectedGeneration, + }); + + if (result.loginOutcome === "auto-login-succeeded") { + toast.success("eCampus에 자동 로그인되었습니다."); + } + + if (result.loginOutcome === "credential-expired") { + toast.error( + "저장된 로그인 정보가 만료되었습니다. 다시 로그인해주세요.", + ); + } + + if (!result.success && result.needsLogin && shouldOpenLoginModal) { + setShowLoginModal(true); + } + + return result; + }, + [], + ); + + return { + closeLoginModal, + handleLoginModalOpenChange, + loadECampusTodos, + openLoginModal, + showLoginModal, + }; +} diff --git a/src/hooks/useTodoListData.ts b/src/hooks/useTodoListData.ts new file mode 100644 index 0000000..f7acd62 --- /dev/null +++ b/src/hooks/useTodoListData.ts @@ -0,0 +1,318 @@ +import { + createElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { toast } from "sonner"; + +import { + eCampusGoLectureAPI, + LOCAL_SAMPLE_LECTURE_URL, +} from "@/apis"; +import { useECampusAuth } from "@/hooks/useECampusAuth"; +import { useTodoSettings } from "@/hooks/useTodoSettings"; +import type { CustomTodoItem, ECampusTodoItem, TodoItem } from "@/types/todo"; +import { + sendTodoItemComplete, + sendTodoItemDelete, + sendTodoView, +} from "@/utils/analytics"; +import { subscribeECampusTodosChange } from "@/utils/ecampus/todos"; +import { resolveECampusTodosAfterLoad } from "@/utils/ecampus/todoState"; +import { errorLog } from "@/utils/logger"; +import { + syncTodoCountAfterCustomChange, + syncTodoCountWithECampusTodos, +} from "@/utils/todo/count"; +import { + deleteCustomTodo, + getCustomTodos, + toggleCustomTodo, +} from "@/utils/todo/customTodo"; +import { getTodoDeadline } from "@/utils/todo/dateFormat"; + +type LoginResult = string | null; + +export function useTodoListData() { + const viewOpenSentRef = useRef(false); + const [isLoading, setIsLoading] = useState(true); + const [isECampusLoading, setIsECampusLoading] = useState(false); + const [ecampusTodos, setECampusTodos] = useState([]); + const [customTodos, setCustomTodos] = useState([]); + const [ecampusError, setECampusError] = useState(""); + const [ecampusNeedsLogin, setECampusNeedsLogin] = useState(false); + const { + handleLoginModalOpenChange, + loadECampusTodos: loadECampusTodosWithAuth, + openLoginModal, + showLoginModal, + } = useECampusAuth(); + const { + sortMethod, + filterMode, + timerEnabled, + toggleSortMethod, + toggleFilterMode, + } = useTodoSettings(); + + const allTodos: TodoItem[] = useMemo(() => { + const filteredTodos = + filterMode === "incomplete" + ? [...ecampusTodos, ...customTodos].filter( + (todo) => todo.type === "ecampus" || !todo.completed, + ) + : [...ecampusTodos, ...customTodos]; + + return filteredTodos.sort((a, b) => { + const deadlineA = getTodoDeadline(a); + const deadlineB = getTodoDeadline(b); + + return sortMethod === "dday-asc" + ? deadlineA.getTime() - deadlineB.getTime() + : deadlineB.getTime() - deadlineA.getTime(); + }); + }, [customTodos, ecampusTodos, filterMode, sortMethod]); + + const updateECampusTodos = useCallback((todos: ECampusTodoItem[]) => { + setECampusTodos(todos); + }, []); + + const loadAndStoreCustomTodos = useCallback(async () => { + try { + const todos = await getCustomTodos(); + setCustomTodos(todos); + return todos; + } catch (error) { + errorLog("Error loading custom todos:", error); + return []; + } + }, []); + + const applyECampusResult = useCallback( + async ( + result: Awaited>, + ): Promise => { + if (result.superseded) { + return null; + } + + setECampusTodos((currentTodos) => + resolveECampusTodosAfterLoad(currentTodos, result), + ); + + if (result.success) { + setECampusNeedsLogin(false); + setECampusError(""); + await syncTodoCountWithECampusTodos(result.todos); + return null; + } + + setECampusNeedsLogin(Boolean(result.needsLogin)); + setECampusError(result.needsLogin ? "" : (result.error ?? "")); + if (result.needsLogin) { + await syncTodoCountWithECampusTodos([]); + } else { + await syncTodoCountAfterCustomChange(); + } + return result.error ?? (result.needsLogin ? "로그인이 필요합니다." : null); + }, + [], + ); + + const loadECampusTodoList = useCallback(async () => { + setECampusError(""); + setECampusNeedsLogin(false); + setIsECampusLoading(true); + + try { + const result = await loadECampusTodosWithAuth({ + allowAutoLogin: true, + openLoginModal: false, + }); + await applyECampusResult(result); + } catch (error) { + errorLog("Error loading eCampus todo list:", error); + setECampusNeedsLogin(false); + setECampusError("eCampus 할 일을 불러오는 중 오류가 발생했습니다."); + await syncTodoCountAfterCustomChange(); + } finally { + setIsECampusLoading(false); + } + }, [ + applyECampusResult, + loadECampusTodosWithAuth, + ]); + + const loadTodoList = useCallback(async () => { + setIsLoading(true); + + try { + await loadAndStoreCustomTodos(); + await syncTodoCountAfterCustomChange(); + } finally { + setIsLoading(false); + } + + await loadECampusTodoList(); + }, [loadAndStoreCustomTodos, loadECampusTodoList]); + + useEffect(() => { + void loadTodoList(); + }, [loadTodoList]); + + useEffect(() => { + return subscribeECampusTodosChange((change) => { + if (change === "clear") { + updateECampusTodos([]); + setECampusError(""); + setECampusNeedsLogin(false); + setIsECampusLoading(false); + return; + } + + void loadECampusTodoList(); + }); + }, [loadECampusTodoList, updateECampusTodos]); + + useEffect(() => { + if (!isLoading && !isECampusLoading && !viewOpenSentRef.current) { + viewOpenSentRef.current = true; + void sendTodoView(ecampusTodos.length + customTodos.length); + } + }, [ + customTodos.length, + ecampusTodos.length, + isECampusLoading, + isLoading, + ]); + + const refreshCustomTodos = useCallback(async () => { + await loadAndStoreCustomTodos(); + await syncTodoCountAfterCustomChange(); + }, [loadAndStoreCustomTodos]); + + const handleLoginSuccess = useCallback(async ( + expectedGeneration: number, + ): Promise => { + setIsECampusLoading(true); + + try { + const result = await loadECampusTodosWithAuth({ + allowAutoLogin: false, + openLoginModal: false, + expectedGeneration, + }); + + return await applyECampusResult(result); + } catch (error) { + errorLog("Error loading eCampus todos after login:", error); + setECampusNeedsLogin(false); + setECampusError( + "eCampus 할 일을 다시 불러오는 중 오류가 발생했습니다.", + ); + await syncTodoCountAfterCustomChange(); + return "eCampus 할 일을 다시 불러오는 중 오류가 발생했습니다."; + } finally { + setIsECampusLoading(false); + } + }, [ + applyECampusResult, + loadECampusTodosWithAuth, + ]); + + const handleToggleTodo = useCallback( + async (id: string) => { + try { + await toggleCustomTodo(id); + await refreshCustomTodos(); + void sendTodoItemComplete("custom"); + } catch (error) { + errorLog("Failed to toggle todo:", error); + toast.error("상태 변경에 실패했습니다."); + } + }, + [refreshCustomTodos], + ); + + const handleDeleteTodo = useCallback( + async (id: string) => { + try { + await deleteCustomTodo(id); + await refreshCustomTodos(); + void sendTodoItemDelete("custom"); + const deletionToastId = `todo-deleted-${id}`; + + toast.success("할 일이 삭제되었습니다.", { + id: deletionToastId, + action: createElement("button", { + type: "button", + "aria-label": "삭제 알림 닫기", + className: + "absolute inset-0 cursor-pointer rounded-[inherit] bg-transparent", + onClick: () => toast.dismiss(deletionToastId), + }), + }); + } catch (error) { + errorLog("Failed to delete todo:", error); + toast.error("삭제에 실패했습니다."); + } + }, + [refreshCustomTodos], + ); + + const handleTodoAdded = useCallback(() => { + void refreshCustomTodos(); + }, [refreshCustomTodos]); + + const handleTodoItemClick = useCallback( + async (kj: string, seq: string, gubun: string) => { + try { + const result = await eCampusGoLectureAPI(kj, seq, gubun); + + if (result.success && result.message === LOCAL_SAMPLE_LECTURE_URL) { + toast.info( + "로컬 예시 eCampus 항목입니다. 실제 강의 페이지로는 이동하지 않습니다.", + ); + return; + } + + if (result.success && result.message) { + window.open( + `https://ecampus.konkuk.ac.kr${result.message}`, + "_blank", + ); + } + } catch (error) { + errorLog("Failed to navigate to lecture:", error); + } + }, + [], + ); + + return { + allTodos, + ecampusError, + ecampusNeedsLogin, + filterMode, + handleDeleteTodo, + handleOpenECampusLogin: openLoginModal, + handleTodoAdded, + handleTodoItemClick, + handleToggleTodo, + isECampusLoading, + isLoading, + loginDialogProps: { + isOpen: showLoginModal, + onOpenChange: handleLoginModalOpenChange, + onLoginSuccess: handleLoginSuccess, + }, + sortMethod, + timerEnabled, + toggleFilterMode, + toggleSortMethod, + }; +} diff --git a/src/hooks/useTodoSettings.ts b/src/hooks/useTodoSettings.ts new file mode 100644 index 0000000..fab5ebf --- /dev/null +++ b/src/hooks/useTodoSettings.ts @@ -0,0 +1,100 @@ +/** + * Todo 설정 관리 Hook (정렬, 필터, 타이머) + */ +import { useState, useEffect, useCallback } from 'react'; +import { addStorageChangeListener, getStorage, setStorage } from '@/utils/chrome'; + +type SortMethod = 'dday-asc' | 'dday-desc'; +type FilterMode = 'all' | 'incomplete'; + +const SORT_METHOD_KEY = "todoSortMethod"; +const FILTER_MODE_KEY = "todoFilterMode"; +const TIMER_ENABLED_KEY = "realtimeTimerEnabled"; + +export function useTodoSettings() { + const [sortMethod, setSortMethod] = useState('dday-desc'); + const [filterMode, setFilterMode] = useState('incomplete'); + const [timerEnabled, setTimerEnabled] = useState(true); + + /** + * Load sort method from storage + */ + useEffect(() => { + const loadSortMethod = async () => { + const saved = await getStorage(SORT_METHOD_KEY); + if (saved) { + setSortMethod(saved); + } + }; + loadSortMethod(); + }, []); + + /** + * Load filter mode from storage + */ + useEffect(() => { + const loadFilterMode = async () => { + const saved = await getStorage(FILTER_MODE_KEY); + if (saved) { + setFilterMode(saved); + } + }; + loadFilterMode(); + }, []); + + /** + * Load timer setting and listen for changes + */ + useEffect(() => { + const loadTimerSetting = async () => { + const enabled = await getStorage(TIMER_ENABLED_KEY); + setTimerEnabled(enabled ?? true); + }; + loadTimerSetting(); + + // Listen for storage changes + const handleStorageChange = ( + changes: { [key: string]: chrome.storage.StorageChange }, + areaName: string + ) => { + if (areaName === "local" && changes[TIMER_ENABLED_KEY]) { + const nextValue = changes[TIMER_ENABLED_KEY].newValue; + setTimerEnabled( + typeof nextValue === "boolean" ? nextValue : true, + ); + } + }; + + return addStorageChangeListener(handleStorageChange); + }, []); + + /** + * Toggle sort method + */ + const toggleSortMethod = useCallback(async () => { + const nextMethod: SortMethod = + sortMethod === 'dday-asc' ? 'dday-desc' : 'dday-asc'; + setSortMethod(nextMethod); + await setStorage({ [SORT_METHOD_KEY]: nextMethod }); + }, [sortMethod]); + + /** + * Toggle filter mode + */ + const toggleFilterMode = useCallback(async () => { + const nextMode: FilterMode = + filterMode === 'incomplete' ? 'all' : 'incomplete'; + setFilterMode(nextMode); + await setStorage({ [FILTER_MODE_KEY]: nextMode }); + }, [filterMode]); + + return { + sortMethod, + filterMode, + timerEnabled, + toggleSortMethod, + toggleFilterMode, + }; +} + +export type { SortMethod, FilterMode }; diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 491dcd4..34a19ab 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -41,7 +41,7 @@ */ import { getOrCreateClientId } from "./clientId"; -import { getStorage, setStorage } from "./chrome"; +import { getStorage, isExtensionEnvironment, setStorage } from "./chrome"; import { debugLog, warnLog, errorLog } from "@/utils/logger"; /** GA4 이벤트 파라미터 타입 — string, number, boolean만 허용 */ @@ -124,6 +124,13 @@ async function sendGAEvent( eventName: string, eventParams: Record = {} ): Promise { + if (!isExtensionEnvironment()) { + if (DEBUG_MODE) { + debugLog("[GA] Skipping event outside extension context:", eventName); + } + return; + } + if (!API_SECRET) { warnLog("[GA] API Secret not configured. Event not sent:", eventName); return; @@ -195,6 +202,13 @@ export async function sendExtensionOpen( screenName: string, entryPoint: string ): Promise { + if (!isExtensionEnvironment()) { + if (DEBUG_MODE) { + debugLog("[GA] Skipping lifecycle events outside extension context."); + } + return; + } + if (!API_SECRET) { warnLog("[GA] API Secret not configured. Lifecycle events not sent."); return; @@ -335,6 +349,16 @@ export async function sendButtonClick( }); } +export async function sendSettingChange( + settingName: string, + settingValue: string, +): Promise { + await sendGAEvent("setting_change", { + setting_name: settingName, + setting_value: settingValue, + }); +} + /** * 런타임 오류 이벤트 전송 (레거시) * @param errorCode 에러 식별 코드 (예: "network_error", "auth_required") diff --git a/src/utils/chrome.ts b/src/utils/chrome.ts index b0eb927..b0fc0e0 100644 --- a/src/utils/chrome.ts +++ b/src/utils/chrome.ts @@ -1,22 +1,47 @@ import { errorLog } from '@/utils/logger'; + +export const getChromeApi = (): typeof chrome | undefined => { + return globalThis.chrome; +}; + +export const isExtensionEnvironment = (): boolean => { + return Boolean(getChromeApi()?.runtime?.id); +}; + // activeTab permission export const getCurrentTab = async () => { + const chromeApi = getChromeApi(); + if (!chromeApi?.tabs?.query) { + return null; + } + const queryOptions = { active: true, currentWindow: true }; - const tabs = await chrome.tabs?.query(queryOptions); + const tabs = await chromeApi.tabs.query(queryOptions); if (!tabs) { return null; } const [tab] = tabs; - return tab; + return tab ?? null; }; export const updateTabUrl = (url: string) => { - chrome.tabs.update({ url: url }); + const chromeApi = getChromeApi(); + if (!chromeApi?.tabs?.update) { + window.open(url, '_blank'); + return; + } + + chromeApi.tabs.update({ url }); }; export const executeScript = async (tabId: number, func: () => void) => { + const chromeApi = getChromeApi(); + if (!chromeApi?.scripting?.executeScript) { + throw new Error('chrome.scripting is unavailable in this environment.'); + } + try { - const result = await chrome.scripting.executeScript({ + const result = await chromeApi.scripting.executeScript({ target: { tabId, allFrames: true }, func: func, }); @@ -29,8 +54,13 @@ export const executeScript = async (tabId: number, func: () => void) => { }; export const executeScriptFile = async (tabId: number, files: string[]) => { + const chromeApi = getChromeApi(); + if (!chromeApi?.scripting?.executeScript) { + throw new Error('chrome.scripting is unavailable in this environment.'); + } + try { - const result = await chrome.scripting.executeScript({ + const result = await chromeApi.scripting.executeScript({ target: { tabId, allFrames: true }, files, }); @@ -45,9 +75,15 @@ export const executeScriptFile = async (tabId: number, files: string[]) => { // Chrome Storage API Promise 래퍼 export const getStorage = (key: string): Promise => { return new Promise((resolve, reject) => { - chrome?.storage?.local?.get(key, (data) => { - if (chrome.runtime.lastError) { - reject(chrome.runtime.lastError); + const chromeApi = getChromeApi(); + if (!chromeApi?.storage?.local) { + resolve(undefined); + return; + } + + chromeApi.storage.local.get(key, (data) => { + if (chromeApi.runtime?.lastError) { + reject(chromeApi.runtime.lastError); } else { resolve(data[key] as T | undefined); } @@ -59,9 +95,15 @@ export const setStorage = >( data: T, ): Promise => { return new Promise((resolve, reject) => { - chrome?.storage?.local?.set(data, () => { - if (chrome.runtime.lastError) { - reject(chrome.runtime.lastError); + const chromeApi = getChromeApi(); + if (!chromeApi?.storage?.local) { + resolve(); + return; + } + + chromeApi.storage.local.set(data, () => { + if (chromeApi.runtime?.lastError) { + reject(chromeApi.runtime.lastError); } else { resolve(); } @@ -69,14 +111,37 @@ export const setStorage = >( }); }; -export const removeStorage = (key: string): Promise => { +export const removeStorage = (key: string | string[]): Promise => { return new Promise((resolve, reject) => { - chrome?.storage?.local?.remove(key, () => { - if (chrome.runtime.lastError) { - reject(chrome.runtime.lastError); + const chromeApi = getChromeApi(); + if (!chromeApi?.storage?.local) { + resolve(); + return; + } + + chromeApi.storage.local.remove(key, () => { + if (chromeApi.runtime?.lastError) { + reject(chromeApi.runtime.lastError); } else { resolve(); } }); }); }; + +export const addStorageChangeListener = ( + listener: ( + changes: Record, + areaName: string, + ) => void, +): (() => void) => { + const onChanged = getChromeApi()?.storage?.onChanged; + if (!onChanged?.addListener) { + return () => {}; + } + + onChanged.addListener(listener); + return () => { + onChanged.removeListener(listener); + }; +}; diff --git a/src/utils/ecampus/authQueue.ts b/src/utils/ecampus/authQueue.ts new file mode 100644 index 0000000..abeed83 --- /dev/null +++ b/src/utils/ecampus/authQueue.ts @@ -0,0 +1,43 @@ +export type SerializedAuthAttempt = + | { superseded: true } + | { superseded: false; result: T }; + +export interface SerializedAuthQueue { + run: ( + isCurrent: () => boolean, + authenticate: () => Promise, + ) => Promise>; +} + +/** + * eCampus가 하나의 브라우저 세션을 공유하므로 로그인 요청을 순서대로 실행한다. + * 뒤늦게 완료된 요청은 결과를 소비하지 않아 이전 계정 상태를 저장하거나 표시하지 않는다. + */ +export const createSerializedAuthQueue = (): SerializedAuthQueue => { + let tail: Promise = Promise.resolve(); + + const run = ( + isCurrent: () => boolean, + authenticate: () => Promise, + ): Promise> => { + const execute = async (): Promise> => { + if (!isCurrent()) { + return { superseded: true }; + } + + const result = await authenticate(); + return isCurrent() + ? { superseded: false, result } + : { superseded: true }; + }; + + const attempt = tail.then(execute, execute); + tail = attempt.then( + () => undefined, + () => undefined, + ); + return attempt; + }; + + return { run }; +}; diff --git a/src/utils/ecampus/todoState.ts b/src/utils/ecampus/todoState.ts new file mode 100644 index 0000000..b86e0f2 --- /dev/null +++ b/src/utils/ecampus/todoState.ts @@ -0,0 +1,19 @@ +export interface ECampusTodoLoadState { + success: boolean; + todos: T[]; + needsLogin?: boolean; +} + +/** + * 일시적인 요청 실패에는 마지막 정상 목록을 유지하고, 인증이 해제됐을 때만 비운다. + */ +export const resolveECampusTodosAfterLoad = ( + previousTodos: T[], + result: ECampusTodoLoadState, +): T[] => { + if (result.success) { + return result.todos; + } + + return result.needsLogin ? [] : previousTodos; +}; diff --git a/src/utils/ecampus/todos.ts b/src/utils/ecampus/todos.ts new file mode 100644 index 0000000..052eabf --- /dev/null +++ b/src/utils/ecampus/todos.ts @@ -0,0 +1,320 @@ +import { + eCampusLoginAPI, + eCampusTodoListAPI, + type ECampusLoginResponse, +} from "@/apis"; +import type { ECampusTodoItem } from "@/types/todo"; +import { + clearECampusCredentials, + loadECampusCredentials, +} from "@/utils/credentials"; +import { debugLog, errorLog } from "@/utils/logger"; + +import { + createSerializedAuthQueue, + type SerializedAuthAttempt, +} from "./authQueue"; + +export interface LoadECampusTodosOptions { + allowAutoLogin?: boolean; + clearExpiredCredentials?: boolean; + expectedGeneration?: number; +} + +export interface LoadECampusTodosResult { + success: boolean; + todos: ECampusTodoItem[]; + error?: string; + needsLogin?: boolean; + superseded?: boolean; + loginOutcome?: + | "none" + | "auto-login-succeeded" + | "network-error" + | "credential-expired"; +} + +interface NormalizedLoadECampusTodosOptions { + allowAutoLogin: boolean; + clearExpiredCredentials: boolean; +} + +interface CachedECampusTodosResult { + expiresAt: number; + result: LoadECampusTodosResult; +} + +export type ECampusTodosChange = "clear" | "refresh"; +type ECampusTodosChangeListener = (change: ECampusTodosChange) => void; + +export type ECampusAccountLoginAttempt = + | { superseded: true } + | { + superseded: false; + result: ECampusLoginResponse; + requestGeneration: number; + }; + +const ECAMPUS_TODO_CACHE_TTL_MS = 30_000; +const cachedResults = new Map(); +const inFlightLoads = new Map>(); +const changeListeners = new Set(); +const eCampusAuthQueue = createSerializedAuthQueue(); +let cacheGeneration = 0; + +const normalizeOptions = ( + options: LoadECampusTodosOptions, +): NormalizedLoadECampusTodosOptions => ({ + allowAutoLogin: options.allowAutoLogin ?? true, + clearExpiredCredentials: options.clearExpiredCredentials ?? true, +}); + +const getRequestKey = ({ + allowAutoLogin, + clearExpiredCredentials, +}: NormalizedLoadECampusTodosOptions) => + `${allowAutoLogin}:${clearExpiredCredentials}`; + +const createSupersededResult = (): LoadECampusTodosResult => ({ + success: false, + todos: [], + superseded: true, + loginOutcome: "none", +}); + +const withoutLoginOutcome = ( + result: LoadECampusTodosResult, +): LoadECampusTodosResult => ({ + ...result, + loginOutcome: "none", +}); + +/** + * 로그인 세션 또는 저장된 계정이 바뀌면 이전 계정의 cache와 진행 중 결과를 폐기한다. + */ +export const invalidateECampusTodosCache = (): number => { + cacheGeneration += 1; + cachedResults.clear(); + inFlightLoads.clear(); + return cacheGeneration; +}; + +export const isECampusAccountCurrent = (requestGeneration: number): boolean => + requestGeneration === cacheGeneration; + +/** + * 계정 변경 결과를 현재 열려 있는 Todo 화면에 명시적으로 전달한다. + */ +export const notifyECampusTodosChange = (change: ECampusTodosChange) => { + changeListeners.forEach((listener) => listener(change)); +}; + +export const subscribeECampusTodosChange = ( + listener: ECampusTodosChangeListener, +) => { + changeListeners.add(listener); + return () => { + changeListeners.delete(listener); + }; +}; + +const authenticateECampusAccount = ( + userId: string, + userPassword: string, + requestGeneration: number, +): Promise> => + eCampusAuthQueue.run( + () => requestGeneration === cacheGeneration, + () => eCampusLoginAPI(userId, userPassword), + ); + +/** + * 수동 로그인도 자동 로그인과 같은 queue를 사용해 기존 세션을 덮어쓰지 않게 한다. + */ +export const loginECampusAccount = ( + userId: string, + userPassword: string, +): Promise => { + const requestGeneration = invalidateECampusTodosCache(); + return authenticateECampusAccount( + userId, + userPassword, + requestGeneration, + ).then((attempt) => + attempt.superseded + ? attempt + : { ...attempt, requestGeneration }, + ); +}; + +const fetchECampusTodos = async (): Promise => { + try { + const result = await eCampusTodoListAPI(); + + if (result.success && result.data?.todoList) { + return { success: true, todos: result.data.todoList, loginOutcome: "none" }; + } + + if (result.needLogin) { + return { success: false, todos: [], needsLogin: true, loginOutcome: "none" }; + } + + return { + success: false, + todos: [], + error: "eCampus 할 일을 불러오지 못했습니다.", + loginOutcome: "none", + }; + } catch (error) { + errorLog("Error fetching todo list:", error); + return { + success: false, + todos: [], + error: "eCampus 할 일을 불러오는 중 오류가 발생했습니다.", + loginOutcome: "none", + }; + } +}; + +const loadECampusTodosUncached = async ( + options: NormalizedLoadECampusTodosOptions, + requestGeneration: number, +): Promise => { + const { allowAutoLogin, clearExpiredCredentials } = options; + + const directResult = await fetchECampusTodos(); + if (directResult.success || !directResult.needsLogin) { + return directResult; + } + + if (!allowAutoLogin) { + return directResult; + } + + try { + const credentials = await loadECampusCredentials(); + if (!credentials) { + return directResult; + } + + if (requestGeneration !== cacheGeneration) { + return createSupersededResult(); + } + + const loginAttempt = await authenticateECampusAccount( + credentials.id, + credentials.password, + requestGeneration, + ); + if (loginAttempt.superseded) { + return createSupersededResult(); + } + + const loginResult = loginAttempt.result; + + if (loginResult.success) { + const retryResult = await fetchECampusTodos(); + if (requestGeneration !== cacheGeneration) { + return createSupersededResult(); + } + + if (retryResult.success) { + return { + ...retryResult, + loginOutcome: "auto-login-succeeded", + }; + } + + return retryResult; + } + + if (loginResult.error) { + debugLog( + "[Auto-login] Network error, keeping credentials:", + loginResult.error, + ); + return { + success: false, + todos: [], + error: "eCampus 자동 로그인 중 네트워크 오류가 발생했습니다.", + loginOutcome: "network-error", + }; + } + + if (loginResult.data?.isError) { + debugLog("[Auto-login] Auth failed, clearing credentials"); + if (clearExpiredCredentials && requestGeneration === cacheGeneration) { + await clearECampusCredentials(); + } + + if (requestGeneration !== cacheGeneration) { + return createSupersededResult(); + } + + return { + success: false, + todos: [], + error: "저장된 로그인 정보가 만료되었습니다. 다시 로그인해주세요.", + needsLogin: true, + loginOutcome: "credential-expired", + }; + } + + debugLog("[Auto-login] Unknown error, keeping credentials"); + return directResult; + } catch (error) { + errorLog("Error with saved credentials:", error); + return directResult; + } +}; + +/** + * 한 popup 생명주기 안에서 같은 eCampus 요청을 공유한다. + * 성공 결과만 짧게 캐시해 badge와 Todo 탭이 연달아 같은 요청을 보내지 않게 한다. + */ +export const loadECampusTodos = ( + options: LoadECampusTodosOptions = {}, +): Promise => { + const requestGeneration = options.expectedGeneration ?? cacheGeneration; + if (!isECampusAccountCurrent(requestGeneration)) { + return Promise.resolve(createSupersededResult()); + } + + const normalizedOptions = normalizeOptions(options); + const requestKey = getRequestKey(normalizedOptions); + const cached = cachedResults.get(requestKey); + + if (cached && cached.expiresAt > Date.now()) { + return Promise.resolve(cached.result); + } + cachedResults.delete(requestKey); + + const inFlight = inFlightLoads.get(requestKey); + if (inFlight) { + return inFlight; + } + + const request = loadECampusTodosUncached(normalizedOptions, requestGeneration) + .then((result) => { + if (requestGeneration !== cacheGeneration) { + return createSupersededResult(); + } + + if (result.success) { + cachedResults.set(requestKey, { + expiresAt: Date.now() + ECAMPUS_TODO_CACHE_TTL_MS, + result: withoutLoginOutcome(result), + }); + } + + return result; + }) + .finally(() => { + if (inFlightLoads.get(requestKey) === request) { + inFlightLoads.delete(requestKey); + } + }); + + inFlightLoads.set(requestKey, request); + return request; +}; diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 9d07359..24bd062 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -8,6 +8,7 @@ import { BackgroundMessageType } from "../background/types"; import type { GoogleLoginResponse } from "../background/types"; +import { getChromeApi, getStorage, removeStorage } from "./chrome"; import { debugLog, errorLog, getErrorLogDetails } from "@/utils/logger"; /** @@ -36,8 +37,7 @@ function isUserProfile(value: unknown): value is UserProfile { * Get access token from chrome.storage.local */ export async function getAccessToken(): Promise { - const result = await chrome.storage.local.get(["accessToken"]); - const token = result.accessToken; + const token = await getStorage("accessToken"); return typeof token === "string" ? token : null; } @@ -45,8 +45,7 @@ export async function getAccessToken(): Promise { * Get user profile from chrome.storage.local */ export async function getUserProfile(): Promise { - const result = await chrome.storage.local.get(["userProfile"]); - const profile = result.userProfile; + const profile = await getStorage("userProfile"); return isUserProfile(profile) ? profile : null; } @@ -54,7 +53,7 @@ export async function getUserProfile(): Promise { * Clear all tokens and user profile from chrome.storage.local */ export async function clearTokens(): Promise { - await chrome.storage.local.remove([ + await removeStorage([ "accessToken", "refreshToken", "guestToken", @@ -76,9 +75,10 @@ export async function isLoggedIn(): Promise { * Check if current user is a guest (needs email verification) */ export async function isGuestUser(): Promise { - const result = await chrome.storage.local.get(["isGuest", "refreshToken"]); + const isGuest = await getStorage("isGuest"); + const refreshToken = await getStorage("refreshToken"); // Guest if isGuest flag is true OR no refreshToken - return result.isGuest === true || !result.refreshToken; + return isGuest === true || !refreshToken; } /** @@ -88,11 +88,19 @@ export async function isGuestUser(): Promise { * Background service worker has access to chrome.identity API */ export async function startGoogleLogin(): Promise { + const chromeApi = getChromeApi(); + if (!chromeApi?.runtime?.sendMessage) { + return { + success: false, + error: "Chrome extension environment is unavailable.", + }; + } + try { debugLog("[Popup] Sending Google login request to background"); // Send message to background service worker - const response = await chrome.runtime.sendMessage({ + const response = await chromeApi.runtime.sendMessage({ type: BackgroundMessageType.GOOGLE_LOGIN, }); diff --git a/src/utils/todo/badge.ts b/src/utils/todo/badge.ts new file mode 100644 index 0000000..4cb32ca --- /dev/null +++ b/src/utils/todo/badge.ts @@ -0,0 +1,10 @@ +export const TODO_BADGE_BACKGROUND_COLOR = "#00913A"; +export const TODO_BADGE_TEXT_COLOR = "#FFFFFF"; + +export function formatTodoBadgeCount(count: number): string { + if (count <= 0) { + return ""; + } + + return count > 99 ? "99+" : String(count); +} diff --git a/src/utils/todo/count.ts b/src/utils/todo/count.ts new file mode 100644 index 0000000..75313fd --- /dev/null +++ b/src/utils/todo/count.ts @@ -0,0 +1,112 @@ +import type { ECampusTodoItem } from "@/types/todo"; +import { getStorage, setStorage } from "@/utils/chrome"; +import { loadECampusTodos } from "@/utils/ecampus/todos"; +import { errorLog } from "@/utils/logger"; + +import { getCustomTodos } from "./customTodo"; + +const TODO_COUNT_KEY = "todoCount"; +const ECAMPUS_TODO_COUNT_KEY = "ecampusTodoCount"; + +interface TodoCountSnapshot { + customIncompleteCount: number; + ecampusCount: number; +} + +let countWriteQueue: Promise = Promise.resolve(); + +const enqueueCountWrite = (write: () => Promise): Promise => { + const result = countWriteQueue.then(write, write); + countWriteQueue = result.then( + () => undefined, + () => undefined, + ); + return result; +}; + +export const loadStoredTodoCount = async (): Promise => { + return (await getStorage(TODO_COUNT_KEY)) ?? 0; +}; + +const loadLatestCountSnapshot = async (): Promise => { + const [customTodos, storedECampusCount, storedTotalCount] = + await Promise.all([ + getCustomTodos(), + getStorage(ECAMPUS_TODO_COUNT_KEY), + loadStoredTodoCount(), + ]); + const customIncompleteCount = customTodos.filter( + (todo) => !todo.completed, + ).length; + + return { + customIncompleteCount, + ecampusCount: + storedECampusCount ?? + Math.max(0, storedTotalCount - customIncompleteCount), + }; +}; + +const writeTodoCount = async ( + resolveECampusCount: (snapshot: TodoCountSnapshot) => number, +): Promise => { + return enqueueCountWrite(async () => { + const snapshot = await loadLatestCountSnapshot(); + const ecampusCount = Math.max(0, resolveECampusCount(snapshot)); + const totalCount = snapshot.customIncompleteCount + ecampusCount; + + await setStorage({ + [TODO_COUNT_KEY]: totalCount, + [ECAMPUS_TODO_COUNT_KEY]: ecampusCount, + }); + return totalCount; + }); +}; + +/** + * popup 진입 시 eCampus를 새로 불러오고 전체 count를 갱신한다. + * 일시 실패는 마지막 정상 eCampus count를 유지하고, 로그인 해제는 0으로 반영한다. + */ +export const refreshTodoCount = async ( + expectedGeneration?: number, +): Promise => { + try { + const result = await loadECampusTodos({ + allowAutoLogin: true, + clearExpiredCredentials: true, + expectedGeneration, + }); + + return await writeTodoCount((snapshot) => { + if (result.success) return result.todos.length; + if (result.needsLogin && !result.superseded) return 0; + return snapshot.ecampusCount; + }); + } catch (error) { + errorLog("[TodoCount] Failed to refresh todo count:", error); + return loadStoredTodoCount(); + } +}; + +/** + * 성공적으로 받은 eCampus 목록과 storage의 최신 custom Todo를 합산한다. + */ +export const syncTodoCountWithECampusTodos = ( + ecampusTodos: ECampusTodoItem[], +): Promise => { + return writeTodoCount(() => ecampusTodos.length); +}; + +/** + * custom Todo 변경 후 마지막 정상 eCampus count를 보존해 다시 합산한다. + */ +export const syncTodoCountAfterCustomChange = (): Promise => { + return writeTodoCount((snapshot) => snapshot.ecampusCount); +}; + +/** + * 로그아웃 또는 계정 교체 시 이전 eCampus count를 제거한다. + */ +export const clearECampusTodoCount = (): Promise => { + return writeTodoCount(() => 0); +}; diff --git a/src/utils/todo/customTodo.ts b/src/utils/todo/customTodo.ts index 48b652a..08b85ce 100644 --- a/src/utils/todo/customTodo.ts +++ b/src/utils/todo/customTodo.ts @@ -6,47 +6,10 @@ import { getStorage, setStorage } from "../chrome"; import { CustomTodoItem } from "@/types/todo"; import { errorLog } from '@/utils/logger'; +import { calculateDDay } from "./dateFormat"; const CUSTOM_TODOS_KEY = "customTodos"; -/** - * D-Day 계산 함수 - * @param dueDate 마감 날짜 (YYYY.MM.DD 또는 YYYY-MM-DD 형식) - * @returns D-Day 문자열 (예: "D-3", "D-Day", "D+2") - */ -function calculateDDay(dueDate: string): string { - try { - // 날짜 형식 정규화: YYYY-MM-DD → YYYY.MM.DD - const normalizedDate = dueDate.replace(/-/g, "."); - const [year, month, day] = normalizedDate.split(".").map(Number); - - // 유효성 검사 - if (!year || !month || !day) { - errorLog(`[calculateDDay] Invalid date format: ${dueDate}`); - return "D-Day"; - } - - // 자정 기준으로 날짜 차이 계산 - const dueDay = new Date(year, month - 1, day); - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const diffTime = dueDay.getTime() - today.getTime(); - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) { - return "D-Day"; - } else if (diffDays > 0) { - return `D-${diffDays}`; - } else { - return `D+${Math.abs(diffDays)}`; - } - } catch (error) { - errorLog(`[calculateDDay] Error calculating D-Day:`, error); - return "D-Day"; - } -} - /** * 모든 사용자 정의 Todo 가져오기 * 기존 버전의 todo가 있다면 새 형식으로 마이그레이션 @@ -58,22 +21,15 @@ export async function getCustomTodos(): Promise { return []; } - // 마이그레이션: 기존 todo에 새 필드가 없다면 추가 - let needsMigration = false; - const migratedTodos = todos.map((todo) => { - // 새 필드가 이미 있다면 그대로 반환 - if (todo.dDay && todo.dueTime && todo.dueDate.includes(".")) { - return todo; - } - - needsMigration = true; - + // 매번 D-Day를 재계산하여 반환 + const updatedTodos = todos.map((todo) => { // 날짜 형식 정규화: YYYY-MM-DD → YYYY.MM.DD const normalizedDate = todo.dueDate.replace(/-/g, "."); // 기존 dueDate가 시간을 포함하지 않으면 기본 시간 설정 const dueTime = todo.dueTime || "23:59"; - const dDay = calculateDDay(normalizedDate); + // D-Day를 매번 재계산 + const dDay = calculateDDay(normalizedDate, dueTime); return { ...todo, @@ -84,14 +40,7 @@ export async function getCustomTodos(): Promise { }; }); - // 마이그레이션이 필요했다면 저장 - if (needsMigration) { - await setStorage({ - [CUSTOM_TODOS_KEY]: migratedTodos, - }); - } - - return migratedTodos; + return updatedTodos; } catch (error) { errorLog("[CustomTodo] Error getting custom todos:", error); return []; @@ -109,7 +58,7 @@ export async function addCustomTodo( ): Promise { try { const todos = await getCustomTodos(); - const dDay = calculateDDay(dueDate); + const dDay = calculateDDay(dueDate, dueTime); const newTodo: CustomTodoItem = { type: "custom", diff --git a/src/utils/todo/dateFormat.ts b/src/utils/todo/dateFormat.ts index a03fd2a..eb06234 100644 --- a/src/utils/todo/dateFormat.ts +++ b/src/utils/todo/dateFormat.ts @@ -1,14 +1,76 @@ /** - * 날짜/시간 형식 변환 유틸리티 + * 날짜/시간 형식 변환 및 D-Day 계산 유틸리티 */ +import { TodoItem } from "@/types/todo"; +import { errorLog } from "@/utils/logger"; + +const TODO_DATE_PATTERN = /^(\d{4})[.-](\d{1,2})[.-](\d{1,2})$/; +const TODO_TIME_PATTERN = /^(\d{1,2}):(\d{2})$/; +const INVALID_TODO_DEADLINE = "2099-12-31T23:59:59"; + +const parseTodoTime = ( + dueTime: string, +): { hour: number; minute: number } | null => { + const match = TODO_TIME_PATTERN.exec(dueTime); + if (!match) return null; + + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour > 23 || minute > 59) return null; + + return { hour, minute }; +}; + +/** + * Todo 날짜와 시간을 런타임에서 검증해 Date 객체로 변환 + * TypeScript 타입만으로는 storage/eCampus에서 들어오는 문자열을 검증할 수 없다. + */ +export function parseTodoDateTime( + dueDate: string, + dueTime: string, +): Date | null { + const dateMatch = TODO_DATE_PATTERN.exec(dueDate); + const time = parseTodoTime(dueTime); + if (!dateMatch || !time) return null; + + const year = Number(dateMatch[1]); + const month = Number(dateMatch[2]); + const day = Number(dateMatch[3]); + const parsed = new Date( + year, + month - 1, + day, + time.hour, + time.minute, + 0, + 0, + ); + + if ( + !Number.isFinite(parsed.getTime()) || + parsed.getFullYear() !== year || + parsed.getMonth() !== month - 1 || + parsed.getDate() !== day || + parsed.getHours() !== time.hour || + parsed.getMinutes() !== time.minute + ) { + return null; + } + + return parsed; +} + /** * 24시간 형식을 12시간 형식 + 오전/오후로 변환 * @param time24 24시간 형식 시간 (HH:mm) * @returns 12시간 형식 시간 (오전/오후 HH:mm) */ export function format24to12Hour(time24: string): string { - const [hour24, minute] = time24.split(':').map(Number); + const parsedTime = parseTodoTime(time24); + if (!parsedTime) return time24; + + const { hour: hour24, minute } = parsedTime; // 오전/오후 결정 const period = hour24 < 12 ? '오전' : '오후'; @@ -33,3 +95,116 @@ export function formatTodoDateTime(dueDate: string, dueTime: string): string { const formattedTime = format24to12Hour(dueTime); return `${dueDate} ${formattedTime}`; } + +/** + * D-Day 계산 함수 + * @param dueDate 마감 날짜 (YYYY.MM.DD 또는 YYYY-MM-DD 형식) + * @param dueTime 마감 시간 (HH:mm 형식) + * @returns D-Day 문자열 (예: "D-3", "D-Day", "마감", "D+2") + */ +export function calculateDDay(dueDate: string, dueTime: string): string { + try { + const dueDateTime = parseTodoDateTime(dueDate, dueTime); + if (!dueDateTime) { + errorLog("[calculateDDay] Invalid date or time"); + return "D-Day"; + } + + const now = new Date(); + + // 날짜 차이 계산 (자정 기준) + const dueDay = new Date(dueDateTime); + dueDay.setHours(0, 0, 0, 0); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const dateDiffMs = dueDay.getTime() - today.getTime(); + const dateDiff = Math.floor(dateDiffMs / (1000 * 60 * 60 * 24)); + + // 시간 차이 계산 (밀리초) + const timeDiffMs = dueDateTime.getTime() - now.getTime(); + + if (dateDiff < 0) { + // 과거 날짜: D+1, D+2, ... + return `D+${Math.abs(dateDiff)}`; + } else if (dateDiff > 0) { + // 미래 날짜: D-1, D-2, ... + return `D-${dateDiff}`; + } else { + // 같은 날 (dateDiff === 0) + if (timeDiffMs < 0) { + // 오늘인데 시간은 이미 지남 → "마감"으로 표시 + return "마감"; + } else { + // 오늘인데 시간은 아직 안 지남 + return "D-Day"; + } + } + } catch (error) { + errorLog("[calculateDDay] Error calculating D-Day:", error); + return "D-Day"; + } +} + +/** + * eCampus 형식의 마감일을 Date 객체로 변환 + * @param dueDate "2025.10.11 오후 11:59" 형식 + * @returns Date 객체 + */ +export function parseECampusDueDate(dueDate: string): Date { + const parsed = parseECampusToTimerFormat(dueDate); + if (!parsed) { + return new Date(INVALID_TODO_DEADLINE); + } + + return ( + parseTodoDateTime(parsed.date, parsed.time) ?? + new Date(INVALID_TODO_DEADLINE) + ); +} + +/** + * eCampus 형식의 마감일을 타이머 형식으로 변환 + * @param dueDate "2025.10.11 오후 11:59" 형식 + * @returns { date: "2025.10.11", time: "23:59" } 또는 null + */ +export function parseECampusToTimerFormat(dueDate: string): { date: string; time: string } | null { + const match = + /^(\d{4})\.(\d{1,2})\.(\d{1,2})\s+(오전|오후)\s+(\d{1,2}):(\d{2})$/.exec( + dueDate.trim(), + ); + if (!match) return null; + + const [, year, month, day, period, hourText, minuteText] = match; + const hour12 = Number(hourText); + const minute = Number(minuteText); + if (hour12 < 1 || hour12 > 12 || minute > 59) return null; + + let hour24 = hour12 % 12; + if (period === "오후") { + hour24 += 12; + } + + const pad = (value: string | number) => String(value).padStart(2, "0"); + const date = `${year}.${pad(month)}.${pad(day)}`; + const time = `${pad(hour24)}:${pad(minute)}`; + + return parseTodoDateTime(date, time) ? { date, time } : null; +} + +/** + * TodoItem의 실제 마감 시간을 Date 객체로 반환 + * @param todo TodoItem (eCampus 또는 Custom) + * @returns Date 객체 + */ +export function getTodoDeadline(todo: TodoItem): Date { + if (todo.type === 'ecampus') { + return parseECampusDueDate(todo.dueDate); + } + + return ( + parseTodoDateTime(todo.dueDate, todo.dueTime) ?? + new Date(INVALID_TODO_DEADLINE) + ); +} diff --git a/src/utils/todo/secondTicker.ts b/src/utils/todo/secondTicker.ts new file mode 100644 index 0000000..88e51a8 --- /dev/null +++ b/src/utils/todo/secondTicker.ts @@ -0,0 +1,33 @@ +type TickListener = () => void; + +const listeners = new Set(); +let intervalId: number | null = null; + +const startTicker = () => { + if (intervalId !== null) { + return; + } + + intervalId = window.setInterval(() => { + listeners.forEach((listener) => listener()); + }, 1000); +}; + +const stopTicker = () => { + if (intervalId === null || listeners.size > 0) { + return; + } + + window.clearInterval(intervalId); + intervalId = null; +}; + +export const subscribeSecondTick = (listener: TickListener) => { + listeners.add(listener); + startTicker(); + + return () => { + listeners.delete(listener); + stopTicker(); + }; +}; diff --git a/src/utils/todo/timer.ts b/src/utils/todo/timer.ts new file mode 100644 index 0000000..bc22f44 --- /dev/null +++ b/src/utils/todo/timer.ts @@ -0,0 +1,91 @@ +/** + * Todo 실시간 타이머 유틸 함수 + */ + +import { errorLog } from "@/utils/logger"; +import { parseTodoDateTime } from "@/utils/todo/dateFormat"; + +export interface TimeLeft { + hours: number; + minutes: number; + seconds: number; + totalMilliseconds: number; +} + +/** + * 마감일시까지 남은 시간 계산 + * @param dueDate - "YYYY.MM.DD" 형식 + * @param dueTime - "HH:mm" 형식 (24시간) + * @returns TimeLeft 객체 또는 null (이미 지난 경우) + */ +export function calculateTimeLeft(dueDate: string, dueTime: string): TimeLeft | null { + try { + const deadline = parseTodoDateTime(dueDate, dueTime); + if (!deadline) { + return null; + } + + const now = new Date(); + + const diff = deadline.getTime() - now.getTime(); + + // 이미 지난 경우 + if (diff <= 0) { + return null; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + return { + hours, + minutes, + seconds, + totalMilliseconds: diff, + }; + } catch (error) { + errorLog('Error calculating time left:', error); + return null; + } +} + +/** + * 남은 시간을 HH:mm:ss 형식으로 포맷 + * @param timeLeft - TimeLeft 객체 + * @returns "23:45:12" 형식 문자열 + */ +export function formatTimeLeft(timeLeft: TimeLeft): string { + const h = String(timeLeft.hours).padStart(2, '0'); + const m = String(timeLeft.minutes).padStart(2, '0'); + const s = String(timeLeft.seconds).padStart(2, '0'); + + return `${h}:${m}:${s}`; +} + +/** + * 24시간 이하로 남았는지 확인 + * @param dueDate - "YYYY.MM.DD" 형식 + * @param dueTime - "HH:mm" 형식 + * @returns 24시간 이하면 true + */ +export function shouldShowTimer(dueDate: string, dueTime: string): boolean { + const timeLeft = calculateTimeLeft(dueDate, dueTime); + + if (!timeLeft) { + return false; + } + + const hoursLeft = timeLeft.totalMilliseconds / (1000 * 60 * 60); + return hoursLeft <= 24; +} + +/** + * 12시간 이하로 남았는지 확인 (빨간색 표시용) + * @param timeLeft - TimeLeft 객체 + * @returns 12시간 이하면 true + */ +export function isUrgent(timeLeft: TimeLeft): boolean { + const hoursLeft = timeLeft.totalMilliseconds / (1000 * 60 * 60); + return hoursLeft <= 12; +} diff --git a/src/utils/todo/todoMarkdown.ts b/src/utils/todo/todoMarkdown.ts index 43c5a46..2192dca 100644 --- a/src/utils/todo/todoMarkdown.ts +++ b/src/utils/todo/todoMarkdown.ts @@ -24,15 +24,15 @@ export const convertTodosToMarkdown = (todos: TodoItem[]): string => { } // 이캠퍼스 Todo와 사용자 정의 Todo 분리 - const ecampusTodos = todos.filter((todo) => todo.type === 'ecampus'); - const customTodos = todos.filter((todo) => todo.type === 'custom'); + const ecampusTodos = todos.filter((todo) => todo.type === "ecampus"); + const customTodos = todos.filter((todo) => todo.type === "custom"); const sections: string[] = []; // 이캠퍼스 Todo 섹션 if (ecampusTodos.length > 0) { const ecampusMarkdown = ecampusTodos - .map((item) => `- [ ] ${item.title} | ${item.subject} - ${item.dueDate}`) + .map((item) => `- [ ] ${item.subject} ${item.title} | ${item.dueDate}`) .join("\n"); sections.push(`## 이캠퍼스 Todo\n${ecampusMarkdown}`); } @@ -41,10 +41,13 @@ export const convertTodosToMarkdown = (todos: TodoItem[]): string => { if (customTodos.length > 0) { const customMarkdown = customTodos .map((item) => { - const formattedDateTime = formatTodoDateTime(item.dueDate, item.dueTime); - const checkbox = item.completed ? 'x' : ' '; - const subjectPart = item.subject ? ` | ${item.subject}` : ''; - return `- [${checkbox}] ${item.title}${subjectPart} - ${formattedDateTime}`; + const formattedDateTime = formatTodoDateTime( + item.dueDate, + item.dueTime + ); + const checkbox = item.completed ? "x" : " "; + const subjectPart = item.subject || ""; + return `- [${checkbox}] ${subjectPart} ${item.title} | ${formattedDateTime}`; }) .join("\n"); sections.push(`## 나의 Todo\n${customMarkdown}`); diff --git a/src/utils/todoMarkdown.ts b/src/utils/todoMarkdown.ts deleted file mode 100644 index cd90cf1..0000000 --- a/src/utils/todoMarkdown.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { TodoItem } from "@/types/todo"; -import { formatTodoDateTime } from "./todo/dateFormat"; - -/** - * Todo 항목들을 마크다운 체크리스트 형식으로 변환합니다. - * - * @param todos - 변환할 Todo 항목 배열 - * @returns 마크다운 형식의 문자열 - * - * @example - * ```typescript - * const todos = [ - * { type: 'ecampus', title: "과제 제출", subject: "프로그래밍", dueDate: "2024.03.20 오후 11:59", dDay: "D-3" }, - * { type: 'custom', title: "책 읽기", dueDate: "2024.03.25", dueTime: "23:59", completed: false } - * ]; - * - * convertTodosToMarkdown(todos); - * // "## 이캠퍼스 Todo\n- [ ] 과제 제출 | 프로그래밍 - 2024.03.20 오후 11:59\n\n## 나의 Todo\n- [ ] 책 읽기 - 2024.03.25 오후 11:59" - * ``` - */ -export const convertTodosToMarkdown = (todos: TodoItem[]): string => { - if (todos.length === 0) { - return "할 일이 없습니다."; - } - - // 이캠퍼스 Todo와 사용자 정의 Todo 분리 - const ecampusTodos = todos.filter((todo) => todo.type === "ecampus"); - const customTodos = todos.filter((todo) => todo.type === "custom"); - - const sections: string[] = []; - - // 이캠퍼스 Todo 섹션 - if (ecampusTodos.length > 0) { - const ecampusMarkdown = ecampusTodos - .map( - (item) => `- [ ] ${item.title} | ${item.subject} - ${item.dueDate}` - ) - .join("\n"); - sections.push(`## 이캠퍼스 Todo\n${ecampusMarkdown}`); - } - - // 사용자 정의 Todo 섹션 - if (customTodos.length > 0) { - const customMarkdown = customTodos - .map((item) => { - const formattedDateTime = formatTodoDateTime( - item.dueDate, - item.dueTime - ); - const checkbox = item.completed ? "x" : " "; - const subjectPart = item.subject ? ` | ${item.subject}` : ""; - return `- [${checkbox}] ${item.title}${subjectPart} - ${formattedDateTime}`; - }) - .join("\n"); - sections.push(`## 나의 Todo\n${customMarkdown}`); - } - - return sections.join("\n\n"); -}; diff --git a/tests/todo/ecampusAuthQueue.test.ts b/tests/todo/ecampusAuthQueue.test.ts new file mode 100644 index 0000000..e89e6df --- /dev/null +++ b/tests/todo/ecampusAuthQueue.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createSerializedAuthQueue } from "../../src/utils/ecampus/authQueue.ts"; + +test("later account transition supersedes an in-flight login and runs after it", async () => { + const authQueue = createSerializedAuthQueue(); + let generation = 0; + let resolveFirstLogin: ((value: string) => void) | undefined; + let secondLoginStarted = false; + + const firstAttempt = authQueue.run( + () => generation === 0, + () => + new Promise((resolve) => { + resolveFirstLogin = resolve; + }), + ); + + await Promise.resolve(); + generation = 1; + + const secondAttempt = authQueue.run(() => generation === 1, async () => { + secondLoginStarted = true; + return "second-account"; + }); + + assert.equal(secondLoginStarted, false); + assert.ok(resolveFirstLogin); + resolveFirstLogin("first-account"); + + assert.deepEqual(await firstAttempt, { superseded: true }); + assert.deepEqual(await secondAttempt, { + superseded: false, + result: "second-account", + }); +}); + +test("superseded login does not start an authentication request", async () => { + const authQueue = createSerializedAuthQueue(); + let authenticateCalls = 0; + + const attempt = authQueue.run( + () => false, + async () => { + authenticateCalls += 1; + return "unused"; + }, + ); + + assert.deepEqual(await attempt, { superseded: true }); + assert.equal(authenticateCalls, 0); +}); diff --git a/tests/todo/ecampusTodoState.test.ts b/tests/todo/ecampusTodoState.test.ts new file mode 100644 index 0000000..64e74c7 --- /dev/null +++ b/tests/todo/ecampusTodoState.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveECampusTodosAfterLoad } from "../../src/utils/ecampus/todoState.ts"; + +test("일시 eCampus 오류에는 마지막 정상 목록을 보존한다", () => { + const previousTodos = [{ id: "ecampus-1" }]; + + assert.deepEqual( + resolveECampusTodosAfterLoad(previousTodos, { + success: false, + todos: [], + }), + previousTodos, + ); +}); + +test("로그인이 필요한 응답에는 eCampus 목록을 비운다", () => { + assert.deepEqual( + resolveECampusTodosAfterLoad([{ id: "ecampus-1" }], { + success: false, + todos: [], + needsLogin: true, + }), + [], + ); +});