Skip to content

Instantly share code, notes, and snippets.

@lunamoth
Created November 20, 2025 13:32
Show Gist options
  • Select an option

  • Save lunamoth/f75877b97ad9a95f829382b13cdb82a7 to your computer and use it in GitHub Desktop.

Select an option

Save lunamoth/f75877b97ad9a95f829382b13cdb82a7 to your computer and use it in GitHub Desktop.
<!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">&lt;</button>
<button id="thisWeekBtn">이번 주</button>
<button id="calendarBtn" class="calendar-btn">달력 이동</button>
<button id="viewToggleBtn">달력 보기</button>
<button id="searchBtn">검색</button>
<button id="nextWeekBtn">&gt;</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 {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[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