Created
December 8, 2025 14:45
-
-
Save lunamoth/9abc95e39dc8e9c32b01f90100684ea9 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>💪 다이어트 챌린지 2025</title> | |
| <!-- 필수 라이브러리: Chart.js 및 Date Adapter (순서 중요) --> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/date-fns"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script> | |
| <style> | |
| :root { | |
| --primary: #4CAF50; | |
| --primary-dark: #388E3C; | |
| --secondary: #2196F3; | |
| --secondary-dark: #1976D2; | |
| --accent: #FF9800; | |
| --danger: #F44336; | |
| --bg: #f0f2f5; | |
| --card-bg: #ffffff; | |
| --text: #333333; | |
| --text-light: #757575; | |
| /* 히트맵 색상 */ | |
| --heatmap-empty: #ebedf0; | |
| --heatmap-1: #9be9a8; /* 약한 감량 */ | |
| --heatmap-2: #40c463; | |
| --heatmap-3: #30a14e; | |
| --heatmap-4: #216e39; /* 강한 감량 */ | |
| --heatmap-gain: #ffcdd2; /* 증량 */ | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text); | |
| margin: 0; | |
| padding: 20px; | |
| line-height: 1.6; | |
| } | |
| .container { max-width: 900px; margin: 0 auto; } | |
| /* 헤더 */ | |
| header { | |
| text-align: center; | |
| margin-bottom: 30px; | |
| background: linear-gradient(135deg, var(--primary), var(--secondary)); | |
| padding: 30px; | |
| border-radius: 20px; | |
| color: white; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.1); | |
| } | |
| h1 { margin: 0; font-size: 2.2rem; } | |
| .subtitle { opacity: 0.9; margin-top: 5px; font-size: 1rem; } | |
| /* 카드 공통 */ | |
| .card { | |
| background: var(--card-bg); | |
| border-radius: 16px; | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.05); | |
| transition: transform 0.2s; | |
| } | |
| .card h3 { margin-top: 0; border-bottom: 2px solid #f0f0f0; padding-bottom: 10px; margin-bottom: 15px; font-size: 1.2rem; display: flex; justify-content: space-between; align-items: center; } | |
| /* 입력 폼 */ | |
| .input-group { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; } | |
| .input-wrapper { flex: 1; min-width: 120px; } | |
| label { display: block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 5px; font-weight: bold; } | |
| input, select { width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 1rem; box-sizing: border-box; outline: none; } | |
| input:focus { border-color: var(--primary); } | |
| button.add-btn { | |
| background-color: var(--primary); color: white; border: none; padding: 10px 24px; border-radius: 10px; | |
| font-size: 1rem; cursor: pointer; font-weight: bold; min-width: 100px; box-shadow: 0 4px 6px rgba(76, 175, 80, 0.3); | |
| } | |
| button.add-btn:active { transform: scale(0.98); } | |
| /* 설정 패널 */ | |
| .settings-toggle { text-align: right; margin-bottom: 10px; cursor: pointer; color: var(--text-light); font-size: 0.9rem; } | |
| #settingsPanel { display: none; background: #fafafa; border: 1px dashed #ccc; } | |
| .danger-zone { border: 1px solid var(--danger); background: #fff5f5; padding: 15px; border-radius: 10px; margin-top: 20px; } | |
| /* 대시보드 그리드 */ | |
| .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 15px; margin-bottom: 20px; } | |
| .stat-card { background: var(--card-bg); padding: 15px; border-radius: 12px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.05); } | |
| .stat-icon { font-size: 1.8rem; margin-bottom: 5px; display: block; } | |
| .stat-value { font-size: 1.4rem; font-weight: bold; color: var(--text); } | |
| .stat-label { font-size: 0.8rem; color: var(--text-light); } | |
| /* 텍스트 리포트 */ | |
| .text-report { background: #e3f2fd; color: #0d47a1; padding: 12px 18px; border-radius: 10px; font-size: 0.95rem; margin-bottom: 15px; display: flex; align-items: center; gap: 10px; } | |
| /* 상세 분석 그리드 */ | |
| .analysis-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 15px; margin-bottom: 20px; } | |
| /* 테이블 */ | |
| .data-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } | |
| .data-table th, .data-table td { padding: 10px; text-align: center; border-bottom: 1px solid #eee; } | |
| .data-table th { background-color: #f9f9f9; color: var(--text-light); font-weight: 600; } | |
| .data-table td.pos { color: var(--danger); font-weight: bold; } /* 증량 */ | |
| .data-table td.neg { color: var(--primary); font-weight: bold; } /* 감량 */ | |
| /* 히트맵 */ | |
| .heatmap-container { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; padding: 10px 0; max-width: 100%; } | |
| .heatmap-cell { width: 12px; height: 12px; border-radius: 2px; background-color: var(--heatmap-empty); cursor: pointer; position: relative; } | |
| .heatmap-legend { display: flex; justify-content: flex-end; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--text-light); margin-top: 5px; } | |
| .legend-box { width: 10px; height: 10px; border-radius: 2px; } | |
| /* 차트 컨테이너 */ | |
| .mini-chart-container { height: 200px; width: 100%; position: relative; } | |
| .chart-container { position: relative; height: 350px; width: 100%; } | |
| /* 필터 */ | |
| .filter-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 15px; } | |
| .filter-group { display: flex; gap: 5px; } | |
| .filter-btn { background: #f0f0f0; border: none; padding: 6px 12px; border-radius: 15px; font-size: 0.85rem; cursor: pointer; color: var(--text-light); } | |
| .filter-btn.active { background: var(--secondary); color: white; font-weight: bold; } | |
| .date-range-picker { display: flex; gap: 5px; align-items: center; font-size: 0.85rem; } | |
| @media (max-width: 600px) { | |
| h1 { font-size: 1.6rem; } | |
| .dashboard-grid { grid-template-columns: 1fr 1fr; } | |
| .filter-row { flex-direction: column; align-items: flex-start; } | |
| .input-group { flex-direction: column; } | |
| .input-wrapper, button.add-btn { width: 100%; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <div style="font-size: 3rem; margin-bottom: 10px;">🏃♂️🥗💧</div> | |
| <h1>다이어트 챌린지 2025</h1> | |
| <div class="subtitle">데이터 기반 체중 관리 시스템</div> | |
| </header> | |
| <!-- 설정 영역 --> | |
| <div class="settings-toggle" onclick="toggleSettings()">⚙️ 설정 / 백업 / 초기화</div> | |
| <div class="card" id="settingsPanel"> | |
| <h4 style="margin-top:0;">기본 설정</h4> | |
| <div class="input-group" style="margin-bottom: 20px;"> | |
| <div class="input-wrapper"> | |
| <label>📏 키 (cm)</label> | |
| <input type="number" id="userHeight" value="175"> | |
| </div> | |
| <div class="input-wrapper"> | |
| <label>🏁 시작 체중 (kg)</label> | |
| <input type="number" id="startWeight" value="78.5" step="0.1"> | |
| </div> | |
| <div class="input-wrapper"> | |
| <label>🎯 목표 체중 (kg)</label> | |
| <input type="number" id="goal1Weight" value="70" step="0.1"> | |
| </div> | |
| <button class="add-btn" style="background-color: var(--secondary);" onclick="saveSettings()">저장</button> | |
| </div> | |
| <h4 style="margin-top:0;">📂 백업 데이터 불러오기 (CSV)</h4> | |
| <div class="input-group" style="margin-bottom: 20px; border: 1px dashed #ccc; padding: 10px; border-radius: 10px;"> | |
| <input type="file" id="csvFileInput" accept=".csv" style="border:none;"> | |
| <button class="add-btn" style="background-color: var(--text-light); padding: 8px 15px; font-size: 0.9rem;" onclick="importCSV()">불러오기</button> | |
| </div> | |
| <div class="danger-zone"> | |
| <h4 style="color: var(--danger); margin-top:0;">⚠️ 위험 구역</h4> | |
| <p style="font-size: 0.9rem; margin-bottom: 10px;">모든 데이터를 삭제하려면 <strong>"초기화"</strong>라고 입력하세요.</p> | |
| <div style="display: flex; gap: 10px;"> | |
| <input type="text" id="resetConfirmInput" placeholder="초기화 입력" style="max-width: 150px;"> | |
| <button onclick="safeResetData()" style="background-color: var(--danger); color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer;">삭제</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 입력 영역 --> | |
| <div class="card"> | |
| <h3>📅 오늘의 기록</h3> | |
| <div class="input-group"> | |
| <div class="input-wrapper"> | |
| <label>날짜</label> | |
| <input type="date" id="dateInput"> | |
| </div> | |
| <div class="input-wrapper"> | |
| <label>현재 체중 (kg)</label> | |
| <input type="number" id="weightInput" step="0.1" placeholder="예: 78.0"> | |
| </div> | |
| <button class="add-btn" onclick="addRecord()">기록하기 📝</button> | |
| </div> | |
| </div> | |
| <!-- 주간/월간 리포트 텍스트 --> | |
| <div class="text-report"> | |
| <span style="font-size: 1.2rem;">📢</span> | |
| <span id="analysisText">데이터를 입력하면 분석 리포트가 여기에 표시됩니다.</span> | |
| </div> | |
| <!-- 핵심 통계 대시보드 --> | |
| <div class="dashboard-grid"> | |
| <div class="stat-card"> | |
| <span class="stat-icon">⚖️</span> | |
| <div class="stat-value" id="currentWeightDisplay">-</div> | |
| <div class="stat-label">현재 체중</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">📉</span> | |
| <div class="stat-value" id="totalLostDisplay">-</div> | |
| <div class="stat-label">총 감량</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🚀</span> | |
| <div class="stat-value" id="progressPercent">-</div> | |
| <div class="stat-label">목표 달성률</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🔮</span> | |
| <div class="stat-value" id="predictedDate" style="font-size: 1.1rem; line-height: 1.8rem;">-</div> | |
| <div class="stat-label">목표 달성 예상</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🔥</span> | |
| <div class="stat-value" id="streakDisplay">-</div> | |
| <div class="stat-label">연속 감량 (Streak)</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">📊</span> | |
| <div class="stat-value" id="successRateDisplay">-</div> | |
| <div class="stat-label">감량 성공률 (일일)</div> | |
| </div> | |
| </div> | |
| <!-- 메인 차트 영역 --> | |
| <div class="card"> | |
| <h3>📈 체중 변화 그래프 (시계열)</h3> | |
| <div class="filter-row"> | |
| <div class="filter-group"> | |
| <button class="filter-btn" id="btn-1m" onclick="setChartFilter('1M')">1개월</button> | |
| <button class="filter-btn" id="btn-3m" onclick="setChartFilter('3M')">3개월</button> | |
| <button class="filter-btn active" id="btn-all" onclick="setChartFilter('ALL')">전체</button> | |
| </div> | |
| <div class="date-range-picker"> | |
| <input type="date" id="chartStartDate" onchange="applyCustomDateRange()"> | |
| <span>~</span> | |
| <input type="date" id="chartEndDate" onchange="applyCustomDateRange()"> | |
| </div> | |
| </div> | |
| <div class="chart-container"> | |
| <canvas id="mainChart"></canvas> | |
| </div> | |
| <div style="text-align: center; margin-top: 10px; font-size: 0.85rem; color: var(--text-light);"> | |
| <label><input type="checkbox" id="showTrend" checked onchange="updateMainChart()"> 7일 이동평균선(추세) 표시</label> | |
| </div> | |
| </div> | |
| <!-- 상세 분석 영역 --> | |
| <div class="analysis-grid"> | |
| <!-- 1. 감량 속도 --> | |
| <div class="card"> | |
| <h3>⚡ 감량 페이스 & 주간 비교</h3> | |
| <table class="data-table"> | |
| <tr><th>구분</th><th>속도 (일평균)</th></tr> | |
| <tr><td>최근 7일</td><td id="rate7Days">-</td></tr> | |
| <tr><td>최근 30일</td><td id="rate30Days">-</td></tr> | |
| </table> | |
| <div style="margin-top: 20px; text-align: center;"> | |
| <h4 style="font-size: 0.9rem; color:var(--text-light); margin-bottom: 5px;">지난주 vs 이번주 (평균 체중)</h4> | |
| <div id="weeklyCompareDisplay" style="font-size: 1.3rem; font-weight: bold;">-</div> | |
| </div> | |
| </div> | |
| <!-- 2. 요일별 통계 --> | |
| <div class="card"> | |
| <h3>📅 요일별 평균 변화량</h3> | |
| <div class="mini-chart-container"> | |
| <canvas id="dayOfWeekChart"></canvas> | |
| </div> | |
| <p style="font-size: 0.75rem; text-align: center; color: var(--text-light); margin-top:5px;">막대가 아래(녹색)일수록 살이 잘 빠지는 요일입니다.</p> | |
| </div> | |
| <!-- 3. 히스토그램 --> | |
| <div class="card"> | |
| <h3>📊 체중 구간별 빈도</h3> | |
| <div class="mini-chart-container"> | |
| <canvas id="histogramChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 히트맵 --> | |
| <div class="card"> | |
| <h3>🌿 감량 히트맵 (최근 1년)</h3> | |
| <div class="heatmap-container" id="heatmapGrid"></div> | |
| <div class="heatmap-legend"> | |
| <span>증량</span> <div class="legend-box" style="background: var(--heatmap-gain)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-empty)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-1)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-2)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-3)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-4)"></div> <span>감량 성공</span> | |
| </div> | |
| </div> | |
| <!-- 데이터 테이블 탭 --> | |
| <div class="card"> | |
| <h3>📑 상세 리포트 & 기록</h3> | |
| <div class="filter-row"> | |
| <div class="filter-group"> | |
| <button class="filter-btn active" id="tab-btn-monthly" onclick="switchTab('tab-monthly')">월별 결산</button> | |
| <button class="filter-btn" id="tab-btn-weekly" onclick="switchTab('tab-weekly')">주별 결산</button> | |
| <button class="filter-btn" id="tab-btn-milestone" onclick="switchTab('tab-milestone')">마일스톤</button> | |
| <button class="filter-btn" id="tab-btn-history" onclick="switchTab('tab-history')">전체 기록</button> | |
| </div> | |
| </div> | |
| <!-- 탭 내용 --> | |
| <div id="tab-monthly" class="tab-content"> | |
| <table class="data-table"> | |
| <thead><tr><th>월</th><th>시작</th><th>종료</th><th>변화량</th><th>평균</th></tr></thead> | |
| <tbody id="monthlyTableBody"></tbody> | |
| </table> | |
| </div> | |
| <div id="tab-weekly" class="tab-content" style="display:none;"> | |
| <table class="data-table"> | |
| <thead><tr><th>주차 (시작일)</th><th>평균 체중</th><th>변화</th></tr></thead> | |
| <tbody id="weeklyTableBody"></tbody> | |
| </table> | |
| </div> | |
| <div id="tab-milestone" class="tab-content" style="display:none;"> | |
| <table class="data-table"> | |
| <thead><tr><th>달성 목표</th><th>달성일</th><th>소요 기간</th></tr></thead> | |
| <tbody id="milestoneTableBody"></tbody> | |
| </table> | |
| </div> | |
| <div id="tab-history" class="tab-content" style="display:none; max-height: 400px; overflow-y: auto;"> | |
| <table class="data-table"> | |
| <thead><tr><th>날짜</th><th>체중</th><th>변화</th><th>관리</th></tr></thead> | |
| <tbody id="historyList"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- 1. 전역 변수 및 설정 --- | |
| const STORAGE_KEY = 'diet_pro_records'; | |
| const SETTINGS_KEY = 'diet_pro_settings'; | |
| let records = JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; | |
| let settings = JSON.parse(localStorage.getItem(SETTINGS_KEY)) || { | |
| height: 175, startWeight: 78.5, goal1: 70 | |
| }; | |
| // 차트 인스턴스 전역 변수 | |
| let mainChart = null; | |
| let dowChart = null; | |
| let histChart = null; | |
| // 차트 필터 모드 | |
| let chartFilterMode = 'ALL'; | |
| let customStart = null; | |
| let customEnd = null; | |
| // --- 2. 초기화 --- | |
| window.onload = function() { | |
| // 날짜 입력창 오늘로 설정 | |
| document.getElementById('dateInput').valueAsDate = new Date(); | |
| // 설정값 로드 | |
| document.getElementById('userHeight').value = settings.height; | |
| document.getElementById('startWeight').value = settings.startWeight; | |
| document.getElementById('goal1Weight').value = settings.goal1; | |
| // UI 렌더링 시작 | |
| updateUI(); | |
| }; | |
| // --- 3. 기본 기능 (저장, 삭제, CSV) --- | |
| function toggleSettings() { | |
| const panel = document.getElementById('settingsPanel'); | |
| panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; | |
| } | |
| function saveSettings() { | |
| settings.height = parseFloat(document.getElementById('userHeight').value); | |
| settings.startWeight = parseFloat(document.getElementById('startWeight').value); | |
| settings.goal1 = parseFloat(document.getElementById('goal1Weight').value); | |
| localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); | |
| toggleSettings(); | |
| updateUI(); | |
| alert('설정이 저장되었습니다.'); | |
| } | |
| function addRecord() { | |
| const date = document.getElementById('dateInput').value; | |
| const weight = parseFloat(document.getElementById('weightInput').value); | |
| if (!date || isNaN(weight)) return alert('날짜와 체중을 올바르게 입력해주세요.'); | |
| const existingIndex = records.findIndex(r => r.date === date); | |
| if (existingIndex >= 0) records[existingIndex].weight = weight; | |
| else records.push({ date, weight }); | |
| // 날짜순 정렬 | |
| records.sort((a, b) => new Date(a.date) - new Date(b.date)); | |
| saveRecords(); | |
| document.getElementById('weightInput').value = ''; | |
| updateUI(); | |
| } | |
| function deleteRecord(date) { | |
| if(confirm('이 날짜의 기록을 삭제하시겠습니까?')) { | |
| records = records.filter(r => r.date !== date); | |
| saveRecords(); | |
| updateUI(); | |
| } | |
| } | |
| function safeResetData() { | |
| const input = document.getElementById('resetConfirmInput').value; | |
| if (input === "초기화") { | |
| localStorage.removeItem(STORAGE_KEY); | |
| records = []; | |
| document.getElementById('resetConfirmInput').value = ''; | |
| updateUI(); | |
| alert('초기화되었습니다.'); | |
| } else { | |
| alert('"초기화"라고 정확히 입력해주세요.'); | |
| } | |
| } | |
| function saveRecords() { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); | |
| } | |
| function importCSV() { | |
| const file = document.getElementById('csvFileInput').files[0]; | |
| if (!file) return alert('파일을 선택해주세요.'); | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const lines = e.target.result.split('\n'); | |
| let count = 0; | |
| for(let i=1; i<lines.length; i++) { // 헤더 스킵 | |
| const parts = lines[i].split(','); | |
| if(parts.length >= 2) { | |
| const d = parts[0].trim(); | |
| const w = parseFloat(parts[1]); | |
| if(d && !isNaN(w)) { | |
| const idx = records.findIndex(r => r.date === d); | |
| if(idx >= 0) records[idx].weight = w; | |
| else records.push({ date: d, weight: w }); | |
| count++; | |
| } | |
| } | |
| } | |
| records.sort((a, b) => new Date(a.date) - new Date(b.date)); | |
| saveRecords(); | |
| updateUI(); | |
| alert(`${count}건의 데이터를 불러왔습니다.`); | |
| }; | |
| reader.readAsText(file); | |
| } | |
| // --- 4. 메인 렌더링 함수 (updateUI) --- | |
| function updateUI() { | |
| // 데이터가 없어도 기본 값 표시를 위해 실행 | |
| renderStats(); | |
| renderAnalysisText(); | |
| // 차트 업데이트 (오류 방지를 위해 try-catch 처리 가능하나 로직 수정함) | |
| updateMainChart(); | |
| updateDayOfWeekChart(); | |
| updateHistogram(); | |
| // 테이블 및 히트맵 | |
| renderHeatmap(); | |
| renderAllTables(); | |
| } | |
| // --- 5. 통계 및 텍스트 리포트 --- | |
| function renderStats() { | |
| const currentW = records.length > 0 ? records[records.length-1].weight : settings.startWeight; | |
| const totalLost = (settings.startWeight - currentW).toFixed(1); | |
| document.getElementById('currentWeightDisplay').innerText = currentW + 'kg'; | |
| document.getElementById('totalLostDisplay').innerText = `${totalLost}kg`; | |
| document.getElementById('totalLostDisplay').style.color = totalLost > 0 ? 'var(--primary-dark)' : (totalLost < 0 ? 'var(--danger)' : 'var(--text)'); | |
| let pct = 0; | |
| const totalGap = settings.startWeight - settings.goal1; | |
| const currentGap = settings.startWeight - currentW; | |
| if(totalGap !== 0) pct = Math.max(0, Math.min(100, (currentGap / totalGap) * 100)); | |
| document.getElementById('progressPercent').innerText = pct.toFixed(1) + '%'; | |
| document.getElementById('streakDisplay').innerText = calculateStreak() + '일'; | |
| document.getElementById('successRateDisplay').innerText = calculateSuccessRate() + '%'; | |
| document.getElementById('predictedDate').innerText = calculatePrediction(currentW); | |
| // 속도 표 | |
| document.getElementById('rate7Days').innerText = getRate(7); | |
| document.getElementById('rate30Days').innerText = getRate(30); | |
| document.getElementById('weeklyCompareDisplay').innerText = getWeeklyComparison(); | |
| } | |
| function renderAnalysisText() { | |
| const el = document.getElementById('analysisText'); | |
| if (records.length < 2) { | |
| el.innerText = "데이터가 2개 이상 쌓이면 분석을 시작합니다. 화이팅!"; | |
| return; | |
| } | |
| const last = records[records.length-1]; | |
| const prev = records[records.length-2]; | |
| const diff = (last.weight - prev.weight).toFixed(1); | |
| if (diff < 0) el.innerText = `어제보다 ${Math.abs(diff)}kg 빠졌습니다! 이대로 쭉 가봅시다! 🔥`; | |
| else if (diff > 0) el.innerText = `약간 증량(${diff}kg)했지만 괜찮습니다. 장기적인 추세가 중요합니다.`; | |
| else el.innerText = `체중 유지 중입니다. 꾸준함이 답입니다.`; | |
| } | |
| // --- 6. 분석 계산 로직 --- | |
| function calculateStreak() { | |
| if (records.length < 2) return 0; | |
| let maxS = 0, curS = 0; | |
| for(let i=1; i<records.length; i++) { | |
| if(records[i].weight < records[i-1].weight) curS++; | |
| else curS = 0; | |
| if(curS > maxS) maxS = curS; | |
| } | |
| return maxS; | |
| } | |
| function calculateSuccessRate() { | |
| if (records.length < 2) return 0; | |
| let success = 0; | |
| for(let i=1; i<records.length; i++) { | |
| if(records[i].weight < records[i-1].weight) success++; | |
| } | |
| return Math.round((success / (records.length - 1)) * 100); | |
| } | |
| function calculatePrediction(currentW) { | |
| if(currentW <= settings.goal1) return "달성 완료! 🎉"; | |
| if(records.length < 5) return "데이터 수집 중..."; | |
| const day30 = new Date(); day30.setDate(day30.getDate()-30); | |
| const recent = records.filter(r => new Date(r.date) >= day30); | |
| if(recent.length < 2) return "분석 중..."; | |
| const first = recent[0]; | |
| const last = recent[recent.length-1]; | |
| const days = (new Date(last.date) - new Date(first.date)) / (1000*3600*24); | |
| const diff = last.weight - first.weight; | |
| if(diff >= 0 || days === 0) return "증량/유지세 🤔"; | |
| const rate = Math.abs(diff / days); // kg per day | |
| const remain = currentW - settings.goal1; | |
| const daysLeft = Math.ceil(remain / rate); | |
| const pDate = new Date(); | |
| pDate.setDate(pDate.getDate() + daysLeft); | |
| return `${pDate.getMonth()+1}/${pDate.getDate()} (${daysLeft}일 후)`; | |
| } | |
| function getRate(d) { | |
| const startD = new Date(); startD.setDate(startD.getDate()-d); | |
| const rel = records.filter(r => new Date(r.date) >= startD); | |
| if(rel.length < 2) return "-"; | |
| const diff = rel[rel.length-1].weight - rel[0].weight; | |
| const days = (new Date(rel[rel.length-1].date) - new Date(rel[0].date))/(1000*3600*24); | |
| if(days===0) return "-"; | |
| const g = ((diff/days)*1000).toFixed(0); | |
| return `${g > 0 ? '+' : ''}${g}g / 일`; | |
| } | |
| function getWeeklyComparison() { | |
| const now = new Date(); | |
| const d7 = new Date(now); d7.setDate(now.getDate()-7); | |
| const d14 = new Date(now); d14.setDate(now.getDate()-14); | |
| const thisW = records.filter(r => new Date(r.date) >= d7); | |
| const lastW = records.filter(r => { const d = new Date(r.date); return d >= d14 && d < d7; }); | |
| if(thisW.length === 0 || lastW.length === 0) return "데이터 부족"; | |
| const avgT = thisW.reduce((a,b)=>a+b.weight,0)/thisW.length; | |
| const avgL = lastW.reduce((a,b)=>a+b.weight,0)/lastW.length; | |
| const diff = (avgT - avgL).toFixed(2); | |
| const icon = diff < 0 ? '🔻' : (diff > 0 ? '🔺' : '➖'); | |
| return `${icon} ${Math.abs(diff)}kg`; | |
| } | |
| // --- 7. 차트 그리기 함수들 --- | |
| function setChartFilter(mode) { | |
| chartFilterMode = mode; | |
| document.getElementById('btn-1m').className = 'filter-btn' + (mode==='1M'?' active':''); | |
| document.getElementById('btn-3m').className = 'filter-btn' + (mode==='3M'?' active':''); | |
| document.getElementById('btn-all').className = 'filter-btn' + (mode==='ALL'?' active':''); | |
| updateMainChart(); | |
| } | |
| function applyCustomDateRange() { | |
| const s = document.getElementById('chartStartDate').value; | |
| const e = document.getElementById('chartEndDate').value; | |
| if(s && e) { | |
| chartFilterMode = 'CUSTOM'; | |
| customStart = s; customEnd = e; | |
| document.querySelectorAll('.filter-group .filter-btn').forEach(b=>b.classList.remove('active')); | |
| updateMainChart(); | |
| } | |
| } | |
| function getFilteredData() { | |
| if(records.length === 0) return []; | |
| let start = new Date(records[0].date); | |
| let end = new Date(); // 오늘 | |
| if(chartFilterMode === '1M') { start = new Date(); start.setMonth(start.getMonth()-1); } | |
| else if(chartFilterMode === '3M') { start = new Date(); start.setMonth(start.getMonth()-3); } | |
| else if(chartFilterMode === 'CUSTOM' && customStart) { start = new Date(customStart); end = new Date(customEnd); } | |
| return records.filter(r => { | |
| const d = new Date(r.date); | |
| return d >= start && d <= end; | |
| }); | |
| } | |
| function updateMainChart() { | |
| const ctx = document.getElementById('mainChart').getContext('2d'); | |
| const data = getFilteredData(); | |
| const showTrend = document.getElementById('showTrend').checked; | |
| // 날짜 파싱 어댑터가 작동하도록 x값을 문자열 날짜로 전달 | |
| const points = data.map(r => ({ x: r.date, y: r.weight })); | |
| // 추세선(이동평균) | |
| const trend = []; | |
| if(showTrend) { | |
| for(let i=0; i<points.length; i++) { | |
| let sum=0, cnt=0; | |
| for(let j=0; j<7; j++) { | |
| if(i-j >= 0) { sum+=points[i-j].y; cnt++; } | |
| } | |
| trend.push({ x: points[i].x, y: sum/cnt }); | |
| } | |
| } | |
| if(mainChart) mainChart.destroy(); | |
| mainChart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| datasets: [ | |
| { | |
| label: '체중', | |
| data: points, | |
| borderColor: '#4CAF50', | |
| backgroundColor: 'rgba(76, 175, 80, 0.1)', | |
| fill: true, | |
| tension: 0.1, | |
| pointRadius: 3 | |
| }, | |
| ...(showTrend && trend.length > 0 ? [{ | |
| label: '7일 추세', | |
| data: trend, | |
| borderColor: '#FF9800', | |
| borderWidth: 2, | |
| pointRadius: 0, | |
| fill: false, | |
| tension: 0.4 | |
| }] : []), | |
| { | |
| label: '목표', | |
| data: data.length ? [{x: data[0].date, y: settings.goal1}, {x: data[data.length-1].date, y: settings.goal1}] : [], | |
| borderColor: '#2196F3', | |
| borderDash: [5,5], | |
| pointRadius: 0, | |
| borderWidth: 1 | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| x: { | |
| type: 'time', | |
| time: { | |
| unit: 'day', | |
| displayFormats: { day: 'MM/dd' } | |
| } | |
| } | |
| }, | |
| plugins: { | |
| tooltip: { mode: 'index', intersect: false } | |
| } | |
| } | |
| }); | |
| } | |
| function updateDayOfWeekChart() { | |
| if(records.length < 2) return; | |
| const sums = [0,0,0,0,0,0,0]; // 일~토 | |
| const counts = [0,0,0,0,0,0,0]; | |
| for(let i=1; i<records.length; i++) { | |
| const diff = records[i].weight - records[i-1].weight; | |
| const day = new Date(records[i].date).getDay(); | |
| sums[day] += diff; | |
| counts[day]++; | |
| } | |
| const avgs = sums.map((s, i) => counts[i] ? s/counts[i] : 0); | |
| if(dowChart) dowChart.destroy(); | |
| const ctx = document.getElementById('dayOfWeekChart').getContext('2d'); | |
| dowChart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: ['일','월','화','수','목','금','토'], | |
| datasets: [{ | |
| label: '평균 변화(kg)', | |
| data: avgs, | |
| backgroundColor: avgs.map(v => v>0 ? '#ffcdd2':'#c8e6c9'), | |
| borderColor: avgs.map(v => v>0 ? '#e57373':'#81c784'), | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { legend: { display: false } } | |
| } | |
| }); | |
| } | |
| function updateHistogram() { | |
| if(records.length === 0) return; | |
| const weights = records.map(r => r.weight); | |
| const min = Math.floor(Math.min(...weights)); | |
| const max = Math.ceil(Math.max(...weights)); | |
| const labels = []; | |
| const data = []; | |
| for(let i=min; i<=max; i++) { | |
| labels.push(i + 'kg대'); | |
| data.push(weights.filter(w => Math.floor(w) === i).length); | |
| } | |
| if(histChart) histChart.destroy(); | |
| const ctx = document.getElementById('histogramChart').getContext('2d'); | |
| histChart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: '일수', | |
| data: data, | |
| backgroundColor: '#2196F3', | |
| borderRadius: 4 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { legend: { display: false } } | |
| } | |
| }); | |
| } | |
| // --- 8. 테이블 & 히트맵 렌더링 --- | |
| function renderHeatmap() { | |
| const container = document.getElementById('heatmapGrid'); | |
| container.innerHTML = ''; | |
| if(records.length === 0) return; | |
| const deltaMap = {}; | |
| for(let i=1; i<records.length; i++) { | |
| const diff = records[i].weight - records[i-1].weight; | |
| deltaMap[records[i].date] = diff; | |
| } | |
| const end = new Date(); | |
| const start = new Date(); start.setFullYear(start.getFullYear()-1); | |
| for(let d=start; d<=end; d.setDate(d.getDate()+1)) { | |
| const dateStr = d.toISOString().split('T')[0]; | |
| const div = document.createElement('div'); | |
| div.className = 'heatmap-cell'; | |
| div.title = dateStr; | |
| if(deltaMap[dateStr] !== undefined) { | |
| const val = deltaMap[dateStr]; | |
| div.title += ` (${val>0?'+':''}${val.toFixed(1)}kg)`; | |
| if(val > 0) div.style.background = 'var(--heatmap-gain)'; | |
| else if(val > -0.1) div.style.background = 'var(--heatmap-1)'; | |
| else if(val > -0.3) div.style.background = 'var(--heatmap-2)'; | |
| else if(val > -0.5) div.style.background = 'var(--heatmap-3)'; | |
| else div.style.background = 'var(--heatmap-4)'; | |
| } | |
| container.appendChild(div); | |
| } | |
| } | |
| function renderAllTables() { | |
| renderMonthlyTable(); | |
| renderWeeklyTable(); | |
| renderMilestoneTable(); | |
| renderHistoryTable(); | |
| } | |
| function renderMonthlyTable() { | |
| const months = {}; | |
| records.forEach(r => { | |
| const key = r.date.substring(0, 7); | |
| if(!months[key]) months[key] = []; | |
| months[key].push(r.weight); | |
| }); | |
| let html = ''; | |
| Object.keys(months).sort().reverse().forEach(m => { | |
| const arr = months[m]; | |
| const start = arr[0]; | |
| const end = arr[arr.length-1]; | |
| const diff = (end - start).toFixed(1); | |
| const avg = (arr.reduce((a,b)=>a+b,0)/arr.length).toFixed(1); | |
| html += `<tr><td>${m}</td><td>${start}</td><td>${end}</td><td class="${diff<=0?'neg':'pos'}">${diff}</td><td>${avg}</td></tr>`; | |
| }); | |
| document.getElementById('monthlyTableBody').innerHTML = html; | |
| } | |
| function renderWeeklyTable() { | |
| // 주차별 그룹화 (단순화: 월요일 시작 기준) | |
| const weeks = {}; | |
| records.forEach(r => { | |
| const d = new Date(r.date); | |
| // 해당 날짜의 주의 월요일 계산 | |
| const day = d.getDay(), diff = d.getDate() - day + (day == 0 ? -6:1); | |
| const monday = new Date(d.setDate(diff)); | |
| const key = monday.toISOString().split('T')[0]; | |
| if(!weeks[key]) weeks[key] = []; | |
| weeks[key].push(r.weight); | |
| }); | |
| let html = ''; | |
| Object.keys(weeks).sort().reverse().forEach(w => { | |
| const arr = weeks[w]; | |
| const avg = (arr.reduce((a,b)=>a+b,0)/arr.length).toFixed(1); | |
| // 변화량은 이 주차의 마지막 - 첫번째 | |
| const diff = (arr[arr.length-1] - arr[0]).toFixed(1); | |
| html += `<tr><td>${w} 주</td><td>${avg}kg</td><td class="${diff<=0?'neg':'pos'}">${diff}</td></tr>`; | |
| }); | |
| document.getElementById('weeklyTableBody').innerHTML = html; | |
| } | |
| function renderMilestoneTable() { | |
| let html = ''; | |
| if(records.length > 0) { | |
| let currentInt = Math.floor(records[0].weight); | |
| let startDate = new Date(records[0].date); | |
| for(let i=1; i<records.length; i++) { | |
| const w = Math.floor(records[i].weight); | |
| if(w < currentInt) { | |
| const nowD = new Date(records[i].date); | |
| const days = Math.ceil((nowD - startDate)/(1000*3600*24)); | |
| html += `<tr><td>🎉 ${w}kg대 진입</td><td>${records[i].date}</td><td>${days}일 소요</td></tr>`; | |
| currentInt = w; | |
| startDate = nowD; | |
| } | |
| } | |
| } | |
| document.getElementById('milestoneTableBody').innerHTML = html || '<tr><td colspan="3">아직 기록된 마일스톤이 없습니다.</td></tr>'; | |
| } | |
| function renderHistoryTable() { | |
| let html = ''; | |
| const rev = [...records].reverse(); | |
| rev.forEach(r => { | |
| // 원본 인덱스 찾아서 변화량 계산 | |
| const idx = records.findIndex(o => o.date === r.date); | |
| let diffStr = '-'; | |
| let cls = ''; | |
| if(idx > 0) { | |
| const d = r.weight - records[idx-1].weight; | |
| diffStr = (d>0?'+':'') + d.toFixed(1); | |
| cls = d>0?'pos':(d<0?'neg':''); | |
| } | |
| html += `<tr><td>${r.date}</td><td>${r.weight}kg</td><td class="${cls}">${diffStr}</td> | |
| <td><button style="border:none;background:none;cursor:pointer;" onclick="deleteRecord('${r.date}')">🗑️</button></td></tr>`; | |
| }); | |
| document.getElementById('historyList').innerHTML = html; | |
| } | |
| function switchTab(tabId) { | |
| document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none'); | |
| document.getElementById(tabId).style.display = 'block'; | |
| // 탭 버튼 스타일 | |
| document.querySelectorAll('.filter-group button[id^="tab-btn"]').forEach(b => b.classList.remove('active')); | |
| // 맵핑 (간단히 구현) | |
| if(tabId.includes('monthly')) document.getElementById('tab-btn-monthly').classList.add('active'); | |
| if(tabId.includes('weekly')) document.getElementById('tab-btn-weekly').classList.add('active'); | |
| if(tabId.includes('milestone')) document.getElementById('tab-btn-milestone').classList.add('active'); | |
| if(tabId.includes('history')) document.getElementById('tab-btn-history').classList.add('active'); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment