Skip to content

Instantly share code, notes, and snippets.

@lunamoth
Last active November 11, 2025 11:40
Show Gist options
  • Select an option

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

Select an option

Save lunamoth/1ef007854235c56cfcd2bb3421a38f82 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>
<!-- Chart.js CDN 추가 -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
.heatmap-days,.heatmap-grid{grid-template-rows:repeat(7,16px)}.calendar-day.other-month,.dragging{opacity:.5}:root{--bg-gradient:radial-gradient(circle at top left, rgba(121, 151, 255, 0.2), transparent 40%),radial-gradient(circle at bottom right, rgba(255, 121, 238, 0.2), transparent 40%);--bg-color:#f5f5f7;--content-bg:rgba(255, 255, 255, 0.6);--secondary-bg:rgba(255, 255, 255, 0.4);--text-color:#1d1d1f;--text-secondary-color:rgba(60, 60, 67, 0.85);--primary-color:#007aff;--primary-hover-color:#006ee6;--border-color:rgba(0, 0, 0, 0.1);--shadow-color:rgba(0, 0, 0, 0.08);--success-color:#34c759;--missed-color:#ff3b30;--warning-color:#ff9500;--today-bg-color:rgba(0, 122, 255, 0.1);--modal-backdrop:rgba(0, 0, 0, 0.3);--saturday-color:#007aff;--sunday-color:#ff3b30;--heatmap-0:rgba(120, 120, 128, 0.08);--heatmap-1:#6be094;--heatmap-2:#40c463;--heatmap-3:#30a14e;--heatmap-4:#216e39}body.dark-mode{--bg-gradient:radial-gradient(circle at top left, rgba(0, 122, 255, 0.3), transparent 50%),radial-gradient(circle at bottom right, rgba(175, 82, 222, 0.3), transparent 50%);--bg-color:#000000;--content-bg:rgba(29, 29, 31, 0.7);--secondary-bg:rgba(44, 44, 46, 0.6);--text-color:#f5f5f7;--text-secondary-color:rgba(235, 235, 245, 0.8);--primary-color:#0a84ff;--primary-hover-color:#3b9aff;--border-color:rgba(255, 255, 255, 0.15);--shadow-color:rgba(0, 0, 0, 0.25);--success-color:#30d158;--missed-color:#ff453a;--warning-color:#ff9f0a;--today-bg-color:rgba(10, 132, 255, 0.15);--modal-backdrop:rgba(0, 0, 0, 0.5);--saturday-color:#0a84ff;--sunday-color:#ff453a;--heatmap-0:rgba(120, 120, 128, 0.12)}*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif}body{background-color:var(--bg-color);background-image:var(--bg-gradient);background-attachment:fixed;color:var(--text-color);transition:background-color .4s,color .4s;line-height:1.5;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.container{max-width:1200px;margin:40px auto;padding:24px;background-color:var(--content-bg);border-radius:24px;border:1px solid var(--border-color);box-shadow:0 16px 48px var(--shadow-color);backdrop-filter:blur(30px);-webkit-backdrop-filter:blur(30px)}.controls,.filters{margin-bottom:24px}.filters,.view-switcher{background-color:var(--secondary-bg);display:flex}header{text-align:center;margin-bottom:32px}header h1{font-size:2.8em;font-weight:700;margin-bottom:8px;letter-spacing:-.5px}#motivational-quote{font-size:1.1em;color:var(--text-secondary-color);font-weight:500}.controls{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:16px}.view-switcher{gap:4px;padding:4px;border-radius:12px;border:1px solid var(--border-color)}.view-switcher button{background-color:transparent;color:var(--text-secondary-color);padding:8px 16px;font-size:.95em;font-weight:600;border-radius:9px;transition:.3s}.view-switcher button.active{background-color:var(--content-bg);color:var(--text-color);box-shadow:0 2px 8px rgba(0,0,0,.1)}.habit-on-calendar:hover,.view-switcher button:not(.active):hover{background-color:rgba(120,120,128,.1)}.main-actions{display:flex;gap:12px;flex-wrap:wrap}.filters{gap:12px;flex-wrap:wrap;padding:12px;border-radius:16px;align-items:center}.filters input,.filters select{padding:10px 14px;border-radius:10px;border:1px solid var(--border-color);background-color:var(--content-bg);color:var(--text-color);font-size:1em}.filters label{display:flex;align-items:center;gap:6px;font-weight:500}button{padding:10px 20px;border-radius:999px;border:none;background-color:var(--primary-color);color:#fff;font-size:1em;font-weight:600;cursor:pointer;transition:.2s}button:hover{background-color:var(--primary-hover-color);transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,122,255,.2)}button:active{transform:translateY(0) scale(.98);box-shadow:none}button.secondary{background-color:var(--secondary-bg);color:var(--text-color);border:1px solid var(--border-color)}button.secondary:hover{background-color:rgba(120,120,128,.2);box-shadow:none;transform:translateY(0)}button.warning{background-color:var(--warning-color)}button:disabled{background-color:#ccc;cursor:not-allowed;opacity:.6;box-shadow:none;transform:none}#app-content{margin-top:24px}.view-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.modal-header h2,.view-header h2{font-size:2em;font-weight:600}.calendar-header button,.view-header button{padding:8px;width:44px;height:44px;border-radius:50%;font-size:1.2em;background-color:var(--secondary-bg);color:var(--text-color);line-height:1}.calendar-day-name,.dashboard-card-title,.empty-state p{color:var(--text-secondary-color)}.view-header .today-nav{display:flex;align-items:center;gap:8px}#calendar-go-to-today,#go-to-today-btn{font-size:.8em}.empty-state{text-align:center;padding:60px 30px;background-color:var(--secondary-bg);border-radius:20px}.empty-state h3{font-size:1.8em;margin-bottom:12px;font-weight:600}.day-number,.empty-state button{font-size:1.1em}.empty-state p{margin-bottom:24px}.dragging{background:var(--today-bg-color)}.drop-placeholder{height:80px;background:rgba(0,122,255,.1);border:2px dashed var(--primary-color);border-radius:20px;margin-bottom:16px}.calendar-header{position:relative;display:flex;justify-content:center;align-items:center;margin-bottom:20px}.calendar-header .calendar-nav{position:absolute;left:0;display:flex;align-items:center;gap:8px}.calendar-header h2{font-size:2em;font-weight:600;cursor:pointer;}.calendar-header .calendar-title-wrapper{position:absolute;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px}.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:8px}.calendar-day-name{font-weight:600;text-align:center;padding:12px 5px}.calendar-day{background-color:var(--secondary-bg);border-radius:16px;padding:12px;min-height:140px;display:flex;flex-direction:column;gap:6px;transition:background-color .2s}.calendar-habit-list{display:flex;flex-direction:column;gap:4px;max-height:400px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--primary-color) transparent}.calendar-habit-list::-webkit-scrollbar{width:4px}.calendar-habit-list::-webkit-scrollbar-thumb{background-color:var(--primary-color);border-radius:4px}.calendar-day.today{background-color:var(--today-bg-color);box-shadow:0 0 0 2px var(--primary-color) inset}.day-number{font-weight:600;margin-bottom:5px}.day-number.saturday{color:var(--saturday-color)}.day-number.sunday{color:var(--sunday-color)}.habit-on-calendar{display:flex;align-items:center;font-size:.9em;padding:4px 8px;border-radius:999px;cursor:pointer;background-color:var(--content-bg);transition:background-color .2s,color .2s,transform .15s ease-out}.habit-on-calendar.completed{background-color:var(--success-color);color:#fff}.habit-on-calendar .habit-icon{margin-right:6px}.archive-item,.habit-list-item,.stat-card{background-color:var(--secondary-bg);border-radius:20px;padding:24px;margin-bottom:16px;display:flex;flex-direction:column;gap:16px;transition:transform .2s,box-shadow .2s}.habit-list-item[draggable=true]{cursor:grab}.archive-item:hover,.habit-list-item:hover{transform:translateY(-2px);box-shadow:0 8px 24px var(--shadow-color)}.archive-item-header,.habit-header{display:flex;justify-content:space-between;align-items:center}.archive-item-title,.habit-title{font-size:1.6em;font-weight:600}.archive-item-actions,.habit-actions,.heatmap-body{display:flex;gap:8px}.archive-item-actions button,.habit-actions button{background:0 0;border:none;font-size:1.4em;cursor:pointer;color:var(--text-secondary-color);padding:8px;border-radius:50%;transition:background-color .2s,color .2s}.archive-item-actions button:hover,.habit-actions button:hover{background-color:rgba(120,120,128,.2);color:var(--text-color)}.habit-progress{display:flex;align-items:center;gap:15px}.habit-progress input[type=checkbox]{width:28px;height:28px;cursor:pointer;accent-color:var(--primary-color)}.progress-bar-container{width:100%;height:8px;background-color:rgba(120,120,128,.2);border-radius:4px;overflow:hidden}.progress-bar{height:100%;background-color:var(--primary-color);border-radius:4px;transition:width .4s}.stats-dashboard{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:20px;margin-bottom:30px}.dashboard-card{background-color:var(--secondary-bg);padding:24px;border-radius:20px;text-align:center}.dashboard-card-title{font-size:1.1em;font-weight:500;margin-bottom:10px}.dashboard-card-value{font-size:2.5em;font-weight:700;color:var(--primary-color)}.stat-item span:last-child,.timeline-date{font-weight:600}.stats-grid{display:grid;grid-template-columns:1fr;gap:20px}.heatmap-grid,.heatmap-months{grid-template-columns:repeat(53,16px)}.stat-card-title{font-size:1.8em;margin-bottom:16px;border-bottom:1px solid var(--border-color);padding-bottom:12px;font-weight:600}.stat-item{display:flex;justify-content:space-between;font-size:1.1em;padding:10px 0}.heatmap-days,.heatmap-months{font-size:.8em;color:var(--text-secondary-color);gap:4px}.chart-container{position:relative;height:200px;width:100%;margin-top:16px}.heatmap-container-wrapper{margin-top:20px;overflow-x:auto;padding-bottom:10px}.heatmap-wrapper{display:inline-block}.heatmap-months{display:grid;padding-left:30px;margin-bottom:4px}.heatmap-days{display:grid}.heatmap-grid{display:grid;gap:4px;grid-auto-flow:column}.heatmap-cell{width:16px;height:16px;background-color:var(--heatmap-0);border-radius:4px}.heatmap-cell[data-level='1']{background-color:var(--heatmap-1);opacity:.6}.heatmap-cell[data-level='2']{background-color:var(--heatmap-2)}.heatmap-cell[data-level='3']{background-color:var(--heatmap-3)}.heatmap-cell[data-level='4']{background-color:var(--heatmap-4)}.timeline-container{padding:20px 0}.timeline-item{padding:20px;background-color:var(--secondary-bg);border-radius:16px;margin-bottom:16px;border-left:4px solid var(--primary-color)}.timeline-date{color:var(--primary-color);margin-bottom:8px}.achievement-desc,.close-button{color:var(--text-secondary-color)}.modal{display:none;position:fixed;z-index:1000;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:var(--modal-backdrop);align-items:center;justify-content:center;backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)}.modal.visible{display:flex;animation:.3s fade-in}@keyframes fade-in{from{opacity:0}to{opacity:1}}.modal-content{background-color:var(--content-bg);backdrop-filter:blur(50px);-webkit-backdrop-filter:blur(50px);padding:32px;border:1px solid var(--border-color);width:90%;max-width:600px;border-radius:24px;box-shadow:0 20px 60px rgba(0,0,0,.3);animation:.4s cubic-bezier(.16,1,.3,1) slide-up}@keyframes slide-up{from{transform:translateY(30px) scale(.98);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}.modal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.close-button{font-size:1.5em;font-weight:700;cursor:pointer;width:36px;height:36px;display:flex;align-items:center;justify-content:center;background:var(--secondary-bg);border-radius:50%;transition:background-color .2s}.close-button:hover{background:rgba(120,120,128,.2)}.form-group{margin-bottom:20px}.form-group label{display:block;margin-bottom:8px;font-weight:600}.form-group input,.form-group select,.form-group textarea{width:100%;padding:12px 16px;border:1px solid var(--border-color);border-radius:12px;background-color:var(--secondary-bg);color:var(--text-color);font-size:1.05em}.modal-buttons{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:24px}.modal-buttons .right-actions{display:flex;gap:12px;align-items:center}.modal-buttons button.danger,.toast.danger{background-color:var(--missed-color)}.modal-buttons button.danger:hover{background-color:#e33e34;box-shadow:0 4px 12px rgba(255,59,48,.2)}.frequency-days{display:flex;justify-content:space-between;gap:6px}.frequency-days label{flex:1;text-align:center}.frequency-days input,input[type=file]{display:none}.frequency-days span{display:block;padding:12px;border:1px solid var(--border-color);border-radius:10px;cursor:pointer;user-select:none;font-weight:500;transition:.2s}.frequency-days input:checked+span{background-color:var(--primary-color);color:#fff;border-color:var(--primary-color)}.achievements-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:16px}.achievement-card{padding:20px;border-radius:16px;text-align:center;background-color:var(--secondary-bg);opacity:.7}.achievement-card.unlocked{opacity:1;border:2px solid var(--success-color);background-color:var(--today-bg-color)}.achievement-icon{font-size:2.8em}.achievement-title{font-weight:600;margin-top:8px}.achievement-desc{font-size:.8em}.file-label,.toast{color:#fff;font-weight:600}#settings-modal .modal-content h3{border-bottom:1px solid var(--border-color);padding-bottom:8px;margin-bottom:12px;font-weight:600}#settings-modal .modal-content div{margin-bottom:24px}.file-label{display:inline-block;padding:10px 20px;background-color:var(--primary-color);border-radius:999px;cursor:pointer}.file-label:hover{background-color:var(--primary-hover-color)}#date-picker-input{position:absolute;opacity:0;width:1px;height:1px;border:none;padding:0;pointer-events:none;left:-9999px;top:-9999px}#toast-container{position:fixed;bottom:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:10px}.toast{padding:12px 20px;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,.15);opacity:0;transform:translateX(100%);animation:.5s forwards toast-in,.5s 2.5s forwards toast-out}.toast.success{background-color:var(--success-color)}.toast.info{background-color:var(--primary-color)}@keyframes toast-in{to{opacity:1;transform:translateX(0)}}@keyframes toast-out{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(100%)}}@keyframes habit-complete-pop{0%,100%{transform:scale(1)}50%{transform:scale(1.05)}}.completed-animation{animation:.3s ease-out habit-complete-pop}@media (max-width:768px){body{padding:10px}.container{margin:0 auto;padding:16px;border-radius:20px}header h1{font-size:2.2em}.controls,.filters{flex-direction:column;align-items:stretch}.main-actions,.view-switcher{justify-content:center}.calendar-day{min-height:120px;padding:8px;border-radius:12px}.modal-content{padding:24px;width:100%}}
</style>
</head>
<body>
<input type="date" id="date-picker-input">
<div class="container">
<header>
<h1>습관 트래커</h1>
<p id="motivational-quote">작은 습관이 모여 위대한 성공을 이룹니다. ✨</p>
</header>
<div class="controls">
<div class="view-switcher">
<button id="view-calendar" class="active">🗓️ 달력</button>
<button id="view-today">🎯 오늘</button>
<button id="view-timeline">📜 타임라인</button>
<button id="view-stats">📊 통계</button>
<button id="view-review">🧐 리뷰</button>
<button id="view-archive">📦 보관함</button>
</div>
<div class="main-actions">
<button id="achievements-btn" class="secondary">🏆 업적</button>
<button id="add-habit-btn">💪 새 습관 추가</button>
<button id="settings-btn" class="secondary">⚙️</button>
</div>
</div>
<div id="app-filters-container">
<!-- Dynamic filters will be rendered here -->
</div>
<div id="app-content">
<!-- Dynamic content will be rendered here -->
</div>
</div>
<!-- Modals -->
<div id="habit-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">새 습관 만들기</h2>
<span class="close-button">&times;</span>
</div>
<form id="habit-form">
<input type="hidden" id="habit-id">
<div class="form-group">
<label for="habit-name">습관 내용 ✍️</label>
<input type="text" id="habit-name" required placeholder="예: 매일 아침 운동하기">
</div>
<div class="form-group">
<label for="habit-frequency">실천 주기 🗓️</label>
<select id="habit-frequency">
<option value="daily">매일</option>
<option value="weekdays">주중 (월-금)</option>
<option value="weekends">주말 (토-일)</option>
<option value="specific_days">특정 요일</option>
</select>
</div>
<div id="specific-days-group" class="form-group" style="display: none;">
<div class="frequency-days">
<label><input type="checkbox" name="specific-day" value="0"><span></span></label>
<label><input type="checkbox" name="specific-day" value="1"><span></span></label>
<label><input type="checkbox" name="specific-day" value="2"><span></span></label>
<label><input type="checkbox" name="specific-day" value="3"><span></span></label>
<label><input type="checkbox" name="specific-day" value="4"><span></span></label>
<label><input type="checkbox" name="specific-day" value="5"><span></span></label>
<label><input type="checkbox" name="specific-day" value="6"><span></span></label>
</div>
</div>
<div class="modal-buttons">
<div class="left-actions">
<button type="button" id="archive-habit-btn" class="warning" style="display: none;">📦 보관</button>
</div>
<div class="right-actions">
<button type="button" id="delete-habit-btn" class="danger" style="display: none;">🗑️ 삭제</button>
<button type="button" id="cancel-habit-btn" class="secondary">취소</button>
<button type="submit" class="primary">💾 저장</button>
</div>
</div>
</form>
</div>
</div>
<div id="achievements-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>🏆 나의 업적 🏆</h2>
<span class="close-button">&times;</span>
</div>
<div id="achievements-grid" class="achievements-grid">
<!-- Achievements will be rendered here -->
</div>
</div>
</div>
<div id="settings-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>⚙️ 설정 및 데이터 관리</h2>
<span class="close-button">&times;</span>
</div>
<div>
<h3>🌓 테마 설정</h3>
<button id="theme-toggle-btn">다크 모드로 전환 🌙</button>
</div>
<div>
<h3>🗄️ 데이터 관리</h3>
<button id="export-data-btn" class="secondary">데이터 내보내기 (백업)</button>
<label for="import-file-input" class="file-label">데이터 가져오기 (복원)</label>
<input type="file" id="import-file-input" accept=".json">
</div>
<div>
<h3>🚨 위험 구역</h3>
<button id="clear-all-data-btn" class="danger">모든 데이터 초기화</button>
</div>
</div>
</div>
<div id="confirm-modal" class="modal">
<div class="modal-content" style="max-width: 420px; text-align: center;">
<h2 id="confirm-title" style="margin-bottom: 12px;">정말 삭제하시겠습니까?</h2>
<p id="confirm-message" style="color: var(--text-secondary-color); margin-bottom: 24px;">이 작업은 되돌릴 수 없습니다.</p>
<div class="modal-buttons" style="justify-content: center;">
<div class="right-actions">
<button id="confirm-cancel-btn" class="secondary">취소</button>
<button id="confirm-ok-btn" class="danger">확인</button>
</div>
</div>
</div>
</div>
<div id="toast-container"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- MODIFIED: 신규 업적 대량 추가 ---
const achievementList = {
'first_habit': { title: '첫걸음', description: '첫 습관을 추가했습니다.', icon: '🌱' },
'perfect_day': { title: '완벽한 하루', description: '하루의 모든 습관을 완료했습니다.', icon: '💯' },
'7_day_streak': { title: '불타는 일주일', description: '한 습관을 7일 연속으로 달성했습니다.', icon: '🔥' },
'perfect_week': { title: '완벽한 한 주', description: '7일 연속으로 모든 습관을 완료했습니다.', icon: '📅' },
'30_day_streak': { title: '한 달의 위업', description: '한 습관을 30일 연속으로 달성했습니다.', icon: '🗓️' },
'100_completions': { title: '성실함의 증표', description: '한 습관을 100회 완료했습니다.', icon: '👑' },
'5_habits': { title: '습관 부자', description: '5개 이상의 활성 습관을 만들었습니다.', icon: '🏦' },
'10_habits': { title: '습관 콜렉터', description: '10개 이상의 활성 습관을 만들었습니다.', icon: '📚' },
'archivist': { title: '정리정돈', description: '첫 습관을 보관했습니다.', icon: '📦' },
'perfect_month': { title: '완벽한 한 달', description: '한 달 내내 모든 습관을 100% 달성했습니다.', icon: '🏅' },
'180_day_streak': { title: '반년의 끈기', description: '한 습관을 180일 연속으로 달성했습니다.', icon: '🦾' },
'365_day_streak': { title: '1년의 위업', description: '한 습관을 365일 연속으로 달성했습니다.', icon: '🎉' },
'500_completions': { title: '장인정신', description: '한 습관을 500회 완료했습니다.', icon: '💎' },
'1000_completions': { title: '습관의 대가', description: '한 습관을 1000회 완료했습니다.', icon: '🌌' },
'explorer': { title: '탐험가', description: '모든 주요 메뉴를 한 번씩 사용했습니다.', icon: '🧭' },
'data_guardian': { title: '데이터 지킴이', description: '데이터를 처음으로 백업했습니다.', icon: '🛡️' },
'comeback_king': { title: '돌아온 탕자', description: '7일 이상 쉬고 다시 습관을 시작했습니다.', icon: '💪' },
};
let draggedItem = null;
let dropPlaceholder = null;
const app = {
state: {
habits: [],
currentView: 'calendar',
currentDate: new Date(),
settings: {
theme: 'light',
},
achievements: {},
// --- NEW: 리뷰 기능 상태 추가 ---
reviewPeriod: 'weekly',
// --- NEW: 탐험가 업적 달성을 위한 상태 추가 ---
visitedViews: {},
filters: {
search: '',
showArchived: false,
sortBy: 'order',
},
reportPeriod: 'weekly',
chartInstances: {},
},
elements: {},
init() {
this.cacheElements();
this.loadData();
this.state.currentView = 'calendar';
// --- MODIFIED: 사용자가 데이터를 초기화한 후 새로고침 시 기본 습관이 추가되는 것을 방지 ---
const hasBeenInitialized = localStorage.getItem('habitTrackerInitialized') === 'true';
if (this.state.habits.length === 0 && !hasBeenInitialized) {
this.setupDefaultHabits();
localStorage.setItem('habitTrackerInitialized', 'true');
}
this.applySettings();
this.addEventListeners();
this.render();
this.showRandomQuote();
},
setupDefaultHabits() {
const now = Date.now();
const defaultHabits = [
{ id: now + 0, name: '⚖️ 매일 아침 체중 측정', type: 'check', goal: 1, frequency: { type: 'daily', days: [0,1,2,3,4,5,6] }, isArchived: false, order: 0, logs: {}, createdAt: now + 0 },
{ id: now + 1, name: '🥗 샐러드 먹기', type: 'check', goal: 1, frequency: { type: 'daily', days: [0,1,2,3,4,5,6] }, isArchived: false, order: 1, logs: {}, createdAt: now + 1 },
{ id: now + 2, name: '💧 물 1리터 마시기', type: 'check', goal: 1, frequency: { type: 'daily', days: [0,1,2,3,4,5,6] }, isArchived: false, order: 2, logs: {}, createdAt: now + 2 },
{ id: now + 3, name: '🕒 4시 이후 금식', type: 'check', goal: 1, frequency: { type: 'daily', days: [0,1,2,3,4,5,6] }, isArchived: false, order: 3, logs: {}, createdAt: now + 3 },
{ id: now + 4, name: '💊 영양제 먹기', type: 'check', goal: 1, frequency: { type: 'daily', days: [0,1,2,3,4,5,6] }, isArchived: false, order: 4, logs: {}, createdAt: now + 4 },
{ id: now + 5, name: '😴 7~8시간 자기', type: 'check', goal: 1, frequency: { type: 'daily', days: [0,1,2,3,4,5,6] }, isArchived: false, order: 5, logs: {}, createdAt: now + 5 },
{ id: now + 6, name: '🚫 쌀/빵/면/과자/과당음료 먹지 않기', type: 'check', goal: 1, frequency: { type: 'daily', days: [0,1,2,3,4,5,6] }, isArchived: false, order: 6, logs: {}, createdAt: now + 6 },
{ id: now + 7, name: '🏋️ 운동', type: 'check', goal: 1, frequency: { type: 'daily', days: [0,1,2,3,4,5,6] }, isArchived: false, order: 7, logs: {}, createdAt: now + 7 }
];
this.state.habits = defaultHabits;
this.saveData();
},
cacheElements() {
this.elements = {
appContent: document.getElementById('app-content'),
appFiltersContainer: document.getElementById('app-filters-container'),
addHabitBtn: document.getElementById('add-habit-btn'),
settingsBtn: document.getElementById('settings-btn'),
achievementsBtn: document.getElementById('achievements-btn'),
viewButtons: document.querySelectorAll('.view-switcher button'),
habitModal: document.getElementById('habit-modal'),
settingsModal: document.getElementById('settings-modal'),
confirmModal: document.getElementById('confirm-modal'),
achievementsModal: document.getElementById('achievements-modal'),
habitForm: document.getElementById('habit-form'),
modalTitle: document.getElementById('modal-title'),
habitIdInput: document.getElementById('habit-id'),
habitNameInput: document.getElementById('habit-name'),
habitFrequencySelect: document.getElementById('habit-frequency'),
specificDaysGroup: document.getElementById('specific-days-group'),
deleteHabitBtn: document.getElementById('delete-habit-btn'),
archiveHabitBtn: document.getElementById('archive-habit-btn'),
cancelHabitBtn: document.getElementById('cancel-habit-btn'),
themeToggleBtn: document.getElementById('theme-toggle-btn'),
exportDataBtn: document.getElementById('export-data-btn'),
importFileInput: document.getElementById('import-file-input'),
clearAllDataBtn: document.getElementById('clear-all-data-btn'),
confirmTitle: document.getElementById('confirm-title'),
confirmMessage: document.getElementById('confirm-message'),
confirmOkBtn: document.getElementById('confirm-ok-btn'),
confirmCancelBtn: document.getElementById('confirm-cancel-btn'),
achievementsGrid: document.getElementById('achievements-grid'),
motivationalQuote: document.getElementById('motivational-quote'),
datePickerInput: document.getElementById('date-picker-input'),
toastContainer: document.getElementById('toast-container'),
};
},
addEventListeners() {
this.elements.viewButtons.forEach(btn => {
btn.addEventListener('click', () => {
const viewName = btn.id.split('-')[1];
this.state.currentView = viewName;
// --- NEW: 탐험가 업적 확인 로직 ---
this.state.visitedViews[viewName] = true;
this.checkAchievement('explorer');
if (this.state.currentView === 'today') {
this.state.currentDate = this.getTodayUTC();
}
this.render();
});
});
this.elements.addHabitBtn.addEventListener('click', () => this.openHabitModal());
this.elements.settingsBtn.addEventListener('click', () => this.openModal(this.elements.settingsModal));
this.elements.achievementsBtn.addEventListener('click', () => this.openAchievementsModal());
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal || e.target.classList.contains('close-button') || e.target.id === 'cancel-habit-btn') {
this.closeModal(modal);
}
});
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const visibleModal = document.querySelector('.modal.visible');
if (visibleModal) {
this.closeModal(visibleModal);
}
}
});
this.elements.habitForm.addEventListener('submit', (e) => this.handleHabitFormSubmit(e));
this.elements.habitFrequencySelect.addEventListener('change', (e) => {
this.elements.specificDaysGroup.style.display = e.target.value === 'specific_days' ? 'block' : 'none';
});
this.elements.deleteHabitBtn.addEventListener('click', () => this.handleDeleteHabit());
this.elements.archiveHabitBtn.addEventListener('click', () => this.handleArchiveHabit());
this.elements.themeToggleBtn.addEventListener('click', () => this.toggleTheme());
this.elements.exportDataBtn.addEventListener('click', () => this.exportData());
this.elements.importFileInput.addEventListener('change', (e) => this.importData(e));
this.elements.clearAllDataBtn.addEventListener('click', () => this.clearAllData());
this.elements.datePickerInput.addEventListener('change', (e) => this.handleDateJump(e));
this.addDelegatedEventListeners();
},
// ----- DATA & STATE MANAGEMENT -----
saveData() {
localStorage.setItem('habitTrackerDataV2', JSON.stringify(this.state, (key, value) => key === 'chartInstances' ? undefined : value));
},
loadData() {
const data = localStorage.getItem('habitTrackerDataV2');
const oldData = localStorage.getItem('habitTrackerData');
if (data) {
const parsedData = JSON.parse(data);
// --- MODIFIED: UTC 기준 날짜로 변환 ---
if (parsedData.currentDate && !isNaN(new Date(parsedData.currentDate).getTime())) {
const d = new Date(parsedData.currentDate);
parsedData.currentDate = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
} else {
parsedData.currentDate = this.getTodayUTC();
}
this.state = { ...this.state, ...parsedData, chartInstances: {} };
if (!this.state.settings) this.state.settings = { theme: 'light' };
if (this.state.settings.startOfWeek !== undefined) delete this.state.settings.startOfWeek;
if (!this.state.filters) this.state.filters = { search: '', showArchived: false, sortBy: 'order' };
if (!this.state.achievements) this.state.achievements = {};
if (!this.state.visitedViews) this.state.visitedViews = {};
} else {
// --- NEW: Fresh start with UTC date ---
this.state.currentDate = this.getTodayUTC();
}
if ((!this.state.habits || this.state.habits.length === 0) && oldData) {
console.log("Old data found and no new data exists. Migrating...");
this.migrateData(oldData);
}
},
migrateData(oldDataString) {
try {
const oldState = JSON.parse(oldDataString);
if (!oldState.habits) return;
this.state.habits = oldState.habits.map((habit, index) => ({
id: habit.id || Date.now() + index,
name: habit.name || 'Untitled',
type: 'check',
goal: 1,
frequency: { type: 'daily', days: [0, 1, 2, 3, 4, 5, 6] },
isArchived: false,
order: index,
createdAt: habit.id || Date.now() + index,
logs: Object.entries(habit.logs || {}).reduce((acc, [date, value]) => {
const wasCompleted = (habit.type === 'count' && habit.goal) ? (value >= habit.goal) : (value > 0);
acc[date] = { value: wasCompleted ? 1 : 0 };
return acc;
}, {}),
}));
this.state.settings.theme = oldState.settings?.theme || 'light';
this.saveData();
localStorage.removeItem('habitTrackerData');
this.showToast("데이터 구조가 최신 버전으로 업데이트되었습니다!", 'info');
} catch (e) {
console.error("Failed to migrate old data:", e);
}
},
applySettings() {
if (this.state.settings.theme === 'dark') {
document.body.classList.add('dark-mode');
this.elements.themeToggleBtn.textContent = '라이트 모드로 전환 ☀️';
} else {
document.body.classList.remove('dark-mode');
this.elements.themeToggleBtn.textContent = '다크 모드로 전환 🌙';
}
},
toggleTheme() {
this.state.settings.theme = this.state.settings.theme === 'light' ? 'dark' : 'light';
this.applySettings();
this.saveData();
},
// ----- RENDERING -----
render() {
this.updateActiveViewButton();
this.renderFilters();
this.destroyCharts();
const viewRenderers = {
calendar: this.renderCalendar,
today: this.renderToday,
stats: this.renderStats,
timeline: this.renderTimeline,
review: this.renderReview,
archive: this.renderArchive,
};
const renderFunction = viewRenderers[this.state.currentView];
if (renderFunction) {
renderFunction.call(this);
}
},
updateActiveViewButton() {
this.elements.viewButtons.forEach(btn => {
btn.classList.toggle('active', btn.id === `view-${this.state.currentView}`);
});
},
renderFilters() {
const showSort = ['today', 'stats'].includes(this.state.currentView);
const sortOptions = `
<label for="sort-by">정렬:</label>
<select id="sort-by">
<option value="order" ${this.state.filters.sortBy === 'order' ? 'selected' : ''}>기본</option>
<option value="name_asc" ${this.state.filters.sortBy === 'name_asc' ? 'selected' : ''}>이름 (오름차순)</option>
<option value="name_desc" ${this.state.filters.sortBy === 'name_desc' ? 'selected' : ''}>이름 (내림차순)</option>
<option value="created_at" ${this.state.filters.sortBy === 'created_at' ? 'selected' : ''}>최신순</option>
<option value="streak" ${this.state.filters.sortBy === 'streak' ? 'selected' : ''}>현재 연속일순</option>
<option value="completion_rate" ${this.state.filters.sortBy === 'completion_rate' ? 'selected' : ''}>달성률순</option>
</select>
`;
const html = `
<div class="filters">
<input type="search" id="search-filter" placeholder="습관 검색 후 Enter..." value="${this.escapeHTML(this.state.filters.search)}">
<label>
<input type="checkbox" id="archived-filter" ${this.state.filters.showArchived ? 'checked' : ''}>
보관된 습관 보기
</label>
${showSort ? sortOptions : ''}
</div>`;
this.elements.appFiltersContainer.innerHTML = (this.state.currentView !== 'archive' && this.state.currentView !== 'review') ? html : '';
if (this.state.currentView !== 'archive' && this.state.currentView !== 'review') {
const searchInput = document.getElementById('search-filter');
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.state.filters.search = e.target.value;
this.render();
}
});
searchInput.addEventListener('blur', (e) => {
this.state.filters.search = e.target.value;
this.render();
});
document.getElementById('archived-filter').addEventListener('change', (e) => { this.state.filters.showArchived = e.target.checked; this.render(); });
if(showSort) {
document.getElementById('sort-by').addEventListener('change', (e) => { this.state.filters.sortBy = e.target.value; this.render(); });
}
}
},
getFilteredHabits() {
const { sortBy, showArchived, search } = this.state.filters;
const searchLower = search.toLowerCase();
let filteredHabits = this.state.habits
.filter(h => h.isArchived === showArchived)
.filter(h => h.name.toLowerCase().includes(searchLower));
if (['streak', 'completion_rate'].includes(sortBy)) {
filteredHabits = filteredHabits.map(h => ({
...h,
stats: this.calculateHabitStats(h)
}));
}
return filteredHabits.sort((a, b) => {
switch (sortBy) {
case 'name_asc': return a.name.localeCompare(b.name);
case 'name_desc': return b.name.localeCompare(a.name);
case 'created_at': return (b.createdAt || b.id) - (a.createdAt || a.id);
case 'streak': return (b.stats?.currentStreak || 0) - (a.stats?.currentStreak || 0);
case 'completion_rate': return (b.stats?.completionRate || 0) - (a.stats?.completionRate || 0);
case 'order':
default:
return (a.order || 0) - (b.order || 0);
}
});
},
renderEmptyState(view) {
let message = {};
const hasSearchTerm = this.state.filters.search.trim() !== '';
if (this.state.habits.length === 0) {
message = { title: "첫 습관을 만들어볼까요? 🌱", body: "'새 습관 추가' 버튼을 눌러 추적하고 싶은 첫 습관을 만들어보세요!", showAddButton: true };
} else if (hasSearchTerm && this.getFilteredHabits().length === 0) {
message = { title: "검색 결과 없음 🧐", body: `"${this.escapeHTML(this.state.filters.search)}"에 대한 검색 결과가 없습니다.`, showAddButton: false };
} else if (view === 'today' || view === 'calendar') {
message = { title: "표시할 습관이 없어요 🤷", body: "이 날짜에는 예정된 습관이 없거나 필터와 일치하는 습관이 없습니다.", showAddButton: false };
} else {
message = { title: "표시할 습관이 없어요 🤷", body: "필터 설정을 확인하거나 새로운 습관을 추가해보세요.", showAddButton: false };
}
this.elements.appContent.innerHTML = `
<div class="empty-state">
<h3>${message.title}</h3>
<p>${message.body}</p>
${message.showAddButton ? '<button id="empty-add-habit-btn">💪 첫 습관 추가하기</button>' : ''}
</div>
`;
if (message.showAddButton) {
document.getElementById('empty-add-habit-btn').addEventListener('click', () => this.openHabitModal());
}
},
// ----- VIEWS -----
renderCalendar() {
const date = this.state.currentDate;
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const monthName = new Date(Date.UTC(year, month)).toLocaleString('ko-KR', { month: 'long', timeZone: 'UTC' });
const firstDayOfMonth = new Date(Date.UTC(year, month, 1));
const lastDayOfMonth = new Date(Date.UTC(year, month + 1, 0));
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
let html = `
<div class="calendar-header">
<div class="calendar-nav">
<button id="prev-month">ᐊ</button>
<button id="calendar-go-to-today" class="secondary">오늘</button>
<button id="next-month">ᐅ</button>
</div>
<div class="calendar-title-wrapper">
<h2 id="calendar-title">${year}${monthName}</h2>
<button id="calendar-date-picker-trigger-btn" class="secondary" title="날짜 선택" style="width: 40px; height: 40px; padding: 0; border-radius: 50%; font-size: 1.2em;">📅</button>
</div>
</div>
<div class="calendar-grid">
${dayNames.map(day => `<div class="calendar-day-name">${day}</div>`).join('')}
`;
let startDayOffset = firstDayOfMonth.getUTCDay();
for (let i = 0; i < startDayOffset; i++) { html += `<div class="calendar-day other-month"></div>`; }
for (let i = 1; i <= lastDayOfMonth.getUTCDate(); i++) {
const loopDate = new Date(Date.UTC(year, month, i));
const dateStr = this.getUTCDateString(loopDate);
const isToday = this.getUTCDateString(this.getTodayUTC()) === dateStr;
const habitsForDay = this.getFilteredHabits().filter(habit => this.isHabitForDate(habit, loopDate));
const dayOfWeek = loopDate.getUTCDay();
let dayClass = '';
if (dayOfWeek === 0) dayClass = 'sunday';
else if (dayOfWeek === 6) dayClass = 'saturday';
html += `
<div class="calendar-day ${isToday ? 'today' : ''}">
<div class="day-number ${dayClass}">${i}</div>
<div class="calendar-habit-list">
${habitsForDay.map(habit => this.getHabitHTMLForCalendar(habit, dateStr)).join('')}
</div>
</div>`;
}
html += `</div>`;
this.elements.appContent.innerHTML = html;
},
renderToday() {
const selectedDate = this.state.currentDate;
const selectedDateStr = this.getUTCDateString(selectedDate);
const todayUTC = this.getTodayUTC();
const todayStr = this.getUTCDateString(todayUTC);
const yesterdayUTC = new Date(todayUTC.getTime() - 86400000);
const yesterdayStr = this.getUTCDateString(yesterdayUTC);
let title = selectedDate.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' });
if (selectedDateStr === todayStr) title = '오늘';
if (selectedDateStr === yesterdayStr) title = '어제';
let headerHtml = `
<div class="view-header" style="position: relative;">
<div class="today-nav">
<button id="prev-day">ᐊ</button>
<button id="go-to-today-btn" class="secondary">오늘</button>
<button id="next-day">ᐅ</button>
</div>
<div style="display: flex; align-items: center; gap: 8px; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); margin: 0; cursor: pointer;">
<h2 id="today-view-date-title" style="margin: 0;">${title}</h2>
<button id="date-picker-trigger-btn" class="secondary" title="날짜 선택" style="width: 40px; height: 40px; padding: 0; border-radius: 50%; font-size: 1.2em;">📅</button>
</div>
</div>`;
let contentHtml = '';
if (this.state.habits.length === 0) {
contentHtml = `
<div class="empty-state">
<h3>첫 습관을 만들어볼까요? 🌱</h3>
<p>'새 습관 추가' 버튼을 눌러 추적하고 싶은 첫 습관을 만들어보세요!</p>
<button id="empty-add-habit-btn">💪 첫 습관 추가하기</button>
</div>
`;
this.elements.appContent.innerHTML = contentHtml; // 헤더 없이 전체를 덮어씀
document.getElementById('empty-add-habit-btn').addEventListener('click', () => this.openHabitModal());
return;
}
const habitsForDay = this.getFilteredHabits().filter(h => this.isHabitForDate(h, selectedDate));
if (habitsForDay.length === 0) {
contentHtml = `
<div class="empty-state" style="padding-top: 20px;">
<h3>표시할 습관이 없어요 🤷</h3>
<p>이 날짜에는 예정된 습관이 없거나 필터와 일치하는 습관이 없습니다.</p>
</div>
`;
} else {
contentHtml = `<div id="habit-list-container">${habitsForDay.map(habit => this.getHabitHTMLForList(habit, selectedDateStr)).join('')}</div>`;
}
this.elements.appContent.innerHTML = headerHtml + contentHtml;
},
renderStats() {
const habits = this.state.habits.filter(h => !h.isArchived);
if (habits.length === 0) {
this.renderEmptyState('stats');
return;
}
let html = this.getReportHTML();
html += this.getStatsDashboardHTML(habits);
html += '<div class="stats-grid">';
html += this.getFilteredHabits()
.map(habit => this.getHabitStatCard(habit)).join('');
html += '</div>';
this.elements.appContent.innerHTML = html;
this.renderStatsCharts(this.getFilteredHabits());
},
renderTimeline() {
const habits = this.getFilteredHabits();
if (this.state.habits.length === 0) {
this.renderEmptyState('timeline');
return;
}
let allLogs = [];
habits.forEach(habit => {
Object.keys(habit.logs).forEach(dateStr => {
const logData = habit.logs[dateStr];
if (logData && logData.value > 0) {
allLogs.push({ date: this.parseUTCDateString(dateStr), habit, ...logData, isCompleted: this.isHabitCompletedOn(habit, dateStr) });
}
});
});
if (allLogs.length === 0) {
this.renderEmptyState('timeline');
return;
}
allLogs.sort((a, b) => b.date - a.date);
let html = '<div class="timeline-container">';
html += allLogs.map((log, index) => this.getTimelineItem(log, index)).join('');
html += '</div>';
this.elements.appContent.innerHTML = html;
},
renderReview() {
const getBtnClass = (p) => this.state.reviewPeriod === p ? 'active' : '';
let headerHtml = `
<div class="view-header">
<h2>🧐 주간/월간 리뷰</h2>
<div class="view-switcher" style="padding: 2px; border-radius: 8px;">
<button class="review-period-btn ${getBtnClass('weekly')}" data-period="weekly" style="padding: 4px 10px; font-size: 0.8em; border-radius: 6px;">지난 주</button>
<button class="review-period-btn ${getBtnClass('monthly')}" data-period="monthly" style="padding: 4px 10px; font-size: 0.8em; border-radius: 6px;">지난 달</button>
</div>
</div>
`;
const contentHtml = this.getReviewHTML(this.state.reviewPeriod);
this.elements.appContent.innerHTML = headerHtml + contentHtml;
},
renderArchive() {
const archivedHabits = this.state.habits.filter(h => h.isArchived);
if (archivedHabits.length === 0) {
this.elements.appContent.innerHTML = `
<div class="empty-state">
<h3>📦 보관함이 비었어요</h3>
<p>습관 편집 메뉴에서 '보관' 버튼을 누르면 여기에 표시됩니다.</p>
</div>
`;
return;
}
const listHtml = archivedHabits.map(habit => `
<div class="archive-item" data-habit-id="${habit.id}">
<div class="archive-item-header">
<span class="archive-item-title">${this.escapeHTML(habit.name)}</span>
<div class="archive-item-actions">
<button class="unarchive-btn" title="보관 취소">↩️</button>
<button class="delete-permanently-btn" title="영구 삭제">🗑️</button>
</div>
</div>
</div>
`).join('');
this.elements.appContent.innerHTML = `<div id="archive-list-container">${listHtml}</div>`;
},
// ----- MODAL HANDLERS -----
openModal(modalElement) {
document.body.style.overflow = 'hidden';
modalElement.classList.add('visible');
this.trapFocus(modalElement);
},
closeModal(modalElement) {
document.body.style.overflow = '';
modalElement.classList.remove('visible');
if (modalElement._focusTrapHandler) {
modalElement.removeEventListener('keydown', modalElement._focusTrapHandler);
delete modalElement._focusTrapHandler;
}
},
openHabitModal(habitId = null) {
this.elements.habitForm.reset();
this.elements.habitIdInput.value = '';
this.elements.deleteHabitBtn.style.display = 'none';
this.elements.archiveHabitBtn.style.display = 'none';
this.elements.specificDaysGroup.style.display = 'none';
if (habitId) {
const habit = this.state.habits.find(h => h.id == habitId);
if (habit) {
this.elements.modalTitle.textContent = '습관 편집 ✍️';
this.elements.habitIdInput.value = habit.id;
this.elements.habitNameInput.value = habit.name;
const freq = habit.frequency || { type: 'daily', days: [0,1,2,3,4,5,6] };
this.elements.habitFrequencySelect.value = freq.type;
if(freq.type === 'specific_days') {
this.elements.specificDaysGroup.style.display = 'block';
document.querySelectorAll('input[name="specific-day"]').forEach(checkbox => {
checkbox.checked = freq.days.includes(parseInt(checkbox.value));
});
}
this.elements.deleteHabitBtn.style.display = 'inline-block';
this.elements.archiveHabitBtn.style.display = 'inline-block';
this.elements.archiveHabitBtn.textContent = habit.isArchived ? '📦 보관 취소' : '📦 보관';
this.elements.archiveHabitBtn.classList.toggle('warning', !habit.isArchived);
this.elements.archiveHabitBtn.classList.toggle('secondary', habit.isArchived);
}
} else {
this.elements.modalTitle.textContent = '새 습관 만들기 💪';
}
this.openModal(this.elements.habitModal);
setTimeout(() => this.elements.habitNameInput.focus(), 100);
},
openAchievementsModal() {
this.renderAchievements();
this.openModal(this.elements.achievementsModal);
},
// ----- HTML CHUNKS -----
getHabitHTMLForCalendar(habit, dateStr) {
const isCompleted = this.isHabitCompletedOn(habit, dateStr);
const safeName = this.escapeHTML(habit.name);
return `<div class="habit-on-calendar ${isCompleted ? 'completed' : ''}" data-habit-id="${habit.id}" data-date="${dateStr}">${safeName}</div>`;
},
getHabitHTMLForList(habit, dateStr) {
const log = habit.logs[dateStr] || { value: 0 };
const progress = log.value * 100;
const safeName = this.escapeHTML(habit.name);
const isDraggable = this.state.filters.sortBy === 'order';
return `
<div class="habit-list-item" ${isDraggable ? 'draggable="true"' : ''} data-habit-id="${habit.id}">
<div class="habit-header">
<span class="habit-title">${safeName}</span>
<div class="habit-actions">
<button class="edit-habit-btn" data-habit-id="${habit.id}" aria-label="습관 수정">✏️</button>
</div>
</div>
<div class="habit-progress" data-habit-id="${habit.id}" data-date="${dateStr}">
<input type="checkbox" class="habit-check" ${log.value === 1 ? 'checked' : ''}>
</div>
<div class="progress-bar-container"><div class="progress-bar" style="width: ${Math.min(100, progress)}%;"></div></div>
</div>`;
},
getHabitStatCard(habit) {
const stats = habit.stats && habit.stats.totalCompletions !== undefined ? habit.stats : this.calculateHabitStats(habit);
const safeName = this.escapeHTML(habit.name);
return `
<div class="stat-card">
<h3 class="stat-card-title">${safeName}</h3>
<div class="stat-item"><span>🔥 현재 연속 달성</span><span>${stats.currentStreak}일</span></div>
<div class="stat-item"><span>🏆 최고 연속 달성</span><span>${stats.longestStreak}일</span></div>
<div class="stat-item"><span>🎯 총 달성일</span><span>${stats.totalCompletions}일</span></div>
<div class="stat-item"><span>✅ 전체 달성률</span><span>${stats.completionRate}%</span></div>
<div class="chart-container"><canvas id="chart-habit-${habit.id}"></canvas></div>
<div class="heatmap-container-wrapper"><h4>지난 1년 활동 기록</h4>${this.generateHeatmap(habit)}</div>
</div>`;
},
getReportDateRange(period) {
const today = this.getTodayUTC();
let startDate = new Date(today);
switch(period) {
case 'this_week':
const dayOfWeek = today.getUTCDay();
startDate.setUTCDate(today.getUTCDate() - dayOfWeek);
break;
case 'this_month':
startDate.setUTCDate(1);
break;
case 'monthly':
startDate.setUTCDate(today.getUTCDate() - 29);
break;
case 'yearly':
startDate.setUTCFullYear(today.getUTCFullYear() - 1);
startDate.setUTCDate(today.getUTCDate() + 1);
break;
case 'weekly':
default:
startDate.setUTCDate(today.getUTCDate() - 6);
break;
}
return { startDate, endDate: today };
},
calculateReportStats(startDate, endDate) {
let totalPossible = 0;
let totalCompleted = 0;
const habits = this.state.habits.filter(h => !h.isArchived);
for (let d = new Date(startDate); d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) {
const dateStr = this.getUTCDateString(d);
habits.forEach(habit => {
if (this.isHabitForDate(habit, d)) {
totalPossible++;
if (this.isHabitCompletedOn(habit, dateStr)) {
totalCompleted++;
}
}
});
}
const rate = totalPossible > 0 ? Math.round((totalCompleted / totalPossible) * 100) : 0;
return { totalPossible, totalCompleted, rate };
},
calculateHabitPerformance(habit, startDate, endDate) {
let possible = 0;
let completed = 0;
for (let d = new Date(startDate); d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) {
if (this.isHabitForDate(habit, d)) {
possible++;
if (this.isHabitCompletedOn(habit, this.getUTCDateString(d))) {
completed++;
}
}
}
const rate = possible > 0 ? Math.round((completed / possible) * 100) : 0;
return { possible, completed, rate };
},
getReportHTML() {
const period = this.state.reportPeriod;
const periodText = {
this_week: '이번 주',
this_month: '이번 달',
weekly: '지난 7일',
monthly: '지난 30일',
yearly: '지난 1년'
}[period];
const { startDate, endDate } = this.getReportDateRange(period);
const currentStats = this.calculateReportStats(startDate, endDate);
const activeHabits = this.state.habits.filter(h => !h.isArchived);
const habitPerformances = activeHabits
.map(habit => ({
habit,
performance: this.calculateHabitPerformance(habit, startDate, endDate)
}))
.filter(item => item.performance.possible > 0)
.sort((a, b) => b.performance.rate - a.performance.rate || b.performance.completed - a.performance.completed);
let mostMissedHabit = null;
if (activeHabits.length > 0) {
const missedCounts = activeHabits.map(habit => ({
habit,
missed: this.getMissedCount(habit, startDate, endDate)
})).sort((a, b) => b.missed - a.missed);
if (missedCounts[0].missed > 0) {
mostMissedHabit = missedCounts[0];
}
}
let bestPerfHTML = `<div class="stat-item"><span>🥇 최고 성과 습관</span><span>-</span></div>`;
let worstPerfHTML = `<div class="stat-item"><span>🧗‍♀️ 개선 필요 습관</span><span>-</span></div>`;
let mostMissedHTML = mostMissedHabit ? `<div class="stat-item"><span>😥 가장 많이 놓친 습관</span><span>${this.escapeHTML(mostMissedHabit.habit.name)} (${mostMissedHabit.missed}회)</span></div>` : '';
let individualPerfHTML = '';
if (habitPerformances.length > 0) {
const best = habitPerformances[0];
bestPerfHTML = `<div class="stat-item"><span>🥇 최고 성과 습관</span><span>${this.escapeHTML(best.habit.name)} (${best.performance.rate}%)</span></div>`;
if (habitPerformances.length > 1) {
const worst = habitPerformances[habitPerformances.length - 1];
worstPerfHTML = `<div class="stat-item"><span>🧗‍♀️ 개선 필요 습관</span><span>${this.escapeHTML(worst.habit.name)} (${worst.performance.rate}%)</span></div>`;
}
// --- MODIFIED: details/summary 태그 제거하고 항상 보이도록 변경 ---
individualPerfHTML = `
<div style="margin-top: 24px;">
<h4 style="font-weight: 600; margin-bottom: 10px;">개별 습관 달성률</h4>
<div style="padding-left: 10px; border-left: 2px solid var(--border-color);">
${habitPerformances.map(item => `
<div class="stat-item" style="padding: 6px 0;">
<span>${this.escapeHTML(item.habit.name)}</span>
<span style="color: ${item.performance.rate >= 80 ? 'var(--success-color)' : item.performance.rate < 50 ? 'var(--missed-color)' : 'inherit'};">
${item.performance.rate}% (${item.performance.completed}/${item.performance.possible})
</span>
</div>
`).join('')}
</div>
</div>
`;
}
let prevStartDate, prevEndDate;
const diff = (endDate.getTime() - startDate.getTime());
prevEndDate = new Date(startDate.getTime() - 86400000);
prevStartDate = new Date(prevEndDate.getTime() - diff);
if (period === 'this_month') {
prevStartDate = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth() - 1, 1));
prevEndDate = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), 0));
} else if (period === 'this_week') {
prevStartDate = new Date(startDate.getTime() - 7 * 86400000);
prevEndDate = new Date(startDate.getTime() - 1 * 86400000);
}
const prevStats = this.calculateReportStats(prevStartDate, prevEndDate);
const difference = currentStats.rate - prevStats.rate;
let comparisonHTML = '';
if (prevStats.totalPossible > 0) {
let icon = '➖';
let color = 'var(--text-secondary-color)';
if (difference > 0) {
icon = `🔼`;
color = 'var(--success-color)';
} else if (difference < 0) {
icon = `🔽`;
color = 'var(--missed-color)';
}
comparisonHTML = `<span style="font-size: 0.9em; color: ${color}; margin-left: 10px;">${icon} ${Math.abs(difference)}%</span>`;
}
const getBtnClass = (p) => this.state.reportPeriod === p ? 'active' : '';
return `
<div class="stat-card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h3 class="stat-card-title" style="margin-bottom:0; border:0; padding:0;">${periodText} 리포트 📈</h3>
<div class="view-switcher" style="padding: 2px; border-radius: 8px;">
<button class="report-period-btn ${getBtnClass('this_week')}" data-period="this_week" style="padding: 4px 10px; font-size: 0.8em; border-radius: 6px;">이번 주</button>
<button class="report-period-btn ${getBtnClass('this_month')}" data-period="this_month" style="padding: 4px 10px; font-size: 0.8em; border-radius: 6px;">이번 달</button>
<button class="report-period-btn ${getBtnClass('weekly')}" data-period="weekly" style="padding: 4px 10px; font-size: 0.8em; border-radius: 6px;">7일</button>
<button class="report-period-btn ${getBtnClass('monthly')}" data-period="monthly" style="padding: 4px 10px; font-size: 0.8em; border-radius: 6px;">30일</button>
<button class="report-period-btn ${getBtnClass('yearly')}" data-period="yearly" style="padding: 4px 10px; font-size: 0.8em; border-radius: 6px;">1년</button>
</div>
</div>
<div class="stat-item"><span>총 실천 가능 횟수</span><span>${currentStats.totalPossible}회</span></div>
<div class="stat-item"><span>총 완료 횟수</span><span>${currentStats.totalCompleted}회</span></div>
<div class="stat-item">
<span>평균 달성률</span>
<div style="display: flex; align-items: center;">
<span style="font-size: 1.5em; color: var(--primary-color);">${currentStats.rate}%</span>
${comparisonHTML}
</div>
</div>
${bestPerfHTML}
${worstPerfHTML}
${mostMissedHTML}
${individualPerfHTML}
</div>
`;
},
getReviewHTML(period) {
const today = this.getTodayUTC();
let startDate, endDate;
let periodTitle = '';
if (period === 'weekly') {
periodTitle = '지난 주';
const dateForCalc = new Date(today);
dateForCalc.setUTCDate(dateForCalc.getUTCDate() - dateForCalc.getUTCDay() - 7);
startDate = new Date(dateForCalc);
endDate = new Date(startDate);
endDate.setUTCDate(startDate.getUTCDate() + 6);
} else { // monthly
periodTitle = '지난 달';
startDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1));
endDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0));
}
const stats = this.calculateReviewStats(startDate, endDate);
const localeOpts = { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric' };
if (stats.totalPossible === 0) {
return `
<div class="empty-state">
<h3>데이터가 부족해요 📊</h3>
<p>${periodTitle}(${startDate.toLocaleDateString('ko-KR', localeOpts)} ~ ${endDate.toLocaleDateString('ko-KR', localeOpts)})에 대한 기록이 없습니다. 습관을 꾸준히 기록해 주세요!</p>
</div>`;
}
let summaryMessage = `지난 주에는 평균 <span style="color:var(--primary-color); font-weight: bold;">${stats.completionRate}%</span>의 달성률을 보였어요. `;
if (stats.completionRate >= 90) {
summaryMessage += "정말 대단해요! 완벽에 가까운 한 주였습니다. ✨";
} else if (stats.completionRate >= 70) {
summaryMessage += "아주 잘하고 있어요! 꾸준함이 엿보이네요. 👍";
} else if (stats.completionRate >= 50) {
summaryMessage += "절반의 성공! 다음 주에는 조금 더 힘내봐요. 😊";
} else {
summaryMessage += "괜찮아요, 다시 시작하면 돼요! 이번 주 목표를 다시 세워볼까요? 💪";
}
return `
<div class="stat-card">
<h3 class="stat-card-title">${periodTitle} 리뷰 (${startDate.toLocaleDateString('ko-KR', localeOpts)} ~ ${endDate.toLocaleDateString('ko-KR', localeOpts)})</h3>
<p style="font-size: 1.2em; text-align: center; margin: 20px 0;">${summaryMessage}</p>
<div class="stat-item">
<span>🏆 가장 잘 지킨 습관</span>
<span>${stats.bestHabit ? `${this.escapeHTML(stats.bestHabit.name)} (${stats.bestHabit.rate}%)` : '-'}</span>
</div>
<div class="stat-item">
<span>🧗‍♀️ 가장 아쉬웠던 습관</span>
<span>${stats.worstHabit ? `${this.escapeHTML(stats.worstHabit.name)} (${stats.worstHabit.rate}%)` : '-'}</span>
</div>
<div class="stat-item">
<span>✅ 총 달성률</span>
<span style="font-size: 1.5em; color: var(--primary-color);">${stats.completionRate}%</span>
</div>
<div class="stat-item">
<span>총 완료 / 가능 횟수</span>
<span>${stats.totalCompleted} / ${stats.totalPossible} 회</span>
</div>
<div style="margin-top: 16px;">
<h4 style="margin-bottom: 8px;">습관별 성과</h4>
${stats.habitPerformances.map(item => `
<div class="stat-item" style="padding: 6px 0;">
<span>${this.escapeHTML(item.habit.name)}</span>
<span style="color: ${item.rate >= 80 ? 'var(--success-color)' : item.rate < 50 ? 'var(--missed-color)' : 'inherit'};">
${item.rate}% (${item.completed}/${item.possible})
</span>
</div>
`).join('')}
</div>
</div>
`;
},
getStatsDashboardHTML(habits) {
let totalHabitsForToday = 0, completedHabitsForToday = 0;
const todayUTC = this.getTodayUTC(), todayStr = this.getUTCDateString(todayUTC);
habits.forEach(h => {
if (this.isHabitForDate(h, todayUTC)) {
totalHabitsForToday++;
if (this.isHabitCompletedOn(h, todayStr)) completedHabitsForToday++;
}
});
const todayCompletionRate = totalHabitsForToday > 0 ? Math.round((completedHabitsForToday / totalHabitsForToday) * 100) : 0;
const totalActiveHabits = this.state.habits.filter(h => !h.isArchived).length;
return `
<div class="stats-dashboard">
<div class="dashboard-card"><div class="dashboard-card-title">오늘 달성률</div><div class="dashboard-card-value">${todayCompletionRate}%</div></div>
<div class="dashboard-card"><div class="dashboard-card-title">총 활성 습관</div><div class="dashboard-card-value">${totalActiveHabits}</div></div>
<div class="dashboard-card"><div class="dashboard-card-title">최고 연속 기록</div><div class="dashboard-card-value">${Math.max(0, ...habits.map(h => this.calculateHabitStats(h).longestStreak))}일</div></div>
<div class="dashboard-card" style="grid-column: 1 / -1;"><div class="dashboard-card-title">월별 전체 달성률</div><div class="chart-container" style="height: 250px;"><canvas id="main-stats-chart"></canvas></div></div>
<div class="dashboard-card" style="grid-column: 1 / -1;"><div class="dashboard-card-title">요일별 성공/실패 분석</div><div class="chart-container" style="height: 250px;"><canvas id="day-of-week-chart"></canvas></div></div>
<div class="dashboard-card" style="grid-column: 1 / -1;">
<div class="dashboard-card-title">전체 활동 히트맵 (지난 1년)</div>
<div class="heatmap-container-wrapper" style="margin-top: 0;">${this.generateOverallHeatmap()}</div>
</div>
</div>`;
},
getTimelineItem(log, index) {
const dateStr = log.date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' });
const status = log.isCompleted ? '완료! 🎉' : '진행중...';
const safeName = this.escapeHTML(log.habit.name);
return `
<div class="timeline-item">
<div class="timeline-date">${dateStr}</div>
<h4>${safeName} - <span style="font-weight: 400">${status}</span></h4>
</div>`;
},
// ----- DYNAMIC EVENT LISTENERS (EVENT DELEGATION) -----
addDelegatedEventListeners() {
this.elements.appContent.addEventListener('click', (e) => {
const habitCalendarItem = e.target.closest('.habit-on-calendar');
if (habitCalendarItem) {
const { habitId, date } = habitCalendarItem.dataset;
this.handleHabitClick(habitId, date, habitCalendarItem);
return;
}
const editBtn = e.target.closest('.edit-habit-btn');
if (editBtn) {
this.openHabitModal(editBtn.dataset.habitId);
return;
}
const datePickerTrigger = e.target.closest('#date-picker-trigger-btn, #calendar-date-picker-trigger-btn, #today-view-date-title');
if (datePickerTrigger) {
const picker = this.elements.datePickerInput;
const rect = datePickerTrigger.getBoundingClientRect();
document.body.appendChild(picker);
picker.style.position = 'absolute';
picker.style.top = `${rect.bottom + window.scrollY + 5}px`;
picker.style.left = `${rect.left + window.scrollX + (rect.width / 2)}px`;
picker.style.transform = 'translateX(-50%)';
if (e.target.closest('#calendar-date-picker-trigger-btn')) {
picker.type = 'month';
picker.value = this.getUTCDateString(this.state.currentDate).substring(0, 7);
} else {
picker.type = 'date';
picker.value = this.getUTCDateString(this.state.currentDate);
}
try {
picker.showPicker();
} catch (err) {
console.warn("showPicker() is not supported by this browser.", err);
}
return;
}
if (e.target.id === 'prev-day') this.changeDay(-1);
if (e.target.id === 'next-day') this.changeDay(1);
if (e.target.id === 'go-to-today-btn' || e.target.id === 'calendar-go-to-today') { this.state.currentDate = this.getTodayUTC(); this.render(); }
if (e.target.id === 'prev-month') this.changeMonth(-1);
if (e.target.id === 'next-month') this.changeMonth(1);
if (e.target.closest('#calendar-title')) this.jumpToMonth();
const unarchiveBtn = e.target.closest('.unarchive-btn');
if (unarchiveBtn) {
const habitId = unarchiveBtn.closest('.archive-item').dataset.habitId;
this.unarchiveHabit(habitId);
return;
}
const deletePermanentlyBtn = e.target.closest('.delete-permanently-btn');
if (deletePermanentlyBtn) {
const habitId = deletePermanentlyBtn.closest('.archive-item').dataset.habitId;
this.deleteHabitPermanently(habitId);
return;
}
const reportPeriodBtn = e.target.closest('.report-period-btn');
if (reportPeriodBtn) {
this.state.reportPeriod = reportPeriodBtn.dataset.period;
this.render();
return;
}
const reviewPeriodBtn = e.target.closest('.review-period-btn');
if (reviewPeriodBtn) {
this.state.reviewPeriod = reviewPeriodBtn.dataset.period;
this.render();
return;
}
});
this.elements.appContent.addEventListener('change', (e) => {
const habitCheck = e.target.closest('.habit-check');
if (habitCheck) {
const habitProgress = e.target.closest('.habit-progress');
const { habitId, date } = habitProgress.dataset;
this.updateCheck(habitId, date, habitCheck.checked);
return;
}
});
this.elements.appContent.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('habit-list-item') && e.target.draggable) {
draggedItem = e.target;
dropPlaceholder = document.createElement('div');
dropPlaceholder.className = 'drop-placeholder';
setTimeout(() => e.target.classList.add('dragging'), 0);
}
});
this.elements.appContent.addEventListener('dragend', (e) => {
if (draggedItem) {
draggedItem.classList.remove('dragging');
if (dropPlaceholder && dropPlaceholder.parentNode) {
dropPlaceholder.parentNode.removeChild(dropPlaceholder);
}
this.updateHabitOrder();
draggedItem = null;
dropPlaceholder = null;
}
});
this.elements.appContent.addEventListener('dragover', (e) => {
e.preventDefault();
const container = e.target.closest('#habit-list-container');
if(container && draggedItem) {
const afterElement = this.getDragAfterElement(container, e.clientY);
if (afterElement == null) {
container.appendChild(draggedItem);
} else {
container.insertBefore(draggedItem, afterElement);
}
}
});
},
// ----- FORM SUBMISSIONS & ACTIONS -----
handleHabitFormSubmit(e) {
e.preventDefault();
const id = this.elements.habitIdInput.value;
const frequencyType = this.elements.habitFrequencySelect.value;
const selectedDays = Array.from(document.querySelectorAll('input[name="specific-day"]:checked')).map(cb => parseInt(cb.value));
if (frequencyType === 'specific_days' && selectedDays.length === 0) {
this.showToast('최소 하나 이상의 요일을 선택해야 합니다.', 'danger');
return;
}
const frequencyMap = { 'daily': [0,1,2,3,4,5,6], 'weekdays': [1,2,3,4,5], 'weekends': [0,6] };
const habitData = {
name: this.elements.habitNameInput.value.trim(),
type: 'check',
goal: 1,
frequency: { type: frequencyType, days: frequencyType === 'specific_days' ? selectedDays : frequencyMap[frequencyType] },
};
if (id) {
const index = this.state.habits.findIndex(h => h.id == id);
if (index > -1) this.state.habits[index] = { ...this.state.habits[index], ...habitData };
} else {
const now = Date.now();
const maxOrder = this.state.habits.reduce((max, h) => Math.max(max, h.order || 0), 0);
this.state.habits.push({ ...habitData, id: now, logs: {}, isArchived: false, order: maxOrder + 1, createdAt: now });
this.checkAchievement('first_habit');
this.checkAchievement('5_habits');
this.checkAchievement('10_habits');
}
this.saveData();
this.render();
this.closeModal(this.elements.habitModal);
},
handleDeleteHabit() {
const id = this.elements.habitIdInput.value;
if (!id) return;
this.deleteHabitPermanently(id, () => {
this.closeModal(this.elements.habitModal);
});
},
deleteHabitPermanently(habitId, callback) {
this.showConfirmDialog('🗑️ 영구 삭제', '정말로 이 습관과 모든 기록을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', () => {
this.state.habits = this.state.habits.filter(h => h.id != habitId);
this.saveData();
this.render();
if (callback) callback();
});
},
handleArchiveHabit() {
const id = this.elements.habitIdInput.value;
const habit = this.state.habits.find(h => h.id == id);
if(habit) {
habit.isArchived = !habit.isArchived;
if (habit.isArchived) {
this.checkAchievement('archivist');
}
this.saveData();
this.render();
this.closeModal(this.elements.habitModal);
}
},
unarchiveHabit(habitId) {
const habit = this.state.habits.find(h => h.id == habitId);
if(habit) {
habit.isArchived = false;
this.saveData();
this.render();
}
},
// ----- HABIT LOGIC & HELPERS -----
updateCheck(habitId, dateStr, isChecked) {
const habit = this.state.habits.find(h => h.id == habitId);
if (!habit) return;
if (!habit.logs[dateStr]) habit.logs[dateStr] = { value: 0 };
habit.logs[dateStr].value = isChecked ? 1 : 0;
this.checkComebackAchievement(habit, dateStr, isChecked);
this.saveData();
const habitItem = document.querySelector(`.habit-list-item[data-habit-id='${habitId}']`);
if(habitItem) {
const progressBar = habitItem.querySelector('.progress-bar');
if (progressBar) {
progressBar.style.width = isChecked ? '100%' : '0%';
}
if(isChecked){
habitItem.classList.add('completed-animation');
setTimeout(() => habitItem.classList.remove('completed-animation'), 300);
}
}
this.checkAllAchievements();
},
handleHabitClick(habitId, dateStr, element) {
const habit = this.state.habits.find(h => h.id == habitId);
if (!habit) return;
if (!habit.logs[dateStr]) habit.logs[dateStr] = { value: 0 };
const isCompleted = habit.logs[dateStr].value === 1;
habit.logs[dateStr].value = isCompleted ? 0 : 1;
this.checkComebackAchievement(habit, dateStr, !isCompleted);
this.saveData();
element.classList.toggle('completed', !isCompleted);
if (!isCompleted) {
element.classList.add('completed-animation');
setTimeout(() => element.classList.remove('completed-animation'), 300);
}
this.checkAllAchievements();
},
calculateHabitStats(habit) {
const sortedDates = Object.keys(habit.logs).sort();
if (sortedDates.length === 0) return { currentStreak: 0, longestStreak: 0, totalCompletions: 0, completionRate: 0 };
let totalCompletions = 0, longestStreak = 0;
let currentLongestStreak = 0;
let lastCompletionDate = null;
sortedDates.forEach(dateStr => {
if (this.isHabitCompletedOn(habit, dateStr)) {
totalCompletions++;
const currentDate = this.parseUTCDateString(dateStr);
if (lastCompletionDate) {
let missedPracticeDay = false;
let checkDate = new Date(lastCompletionDate);
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
while(this.getUTCDateString(checkDate) < this.getUTCDateString(currentDate)) {
if (this.isHabitForDate(habit, checkDate)) {
missedPracticeDay = true;
break;
}
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
}
currentLongestStreak = missedPracticeDay ? 1 : currentLongestStreak + 1;
} else {
currentLongestStreak = 1;
}
longestStreak = Math.max(longestStreak, currentLongestStreak);
lastCompletionDate = currentDate;
}
});
let currentStreak = 0;
const today = this.getTodayUTC();
for (let i = 0; i < 365 * 5; i++) {
let dateToCheck = new Date(today);
dateToCheck.setUTCDate(today.getUTCDate() - i);
if (this.isHabitForDate(habit, dateToCheck)) {
if (this.isHabitCompletedOn(habit, this.getUTCDateString(dateToCheck))) {
currentStreak++;
} else {
if(i > 0) break;
}
}
}
const firstDate = this.parseUTCDateString(sortedDates[0]);
const totalDays = Math.ceil((new Date() - firstDate) / (1000 * 60 * 60 * 24));
const completionRate = totalCompletions === 0 ? 0 : Math.round((totalCompletions / Math.max(1, totalCompletions + (this.getMissedCount(habit)))) * 100);
return { currentStreak, longestStreak, totalCompletions, completionRate };
},
calculateReviewStats(startDate, endDate) {
const habits = this.state.habits.filter(h => !h.isArchived);
let totalPossible = 0;
let totalCompleted = 0;
const habitPerformances = habits.map(habit => {
const perf = this.calculateHabitPerformance(habit, startDate, endDate);
totalPossible += perf.possible;
totalCompleted += perf.completed;
return { habit, ...perf };
})
.filter(item => item.possible > 0)
.sort((a, b) => b.rate - a.rate || b.completed - a.completed);
const completionRate = totalPossible > 0 ? Math.round((totalCompleted / totalPossible) * 100) : 0;
return {
totalPossible,
totalCompleted,
completionRate,
bestHabit: habitPerformances.length > 0 ? { name: habitPerformances[0].habit.name, rate: habitPerformances[0].rate } : null,
worstHabit: habitPerformances.length > 1 ? { name: habitPerformances[habitPerformances.length - 1].habit.name, rate: habitPerformances[habitPerformances.length - 1].rate } : null,
habitPerformances
};
},
getMissedCount(habit, startDate = null, endDate = null) {
let missed = 0;
const sortedDates = Object.keys(habit.logs).sort();
if (sortedDates.length === 0 && !startDate) return 0;
const firstDate = startDate ? new Date(startDate) : this.parseUTCDateString(sortedDates[0]);
const lastDate = endDate ? new Date(endDate) : this.getTodayUTC();
for(let d = new Date(firstDate); d <= lastDate; d.setUTCDate(d.getUTCDate() + 1)) {
if (this.isHabitForDate(habit, d) && !this.isHabitCompletedOn(habit, this.getUTCDateString(d))) {
missed++;
}
}
return missed;
},
// ----- UTILITY & HELPERS (UTC BASED) -----
getTodayUTC: () => {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
},
getUTCDateString: (date) => {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
parseUTCDateString: (dateStr) => {
const [year, month, day] = dateStr.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
},
isHabitForDate: (habit, date) => (habit.frequency?.days || [0,1,2,3,4,5,6]).includes(date.getUTCDay()),
isHabitCompletedOn(habit, dateStr) {
const log = habit.logs[dateStr];
if (!log) return false;
return log.value === 1;
},
changeMonth(offset) {
const newDate = new Date(this.state.currentDate);
newDate.setUTCDate(1);
newDate.setUTCMonth(newDate.getUTCMonth() + offset);
this.state.currentDate = newDate;
this.render();
},
jumpToMonth() {
const picker = this.elements.datePickerInput;
const rect = document.getElementById('calendar-title').getBoundingClientRect();
document.body.appendChild(picker);
picker.style.position = 'absolute';
picker.style.top = `${rect.bottom + window.scrollY + 5}px`;
picker.style.left = `${rect.left + window.scrollX + (rect.width / 2)}px`;
picker.style.transform = 'translateX(-50%)';
picker.type = 'month';
picker.value = this.getUTCDateString(this.state.currentDate).substring(0, 7);
try {
picker.showPicker();
} catch (e) {
console.warn("showPicker() is not supported by this browser.");
}
},
handleDateJump(event) {
const value = event.target.value;
if (!value) return;
if (event.target.type === 'month') {
const [year, month] = value.split('-').map(Number);
if (!isNaN(year) && !isNaN(month)) {
this.state.currentDate = new Date(Date.UTC(year, month - 1, 1));
this.render();
}
} else if (event.target.type === 'date') {
const [year, month, day] = value.split('-').map(Number);
if (!isNaN(year) && !isNaN(month) && !isNaN(day)) {
this.state.currentDate = new Date(Date.UTC(year, month - 1, day));
this.render();
}
}
event.target.style.cssText = '';
},
changeDay(offset) {
const newDate = new Date(this.state.currentDate);
newDate.setUTCDate(newDate.getUTCDate() + offset);
this.state.currentDate = newDate;
this.render();
},
escapeHTML(str) {
if (typeof str !== 'string') return '';
return str.replace(/[&<>'"]/g,
tag => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;'
}[tag] || tag)
);
},
// ----- DRAG & DROP -----
getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.habit-list-item:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
return (offset < 0 && offset > closest.offset) ? { offset, element: child } : closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
},
updateHabitOrder() {
const habitElements = this.elements.appContent.querySelectorAll('#habit-list-container .habit-list-item');
const orderedIds = Array.from(habitElements).map(el => Number(el.dataset.habitId));
let currentOrder = 10;
orderedIds.forEach(id => {
const habit = this.state.habits.find(h => h.id === id);
if (habit) {
habit.order = currentOrder;
currentOrder += 10;
}
});
this.saveData();
},
generateHeatmap(habit) {
const endDate = this.getTodayUTC();
const startDate = new Date(endDate);
startDate.setUTCFullYear(endDate.getUTCFullYear() - 1);
startDate.setUTCDate(startDate.getUTCDate() + 1);
const dates = [];
for (let d = new Date(startDate); d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) {
dates.push(new Date(d));
}
const cells = Array(53 * 7).fill(null);
dates.forEach(date => {
const dateStr = this.getUTCDateString(date);
const isCompleted = this.isHabitCompletedOn(habit, dateStr);
const level = isCompleted ? 4 : 0;
const dayOfWeek = date.getUTCDay();
const firstDayOfYear = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const pastDaysOfYear = (date - firstDayOfYear) / 86400000;
const weekNumber = Math.floor((pastDaysOfYear + firstDayOfYear.getUTCDay()) / 7);
cells[weekNumber * 7 + dayOfWeek] = `<div class="heatmap-cell" data-level="${level}" title="${dateStr}" style="grid-column: ${weekNumber + 1}; grid-row: ${dayOfWeek + 1};"></div>`;
});
return `<div class="heatmap-wrapper">
<div class="heatmap-body">
<div class="heatmap-grid">${cells.filter(c => c).join('')}</div>
</div>
</div>`;
},
generateOverallHeatmap() {
const habits = this.state.habits.filter(h => !h.isArchived);
if (habits.length === 0) return '<p>활성 습관이 없습니다.</p>';
const endDate = this.getTodayUTC();
const startDate = new Date(endDate);
startDate.setUTCFullYear(endDate.getUTCFullYear() - 1);
startDate.setUTCDate(startDate.getUTCDate() + 1);
const dailyData = {};
for (let d = new Date(startDate); d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) {
const dateStr = this.getUTCDateString(d);
let possible = 0;
let completed = 0;
habits.forEach(habit => {
if (this.isHabitForDate(habit, d)) {
possible++;
if (this.isHabitCompletedOn(habit, dateStr)) {
completed++;
}
}
});
dailyData[dateStr] = { possible, completed };
}
const cells = [];
for (let d = new Date(startDate); d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) {
const dateStr = this.getUTCDateString(d);
const data = dailyData[dateStr];
let level = 0;
let title = `${dateStr}: 예정된 습관 없음`;
if (data && data.possible > 0) {
const rate = data.completed / data.possible;
if (rate === 1) level = 4;
else if (rate >= 0.67) level = 3;
else if (rate >= 0.34) level = 2;
else if (rate > 0) level = 1;
title = `${dateStr}: ${data.completed} / ${data.possible}개 완료 (${Math.round(rate * 100)}%)`;
}
const dayOfWeek = d.getUTCDay();
const firstDayOfYear = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const pastDaysOfYear = (d - firstDayOfYear) / 86400000;
const weekNumber = Math.floor((pastDaysOfYear + firstDayOfYear.getUTCDay()) / 7);
cells.push(`<div class="heatmap-cell" data-level="${level}" title="${this.escapeHTML(title)}" style="grid-column: ${weekNumber + 1}; grid-row: ${dayOfWeek + 1};"></div>`);
}
return `<div class="heatmap-wrapper">
<div class="heatmap-body">
<div class="heatmap-grid">${cells.join('')}</div>
</div>
</div>`;
},
// ----- CHARTS -----
destroyCharts() {
Object.values(this.state.chartInstances).forEach(chart => chart.destroy());
this.state.chartInstances = {};
},
renderStatsCharts(habits) {
this.renderMainMonthlyChart(habits);
this.renderDayOfWeekChart(habits);
habits.forEach(habit => this.renderHabitActivityChart(habit));
},
renderMainMonthlyChart(habits) {
const ctx = document.getElementById('main-stats-chart')?.getContext('2d');
if (!ctx) return;
const labels = [];
const data = [];
const today = this.getTodayUTC();
for(let i=5; i>=0; i--) {
const date = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - i, 1));
labels.push(date.toLocaleString('ko-KR', { month: 'long', timeZone: 'UTC' }));
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
let totalPossible = 0;
let totalCompleted = 0;
for(let day=1; day<=daysInMonth; day++) {
const loopDate = new Date(Date.UTC(year, month, day));
const dateStr = this.getUTCDateString(loopDate);
habits.forEach(habit => {
if (this.isHabitForDate(habit, loopDate)) {
totalPossible++;
if (this.isHabitCompletedOn(habit, dateStr)) {
totalCompleted++;
}
}
});
}
data.push(totalPossible > 0 ? (totalCompleted / totalPossible) * 100 : 0);
}
this.state.chartInstances.main = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: '달성률 (%)', data, backgroundColor: 'rgba(0, 122, 255, 0.6)', borderColor: 'rgba(0, 122, 255, 1)', borderWidth: 1 }] },
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, max: 100 } } }
});
},
renderDayOfWeekChart(habits) {
const ctx = document.getElementById('day-of-week-chart')?.getContext('2d');
if (!ctx) return;
const possible = Array(7).fill(0);
const completed = Array(7).fill(0);
let firstLogDate = this.getTodayUTC();
if (habits.length > 0) {
const allLogDates = habits.flatMap(h => Object.keys(h.logs).map(d => this.parseUTCDateString(d)));
if (allLogDates.length > 0) {
firstLogDate = new Date(Math.min(...allLogDates));
}
}
const today = this.getTodayUTC();
for (let d = new Date(firstLogDate); d <= today; d.setUTCDate(d.getUTCDate() + 1)) {
const dayOfWeek = d.getUTCDay();
const dateStr = this.getUTCDateString(d);
habits.forEach(habit => {
if (this.isHabitForDate(habit, d)) {
possible[dayOfWeek]++;
if (this.isHabitCompletedOn(habit, dateStr)) {
completed[dayOfWeek]++;
}
}
});
}
const missed = possible.map((p, i) => p - completed[i]);
this.state.chartInstances.dayOfWeek = new Chart(ctx, {
type: 'bar',
data: {
labels: ['일', '월', '화', '수', '목', '금', '토'],
datasets: [
{
label: '성공',
data: completed,
backgroundColor: 'rgba(52, 199, 89, 0.7)'
},
{
label: '실패',
data: missed,
backgroundColor: 'rgba(255, 59, 48, 0.5)'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { stacked: true },
y: { stacked: true, beginAtZero: true }
},
plugins: {
tooltip: {
callbacks: {
footer: function(tooltipItems) {
let total = 0;
tooltipItems.forEach(item => total += item.parsed.y);
if (total > 0) {
const completed = tooltipItems.find(i => i.dataset.label === '성공')?.parsed.y || 0;
return `달성률: ${Math.round(completed / total * 100)}%`;
}
return '';
}
}
}
}
}
});
},
renderHabitActivityChart(habit) {
const ctx = document.getElementById(`chart-habit-${habit.id}`)?.getContext('2d');
if (!ctx) return;
const labels = [];
const data = [];
const today = this.getTodayUTC();
for(let i=29; i>=0; i--) {
const date = new Date(today);
date.setUTCDate(today.getUTCDate() - i);
labels.push(`${date.getUTCMonth()+1}/${date.getUTCDate()}`);
const dateStr = this.getUTCDateString(date);
const log = habit.logs[dateStr];
data.push(log && log.value === 1 ? 1 : 0);
}
this.state.chartInstances[`habit-${habit.id}`] = new Chart(ctx, {
type: 'line',
data: { labels, datasets: [{ label: '활동량', data, fill: true, backgroundColor: 'rgba(0, 122, 255, 0.1)', borderColor: 'rgba(0, 122, 255, 1)', tension: 0.3, pointRadius: 2 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } }
});
},
// ----- ACHIEVEMENTS -----
checkAchievement(id) {
if (this.state.achievements[id]) return;
const unlockConditions = {
'first_habit': () => this.state.habits.length > 0,
'5_habits': () => this.state.habits.filter(h => !h.isArchived).length >= 5,
'10_habits': () => this.state.habits.filter(h => !h.isArchived).length >= 10,
'perfect_day': () => {
const today = this.getTodayUTC(), todayStr = this.getUTCDateString(today);
const habitsForToday = this.state.habits.filter(h => !h.isArchived && this.isHabitForDate(h, today));
return habitsForToday.length > 0 && habitsForToday.every(h => this.isHabitCompletedOn(h, todayStr));
},
'perfect_week': () => {
const today = this.getTodayUTC();
for (let i = 0; i < 7; i++) {
const dateToCheck = new Date(today); dateToCheck.setUTCDate(today.getUTCDate() - i);
const dateStr = this.getUTCDateString(dateToCheck);
const habitsForDay = this.state.habits.filter(h => !h.isArchived && this.isHabitForDate(h, dateToCheck));
if (habitsForDay.length > 0 && !habitsForDay.every(h => this.isHabitCompletedOn(h, dateStr))) {
return false;
}
}
return this.state.habits.filter(h => !h.isArchived).length > 0;
},
// --- MODIFIED: 'perfect_month' 업적 로직 개선 ---
'perfect_month': () => {
const activeHabits = this.state.habits.filter(h => !h.isArchived);
if (activeHabits.length === 0) return false;
const allLogDates = activeHabits.flatMap(h => Object.keys(h.logs));
if (allLogDates.length === 0) return false;
const monthsToCheck = new Set(allLogDates.map(d => d.substring(0, 7)));
for (const monthStr of monthsToCheck) {
const [year, month] = monthStr.split('-').map(Number);
const firstDay = new Date(Date.UTC(year, month - 1, 1));
const lastDay = new Date(Date.UTC(year, month, 0));
let isMonthPerfect = true;
for (let d = new Date(firstDay); d <= lastDay; d.setUTCDate(d.getUTCDate() + 1)) {
const dateStr = this.getUTCDateString(d);
const habitsForDay = activeHabits.filter(h => this.isHabitForDate(h, d));
if (habitsForDay.length > 0) {
if (!habitsForDay.every(h => this.isHabitCompletedOn(h, dateStr))) {
isMonthPerfect = false;
break;
}
}
}
if (isMonthPerfect) return true;
}
return false;
},
'7_day_streak': () => this.state.habits.some(h => this.calculateHabitStats(h).currentStreak >= 7),
'30_day_streak': () => this.state.habits.some(h => this.calculateHabitStats(h).currentStreak >= 30),
'180_day_streak': () => this.state.habits.some(h => this.calculateHabitStats(h).currentStreak >= 180),
'365_day_streak': () => this.state.habits.some(h => this.calculateHabitStats(h).currentStreak >= 365),
'100_completions': () => this.state.habits.some(h => this.calculateHabitStats(h).totalCompletions >= 100),
'500_completions': () => this.state.habits.some(h => this.calculateHabitStats(h).totalCompletions >= 500),
'1000_completions': () => this.state.habits.some(h => this.calculateHabitStats(h).totalCompletions >= 1000),
'archivist': () => this.state.habits.some(h => h.isArchived),
'explorer': () => Object.keys(this.state.visitedViews).length >= 6,
'comeback_king': () => false,
'data_guardian': () => false,
};
if (unlockConditions[id] && unlockConditions[id]()) {
this.state.achievements[id] = { unlockedAt: new Date().toISOString() };
this.saveData();
const { title, description } = achievementList[id];
this.showToast(`🏆 ${title}: ${description}`, 'success', 5000);
}
},
checkAllAchievements() { Object.keys(achievementList).forEach(id => this.checkAchievement(id)); },
checkComebackAchievement(habit, dateStr, isChecked) {
if (!isChecked) return;
const today = this.parseUTCDateString(dateStr);
let lastPracticeDate = null;
const sortedLogs = Object.keys(habit.logs)
.filter(d => d < dateStr && habit.logs[d].value > 0)
.sort().pop();
if(sortedLogs) {
lastPracticeDate = this.parseUTCDateString(sortedLogs);
const diffDays = Math.floor((today - lastPracticeDate) / (1000 * 60 * 60 * 24));
if (diffDays >= 7) {
this.checkAchievement('comeback_king');
}
}
},
renderAchievements() {
this.elements.achievementsGrid.innerHTML = Object.entries(achievementList).map(([id, ach]) => `
<div class="achievement-card ${this.state.achievements[id] ? 'unlocked' : ''}">
<div class="achievement-icon">${this.state.achievements[id] ? ach.icon : '❓'}</div>
<div class="achievement-title">${ach.title}</div>
<p class="achievement-desc">${ach.description}</p>
</div>
`).join('');
},
// ----- DATA MANAGEMENT -----
exportData() {
const dataStr = JSON.stringify(this.state, (key, value) => key === 'chartInstances' ? undefined : value, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
const d = new Date();
const yy = String(d.getFullYear()).slice(-2);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
a.download = `${yy}${mm}${dd}_Habit_Tracker_Backup.json`;
a.click();
URL.revokeObjectURL(a.href);
if (!this.state.achievements['data_guardian']) {
this.state.achievements['data_guardian'] = { unlockedAt: new Date().toISOString() };
this.saveData();
const { title, description } = achievementList['data_guardian'];
this.showToast(`🏆 ${title}: ${description}`, 'success', 5000);
}
},
importData(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedState = JSON.parse(e.target.result);
if (importedState.habits && importedState.settings) {
const habitCount = importedState.habits.length;
const logCount = importedState.habits.reduce((sum, h) => sum + Object.keys(h.logs || {}).length, 0);
const confirmMsg = `현재 모든 데이터는 덮어씌워집니다. 가져올 데이터: 습관 ${habitCount}개, 기록 ${logCount}개. 계속하시겠습니까?`;
this.showConfirmDialog('📥 데이터 가져오기', confirmMsg, () => {
// --- MODIFIED: UTC Date conversion on import ---
const d = new Date(importedState.currentDate);
importedState.currentDate = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
this.state = { ...this.state, ...importedState, chartInstances: {} };
if (!this.state.filters) this.state.filters = { search: '', showArchived: false, sortBy: 'order' };
if (!this.state.achievements) this.state.achievements = {};
this.saveData(); this.applySettings(); this.render();
this.showToast(`데이터를 성공적으로 가져왔습니다!`, 'success');
this.closeModal(this.elements.settingsModal);
});
} else { this.showToast('⚠️ 유효하지 않은 파일 형식입니다.', 'danger'); }
} catch (error) { this.showToast('⚠️ 파일을 읽는 중 오류가 발생했습니다.', 'danger'); }
};
reader.readAsText(file);
event.target.value = '';
},
clearAllData() {
this.showConfirmDialog('🚨 모든 데이터 초기화', '정말로 모든 습관과 기록을 삭제하시겠습니까?', () => {
this.state.habits = [];
this.state.achievements = {};
this.saveData(); this.render();
this.closeModal(this.elements.settingsModal);
this.showToast('모든 데이터가 초기화되었습니다. 🧹', 'info');
});
},
showConfirmDialog(title, message, onConfirm) {
this.elements.confirmTitle.textContent = title;
this.elements.confirmMessage.textContent = message;
const onOkClick = () => {
onConfirm();
this.closeModal(this.elements.confirmModal);
cleanup();
};
const onCancelClick = () => {
this.closeModal(this.elements.confirmModal);
cleanup();
};
const cleanup = () => {
this.elements.confirmOkBtn.removeEventListener('click', onOkClick);
this.elements.confirmCancelBtn.removeEventListener('click', onCancelClick);
};
this.elements.confirmOkBtn.addEventListener('click', onOkClick, { once: true });
this.elements.confirmCancelBtn.addEventListener('click', onCancelClick, { once: true });
this.openModal(this.elements.confirmModal);
},
trapFocus(modal) {
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
};
modal.addEventListener('keydown', handleKeyDown);
modal._focusTrapHandler = handleKeyDown;
},
// ----- MOTIVATION & NOTIFICATION -----
showToast(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
this.elements.toastContainer.appendChild(toast);
setTimeout(() => {
toast.remove();
}, duration);
},
showRandomQuote() {
const quotes = ["성공의 비밀은 꾸준함에 있다. 💪", "오늘의 작은 실천이 내일의 큰 변화를 만든다. 🌱", "어제보다 나은 오늘을 만들자. ✨", "포기하지 않는 한, 실패는 없다. 🌟", "꾸준함이 재능을 이긴다. 🐢"];
this.elements.motivationalQuote.textContent = quotes[Math.floor(Math.random() * quotes.length)];
}
};
app.init();
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment