-
Notifications
You must be signed in to change notification settings - Fork 8
[1주차] 박유민 과제 제출합니다. #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 15 commits
687eb44
3ceeb05
fdfe882
9eadc73
509b731
79fe85a
6924324
a10ee5b
fed69ac
99e8509
e1df7ff
b07e078
9f18965
89ed9b5
4cb4a13
876d945
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | ||
| 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="이전 달">◀</button> | ||
| <span id="calendarTitle"></span> | ||
| <button id="nextMonthBtn" aria-label="다음 달">▶</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> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 시맨틱 태그를 꼼꼼하게 달아주셔서 코드를 읽는 것만으로도 발표 때 보여주셨던 그림이 상상됩니다! |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| // 상태 | ||
| const today = new Date() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이미 보셨을지는 모르지만 최근 Date를 대체하여 떠오르는 Temporal API에 대해서도 아시면 유용할 것 같습니다!
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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에서는' '를 사용하여 파일별로 시각적으로 구분이 되어 코드 읽기가 훨씬 편했습니다!