Skip to content

완성 소스 ​

이 부록은 실습에서 최종적으로 완성되는 To-Do 웹앱의 전체 소스 코드입니다. 할 일 생성·편집·삭제, 3단계 상태 관리, 마감일 표시, 우선순위 정렬·필터, 진행률, 로컬 스토리지 영속성까지 Scene C의 Spec 요구사항을 모두 충족합니다. 실습 중 결과물과 비교하거나, 참고용으로 활용하세요.

파일 구조 ​

todo-app/
├── index.html
├── css/
│   └── style.css
└── js/
    ├── storage.js
    └── script.js

index.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();