Created
November 20, 2025 13:32
-
-
Save lunamoth/f75877b97ad9a95f829382b13cdb82a7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>주간 모험 계획 🗺️</title> | |
| <!-- 폰트 로딩 최적화 --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link rel="preload" href="https://fonts.gstatic.com/s/lora/v32/0QI6MX1D_JOuGQbT0bvTPZ0.woff2" as="font" type="font/woff2" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet"> | |
| <style>html { opacity: 0; visibility: hidden; }</style> | |
| <script> | |
| (function() { | |
| const savedTheme = localStorage.getItem('theme'); | |
| if (savedTheme === 'dark') { | |
| document.documentElement.classList.add('dark-mode'); | |
| } | |
| })(); | |
| </script> | |
| <style> | |
| @font-face { | |
| font-family: 'Lora-Fallback'; | |
| src: local('Times New Roman'); | |
| ascent-override: 95%; | |
| descent-override: 25%; | |
| size-adjust: 100%; | |
| } | |
| :root { | |
| --bg-color: #3d2c1d; | |
| --page-bg-color: #fdf6e3; | |
| --primary-text-color: #583e23; | |
| --secondary-text-color: #9f8c76; | |
| --border-color: #dcd1b8; | |
| --accent-color: #4a6b82; | |
| --accent-red-color: #b74a3d; | |
| --hover-bg-color: #f9f0d9; | |
| --page-border-color: #856a4f; | |
| --drag-line-color: #583e23; | |
| --sidebar-width: 320px; | |
| --modal-overlay-bg: rgba(0, 0, 0, 0.6); | |
| --toast-bg: #333; | |
| --toast-text: #fff; | |
| --anim-duration: 300ms; | |
| --highlight-duration: 2000ms; | |
| --toast-duration: 3000ms; | |
| } | |
| html.dark-mode { | |
| --bg-color: #0f172a; | |
| --page-bg-color: #1e293b; | |
| --primary-text-color: #e2e8f0; | |
| --secondary-text-color: #94a3b8; | |
| --border-color: #334155; | |
| --accent-color: #38bdf8; | |
| --accent-red-color: #f87171; | |
| --hover-bg-color: #334155; | |
| --page-border-color: #475569; | |
| --drag-line-color: #e2e8f0; | |
| --modal-overlay-bg: rgba(0, 0, 0, 0.8); | |
| } | |
| *, | |
| *::before, | |
| *::after { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Lora', 'Lora-Fallback', serif; | |
| background-color: var(--bg-color); | |
| color: var(--primary-text-color); | |
| -webkit-font-smoothing: antialiased; | |
| font-size: 16px; | |
| overflow-x: hidden; | |
| } | |
| body.ui-ready, | |
| body.ui-ready .planner-page { | |
| transition: background-color 0.3s ease, color 0.3s ease; | |
| } | |
| .planner-page { | |
| background-color: var(--page-bg-color); | |
| padding: 2.5rem; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| } | |
| .planner-page.search-active { | |
| margin-right: var(--sidebar-width); | |
| } | |
| body.ui-ready .planner-page { | |
| transition: background-color 0.3s ease, margin-right 0.3s ease; | |
| } | |
| main { | |
| display: flex; | |
| flex-grow: 1; | |
| flex-direction: column; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .app-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 2rem; | |
| border-bottom: 2px solid var(--page-border-color); | |
| padding-bottom: 1.5rem; | |
| } | |
| .header-left { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| } | |
| .header-date h1 { | |
| font-size: 2.5rem; | |
| font-weight: 600; | |
| margin: 0; | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| .week-navigation { | |
| display: flex; | |
| align-items: center; | |
| } | |
| .week-navigation button { | |
| background: none; | |
| border: 1px solid var(--border-color); | |
| border-radius: 4px; | |
| padding: 0.5rem 1rem; | |
| font-family: 'Lora', serif; | |
| font-size: 0.9rem; | |
| color: var(--primary-text-color); | |
| cursor: pointer; | |
| transition: background-color 0.2s, border-color 0.2s; | |
| margin: 0 0.25rem; | |
| } | |
| .week-navigation button:hover { | |
| background-color: var(--hover-bg-color); | |
| border-color: var(--page-border-color); | |
| } | |
| .calendar-btn { | |
| font-size: 1.1rem; | |
| padding: 0.4rem 0.8rem !important; | |
| } | |
| #datePicker { | |
| width: 0; | |
| height: 0; | |
| opacity: 0; | |
| position: absolute; | |
| z-index: -1; | |
| } | |
| /* Week Grid */ | |
| .week-grid { | |
| display: grid; | |
| grid-template-columns: repeat(7, 1fr); | |
| border-left: 1px solid var(--border-color); | |
| flex-grow: 1; | |
| opacity: 0; | |
| position: relative; | |
| transform: translateX(0); | |
| } | |
| .week-grid.active { | |
| display: grid; | |
| animation: fadeIn 0.4s ease forwards; | |
| } | |
| .week-grid:not(.active) { | |
| display: none; | |
| } | |
| /* Month Grid */ | |
| .month-grid { | |
| display: none; | |
| grid-template-columns: repeat(7, 1fr); | |
| grid-template-rows: auto; | |
| grid-auto-rows: minmax(120px, 1fr); | |
| border-top: 1px solid var(--border-color); | |
| border-left: 1px solid var(--border-color); | |
| flex-grow: 1; | |
| opacity: 0; | |
| background-color: var(--page-bg-color); | |
| } | |
| .month-grid.active { | |
| display: grid; | |
| animation: fadeIn 0.4s ease forwards; | |
| } | |
| .month-header-row { | |
| display: grid; | |
| grid-template-columns: repeat(7, 1fr); | |
| border-bottom: 1px solid var(--border-color); | |
| margin-bottom: 0; | |
| grid-column: 1 / -1; /* 그리드 전체 너비 차지 */ | |
| width: 100%; | |
| } | |
| .month-header-cell { | |
| text-align: center; | |
| padding: 0.8rem; | |
| font-weight: bold; | |
| color: var(--secondary-text-color); | |
| border-right: 1px solid transparent; | |
| } | |
| .month-header-cell:nth-child(1) { color: var(--accent-red-color); } /* Sunday */ | |
| .month-header-cell:nth-child(7) { color: var(--accent-color); } /* Saturday */ | |
| .month-cell { | |
| border-right: 1px solid var(--border-color); | |
| border-bottom: 1px solid var(--border-color); | |
| padding: 0.5rem; | |
| position: relative; | |
| cursor: pointer; | |
| transition: background-color 0.2s; | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 100px; | |
| } | |
| .month-cell:hover { | |
| background-color: var(--hover-bg-color); | |
| } | |
| .month-cell.other-month { | |
| background-color: rgba(0,0,0,0.03); | |
| color: var(--secondary-text-color); | |
| opacity: 0.7; | |
| } | |
| .month-cell.today { | |
| background-color: rgba(var(--primary-text-color), 0.05); | |
| } | |
| .month-date { | |
| text-align: right; | |
| font-weight: bold; | |
| margin-bottom: 0.5rem; | |
| font-size: 1rem; | |
| } | |
| .month-cell.sunday .month-date { color: var(--accent-red-color); } | |
| .month-cell.saturday .month-date { color: var(--accent-color); } | |
| .month-cell.today .month-date { text-decoration: underline; font-weight: 900; } | |
| .task-dots { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px; | |
| align-content: flex-start; | |
| } | |
| /* [FIX] Dots enlarged for better visibility */ | |
| .task-dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| background-color: var(--secondary-text-color); | |
| } | |
| .task-dot.active { | |
| background-color: var(--accent-red-color); | |
| } | |
| .task-dot.completed { | |
| background-color: var(--accent-color); | |
| opacity: 0.4; | |
| } | |
| /* [FIX] Tooltip for Month View */ | |
| .task-tooltip { | |
| position: absolute; | |
| bottom: 100%; /* 셀 바로 위에 위치 */ | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: var(--toast-bg); | |
| color: var(--toast-text); | |
| /* [수정됨] 크기 및 여백 확장 */ | |
| padding: 0.8rem 1rem; | |
| border-radius: 4px; | |
| font-size: 0.9rem; | |
| /* [수정됨] 줄바꿈 허용 */ | |
| white-space: pre-wrap; /* [수정됨] 줄바꿈 문자(\n) 적용을 위해 pre-wrap 사용 */ | |
| line-height: 1.5; /* 줄간격 살짝 추가하여 가독성 확보 */ | |
| text-align: left; /* 리스트 형태이므로 좌측 정렬 */ | |
| z-index: 20; | |
| display: none; | |
| pointer-events: none; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.3); | |
| /* [수정됨] 너비 확장 */ | |
| min-width: 180px; /* 너무 작아지지 않게 최소 너비 설정 */ | |
| max-width: 350px; /* 최대 너비 설정 */ | |
| /* 기존의 말줄임(...) 처리는 제거 (내용을 다 보여주기 위함) */ | |
| /* overflow: hidden; */ | |
| /* text-overflow: ellipsis; */ | |
| } | |
| /* 툴팁 아래쪽의 작은 화살표 (이 코드가 없으면 말풍선 꼬리가 사라집니다) */ | |
| .task-tooltip::after { | |
| content: ''; | |
| position: absolute; | |
| top: 100%; | |
| left: 50%; | |
| margin-left: -5px; | |
| border-width: 5px; | |
| border-style: solid; | |
| border-color: var(--toast-bg) transparent transparent transparent; | |
| } | |
| /* 마우스를 올렸을 때 툴팁이 나타나게 하는 동작 */ | |
| .month-cell:hover .task-tooltip { | |
| display: block; | |
| animation: fadeIn 0.2s ease; | |
| } | |
| @keyframes slideOutLeft { | |
| from { transform: translateX(0); opacity: 1; } | |
| to { transform: translateX(-30px); opacity: 0; } | |
| } | |
| @keyframes slideInRight { | |
| from { transform: translateX(30px); opacity: 0; } | |
| to { transform: translateX(0); opacity: 1; } | |
| } | |
| @keyframes slideOutRight { | |
| from { transform: translateX(0); opacity: 1; } | |
| to { transform: translateX(30px); opacity: 0; } | |
| } | |
| @keyframes slideInLeft { | |
| from { transform: translateX(-30px); opacity: 0; } | |
| to { transform: translateX(0); opacity: 1; } | |
| } | |
| .anim-slide-out-left { animation: slideOutLeft 0.2s ease forwards; } | |
| .anim-slide-in-right { animation: slideInRight 0.2s ease forwards; } | |
| .anim-slide-out-right { animation: slideOutRight 0.2s ease forwards; } | |
| .anim-slide-in-left { animation: slideInLeft 0.2s ease forwards; } | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| .day-column { | |
| padding: 1rem 1.5rem; | |
| border-right: 1px solid var(--border-color); | |
| display: flex; | |
| flex-direction: column; | |
| cursor: text; | |
| transition: background-color 0.5s ease; | |
| outline: none; | |
| } | |
| .day-column:focus-within { | |
| background-color: rgba(var(--primary-text-color), 0.02); | |
| } | |
| @keyframes flash-highlight { | |
| 0% { background-color: rgba(220, 209, 184, 0.8); } | |
| 100% { background-color: transparent; } | |
| } | |
| .day-column.flash-active { | |
| animation: flash-highlight 1s ease-out; | |
| } | |
| .day-header { | |
| padding-bottom: 1rem; | |
| margin-bottom: 1rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| border-bottom: 1px solid var(--border-color); | |
| } | |
| .date { | |
| font-weight: 700; | |
| font-size: 1.32rem; | |
| } | |
| .day-name { | |
| font-size: 1.08rem; | |
| font-style: italic; | |
| color: var(--secondary-text-color); | |
| } | |
| .day-header.sunday .date, | |
| .day-header.sunday .day-name { | |
| color: var(--accent-red-color); | |
| } | |
| .day-header.saturday .date, | |
| .day-header.saturday .day-name { | |
| color: var(--accent-color); | |
| } | |
| .day-column.today .day-header .date { | |
| color: var(--primary-text-color); | |
| font-weight: 900; | |
| text-decoration: underline; | |
| text-decoration-thickness: 2px; | |
| } | |
| .day-column.today-flash { | |
| animation: flash-highlight 1s ease-out; | |
| } | |
| .today-date-highlight { | |
| color: var(--primary-text-color) !important; | |
| font-weight: 900 !important; | |
| text-decoration: underline; | |
| text-decoration-thickness: 2px; | |
| } | |
| .today-name-highlight { | |
| font-weight: 700 !important; | |
| color: var(--primary-text-color) !important; | |
| } | |
| .task-list { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| flex-grow: 1; | |
| min-height: 50px; | |
| } | |
| .task-item { | |
| display: flex; | |
| align-items: flex-start; | |
| padding: 0.75rem 0.25rem; | |
| cursor: grab; | |
| transition: background-color 0.2s; | |
| border-bottom: 1px dotted var(--border-color); | |
| border-top: 2px solid transparent; | |
| position: relative; | |
| } | |
| .task-item:last-of-type { | |
| border-bottom: none; | |
| } | |
| .task-item:hover { | |
| background-color: var(--hover-bg-color); | |
| } | |
| /* 반복 일정 시각적 구분 */ | |
| .task-item.recurring { | |
| background-color: rgba(var(--accent-color), 0.03); | |
| } | |
| .task-item.dragging { | |
| opacity: 0.5; | |
| background-color: var(--hover-bg-color); | |
| will-change: transform, opacity; | |
| } | |
| .task-item:focus-within { | |
| background-color: var(--hover-bg-color); | |
| } | |
| .drag-ghost { | |
| position: absolute; | |
| top: -1000px; | |
| background-color: var(--page-bg-color); | |
| border: 1px solid var(--border-color); | |
| padding: 0.5rem; | |
| opacity: 0.8; | |
| pointer-events: none; | |
| z-index: 1000; | |
| } | |
| @keyframes search-pulse { | |
| 0% { background-color: rgba(74, 107, 130, 0.3); } | |
| 50% { background-color: rgba(74, 107, 130, 0.6); } | |
| 100% { background-color: rgba(74, 107, 130, 0.3); } | |
| } | |
| .highlight-task { | |
| animation: search-pulse 2s infinite; | |
| border-radius: 4px; | |
| } | |
| .drag-placeholder { | |
| height: 40px; | |
| background-color: rgba(88, 62, 35, 0.1); | |
| border: 2px dashed var(--drag-line-color); | |
| margin: 0.5rem 0; | |
| border-radius: 4px; | |
| transition: all 0.2s ease; | |
| } | |
| input[type="checkbox"] { | |
| width: 16px; | |
| height: 16px; | |
| margin-right: 0.75rem; | |
| margin-top: 0.25rem; | |
| cursor: pointer; | |
| flex-shrink: 0; | |
| accent-color: var(--primary-text-color); | |
| } | |
| /* 반복 일정 아이콘 표시 */ | |
| .recurring-indicator { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: var(--accent-color); | |
| font-size: 0.85rem; | |
| margin-right: 0.3rem; | |
| margin-top: 0.2rem; | |
| flex-shrink: 0; | |
| opacity: 0.8; | |
| cursor: help; | |
| } | |
| .task-text { | |
| flex-grow: 1; | |
| outline: none; | |
| line-height: 1.6; | |
| min-height: 1em; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| transition: opacity 0.3s, color 0.3s; | |
| border-radius: 3px; | |
| padding: 0 2px; | |
| } | |
| .task-text b { font-weight: 700; color: var(--primary-text-color); } | |
| .task-text i { font-style: italic; color: var(--secondary-text-color); } | |
| .task-text s { text-decoration: line-through; color: var(--secondary-text-color); opacity: 0.8; } | |
| .task-text.editing { | |
| font-family: "Courier New", Courier, monospace; | |
| background-color: rgba(var(--primary-text-color), 0.03); | |
| color: var(--primary-text-color); | |
| white-space: pre-wrap; | |
| } | |
| input[type="checkbox"]:checked+.task-text { | |
| text-decoration: line-through; | |
| color: var(--secondary-text-color); | |
| font-style: italic; | |
| opacity: 0.5; | |
| } | |
| .task-controls { | |
| display: flex; | |
| align-items: center; | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| } | |
| .task-item:hover .task-controls, | |
| .task-item:focus-within .task-controls, | |
| .task-controls.active { | |
| opacity: 1; | |
| } | |
| .repeat-btn, .delete-task { | |
| background: none; | |
| border: none; | |
| cursor: pointer; | |
| font-size: 1.1rem; | |
| color: var(--secondary-text-color); | |
| padding: 0 4px; | |
| transition: color 0.2s; | |
| } | |
| .repeat-btn:hover { color: var(--primary-text-color); } | |
| .delete-task:hover { color: var(--accent-red-color); } | |
| .repeat-btn.active { | |
| color: var(--accent-color); | |
| font-weight: bold; | |
| opacity: 1 !important; | |
| } | |
| .repeat-menu { | |
| position: absolute; | |
| top: calc(100% + 4px); | |
| right: 0; | |
| background-color: var(--page-bg-color); | |
| border: 1px solid var(--border-color); | |
| border-radius: 4px; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.2); | |
| z-index: 2000; | |
| display: none; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 2px; | |
| padding: 6px; | |
| width: 320px; | |
| max-height: none; | |
| overflow: visible; | |
| } | |
| .repeat-menu.show { display: grid; } | |
| .repeat-menu.open-up { | |
| top: auto; | |
| bottom: calc(100% + 4px); | |
| box-shadow: 0 -4px 15px rgba(0,0,0,0.2); | |
| } | |
| .repeat-option { | |
| background: none; | |
| border: 1px solid transparent; | |
| text-align: left; | |
| padding: 0.5rem 0.5rem; | |
| font-family: 'Lora', serif; | |
| font-size: 0.85rem; | |
| color: var(--primary-text-color); | |
| cursor: pointer; | |
| white-space: nowrap; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .repeat-option:hover { | |
| background-color: var(--hover-bg-color); | |
| border-color: rgba(var(--border-color), 0.5); | |
| } | |
| .repeat-option.selected { | |
| font-weight: bold; | |
| color: var(--accent-color); | |
| background-color: rgba(var(--primary-text-color), 0.05); | |
| border-color: var(--border-color); | |
| } | |
| .empty-state { | |
| text-align: center; | |
| padding: 2rem 0; | |
| opacity: 0.5; | |
| font-style: italic; | |
| font-size: 0.9rem; | |
| color: var(--secondary-text-color); | |
| pointer-events: none; | |
| } | |
| .data-controls { | |
| margin-top: 2rem; | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 1rem; | |
| opacity: 1; | |
| transition: opacity 0.3s ease; | |
| font-size: 0.8rem; | |
| } | |
| .data-btn { | |
| background: none; | |
| border: none; | |
| cursor: pointer; | |
| color: var(--secondary-text-color); | |
| text-decoration: underline; | |
| padding: 0; | |
| font-family: 'Lora', serif; | |
| } | |
| .data-btn:hover { color: var(--accent-color); } | |
| .search-overlay { | |
| position: fixed; | |
| top: 0; | |
| right: calc(-1 * var(--sidebar-width)); | |
| width: var(--sidebar-width); | |
| height: 100%; | |
| background-color: var(--bg-color); | |
| border-left: 1px solid var(--border-color); | |
| box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); | |
| z-index: 2000; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 1.5rem; | |
| transition: right 0.3s ease; | |
| will-change: right; | |
| } | |
| .search-overlay.active { right: 0; } | |
| .search-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 1.5rem; | |
| } | |
| .search-title { font-weight: bold; font-size: 1.2rem; margin: 0; color: #ffffff; } | |
| .close-search { background: none; border: none; color: #ffffff; font-size: 1.5rem; cursor: pointer; } | |
| .search-input { | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| color: #ffffff; | |
| font-size: 1rem; | |
| font-family: 'Lora', serif; | |
| padding: 0.8rem; | |
| outline: none; | |
| width: 100%; | |
| border-radius: 4px; | |
| margin-bottom: 1rem; | |
| } | |
| .search-input:focus { border-color: #ffffff; background: rgba(255, 255, 255, 0.1); } | |
| .search-input::placeholder { color: rgba(255, 255, 255, 0.4); } | |
| .search-results { overflow-y: auto; flex-grow: 1; } | |
| .search-result-item { | |
| padding: 0.8rem; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
| cursor: pointer; | |
| color: #ffffff; | |
| display: flex; | |
| flex-direction: column; | |
| transition: background-color 0.2s; | |
| } | |
| .search-result-item:hover { background-color: rgba(255, 255, 255, 0.1); } | |
| .result-week { font-size: 0.75rem; color: rgba(255, 255, 255, 0.6); display: block; margin-bottom: 0.25rem; } | |
| .result-text { font-size: 0.95rem; line-height: 1.4; color: #ffffff; } | |
| .kbd-hint { | |
| font-size: 0.7rem; | |
| color: rgba(255, 255, 255, 0.5); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| padding: 2px 4px; | |
| border-radius: 3px; | |
| margin-left: auto; | |
| display: inline-block; | |
| } | |
| .toast-container { | |
| position: fixed; | |
| bottom: 2rem; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 3000; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| pointer-events: none; | |
| } | |
| .toast-message { | |
| background-color: var(--toast-bg); | |
| color: var(--toast-text); | |
| padding: 0.8rem 1.5rem; | |
| border-radius: 4px; | |
| font-size: 0.9rem; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
| animation: slideUp 0.3s ease forwards; | |
| opacity: 0; | |
| transform: translateY(20px); | |
| pointer-events: auto; | |
| } | |
| .toast-message.hide { | |
| opacity: 0 !important; | |
| transform: translateY(-10px) !important; | |
| animation: none !important; | |
| transition: opacity 0.3s ease, transform 0.3s ease; | |
| } | |
| @keyframes slideUp { | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: var(--modal-overlay-bg); | |
| z-index: 3000; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| opacity: 0; | |
| animation: fadeIn 0.2s ease forwards; | |
| } | |
| .modal-box { | |
| background-color: var(--page-bg-color); | |
| color: var(--primary-text-color); | |
| padding: 2rem; | |
| border-radius: 8px; | |
| border: 1px solid var(--border-color); | |
| max-width: 400px; | |
| width: 90%; | |
| box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); | |
| } | |
| .modal-title { | |
| font-size: 1.3rem; | |
| font-weight: bold; | |
| margin-bottom: 1rem; | |
| } | |
| .modal-content { | |
| margin-bottom: 1.5rem; | |
| line-height: 1.5; | |
| } | |
| .modal-actions { | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .modal-btn { | |
| padding: 0.5rem 1rem; | |
| border: 1px solid var(--border-color); | |
| border-radius: 4px; | |
| background: none; | |
| cursor: pointer; | |
| font-family: 'Lora', serif; | |
| color: var(--primary-text-color); | |
| transition: all 0.2s; | |
| } | |
| .modal-btn:hover { background-color: var(--hover-bg-color); } | |
| .modal-btn.danger { color: var(--accent-red-color); border-color: var(--accent-red-color); } | |
| .modal-btn.danger:hover { background-color: rgba(183, 74, 61, 0.1); } | |
| .modal-btn.secondary { color: var(--secondary-text-color); border-color: var(--border-color); } | |
| @media (max-width: 1200px) { | |
| .planner-page { padding: 1.5rem; } | |
| .planner-page.search-active { margin-right: 0; } | |
| .search-overlay { width: 100%; right: -100%; } | |
| .search-overlay.active { right: 0; } | |
| .week-grid.active { grid-template-columns: 1fr; border-left: none; } | |
| .day-column { border: 1px solid var(--border-color); border-top: none; } | |
| .day-column:first-child { border-top: 1px solid var(--border-color); } | |
| } | |
| @media print { | |
| .app-header button, .data-controls, .task-controls, .week-navigation, #themeBtn, .empty-state, .search-overlay, .toast-container { display: none !important; } | |
| body { background-color: white !important; color: black !important; margin: 0 !important; } | |
| .planner-page { background-color: white !important; color: black !important; height: auto; padding: 0; margin: 0 !important; border: none !important; box-shadow: none !important; } | |
| .app-header { border-bottom: 1px solid #000; } | |
| .week-grid.active { border: 1px solid #000; display: grid; grid-template-columns: repeat(7, 1fr); page-break-inside: avoid; opacity: 1 !important; } | |
| .day-column { border: 1px solid #ccc; break-inside: avoid; page-break-inside: avoid; background-color: white !important; } | |
| .task-item { border-bottom: 1px solid #eee; page-break-inside: avoid; } | |
| input[type="checkbox"] { appearance: none; -webkit-appearance: none; border: 1px solid #000; width: 12px; height: 12px; } | |
| input[type="checkbox"]:checked { background-color: #000; box-shadow: inset 0 0 0 2px white; } | |
| .task-text { color: black !important; } | |
| a[href]:after { content: none !important; } | |
| .month-grid { display: none !important; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="planner-page" id="mainContainer"> | |
| <header class="app-header"> | |
| <div class="header-left"> | |
| <div class="header-date"> | |
| <h1 id="yearMonthDisplay" title="날짜 선택"></h1> | |
| </div> | |
| </div> | |
| <div class="week-navigation"> | |
| <button id="prevWeekBtn"><</button> | |
| <button id="thisWeekBtn">이번 주</button> | |
| <button id="calendarBtn" class="calendar-btn">달력 이동</button> | |
| <button id="viewToggleBtn">달력 보기</button> | |
| <button id="searchBtn">검색</button> | |
| <button id="nextWeekBtn">></button> | |
| <input type="date" id="datePicker"> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="week-grid active" id="weekGrid"></div> | |
| <div class="month-grid" id="monthGrid"></div> | |
| <div class="data-controls"> | |
| <button id="migrateBtn" class="data-btn" title="지난 일정 이월하기">과거 미완료 할일 이월</button> | |
| <button id="exportBtn" class="data-btn" title="데이터를 파일로 저장합니다">백업</button> | |
| <button id="importBtn" class="data-btn" title="저장된 파일을 불러옵니다">복원</button> | |
| <button id="resetBtn" class="data-btn" title="모든 데이터를 초기화하고 샘플 데이터를 로드합니다">초기화</button> | |
| <button id="themeBtn" class="data-btn" title="화면 테마를 변경합니다">테마 변경</button> | |
| <input type="file" id="importFile" style="display: none;" accept=".json"> | |
| </div> | |
| </main> | |
| </div> | |
| <div id="searchSidebar" class="search-overlay"> | |
| <div class="search-header"> | |
| <h2 class="search-title">기록 검색</h2> | |
| <button id="closeSearch" class="close-search">×</button> | |
| </div> | |
| <input type="text" id="searchInput" class="search-input" placeholder="검색어를 입력하세요..."> | |
| <div class="kbd-hint">ESC로 닫기</div> | |
| <div id="searchResults" class="search-results"></div> | |
| </div> | |
| <script> | |
| window.requestIdleCallback = window.requestIdleCallback || function(cb) { | |
| return setTimeout(() => { | |
| const start = Date.now(); | |
| cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }); | |
| }, 50); | |
| }; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const getCssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); | |
| const CONFIG = { | |
| STORAGE_PREFIX: 'vp_', | |
| RULES_KEY: 'vp_recurring_rules', | |
| INITIALIZED_KEY: 'vp_initialized', | |
| DAY_PREFIX: 'day-', | |
| SIDEBAR_WIDTH: 320, | |
| DEBOUNCE_DELAY: 300, | |
| CHUNK_SIZE: 20, | |
| ANIMATION_DURATION: parseInt(getCssVar('--anim-duration')) || 300, | |
| HIGHLIGHT_DURATION: parseInt(getCssVar('--highlight-duration')) || 2000, | |
| TOAST_DURATION: parseInt(getCssVar('--toast-duration')) || 3000 | |
| }; | |
| const MESSAGES = { | |
| EMPTY_STATE: "새로운 모험을 기록하세요...", | |
| MIGRATE_SUCCESS: (count) => `${count}개의 할 일을 이월했습니다.`, | |
| MIGRATE_EMPTY: "이월할 미완료 할 일이 없습니다.", | |
| RESET_CONFIRM_TITLE: "데이터 초기화", | |
| RESET_CONFIRM_BODY: "모든 데이터를 삭제하고 샘플 데이터를 로드하시겠습니까?", | |
| RESET_FINAL_CONFIRM_TITLE: "최종 확인", | |
| RESET_FINAL_CONFIRM_BODY: "정말로 초기화하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.", | |
| DELETE_CONFIRM_TITLE: "할 일 삭제", | |
| DELETE_CONFIRM_BODY: "이 항목을 삭제하시겠습니까?", | |
| DELETE_RECURRING_TITLE: "반복 일정 삭제", | |
| DELETE_RECURRING_BODY: "이 일정은 반복 일정의 일부입니다. 어떻게 삭제하시겠습니까?", | |
| SEARCH_EMPTY: '<div class="search-result-item">결과가 없습니다.</div>', | |
| FILE_ERROR: "파일 오류: ", | |
| IMPORT_SUCCESS: "데이터 복원 완료!", | |
| IMPORT_ERROR: "잘못된 데이터 형식입니다.", | |
| BACKUP_FILENAME: (date) => `vintage_planner_backup_${date}.json`, | |
| STORAGE_ERROR: "저장 용량이 가득 찼습니다. 데이터를 백업하고 정리해주세요.", | |
| REPEAT_SET: (type) => `반복 주기가 [${type}]로 설정되었습니다.` | |
| }; | |
| const CLASSES = { | |
| DAY_COLUMN: 'day-column', | |
| DAY_HEADER: 'day-header', | |
| TASK_LIST: 'task-list', | |
| TASK_ITEM: 'task-item', | |
| TASK_TEXT: 'task-text', | |
| DELETE_BTN: 'delete-task', | |
| REPEAT_BTN: 'repeat-btn', | |
| REPEAT_MENU: 'repeat-menu', | |
| REPEAT_OPTION: 'repeat-option', | |
| REPEAT_ACTIVE: 'active', | |
| DRAGGING: 'dragging', | |
| TODAY: 'today', | |
| SUNDAY: 'sunday', | |
| SATURDAY: 'saturday', | |
| FLASH_ACTIVE: 'flash-active', | |
| DARK_MODE: 'dark-mode', | |
| HIGHLIGHT_TASK: 'highlight-task', | |
| TODAY_DATE_HIGHLIGHT: 'today-date-highlight', | |
| TODAY_NAME_HIGHLIGHT: 'today-name-highlight', | |
| DRAG_PLACEHOLDER: 'drag-placeholder', | |
| DRAG_GHOST: 'drag-ghost', | |
| EMPTY_STATE: 'empty-state', | |
| SEARCH_ACTIVE: 'search-active', | |
| EDITING: 'editing', | |
| TODAY_FLASH: 'today-flash', | |
| RECURRING: 'recurring' | |
| }; | |
| const DAY_NAMES = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; | |
| const REPEAT_TYPES = { | |
| 'none': '없음', | |
| 'daily': '매일', | |
| 'weekday': '평일(월~금)', | |
| 'weekend': '주말(토,일)', | |
| 'weekly': '매주', | |
| 'biweekly': '격주', | |
| 'monthly': '매월', | |
| 'monthly_weekday': '매월 같은 요일', | |
| 'month_end': '매월 말일', | |
| 'quarterly': '분기별', | |
| 'half_yearly': '6개월마다', | |
| 'yearly': '매년' | |
| }; | |
| const getDayKey = (index) => `${CONFIG.DAY_PREFIX}${index}`; | |
| class DateService { | |
| static toLocalISOString(date) { | |
| const offset = date.getTimezoneOffset() * 60000; | |
| return new Date(date.getTime() - offset).toISOString().slice(0, 10); | |
| } | |
| static fromLocalISOString(dateStr) { | |
| const [y, m, d] = dateStr.split('-').map(Number); | |
| return new Date(y, m - 1, d); | |
| } | |
| static getStartOfWeek(date) { | |
| const d = new Date(date); | |
| d.setHours(0, 0, 0, 0); | |
| const day = d.getDay(); | |
| const diff = d.getDate() - day; | |
| d.setDate(diff); | |
| return d; | |
| } | |
| static getLastDayOfMonth(date) { | |
| return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); | |
| } | |
| static getWeekId(date) { | |
| const start = this.getStartOfWeek(date); | |
| return this.toLocalISOString(start); | |
| } | |
| } | |
| class NotificationManager { | |
| constructor() { | |
| this.toastContainer = document.createElement('div'); | |
| this.toastContainer.className = 'toast-container'; | |
| document.body.appendChild(this.toastContainer); | |
| } | |
| showToast(message) { | |
| const toast = document.createElement('div'); | |
| toast.className = 'toast-message'; | |
| toast.textContent = message; | |
| this.toastContainer.prepend(toast); | |
| setTimeout(() => { | |
| toast.classList.add('hide'); | |
| setTimeout(() => { | |
| if (toast.parentElement) toast.remove(); | |
| }, 500); | |
| }, CONFIG.TOAST_DURATION); | |
| } | |
| confirm(title, body) { | |
| return new Promise((resolve) => { | |
| this.createModal(title, body, [ | |
| { text: '취소', class: 'modal-btn', value: false }, | |
| { text: '확인', class: 'modal-btn danger', value: true } | |
| ], resolve); | |
| }); | |
| } | |
| showChoiceModal(title, body, choices) { | |
| return new Promise((resolve) => { | |
| this.createModal(title, body, choices, resolve); | |
| }); | |
| } | |
| createModal(title, body, buttons, resolve) { | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'modal-overlay'; | |
| const box = document.createElement('div'); | |
| box.className = 'modal-box'; | |
| let buttonsHtml = ''; | |
| buttons.forEach((btn, idx) => { | |
| buttonsHtml += `<button class="${btn.class}" data-idx="${idx}">${btn.text}</button>`; | |
| }); | |
| box.innerHTML = ` | |
| <div class="modal-title">${title}</div> | |
| <div class="modal-content">${body}</div> | |
| <div class="modal-actions">${buttonsHtml}</div> | |
| `; | |
| overlay.appendChild(box); | |
| document.body.appendChild(overlay); | |
| const cleanup = () => { | |
| overlay.style.opacity = '0'; | |
| setTimeout(() => overlay.remove(), 200); | |
| }; | |
| box.querySelectorAll('button').forEach(btn => { | |
| btn.onclick = () => { | |
| cleanup(); | |
| resolve(buttons[btn.dataset.idx].value); | |
| }; | |
| }); | |
| overlay.onclick = (e) => { | |
| if (e.target === overlay) { | |
| cleanup(); | |
| resolve(null); // Dismissed | |
| } | |
| }; | |
| } | |
| } | |
| class DataManager { | |
| constructor(planner, storage, notifications) { | |
| this.planner = planner; | |
| this.storage = storage; | |
| this.notifications = notifications; | |
| } | |
| exportData() { | |
| const data = {}; | |
| this.storage.getAllKeys().forEach(key => { | |
| data[key] = localStorage.getItem(key); | |
| }); | |
| const now = new Date(); | |
| const localISOTime = DateService.toLocalISOString(now); | |
| const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = MESSAGES.BACKUP_FILENAME(localISOTime); | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| importData(file) { | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const json = e.target.result; | |
| const data = JSON.parse(json); | |
| if (!this.validateImportSchema(data)) { | |
| throw new Error("Invalid Schema"); | |
| } | |
| this.storage.reset(); | |
| for (const key in data) { | |
| localStorage.setItem(key, data[key]); | |
| } | |
| this.storage.setInitialized(); | |
| this.notifications.showToast(MESSAGES.IMPORT_SUCCESS); | |
| setTimeout(() => location.reload(), 1000); | |
| } catch (err) { | |
| this.notifications.showToast(MESSAGES.IMPORT_ERROR + " (" + err.message + ")"); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| validateImportSchema(data) { | |
| if (typeof data !== 'object' || data === null) return false; | |
| const keys = Object.keys(data); | |
| if (keys.length === 0) return true; | |
| return keys.every(k => { | |
| const validKey = k.startsWith(CONFIG.STORAGE_PREFIX) || k === CONFIG.INITIALIZED_KEY; | |
| if (!validKey) return false; | |
| if (k === CONFIG.INITIALIZED_KEY || k === CONFIG.RULES_KEY) return true; | |
| try { | |
| const weekData = JSON.parse(data[k]); | |
| if (typeof weekData !== 'object' || weekData === null) return false; | |
| return Object.keys(weekData).every(dayKey => { | |
| if (!dayKey.startsWith(CONFIG.DAY_PREFIX)) return false; | |
| const tasks = weekData[dayKey]; | |
| if (!Array.isArray(tasks)) return false; | |
| return tasks.every(task => | |
| typeof task.id === 'string' && | |
| typeof task.text === 'string' && | |
| typeof task.completed === 'boolean' | |
| ); | |
| }); | |
| } catch (e) { | |
| return false; | |
| } | |
| }); | |
| } | |
| migrateTasks() { | |
| const currentWeekId = this.planner.currentWeekId; | |
| let count = 0; | |
| const targetDay = new Date().getDay(); | |
| const allKeys = this.storage.getAllKeys(); | |
| allKeys.forEach(key => { | |
| if (key === CONFIG.RULES_KEY) return; | |
| const wId = key.replace(CONFIG.STORAGE_PREFIX, ''); | |
| if (this.compareWeekIds(wId, currentWeekId) < 0) { | |
| const data = this.storage.getWeekData(wId); | |
| let modified = false; | |
| if (data && typeof data === 'object') { | |
| Object.keys(data).forEach(dKey => { | |
| if (Array.isArray(data[dKey])) { | |
| const incomplete = data[dKey].filter(t => !t.completed); | |
| if (incomplete.length > 0) { | |
| incomplete.forEach(t => { | |
| const newTask = { ...t, id: this.planner.generateUUID() }; | |
| this.planner.cachedWeekData[getDayKey(targetDay)].push(newTask); | |
| count++; | |
| }); | |
| data[dKey] = data[dKey].filter(t => t.completed); | |
| modified = true; | |
| } | |
| } | |
| }); | |
| if (modified) this.storage.saveWeekData(wId, data); | |
| } | |
| } | |
| }); | |
| if (count > 0) { | |
| this.planner.ui.renderTasks(targetDay, this.planner.cachedWeekData[getDayKey(targetDay)]); | |
| this.planner.saveData(); | |
| this.notifications.showToast(MESSAGES.MIGRATE_SUCCESS(count)); | |
| } else { | |
| this.notifications.showToast(MESSAGES.MIGRATE_EMPTY); | |
| } | |
| } | |
| compareWeekIds(id1, id2) { | |
| const date1 = DateService.fromLocalISOString(id1).getTime(); | |
| const date2 = DateService.fromLocalISOString(id2).getTime(); | |
| if (isNaN(date1) || isNaN(date2)) return 0; | |
| return date1 - date2; | |
| } | |
| } | |
| class RecurringHandler { | |
| constructor(context) { | |
| this.context = context; | |
| } | |
| setRepeatRule(task, type, dayIndex, currentWeekStart) { | |
| let rules = this.context.getRules(); | |
| rules = rules.filter(r => r.originTaskId !== task.id); | |
| if (type !== 'none') { | |
| const startDate = new Date(currentWeekStart); | |
| startDate.setDate(startDate.getDate() + parseInt(dayIndex)); | |
| rules.push({ | |
| id: `rule-${Date.now()}`, | |
| originTaskId: task.id, | |
| text: task.text, | |
| type: type, | |
| startDate: DateService.toLocalISOString(startDate), | |
| dayIndex: parseInt(dayIndex), | |
| exceptions: [] | |
| }); | |
| } | |
| this.context.saveRules(rules); | |
| return rules; | |
| } | |
| addException(ruleId, dateStr) { | |
| const rules = this.context.getRules(); | |
| const rule = rules.find(r => r.id === ruleId); | |
| if (rule) { | |
| if (!rule.exceptions) rule.exceptions = []; | |
| if (!rule.exceptions.includes(dateStr)) { | |
| rule.exceptions.push(dateStr); | |
| this.context.saveRules(rules); | |
| } | |
| } | |
| } | |
| applyRules(weekId, weekData) { | |
| const rules = this.context.getRules(); | |
| if (!rules || rules.length === 0) return weekData; | |
| const weekStart = DateService.fromLocalISOString(weekId); | |
| for (let i = 0; i < 7; i++) { | |
| const currentDate = new Date(weekStart); | |
| currentDate.setDate(weekStart.getDate() + i); | |
| const dateStr = DateService.toLocalISOString(currentDate); | |
| const dayKey = getDayKey(i); | |
| rules.forEach(rule => { | |
| if (rule.exceptions && rule.exceptions.includes(dateStr)) return; | |
| const ruleStart = DateService.fromLocalISOString(rule.startDate.substring(0, 10)); | |
| const startMidnight = new Date(ruleStart); | |
| startMidnight.setHours(0,0,0,0); | |
| const currentMidnight = new Date(currentDate); | |
| currentMidnight.setHours(0,0,0,0); | |
| if (currentMidnight < startMidnight) return; | |
| let shouldAdd = false; | |
| if (rule.type === 'daily') shouldAdd = true; | |
| else if (rule.type === 'weekday' && i >= 1 && i <= 5) shouldAdd = true; | |
| else if (rule.type === 'weekend' && (i === 0 || i === 6)) shouldAdd = true; | |
| else if (rule.type === 'weekly' && rule.dayIndex === i) shouldAdd = true; | |
| else if (rule.type === 'biweekly' && rule.dayIndex === i) { | |
| const msPerDay = 24 * 60 * 60 * 1000; | |
| const diffDays = Math.floor((currentMidnight.getTime() - startMidnight.getTime()) / msPerDay); | |
| const diffWeeks = Math.floor(diffDays / 7); | |
| if (diffWeeks % 2 === 0) shouldAdd = true; | |
| } | |
| else if (rule.type === 'monthly') { | |
| // [FIX] Improved Month End / Revert Logic | |
| const targetDay = ruleStart.getDate(); | |
| const maxDayInCurrentMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate(); | |
| const actualTargetDay = Math.min(targetDay, maxDayInCurrentMonth); | |
| if (currentDate.getDate() === actualTargetDay) shouldAdd = true; | |
| } | |
| else if (rule.type === 'monthly_weekday') { | |
| if (currentDate.getDay() === ruleStart.getDay()) { | |
| const nthWeekCurrent = Math.ceil(currentDate.getDate() / 7); | |
| const nthWeekStart = Math.ceil(ruleStart.getDate() / 7); | |
| if (nthWeekCurrent === nthWeekStart) shouldAdd = true; | |
| } | |
| } | |
| else if (rule.type === 'month_end') { | |
| const lastDayOfMonth = DateService.getLastDayOfMonth(currentDate); | |
| if (currentDate.getDate() === lastDayOfMonth) shouldAdd = true; | |
| } | |
| else if (rule.type === 'quarterly') { | |
| const diffMonths = (currentDate.getFullYear() - ruleStart.getFullYear()) * 12 + (currentDate.getMonth() - ruleStart.getMonth()); | |
| if (diffMonths % 3 === 0 && currentDate.getDate() === ruleStart.getDate()) { | |
| shouldAdd = true; | |
| } | |
| } | |
| else if (rule.type === 'half_yearly') { | |
| const diffMonths = (currentDate.getFullYear() - ruleStart.getFullYear()) * 12 + (currentDate.getMonth() - ruleStart.getMonth()); | |
| if (diffMonths > 0 && diffMonths % 6 === 0 && currentDate.getDate() === ruleStart.getDate()) { | |
| shouldAdd = true; | |
| } | |
| } | |
| else if (rule.type === 'yearly') { | |
| if (currentDate.getMonth() === ruleStart.getMonth() && | |
| currentDate.getDate() === ruleStart.getDate()) { | |
| shouldAdd = true; | |
| } | |
| } | |
| if (shouldAdd) { | |
| const generatedId = `task-${rule.id}-${dateStr}`; | |
| const exists = weekData[dayKey].some(t => t.id === generatedId || t.id === rule.originTaskId); | |
| if (!exists) { | |
| weekData[dayKey].push({ | |
| id: generatedId, | |
| text: rule.text, | |
| completed: false, | |
| repeat: rule.type | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| return weekData; | |
| } | |
| } | |
| class StorageManager { | |
| constructor(notifications) { | |
| this.cachedData = {}; | |
| this.searchWorker = this.initSearchWorker(); | |
| this.notifications = notifications; | |
| } | |
| initSearchWorker() { | |
| const workerScript = ` | |
| let searchCache = []; | |
| self.onmessage = function(e) { | |
| const { type, payload } = e.data; | |
| if (type === 'BUILD_CHUNK') { | |
| const { dataMap, prefix } = payload; | |
| Object.entries(dataMap).forEach(([key, rawTasks]) => { | |
| // [FIX] Parsing inside worker to save main thread | |
| try { | |
| const tasks = JSON.parse(rawTasks); | |
| const weekId = key.replace(prefix, ''); | |
| addToCache(weekId, tasks); | |
| } catch(e) {} | |
| }); | |
| } else if (type === 'UPDATE_WEEK') { | |
| const { weekId, tasks } = payload; | |
| searchCache = searchCache.filter(item => item.weekId !== weekId); | |
| addToCache(weekId, tasks); | |
| } else if (type === 'INDEX_FINISH') { | |
| self.postMessage({ type: 'INDEX_READY', payload: searchCache }); | |
| } else if (type === 'SEARCH') { | |
| const { query } = payload; | |
| const q = query.toLowerCase(); | |
| const results = searchCache.filter(item => item.text.includes(q)); | |
| self.postMessage({ type: 'SEARCH_RESULTS', payload: results }); | |
| } | |
| }; | |
| function addToCache(weekId, weekData) { | |
| if(weekData && typeof weekData === 'object') { | |
| Object.entries(weekData).forEach(([dayKey, dayTasks]) => { | |
| if(Array.isArray(dayTasks)) { | |
| dayTasks.forEach(task => { | |
| searchCache.push({ | |
| weekId: weekId, | |
| dayIndex: parseInt(dayKey.split('-')[1]), | |
| text: task.text.toLowerCase(), | |
| originalText: task.text, | |
| id: task.id | |
| }); | |
| }); | |
| } | |
| }); | |
| } | |
| } | |
| `; | |
| const blob = new Blob([workerScript], { type: 'application/javascript' }); | |
| const blobUrl = URL.createObjectURL(blob); | |
| const worker = new Worker(blobUrl); | |
| URL.revokeObjectURL(blobUrl); | |
| return worker; | |
| } | |
| getWeekData(weekId) { | |
| if (this.cachedData[weekId]) { | |
| return this.cachedData[weekId]; | |
| } | |
| const raw = localStorage.getItem(CONFIG.STORAGE_PREFIX + weekId); | |
| try { | |
| const data = raw ? JSON.parse(raw) : null; | |
| if (data) this.cachedData[weekId] = data; | |
| return data; | |
| } catch (e) { | |
| console.error("Data corruption detected for week:", weekId, e); | |
| return null; | |
| } | |
| } | |
| saveWeekData(weekId, data) { | |
| this.cachedData[weekId] = data; | |
| try { | |
| localStorage.setItem(CONFIG.STORAGE_PREFIX + weekId, JSON.stringify(data)); | |
| this.searchWorker.postMessage({ | |
| type: 'UPDATE_WEEK', | |
| payload: { weekId, tasks: data } | |
| }); | |
| } catch (e) { | |
| if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') { | |
| if (this.notifications) this.notifications.showToast(MESSAGES.STORAGE_ERROR); | |
| } else { | |
| console.error("Storage Error:", e); | |
| } | |
| } | |
| } | |
| getRecurringRules() { | |
| try { | |
| return JSON.parse(localStorage.getItem(CONFIG.RULES_KEY)) || []; | |
| } catch { return []; } | |
| } | |
| saveRecurringRules(rules) { | |
| localStorage.setItem(CONFIG.RULES_KEY, JSON.stringify(rules)); | |
| } | |
| isInitialized() { | |
| return localStorage.getItem(CONFIG.INITIALIZED_KEY) === 'true'; | |
| } | |
| setInitialized() { | |
| localStorage.setItem(CONFIG.INITIALIZED_KEY, 'true'); | |
| } | |
| getAllKeys() { | |
| const keys = []; | |
| for (let i = 0; i < localStorage.length; i++) { | |
| const key = localStorage.key(i); | |
| if (key.startsWith(CONFIG.STORAGE_PREFIX) && key !== CONFIG.INITIALIZED_KEY && key !== CONFIG.RULES_KEY) { | |
| keys.push(key); | |
| } | |
| } | |
| return keys; | |
| } | |
| triggerIndexBuild() { | |
| const allKeys = this.getAllKeys(); | |
| let index = 0; | |
| const processChunk = () => { | |
| if (index >= allKeys.length) { | |
| this.searchWorker.postMessage({ type: 'INDEX_FINISH' }); | |
| return; | |
| } | |
| const chunkMap = {}; | |
| const limit = Math.min(index + CONFIG.CHUNK_SIZE, allKeys.length); | |
| for (; index < limit; index++) { | |
| const key = allKeys[index]; | |
| try { | |
| // [FIX] Optimization: Pass raw string to worker instead of parsing in main thread | |
| chunkMap[key] = localStorage.getItem(key); | |
| } catch (e) { console.error("Cache Load Error", e); } | |
| } | |
| this.searchWorker.postMessage({ | |
| type: 'BUILD_CHUNK', | |
| payload: { dataMap: chunkMap, prefix: CONFIG.STORAGE_PREFIX } | |
| }); | |
| if (window.requestIdleCallback) { | |
| requestIdleCallback(processChunk); | |
| } else { | |
| setTimeout(processChunk, 0); | |
| } | |
| }; | |
| processChunk(); | |
| } | |
| reset() { | |
| this.getAllKeys().forEach(key => localStorage.removeItem(key)); | |
| localStorage.removeItem(CONFIG.INITIALIZED_KEY); | |
| localStorage.removeItem(CONFIG.RULES_KEY); | |
| this.cachedData = {}; | |
| } | |
| } | |
| class UIManager { | |
| constructor(containerId) { | |
| this.weekGrid = document.getElementById(containerId); | |
| this.monthGrid = document.getElementById('monthGrid'); | |
| this.dayLists = []; | |
| this.dayHeaders = []; | |
| this.initGrid(); | |
| } | |
| initGrid() { | |
| const fragment = document.createDocumentFragment(); | |
| for (let i = 0; i < 7; i++) { | |
| const dayColumn = document.createElement('div'); | |
| dayColumn.classList.add(CLASSES.DAY_COLUMN); | |
| dayColumn.dataset.dayIndex = i; | |
| dayColumn.tabIndex = -1; | |
| const header = document.createElement('div'); | |
| header.classList.add(CLASSES.DAY_HEADER); | |
| if (i === 0) header.classList.add(CLASSES.SUNDAY); | |
| if (i === 6) header.classList.add(CLASSES.SATURDAY); | |
| header.innerHTML = `<span class="date"></span><span class="day-name"></span>`; | |
| const taskList = document.createElement('ul'); | |
| taskList.classList.add(CLASSES.TASK_LIST); | |
| taskList.id = `task-list-${i}`; | |
| this.dayLists.push(taskList); | |
| this.dayHeaders.push(header); | |
| dayColumn.append(header, taskList); | |
| fragment.appendChild(dayColumn); | |
| } | |
| this.weekGrid.appendChild(fragment); | |
| } | |
| animateGrid(direction, callback) { | |
| const outClass = direction === 'next' ? 'anim-slide-out-left' : 'anim-slide-out-right'; | |
| const inClass = direction === 'next' ? 'anim-slide-in-right' : 'anim-slide-in-left'; | |
| const targetGrid = this.weekGrid.classList.contains('active') ? this.weekGrid : this.monthGrid; | |
| targetGrid.classList.add(outClass); | |
| setTimeout(() => { | |
| callback(); | |
| targetGrid.classList.remove(outClass); | |
| targetGrid.classList.add(inClass); | |
| setTimeout(() => { | |
| targetGrid.classList.remove(inClass); | |
| }, 200); | |
| }, 200); | |
| } | |
| updateDates(startOfWeek, today) { | |
| for (let i = 0; i < 7; i++) { | |
| const colDate = new Date(startOfWeek); | |
| colDate.setDate(startOfWeek.getDate() + i); | |
| const header = this.dayHeaders[i]; | |
| const dayColumn = header.parentElement; | |
| const dateSpan = header.querySelector('.date'); | |
| const nameSpan = header.querySelector('.day-name'); | |
| if (colDate.toDateString() === today.toDateString()) { | |
| dayColumn.classList.add(CLASSES.TODAY); | |
| dateSpan.classList.add(CLASSES.TODAY_DATE_HIGHLIGHT); | |
| nameSpan.classList.add(CLASSES.TODAY_NAME_HIGHLIGHT); | |
| } else { | |
| dayColumn.classList.remove(CLASSES.TODAY); | |
| dateSpan.classList.remove(CLASSES.TODAY_DATE_HIGHLIGHT); | |
| nameSpan.classList.remove(CLASSES.TODAY_NAME_HIGHLIGHT); | |
| } | |
| dateSpan.textContent = colDate.getDate(); | |
| nameSpan.textContent = DAY_NAMES[i].substring(0, 1); | |
| } | |
| } | |
| escapeHtml(text) { | |
| if (!text) return ''; | |
| return text.replace(/[&<>"']/g, function(m) { | |
| return { | |
| '&': '&', | |
| '<': '<', | |
| '>': '>', | |
| '"': '"', | |
| "'": ''' | |
| }[m]; | |
| }); | |
| } | |
| renderMarkdown(container, text) { | |
| if (container.dataset.rawText === text) return; | |
| container.dataset.rawText = text || ''; | |
| if (!text) { | |
| container.innerHTML = ''; | |
| return; | |
| } | |
| // [FIX] XSS Prevention: Escape first, then format. Whitelist approach. | |
| let html = this.escapeHtml(text); | |
| html = html.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>'); | |
| html = html.replace(/_(.*?)_/g, '<i>$1</i>'); | |
| html = html.replace(/~~(.*?)~~/g, '<s>$1</s>'); | |
| container.innerHTML = html; | |
| } | |
| renderTasks(dayIndex, tasks) { | |
| const list = this.dayLists[dayIndex]; | |
| const activeEl = document.activeElement; | |
| const emptyMsg = list.querySelector(`.${CLASSES.EMPTY_STATE}`); | |
| if (tasks.length === 0) { | |
| if (!emptyMsg || list.children.length > 1) { | |
| list.innerHTML = ''; | |
| const div = document.createElement('div'); | |
| div.classList.add(CLASSES.EMPTY_STATE); | |
| div.textContent = MESSAGES.EMPTY_STATE; | |
| list.appendChild(div); | |
| } | |
| return; | |
| } else if (emptyMsg) { | |
| emptyMsg.remove(); | |
| } | |
| const currentItems = Array.from(list.querySelectorAll(`.${CLASSES.TASK_ITEM}`)); | |
| const currentMap = new Map(currentItems.map(el => [el.id, el])); | |
| const newIds = new Set(tasks.map(t => t.id)); | |
| currentItems.forEach(el => { | |
| if (!newIds.has(el.id)) el.remove(); | |
| }); | |
| let previousNode = null; | |
| tasks.forEach((task, index) => { | |
| let item = currentMap.get(task.id); | |
| if (item) { | |
| const checkbox = item.querySelector('input[type="checkbox"]'); | |
| if (checkbox.checked !== !!task.completed) checkbox.checked = !!task.completed; | |
| const textEl = item.querySelector(`.${CLASSES.TASK_TEXT}`); | |
| if (activeEl !== textEl) { | |
| this.renderMarkdown(textEl, task.text); | |
| } | |
| const repeatBtn = item.querySelector(`.${CLASSES.REPEAT_BTN}`); | |
| if (repeatBtn) { | |
| if (task.repeat && task.repeat !== 'none') { | |
| repeatBtn.classList.add(CLASSES.REPEAT_ACTIVE); | |
| item.classList.add(CLASSES.RECURRING); | |
| if (!item.querySelector('.recurring-indicator')) { | |
| const icon = document.createElement('span'); | |
| icon.className = 'recurring-indicator'; | |
| icon.innerHTML = '↻'; | |
| item.insertBefore(icon, textEl); | |
| } | |
| } else { | |
| repeatBtn.classList.remove(CLASSES.REPEAT_ACTIVE); | |
| item.classList.remove(CLASSES.RECURRING); | |
| const icon = item.querySelector('.recurring-indicator'); | |
| if (icon) icon.remove(); | |
| } | |
| } | |
| if (index === 0) { | |
| if (list.firstChild !== item) list.prepend(item); | |
| } else { | |
| if (previousNode.nextSibling !== item) { | |
| previousNode.after(item); | |
| } | |
| } | |
| } else { | |
| item = this.createTaskElement(task); | |
| if (index === 0) { | |
| list.prepend(item); | |
| } else { | |
| previousNode.after(item); | |
| } | |
| } | |
| previousNode = item; | |
| }); | |
| } | |
| createTaskElement(task) { | |
| const item = document.createElement('li'); | |
| item.classList.add(CLASSES.TASK_ITEM); | |
| item.draggable = true; | |
| item.id = task.id; | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.checked = task.completed; | |
| checkbox.dataset.action = 'toggle'; | |
| const textSpan = document.createElement('span'); | |
| textSpan.classList.add(CLASSES.TASK_TEXT); | |
| textSpan.contentEditable = true; | |
| this.renderMarkdown(textSpan, task.text); | |
| if (task.repeat && task.repeat !== 'none') { | |
| item.classList.add(CLASSES.RECURRING); | |
| const icon = document.createElement('span'); | |
| icon.className = 'recurring-indicator'; | |
| icon.innerHTML = '↻'; | |
| item.append(checkbox, icon, textSpan); | |
| } else { | |
| item.append(checkbox, textSpan); | |
| } | |
| const controlsDiv = document.createElement('div'); | |
| controlsDiv.className = 'task-controls'; | |
| const repeatBtn = document.createElement('button'); | |
| repeatBtn.className = CLASSES.REPEAT_BTN; | |
| repeatBtn.innerHTML = '↻'; | |
| repeatBtn.title = '반복 설정'; | |
| if (task.repeat && task.repeat !== 'none') repeatBtn.classList.add(CLASSES.REPEAT_ACTIVE); | |
| repeatBtn.dataset.action = 'repeat'; | |
| const repeatMenu = document.createElement('div'); | |
| repeatMenu.className = CLASSES.REPEAT_MENU; | |
| Object.keys(REPEAT_TYPES).forEach(type => { | |
| const option = document.createElement('button'); | |
| option.className = CLASSES.REPEAT_OPTION; | |
| option.textContent = REPEAT_TYPES[type]; | |
| option.dataset.type = type; | |
| if (task.repeat === type) option.classList.add('selected'); | |
| repeatMenu.appendChild(option); | |
| }); | |
| controlsDiv.appendChild(repeatBtn); | |
| controlsDiv.appendChild(repeatMenu); | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.classList.add(CLASSES.DELETE_BTN); | |
| deleteBtn.textContent = '×'; | |
| deleteBtn.dataset.action = 'delete'; | |
| controlsDiv.appendChild(deleteBtn); | |
| item.appendChild(controlsDiv); | |
| return item; | |
| } | |
| } | |
| class DragDropHandler { | |
| constructor(context) { | |
| this.context = context; | |
| this.dragAF = null; | |
| this.dragPlaceholder = null; | |
| this.draggedItemMeta = null; | |
| this.cachedPositions = {}; | |
| } | |
| handleDragStart(e) { | |
| if (!e.target.classList.contains(CLASSES.TASK_ITEM)) return; | |
| const item = e.target; | |
| const dayIndex = item.closest(`.${CLASSES.DAY_COLUMN}`).dataset.dayIndex; | |
| const task = this.context.getTask(dayIndex, item.id); | |
| const taskIndex = this.context.getTasks(dayIndex).indexOf(task); | |
| const activeEl = document.activeElement; | |
| if (activeEl && activeEl.classList.contains(CLASSES.TASK_TEXT)) { | |
| this.context.setFocusedTask(activeEl.closest(`.${CLASSES.TASK_ITEM}`).id); | |
| } else { | |
| this.context.setFocusedTask(null); | |
| } | |
| this.draggedItemMeta = { | |
| id: item.id, | |
| sourceDayIndex: dayIndex, | |
| sourceIndex: taskIndex, | |
| taskData: { ...task } | |
| }; | |
| e.target.classList.add(CLASSES.DRAGGING); | |
| e.dataTransfer.effectAllowed = 'move'; | |
| this.cachePositions(); | |
| const ghost = item.cloneNode(true); | |
| ghost.classList.add(CLASSES.DRAG_GHOST); | |
| ghost.style.width = item.offsetWidth + "px"; | |
| document.body.appendChild(ghost); | |
| e.dataTransfer.setDragImage(ghost, 0, 0); | |
| setTimeout(() => document.body.removeChild(ghost), 0); | |
| } | |
| cachePositions() { | |
| this.cachedPositions = {}; | |
| const lists = document.querySelectorAll(`.${CLASSES.TASK_LIST}`); | |
| lists.forEach(list => { | |
| this.cachedPositions[list.id] = []; | |
| const children = Array.from(list.children).filter(el => | |
| el.classList.contains(CLASSES.TASK_ITEM) && !el.classList.contains(CLASSES.DRAGGING) | |
| ); | |
| children.forEach(child => { | |
| const rect = child.getBoundingClientRect(); | |
| this.cachedPositions[list.id].push({ | |
| element: child, | |
| list: list, | |
| top: rect.top, | |
| height: rect.height, | |
| mid: rect.top + rect.height / 2 | |
| }); | |
| }); | |
| }); | |
| } | |
| handleDragOver(e) { | |
| e.preventDefault(); | |
| if (this.dragAF) return; | |
| this.dragAF = requestAnimationFrame(() => { | |
| const list = e.target.closest(`.${CLASSES.TASK_LIST}`); | |
| if (list) { | |
| if (!this.dragPlaceholder) { | |
| this.dragPlaceholder = document.createElement('div'); | |
| this.dragPlaceholder.className = CLASSES.DRAG_PLACEHOLDER; | |
| } | |
| const afterElement = this.getDragAfterElement(list, e.clientY); | |
| if (afterElement == null) { | |
| list.appendChild(this.dragPlaceholder); | |
| } else { | |
| list.insertBefore(this.dragPlaceholder, afterElement); | |
| } | |
| } | |
| this.dragAF = null; | |
| }); | |
| } | |
| handleDrop(e) { | |
| e.preventDefault(); | |
| if(this.dragAF) { | |
| cancelAnimationFrame(this.dragAF); | |
| this.dragAF = null; | |
| } | |
| const list = e.target.closest(`.${CLASSES.TASK_LIST}`); | |
| const meta = this.draggedItemMeta; | |
| if (list && meta) { | |
| const targetDayIndex = list.id.split('-')[1]; | |
| const children = Array.from(list.children).filter(el => | |
| el !== this.dragPlaceholder && !el.classList.contains(CLASSES.DRAGGING) && el.classList.contains(CLASSES.TASK_ITEM) | |
| ); | |
| let insertIndex = children.length; | |
| this.cachePositions(); | |
| const afterElement = this.getDragAfterElement(list, e.clientY); | |
| if (afterElement) insertIndex = children.indexOf(afterElement); | |
| const item = document.getElementById(meta.id); | |
| if (this.dragPlaceholder && this.dragPlaceholder.parentNode === list) { | |
| list.insertBefore(item, this.dragPlaceholder); | |
| } | |
| const sourceTasks = this.context.getTasks(meta.sourceDayIndex); | |
| if (meta.sourceDayIndex === targetDayIndex) { | |
| sourceTasks.splice(meta.sourceIndex, 1); | |
| if (meta.sourceIndex < insertIndex) { | |
| insertIndex--; | |
| } | |
| sourceTasks.splice(insertIndex, 0, meta.taskData); | |
| this.context.save(); | |
| this.context.render(targetDayIndex, sourceTasks); | |
| } else { | |
| const targetTasks = this.context.getTasks(targetDayIndex); | |
| sourceTasks.splice(meta.sourceIndex, 1); | |
| targetTasks.splice(insertIndex, 0, meta.taskData); | |
| this.context.save(); | |
| this.context.render(meta.sourceDayIndex, sourceTasks); | |
| this.context.render(targetDayIndex, targetTasks); | |
| } | |
| } | |
| this.handleDragEnd(); | |
| } | |
| handleDragEnd() { | |
| if (this.dragPlaceholder) { | |
| this.dragPlaceholder.remove(); | |
| this.dragPlaceholder = null; | |
| } | |
| const dragging = document.querySelector(`.${CLASSES.DRAGGING}`); | |
| if (dragging) dragging.classList.remove(CLASSES.DRAGGING); | |
| this.cachedPositions = {}; | |
| const focusedId = this.context.getFocusedTask(); | |
| if (focusedId) { | |
| const el = document.getElementById(focusedId); | |
| if (el) { | |
| const textSpan = el.querySelector(`.${CLASSES.TASK_TEXT}`); | |
| textSpan.focus(); | |
| const range = document.createRange(); | |
| const sel = window.getSelection(); | |
| range.selectNodeContents(textSpan); | |
| range.collapse(false); | |
| sel.removeAllRanges(); | |
| sel.addRange(range); | |
| } | |
| this.context.setFocusedTask(null); | |
| } | |
| this.draggedItemMeta = null; | |
| } | |
| getDragAfterElement(container, y) { | |
| const relevantPositions = this.cachedPositions[container.id] || []; | |
| return relevantPositions.reduce((closest, childPos) => { | |
| const offset = y - childPos.mid; | |
| if (offset < 0 && offset > closest.offset) { | |
| return { offset: offset, element: childPos.element }; | |
| } else { | |
| return closest; | |
| } | |
| }, { offset: Number.NEGATIVE_INFINITY }).element; | |
| } | |
| } | |
| class ShortcutsHandler { | |
| constructor(context) { | |
| this.context = context; | |
| } | |
| handleKeydown(e) { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'f') { | |
| e.preventDefault(); | |
| this.context.toggleSearch(); | |
| return; | |
| } | |
| if (e.key === 'Escape') { | |
| this.context.closeSearch(); | |
| document.activeElement.blur(); | |
| return; | |
| } | |
| if (e.target.classList.contains(CLASSES.TASK_TEXT)) { | |
| const item = e.target.closest(`.${CLASSES.TASK_ITEM}`); | |
| const dayIndex = item.closest(`.${CLASSES.DAY_COLUMN}`).dataset.dayIndex; | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| const checkbox = item.querySelector('input[type="checkbox"]'); | |
| checkbox.click(); | |
| return; | |
| } | |
| if (e.key === 'Enter') { | |
| if (e.isComposing || e.nativeEvent?.isComposing === true) return; | |
| e.preventDefault(); | |
| e.target.blur(); | |
| const tasks = this.context.getTasks(dayIndex); | |
| const idx = tasks.findIndex(t => t.id === item.id); | |
| const newTask = { id: this.context.generateUUID(), text: "", completed: false }; | |
| tasks.splice(idx + 1, 0, newTask); | |
| this.context.render(dayIndex, tasks); | |
| this.context.save(); | |
| setTimeout(() => document.getElementById(newTask.id).querySelector(`.${CLASSES.TASK_TEXT}`).focus(), 0); | |
| } else if (e.key === 'Backspace') { | |
| const selection = window.getSelection().toString(); | |
| const textContent = e.target.textContent; | |
| if (textContent === '' || (selection.length > 0 && selection === textContent)) { | |
| e.preventDefault(); | |
| const prev = item.previousElementSibling; | |
| const delBtn = item.querySelector(`.${CLASSES.DELETE_BTN}`); | |
| if (delBtn) delBtn.click(); | |
| if (prev && prev.classList.contains(CLASSES.TASK_ITEM)) { | |
| const textEl = prev.querySelector(`.${CLASSES.TASK_TEXT}`); | |
| const range = document.createRange(); | |
| const sel = window.getSelection(); | |
| range.selectNodeContents(textEl); | |
| range.collapse(false); | |
| sel.removeAllRanges(); | |
| sel.addRange(range); | |
| textEl.focus(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| handlePaste(e) { | |
| if (!e.target.classList.contains(CLASSES.TASK_TEXT)) return; | |
| e.preventDefault(); | |
| const text = (e.clipboardData || window.clipboardData).getData('text'); | |
| const selection = window.getSelection(); | |
| if (!selection.rangeCount) return; | |
| selection.deleteFromDocument(); | |
| const textNode = document.createTextNode(text); | |
| selection.getRangeAt(0).insertNode(textNode); | |
| selection.collapse(textNode, textNode.length); | |
| } | |
| } | |
| class SearchHandler { | |
| constructor(planner) { | |
| this.planner = planner; | |
| this.searchIndex = []; | |
| } | |
| toggleSearch() { | |
| const sidebar = this.planner.elements.searchSidebar; | |
| const container = this.planner.elements.container; | |
| if (sidebar.classList.contains('active')) { | |
| sidebar.classList.remove('active'); | |
| container.classList.remove(CLASSES.SEARCH_ACTIVE); | |
| } else { | |
| sidebar.classList.add('active'); | |
| container.classList.add(CLASSES.SEARCH_ACTIVE); | |
| this.planner.elements.searchInput.focus(); | |
| } | |
| } | |
| performSearch(query) { | |
| if (!query) { | |
| this.planner.elements.searchResults.innerHTML = ''; | |
| return; | |
| } | |
| this.planner.storage.searchWorker.postMessage({ | |
| type: 'SEARCH', | |
| payload: { query, index: this.searchIndex } | |
| }); | |
| } | |
| getDisplayDate(weekId, dayIndex) { | |
| try { | |
| const weekStart = DateService.fromLocalISOString(weekId); | |
| const targetDate = new Date(weekStart); | |
| targetDate.setDate(weekStart.getDate() + dayIndex); | |
| const year = targetDate.getFullYear(); | |
| const month = targetDate.getMonth() + 1; | |
| const date = targetDate.getDate(); | |
| const dayName = DAY_NAMES[targetDate.getDay()]; | |
| return `${year}년 ${month}월 ${date}일 (${dayName})`; | |
| } catch (e) { | |
| return `${weekId} / ${DAY_NAMES[dayIndex]}`; | |
| } | |
| } | |
| displaySearchResults(results) { | |
| this.planner.elements.searchResults.innerHTML = ''; | |
| if (results.length === 0) { | |
| this.planner.elements.searchResults.innerHTML = MESSAGES.SEARCH_EMPTY; | |
| return; | |
| } | |
| const fragment = document.createDocumentFragment(); | |
| results.forEach(res => { | |
| const div = document.createElement('div'); | |
| div.className = 'search-result-item'; | |
| const friendlyDate = this.getDisplayDate(res.weekId, res.dayIndex); | |
| div.innerHTML = `<span class="result-week">${friendlyDate}</span> | |
| <span class="result-text">${res.originalText}</span>`; | |
| div.onclick = () => { | |
| try { | |
| const [y, m, d] = res.weekId.split('-').map(Number); | |
| if (!isNaN(y) && !isNaN(m) && !isNaN(d)) { | |
| const targetDate = new Date(y, m - 1, d); | |
| this.planner.currentDate = targetDate; | |
| this.planner.switchView('weekly'); | |
| this.planner.render(true); | |
| setTimeout(() => { | |
| const el = document.getElementById(res.id); | |
| if (el) { | |
| el.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| el.classList.add(CLASSES.HIGHLIGHT_TASK); | |
| setTimeout(() => el.classList.remove(CLASSES.HIGHLIGHT_TASK), CONFIG.HIGHLIGHT_DURATION); | |
| } | |
| }, CONFIG.ANIMATION_DURATION); | |
| if (window.innerWidth <= 1200) { | |
| this.toggleSearch(); | |
| } | |
| } | |
| } catch(e) { | |
| console.error("Navigation Error", e); | |
| } | |
| }; | |
| fragment.appendChild(div); | |
| }); | |
| this.planner.elements.searchResults.appendChild(fragment); | |
| } | |
| } | |
| class VintagePlanner { | |
| constructor() { | |
| this.notifications = new NotificationManager(); | |
| this.storage = new StorageManager(this.notifications); | |
| this.ui = new UIManager('weekGrid'); | |
| this.dataManager = new DataManager(this, this.storage, this.notifications); | |
| this.currentDate = new Date(); | |
| this.currentWeekId = null; | |
| this.cachedWeekData = {}; | |
| this.focusedTaskState = null; | |
| this.viewMode = 'weekly'; // 'weekly' or 'monthly' | |
| this.boundHandlers = {}; | |
| const handlerContext = { | |
| getTasks: (dayIndex) => this.cachedWeekData[getDayKey(dayIndex)], | |
| getTask: (dayIndex, taskId) => this.findTask(dayIndex, taskId), | |
| save: () => this.saveData(), | |
| render: (dayIndex, tasks) => this.ui.renderTasks(dayIndex, tasks), | |
| generateUUID: () => this.generateUUID(), | |
| setFocusedTask: (id) => { this.focusedTaskState = id ? { id } : null; }, | |
| getFocusedTask: () => this.focusedTaskState ? this.focusedTaskState.id : null, | |
| toggleSearch: () => this.searchHandler.toggleSearch(), | |
| closeSearch: () => { | |
| this.elements.searchSidebar.classList.remove('active'); | |
| this.elements.container.classList.remove(CLASSES.SEARCH_ACTIVE); | |
| }, | |
| getRules: () => this.storage.getRecurringRules(), | |
| saveRules: (rules) => this.storage.saveRecurringRules(rules), | |
| addException: (id, date) => this.recurringHandler.addException(id, date) | |
| }; | |
| this.dragHandler = new DragDropHandler(handlerContext); | |
| this.shortcutsHandler = new ShortcutsHandler(handlerContext); | |
| this.searchHandler = new SearchHandler(this); | |
| this.recurringHandler = new RecurringHandler(handlerContext); | |
| this.saveDataDebounced = this.debounce(this.performSave.bind(this), 500); | |
| this.bindElements(); | |
| this.bindEvents(); | |
| this.init(); | |
| } | |
| bindElements() { | |
| this.elements = { | |
| container: document.getElementById('mainContainer'), | |
| yearMonth: document.getElementById('yearMonthDisplay'), | |
| prevBtn: document.getElementById('prevWeekBtn'), | |
| thisBtn: document.getElementById('thisWeekBtn'), | |
| nextBtn: document.getElementById('nextWeekBtn'), | |
| calBtn: document.getElementById('calendarBtn'), | |
| viewToggleBtn: document.getElementById('viewToggleBtn'), | |
| datePicker: document.getElementById('datePicker'), | |
| exportBtn: document.getElementById('exportBtn'), | |
| importBtn: document.getElementById('importBtn'), | |
| importFile: document.getElementById('importFile'), | |
| resetBtn: document.getElementById('resetBtn'), | |
| migrateBtn: document.getElementById('migrateBtn'), | |
| themeBtn: document.getElementById('themeBtn'), | |
| searchBtn: document.getElementById('searchBtn'), | |
| searchSidebar: document.getElementById('searchSidebar'), | |
| closeSearch: document.getElementById('closeSearch'), | |
| searchInput: document.getElementById('searchInput'), | |
| searchResults: document.getElementById('searchResults') | |
| }; | |
| } | |
| init() { | |
| this.storage.searchWorker.onmessage = (e) => { | |
| const { type, payload } = e.data; | |
| if (type === 'INDEX_READY') { | |
| } else if (type === 'SEARCH_RESULTS') { | |
| this.searchHandler.displaySearchResults(payload); | |
| } | |
| }; | |
| this.storage.triggerIndexBuild(); | |
| this.render(); | |
| requestAnimationFrame(() => { | |
| document.getElementById('weekGrid').classList.add('loaded'); | |
| document.documentElement.style.opacity = '1'; | |
| document.documentElement.style.visibility = 'visible'; | |
| document.body.classList.add('ui-ready'); | |
| }); | |
| window.addEventListener('beforeunload', () => this.performSave()); | |
| document.addEventListener('click', (e) => { | |
| if (!e.target.closest(`.${CLASSES.REPEAT_MENU}`) && !e.target.closest(`.${CLASSES.REPEAT_BTN}`)) { | |
| document.querySelectorAll(`.${CLASSES.REPEAT_MENU}`).forEach(el => el.classList.remove('show')); | |
| } | |
| }); | |
| } | |
| generateUUID() { | |
| return typeof crypto !== 'undefined' && crypto.randomUUID | |
| ? `task-${crypto.randomUUID()}` | |
| : `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; | |
| } | |
| debounce(func, timeout = CONFIG.DEBOUNCE_DELAY) { | |
| let timer; | |
| return (...args) => { | |
| clearTimeout(timer); | |
| timer = setTimeout(() => { func.apply(this, args); }, timeout); | |
| }; | |
| } | |
| switchView(mode) { | |
| this.viewMode = mode; | |
| const weekGrid = document.getElementById('weekGrid'); | |
| const monthGrid = document.getElementById('monthGrid'); | |
| const btn = this.elements.viewToggleBtn; | |
| if (mode === 'monthly') { | |
| weekGrid.classList.remove('active'); | |
| monthGrid.classList.add('active'); | |
| btn.textContent = '주간 보기'; | |
| } else { | |
| monthGrid.classList.remove('active'); | |
| weekGrid.classList.add('active'); | |
| btn.textContent = '달력 보기'; | |
| } | |
| } | |
| handleNavigation(direction) { | |
| this.ui.animateGrid(direction, () => { | |
| if (this.viewMode === 'weekly') { | |
| if (direction === 'prev') { | |
| this.currentDate.setDate(this.currentDate.getDate() - 7); | |
| } else if (direction === 'next') { | |
| this.currentDate.setDate(this.currentDate.getDate() + 7); | |
| } | |
| } else { | |
| if (direction === 'prev') { | |
| this.currentDate.setMonth(this.currentDate.getMonth() - 1); | |
| } else if (direction === 'next') { | |
| this.currentDate.setMonth(this.currentDate.getMonth() + 1); | |
| } | |
| } | |
| this.render(); | |
| }); | |
| } | |
| render(force = false) { | |
| const startOfWeek = DateService.getStartOfWeek(this.currentDate); | |
| const weekId = DateService.getWeekId(startOfWeek); | |
| this.ui.updateDates(startOfWeek, new Date()); | |
| if (this.viewMode === 'weekly') { | |
| this.updateHeader(startOfWeek); | |
| if (!force && this.currentWeekId === weekId) return; | |
| this.currentWeekId = weekId; | |
| this.loadDataForWeek(weekId); | |
| } else { | |
| const year = this.currentDate.getFullYear(); | |
| const month = this.currentDate.toLocaleString('ko-KR', { month: 'long' }); | |
| this.elements.yearMonth.textContent = `${year}년 ${month}`; | |
| this.renderMonth(); | |
| } | |
| } | |
| updateHeader(startOfWeek) { | |
| const endOfWeek = new Date(startOfWeek); | |
| endOfWeek.setDate(startOfWeek.getDate() + 6); | |
| const startMonth = startOfWeek.toLocaleString('ko-KR', { month: 'long' }); | |
| const endMonth = endOfWeek.toLocaleString('ko-KR', { month: 'long' }); | |
| const year = startOfWeek.getFullYear(); | |
| this.elements.yearMonth.textContent = startOfWeek.getMonth() !== endOfWeek.getMonth() | |
| ? `${year}년 ${startMonth} - ${endMonth}` | |
| : `${year}년 ${startMonth}`; | |
| } | |
| renderMonth() { | |
| const grid = document.getElementById('monthGrid'); | |
| grid.innerHTML = ''; | |
| const year = this.currentDate.getFullYear(); | |
| const month = this.currentDate.getMonth(); | |
| const firstDay = new Date(year, month, 1); | |
| const lastDay = new Date(year, month + 1, 0); | |
| const startDate = new Date(firstDay); | |
| startDate.setDate(1 - startDate.getDay()); | |
| const endDate = new Date(lastDay); | |
| endDate.setDate(lastDay.getDate() + (6 - lastDay.getDay())); | |
| const headerRow = document.createElement('div'); | |
| headerRow.className = 'month-header-row'; | |
| DAY_NAMES.forEach((name, i) => { | |
| const cell = document.createElement('div'); | |
| cell.className = 'month-header-cell'; | |
| cell.textContent = name.substring(0, 1); | |
| headerRow.appendChild(cell); | |
| }); | |
| grid.appendChild(headerRow); | |
| const iterator = new Date(startDate); | |
| while (iterator <= endDate) { | |
| const cell = document.createElement('div'); | |
| cell.className = 'month-cell'; | |
| const isOtherMonth = iterator.getMonth() !== month; | |
| if (isOtherMonth) cell.classList.add('other-month'); | |
| const today = new Date(); | |
| if (iterator.toDateString() === today.toDateString()) cell.classList.add('today'); | |
| if (iterator.getDay() === 0) cell.classList.add('sunday'); | |
| if (iterator.getDay() === 6) cell.classList.add('saturday'); | |
| const dateNum = document.createElement('div'); | |
| dateNum.className = 'month-date'; | |
| dateNum.textContent = iterator.getDate(); | |
| cell.appendChild(dateNum); | |
| const weekId = DateService.getWeekId(iterator); | |
| let weekData = this.storage.getWeekData(weekId); | |
| if (!weekData && this.storage.isInitialized()) { | |
| weekData = {}; | |
| for(let i=0; i<7; i++) weekData[getDayKey(i)] = []; | |
| } | |
| if (weekData) { | |
| weekData = this.recurringHandler.applyRules(weekId, weekData); | |
| const dayKey = getDayKey(iterator.getDay()); | |
| const tasks = weekData[dayKey] || []; | |
| if (tasks.length > 0) { | |
| const dotsContainer = document.createElement('div'); | |
| dotsContainer.className = 'task-dots'; | |
| // [FIX] Tooltip content creation | |
| const tooltip = document.createElement('div'); | |
| tooltip.className = 'task-tooltip'; | |
| let tooltipText = ''; | |
| tasks.forEach((task, i) => { | |
| const dot = document.createElement('div'); | |
| dot.className = 'task-dot'; | |
| if (task.completed) dot.classList.add('completed'); | |
| else dot.classList.add('active'); | |
| dotsContainer.appendChild(dot); | |
| if(i < 5) tooltipText += (i > 0 ? '\n' : '') + task.text; | |
| }); | |
| if(tasks.length > 5) tooltipText += `\n+ 그 외 ${tasks.length - 5}개`; | |
| tooltip.textContent = tooltipText || '할 일 없음'; | |
| cell.appendChild(dotsContainer); | |
| cell.appendChild(tooltip); | |
| } | |
| } | |
| const clickDate = new Date(iterator); | |
| cell.onclick = () => { | |
| this.currentDate = clickDate; | |
| this.switchView('weekly'); | |
| this.render(); | |
| }; | |
| grid.appendChild(cell); | |
| iterator.setDate(iterator.getDate() + 1); | |
| } | |
| } | |
| loadDataForWeek(weekId) { | |
| let data = this.storage.getWeekData(weekId); | |
| const isInit = this.storage.isInitialized(); | |
| if (!isInit) { | |
| data = this.getSampleData(); | |
| this.storage.setInitialized(); | |
| this.storage.saveWeekData(weekId, data); | |
| } else if (!data) { | |
| data = {}; | |
| } | |
| let weekData = JSON.parse(JSON.stringify(data)); | |
| for (let i = 0; i < 7; i++) { | |
| const key = getDayKey(i); | |
| if (!weekData[key]) weekData[key] = []; | |
| } | |
| weekData = this.recurringHandler.applyRules(weekId, weekData); | |
| this.cachedWeekData = weekData; | |
| for (let i = 0; i < 7; i++) { | |
| this.ui.renderTasks(i, this.cachedWeekData[getDayKey(i)]); | |
| } | |
| this.performSave(); | |
| } | |
| getSampleData() { | |
| const now = Date.now(); | |
| const r = () => Math.random().toString(36).substring(2, 5); | |
| return { | |
| [getDayKey(0)]: [ | |
| { id: `task-${now}_0_1_${r()}`, text: "마샬 대학에서 원정 계획 수립 🏫", completed: true }, | |
| { id: `task-${now}_0_2_${r()}`, text: "채찍과 페도라 챙기기 🎒", completed: false } | |
| ], | |
| [getDayKey(1)]: [ | |
| { id: `task-${now}_1_1_${r()}`, text: "카이로에서 살라 만나기 🇪🇬", completed: false }, | |
| { id: `task-${now}_1_2_${r()}`, text: "**중요:** 라의 지팡이 장식 해독 📜", completed: false } | |
| ], | |
| [getDayKey(2)]: [ | |
| { id: `task-${now}_2_1_${r()}`, text: "타니스의 지도실 방문 🗺️", completed: false }, | |
| { id: `task-${now}_2_2_${r()}`, text: "시장통에서 추격자 일당 따돌리기 (14:00) 🕵️♂️", completed: false } | |
| ], | |
| [getDayKey(3)]: [ | |
| { id: `task-${now}_3_1_${r()}`, text: "조종사 젭과 함께 정글로 비행 ✈️", completed: false }, | |
| { id: `task-${now}_3_2_${r()}`, text: "~~뱀 조심할 것~~ (해결됨) 🐍", completed: false } | |
| ], | |
| [getDayKey(4)]: [ | |
| { id: `task-${now}_4_1_${r()}`, text: "고대 사원의 함정 통과하기 🧩", completed: false }, | |
| { id: `task-${now}_4_2_${r()}`, text: "거대한 바위에서 탈출하기 🪨", completed: true } | |
| ], | |
| [getDayKey(5)]: [ | |
| { id: `task-${now}_5_1_${r()}`, text: "성궤 회수하기 (!!)", completed: false }, | |
| { id: `task-${now}_5_2_${r()}`, text: "눈 감아! 절대 보지 마! 🫣", completed: false } | |
| ], | |
| [getDayKey(6)]: [ | |
| { id: `task-${now}_6_1_${r()}`, text: "박물관에 유물 전달하기 🏛️", completed: false }, | |
| { id: `task-${now}_6_2_${r()}`, text: "메리언과 저녁 식사 🍷", completed: false } | |
| ] | |
| }; | |
| } | |
| saveData() { | |
| this.saveDataDebounced(); | |
| } | |
| performSave() { | |
| if (!this.currentWeekId) return; | |
| this.storage.saveWeekData(this.currentWeekId, this.cachedWeekData); | |
| } | |
| findTask(dayIndex, taskId) { | |
| return this.cachedWeekData[getDayKey(dayIndex)].find(t => t.id === taskId); | |
| } | |
| handleGridClick(e) { | |
| const target = e.target; | |
| const taskItem = target.closest(`.${CLASSES.TASK_ITEM}`); | |
| const dayColumn = target.closest(`.${CLASSES.DAY_COLUMN}`); | |
| if (target.classList.contains(CLASSES.REPEAT_OPTION) && taskItem) { | |
| const type = target.dataset.type; | |
| const dayIndex = taskItem.closest(`.${CLASSES.DAY_COLUMN}`).dataset.dayIndex; | |
| const task = this.findTask(dayIndex, taskItem.id); | |
| if (task) { | |
| task.repeat = type; | |
| this.recurringHandler.setRepeatRule(task, type, dayIndex, DateService.getStartOfWeek(this.currentDate)); | |
| this.saveData(); | |
| this.ui.renderTasks(dayIndex, this.cachedWeekData[getDayKey(dayIndex)]); | |
| this.notifications.showToast(MESSAGES.REPEAT_SET(REPEAT_TYPES[type])); | |
| } | |
| return; | |
| } | |
| if (target.dataset.action === 'repeat' && taskItem) { | |
| const menu = taskItem.querySelector(`.${CLASSES.REPEAT_MENU}`); | |
| document.querySelectorAll(`.${CLASSES.REPEAT_MENU}`).forEach(el => { | |
| if (el !== menu) el.classList.remove('show'); | |
| }); | |
| menu.classList.toggle('show'); | |
| if (menu.classList.contains('show')) { | |
| menu.classList.remove('open-up'); | |
| const menuHeight = menu.offsetHeight || 200; | |
| const buttonRect = target.getBoundingClientRect(); | |
| const spaceBelow = window.innerHeight - buttonRect.bottom; | |
| if (spaceBelow < menuHeight) { | |
| menu.classList.add('open-up'); | |
| } | |
| const menuRect = menu.getBoundingClientRect(); | |
| if (menuRect.right > window.innerWidth) { | |
| menu.style.right = '0'; | |
| menu.style.left = 'auto'; | |
| } | |
| } | |
| return; | |
| } | |
| if (target.dataset.action === 'delete' && taskItem && dayColumn) { | |
| this.deleteTaskAction(dayColumn, taskItem); | |
| return; | |
| } | |
| if (target.dataset.action === 'toggle' && taskItem && dayColumn) { | |
| const dayIndex = dayColumn.dataset.dayIndex; | |
| const task = this.findTask(dayIndex, taskItem.id); | |
| if (task) { | |
| task.completed = target.checked; | |
| this.saveData(); | |
| } | |
| return; | |
| } | |
| if (dayColumn && !taskItem && !target.closest(`.${CLASSES.DAY_HEADER}`) && !target.classList.contains(CLASSES.TASK_TEXT) && !target.closest(`.${CLASSES.TASK_LIST}`)) { | |
| if (window.getSelection().toString().length > 0) return; | |
| } | |
| if (dayColumn && !taskItem && !target.closest(`.${CLASSES.DAY_HEADER}`) && !target.classList.contains(CLASSES.TASK_TEXT)) { | |
| const dayIndex = dayColumn.dataset.dayIndex; | |
| const newTask = { id: this.generateUUID(), text: "", completed: false }; | |
| this.cachedWeekData[getDayKey(dayIndex)].push(newTask); | |
| this.ui.renderTasks(dayIndex, this.cachedWeekData[getDayKey(dayIndex)]); | |
| this.saveData(); | |
| setTimeout(() => { | |
| const el = document.getElementById(newTask.id); | |
| if (el) el.querySelector(`.${CLASSES.TASK_TEXT}`).focus(); | |
| }, 0); | |
| } | |
| } | |
| async deleteTaskAction(dayColumn, taskItem) { | |
| const dayIndex = dayColumn.dataset.dayIndex; | |
| const task = this.findTask(dayIndex, taskItem.id); | |
| if (!task) return; | |
| const isGenerated = task.id.startsWith('task-rule-'); | |
| let isOrigin = false; | |
| let ruleId = null; | |
| if (isGenerated) { | |
| const match = task.id.match(/task-(rule-\d+)-/); | |
| if (match) ruleId = match[1]; | |
| } else { | |
| const rules = this.storage.getRecurringRules(); | |
| const relatedRule = rules.find(r => r.originTaskId === task.id); | |
| if (relatedRule) { | |
| isOrigin = true; | |
| ruleId = relatedRule.id; | |
| } | |
| } | |
| if (ruleId) { | |
| const choice = await this.notifications.showChoiceModal( | |
| MESSAGES.DELETE_RECURRING_TITLE, | |
| MESSAGES.DELETE_RECURRING_BODY, | |
| [ | |
| { text: '취소', class: 'modal-btn', value: 'cancel' }, | |
| { text: '이 일정만 삭제', class: 'modal-btn secondary', value: 'instance' }, | |
| { text: '모든 일정 삭제', class: 'modal-btn danger', value: 'all' } | |
| ] | |
| ); | |
| if (!choice || choice === 'cancel') return; | |
| if (choice === 'all') { | |
| let rules = this.storage.getRecurringRules(); | |
| rules = rules.filter(r => r.id !== ruleId); | |
| this.storage.saveRecurringRules(rules); | |
| if (isGenerated) { | |
| const dateMatch = task.id.match(/(\d{4}-\d{2}-\d{2})$/); | |
| if (dateMatch) this.recurringHandler.addException(ruleId, dateMatch[1]); | |
| } | |
| location.reload(); | |
| return; | |
| } | |
| } else { | |
| const confirmDelete = await this.notifications.confirm(MESSAGES.DELETE_CONFIRM_TITLE, MESSAGES.DELETE_CONFIRM_BODY); | |
| if (!confirmDelete) return; | |
| } | |
| const taskIndex = this.cachedWeekData[getDayKey(dayIndex)].indexOf(task); | |
| if (isGenerated && ruleId) { | |
| const dateMatch = task.id.match(/(\d{4}-\d{2}-\d{2})$/); | |
| if (dateMatch) { | |
| this.recurringHandler.addException(ruleId, dateMatch[1]); | |
| } | |
| } | |
| this.cachedWeekData[getDayKey(dayIndex)].splice(taskIndex, 1); | |
| this.ui.renderTasks(dayIndex, this.cachedWeekData[getDayKey(dayIndex)]); | |
| this.saveData(); | |
| } | |
| handleFocusIn(e) { | |
| if (e.target.classList.contains(CLASSES.TASK_TEXT)) { | |
| e.target.classList.add(CLASSES.EDITING); | |
| const item = e.target.closest(`.${CLASSES.TASK_ITEM}`); | |
| const dayIndex = item.closest(`.${CLASSES.DAY_COLUMN}`).dataset.dayIndex; | |
| const task = this.findTask(dayIndex, item.id); | |
| if (task) { | |
| e.target.textContent = task.text; | |
| } | |
| } | |
| } | |
| handleFocusOut(e) { | |
| if (e.target.classList.contains(CLASSES.TASK_TEXT)) { | |
| e.target.classList.remove(CLASSES.EDITING); | |
| const item = e.target.closest(`.${CLASSES.TASK_ITEM}`); | |
| const dayIndex = item.closest(`.${CLASSES.DAY_COLUMN}`).dataset.dayIndex; | |
| const task = this.findTask(dayIndex, item.id); | |
| if (task) { | |
| const newText = e.target.textContent; | |
| const oldText = task.text; | |
| if (newText.trim() === '') { | |
| const idx = this.cachedWeekData[getDayKey(dayIndex)].indexOf(task); | |
| this.cachedWeekData[getDayKey(dayIndex)].splice(idx, 1); | |
| this.ui.renderTasks(dayIndex, this.cachedWeekData[getDayKey(dayIndex)]); | |
| this.saveData(); | |
| } else { | |
| // [FIX] Detach Recurring Task logic | |
| if (newText !== oldText) { | |
| if (task.id.includes('task-rule-')) { | |
| const match = task.id.match(/task-(rule-\d+)-(\d{4}-\d{2}-\d{2})/); | |
| if (match) { | |
| const ruleId = match[1]; | |
| const dateStr = match[2]; | |
| // 1. Add Exception to Rule | |
| this.recurringHandler.addException(ruleId, dateStr); | |
| // 2. Create new independent task | |
| const newId = this.generateUUID(); | |
| const newTask = { ...task, id: newId, text: newText }; | |
| delete newTask.repeat; // Detached | |
| // 3. Replace in Data | |
| const list = this.cachedWeekData[getDayKey(dayIndex)]; | |
| const idx = list.findIndex(t => t.id === task.id); | |
| if (idx !== -1) list[idx] = newTask; | |
| // 4. Update DOM | |
| item.id = newId; | |
| } else { | |
| task.text = newText; | |
| } | |
| } else { | |
| task.text = newText; | |
| } | |
| this.saveData(); | |
| } | |
| this.ui.renderMarkdown(e.target, task.text); | |
| } | |
| } | |
| } | |
| } | |
| highlightToday() { | |
| const today = new Date(); | |
| if (today.getDay() !== undefined) { | |
| const dayIndex = today.getDay(); | |
| const dayColumn = document.querySelector(`.${CLASSES.DAY_COLUMN}[data-day-index="${dayIndex}"]`); | |
| if (dayColumn && dayColumn.classList.contains(CLASSES.TODAY)) { | |
| dayColumn.classList.remove(CLASSES.TODAY_FLASH); | |
| void dayColumn.offsetWidth; | |
| dayColumn.classList.add(CLASSES.TODAY_FLASH); | |
| } | |
| } | |
| } | |
| bindEvents() { | |
| const el = this.elements; | |
| this.boundHandlers = { | |
| prev: () => this.handleNavigation('prev'), | |
| next: () => this.handleNavigation('next'), | |
| thisWeek: () => { | |
| this.currentDate = new Date(); | |
| this.switchView('weekly'); | |
| this.render(); | |
| setTimeout(() => this.highlightToday(), 100); | |
| }, | |
| toggleView: () => { | |
| this.switchView(this.viewMode === 'weekly' ? 'monthly' : 'weekly'); | |
| this.render(); | |
| }, | |
| showPicker: () => el.datePicker.showPicker(), | |
| onDatePick: (e) => { | |
| if (e.target.value) { | |
| const [y, m, d] = e.target.value.split('-').map(Number); | |
| this.currentDate = new Date(y, m - 1, d); | |
| this.switchView('weekly'); | |
| this.render(); | |
| } | |
| }, | |
| export: () => this.dataManager.exportData(), | |
| importClick: () => el.importFile.click(), | |
| importChange: (e) => { this.dataManager.importData(e.target.files[0]); e.target.value = ''; }, | |
| reset: async () => { | |
| const firstConfirm = await this.notifications.confirm(MESSAGES.RESET_CONFIRM_TITLE, MESSAGES.RESET_CONFIRM_BODY); | |
| if (firstConfirm) { | |
| const secondConfirm = await this.notifications.confirm(MESSAGES.RESET_FINAL_CONFIRM_TITLE, MESSAGES.RESET_FINAL_CONFIRM_BODY); | |
| if (secondConfirm) { | |
| this.storage.reset(); | |
| location.reload(); | |
| } | |
| } | |
| }, | |
| migrate: () => this.dataManager.migrateTasks(), | |
| theme: () => { | |
| document.documentElement.classList.toggle('dark-mode'); | |
| localStorage.setItem('theme', document.documentElement.classList.contains('dark-mode') ? 'dark' : 'light'); | |
| }, | |
| searchToggle: () => this.searchHandler.toggleSearch(), | |
| searchInput: this.debounce((e) => this.searchHandler.performSearch(e.target.value), 300) | |
| }; | |
| el.prevBtn.onclick = this.boundHandlers.prev; | |
| el.nextBtn.onclick = this.boundHandlers.next; | |
| el.thisBtn.onclick = this.boundHandlers.thisWeek; | |
| el.calBtn.onclick = this.boundHandlers.showPicker; | |
| el.viewToggleBtn.onclick = this.boundHandlers.toggleView; | |
| el.yearMonth.onclick = this.boundHandlers.showPicker; | |
| el.datePicker.onchange = this.boundHandlers.onDatePick; | |
| el.exportBtn.onclick = this.boundHandlers.export; | |
| el.importBtn.onclick = this.boundHandlers.importClick; | |
| el.importFile.onchange = this.boundHandlers.importChange; | |
| el.resetBtn.onclick = this.boundHandlers.reset; | |
| el.migrateBtn.onclick = this.boundHandlers.migrate; | |
| el.themeBtn.onclick = this.boundHandlers.theme; | |
| el.searchBtn.onclick = this.boundHandlers.searchToggle; | |
| el.closeSearch.onclick = this.boundHandlers.searchToggle; | |
| el.searchInput.oninput = this.boundHandlers.searchInput; | |
| const grid = this.ui.weekGrid; | |
| grid.addEventListener('click', (e) => this.handleGridClick(e)); | |
| grid.addEventListener('keydown', (e) => this.shortcutsHandler.handleKeydown(e)); | |
| grid.addEventListener('paste', (e) => this.shortcutsHandler.handlePaste(e)); | |
| grid.addEventListener('focusin', (e) => this.handleFocusIn(e)); | |
| grid.addEventListener('focusout', (e) => this.handleFocusOut(e)); | |
| grid.addEventListener('dragstart', (e) => this.dragHandler.handleDragStart(e)); | |
| grid.addEventListener('dragover', (e) => this.dragHandler.handleDragOver(e)); | |
| grid.addEventListener('drop', (e) => this.dragHandler.handleDrop(e)); | |
| grid.addEventListener('dragend', (e) => this.dragHandler.handleDragEnd(e)); | |
| document.addEventListener('keydown', (e) => this.shortcutsHandler.handleKeydown(e)); | |
| } | |
| } | |
| const app = new VintagePlanner(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment