테마
완성 소스
이 부록은 실습에서 최종적으로 완성되는 To-Do 웹앱의 전체 소스 코드입니다. 할 일 생성·편집·삭제, 3단계 상태 관리, 마감일 표시, 우선순위 정렬·필터, 진행률, 로컬 스토리지 영속성까지 Scene C의 Spec 요구사항을 모두 충족합니다. 실습 중 결과물과 비교하거나, 참고용으로 활용하세요.
파일 구조
todo-app/
├── index.html
├── css/
│ └── style.css
└── js/
├── storage.js
└── script.jsindex.html
html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Todo App</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<main class="app" role="main">
<h1 class="app__title">할 일 관리</h1>
<!-- 입력 영역 -->
<form id="todo-form" class="todo-form" aria-label="할 일 추가">
<input
id="todo-input"
class="todo-form__input"
type="text"
placeholder="할 일을 입력하세요"
aria-label="할 일 내용"
required
/>
<select id="todo-priority" class="todo-form__select" aria-label="우선순위">
<option value="high">높음</option>
<option value="medium" selected>보통</option>
<option value="low">낮음</option>
</select>
<input id="todo-due" class="todo-form__date" type="date" aria-label="마감일" />
<button id="submit-btn" type="submit" class="todo-form__btn">추가</button>
<button id="cancel-edit-btn" type="button" class="todo-form__cancel" hidden>
편집 취소
</button>
</form>
<!-- 필터와 정렬 영역 -->
<div class="filter-bar" role="group" aria-label="목록 필터와 정렬">
<label class="filter-bar__field">
상태
<select id="status-filter" class="filter-bar__select">
<option value="all">전체</option>
<option value="todo">할 일</option>
<option value="in-progress">진행 중</option>
<option value="completed">완료</option>
</select>
</label>
<label class="filter-bar__field">
우선순위
<select id="priority-filter" class="filter-bar__select">
<option value="all">전체</option>
<option value="high">높음</option>
<option value="medium">보통</option>
<option value="low">낮음</option>
</select>
</label>
<label class="filter-bar__field">
정렬
<select id="sort-order" class="filter-bar__select">
<option value="newest">최신순</option>
<option value="priority">우선순위순</option>
</select>
</label>
</div>
<!-- 마감일 알림 영역 -->
<p id="due-alert" class="due-alert" aria-live="polite" hidden></p>
<!-- 목록 영역 -->
<ul id="todo-list" class="todo-list" aria-live="polite"></ul>
<!-- 진행률 영역 -->
<div class="progress" aria-label="완료율">
<div class="progress__bar"><div id="progress-fill" class="progress__fill"></div></div>
<span id="progress-text" class="progress__text">0% 완료</span>
</div>
</main>
<script src="js/storage.js"></script>
<script src="js/script.js"></script>
</body>
</html>css/style.css
css
/* 색상 테마: 파란색 계열 · 모바일 퍼스트 · BEM */
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--danger: #dc2626;
--warning: #b45309;
--warning-bg: #fffbeb;
--bg: #f5f7fa;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--radius: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "Segoe UI", "Malgun Gothic", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 16px;
}
.app {
max-width: 640px;
margin: 0 auto;
background: var(--card);
border-radius: var(--radius);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
padding: 24px;
}
.app__title {
font-size: 1.5rem;
margin-bottom: 20px;
text-align: center;
}
/* 입력 폼 */
.todo-form {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.todo-form__input { flex: 1 1 100%; }
.todo-form__input,
.todo-form__select,
.todo-form__date {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
}
.todo-form__btn {
padding: 10px 20px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.todo-form__btn:hover { background: var(--primary-dark); }
.todo-form__cancel {
padding: 10px 16px;
background: #fff;
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
/* 필터와 정렬 */
.filter-bar {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 16px;
}
.filter-bar__field {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted);
font-size: 0.75rem;
}
.filter-bar__select {
padding: 8px 10px;
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
font-size: 0.875rem;
}
/* 마감일 알림 */
.due-alert {
margin-bottom: 12px;
padding: 10px 12px;
border-left: 4px solid var(--warning);
background: var(--warning-bg);
color: var(--warning);
font-size: 0.875rem;
}
/* 목록 */
.todo-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
.todo-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
}
.todo-item--in-progress { border-left: 4px solid var(--primary); }
.todo-item--overdue { border-color: var(--danger); background: #fef2f2; }
.todo-item--due-soon { border-color: var(--warning); background: var(--warning-bg); }
.todo-item__content { min-width: 0; }
.todo-item__text { display: block; overflow-wrap: anywhere; }
.todo-item--completed .todo-item__text { text-decoration: line-through; color: var(--muted); }
.todo-item__meta { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.todo-item__badge {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 999px;
background: #eef2ff;
color: var(--primary-dark);
}
.todo-item__badge--high { background: #fee2e2; color: var(--danger); }
.todo-item__badge--low { background: #ecfdf5; color: #059669; }
.todo-item__due { font-size: 0.75rem; color: var(--muted); }
.todo-item__due--overdue { color: var(--danger); font-weight: 700; }
.todo-item__due--soon { color: var(--warning); font-weight: 700; }
.todo-item__controls {
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.todo-item__status,
.todo-item__action {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
font-size: 0.8rem;
}
.todo-item__action {
cursor: pointer;
}
.todo-item__action--delete { color: var(--danger); }
/* 진행률 */
.progress { margin-top: 20px; }
.progress__bar { height: 8px; background: var(--border); border-radius: 999px; overflow: hidden; }
.progress__fill { height: 100%; width: 0; background: var(--primary); transition: width 0.3s; }
.progress__text { display: block; margin-top: 6px; font-size: 0.875rem; color: var(--muted); text-align: right; }
/* 모바일 목록 조작부 */
@media (max-width: 479px) {
.todo-item { grid-template-columns: 1fr; }
.todo-item__controls { justify-content: flex-start; }
}
/* 데스크톱 */
@media (min-width: 480px) {
.todo-form__input { flex: 1 1 auto; }
.filter-bar { grid-template-columns: repeat(3, 1fr); }
}js/storage.js
js
// localStorage 데이터 계층 — 데이터 영속성 담당
const STORAGE_KEY = 'todos';
// 저장된 할 일 목록을 불러온다. 실패 시 빈 배열 반환.
function loadTodos() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
} catch (err) {
console.error('할 일 불러오기 실패:', err);
return [];
}
}
// 할 일 목록을 저장한다.
function saveTodos(todos) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch (err) {
console.error('할 일 저장 실패:', err);
}
}js/script.js
js
// 애플리케이션 로직 — 상태 관리·렌더링·이벤트
let todos = loadTodos();
let currentStatusFilter = 'all';
let currentPriorityFilter = 'all';
let currentSort = 'newest';
let editingId = null;
const form = document.getElementById('todo-form');
const input = document.getElementById('todo-input');
const prioritySelect = document.getElementById('todo-priority');
const dueInput = document.getElementById('todo-due');
const submitBtn = document.getElementById('submit-btn');
const cancelEditBtn = document.getElementById('cancel-edit-btn');
const statusFilter = document.getElementById('status-filter');
const priorityFilter = document.getElementById('priority-filter');
const sortOrder = document.getElementById('sort-order');
const dueAlert = document.getElementById('due-alert');
const listEl = document.getElementById('todo-list');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
// 할 일 추가
function addTodo(text, priority, dueDate) {
const trimmed = text.trim();
if (!trimmed) return; // 입력 검증: 빈 값 거부
todos.push({
id: Date.now(),
text: trimmed,
priority: priority,
status: 'todo',
dueDate: dueDate || null,
createdAt: new Date().toISOString(),
});
saveTodos(todos);
render();
}
// 편집할 항목을 입력 폼에 불러온다.
function startEdit(id) {
const todo = todos.find((item) => item.id === id);
if (!todo) return;
editingId = id;
input.value = todo.text;
prioritySelect.value = todo.priority;
dueInput.value = todo.dueDate || '';
submitBtn.textContent = '수정 저장';
cancelEditBtn.hidden = false;
input.focus();
}
// 편집 모드를 끝내고 입력 폼을 초기화한다.
function cancelEdit() {
editingId = null;
form.reset();
submitBtn.textContent = '추가';
cancelEditBtn.hidden = true;
}
// 입력 폼의 값으로 기존 할 일을 수정한다.
function updateTodo(id, text, priority, dueDate) {
const trimmed = text.trim();
if (!trimmed) return;
todos = todos.map((t) =>
t.id === id
? { ...t, text: trimmed, priority: priority, dueDate: dueDate || null }
: t
);
saveTodos(todos);
cancelEdit();
render();
}
// 할 일, 진행 중, 완료 중 선택한 상태로 변경한다.
function changeStatus(id, status) {
todos = todos.map((t) => (t.id === id ? { ...t, status: status } : t));
saveTodos(todos);
render();
}
// 할 일 삭제
function deleteTodo(id) {
todos = todos.filter((t) => t.id !== id);
if (editingId === id) cancelEdit();
saveTodos(todos);
render();
}
// 우선순위 라벨
function priorityLabel(p) {
return { high: '높음', medium: '보통', low: '낮음' }[p] || '보통';
}
// 마감일이 지났거나 하루 이내로 남았는지 계산한다.
function getDueInfo(todo) {
if (!todo.dueDate || todo.status === 'completed') return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const dueDate = new Date(todo.dueDate + 'T00:00:00');
const daysLeft = Math.round((dueDate - today) / 86400000);
if (daysLeft < 0) return { type: 'overdue', label: '기한 지남' };
if (daysLeft === 0) return { type: 'soon', label: '오늘 마감' };
if (daysLeft === 1) return { type: 'soon', label: '내일 마감' };
return { type: 'normal', label: todo.dueDate };
}
// 상태·우선순위 필터를 적용한 뒤 선택한 기준으로 정렬한다.
function getVisibleTodos() {
const priorityOrder = { high: 0, medium: 1, low: 2 };
const visible = todos.filter((todo) => {
const matchesStatus =
currentStatusFilter === 'all' || todo.status === currentStatusFilter;
const matchesPriority =
currentPriorityFilter === 'all' || todo.priority === currentPriorityFilter;
return matchesStatus && matchesPriority;
});
return visible.sort((a, b) => {
if (currentSort === 'priority') {
return priorityOrder[a.priority] - priorityOrder[b.priority];
}
return new Date(b.createdAt) - new Date(a.createdAt);
});
}
// 진행률 갱신
function updateProgress() {
const total = todos.length;
const done = todos.filter((t) => t.status === 'completed').length;
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
progressFill.style.width = pct + '%';
progressText.textContent = pct + '% 완료';
}
// 지난 마감일과 임박한 마감일을 화면 알림으로 보여준다.
function updateDueAlert() {
const dueStates = todos.map(getDueInfo).filter(Boolean);
const overdueCount = dueStates.filter((item) => item.type === 'overdue').length;
const soonCount = dueStates.filter((item) => item.type === 'soon').length;
dueAlert.hidden = overdueCount === 0 && soonCount === 0;
dueAlert.textContent =
'마감 알림: 기한이 지난 할 일 ' +
overdueCount +
'개, 오늘·내일 마감 할 일 ' +
soonCount +
'개';
}
// 화면 렌더링
function render() {
listEl.innerHTML = '';
getVisibleTodos().forEach((todo) => {
const dueInfo = getDueInfo(todo);
const li = document.createElement('li');
li.className = 'todo-item todo-item--' + todo.status;
if (dueInfo?.type === 'overdue') li.classList.add('todo-item--overdue');
if (dueInfo?.type === 'soon') li.classList.add('todo-item--due-soon');
const content = document.createElement('div');
content.className = 'todo-item__content';
const span = document.createElement('span');
span.className = 'todo-item__text';
span.textContent = todo.text;
const meta = document.createElement('div');
meta.className = 'todo-item__meta';
const badge = document.createElement('span');
badge.className = 'todo-item__badge todo-item__badge--' + todo.priority;
badge.textContent = priorityLabel(todo.priority);
meta.append(badge);
if (todo.dueDate) {
const due = document.createElement('span');
due.className = 'todo-item__due';
due.textContent = dueInfo ? dueInfo.label : todo.dueDate;
if (dueInfo?.type === 'overdue') due.classList.add('todo-item__due--overdue');
if (dueInfo?.type === 'soon') due.classList.add('todo-item__due--soon');
meta.append(due);
}
content.append(span, meta);
const controls = document.createElement('div');
controls.className = 'todo-item__controls';
const status = document.createElement('select');
status.className = 'todo-item__status';
status.setAttribute('aria-label', todo.text + ' 상태 변경');
[
['todo', '할 일'],
['in-progress', '진행 중'],
['completed', '완료'],
].forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
status.append(option);
});
status.value = todo.status;
status.addEventListener('change', () => changeStatus(todo.id, status.value));
const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'todo-item__action';
edit.textContent = '편집';
edit.setAttribute('aria-label', todo.text + ' 편집');
edit.addEventListener('click', () => startEdit(todo.id));
const del = document.createElement('button');
del.type = 'button';
del.className = 'todo-item__action todo-item__action--delete';
del.textContent = '삭제';
del.setAttribute('aria-label', todo.text + ' 삭제');
del.addEventListener('click', () => deleteTodo(todo.id));
controls.append(status, edit, del);
li.append(content, controls);
listEl.append(li);
});
updateProgress();
updateDueAlert();
}
// 이벤트 바인딩
form.addEventListener('submit', (e) => {
e.preventDefault();
if (editingId === null) {
addTodo(input.value, prioritySelect.value, dueInput.value);
form.reset();
} else {
updateTodo(editingId, input.value, prioritySelect.value, dueInput.value);
}
});
cancelEditBtn.addEventListener('click', cancelEdit);
statusFilter.addEventListener('change', () => {
currentStatusFilter = statusFilter.value;
render();
});
priorityFilter.addEventListener('change', () => {
currentPriorityFilter = priorityFilter.value;
render();
});
sortOrder.addEventListener('change', () => {
currentSort = sortOrder.value;
render();
});
// 초기 렌더링
render();