Last active
January 10, 2025 07:32
-
-
Save lunamoth/a1aa5d57f0cedaf53b9498a3c31e7666 to your computer and use it in GitHub Desktop.
This file contains 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> | |
<head> | |
<title>2025년 챌린지</title> | |
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<style> | |
:root { | |
--border-color: #ddd; | |
--header-bg: #f0f0f0; | |
--month-bg: #e0e0e0; | |
--today-bg: #f5f5dc; | |
--weekend-color: blue; | |
--saturday-color: red; | |
} | |
body { | |
font-family: sans-serif; | |
margin: 0; | |
padding: 20px; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
} | |
.container { | |
max-width: 1200px; | |
width: 100%; | |
margin: 0 auto; | |
} | |
.stats-container { | |
background-color: #f8f9fa; | |
padding: 20px; | |
border-radius: 8px; | |
margin-bottom: 20px; | |
width: 100%; | |
max-width: 1200px; | |
} | |
.stats-grid { | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
gap: 20px; | |
} | |
.stat-card { | |
background: white; | |
padding: 15px; | |
border-radius: 6px; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
text-align: center; | |
} | |
.stat-value { | |
font-size: 24px; | |
font-weight: bold; | |
color: #2c3e50; | |
} | |
table { | |
width: 100%; | |
border-collapse: collapse; | |
margin-bottom: 20px; | |
background: white; | |
border-radius: 8px; | |
overflow: hidden; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
} | |
th, td { | |
border: 1px solid var(--border-color); | |
padding: 8px; | |
text-align: center; | |
width: 14.28%; | |
height: 40px; | |
} | |
th { | |
background-color: var(--header-bg); | |
} | |
.month-title { | |
background-color: var(--month-bg); | |
font-weight: bold; | |
padding: 10px; | |
text-align: center; | |
border: 1px solid var(--border-color); | |
} | |
[data-day="0"] { color: var(--weekend-color); } | |
[data-day="6"] { color: var(--saturday-color); } | |
[data-today="true"] { background-color: var(--today-bg); } | |
.checkbox-container { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
} | |
input[type="checkbox"] { | |
display: none; | |
} | |
.checkbox-label { | |
cursor: pointer; | |
user-select: none; | |
} | |
.checkbox-label::before { | |
content: '⬜'; | |
font-size: 1.2em; | |
} | |
input[type="checkbox"]:checked + .checkbox-label::before { | |
content: '✅'; | |
} | |
.progress-container { | |
width: 100%; | |
background-color: #f0f0f0; | |
border-radius: 4px; | |
margin: 5px 0; | |
} | |
.progress-bar { | |
height: 20px; | |
background-color: #4CAF50; | |
border-radius: 4px; | |
transition: width 0.3s ease; | |
} | |
@media (max-width: 768px) { | |
body { | |
padding: 10px; | |
} | |
th, td { | |
padding: 4px; | |
font-size: 14px; | |
} | |
.stats-grid { | |
grid-template-columns: 1fr; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<h1>2025년 챌린지</h1> | |
<div class="stats-container"> | |
<div class="stats-grid"> | |
<div class="stat-card"> | |
<h3>전체 달성률</h3> | |
<div class="stat-value" id="total-progress">0%</div> | |
<div class="progress-container"> | |
<div class="progress-bar" id="total-progress-bar" style="width: 0%"></div> | |
</div> | |
</div> | |
<div class="stat-card"> | |
<h3>이번 달 달성률</h3> | |
<div class="stat-value" id="month-progress">0%</div> | |
<div class="progress-container"> | |
<div class="progress-bar" id="month-progress-bar" style="width: 0%"></div> | |
</div> | |
</div> | |
<div class="stat-card"> | |
<h3>연속 달성 일수</h3> | |
<div class="stat-value" id="streak-count">0일</div> | |
</div> | |
<div class="stat-card"> | |
<h3>전체 달성 일수</h3> | |
<div class="stat-value" id="total-checked">0일</div> | |
</div> | |
</div> | |
</div> | |
<div id="calendar-container"></div> | |
</div> | |
<script> | |
// 상수 정의 | |
const CONSTANTS = { | |
YEAR: 2025, | |
MONTHS: Array.from({length: 12}, (_, i) => i), | |
DAYS_OF_WEEK: ['일', '월', '화', '수', '목', '금', '토'], | |
STORAGE_KEY: 'challenge2025', | |
SAVE_INTERVAL: 3000 // 3초 | |
}; | |
// 유틸리티 함수 | |
class DateUtils { | |
static formatDate(date) { | |
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; | |
} | |
static isLeapYear(year) { | |
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; | |
} | |
static getDaysInMonth(year, month) { | |
return new Date(year, month + 1, 0).getDate(); | |
} | |
} | |
// 로컬 스토리지 관리자 (추상화 적용) | |
class StorageManager { | |
constructor(key, storage = localStorage) { | |
this.key = key; | |
this.storage = storage; | |
this.queuedUpdates = {}; | |
this.lastSave = 0; | |
} | |
load() { | |
try { | |
const stored = this.storage.getItem(this.key); | |
return stored ? JSON.parse(stored) : {}; | |
} catch (e) { | |
console.error('Failed to load data from storage:', e); | |
return {}; | |
} | |
} | |
// 디바운싱 적용하여 저장 | |
save() { | |
const now = Date.now(); | |
if (now - this.lastSave < CONSTANTS.SAVE_INTERVAL) { | |
clearTimeout(this.saveTimeout); | |
} | |
this.saveTimeout = setTimeout(() => { | |
try { | |
const data = this.load(); | |
Object.assign(data, this.queuedUpdates); | |
this.storage.setItem(this.key, JSON.stringify(data)); | |
this.queuedUpdates = {}; | |
this.lastSave = Date.now(); | |
} catch (e) { | |
console.error('Failed to save data to storage:', e); | |
} | |
}, CONSTANTS.SAVE_INTERVAL); | |
} | |
getState(dateString) { | |
const loadedData = this.load(); | |
return this.queuedUpdates[dateString] ?? loadedData[dateString] ?? false; | |
} | |
// 변경사항 큐에 저장 | |
setState(dateString, checked) { | |
this.queuedUpdates[dateString] = checked; | |
this.save(); | |
} | |
getAllCheckedDates() { | |
const data = this.load(); | |
Object.assign(data, this.queuedUpdates); | |
return Object.entries(data) | |
.filter(([_, checked]) => checked) | |
.map(([date]) => date); | |
} | |
} | |
// 달력 생성기 | |
class CalendarGenerator { | |
constructor(year, storageManager) { | |
this.year = year; | |
this.storageManager = storageManager; | |
this.calendarContainer = document.getElementById('calendar-container'); | |
this.fragment = document.createDocumentFragment(); | |
} | |
generateCalendars() { | |
CONSTANTS.MONTHS.forEach(month => this.createMonthCalendar(month)); | |
this.calendarContainer.appendChild(this.fragment); | |
} | |
createMonthCalendar(month) { | |
const table = document.createElement('table'); | |
this.generateMonthHeader(table, month); | |
const tbody = table.querySelector('tbody'); | |
const daysInMonth = DateUtils.getDaysInMonth(this.year, month); | |
const firstDayOfWeek = new Date(this.year, month, 1).getDay(); | |
let currentWeek = document.createElement('tr'); | |
// 첫 주의 빈 칸 | |
for (let i = 0; i < firstDayOfWeek; i++) { | |
currentWeek.appendChild(document.createElement('td')); | |
} | |
// 날짜 채우기 (불필요한 DOM 재계산 최소화) | |
for (let day = 1; day <= daysInMonth; day++) { | |
const date = new Date(this.year, month, day); | |
const dateString = DateUtils.formatDate(date); | |
const dayOfWeek = date.getDay(); | |
if (dayOfWeek === 0 && day !== 1) { | |
tbody.appendChild(currentWeek); | |
currentWeek = document.createElement('tr'); | |
} | |
const cell = this.createDayCell(date, dateString, dayOfWeek); | |
currentWeek.appendChild(cell); | |
} | |
tbody.appendChild(currentWeek); | |
this.fragment.appendChild(table); | |
} | |
generateMonthHeader(table, month) { | |
table.innerHTML = ` | |
<thead> | |
<tr><th colspan="7" class="month-title">${this.year}년 ${month + 1}월</th></tr> | |
<tr> | |
${CONSTANTS.DAYS_OF_WEEK.map(day => `<th>${day}</th>`).join('')} | |
</tr> | |
</thead> | |
<tbody></tbody> | |
`; | |
} | |
createDayCell(date, dateString, dayOfWeek) { | |
const cell = document.createElement('td'); | |
const isToday = date.toDateString() === new Date().toDateString(); | |
cell.setAttribute('data-day', dayOfWeek); | |
if (isToday) cell.setAttribute('data-today', 'true'); | |
const month = date.getMonth() + 1; | |
const day = date.getDate(); | |
cell.innerHTML = ` | |
<div class="checkbox-container"> | |
${month}/${day} | |
<input type="checkbox" id="${dateString}" data-date="${dateString}" | |
${this.storageManager.getState(dateString) ? 'checked' : ''}> | |
<label class="checkbox-label" for="${dateString}"></label> | |
</div> | |
`; | |
return cell; | |
} | |
} | |
// 통계 관리자 | |
class StatsManager { | |
constructor(storageManager) { | |
this.storageManager = storageManager; | |
// DOM 요소 캐싱 | |
this.totalProgressEl = document.getElementById('total-progress'); | |
this.totalProgressBarEl = document.getElementById('total-progress-bar'); | |
this.monthProgressEl = document.getElementById('month-progress'); | |
this.monthProgressBarEl = document.getElementById('month-progress-bar'); | |
this.streakCountEl = document.getElementById('streak-count'); | |
this.totalCheckedEl = document.getElementById('total-checked'); | |
} | |
updateStats() { | |
const checkedDates = this.storageManager.getAllCheckedDates(); | |
const today = new Date(); | |
const currentMonth = today.getMonth(); | |
// 전체 달성률 계산 | |
const totalDaysInYear = DateUtils.isLeapYear(CONSTANTS.YEAR) ? 366 : 365; | |
const totalChecked = checkedDates.length; | |
const totalProgress = Math.round((totalChecked / totalDaysInYear) * 100); | |
// 이번 달 달성률 계산 | |
const daysInCurrentMonth = DateUtils.getDaysInMonth(CONSTANTS.YEAR, currentMonth); | |
const monthChecked = checkedDates.filter(date => date.startsWith(`${CONSTANTS.YEAR}-${String(currentMonth + 1).padStart(2, '0')}`)); | |
const monthProgress = Math.round((monthChecked.length / daysInCurrentMonth) * 100); | |
// 연속 달성 일수 계산 | |
const streak = this.calculateStreak(checkedDates); | |
this.updateUI({ | |
totalProgress, | |
monthProgress, | |
streak, | |
totalChecked | |
}); | |
} | |
calculateStreak(checkedDates) { | |
let streak = 0; | |
const today = new Date(); | |
let currentDate = new Date(today); | |
while (true) { | |
const dateString = DateUtils.formatDate(currentDate); | |
if (!checkedDates.includes(dateString)) break; | |
streak++; | |
currentDate.setDate(currentDate.getDate() - 1); | |
} | |
return streak; | |
} | |
updateUI(stats) { | |
// 달성률이 100%를 넘지 않도록 보장 | |
const safeTotalProgress = Math.min(stats.totalProgress, 100); | |
const safeMonthProgress = Math.min(stats.monthProgress, 100); | |
this.totalProgressEl.textContent = `${safeTotalProgress}%`; | |
this.totalProgressBarEl.style.width = `${safeTotalProgress}%`; | |
this.monthProgressEl.textContent = `${safeMonthProgress}%`; | |
this.monthProgressBarEl.style.width = `${safeMonthProgress}%`; | |
this.streakCountEl.textContent = `${stats.streak}일`; | |
this.totalCheckedEl.textContent = `${stats.totalChecked}일`; | |
} | |
} | |
// 메인 애플리케이션 | |
class ChallengeApp { | |
constructor() { | |
this.storageManager = new StorageManager(CONSTANTS.STORAGE_KEY); | |
this.statsManager = new StatsManager(this.storageManager); | |
this.calendarGenerator = new CalendarGenerator(CONSTANTS.YEAR, this.storageManager); | |
this.init(); | |
} | |
init() { | |
this.calendarGenerator.generateCalendars(); | |
this.setupEventListeners(); | |
this.statsManager.updateStats(); | |
} | |
setupEventListeners() { | |
this.calendarGenerator.calendarContainer.addEventListener('change', (e) => { | |
if (e.target.matches('input[type="checkbox"]')) { | |
const dateString = e.target.dataset.date; | |
this.storageManager.setState(dateString, e.target.checked); | |
this.statsManager.updateStats(); | |
} | |
}); | |
} | |
} | |
// 애플리케이션 시작 | |
document.addEventListener('DOMContentLoaded', () => { | |
new ChallengeApp(); | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment