Skip to content

Instantly share code, notes, and snippets.

@lunamoth
Last active January 10, 2025 07:32
Show Gist options
  • Save lunamoth/a1aa5d57f0cedaf53b9498a3c31e7666 to your computer and use it in GitHub Desktop.
Save lunamoth/a1aa5d57f0cedaf53b9498a3c31e7666 to your computer and use it in GitHub Desktop.
<!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