Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always",
"useTabs": false,
"tabWidth": 2,
"printWidth": 80,
"endOfLine": "lf"
}
Comment on lines +1 to +11
Copy link
Copy Markdown

@a-00-a a-00-a Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prettier 를 이용해 개발 환경을 관리하셔서 코드가 전체적으로 깔끔하게 느껴졌습니다!
특히 "semi": false 설정 덕분에 JS 코드가 줄 끝이 시각적으로 깔끔하게 정리되어 있어 가독성이 좋았습니다.

개인적으로는 "singleQuote": true 설정 덕분에 HTML에서는 " " 를, JS에서는 ' ' 를 사용하여 파일별로 시각적으로 구분이 되어 코드 읽기가 훨씬 편했습니다!

86 changes: 86 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vanilla Todo</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<header id="appHeader">
<h1>Vanilla Todo</h1>
</header>
<main id="appMain">
<section id="calendarSection">
<div id="calendar" role="region" aria-labelledby="calendarTitle">
<div id="calendarHeader">
<button id="prevMonthBtn" aria-label="이전 달">&#9664;</button>
<span id="calendarTitle"></span>
<button id="nextMonthBtn" aria-label="다음 달">&#9654;</button>
</div>
<div id="calendarWeekdays" role="row">
<span>일</span>
<span>월</span>
<span>화</span>
<span>수</span>
<span>목</span>
<span>금</span>
<span>토</span>
</div>
<div id="calendarDays" role="grid"></div>
</div>
<aside id="todayStats" role="region" aria-label="오늘 통계">
<div id="todayStatsHeader">
<p id="todayStatsText" aria-live="polite"></p>
<span id="todayStatsPercent" aria-live="polite"></span>
</div>
<div
id="todayProgressBarTrack"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-label="오늘 완료율"
>
<div id="todayProgressBarFill"></div>
</div>
</aside>
<aside id="monthStats" role="region" aria-label="이번 달 통계">
<div id="statsHeader">
<p id="statsText" aria-live="polite"></p>
<span id="statsPercent" aria-live="polite"></span>
</div>
<div
id="progressBarTrack"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-label="이번 달 완료율"
>
<div id="progressBarFill"></div>
</div>
</aside>
</section>
<section id="todoSection" aria-label="할 일 목록">
<h2 id="selectedDateTitle"></h2>
<div id="todoListWrapper">
<ul id="todoList" aria-live="polite" aria-label="할 일 목록"></ul>
</div>
<div id="todoInputArea">
<input
type="text"
id="todoInput"
placeholder="할 일을 입력하세요"
autocomplete="off"
aria-label="할 일 입력"
/>
<button id="todoAddBtn">추가</button>
</div>
</section>
</main>
</div>
<script src="script.js"></script>
</body>
</html>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

시맨틱 태그를 꼼꼼하게 달아주셔서 코드를 읽는 것만으로도 발표 때 보여주셨던 그림이 상상됩니다!

250 changes: 250 additions & 0 deletions script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// 상태
const today = new Date()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미 보셨을지는 모르지만 최근 Date를 대체하여 떠오르는 Temporal API에 대해서도 아시면 유용할 것 같습니다!

Date는 사라지고 Temporal이 온다

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 알려주셔서 감사합니다!! 👍🏻👍🏻👍🏻

let currentYear = today.getFullYear()
let currentMonth = today.getMonth()
let selectedDate = new Date(today)
let store = loadStoreFromStorage()

// 유틸
const dateToKey = (date) => {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}

const formatDateTitle = (date) =>
`${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`

const isSameDate = (a, b) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()

const getTodosByDate = (date) => store[dateToKey(date)] ?? []

// 스토리지
function loadStoreFromStorage() {
try {
const raw = localStorage.getItem('todo-store')
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}

const saveStoreToStorage = () => {
localStorage.setItem('todo-store', JSON.stringify(store))
}

// 렌더링
const renderTodayStatsSection = () => {
const textEl = document.getElementById('todayStatsText')
const percentEl = document.getElementById('todayStatsPercent')
const fillEl = document.getElementById('todayProgressBarFill')
const trackEl = document.getElementById('todayProgressBarTrack')
const todos = getTodosByDate(today)
const total = todos.length
const done = todos.filter((t) => t.done).length
const percent = total === 0 ? 0 : Math.round((done / total) * 100)

textEl.textContent =
total === 0 ? '오늘 할 일이 없어요' : `오늘 ${done} / ${total}개 완료`
percentEl.textContent = total === 0 ? '' : `${percent}%`
fillEl.style.width = `${percent}%`
trackEl.setAttribute('aria-valuenow', percent)
}

const renderMonthStatsSection = () => {
const statsEl = document.getElementById('statsText')
const fillEl = document.getElementById('progressBarFill')
const percentEl = document.getElementById('statsPercent')
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
let total = 0
let done = 0

for (let i = 1; i <= daysInMonth; i++) {
const todos = getTodosByDate(new Date(currentYear, currentMonth, i))
total += todos.length
done += todos.filter((t) => t.done).length
}

const percent = total === 0 ? 0 : Math.round((done / total) * 100)
statsEl.textContent =
total === 0 ? '이번 달 할 일이 없어요' : `이번 달 ${done} / ${total}개 완료`
percentEl.textContent = total === 0 ? '' : `${percent}%`
fillEl.style.width = `${percent}%`
}

const renderCalendarGrid = () => {
const titleEl = document.getElementById('calendarTitle')
const daysEl = document.getElementById('calendarDays')
titleEl.textContent = `${currentYear}년 ${currentMonth + 1}월`
daysEl.innerHTML = ''

const firstDay = new Date(currentYear, currentMonth, 1).getDay()
const lastDate = new Date(currentYear, currentMonth + 1, 0).getDate()
const prevLastDate = new Date(currentYear, currentMonth, 0).getDate()

for (let i = firstDay - 1; i >= 0; i--) {
const d = new Date(currentYear, currentMonth - 1, prevLastDate - i)
daysEl.appendChild(createCalendarDayButton(d, true))
}

for (let i = 1; i <= lastDate; i++) {
const d = new Date(currentYear, currentMonth, i)
daysEl.appendChild(createCalendarDayButton(d, false))
}

const total = firstDay + lastDate
const remaining = total % 7 === 0 ? 0 : 7 - (total % 7)
for (let i = 1; i <= remaining; i++) {
const d = new Date(currentYear, currentMonth + 1, i)
daysEl.appendChild(createCalendarDayButton(d, true))
}

renderTodayStatsSection()
renderMonthStatsSection()
}

const createCalendarDayButton = (date, otherMonth) => {
const btn = document.createElement('button')
btn.className = 'day'
btn.textContent = String(date.getDate())
btn.dataset.date = dateToKey(date)

if (otherMonth) btn.classList.add('other-month')
if (isSameDate(date, today)) btn.classList.add('today')
if (isSameDate(date, selectedDate)) btn.classList.add('selected')
if (getTodosByDate(date).length > 0) btn.classList.add('has-todos')

btn.addEventListener('click', () => {
selectedDate = date
if (otherMonth) {
currentYear = date.getFullYear()
currentMonth = date.getMonth()
renderCalendarGrid()
} else {
document
.querySelectorAll('.day')
.forEach((el) => el.classList.remove('selected'))
btn.classList.add('selected')
}
renderTodoListSection()
})

return btn
}

const renderTodoListSection = () => {
const titleEl = document.getElementById('selectedDateTitle')
const listEl = document.getElementById('todoList')
titleEl.textContent = formatDateTitle(selectedDate)
listEl.innerHTML = ''

const todos = getTodosByDate(selectedDate)

todos.forEach((todo) => {
const li = document.createElement('li')
li.className = 'todoItem' + (todo.done ? ' done' : '')
li.dataset.id = String(todo.id)

const textEl = document.createElement('p')
textEl.className = 'todoText'
textEl.textContent = todo.text

const actions = document.createElement('div')
actions.className = 'todoActions'

const doneBtn = document.createElement('button')
doneBtn.className = 'doneBtn'
doneBtn.textContent = todo.done ? '취소' : '완료'
doneBtn.addEventListener('click', () => toggleTodoDone(todo.id))

const deleteBtn = document.createElement('button')
deleteBtn.className = 'deleteBtn'
deleteBtn.textContent = '삭제'
deleteBtn.addEventListener('click', () => deleteTodoItem(todo.id))

actions.appendChild(doneBtn)
actions.appendChild(deleteBtn)
li.appendChild(textEl)
li.appendChild(actions)
listEl.appendChild(li)
})
}

// CRUD
const addTodoItem = (text) => {
const key = dateToKey(selectedDate)
if (!store[key]) store[key] = []
store[key].push({ id: Date.now(), text: text.trim(), done: false })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

투두를 저장할 때, Date.now()를 id로 사용하는데 간편하고 좋지만 확장성을 고려하였을 때는 uuid를 활용하는 것이 유지보수 측면에서 더 좋다고 고려됩니다! (사실 이번 과제 정도에서는 이 방식도 직관적이고 좋지만 이후 볼륨이 커졌을 때 uuid 방식을 고려하시면 좋을 것 같아요~)

saveStoreToStorage()
renderTodoListSection()
renderCalendarGrid()
}

const toggleTodoDone = (id) => {
const key = dateToKey(selectedDate)
const todo = store[key]?.find((t) => t.id === id)
if (todo) {
todo.done = !todo.done
saveStoreToStorage()
renderTodoListSection()
renderTodayStatsSection()
renderMonthStatsSection()
}
}

const deleteTodoItem = (id) => {
const key = dateToKey(selectedDate)
if (!store[key]) return
store[key] = store[key].filter((t) => t.id !== id)
if (store[key].length === 0) delete store[key]
saveStoreToStorage()
renderTodoListSection()
renderCalendarGrid()
}

// 이벤트
const handleTodoAdd = () => {
const input = document.getElementById('todoInput')
const text = input.value.trim()
if (!text) return
addTodoItem(text)
input.value = ''
input.focus()
}

const bindUIEvents = () => {
document.getElementById('prevMonthBtn').addEventListener('click', () => {
currentMonth--
if (currentMonth < 0) {
currentMonth = 11
currentYear--
}
renderCalendarGrid()
})

document.getElementById('nextMonthBtn').addEventListener('click', () => {
currentMonth++
if (currentMonth > 11) {
currentMonth = 0
currentYear++
}
renderCalendarGrid()
})

document.getElementById('todoAddBtn').addEventListener('click', handleTodoAdd)

document.getElementById('todoInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.isComposing) handleTodoAdd()
})
}

// 초기화
document.addEventListener('DOMContentLoaded', () => {
bindUIEvents()
renderCalendarGrid()
renderTodoListSection()
})
Loading