Skip to content

Instantly share code, notes, and snippets.

@lunamoth
Created December 14, 2025 10:57
Show Gist options
  • Select an option

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

Select an option

Save lunamoth/5569e670985c16e25a17e25570218ea5 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💪 다이어트 챌린지 2025 (Updated)</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; /* 증량 */
/* 뱃지 색상 */
--gold: #FFD700;
--silver: #C0C0C0;
--bronze: #CD7F32;
}
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; }
/* 프로그레스 바 (방향 수정됨: 오른쪽->왼쪽) */
.progress-card { position: relative; padding: 25px 20px; }
.progress-track {
background: #e0e0e0; height: 10px; border-radius: 5px; position: relative;
margin: 40px 15px 50px 15px; /* 이모지와 텍스트 공간 확보 */
}
.progress-fill {
background: linear-gradient(90deg, var(--primary), var(--secondary));
height: 100%; border-radius: 5px; width: 0%; transition: width 0.5s ease-out;
position: absolute; top: 0;
right: 0; /* 왼쪽 기준(left:0)에서 오른쪽 기준(right:0)으로 변경 */
}
.progress-marker {
position: absolute; top: 50%; transform: translate(-50%, -50%);
width: 14px; height: 14px; background: white; border: 3px solid #ccc; border-radius: 50%; z-index: 2;
}
/* 마커 위치 변경: 시작(우측), 목표(좌측) */
.progress-marker.start { left: 100%; border-color: var(--text-light); }
.progress-marker.goal { left: 0%; border-color: var(--secondary); }
/* 이모지와 텍스트 팝업 (Right 기준 좌표계에 맞춰 transform 변경) */
.progress-indicator-emoji {
position: absolute; top: -35px;
transform: translateX(50%); /* -50%에서 50%로 변경 (우측 기준 정렬 보정) */
font-size: 1.8rem; transition: right 0.5s ease-out; z-index: 3;
}
.progress-info-text {
position: absolute; top: 20px;
transform: translateX(50%); /* -50%에서 50%로 변경 (우측 기준 정렬 보정) */
font-size: 0.8rem; color: var(--text); background: rgba(255,255,255,0.95);
padding: 4px 8px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);
white-space: nowrap; text-align: center; border: 1px solid #eee; transition: right 0.5s ease-out; z-index: 3;
}
.progress-labels { display: flex; justify-content: space-between; font-size: 0.85rem; color: var(--text-light); font-weight: bold; margin-top: -35px; padding: 0 10px; }
/* 대시보드 그리드 */
.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); }
.stat-sub { font-size: 0.75rem; color: var(--secondary); margin-top: 2px; }
/* 텍스트 리포트 */
.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; }
/* 뱃지 시스템 스타일 (New) */
.badge-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 10px; text-align: center; }
.badge-item { background: #fafafa; padding: 10px; border-radius: 10px; border: 1px solid #eee; opacity: 0.4; filter: grayscale(1); transition: all 0.3s; }
.badge-item.unlocked { opacity: 1; filter: grayscale(0); border-color: var(--accent); background: #fff8e1; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
.badge-icon { font-size: 2rem; display: block; margin-bottom: 5px; }
.badge-name { font-size: 0.75rem; font-weight: bold; color: var(--text); }
@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;">📂 데이터 관리</h4>
<div class="input-group" style="margin-bottom: 20px; border: 1px dashed #ccc; padding: 10px; border-radius: 10px;">
<div class="input-wrapper">
<label>CSV 불러오기</label>
<input type="file" id="csvFileInput" accept=".csv" style="border:none;">
</div>
<button class="add-btn" style="background-color: var(--text-light); padding: 8px 15px; font-size: 0.9rem;" onclick="importCSV()">불러오기</button>
<button class="add-btn" style="background-color: var(--primary); padding: 8px 15px; font-size: 0.9rem;" onclick="exportCSV()">내보내기 (다운로드)</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="card progress-card">
<h3 style="border:none; margin-bottom:10px;">🛤️ 목표 달성 현황</h3>
<div class="progress-track">
<div class="progress-fill" id="progressBarFill"></div>
<!-- 마커 -->
<div class="progress-marker start"></div>
<div class="progress-marker goal"></div>
<!-- 현재 위치 표시 (이모지 & 텍스트) -->
<div class="progress-indicator-emoji" id="progressEmoji">👇</div>
<div class="progress-info-text" id="progressText">
정보 로딩중...
</div>
</div>
<div class="progress-labels">
<!-- 라벨 순서 변경: 목표(좌), 시작(우) -->
<span id="labelGoal">목표: -kg</span>
<span id="labelStart">시작: -kg</span>
</div>
</div>
<!-- 🏆 업적 및 뱃지 (New) -->
<div class="card">
<h3 onclick="toggleBadges()" style="cursor:pointer;">🏆 나의 업적 & 뱃지 <span style="font-size:0.8rem; color:var(--text-light);">(클릭하여 펼치기)</span></h3>
<div id="badgeGrid" class="badge-grid" style="display:none;">
<!-- JS로 렌더링 됩니다 -->
</div>
</div>
<!-- 핵심 통계 대시보드 (Expanded) -->
<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 class="stat-sub" id="bmiDisplay">BMI: -</div>
</div>
<div class="stat-card">
<span class="stat-icon">📉</span>
<div class="stat-value" id="totalLostDisplay">-</div>
<div class="stat-label">총 감량</div>
<div class="stat-sub" id="percentLostDisplay">(-%)</div>
</div>
<!-- 남은 감량 -->
<div class="stat-card">
<span class="stat-icon">🏔️</span>
<div class="stat-value" id="remainingWeightDisplay">-</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 class="stat-sub" id="predictionRange" style="font-size:0.65rem;">(빠름~느림)</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>
<!-- New Metrics -->
<div class="stat-card">
<span class="stat-icon">📏</span>
<div class="stat-value" id="minMaxWeightDisplay" style="font-size: 1rem;">-</div>
<div class="stat-label">최고/최저 체중</div>
</div>
<div class="stat-card">
<span class="stat-icon">🎢</span>
<div class="stat-value" id="dailyVolatilityDisplay" style="font-size: 1rem;">-</div>
<div class="stat-label">일일 최대 변동</div>
</div>
<div class="stat-card">
<span class="stat-icon">🗓️</span>
<div class="stat-value" id="weeklyAvgDisplay">-</div>
<div class="stat-label">주간 평균 감량</div>
</div>
<div class="stat-card">
<span class="stat-icon">🌙</span>
<div class="stat-value" id="monthCompareDisplay">-</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" 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>
<span style="margin-left:15px;">※ 배경색 띠는 BMI 구간(정상/과체중/비만)을 나타냅니다.</span>
</div>
</div>
<!-- 추가 분석 차트 (New) -->
<div class="analysis-grid">
<div class="card">
<h3>📉 누적 감량 추이 (Area)</h3>
<div class="mini-chart-container">
<canvas id="cumulativeChart"></canvas>
</div>
</div>
<div class="card">
<h3>📊 월별 순수 변화량</h3>
<div class="mini-chart-container">
<canvas id="monthlyChangeChart"></canvas>
</div>
</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';
const FILTER_KEY = 'diet_pro_filter_mode'; // 필터 저장 키
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 cumulativeChart = null; // New
let monthlyChangeChart = null; // New
// 차트 필터 모드 (저장된 값 로드)
let chartFilterMode = localStorage.getItem(FILTER_KEY) || '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;
// 저장된 필터 버튼 활성화
updateFilterButtons();
// UI 렌더링 시작
updateUI();
};
// --- 3. 기본 기능 (저장, 삭제, CSV) ---
function toggleSettings() {
const panel = document.getElementById('settingsPanel');
panel.style.display = panel.style.display === 'block' ? 'none' : 'block';
}
function toggleBadges() {
const grid = document.getElementById('badgeGrid');
grid.style.display = grid.style.display === 'grid' ? 'none' : 'grid';
}
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);
}
function exportCSV() {
if (records.length === 0) return alert('내보낼 데이터가 없습니다.');
let csvContent = "data:text/csv;charset=utf-8,Date,Weight\n";
records.forEach(row => {
csvContent += `${row.date},${row.weight}\n`;
});
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", "diet_records_backup.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// --- 4. 메인 렌더링 함수 (updateUI) ---
function updateUI() {
// 데이터가 없어도 기본 값 표시를 위해 실행
renderStats();
renderAnalysisText();
// 차트 업데이트
updateMainChart();
updateDayOfWeekChart();
updateHistogram();
updateCumulativeChart(); // New
updateMonthlyChangeChart(); // New
// 테이블, 히트맵, 뱃지
renderHeatmap();
renderAllTables();
renderBadges(); // New
}
// --- 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) + '%';
// 남은 감량 계산
const remaining = (currentW - settings.goal1).toFixed(1);
const remainingDisplay = document.getElementById('remainingWeightDisplay');
remainingDisplay.innerText = `${remaining > 0 ? remaining : 0}kg`;
remainingDisplay.style.color = remaining <= 0 ? 'var(--secondary-dark)' : 'var(--text)';
// BMI 계산
const hMeter = settings.height / 100;
const bmi = (currentW / (hMeter * hMeter)).toFixed(1);
let bmiLabel = '정상';
if(bmi < 18.5) bmiLabel = '저체중';
else if(bmi >= 23 && bmi < 25) bmiLabel = '과체중';
else if(bmi >= 25) bmiLabel = '비만';
document.getElementById('bmiDisplay').innerText = `BMI: ${bmi} (${bmiLabel})`;
// 감량 퍼센트
const percentLost = ((settings.startWeight - currentW) / settings.startWeight * 100).toFixed(1);
document.getElementById('percentLostDisplay').innerText = `(${percentLost > 0 ? '-' : '+'}${Math.abs(percentLost)}%)`;
// 상단 프로그레스 바 업데이트
updateProgressBar(currentW, totalLost, pct, remaining);
document.getElementById('streakDisplay').innerText = calculateStreak() + '일';
document.getElementById('successRateDisplay').innerText = calculateSuccessRate() + '%';
// 예측 시나리오
const pred = calculateScenarios(currentW);
document.getElementById('predictedDate').innerText = pred.avg;
document.getElementById('predictionRange').innerText = pred.range;
// 속도 표
document.getElementById('rate7Days').innerText = getRate(7);
document.getElementById('rate30Days').innerText = getRate(30);
document.getElementById('weeklyCompareDisplay').innerText = getWeeklyComparison();
// New Metrics
const extremes = calculateExtremes();
document.getElementById('minMaxWeightDisplay').innerHTML = `
<span style="color:var(--danger)">${extremes.max}</span> /
<span style="color:var(--primary)">${extremes.min}</span>
`;
const vol = calculateDailyVolatility();
document.getElementById('dailyVolatilityDisplay').innerHTML = `
<span style="color:var(--primary)">▼${vol.drop}</span> /
<span style="color:var(--danger)">▲${vol.gain}</span>
`;
document.getElementById('weeklyAvgDisplay').innerText = calculateWeeklyAvg() + 'kg';
const monComp = calculateMonthlyComparison();
document.getElementById('monthCompareDisplay').innerText = monComp;
document.getElementById('monthCompareDisplay').style.color = monComp.includes('▼') ? 'var(--primary)' : (monComp.includes('▲') ? 'var(--danger)' : 'var(--text)');
}
// 프로그레스 바 렌더링 함수
function updateProgressBar(current, lost, percent, remaining) {
const fill = document.getElementById('progressBarFill');
const emoji = document.getElementById('progressEmoji');
const text = document.getElementById('progressText');
const labelStart = document.getElementById('labelStart');
const labelGoal = document.getElementById('labelGoal');
// 라벨 업데이트
labelStart.innerText = `시작: ${settings.startWeight}kg`;
labelGoal.innerText = `목표: ${settings.goal1}kg`;
// 퍼센트 Clamp (0~100)
let visualPercent = percent;
if(visualPercent < 0) visualPercent = 0;
if(visualPercent > 100) visualPercent = 100;
// 스타일 적용 (오른쪽에서부터 채움)
fill.style.width = `${visualPercent}%`;
// 이모지 및 텍스트 위치: 오른쪽 끝 기준
emoji.style.left = 'auto';
emoji.style.right = `${visualPercent}%`;
text.style.left = 'auto';
text.style.right = `${visualPercent}%`;
// 텍스트 업데이트 (내용은 동일)
const lostP = percent.toFixed(1);
const remainP = (100 - percent).toFixed(1);
const safeRemain = remaining > 0 ? remaining : 0;
const safeRemainP = remainP > 0 ? remainP : 0;
text.innerHTML = `
<strong>${current}kg</strong><br>
감량: ${lost}kg (${lostP}%)<br>
남은: ${safeRemain}kg (${safeRemainP}%)
`;
}
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 calculateScenarios(currentW) {
if(currentW <= settings.goal1) return { avg: "달성 완료! 🎉", range: "" };
if(records.length < 5) return { avg: "데이터 수집 중...", range: "" };
const recent = records.slice(-30); // 최근 30개
if(recent.length < 2) return { avg: "분석 중...", range: "" };
// 평균 속도, 최고 속도(상위 20%), 최저 속도(하위 20%) 계산을 위한 일별 변화량 배열
let diffs = [];
for(let i=1; i<recent.length; i++) {
const dayDiff = (new Date(recent[i].date) - new Date(recent[i-1].date))/(1000*3600*24);
if(dayDiff > 0) diffs.push((recent[i-1].weight - recent[i].weight) / dayDiff); // 양수면 감량
}
// 감량한 날만 고려하거나 전체 평균 고려. 보수적으로 전체 평균 사용.
const first = recent[0];
const last = recent[recent.length-1];
const days = (new Date(last.date) - new Date(first.date)) / (1000*3600*24);
const totalDiff = first.weight - last.weight;
const avgRate = totalDiff / days; // kg per day
if(avgRate <= 0) return { avg: "증량/유지세 🤔", range: "식단 조절 필요" };
const remain = currentW - settings.goal1;
const daysLeftAvg = Math.ceil(remain / avgRate);
// Optimistic: 최근 가장 빨랐던 7일 구간 속도
// Pessimistic: avgRate * 0.7
const fastRate = avgRate * 1.5;
const slowRate = avgRate * 0.7;
const dAvg = new Date(); dAvg.setDate(dAvg.getDate() + daysLeftAvg);
const dFast = new Date(); dFast.setDate(dFast.getDate() + Math.ceil(remain / fastRate));
const dSlow = new Date(); dSlow.setDate(dSlow.getDate() + Math.ceil(remain / slowRate));
const formatDate = (d) => `${d.getMonth()+1}/${d.getDate()}`;
return {
avg: `${formatDate(dAvg)} (${daysLeftAvg}일 후)`,
range: `최적 ${formatDate(dFast)} ~ 보수 ${formatDate(dSlow)}`
};
}
function calculateExtremes() {
if(records.length === 0) return { min: '-', max: '-' };
const weights = records.map(r => r.weight);
return { min: Math.min(...weights).toFixed(1) + 'kg', max: Math.max(...weights).toFixed(1) + 'kg' };
}
function calculateDailyVolatility() {
if(records.length < 2) return { drop: '-', gain: '-' };
let maxDrop = 0;
let maxGain = 0;
for(let i=1; i<records.length; i++) {
const diff = records[i].weight - records[i-1].weight;
const dayDiff = (new Date(records[i].date) - new Date(records[i-1].date))/(1000*3600*24);
if(dayDiff === 1) { // 연속된 날짜만 계산
if(diff < 0 && Math.abs(diff) > maxDrop) maxDrop = Math.abs(diff);
if(diff > 0 && diff > maxGain) maxGain = diff;
}
}
return { drop: maxDrop.toFixed(1), gain: maxGain.toFixed(1) };
}
function calculateWeeklyAvg() {
if(records.length < 7) return '-';
// 전체 기간 주간 평균 감량
const first = records[0];
const last = records[records.length-1];
const weeks = (new Date(last.date) - new Date(first.date)) / (1000*3600*24*7);
if(weeks < 1) return '-';
const totalLost = first.weight - last.weight;
return (totalLost / weeks).toFixed(1);
}
function calculateMonthlyComparison() {
if(records.length === 0) return '-';
const now = new Date();
const thisMonthKey = now.toISOString().slice(0, 7);
const lastMonthDate = new Date(); lastMonthDate.setMonth(now.getMonth()-1);
const lastMonthKey = lastMonthDate.toISOString().slice(0, 7);
const thisMonthRecs = records.filter(r => r.date.startsWith(thisMonthKey));
const lastMonthRecs = records.filter(r => r.date.startsWith(lastMonthKey));
if(thisMonthRecs.length === 0 || lastMonthRecs.length === 0) return '-';
const avgThis = thisMonthRecs.reduce((a,b)=>a+b.weight,0)/thisMonthRecs.length;
const avgLast = lastMonthRecs.reduce((a,b)=>a+b.weight,0)/lastMonthRecs.length;
const diff = avgThis - avgLast;
return `${diff > 0 ? '▲' : '▼'} ${Math.abs(diff).toFixed(1)}kg`;
}
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 updateFilterButtons() {
document.getElementById('btn-1m').className = 'filter-btn' + (chartFilterMode==='1M'?' active':'');
document.getElementById('btn-3m').className = 'filter-btn' + (chartFilterMode==='3M'?' active':'');
document.getElementById('btn-all').className = 'filter-btn' + (chartFilterMode==='ALL'?' active':'');
}
function setChartFilter(mode) {
chartFilterMode = mode;
localStorage.setItem(FILTER_KEY, mode); // 필터 저장
updateFilterButtons();
updateMainChart();
}
function applyCustomDateRange() {
const s = document.getElementById('chartStartDate').value;
const e = document.getElementById('chartEndDate').value;
if(s && e) {
chartFilterMode = 'CUSTOM';
customStart = s; customEnd = e;
localStorage.setItem(FILTER_KEY, 'CUSTOM');
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;
const points = data.map(r => ({ x: r.date, y: r.weight }));
// BMI Bands Calculation
const h = settings.height / 100;
const w185 = 18.5 * h * h; // 저체중 경계
const w23 = 23 * h * h; // 정상/과체중 경계 (아시아 기준 통상 23)
const w25 = 25 * h * h; // 비만 경계
// 날짜 범위 전체에 밴드를 그리기 위해 시작/끝점 필요
const chartStart = points.length ? points[0].x : new Date();
const chartEnd = points.length ? points[points.length-1].x : new Date();
// 추세선(이동평균)
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: [
// BMI 배경 밴드 (순서 중요: 맨 먼저 그려서 뒤로 보냄)
{
label: '비만',
data: [{x: chartStart, y: 150}, {x: chartEnd, y: 150}], // 상한선 크게
fill: { target: {value: w25}, above: 'rgba(244, 67, 54, 0.05)' }, // 25 이상
borderColor: 'transparent', pointRadius: 0
},
{
label: '과체중',
data: [{x: chartStart, y: w25}, {x: chartEnd, y: w25}],
fill: { target: {value: w23}, above: 'rgba(255, 152, 0, 0.05)' }, // 23~25
borderColor: 'transparent', pointRadius: 0
},
{
label: '정상',
data: [{x: chartStart, y: w23}, {x: chartEnd, y: w23}],
fill: { target: {value: w185}, above: 'rgba(76, 175, 80, 0.05)' }, // 18.5~23
borderColor: 'transparent', pointRadius: 0
},
// 실제 데이터
{
label: '체중',
data: points,
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
fill: false,
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' } }
},
y: {
// 사용자 실제 데이터 중 최대값 찾기 (없으면 시작 체중 사용)
max: points.length > 0
? Math.ceil(Math.max(...points.map(p => p.y), settings.startWeight)) + 1
: settings.startWeight + 1,
// 최소값은 목표 체중보다 조금 아래로
suggestedMin: settings.goal1 - 2
}
},
plugins: {
tooltip: { mode: 'index', intersect: false },
legend: {
labels: {
filter: function(item, chart) {
// BMI 배경 범례 숨기기
return !['비만', '과체중', '정상'].includes(item.text);
}
}
}
}
}
});
}
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 } }
}
});
}
function updateCumulativeChart() {
if(records.length === 0) return;
const ctx = document.getElementById('cumulativeChart').getContext('2d');
const points = records.map(r => ({
x: r.date,
y: (settings.startWeight - r.weight).toFixed(2) // 누적 감량량
}));
if(cumulativeChart) cumulativeChart.destroy();
cumulativeChart = new Chart(ctx, {
type: 'line',
data: {
datasets: [{
label: '누적 감량(kg)',
data: points,
borderColor: '#9C27B0',
backgroundColor: 'rgba(156, 39, 176, 0.2)',
fill: true,
tension: 0.2,
pointRadius: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { type: 'time', time: { unit: 'month' } },
y: { beginAtZero: true }
},
plugins: { legend: { display: false } }
}
});
}
function updateMonthlyChangeChart() {
if(records.length === 0) return;
const ctx = document.getElementById('monthlyChangeChart').getContext('2d');
const months = {};
records.forEach(r => {
const key = r.date.substring(0, 7);
if(!months[key]) months[key] = [];
months[key].push(r.weight);
});
const labels = [];
const data = [];
const bgColors = [];
Object.keys(months).sort().forEach(m => {
const arr = months[m];
const change = arr[arr.length-1] - arr[0]; // 월말 - 월초 (단순화)
// 더 정확히는 해당 월의 첫 기록 vs 마지막 기록
labels.push(m);
data.push(change);
bgColors.push(change > 0 ? '#ffcdd2' : '#bbdefb'); // 빨강/파랑
});
if(monthlyChangeChart) monthlyChangeChart.destroy();
monthlyChangeChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '월별 변화(kg)',
data: data,
backgroundColor: bgColors,
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } },
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 renderBadges() {
if(records.length === 0) return;
const currentW = records[records.length-1].weight;
const totalLost = settings.startWeight - currentW;
const streak = calculateStreak();
const badges = [
{ id: 'start', name: '시작이 반', icon: '🐣', condition: records.length >= 1 },
{ id: 'loss3', name: '3kg 감량', icon: '🥉', condition: totalLost >= 3 },
{ id: 'loss5', name: '5kg 감량', icon: '🥈', condition: totalLost >= 5 },
{ id: 'loss10', name: '10kg 감량', icon: '🥇', condition: totalLost >= 10 },
{ id: 'streak3', name: '작심삼일 탈출', icon: '🔥', condition: streak >= 3 },
{ id: 'streak7', name: '일주일 연속', icon: '⚡', condition: streak >= 7 },
{ id: 'digit', name: '앞자리 체인지', icon: '✨', condition: Math.floor(currentW/10) < Math.floor(settings.startWeight/10) },
{ id: 'goal', name: '목표 달성', icon: '👑', condition: currentW <= settings.goal1 }
];
let html = '';
badges.forEach(b => {
const cls = b.condition ? 'badge-item unlocked' : 'badge-item';
html += `<div class="${cls}">
<span class="badge-icon">${b.icon}</span>
<span class="badge-name">${b.name}</span>
</div>`;
});
document.getElementById('badgeGrid').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