Last active
November 11, 2025 11:40
-
-
Save lunamoth/1ef007854235c56cfcd2bb3421a38f82 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>🌟 습관 트래커 🚀</title> | |
| <!-- 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">×</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">×</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">×</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 => ({ | |
| '&': '&', | |
| '<': '<', | |
| '>': '>', | |
| "'": ''', | |
| '"': '"' | |
| }[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