DAY 02 / SOURCE FILES
예제 코드 · 읽고 비교하기
코드는 ZIP 원본과 아래 열람본으로 함께 제공합니다.
예제 앱은 이 브라우저의 저장소를 사용합니다. 파일 URL에서 저장이 제한되면 앱의 안내에 따라 확인하세요.
파일 선택
- assignment-check-day2/README.md
- assignment-check-day2/changes.patch
- assignment-check-day2/checkpoint-edit/index.html
- assignment-check-day2/checks.csv
- assignment-check-day2/git-steps.txt
- assignment-check-day2/prompts.txt
- assignment-check-day2/solution/index.html
- assignment-check-day2/starter/index.html
assignment-check-day2/README.md
# 2일차 과제 체크 앱 개선
starter는 전날 기준, checkpoint-edit는 수정 기능, solution은 수정+검색 완성 코드입니다.
학생은 본인 저장소의 루트 index.html을 단계별로 수정합니다. 강사 공개 앱은 1일차 보존을 위해 /day2/ 경로를 사용합니다.
ZIP에는 .git 이력이 없습니다. Git 실습은 본인 저장소 clone으로 시작하고 각 단계의 HTML을 비교합니다.
단계별 교재: https://vibe-coding-course-three.vercel.app/assignment-day2-lab.html
개선 앱: https://sky6174.github.io/assignment-check-demo/day2/
실제 강사 PR: https://github.com/SKY6174/assignment-check-demo/pull/1
같은 origin과 저장 키 assignment-check-demo:v1을 사용합니다. 로컬/Pages 저장은 서로 다릅니다. 개인정보·비밀키를 넣지 않습니다.
assignment-check-day2/changes.patch
--- a/index.html
+++ b/index.html
@@ -4,20 +4,23 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="과제를 기록하고 마감일과 완료 상태를 확인하는 학생용 학습 예제. 이 브라우저에만 저장됩니다.">
-<title>과제 체크 · 오늘 할 일을 가볍게</title>
+<title>수정과 검색 · 과제 체크 2일차</title>
<style>
:root{font-family:"Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;color:#183c32;background:#f5f7f0;--ink:#183c32;--muted:#64786e;--line:#d4ded2;--green:#204d3b;--mint:#d9f1bd;--warn:#9c4435;color-scheme:light}
-*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button,a,input{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #4b865d}button{cursor:pointer;min-height:48px;border:1px solid var(--line);border-radius:12px;padding:10px 16px;color:var(--ink);background:white}button:hover{background:#edf3e8}a{color:var(--green);text-underline-offset:4px}.wrap{max-width:1140px;margin:auto;padding:28px 28px 45px}.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:24px;border-bottom:1px solid var(--line)}.brand{display:flex;gap:10px;align-items:center;font-weight:800;font-size:18px}.brand svg{width:34px;height:34px}.top a{font-size:13px;padding:14px 0}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.15em;color:var(--muted);margin:0 0 15px}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;padding:40px 0 30px}.hero h1{font-size:clamp(34px,4.4vw,54px);line-height:1.16;letter-spacing:-.055em;margin:0 0 13px}.hero p{margin:0;color:var(--muted);font-size:15px;line-height:1.8}.badge{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;border:1px solid #ccdbc3;padding:9px 13px;border-radius:50px;background:#edf4e5}.badge:before{content:"";width:7px;height:7px;background:#628640;border-radius:50%}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:26px}.stat{border:1px solid var(--line);border-radius:18px;padding:20px 23px;background:#fff}.stat:first-child{background:var(--green);color:#f2f9eb;border-color:var(--green)}.stat span{font-size:13px;display:block;margin-bottom:10px}.stat strong{font:600 35px ui-monospace,monospace;letter-spacing:-.05em}.stat small{font-size:12px;margin-left:7px;color:inherit}.stat.overdue strong{color:var(--warn)}.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:23px;align-items:start}.panel{background:white;border:1px solid var(--line);border-radius:20px;padding:25px}.panel h2,.list-head h2{font-size:20px;letter-spacing:-.035em;margin:0 0 7px}.panel .intro{font-size:13px;line-height:1.8;color:var(--muted);margin:0 0 24px}.field{margin-bottom:19px}.field label{font-size:13px;display:block;margin-bottom:8px;font-weight:650}.field label span{font-size:11px;font-weight:400;color:var(--muted);margin-left:5px}.field input{width:100%;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;font-size:1rem;color:var(--ink);background:#fcfdf9;min-width:0}.field input:user-invalid,.field input[aria-invalid=true]{border-color:var(--warn);background:#fff6f1}.error{display:none;font-size:12px;color:var(--warn);line-height:1.6;margin:7px 0 0}.field input:user-invalid+.error,.field input[aria-invalid=true]+.error{display:block}.primary{background:var(--green);border-color:var(--green);color:#f3ffe8;width:100%;font-weight:700}.primary:hover{background:#153e2d}.privacy{font-size:12px;color:var(--muted);line-height:1.8;margin:18px 0 0}.list-area{min-width:0}.list-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:0 0 18px}.list-head p{font-size:12px;color:var(--muted);margin:0}.sample{background:#e6efdc;border-color:#c8d9bd;font-size:12px;white-space:nowrap}.filters{display:flex;gap:7px;padding:0;margin:0 0 15px;border:0;min-width:0}.filters legend{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}.filters label{cursor:pointer;position:relative;display:flex;align-items:center;gap:7px;min-height:48px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;font-size:13px;background:#fff}.filters label:has(input:checked){background:#e1efd6;border-color:#88a373;font-weight:700}.filters input{accent-color:var(--green);margin:0;width:16px;height:16px}.tasks{padding:0;list-style:none;display:flex;flex-direction:column;gap:11px;margin:0}.task{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px 17px 14px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 14px;align-items:center}.task-check{display:flex;gap:13px;align-items:center;min-height:48px;cursor:pointer;min-width:0}.task-check input{width:22px;height:22px;flex:0 0 22px;accent-color:var(--green);margin:0}.task-title{font-size:16px;line-height:1.5;font-weight:650;overflow-wrap:anywhere}.task.done .task-title{text-decoration:line-through;color:#7d8a80;font-weight:400}.task-meta{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;padding-left:35px;font-size:11px;color:var(--muted);align-items:center}.subject{background:#eef2e9;padding:5px 8px;border-radius:6px}.due{padding:5px 8px;background:#f0f4ea;border-radius:6px}.due.late{color:#963d30;background:#fff0e8}.due.today{color:#5a6b1c;background:#f1f2d7}.delete{font-size:12px;border-color:transparent;color:#768078;padding-inline:10px}.empty{border:1px dashed #c3d2bd;border-radius:18px;padding:50px 25px;text-align:center;background:#f0f5e9}.empty strong{display:block;font-size:18px;margin-bottom:10px}.empty p{font-size:13px;line-height:1.8;color:var(--muted);margin:0}.list-foot{font-size:11px;color:var(--muted);line-height:1.8;margin:15px 0 0}.notice{display:flex;gap:15px;align-items:center;justify-content:space-between;min-height:50px;margin-top:18px;padding:10px 15px;border-radius:12px;background:#e1efd6;font-size:13px;line-height:1.6}.notice:has(span:empty){display:none}.notice button{min-height:40px;font-size:12px;white-space:nowrap}.storage-warning{font-size:13px;line-height:1.8;padding:15px 18px;margin:0 0 22px;background:#fff0df;border:1px solid #e4c39e;border-radius:12px}.storage-warning[hidden],button[hidden],.empty[hidden]{display:none}footer{display:flex;justify-content:space-between;gap:20px;margin-top:35px;padding-top:20px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.9}footer p{margin:0}.skip{position:absolute;top:-100px;left:20px}.skip:focus{top:10px;padding:10px;background:white;z-index:2}
+*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button,a,input{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #4b865d}button{cursor:pointer;min-height:48px;border:1px solid var(--line);border-radius:12px;padding:10px 16px;color:var(--ink);background:white}button:hover{background:#edf3e8}a{color:var(--green);text-underline-offset:4px}.wrap{max-width:1140px;margin:auto;padding:28px 28px 45px}.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:24px;border-bottom:1px solid var(--line)}.brand{display:flex;gap:10px;align-items:center;font-weight:800;font-size:18px}.brand svg{width:34px;height:34px}.top a{font-size:13px;padding:14px 0}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.15em;color:var(--muted);margin:0 0 15px}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;padding:40px 0 30px}.hero h1{font-size:clamp(34px,4.4vw,54px);line-height:1.16;letter-spacing:-.055em;margin:0 0 13px}.hero p{margin:0;color:var(--muted);font-size:15px;line-height:1.8}.badge{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;border:1px solid #ccdbc3;padding:9px 13px;border-radius:50px;background:#edf4e5}.badge:before{content:"";width:7px;height:7px;background:#628640;border-radius:50%}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:26px}.stat{border:1px solid var(--line);border-radius:18px;padding:20px 23px;background:#fff}.stat:first-child{background:var(--green);color:#f2f9eb;border-color:var(--green)}.stat span{font-size:13px;display:block;margin-bottom:10px}.stat strong{font:600 35px ui-monospace,monospace;letter-spacing:-.05em}.stat small{font-size:12px;margin-left:7px;color:inherit}.stat.overdue strong{color:var(--warn)}.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:23px;align-items:start}.panel{background:white;border:1px solid var(--line);border-radius:20px;padding:25px}.panel h2,.list-head h2{font-size:20px;letter-spacing:-.035em;margin:0 0 7px}.panel .intro{font-size:13px;line-height:1.8;color:var(--muted);margin:0 0 24px}.field{margin-bottom:19px}.field label{font-size:13px;display:block;margin-bottom:8px;font-weight:650}.field label span{font-size:11px;font-weight:400;color:var(--muted);margin-left:5px}.field input{width:100%;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;font-size:1rem;color:var(--ink);background:#fcfdf9;min-width:0}.field input:user-invalid,.field input[aria-invalid=true]{border-color:var(--warn);background:#fff6f1}.error{display:none;font-size:12px;color:var(--warn);line-height:1.6;margin:7px 0 0}.field input:user-invalid+.error,.field input[aria-invalid=true]+.error{display:block}.primary{background:var(--green);border-color:var(--green);color:#f3ffe8;width:100%;font-weight:700}.primary:hover{background:#153e2d}.privacy{font-size:12px;color:var(--muted);line-height:1.8;margin:18px 0 0}.list-area{min-width:0}.list-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:0 0 18px}.list-head p{font-size:12px;color:var(--muted);margin:0}.sample{background:#e6efdc;border-color:#c8d9bd;font-size:12px;white-space:nowrap}.filters{display:flex;gap:7px;padding:0;margin:0 0 15px;border:0;min-width:0}.filters legend{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}.filters label{cursor:pointer;position:relative;display:flex;align-items:center;gap:7px;min-height:48px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;font-size:13px;background:#fff}.filters label:has(input:checked){background:#e1efd6;border-color:#88a373;font-weight:700}.filters input{accent-color:var(--green);margin:0;width:16px;height:16px}.tasks{padding:0;list-style:none;display:flex;flex-direction:column;gap:11px;margin:0}.task{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px 17px 14px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 14px;align-items:center}.task-check{display:flex;gap:13px;align-items:center;min-height:48px;cursor:pointer;min-width:0}.task-check input{width:22px;height:22px;flex:0 0 22px;accent-color:var(--green);margin:0}.task-title{font-size:16px;line-height:1.5;font-weight:650;overflow-wrap:anywhere}.task.done .task-title{text-decoration:line-through;color:#7d8a80;font-weight:400}.task-meta{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;padding-left:35px;font-size:11px;color:var(--muted);align-items:center}.subject{background:#eef2e9;padding:5px 8px;border-radius:6px}.due{padding:5px 8px;background:#f0f4ea;border-radius:6px}.due.late{color:#963d30;background:#fff0e8}.due.today{color:#5a6b1c;background:#f1f2d7}.delete{font-size:12px;border-color:transparent;color:#768078;padding-inline:10px}.empty{border:1px dashed #c3d2bd;border-radius:18px;padding:50px 25px;text-align:center;background:#f0f5e9}.empty strong{display:block;font-size:18px;margin-bottom:10px}.empty p{font-size:13px;line-height:1.8;color:var(--muted);margin:0}.list-foot{font-size:11px;color:var(--muted);line-height:1.8;margin:15px 0 0}.notice{display:flex;gap:15px;align-items:center;justify-content:space-between;min-height:50px;margin-top:18px;padding:10px 15px;border-radius:12px;background:#e1efd6;font-size:13px;line-height:1.6}.notice:has(span:empty){display:none}.notice button{min-height:48px;font-size:12px;white-space:nowrap}.storage-warning{font-size:13px;line-height:1.8;padding:15px 18px;margin:0 0 22px;background:#fff0df;border:1px solid #e4c39e;border-radius:12px}.storage-warning[hidden],button[hidden],.empty[hidden]{display:none}footer{display:flex;justify-content:space-between;gap:20px;margin-top:35px;padding-top:20px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.9}footer p{margin:0}.skip{position:absolute;top:-100px;left:20px}.skip:focus{top:10px;padding:10px;background:white;z-index:2}
@media(max-width:760px){.wrap{padding:20px 18px 32px}.top{padding-bottom:17px}.hero{padding:29px 0 23px;display:block}.hero .badge{margin-top:17px}.hero h1{font-size:36px}.stats{gap:9px;margin-bottom:20px}.stat{padding:16px 13px;border-radius:14px}.stat strong{font-size:29px}.stat span{font-size:11px}.workspace{grid-template-columns:1fr;gap:25px}.panel{padding:23px}.list-head{margin-bottom:14px}footer{display:block}footer p+p{margin-top:7px}}
@media(prefers-reduced-motion:no-preference){button{transition:background .15s ease}}
+.task-actions{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end}.edit,.delete{font-size:12px;padding-inline:10px}.cancel{width:100%;margin-top:10px}.panel.editing{border-color:#628640;background:#f5faef}.task-actions button{min-height:48px}
+@media(max-width:480px){.task-actions{flex-direction:column}.task{gap:8px}.task-meta{padding-left:0}}
+.search-field{margin-bottom:16px}.search-field label{display:block;font-size:13px;font-weight:650;margin-bottom:8px}.search-row{display:flex;gap:8px}.search-row input{min-width:0;flex:1;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;background:#fff;color:var(--ink)}button:disabled{opacity:.55;cursor:default}
</style>
</head>
<body>
<a class="skip" href="#taskTitle">과제 추가로 이동</a>
<div class="wrap">
-<header class="top"><div class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><rect width="36" height="36" rx="11" fill="#204d3b"/><path d="m10 18 5 5 11-11" fill="none" stroke="#d9f1bd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>과제 체크</div><a href="https://vibe-coding-course-three.vercel.app/assignment-lab.html">따라 만들기 교재 ↗</a></header>
+<header class="top"><div class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><rect width="36" height="36" rx="11" fill="#204d3b"/><path d="m10 18 5 5 11-11" fill="none" stroke="#d9f1bd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>과제 체크</div><a href="https://vibe-coding-course-three.vercel.app/assignment-day2-lab.html">2일차 실습 교재 ↗</a></header>
<main>
-<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">ONE THING AT A TIME</p><h1 id="pageTitle">오늘 할 일을,<br>하나씩 가볍게.</h1><p>과제를 적고, 마감일을 확인하고, 끝낸 일에 체크하세요.</p></div><span class="badge" id="storageBadge">이 브라우저에 저장</span></section>
+<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">DAY 02 / SMALL CHANGES, REAL PROGRESS</p><h1 id="pageTitle">오늘 할 일을,<br>하나씩 가볍게.</h1><p>어제 만든 앱에, 오늘 필요한 변화를 더하세요.</p></div><span class="badge" id="storageBadge">이 브라우저에 저장</span></section>
<div id="storageWarning" class="storage-warning" role="alert" hidden></div>
<section class="stats" aria-label="과제 현황"><div class="stat"><span>남은 과제</span><strong id="openCount">0</strong><small>개</small></div><div class="stat"><span>오늘 마감</span><strong id="todayCount">0</strong><small>개</small></div><div class="stat overdue"><span>기한 지남</span><strong id="lateCount">0</strong><small>개</small></div></section>
<div class="workspace">
@@ -27,11 +30,13 @@
<div class="field"><label for="taskTitle">과제명 <span>필수 · 60자 이내</span></label><input id="taskTitle" name="title" type="text" required maxlength="60" pattern=".*\S.*" placeholder="예: 국어 독서 감상문 쓰기" autocomplete="off" aria-describedby="titleError"><p id="titleError" class="error">⚠ 과제명을 입력해 주세요. 공백만 입력할 수 없어요.</p></div>
<div class="field"><label for="taskSubject">과목 <span>선택 · 20자 이내</span></label><input id="taskSubject" name="subject" type="text" maxlength="20" placeholder="예: 국어" autocomplete="off" aria-describedby="subjectError"><p id="subjectError" class="error">⚠ 과목은 20자 이내로 입력해 주세요.</p></div>
<div class="field"><label for="taskDue">마감일 <span>필수</span></label><input id="taskDue" name="due" type="date" required min="0001-01-01" max="9999-12-31" aria-describedby="dueError"><p id="dueError" class="error">⚠ 올바른 마감일을 선택해 주세요.</p></div>
-<button class="primary" type="submit">+ 과제 추가</button>
+<button class="primary" id="submitTask" type="submit">+ 과제 추가</button>
+<button id="cancelEdit" class="cancel" type="button" hidden>수정 취소</button>
</fieldset></form>
<p class="privacy">이 브라우저에만 저장돼요. 다른 기기와 자동으로 공유되지 않으며, 사이트 데이터를 지우면 목록도 지워져요.</p>
</section>
<section class="list-area" aria-labelledby="taskListTitle"><div class="list-head"><div><h2 id="taskListTitle">나의 과제</h2><p id="todayLabel"></p></div><button class="sample" type="button" id="sampleBtn">예제 3개 넣기</button></div>
+<div class="search-field"><label for="taskSearch">제목·과목 검색</label><div class="search-row"><input id="taskSearch" type="search" placeholder="예: 감상문 또는 국어" autocomplete="off" aria-controls="tasks"><button id="clearSearch" type="button" disabled>지우기</button></div></div>
<fieldset class="filters"><legend>과제 표시 필터</legend><label><input type="radio" name="filter" value="all" id="filterAll" checked>전체</label><label><input type="radio" name="filter" value="open" id="filterOpen">미완료</label><label><input type="radio" name="filter" value="done" id="filterDone">완료</label></fieldset>
<ul class="tasks" id="tasks" aria-label="과제 목록"></ul>
<div class="empty" id="empty"><strong id="emptyTitle">아직 등록한 과제가 없어요.</strong><p id="emptyMessage">왼쪽에서 첫 과제를 추가하거나<br>예제 3개로 먼저 둘러보세요.</p></div>
@@ -40,14 +45,14 @@
</section>
</div>
</main>
-<footer><p>DAY 01 · 무료 AI → HTML → GitHub Pages 학습 예제</p><p>회원가입·서버·외부 API 없이 동작합니다.</p></footer>
+<footer><p>DAY 02 · Codex → Git · GitHub → 개선된 앱</p><p>회원가입·서버·외부 API 없이 동작합니다.</p></footer>
</div>
<script>
'use strict';
// 1. 달력 날짜는 시간대가 없는 YYYY-MM-DD 값으로 다룹니다.
const $ = selector => document.querySelector(selector);
const STORAGE_KEY = 'assignment-check-demo:v1';
-let items = [], persistent = true, removed = null;
+let items = [], persistent = true, removed = null, editingId = null;
const hasTemporal = typeof Temporal !== 'undefined' && !!Temporal.PlainDate;
function dateValue(date = new Date()) {
return [date.getFullYear(), String(date.getMonth()+1).padStart(2,'0'), String(date.getDate()).padStart(2,'0')].join('-');
@@ -108,7 +113,9 @@
$('#lateCount').textContent=open.filter(item=>compareDates(item.due,today)<0).length;
$('#todayLabel').textContent=`오늘 ${labelDate(today)} · 미완료 먼저, 마감일순`;
const filter=document.querySelector('[name=filter]:checked').value;
- const shown=items.filter(item=>filter==='all'||(filter==='done'?item.done:!item.done)).sort((a,b)=>Number(a.done)-Number(b.done)||compareDates(a.due,b.due)||a.createdAt-b.createdAt);
+ const query=$('#taskSearch').value.trim().toLocaleLowerCase();
+ $('#clearSearch').disabled=$('#taskSearch').value.length===0;
+ const shown=items.filter(item=>(filter==='all'||(filter==='done'?item.done:!item.done))&&(!query||item.title.toLocaleLowerCase().includes(query)||item.subject.toLocaleLowerCase().includes(query))).sort((a,b)=>Number(a.done)-Number(b.done)||compareDates(a.due,b.due)||a.createdAt-b.createdAt);
const list=$('#tasks'); list.replaceChildren();
for (const item of shown) {
const row=make('li','task'+(item.done?' done':''));
@@ -117,9 +124,13 @@
checkbox.setAttribute('aria-label',`${item.title} 완료 표시`);
label.append(checkbox,make('span','task-title',item.title));
checkbox.addEventListener('change',()=>{item.done=checkbox.checked; save(); render(); focusTask(item.id); message(item.done?'과제를 완료했어요.':'다시 미완료로 표시했어요.');});
+ const actions=make('div','task-actions'), edit=make('button','edit','수정');
+ edit.type='button'; edit.dataset.editId=item.id; edit.setAttribute('aria-label',`${item.title} 수정`);
+ edit.addEventListener('click',()=>startEdit(item));
const remove=make('button','delete','삭제'); remove.type='button'; remove.setAttribute('aria-label',`${item.title} 삭제`);
remove.addEventListener('click',()=>{
removed={item,index:items.indexOf(item)}; items=items.filter(task=>task.id!==item.id);
+ if(editingId===item.id)resetEditor();
save(); render(); $('#undoBtn').hidden=false; message('과제를 삭제했어요. 되돌릴 수 있어요.');
($('#tasks input')||$('#taskTitle')).focus();
});
@@ -127,11 +138,11 @@
meta.append(make('span','subject',item.subject||'과목 없음'));
const state=item.done?'완료':(compareDates(item.due,today)<0?'기한 지남':compareDates(item.due,today)===0?'오늘 마감':'마감');
const due=make('span','due'+(!item.done&&compareDates(item.due,today)<0?' late':!item.done&&compareDates(item.due,today)===0?' today':''),`${labelDate(item.due)} · ${state}`);
- meta.append(due); row.append(label,remove,meta); list.append(row);
+ meta.append(due); actions.append(edit,remove); row.append(label,actions,meta); list.append(row);
}
$('#empty').hidden=shown.length>0;
- $('#emptyTitle').textContent=items.length===0?'아직 등록한 과제가 없어요.':filter==='done'?'완료한 과제가 아직 없어요.':'남은 과제가 없어요!';
- $('#emptyMessage').textContent=items.length===0?'새 과제를 추가하거나 예제 3개로 먼저 둘러보세요.':filter==='done'?'끝낸 과제의 체크박스를 눌러보세요.':'전체 목록에서 과제를 확인하거나 새 과제를 추가해 보세요.';
+ $('#emptyTitle').textContent=query?'검색 결과가 없어요.':items.length===0?'아직 등록한 과제가 없어요.':filter==='done'?'완료한 과제가 아직 없어요.':'남은 과제가 없어요!';
+ $('#emptyMessage').textContent=query?'검색어를 바꾸거나 완료 필터를 확인해 주세요.':items.length===0?'새 과제를 추가하거나 예제 3개로 먼저 둘러보세요.':filter==='done'?'끝낸 과제의 체크박스를 눌러보세요.':'전체 목록에서 과제를 확인하거나 새 과제를 추가해 보세요.';
$('#listSummary').textContent=`전체 ${items.length}개 · 완료 ${done}개 · 현재 ${shown.length}개 표시`;
$('#sampleBtn').disabled=['demo-1','demo-2','demo-3'].every(id=>items.some(item=>item.id===id));
}
@@ -139,6 +150,25 @@
// 4. 네이티브 폼 검증과 접근성 상태를 함께 사용합니다.
const form=$('#taskForm'), interacted=new WeakSet();
function syncField(input) { if (input.validity.valid)input.removeAttribute('aria-invalid'); else input.setAttribute('aria-invalid','true'); }
+function resetEditor(){
+ editingId=null; form.reset();
+ form.querySelectorAll('input').forEach(input=>{input.setCustomValidity('');input.removeAttribute('aria-invalid');interacted.delete(input);});
+ $('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue();
+ $('#newTaskTitle').textContent='새 과제 추가';$('#submitTask').textContent='+ 과제 추가';$('#cancelEdit').hidden=true;
+ form.closest('.panel').classList.remove('editing');
+}
+function startEdit(item){
+ resetEditor(); editingId=item.id;
+ $('#taskTitle').value=item.title;$('#taskSubject').value=item.subject;$('#taskDue').value=item.due;
+ $('#newTaskTitle').textContent='과제 수정';$('#submitTask').textContent='변경 저장';$('#cancelEdit').hidden=false;
+ form.closest('.panel').classList.add('editing');$('#taskTitle').focus();message('제목·과목·마감일을 바꾼 뒤 변경 저장을 눌러주세요.');
+}
+$('#cancelEdit').addEventListener('click',()=>{
+ const id=editingId;resetEditor();
+ const trigger=[...document.querySelectorAll('[data-edit-id]')].find(button=>button.dataset.editId===id);
+ if(trigger)trigger.focus();else focusTask(id);
+ message('수정을 취소했어요. 원래 과제는 그대로예요.');
+});
form.addEventListener('invalid',event=>{interacted.add(event.target);syncField(event.target);},true);
form.addEventListener('blur',event=>{if(event.target.matches('input')&&interacted.has(event.target))syncField(event.target);},true);
form.addEventListener('input',event=>{if(event.target.matches('input')){interacted.add(event.target);event.target.setCustomValidity(''); if(event.target.validity.valid)event.target.removeAttribute('aria-invalid');}});
@@ -150,17 +180,26 @@
due.setCustomValidity(validDate(due.value)?'':'올바른 마감일을 선택해 주세요.');
[title,subject,due].forEach(syncField);
if (!form.reportValidity()) return;
- items.push({id:crypto.randomUUID?crypto.randomUUID():`task-${Date.now()}-${Math.random().toString(36).slice(2)}`,title:title.value.trim(),subject:subject.value.trim(),due:due.value,done:false,createdAt:Date.now()});
- save(); $('#filterAll').checked=true; render();
- form.reset(); [title,subject,due].forEach(input=>{input.removeAttribute('aria-invalid');interacted.delete(input);}); due.value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue();
- title.focus(); message(persistent?'과제를 추가하고 이 브라우저에 저장했어요.':'과제를 추가했어요. 현재 임시 모드입니다.');
+ const wasEditing=editingId!==null;
+ if(wasEditing){
+ const item=items.find(task=>task.id===editingId);
+ if(!item){resetEditor();message('수정할 과제를 찾을 수 없어요. 다시 선택해 주세요.');return;}
+ // 동일 항목의 편집 가능한 값만 변경: id, done, createdAt은 보존합니다.
+ Object.assign(item,{title:title.value.trim(),subject:subject.value.trim(),due:due.value});
+ }else{
+ items.push({id:crypto.randomUUID?crypto.randomUUID():`task-${Date.now()}-${Math.random().toString(36).slice(2)}`,title:title.value.trim(),subject:subject.value.trim(),due:due.value,done:false,createdAt:Date.now()});
+ }
+ save(); $('#filterAll').checked=true; $('#taskSearch').value=''; resetEditor(); render();
+ title.focus(); message(persistent?(wasEditing?'과제 변경을 저장했어요.':'과제를 추가하고 이 브라우저에 저장했어요.'):'변경했어요. 현재 임시 모드입니다.');
});
+$('#taskSearch').addEventListener('input',render);
+$('#clearSearch').addEventListener('click',()=>{$('#taskSearch').value='';render();$('#taskSearch').focus();});
document.querySelectorAll('[name=filter]').forEach(input=>input.addEventListener('change',render));
-$('#undoBtn').addEventListener('click',()=>{if(!removed)return;const restored=removed.item;items.splice(removed.index,0,restored);removed=null;save();$('#filterAll').checked=true;render();$('#undoBtn').hidden=true;focusTask(restored.id);message('삭제한 과제를 되돌렸어요.');});
+$('#undoBtn').addEventListener('click',()=>{if(!removed)return;const restored=removed.item;items.splice(removed.index,0,restored);removed=null;save();$('#filterAll').checked=true;$('#taskSearch').value='';render();$('#undoBtn').hidden=true;focusTask(restored.id);message('삭제한 과제를 되돌렸어요.');});
$('#sampleBtn').addEventListener('click',()=>{
const samples=[['demo-1','국어 독서 감상문','국어',0],['demo-2','수학 연습문제 5개','수학',1],['demo-3','영어 단어 복습','영어',-1]];
samples.forEach(([id,title,subject,offset],index)=>{if(!items.some(item=>item.id===id))items.push({id,title,subject,due:shiftToday(offset),done:false,createdAt:Date.now()+index});});
- save();$('#filterAll').checked=true;render();message('가상 과제 3개를 넣었어요. 오늘 마감과 기한 지남을 비교해 보세요.');
+ save();$('#filterAll').checked=true;$('#taskSearch').value='';render();message('가상 과제 3개를 넣었어요. 오늘 마감과 기한 지남을 비교해 보세요.');
});
load(); $('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue(); render();
$('#inputFields').disabled=false;
assignment-check-day2/checkpoint-edit/index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="과제를 기록하고 마감일과 완료 상태를 확인하는 학생용 학습 예제. 이 브라우저에만 저장됩니다.">
<title>과제 수정 · 과제 체크 2일차</title>
<style>
:root{font-family:"Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;color:#183c32;background:#f5f7f0;--ink:#183c32;--muted:#64786e;--line:#d4ded2;--green:#204d3b;--mint:#d9f1bd;--warn:#9c4435;color-scheme:light}
*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button,a,input{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #4b865d}button{cursor:pointer;min-height:48px;border:1px solid var(--line);border-radius:12px;padding:10px 16px;color:var(--ink);background:white}button:hover{background:#edf3e8}a{color:var(--green);text-underline-offset:4px}.wrap{max-width:1140px;margin:auto;padding:28px 28px 45px}.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:24px;border-bottom:1px solid var(--line)}.brand{display:flex;gap:10px;align-items:center;font-weight:800;font-size:18px}.brand svg{width:34px;height:34px}.top a{font-size:13px;padding:14px 0}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.15em;color:var(--muted);margin:0 0 15px}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;padding:40px 0 30px}.hero h1{font-size:clamp(34px,4.4vw,54px);line-height:1.16;letter-spacing:-.055em;margin:0 0 13px}.hero p{margin:0;color:var(--muted);font-size:15px;line-height:1.8}.badge{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;border:1px solid #ccdbc3;padding:9px 13px;border-radius:50px;background:#edf4e5}.badge:before{content:"";width:7px;height:7px;background:#628640;border-radius:50%}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:26px}.stat{border:1px solid var(--line);border-radius:18px;padding:20px 23px;background:#fff}.stat:first-child{background:var(--green);color:#f2f9eb;border-color:var(--green)}.stat span{font-size:13px;display:block;margin-bottom:10px}.stat strong{font:600 35px ui-monospace,monospace;letter-spacing:-.05em}.stat small{font-size:12px;margin-left:7px;color:inherit}.stat.overdue strong{color:var(--warn)}.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:23px;align-items:start}.panel{background:white;border:1px solid var(--line);border-radius:20px;padding:25px}.panel h2,.list-head h2{font-size:20px;letter-spacing:-.035em;margin:0 0 7px}.panel .intro{font-size:13px;line-height:1.8;color:var(--muted);margin:0 0 24px}.field{margin-bottom:19px}.field label{font-size:13px;display:block;margin-bottom:8px;font-weight:650}.field label span{font-size:11px;font-weight:400;color:var(--muted);margin-left:5px}.field input{width:100%;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;font-size:1rem;color:var(--ink);background:#fcfdf9;min-width:0}.field input:user-invalid,.field input[aria-invalid=true]{border-color:var(--warn);background:#fff6f1}.error{display:none;font-size:12px;color:var(--warn);line-height:1.6;margin:7px 0 0}.field input:user-invalid+.error,.field input[aria-invalid=true]+.error{display:block}.primary{background:var(--green);border-color:var(--green);color:#f3ffe8;width:100%;font-weight:700}.primary:hover{background:#153e2d}.privacy{font-size:12px;color:var(--muted);line-height:1.8;margin:18px 0 0}.list-area{min-width:0}.list-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:0 0 18px}.list-head p{font-size:12px;color:var(--muted);margin:0}.sample{background:#e6efdc;border-color:#c8d9bd;font-size:12px;white-space:nowrap}.filters{display:flex;gap:7px;padding:0;margin:0 0 15px;border:0;min-width:0}.filters legend{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}.filters label{cursor:pointer;position:relative;display:flex;align-items:center;gap:7px;min-height:48px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;font-size:13px;background:#fff}.filters label:has(input:checked){background:#e1efd6;border-color:#88a373;font-weight:700}.filters input{accent-color:var(--green);margin:0;width:16px;height:16px}.tasks{padding:0;list-style:none;display:flex;flex-direction:column;gap:11px;margin:0}.task{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px 17px 14px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 14px;align-items:center}.task-check{display:flex;gap:13px;align-items:center;min-height:48px;cursor:pointer;min-width:0}.task-check input{width:22px;height:22px;flex:0 0 22px;accent-color:var(--green);margin:0}.task-title{font-size:16px;line-height:1.5;font-weight:650;overflow-wrap:anywhere}.task.done .task-title{text-decoration:line-through;color:#7d8a80;font-weight:400}.task-meta{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;padding-left:35px;font-size:11px;color:var(--muted);align-items:center}.subject{background:#eef2e9;padding:5px 8px;border-radius:6px}.due{padding:5px 8px;background:#f0f4ea;border-radius:6px}.due.late{color:#963d30;background:#fff0e8}.due.today{color:#5a6b1c;background:#f1f2d7}.delete{font-size:12px;border-color:transparent;color:#768078;padding-inline:10px}.empty{border:1px dashed #c3d2bd;border-radius:18px;padding:50px 25px;text-align:center;background:#f0f5e9}.empty strong{display:block;font-size:18px;margin-bottom:10px}.empty p{font-size:13px;line-height:1.8;color:var(--muted);margin:0}.list-foot{font-size:11px;color:var(--muted);line-height:1.8;margin:15px 0 0}.notice{display:flex;gap:15px;align-items:center;justify-content:space-between;min-height:50px;margin-top:18px;padding:10px 15px;border-radius:12px;background:#e1efd6;font-size:13px;line-height:1.6}.notice:has(span:empty){display:none}.notice button{min-height:48px;font-size:12px;white-space:nowrap}.storage-warning{font-size:13px;line-height:1.8;padding:15px 18px;margin:0 0 22px;background:#fff0df;border:1px solid #e4c39e;border-radius:12px}.storage-warning[hidden],button[hidden],.empty[hidden]{display:none}footer{display:flex;justify-content:space-between;gap:20px;margin-top:35px;padding-top:20px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.9}footer p{margin:0}.skip{position:absolute;top:-100px;left:20px}.skip:focus{top:10px;padding:10px;background:white;z-index:2}
@media(max-width:760px){.wrap{padding:20px 18px 32px}.top{padding-bottom:17px}.hero{padding:29px 0 23px;display:block}.hero .badge{margin-top:17px}.hero h1{font-size:36px}.stats{gap:9px;margin-bottom:20px}.stat{padding:16px 13px;border-radius:14px}.stat strong{font-size:29px}.stat span{font-size:11px}.workspace{grid-template-columns:1fr;gap:25px}.panel{padding:23px}.list-head{margin-bottom:14px}footer{display:block}footer p+p{margin-top:7px}}
@media(prefers-reduced-motion:no-preference){button{transition:background .15s ease}}
.task-actions{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end}.edit,.delete{font-size:12px;padding-inline:10px}.cancel{width:100%;margin-top:10px}.panel.editing{border-color:#628640;background:#f5faef}.task-actions button{min-height:48px}
@media(max-width:480px){.task-actions{flex-direction:column}.task{gap:8px}.task-meta{padding-left:0}}
</style>
</head>
<body>
<a class="skip" href="#taskTitle">과제 추가로 이동</a>
<div class="wrap">
<header class="top"><div class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><rect width="36" height="36" rx="11" fill="#204d3b"/><path d="m10 18 5 5 11-11" fill="none" stroke="#d9f1bd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>과제 체크</div><a href="https://vibe-coding-course-three.vercel.app/assignment-day2-lab.html">2일차 실습 교재 ↗</a></header>
<main>
<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">DAY 02 / SMALL CHANGES, REAL PROGRESS</p><h1 id="pageTitle">오늘 할 일을,<br>하나씩 가볍게.</h1><p>어제 만든 앱에, 오늘 필요한 변화를 더하세요.</p></div><span class="badge" id="storageBadge">이 브라우저에 저장</span></section>
<div id="storageWarning" class="storage-warning" role="alert" hidden></div>
<section class="stats" aria-label="과제 현황"><div class="stat"><span>남은 과제</span><strong id="openCount">0</strong><small>개</small></div><div class="stat"><span>오늘 마감</span><strong id="todayCount">0</strong><small>개</small></div><div class="stat overdue"><span>기한 지남</span><strong id="lateCount">0</strong><small>개</small></div></section>
<div class="workspace">
<section class="panel" aria-labelledby="newTaskTitle"><h2 id="newTaskTitle">새 과제 추가</h2><p class="intro">작게 적어두면, 시작하기 쉬워져요.</p>
<noscript><p>이 예제 앱은 JavaScript가 필요합니다. 브라우저에서 JavaScript를 켠 뒤 다시 열어주세요.</p></noscript>
<form id="taskForm" action="#newTaskTitle" method="get"><fieldset id="inputFields" disabled style="border:0;padding:0;margin:0;min-width:0"><legend style="position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden">과제 정보</legend>
<div class="field"><label for="taskTitle">과제명 <span>필수 · 60자 이내</span></label><input id="taskTitle" name="title" type="text" required maxlength="60" pattern=".*\S.*" placeholder="예: 국어 독서 감상문 쓰기" autocomplete="off" aria-describedby="titleError"><p id="titleError" class="error">⚠ 과제명을 입력해 주세요. 공백만 입력할 수 없어요.</p></div>
<div class="field"><label for="taskSubject">과목 <span>선택 · 20자 이내</span></label><input id="taskSubject" name="subject" type="text" maxlength="20" placeholder="예: 국어" autocomplete="off" aria-describedby="subjectError"><p id="subjectError" class="error">⚠ 과목은 20자 이내로 입력해 주세요.</p></div>
<div class="field"><label for="taskDue">마감일 <span>필수</span></label><input id="taskDue" name="due" type="date" required min="0001-01-01" max="9999-12-31" aria-describedby="dueError"><p id="dueError" class="error">⚠ 올바른 마감일을 선택해 주세요.</p></div>
<button class="primary" id="submitTask" type="submit">+ 과제 추가</button>
<button id="cancelEdit" class="cancel" type="button" hidden>수정 취소</button>
</fieldset></form>
<p class="privacy">이 브라우저에만 저장돼요. 다른 기기와 자동으로 공유되지 않으며, 사이트 데이터를 지우면 목록도 지워져요.</p>
</section>
<section class="list-area" aria-labelledby="taskListTitle"><div class="list-head"><div><h2 id="taskListTitle">나의 과제</h2><p id="todayLabel"></p></div><button class="sample" type="button" id="sampleBtn">예제 3개 넣기</button></div>
<fieldset class="filters"><legend>과제 표시 필터</legend><label><input type="radio" name="filter" value="all" id="filterAll" checked>전체</label><label><input type="radio" name="filter" value="open" id="filterOpen">미완료</label><label><input type="radio" name="filter" value="done" id="filterDone">완료</label></fieldset>
<ul class="tasks" id="tasks" aria-label="과제 목록"></ul>
<div class="empty" id="empty"><strong id="emptyTitle">아직 등록한 과제가 없어요.</strong><p id="emptyMessage">왼쪽에서 첫 과제를 추가하거나<br>예제 3개로 먼저 둘러보세요.</p></div>
<p class="list-foot" id="listSummary" role="status"></p>
<div class="notice"><span id="status" role="status" aria-live="polite"></span><button id="undoBtn" type="button" hidden>삭제 되돌리기</button></div>
</section>
</div>
</main>
<footer><p>DAY 02 · Codex → Git · GitHub → 개선된 앱</p><p>회원가입·서버·외부 API 없이 동작합니다.</p></footer>
</div>
<script>
'use strict';
// 1. 달력 날짜는 시간대가 없는 YYYY-MM-DD 값으로 다룹니다.
const $ = selector => document.querySelector(selector);
const STORAGE_KEY = 'assignment-check-demo:v1';
let items = [], persistent = true, removed = null, editingId = null;
const hasTemporal = typeof Temporal !== 'undefined' && !!Temporal.PlainDate;
function dateValue(date = new Date()) {
return [date.getFullYear(), String(date.getMonth()+1).padStart(2,'0'), String(date.getDate()).padStart(2,'0')].join('-');
}
function validDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000')) return false;
if (hasTemporal) { try { return Temporal.PlainDate.from(value).toString() === value; } catch { return false; } }
// 단일 파일을 오프라인에서도 쓰기 위한 로컬 구성요소 대체 검증입니다.
const [year,month,day] = value.split('-').map(Number), date = new Date(0);
date.setFullYear(year,month-1,day);
return date.getFullYear()===year && date.getMonth()===month-1 && date.getDate()===day;
}
function compareDates(a,b) {
return hasTemporal ? Temporal.PlainDate.compare(a,b) : a.localeCompare(b);
}
function shiftToday(days) {
if (hasTemporal) return Temporal.Now.plainDateISO().add({days}).toString();
const date = new Date(); date.setDate(date.getDate()+days); return dateValue(date);
}
function labelDate(value) { const [,month,day] = value.split('-'); return `${Number(month)}월 ${Number(day)}일`; }
// 2. 저장 실패를 성공으로 표시하지 않고, 임시 모드를 안내합니다.
function temporaryMode(message) {
persistent = false;
$('#storageBadge').textContent = '임시 모드';
$('#storageWarning').hidden = false;
$('#storageWarning').textContent = message;
}
function validItem(item) {
return item && typeof item.id==='string' && item.id.length<=100 && typeof item.title==='string' && item.title.trim().length>0 && item.title.length<=60 && typeof item.subject==='string' && item.subject.length<=20 && typeof item.due==='string' && validDate(item.due) && typeof item.done==='boolean' && Number.isFinite(item.createdAt);
}
function load() {
try {
const value = localStorage.getItem(STORAGE_KEY);
if (value === null) return;
const parsed = JSON.parse(value);
if (!Array.isArray(parsed) || !parsed.every(validItem) || new Set(parsed.map(item=>item.id)).size!==parsed.length) throw new Error('invalid data');
items = parsed;
} catch {
temporaryMode('저장된 목록을 읽을 수 없어 임시 모드로 시작했어요. 기존 저장 데이터는 덮어쓰지 않습니다. 이 화면의 새 목록은 새로고침하면 사라질 수 있어요.');
}
}
function save() {
if (!persistent) return;
try { localStorage.setItem(STORAGE_KEY,JSON.stringify(items)); }
catch { temporaryMode('브라우저 저장이 차단되었거나 저장 공간이 부족해요. 현재 목록은 화면에 유지되지만 새로고침하면 이번 변경을 잃을 수 있어요.'); }
}
function message(text) { $('#status').textContent=text; }
function make(tag,className,text) { const node=document.createElement(tag); if(className)node.className=className; if(text!==undefined)node.textContent=text; return node; }
function focusTask(id) { const target=[...$('#tasks').querySelectorAll('input')].find(input=>input.dataset.id===id); (target||$('#tasks input')||document.querySelector('[name=filter]:checked')).focus(); }
// 3. 사용자가 입력한 값은 HTML로 실행하지 않고 textContent로 표시합니다.
function render() {
const today = hasTemporal ? Temporal.Now.plainDateISO().toString() : dateValue();
const open=items.filter(item=>!item.done), done=items.length-open.length;
$('#openCount').textContent=open.length;
$('#todayCount').textContent=open.filter(item=>compareDates(item.due,today)===0).length;
$('#lateCount').textContent=open.filter(item=>compareDates(item.due,today)<0).length;
$('#todayLabel').textContent=`오늘 ${labelDate(today)} · 미완료 먼저, 마감일순`;
const filter=document.querySelector('[name=filter]:checked').value;
const shown=items.filter(item=>filter==='all'||(filter==='done'?item.done:!item.done)).sort((a,b)=>Number(a.done)-Number(b.done)||compareDates(a.due,b.due)||a.createdAt-b.createdAt);
const list=$('#tasks'); list.replaceChildren();
for (const item of shown) {
const row=make('li','task'+(item.done?' done':''));
const label=make('label','task-check'), checkbox=make('input');
checkbox.type='checkbox'; checkbox.checked=item.done; checkbox.dataset.id=item.id;
checkbox.setAttribute('aria-label',`${item.title} 완료 표시`);
label.append(checkbox,make('span','task-title',item.title));
checkbox.addEventListener('change',()=>{item.done=checkbox.checked; save(); render(); focusTask(item.id); message(item.done?'과제를 완료했어요.':'다시 미완료로 표시했어요.');});
const actions=make('div','task-actions'), edit=make('button','edit','수정');
edit.type='button'; edit.dataset.editId=item.id; edit.setAttribute('aria-label',`${item.title} 수정`);
edit.addEventListener('click',()=>startEdit(item));
const remove=make('button','delete','삭제'); remove.type='button'; remove.setAttribute('aria-label',`${item.title} 삭제`);
remove.addEventListener('click',()=>{
removed={item,index:items.indexOf(item)}; items=items.filter(task=>task.id!==item.id);
if(editingId===item.id)resetEditor();
save(); render(); $('#undoBtn').hidden=false; message('과제를 삭제했어요. 되돌릴 수 있어요.');
($('#tasks input')||$('#taskTitle')).focus();
});
const meta=make('div','task-meta');
meta.append(make('span','subject',item.subject||'과목 없음'));
const state=item.done?'완료':(compareDates(item.due,today)<0?'기한 지남':compareDates(item.due,today)===0?'오늘 마감':'마감');
const due=make('span','due'+(!item.done&&compareDates(item.due,today)<0?' late':!item.done&&compareDates(item.due,today)===0?' today':''),`${labelDate(item.due)} · ${state}`);
meta.append(due); actions.append(edit,remove); row.append(label,actions,meta); list.append(row);
}
$('#empty').hidden=shown.length>0;
$('#emptyTitle').textContent=items.length===0?'아직 등록한 과제가 없어요.':filter==='done'?'완료한 과제가 아직 없어요.':'남은 과제가 없어요!';
$('#emptyMessage').textContent=items.length===0?'새 과제를 추가하거나 예제 3개로 먼저 둘러보세요.':filter==='done'?'끝낸 과제의 체크박스를 눌러보세요.':'전체 목록에서 과제를 확인하거나 새 과제를 추가해 보세요.';
$('#listSummary').textContent=`전체 ${items.length}개 · 완료 ${done}개 · 현재 ${shown.length}개 표시`;
$('#sampleBtn').disabled=['demo-1','demo-2','demo-3'].every(id=>items.some(item=>item.id===id));
}
// 4. 네이티브 폼 검증과 접근성 상태를 함께 사용합니다.
const form=$('#taskForm'), interacted=new WeakSet();
function syncField(input) { if (input.validity.valid)input.removeAttribute('aria-invalid'); else input.setAttribute('aria-invalid','true'); }
function resetEditor(){
editingId=null; form.reset();
form.querySelectorAll('input').forEach(input=>{input.setCustomValidity('');input.removeAttribute('aria-invalid');interacted.delete(input);});
$('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue();
$('#newTaskTitle').textContent='새 과제 추가';$('#submitTask').textContent='+ 과제 추가';$('#cancelEdit').hidden=true;
form.closest('.panel').classList.remove('editing');
}
function startEdit(item){
resetEditor(); editingId=item.id;
$('#taskTitle').value=item.title;$('#taskSubject').value=item.subject;$('#taskDue').value=item.due;
$('#newTaskTitle').textContent='과제 수정';$('#submitTask').textContent='변경 저장';$('#cancelEdit').hidden=false;
form.closest('.panel').classList.add('editing');$('#taskTitle').focus();message('제목·과목·마감일을 바꾼 뒤 변경 저장을 눌러주세요.');
}
$('#cancelEdit').addEventListener('click',()=>{
const id=editingId;resetEditor();
const trigger=[...document.querySelectorAll('[data-edit-id]')].find(button=>button.dataset.editId===id);
if(trigger)trigger.focus();else focusTask(id);
message('수정을 취소했어요. 원래 과제는 그대로예요.');
});
form.addEventListener('invalid',event=>{interacted.add(event.target);syncField(event.target);},true);
form.addEventListener('blur',event=>{if(event.target.matches('input')&&interacted.has(event.target))syncField(event.target);},true);
form.addEventListener('input',event=>{if(event.target.matches('input')){interacted.add(event.target);event.target.setCustomValidity(''); if(event.target.validity.valid)event.target.removeAttribute('aria-invalid');}});
form.addEventListener('submit',event=>{
event.preventDefault();
const title=$('#taskTitle'),subject=$('#taskSubject'),due=$('#taskDue');
title.setCustomValidity(title.value.trim().length>0&&title.value.trim().length<=60?'':'과제명을 1~60자로 입력해 주세요.');
subject.setCustomValidity(subject.value.trim().length<=20?'':'과목은 20자 이내로 입력해 주세요.');
due.setCustomValidity(validDate(due.value)?'':'올바른 마감일을 선택해 주세요.');
[title,subject,due].forEach(syncField);
if (!form.reportValidity()) return;
const wasEditing=editingId!==null;
if(wasEditing){
const item=items.find(task=>task.id===editingId);
if(!item){resetEditor();message('수정할 과제를 찾을 수 없어요. 다시 선택해 주세요.');return;}
// 동일 항목의 편집 가능한 값만 변경: id, done, createdAt은 보존합니다.
Object.assign(item,{title:title.value.trim(),subject:subject.value.trim(),due:due.value});
}else{
items.push({id:crypto.randomUUID?crypto.randomUUID():`task-${Date.now()}-${Math.random().toString(36).slice(2)}`,title:title.value.trim(),subject:subject.value.trim(),due:due.value,done:false,createdAt:Date.now()});
}
save(); $('#filterAll').checked=true; resetEditor(); render();
title.focus(); message(persistent?(wasEditing?'과제 변경을 저장했어요.':'과제를 추가하고 이 브라우저에 저장했어요.'):'변경했어요. 현재 임시 모드입니다.');
});
document.querySelectorAll('[name=filter]').forEach(input=>input.addEventListener('change',render));
$('#undoBtn').addEventListener('click',()=>{if(!removed)return;const restored=removed.item;items.splice(removed.index,0,restored);removed=null;save();$('#filterAll').checked=true;render();$('#undoBtn').hidden=true;focusTask(restored.id);message('삭제한 과제를 되돌렸어요.');});
$('#sampleBtn').addEventListener('click',()=>{
const samples=[['demo-1','국어 독서 감상문','국어',0],['demo-2','수학 연습문제 5개','수학',1],['demo-3','영어 단어 복습','영어',-1]];
samples.forEach(([id,title,subject,offset],index)=>{if(!items.some(item=>item.id===id))items.push({id,title,subject,due:shiftToday(offset),done:false,createdAt:Date.now()+index});});
save();$('#filterAll').checked=true;render();message('가상 과제 3개를 넣었어요. 오늘 마감과 기한 지남을 비교해 보세요.');
});
load(); $('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue(); render();
$('#inputFields').disabled=false;
window.addEventListener('focus',render);
</script>
</body>
</html>
assignment-check-day2/checks.csv
검사,행동,기대 결과,통과/실패/미확인,실제 결과·근거
기존 데이터,전날 목록을 읽기,제목·날짜·완료 상태 유지,,
수정 저장,완료한 과제의 제목·과목·마감일 변경,"동일 id·done·createdAt·개수, 편집 값만 변경",,
취소,값 변경 후 수정 취소,저장 값 불변·수정 버튼 초점 복귀,,
입력 검증,제목 공백·잘못된 날짜,저장 거절·오류 안내,,
검색,제목 일부·과목·영문 대소문자·앞뒤 공백,해당 과제만 표시,,
복합 조건,검색어 + 완료/미완료,두 조건 동시 적용·전체 집계 유지,,
빈 결과/지우기,없는 검색어→지우기,빈 결과 안내→원래 필터 목록,,
편집과 삭제,수정 중 해당 항목 삭제→되돌리기,추가 모드 복귀·원래 항목 복원,,
기존 기능,추가·완료·집계·새로고침·삭제/되돌리기,전날 기능 정상,,
기기/조작,390px·Tab·Enter·Space,가로 잘림 없음·조작/초점 정상,,
Git 근거,커밋2개·PR Files changed,기능별 변경과 검증 근거,,
공개 확인,병합 후 Pages URL에서 검사,최신 배포와 실제 기능 확인,,
assignment-check-day2/git-steps.txt
YOUR_ID와 저장소명은 본인 것으로 바꿉니다. 한 줄씩 읽고 실행하세요. 미커밋 변경은 보존합니다.
B01 전날 앱과 오늘의 성공 기준 확인
본인의 1일차 Pages 앱에서 가상 과제 2개를 만들고 하나를 완료한다. 현재 공개 URL과 수정 전 화면을 기록한다. 오늘은 수정·검색을 추가하고 커밋 2개와 PR로 변경 이유를 남긴다.
확인: Git·GitHub 계정·Codex 로그인과 파일 편집 가능 여부를 확인한다. Codex 이용 가능 범위와 사용량은 계정마다 다를 수 있다. 이 실습 앱에는 AI API 키가 필요 없다.
강사: 1교시 도입 8분에 전날 앱을 다시 연다. Codex 접근이 제한되면 ZIP의 starter→checkpoint-edit→solution을 비교하며 Git 절차를 계속한다. 무료 AI로 시작한 앱을 Codex의 실제 파일 편집으로 이어가는 과정이다.
B02 내 저장소 clone → 같은 폴더를 Codex에서 열기
YOUR_ID를 본인 GitHub 아이디로 바꾸고 아래 명령을 한 줄씩 실행한다. 이미 clone했다면 해당 폴더에서 status·remote만 확인한다. Codex 프로젝트에 그 폴더를 연결하고 index.html과 .git이 있는 저장소를 기본 작업 폴더로 선택한다. Codex에 현재 실행 환경을 확인시켜 로컬 미리보기를 연다.
git clone https://github.com/YOUR_ID/assignment-check.git
cd assignment-check
git status -sb
git remote -v
git branch --show-current
확인: git remote -v가 본인 저장소인지 확인한다. 다운로드 ZIP에는 Git 이력이 없으므로 commit·push 실습은 clone한 폴더에서 한다. 편집기와 터미널이 같은 폴더여야 한다.
강사: GitHub는 원격 저장소, clone은 내 컴퓨터 복사본, Codex는 이 폴더의 파일을 읽고 수정하는 도구라고 설명한다. 복사본 폴더를 여러 개 만들지 않는다. 로컬 서버가 필요하면 Codex가 설치된 실행 도구를 확인해 띄우게 한다. Python 등 새 도구 설치를 수업의 필수로 만들지 않는다.
B03 깨끗한 main → 기능 브랜치 만들기
status에 미커밋 변경이 없고 기본 브랜치가 main인 것을 확인한 뒤 아래 명령을 실행한다. 기본 브랜치가 다르면 실제 이름으로 바꾼다. feature/edit-tasks가 이미 있으면 기존 작업을 확인하고 새 이름을 정한다.
git switch main
git pull --ff-only origin main
git switch -c feature/edit-tasks
git status -sb
확인: 브랜치 이름이 feature/edit-tasks로 바뀌고 작업 파일은 그대로 보인다. 변경이 있거나 ff-only가 실패하면 중단하고 강사와 원인을 확인한다. reset --hard나 강제 push로 해결하지 않는다.
강사: 브랜치를 만들었다고 폴더나 배포 사이트가 새로 생기지 않는다. Git 이력의 작업 지점을 나눈 것이다. Worktree는 별도 폴더가 필요한 때의 선택 시연으로만 다룬다. 기존 작업은 보존한다.
B04 Codex로 과제 수정 구현 → 첫 커밋
아래 수정 요청을 Codex에 입력한다. 수정 버튼→기존 값 불러오기→변경 저장→취소를 직접 실행한다. 완료한 과제를 수정해도 완료 상태와 개수가 같은지 확인한다. 통과한 뒤 diff를 읽고 index.html만 stage·commit한다.
git diff -- index.html
git add index.html
git diff --cached
git commit -m "Add assignment editing"
확인: 제목·과목·마감일만 바뀌고 원래 과제는 1개다. 취소하면 원래 목록이 유지된다. git diff --cached에 의도한 파일만 있다. 커밋 메시지는 Add assignment editing이다.
강사: 1교시 50분은 도입8·clone/브랜치10·읽기/수정20·짝 검증8·커밋/정리4로 진행한다. AI가 전체 앱을 다시 만들면 저장 키와 id 보존 조건을 재확인한다. 코드 저장과 commit의 차이를 보여준다.
B05 제목·과목 검색 구현 → 두 번째 커밋
검색 요청을 Codex에 입력한다. 제목 일부와 과목을 각각 검색하고 영어 대소문자와 공백, 결과 없음, 완료 필터를 함께 시험한다. 검색 지우기 뒤 정상 목록이 돌아오는지 확인한 뒤 두 번째 commit을 만든다.
git diff -- index.html
git add index.html
git diff --cached
git commit -m "Add title and subject search"
git log --oneline -3
확인: 검색은 보이는 목록만 바꾸고 데이터와 전체 현황 숫자는 유지한다. git log에 수정 기능과 검색 기능이 서로 다른 커밋으로 보인다.
강사: 2교시 50분은 검색 요구8·구현15·회귀 검증15·짝 검증8·커밋/정리4다. 검색 기능이 쉬워 보여도 저장 데이터와 필터 조합에 영향을 줄 수 있음을 보여준다. 첫 커밋 덕분에 두 변경의 이유를 따로 설명할 수 있다.
B06 회귀 검사와 변경 파일 검토
검증 요청과 아래 체크표로 확인한다. 새로고침 뒤 수정 내용과 완료 상태를 확인한다. 같은 제목을 새로 추가한 것이 아닌지, 취소가 저장하지 않는지 검사한다. 수정 중 삭제와 되돌리기, 빈 검색, 휴대폰 화면도 확인한다.
git status -sb
git diff main...HEAD -- index.html
git log --oneline --decorate -4
확인: 통과/실패/미확인과 실제 결과를 기록한다. 검사 데이터는 가상 과제만 쓴다. 저장 값 확인은 수업용 가상 데이터에 한해 개발자 도구 Application→Local Storage에서 id·done·createdAt을 비교한다.
강사: 개발자 도구의 localStorage를 지우거나 운영 데이터를 덮어쓰지 않는다. localhost와 Pages는 서로 다른 origin이라 목록이 다른 것은 정상이다. 강사 공개 앱의 루트와 /day2/는 같은 origin·키로 목록을 이어 읽도록 설계했다.
B07 push → 내 저장소의 Pull Request 만들기
대상 remote와 브랜치를 확인한 뒤 push한다. 본인 GitHub 저장소의 Pull requests→New pull request에서 base:main, compare:feature/edit-tasks를 선택한다. 제목과 아래 PR 본문 예시를 채우고 Files changed에서 변경을 읽은 뒤 PR을 만든다.
git push -u origin feature/edit-tasks
확인: 커밋 2개가 PR에 보이고 본인 저장소의 main을 대상으로 한다. push만으로 main이 바뀌거나 Pages에 기능이 공개된 것은 아니다. 강사 예제 저장소에 학생 PR을 만들지 않는다.
강사: 3교시 도입8분에 G05 상태 변화를 보여준다. push/PR12·짝 검토10·병합/Pages8·배포 검증8·정리4분이다. PR 제목보다 변경 이유·검증 근거를 읽게 한다. private 토큰·실제 학생 과제는 본문에 넣지 않는다.
B08 짝 검토 → 병합 → Pages 재배포 확인
짝이 PR의 변경과 검사 결과를 읽고 개선점을 한 가지 적는다. 본인 PR을 Merge pull request로 병합한다. Settings→Pages/Actions에서 배포 완료와 사용한 커밋을 확인하고 본인 github.io 주소에서 수정·검색·새로고침을 다시 검사한다. 로컬 main을 아래 명령으로 갱신한다.
git switch main
git pull --ff-only origin main
git log --oneline -3
확인: 기록: PR URL, 두 기능 커밋, main 병합 커밋, Pages 배포 커밋, 공개 앱 URL, 확인자와 실제 결과. 배포 대기 중은 미확인으로 기록하고 다음 교시 시작 때 확인한다.
강사: PR 병합은 소스 반영, Pages built는 배포 결과, 브라우저 검사는 사용자가 보는 동작의 근거다. 서로 구분한다. 원격 main을 병합한 뒤 로컬 main도 갱신한다. 기능 브랜치 삭제·충돌 해결은 필수가 아니다. 4교시는 기존 Docker·로컬 Supabase 준비로 이어가며 하루 시간은 늘리지 않는다.assignment-check-day2/prompts.txt
editPrompt
현재 폴더의 과제 체크 앱을 읽고 현재 경로, Git 브랜치와 status, index.html의 역할, localStorage 키와 데이터 구조를 먼저 요약해줘. 이어 각 과제에 수정 버튼을 추가하고 기존 입력 폼에서 제목·과목·마감일을 수정·저장·취소할 수 있게 최소 변경해줘. 같은 id 항목만 변경하고 done·createdAt·과제 개수와 기존 저장 데이터는 유지해줘. 취소는 저장하지 않고 수정 버튼으로 초점을 돌려줘. 빈값·공백·길이·날짜 검증, textContent, 입력 label과 네이티브 버튼을 유지해줘. 수정 중인 항목을 삭제할 때 폼도 정리해줘. 변경 파일과 diff, 실제 검증 결과를 설명해줘. 서버·DB·외부 패키지·API는 추가하지 마. 아직 commit·push·PR·merge는 실행하지 마.
searchPrompt
현재 수정 기능과 기존 추가·완료·삭제/되돌리기·저장·날짜 집계를 보존하고 제목·과목 부분 검색을 추가해줘. 앞뒤 공백을 제거하고 대소문자를 무시하며 전체/미완료/완료 필터와 함께 적용해줘. 검색어가 비면 기존 필터 목록, 결과가 없으면 안내, 검색 지우기 버튼과 현재 표시 개수를 제공해줘. 검색은 과제를 삭제하거나 전체 집계·저장 데이터를 바꾸지 않는 화면 조건이야. label이 연결된 네이티브 search 입력을 쓰고 검색 조건은 저장하지 마. 추가·수정 저장 뒤 검색과 완료 필터를 초기화해 결과를 보여줘. 모바일 가로 넘침과 키보드 조작을 확인해줘. 변경 파일·diff·검증 결과를 보여주되 commit·push·PR·merge는 아직 하지 마.
verifyPrompt
현재 과제 체크 앱의 diff를 검토하고 실제 실행한 검사와 미확인을 구분해줘. 전날 데이터가 유지되는지, 완료한 과제를 수정해도 id·done·createdAt·개수가 같은지, 취소 전후 저장 데이터가 같은지, 공백 수정 거절, 제목/과목 검색·대소문자·빈 결과·완료 필터 조합·검색 지우기, 수정 중 삭제/되돌리기, 추가·완료·새로고침·날짜 집계·저장 실패 안내를 확인해줘. 390px 화면과 키보드 초점도 확인해줘. 문제는 재현 절차와 최소 수정으로 보고해줘. 마지막으로 기능별 커밋 메시지와 PR 제목·본문 초안을 제안해줘. 현재 경로·브랜치·remote와 대상 파일을 다시 확인해주고 사용자 데이터·비밀값을 출력하지 마. 원격 push·PR 생성·merge·배포는 내가 해당 단계에서 실행할게.assignment-check-day2/solution/index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="과제를 기록하고 마감일과 완료 상태를 확인하는 학생용 학습 예제. 이 브라우저에만 저장됩니다.">
<title>수정과 검색 · 과제 체크 2일차</title>
<style>
:root{font-family:"Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;color:#183c32;background:#f5f7f0;--ink:#183c32;--muted:#64786e;--line:#d4ded2;--green:#204d3b;--mint:#d9f1bd;--warn:#9c4435;color-scheme:light}
*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button,a,input{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #4b865d}button{cursor:pointer;min-height:48px;border:1px solid var(--line);border-radius:12px;padding:10px 16px;color:var(--ink);background:white}button:hover{background:#edf3e8}a{color:var(--green);text-underline-offset:4px}.wrap{max-width:1140px;margin:auto;padding:28px 28px 45px}.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:24px;border-bottom:1px solid var(--line)}.brand{display:flex;gap:10px;align-items:center;font-weight:800;font-size:18px}.brand svg{width:34px;height:34px}.top a{font-size:13px;padding:14px 0}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.15em;color:var(--muted);margin:0 0 15px}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;padding:40px 0 30px}.hero h1{font-size:clamp(34px,4.4vw,54px);line-height:1.16;letter-spacing:-.055em;margin:0 0 13px}.hero p{margin:0;color:var(--muted);font-size:15px;line-height:1.8}.badge{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;border:1px solid #ccdbc3;padding:9px 13px;border-radius:50px;background:#edf4e5}.badge:before{content:"";width:7px;height:7px;background:#628640;border-radius:50%}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:26px}.stat{border:1px solid var(--line);border-radius:18px;padding:20px 23px;background:#fff}.stat:first-child{background:var(--green);color:#f2f9eb;border-color:var(--green)}.stat span{font-size:13px;display:block;margin-bottom:10px}.stat strong{font:600 35px ui-monospace,monospace;letter-spacing:-.05em}.stat small{font-size:12px;margin-left:7px;color:inherit}.stat.overdue strong{color:var(--warn)}.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:23px;align-items:start}.panel{background:white;border:1px solid var(--line);border-radius:20px;padding:25px}.panel h2,.list-head h2{font-size:20px;letter-spacing:-.035em;margin:0 0 7px}.panel .intro{font-size:13px;line-height:1.8;color:var(--muted);margin:0 0 24px}.field{margin-bottom:19px}.field label{font-size:13px;display:block;margin-bottom:8px;font-weight:650}.field label span{font-size:11px;font-weight:400;color:var(--muted);margin-left:5px}.field input{width:100%;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;font-size:1rem;color:var(--ink);background:#fcfdf9;min-width:0}.field input:user-invalid,.field input[aria-invalid=true]{border-color:var(--warn);background:#fff6f1}.error{display:none;font-size:12px;color:var(--warn);line-height:1.6;margin:7px 0 0}.field input:user-invalid+.error,.field input[aria-invalid=true]+.error{display:block}.primary{background:var(--green);border-color:var(--green);color:#f3ffe8;width:100%;font-weight:700}.primary:hover{background:#153e2d}.privacy{font-size:12px;color:var(--muted);line-height:1.8;margin:18px 0 0}.list-area{min-width:0}.list-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:0 0 18px}.list-head p{font-size:12px;color:var(--muted);margin:0}.sample{background:#e6efdc;border-color:#c8d9bd;font-size:12px;white-space:nowrap}.filters{display:flex;gap:7px;padding:0;margin:0 0 15px;border:0;min-width:0}.filters legend{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}.filters label{cursor:pointer;position:relative;display:flex;align-items:center;gap:7px;min-height:48px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;font-size:13px;background:#fff}.filters label:has(input:checked){background:#e1efd6;border-color:#88a373;font-weight:700}.filters input{accent-color:var(--green);margin:0;width:16px;height:16px}.tasks{padding:0;list-style:none;display:flex;flex-direction:column;gap:11px;margin:0}.task{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px 17px 14px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 14px;align-items:center}.task-check{display:flex;gap:13px;align-items:center;min-height:48px;cursor:pointer;min-width:0}.task-check input{width:22px;height:22px;flex:0 0 22px;accent-color:var(--green);margin:0}.task-title{font-size:16px;line-height:1.5;font-weight:650;overflow-wrap:anywhere}.task.done .task-title{text-decoration:line-through;color:#7d8a80;font-weight:400}.task-meta{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;padding-left:35px;font-size:11px;color:var(--muted);align-items:center}.subject{background:#eef2e9;padding:5px 8px;border-radius:6px}.due{padding:5px 8px;background:#f0f4ea;border-radius:6px}.due.late{color:#963d30;background:#fff0e8}.due.today{color:#5a6b1c;background:#f1f2d7}.delete{font-size:12px;border-color:transparent;color:#768078;padding-inline:10px}.empty{border:1px dashed #c3d2bd;border-radius:18px;padding:50px 25px;text-align:center;background:#f0f5e9}.empty strong{display:block;font-size:18px;margin-bottom:10px}.empty p{font-size:13px;line-height:1.8;color:var(--muted);margin:0}.list-foot{font-size:11px;color:var(--muted);line-height:1.8;margin:15px 0 0}.notice{display:flex;gap:15px;align-items:center;justify-content:space-between;min-height:50px;margin-top:18px;padding:10px 15px;border-radius:12px;background:#e1efd6;font-size:13px;line-height:1.6}.notice:has(span:empty){display:none}.notice button{min-height:48px;font-size:12px;white-space:nowrap}.storage-warning{font-size:13px;line-height:1.8;padding:15px 18px;margin:0 0 22px;background:#fff0df;border:1px solid #e4c39e;border-radius:12px}.storage-warning[hidden],button[hidden],.empty[hidden]{display:none}footer{display:flex;justify-content:space-between;gap:20px;margin-top:35px;padding-top:20px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.9}footer p{margin:0}.skip{position:absolute;top:-100px;left:20px}.skip:focus{top:10px;padding:10px;background:white;z-index:2}
@media(max-width:760px){.wrap{padding:20px 18px 32px}.top{padding-bottom:17px}.hero{padding:29px 0 23px;display:block}.hero .badge{margin-top:17px}.hero h1{font-size:36px}.stats{gap:9px;margin-bottom:20px}.stat{padding:16px 13px;border-radius:14px}.stat strong{font-size:29px}.stat span{font-size:11px}.workspace{grid-template-columns:1fr;gap:25px}.panel{padding:23px}.list-head{margin-bottom:14px}footer{display:block}footer p+p{margin-top:7px}}
@media(prefers-reduced-motion:no-preference){button{transition:background .15s ease}}
.task-actions{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end}.edit,.delete{font-size:12px;padding-inline:10px}.cancel{width:100%;margin-top:10px}.panel.editing{border-color:#628640;background:#f5faef}.task-actions button{min-height:48px}
@media(max-width:480px){.task-actions{flex-direction:column}.task{gap:8px}.task-meta{padding-left:0}}
.search-field{margin-bottom:16px}.search-field label{display:block;font-size:13px;font-weight:650;margin-bottom:8px}.search-row{display:flex;gap:8px}.search-row input{min-width:0;flex:1;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;background:#fff;color:var(--ink)}button:disabled{opacity:.55;cursor:default}
</style>
</head>
<body>
<a class="skip" href="#taskTitle">과제 추가로 이동</a>
<div class="wrap">
<header class="top"><div class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><rect width="36" height="36" rx="11" fill="#204d3b"/><path d="m10 18 5 5 11-11" fill="none" stroke="#d9f1bd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>과제 체크</div><a href="https://vibe-coding-course-three.vercel.app/assignment-day2-lab.html">2일차 실습 교재 ↗</a></header>
<main>
<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">DAY 02 / SMALL CHANGES, REAL PROGRESS</p><h1 id="pageTitle">오늘 할 일을,<br>하나씩 가볍게.</h1><p>어제 만든 앱에, 오늘 필요한 변화를 더하세요.</p></div><span class="badge" id="storageBadge">이 브라우저에 저장</span></section>
<div id="storageWarning" class="storage-warning" role="alert" hidden></div>
<section class="stats" aria-label="과제 현황"><div class="stat"><span>남은 과제</span><strong id="openCount">0</strong><small>개</small></div><div class="stat"><span>오늘 마감</span><strong id="todayCount">0</strong><small>개</small></div><div class="stat overdue"><span>기한 지남</span><strong id="lateCount">0</strong><small>개</small></div></section>
<div class="workspace">
<section class="panel" aria-labelledby="newTaskTitle"><h2 id="newTaskTitle">새 과제 추가</h2><p class="intro">작게 적어두면, 시작하기 쉬워져요.</p>
<noscript><p>이 예제 앱은 JavaScript가 필요합니다. 브라우저에서 JavaScript를 켠 뒤 다시 열어주세요.</p></noscript>
<form id="taskForm" action="#newTaskTitle" method="get"><fieldset id="inputFields" disabled style="border:0;padding:0;margin:0;min-width:0"><legend style="position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden">과제 정보</legend>
<div class="field"><label for="taskTitle">과제명 <span>필수 · 60자 이내</span></label><input id="taskTitle" name="title" type="text" required maxlength="60" pattern=".*\S.*" placeholder="예: 국어 독서 감상문 쓰기" autocomplete="off" aria-describedby="titleError"><p id="titleError" class="error">⚠ 과제명을 입력해 주세요. 공백만 입력할 수 없어요.</p></div>
<div class="field"><label for="taskSubject">과목 <span>선택 · 20자 이내</span></label><input id="taskSubject" name="subject" type="text" maxlength="20" placeholder="예: 국어" autocomplete="off" aria-describedby="subjectError"><p id="subjectError" class="error">⚠ 과목은 20자 이내로 입력해 주세요.</p></div>
<div class="field"><label for="taskDue">마감일 <span>필수</span></label><input id="taskDue" name="due" type="date" required min="0001-01-01" max="9999-12-31" aria-describedby="dueError"><p id="dueError" class="error">⚠ 올바른 마감일을 선택해 주세요.</p></div>
<button class="primary" id="submitTask" type="submit">+ 과제 추가</button>
<button id="cancelEdit" class="cancel" type="button" hidden>수정 취소</button>
</fieldset></form>
<p class="privacy">이 브라우저에만 저장돼요. 다른 기기와 자동으로 공유되지 않으며, 사이트 데이터를 지우면 목록도 지워져요.</p>
</section>
<section class="list-area" aria-labelledby="taskListTitle"><div class="list-head"><div><h2 id="taskListTitle">나의 과제</h2><p id="todayLabel"></p></div><button class="sample" type="button" id="sampleBtn">예제 3개 넣기</button></div>
<div class="search-field"><label for="taskSearch">제목·과목 검색</label><div class="search-row"><input id="taskSearch" type="search" placeholder="예: 감상문 또는 국어" autocomplete="off" aria-controls="tasks"><button id="clearSearch" type="button" disabled>지우기</button></div></div>
<fieldset class="filters"><legend>과제 표시 필터</legend><label><input type="radio" name="filter" value="all" id="filterAll" checked>전체</label><label><input type="radio" name="filter" value="open" id="filterOpen">미완료</label><label><input type="radio" name="filter" value="done" id="filterDone">완료</label></fieldset>
<ul class="tasks" id="tasks" aria-label="과제 목록"></ul>
<div class="empty" id="empty"><strong id="emptyTitle">아직 등록한 과제가 없어요.</strong><p id="emptyMessage">왼쪽에서 첫 과제를 추가하거나<br>예제 3개로 먼저 둘러보세요.</p></div>
<p class="list-foot" id="listSummary" role="status"></p>
<div class="notice"><span id="status" role="status" aria-live="polite"></span><button id="undoBtn" type="button" hidden>삭제 되돌리기</button></div>
</section>
</div>
</main>
<footer><p>DAY 02 · Codex → Git · GitHub → 개선된 앱</p><p>회원가입·서버·외부 API 없이 동작합니다.</p></footer>
</div>
<script>
'use strict';
// 1. 달력 날짜는 시간대가 없는 YYYY-MM-DD 값으로 다룹니다.
const $ = selector => document.querySelector(selector);
const STORAGE_KEY = 'assignment-check-demo:v1';
let items = [], persistent = true, removed = null, editingId = null;
const hasTemporal = typeof Temporal !== 'undefined' && !!Temporal.PlainDate;
function dateValue(date = new Date()) {
return [date.getFullYear(), String(date.getMonth()+1).padStart(2,'0'), String(date.getDate()).padStart(2,'0')].join('-');
}
function validDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000')) return false;
if (hasTemporal) { try { return Temporal.PlainDate.from(value).toString() === value; } catch { return false; } }
// 단일 파일을 오프라인에서도 쓰기 위한 로컬 구성요소 대체 검증입니다.
const [year,month,day] = value.split('-').map(Number), date = new Date(0);
date.setFullYear(year,month-1,day);
return date.getFullYear()===year && date.getMonth()===month-1 && date.getDate()===day;
}
function compareDates(a,b) {
return hasTemporal ? Temporal.PlainDate.compare(a,b) : a.localeCompare(b);
}
function shiftToday(days) {
if (hasTemporal) return Temporal.Now.plainDateISO().add({days}).toString();
const date = new Date(); date.setDate(date.getDate()+days); return dateValue(date);
}
function labelDate(value) { const [,month,day] = value.split('-'); return `${Number(month)}월 ${Number(day)}일`; }
// 2. 저장 실패를 성공으로 표시하지 않고, 임시 모드를 안내합니다.
function temporaryMode(message) {
persistent = false;
$('#storageBadge').textContent = '임시 모드';
$('#storageWarning').hidden = false;
$('#storageWarning').textContent = message;
}
function validItem(item) {
return item && typeof item.id==='string' && item.id.length<=100 && typeof item.title==='string' && item.title.trim().length>0 && item.title.length<=60 && typeof item.subject==='string' && item.subject.length<=20 && typeof item.due==='string' && validDate(item.due) && typeof item.done==='boolean' && Number.isFinite(item.createdAt);
}
function load() {
try {
const value = localStorage.getItem(STORAGE_KEY);
if (value === null) return;
const parsed = JSON.parse(value);
if (!Array.isArray(parsed) || !parsed.every(validItem) || new Set(parsed.map(item=>item.id)).size!==parsed.length) throw new Error('invalid data');
items = parsed;
} catch {
temporaryMode('저장된 목록을 읽을 수 없어 임시 모드로 시작했어요. 기존 저장 데이터는 덮어쓰지 않습니다. 이 화면의 새 목록은 새로고침하면 사라질 수 있어요.');
}
}
function save() {
if (!persistent) return;
try { localStorage.setItem(STORAGE_KEY,JSON.stringify(items)); }
catch { temporaryMode('브라우저 저장이 차단되었거나 저장 공간이 부족해요. 현재 목록은 화면에 유지되지만 새로고침하면 이번 변경을 잃을 수 있어요.'); }
}
function message(text) { $('#status').textContent=text; }
function make(tag,className,text) { const node=document.createElement(tag); if(className)node.className=className; if(text!==undefined)node.textContent=text; return node; }
function focusTask(id) { const target=[...$('#tasks').querySelectorAll('input')].find(input=>input.dataset.id===id); (target||$('#tasks input')||document.querySelector('[name=filter]:checked')).focus(); }
// 3. 사용자가 입력한 값은 HTML로 실행하지 않고 textContent로 표시합니다.
function render() {
const today = hasTemporal ? Temporal.Now.plainDateISO().toString() : dateValue();
const open=items.filter(item=>!item.done), done=items.length-open.length;
$('#openCount').textContent=open.length;
$('#todayCount').textContent=open.filter(item=>compareDates(item.due,today)===0).length;
$('#lateCount').textContent=open.filter(item=>compareDates(item.due,today)<0).length;
$('#todayLabel').textContent=`오늘 ${labelDate(today)} · 미완료 먼저, 마감일순`;
const filter=document.querySelector('[name=filter]:checked').value;
const query=$('#taskSearch').value.trim().toLocaleLowerCase();
$('#clearSearch').disabled=$('#taskSearch').value.length===0;
const shown=items.filter(item=>(filter==='all'||(filter==='done'?item.done:!item.done))&&(!query||item.title.toLocaleLowerCase().includes(query)||item.subject.toLocaleLowerCase().includes(query))).sort((a,b)=>Number(a.done)-Number(b.done)||compareDates(a.due,b.due)||a.createdAt-b.createdAt);
const list=$('#tasks'); list.replaceChildren();
for (const item of shown) {
const row=make('li','task'+(item.done?' done':''));
const label=make('label','task-check'), checkbox=make('input');
checkbox.type='checkbox'; checkbox.checked=item.done; checkbox.dataset.id=item.id;
checkbox.setAttribute('aria-label',`${item.title} 완료 표시`);
label.append(checkbox,make('span','task-title',item.title));
checkbox.addEventListener('change',()=>{item.done=checkbox.checked; save(); render(); focusTask(item.id); message(item.done?'과제를 완료했어요.':'다시 미완료로 표시했어요.');});
const actions=make('div','task-actions'), edit=make('button','edit','수정');
edit.type='button'; edit.dataset.editId=item.id; edit.setAttribute('aria-label',`${item.title} 수정`);
edit.addEventListener('click',()=>startEdit(item));
const remove=make('button','delete','삭제'); remove.type='button'; remove.setAttribute('aria-label',`${item.title} 삭제`);
remove.addEventListener('click',()=>{
removed={item,index:items.indexOf(item)}; items=items.filter(task=>task.id!==item.id);
if(editingId===item.id)resetEditor();
save(); render(); $('#undoBtn').hidden=false; message('과제를 삭제했어요. 되돌릴 수 있어요.');
($('#tasks input')||$('#taskTitle')).focus();
});
const meta=make('div','task-meta');
meta.append(make('span','subject',item.subject||'과목 없음'));
const state=item.done?'완료':(compareDates(item.due,today)<0?'기한 지남':compareDates(item.due,today)===0?'오늘 마감':'마감');
const due=make('span','due'+(!item.done&&compareDates(item.due,today)<0?' late':!item.done&&compareDates(item.due,today)===0?' today':''),`${labelDate(item.due)} · ${state}`);
meta.append(due); actions.append(edit,remove); row.append(label,actions,meta); list.append(row);
}
$('#empty').hidden=shown.length>0;
$('#emptyTitle').textContent=query?'검색 결과가 없어요.':items.length===0?'아직 등록한 과제가 없어요.':filter==='done'?'완료한 과제가 아직 없어요.':'남은 과제가 없어요!';
$('#emptyMessage').textContent=query?'검색어를 바꾸거나 완료 필터를 확인해 주세요.':items.length===0?'새 과제를 추가하거나 예제 3개로 먼저 둘러보세요.':filter==='done'?'끝낸 과제의 체크박스를 눌러보세요.':'전체 목록에서 과제를 확인하거나 새 과제를 추가해 보세요.';
$('#listSummary').textContent=`전체 ${items.length}개 · 완료 ${done}개 · 현재 ${shown.length}개 표시`;
$('#sampleBtn').disabled=['demo-1','demo-2','demo-3'].every(id=>items.some(item=>item.id===id));
}
// 4. 네이티브 폼 검증과 접근성 상태를 함께 사용합니다.
const form=$('#taskForm'), interacted=new WeakSet();
function syncField(input) { if (input.validity.valid)input.removeAttribute('aria-invalid'); else input.setAttribute('aria-invalid','true'); }
function resetEditor(){
editingId=null; form.reset();
form.querySelectorAll('input').forEach(input=>{input.setCustomValidity('');input.removeAttribute('aria-invalid');interacted.delete(input);});
$('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue();
$('#newTaskTitle').textContent='새 과제 추가';$('#submitTask').textContent='+ 과제 추가';$('#cancelEdit').hidden=true;
form.closest('.panel').classList.remove('editing');
}
function startEdit(item){
resetEditor(); editingId=item.id;
$('#taskTitle').value=item.title;$('#taskSubject').value=item.subject;$('#taskDue').value=item.due;
$('#newTaskTitle').textContent='과제 수정';$('#submitTask').textContent='변경 저장';$('#cancelEdit').hidden=false;
form.closest('.panel').classList.add('editing');$('#taskTitle').focus();message('제목·과목·마감일을 바꾼 뒤 변경 저장을 눌러주세요.');
}
$('#cancelEdit').addEventListener('click',()=>{
const id=editingId;resetEditor();
const trigger=[...document.querySelectorAll('[data-edit-id]')].find(button=>button.dataset.editId===id);
if(trigger)trigger.focus();else focusTask(id);
message('수정을 취소했어요. 원래 과제는 그대로예요.');
});
form.addEventListener('invalid',event=>{interacted.add(event.target);syncField(event.target);},true);
form.addEventListener('blur',event=>{if(event.target.matches('input')&&interacted.has(event.target))syncField(event.target);},true);
form.addEventListener('input',event=>{if(event.target.matches('input')){interacted.add(event.target);event.target.setCustomValidity(''); if(event.target.validity.valid)event.target.removeAttribute('aria-invalid');}});
form.addEventListener('submit',event=>{
event.preventDefault();
const title=$('#taskTitle'),subject=$('#taskSubject'),due=$('#taskDue');
title.setCustomValidity(title.value.trim().length>0&&title.value.trim().length<=60?'':'과제명을 1~60자로 입력해 주세요.');
subject.setCustomValidity(subject.value.trim().length<=20?'':'과목은 20자 이내로 입력해 주세요.');
due.setCustomValidity(validDate(due.value)?'':'올바른 마감일을 선택해 주세요.');
[title,subject,due].forEach(syncField);
if (!form.reportValidity()) return;
const wasEditing=editingId!==null;
if(wasEditing){
const item=items.find(task=>task.id===editingId);
if(!item){resetEditor();message('수정할 과제를 찾을 수 없어요. 다시 선택해 주세요.');return;}
// 동일 항목의 편집 가능한 값만 변경: id, done, createdAt은 보존합니다.
Object.assign(item,{title:title.value.trim(),subject:subject.value.trim(),due:due.value});
}else{
items.push({id:crypto.randomUUID?crypto.randomUUID():`task-${Date.now()}-${Math.random().toString(36).slice(2)}`,title:title.value.trim(),subject:subject.value.trim(),due:due.value,done:false,createdAt:Date.now()});
}
save(); $('#filterAll').checked=true; $('#taskSearch').value=''; resetEditor(); render();
title.focus(); message(persistent?(wasEditing?'과제 변경을 저장했어요.':'과제를 추가하고 이 브라우저에 저장했어요.'):'변경했어요. 현재 임시 모드입니다.');
});
$('#taskSearch').addEventListener('input',render);
$('#clearSearch').addEventListener('click',()=>{$('#taskSearch').value='';render();$('#taskSearch').focus();});
document.querySelectorAll('[name=filter]').forEach(input=>input.addEventListener('change',render));
$('#undoBtn').addEventListener('click',()=>{if(!removed)return;const restored=removed.item;items.splice(removed.index,0,restored);removed=null;save();$('#filterAll').checked=true;$('#taskSearch').value='';render();$('#undoBtn').hidden=true;focusTask(restored.id);message('삭제한 과제를 되돌렸어요.');});
$('#sampleBtn').addEventListener('click',()=>{
const samples=[['demo-1','국어 독서 감상문','국어',0],['demo-2','수학 연습문제 5개','수학',1],['demo-3','영어 단어 복습','영어',-1]];
samples.forEach(([id,title,subject,offset],index)=>{if(!items.some(item=>item.id===id))items.push({id,title,subject,due:shiftToday(offset),done:false,createdAt:Date.now()+index});});
save();$('#filterAll').checked=true;$('#taskSearch').value='';render();message('가상 과제 3개를 넣었어요. 오늘 마감과 기한 지남을 비교해 보세요.');
});
load(); $('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue(); render();
$('#inputFields').disabled=false;
window.addEventListener('focus',render);
</script>
</body>
</html>
assignment-check-day2/starter/index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="과제를 기록하고 마감일과 완료 상태를 확인하는 학생용 학습 예제. 이 브라우저에만 저장됩니다.">
<title>과제 체크 · 오늘 할 일을 가볍게</title>
<style>
:root{font-family:"Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;color:#183c32;background:#f5f7f0;--ink:#183c32;--muted:#64786e;--line:#d4ded2;--green:#204d3b;--mint:#d9f1bd;--warn:#9c4435;color-scheme:light}
*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button,a,input{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #4b865d}button{cursor:pointer;min-height:48px;border:1px solid var(--line);border-radius:12px;padding:10px 16px;color:var(--ink);background:white}button:hover{background:#edf3e8}a{color:var(--green);text-underline-offset:4px}.wrap{max-width:1140px;margin:auto;padding:28px 28px 45px}.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:24px;border-bottom:1px solid var(--line)}.brand{display:flex;gap:10px;align-items:center;font-weight:800;font-size:18px}.brand svg{width:34px;height:34px}.top a{font-size:13px;padding:14px 0}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.15em;color:var(--muted);margin:0 0 15px}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;padding:40px 0 30px}.hero h1{font-size:clamp(34px,4.4vw,54px);line-height:1.16;letter-spacing:-.055em;margin:0 0 13px}.hero p{margin:0;color:var(--muted);font-size:15px;line-height:1.8}.badge{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;border:1px solid #ccdbc3;padding:9px 13px;border-radius:50px;background:#edf4e5}.badge:before{content:"";width:7px;height:7px;background:#628640;border-radius:50%}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:26px}.stat{border:1px solid var(--line);border-radius:18px;padding:20px 23px;background:#fff}.stat:first-child{background:var(--green);color:#f2f9eb;border-color:var(--green)}.stat span{font-size:13px;display:block;margin-bottom:10px}.stat strong{font:600 35px ui-monospace,monospace;letter-spacing:-.05em}.stat small{font-size:12px;margin-left:7px;color:inherit}.stat.overdue strong{color:var(--warn)}.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:23px;align-items:start}.panel{background:white;border:1px solid var(--line);border-radius:20px;padding:25px}.panel h2,.list-head h2{font-size:20px;letter-spacing:-.035em;margin:0 0 7px}.panel .intro{font-size:13px;line-height:1.8;color:var(--muted);margin:0 0 24px}.field{margin-bottom:19px}.field label{font-size:13px;display:block;margin-bottom:8px;font-weight:650}.field label span{font-size:11px;font-weight:400;color:var(--muted);margin-left:5px}.field input{width:100%;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;font-size:1rem;color:var(--ink);background:#fcfdf9;min-width:0}.field input:user-invalid,.field input[aria-invalid=true]{border-color:var(--warn);background:#fff6f1}.error{display:none;font-size:12px;color:var(--warn);line-height:1.6;margin:7px 0 0}.field input:user-invalid+.error,.field input[aria-invalid=true]+.error{display:block}.primary{background:var(--green);border-color:var(--green);color:#f3ffe8;width:100%;font-weight:700}.primary:hover{background:#153e2d}.privacy{font-size:12px;color:var(--muted);line-height:1.8;margin:18px 0 0}.list-area{min-width:0}.list-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:0 0 18px}.list-head p{font-size:12px;color:var(--muted);margin:0}.sample{background:#e6efdc;border-color:#c8d9bd;font-size:12px;white-space:nowrap}.filters{display:flex;gap:7px;padding:0;margin:0 0 15px;border:0;min-width:0}.filters legend{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}.filters label{cursor:pointer;position:relative;display:flex;align-items:center;gap:7px;min-height:48px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;font-size:13px;background:#fff}.filters label:has(input:checked){background:#e1efd6;border-color:#88a373;font-weight:700}.filters input{accent-color:var(--green);margin:0;width:16px;height:16px}.tasks{padding:0;list-style:none;display:flex;flex-direction:column;gap:11px;margin:0}.task{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px 17px 14px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 14px;align-items:center}.task-check{display:flex;gap:13px;align-items:center;min-height:48px;cursor:pointer;min-width:0}.task-check input{width:22px;height:22px;flex:0 0 22px;accent-color:var(--green);margin:0}.task-title{font-size:16px;line-height:1.5;font-weight:650;overflow-wrap:anywhere}.task.done .task-title{text-decoration:line-through;color:#7d8a80;font-weight:400}.task-meta{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;padding-left:35px;font-size:11px;color:var(--muted);align-items:center}.subject{background:#eef2e9;padding:5px 8px;border-radius:6px}.due{padding:5px 8px;background:#f0f4ea;border-radius:6px}.due.late{color:#963d30;background:#fff0e8}.due.today{color:#5a6b1c;background:#f1f2d7}.delete{font-size:12px;border-color:transparent;color:#768078;padding-inline:10px}.empty{border:1px dashed #c3d2bd;border-radius:18px;padding:50px 25px;text-align:center;background:#f0f5e9}.empty strong{display:block;font-size:18px;margin-bottom:10px}.empty p{font-size:13px;line-height:1.8;color:var(--muted);margin:0}.list-foot{font-size:11px;color:var(--muted);line-height:1.8;margin:15px 0 0}.notice{display:flex;gap:15px;align-items:center;justify-content:space-between;min-height:50px;margin-top:18px;padding:10px 15px;border-radius:12px;background:#e1efd6;font-size:13px;line-height:1.6}.notice:has(span:empty){display:none}.notice button{min-height:40px;font-size:12px;white-space:nowrap}.storage-warning{font-size:13px;line-height:1.8;padding:15px 18px;margin:0 0 22px;background:#fff0df;border:1px solid #e4c39e;border-radius:12px}.storage-warning[hidden],button[hidden],.empty[hidden]{display:none}footer{display:flex;justify-content:space-between;gap:20px;margin-top:35px;padding-top:20px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.9}footer p{margin:0}.skip{position:absolute;top:-100px;left:20px}.skip:focus{top:10px;padding:10px;background:white;z-index:2}
@media(max-width:760px){.wrap{padding:20px 18px 32px}.top{padding-bottom:17px}.hero{padding:29px 0 23px;display:block}.hero .badge{margin-top:17px}.hero h1{font-size:36px}.stats{gap:9px;margin-bottom:20px}.stat{padding:16px 13px;border-radius:14px}.stat strong{font-size:29px}.stat span{font-size:11px}.workspace{grid-template-columns:1fr;gap:25px}.panel{padding:23px}.list-head{margin-bottom:14px}footer{display:block}footer p+p{margin-top:7px}}
@media(prefers-reduced-motion:no-preference){button{transition:background .15s ease}}
</style>
</head>
<body>
<a class="skip" href="#taskTitle">과제 추가로 이동</a>
<div class="wrap">
<header class="top"><div class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><rect width="36" height="36" rx="11" fill="#204d3b"/><path d="m10 18 5 5 11-11" fill="none" stroke="#d9f1bd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>과제 체크</div><a href="https://vibe-coding-course-three.vercel.app/assignment-lab.html">따라 만들기 교재 ↗</a></header>
<main>
<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">ONE THING AT A TIME</p><h1 id="pageTitle">오늘 할 일을,<br>하나씩 가볍게.</h1><p>과제를 적고, 마감일을 확인하고, 끝낸 일에 체크하세요.</p></div><span class="badge" id="storageBadge">이 브라우저에 저장</span></section>
<div id="storageWarning" class="storage-warning" role="alert" hidden></div>
<section class="stats" aria-label="과제 현황"><div class="stat"><span>남은 과제</span><strong id="openCount">0</strong><small>개</small></div><div class="stat"><span>오늘 마감</span><strong id="todayCount">0</strong><small>개</small></div><div class="stat overdue"><span>기한 지남</span><strong id="lateCount">0</strong><small>개</small></div></section>
<div class="workspace">
<section class="panel" aria-labelledby="newTaskTitle"><h2 id="newTaskTitle">새 과제 추가</h2><p class="intro">작게 적어두면, 시작하기 쉬워져요.</p>
<noscript><p>이 예제 앱은 JavaScript가 필요합니다. 브라우저에서 JavaScript를 켠 뒤 다시 열어주세요.</p></noscript>
<form id="taskForm" action="#newTaskTitle" method="get"><fieldset id="inputFields" disabled style="border:0;padding:0;margin:0;min-width:0"><legend style="position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden">과제 정보</legend>
<div class="field"><label for="taskTitle">과제명 <span>필수 · 60자 이내</span></label><input id="taskTitle" name="title" type="text" required maxlength="60" pattern=".*\S.*" placeholder="예: 국어 독서 감상문 쓰기" autocomplete="off" aria-describedby="titleError"><p id="titleError" class="error">⚠ 과제명을 입력해 주세요. 공백만 입력할 수 없어요.</p></div>
<div class="field"><label for="taskSubject">과목 <span>선택 · 20자 이내</span></label><input id="taskSubject" name="subject" type="text" maxlength="20" placeholder="예: 국어" autocomplete="off" aria-describedby="subjectError"><p id="subjectError" class="error">⚠ 과목은 20자 이내로 입력해 주세요.</p></div>
<div class="field"><label for="taskDue">마감일 <span>필수</span></label><input id="taskDue" name="due" type="date" required min="0001-01-01" max="9999-12-31" aria-describedby="dueError"><p id="dueError" class="error">⚠ 올바른 마감일을 선택해 주세요.</p></div>
<button class="primary" type="submit">+ 과제 추가</button>
</fieldset></form>
<p class="privacy">이 브라우저에만 저장돼요. 다른 기기와 자동으로 공유되지 않으며, 사이트 데이터를 지우면 목록도 지워져요.</p>
</section>
<section class="list-area" aria-labelledby="taskListTitle"><div class="list-head"><div><h2 id="taskListTitle">나의 과제</h2><p id="todayLabel"></p></div><button class="sample" type="button" id="sampleBtn">예제 3개 넣기</button></div>
<fieldset class="filters"><legend>과제 표시 필터</legend><label><input type="radio" name="filter" value="all" id="filterAll" checked>전체</label><label><input type="radio" name="filter" value="open" id="filterOpen">미완료</label><label><input type="radio" name="filter" value="done" id="filterDone">완료</label></fieldset>
<ul class="tasks" id="tasks" aria-label="과제 목록"></ul>
<div class="empty" id="empty"><strong id="emptyTitle">아직 등록한 과제가 없어요.</strong><p id="emptyMessage">왼쪽에서 첫 과제를 추가하거나<br>예제 3개로 먼저 둘러보세요.</p></div>
<p class="list-foot" id="listSummary" role="status"></p>
<div class="notice"><span id="status" role="status" aria-live="polite"></span><button id="undoBtn" type="button" hidden>삭제 되돌리기</button></div>
</section>
</div>
</main>
<footer><p>DAY 01 · 무료 AI → HTML → GitHub Pages 학습 예제</p><p>회원가입·서버·외부 API 없이 동작합니다.</p></footer>
</div>
<script>
'use strict';
// 1. 달력 날짜는 시간대가 없는 YYYY-MM-DD 값으로 다룹니다.
const $ = selector => document.querySelector(selector);
const STORAGE_KEY = 'assignment-check-demo:v1';
let items = [], persistent = true, removed = null;
const hasTemporal = typeof Temporal !== 'undefined' && !!Temporal.PlainDate;
function dateValue(date = new Date()) {
return [date.getFullYear(), String(date.getMonth()+1).padStart(2,'0'), String(date.getDate()).padStart(2,'0')].join('-');
}
function validDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000')) return false;
if (hasTemporal) { try { return Temporal.PlainDate.from(value).toString() === value; } catch { return false; } }
// 단일 파일을 오프라인에서도 쓰기 위한 로컬 구성요소 대체 검증입니다.
const [year,month,day] = value.split('-').map(Number), date = new Date(0);
date.setFullYear(year,month-1,day);
return date.getFullYear()===year && date.getMonth()===month-1 && date.getDate()===day;
}
function compareDates(a,b) {
return hasTemporal ? Temporal.PlainDate.compare(a,b) : a.localeCompare(b);
}
function shiftToday(days) {
if (hasTemporal) return Temporal.Now.plainDateISO().add({days}).toString();
const date = new Date(); date.setDate(date.getDate()+days); return dateValue(date);
}
function labelDate(value) { const [,month,day] = value.split('-'); return `${Number(month)}월 ${Number(day)}일`; }
// 2. 저장 실패를 성공으로 표시하지 않고, 임시 모드를 안내합니다.
function temporaryMode(message) {
persistent = false;
$('#storageBadge').textContent = '임시 모드';
$('#storageWarning').hidden = false;
$('#storageWarning').textContent = message;
}
function validItem(item) {
return item && typeof item.id==='string' && item.id.length<=100 && typeof item.title==='string' && item.title.trim().length>0 && item.title.length<=60 && typeof item.subject==='string' && item.subject.length<=20 && typeof item.due==='string' && validDate(item.due) && typeof item.done==='boolean' && Number.isFinite(item.createdAt);
}
function load() {
try {
const value = localStorage.getItem(STORAGE_KEY);
if (value === null) return;
const parsed = JSON.parse(value);
if (!Array.isArray(parsed) || !parsed.every(validItem) || new Set(parsed.map(item=>item.id)).size!==parsed.length) throw new Error('invalid data');
items = parsed;
} catch {
temporaryMode('저장된 목록을 읽을 수 없어 임시 모드로 시작했어요. 기존 저장 데이터는 덮어쓰지 않습니다. 이 화면의 새 목록은 새로고침하면 사라질 수 있어요.');
}
}
function save() {
if (!persistent) return;
try { localStorage.setItem(STORAGE_KEY,JSON.stringify(items)); }
catch { temporaryMode('브라우저 저장이 차단되었거나 저장 공간이 부족해요. 현재 목록은 화면에 유지되지만 새로고침하면 이번 변경을 잃을 수 있어요.'); }
}
function message(text) { $('#status').textContent=text; }
function make(tag,className,text) { const node=document.createElement(tag); if(className)node.className=className; if(text!==undefined)node.textContent=text; return node; }
function focusTask(id) { const target=[...$('#tasks').querySelectorAll('input')].find(input=>input.dataset.id===id); (target||$('#tasks input')||document.querySelector('[name=filter]:checked')).focus(); }
// 3. 사용자가 입력한 값은 HTML로 실행하지 않고 textContent로 표시합니다.
function render() {
const today = hasTemporal ? Temporal.Now.plainDateISO().toString() : dateValue();
const open=items.filter(item=>!item.done), done=items.length-open.length;
$('#openCount').textContent=open.length;
$('#todayCount').textContent=open.filter(item=>compareDates(item.due,today)===0).length;
$('#lateCount').textContent=open.filter(item=>compareDates(item.due,today)<0).length;
$('#todayLabel').textContent=`오늘 ${labelDate(today)} · 미완료 먼저, 마감일순`;
const filter=document.querySelector('[name=filter]:checked').value;
const shown=items.filter(item=>filter==='all'||(filter==='done'?item.done:!item.done)).sort((a,b)=>Number(a.done)-Number(b.done)||compareDates(a.due,b.due)||a.createdAt-b.createdAt);
const list=$('#tasks'); list.replaceChildren();
for (const item of shown) {
const row=make('li','task'+(item.done?' done':''));
const label=make('label','task-check'), checkbox=make('input');
checkbox.type='checkbox'; checkbox.checked=item.done; checkbox.dataset.id=item.id;
checkbox.setAttribute('aria-label',`${item.title} 완료 표시`);
label.append(checkbox,make('span','task-title',item.title));
checkbox.addEventListener('change',()=>{item.done=checkbox.checked; save(); render(); focusTask(item.id); message(item.done?'과제를 완료했어요.':'다시 미완료로 표시했어요.');});
const remove=make('button','delete','삭제'); remove.type='button'; remove.setAttribute('aria-label',`${item.title} 삭제`);
remove.addEventListener('click',()=>{
removed={item,index:items.indexOf(item)}; items=items.filter(task=>task.id!==item.id);
save(); render(); $('#undoBtn').hidden=false; message('과제를 삭제했어요. 되돌릴 수 있어요.');
($('#tasks input')||$('#taskTitle')).focus();
});
const meta=make('div','task-meta');
meta.append(make('span','subject',item.subject||'과목 없음'));
const state=item.done?'완료':(compareDates(item.due,today)<0?'기한 지남':compareDates(item.due,today)===0?'오늘 마감':'마감');
const due=make('span','due'+(!item.done&&compareDates(item.due,today)<0?' late':!item.done&&compareDates(item.due,today)===0?' today':''),`${labelDate(item.due)} · ${state}`);
meta.append(due); row.append(label,remove,meta); list.append(row);
}
$('#empty').hidden=shown.length>0;
$('#emptyTitle').textContent=items.length===0?'아직 등록한 과제가 없어요.':filter==='done'?'완료한 과제가 아직 없어요.':'남은 과제가 없어요!';
$('#emptyMessage').textContent=items.length===0?'새 과제를 추가하거나 예제 3개로 먼저 둘러보세요.':filter==='done'?'끝낸 과제의 체크박스를 눌러보세요.':'전체 목록에서 과제를 확인하거나 새 과제를 추가해 보세요.';
$('#listSummary').textContent=`전체 ${items.length}개 · 완료 ${done}개 · 현재 ${shown.length}개 표시`;
$('#sampleBtn').disabled=['demo-1','demo-2','demo-3'].every(id=>items.some(item=>item.id===id));
}
// 4. 네이티브 폼 검증과 접근성 상태를 함께 사용합니다.
const form=$('#taskForm'), interacted=new WeakSet();
function syncField(input) { if (input.validity.valid)input.removeAttribute('aria-invalid'); else input.setAttribute('aria-invalid','true'); }
form.addEventListener('invalid',event=>{interacted.add(event.target);syncField(event.target);},true);
form.addEventListener('blur',event=>{if(event.target.matches('input')&&interacted.has(event.target))syncField(event.target);},true);
form.addEventListener('input',event=>{if(event.target.matches('input')){interacted.add(event.target);event.target.setCustomValidity(''); if(event.target.validity.valid)event.target.removeAttribute('aria-invalid');}});
form.addEventListener('submit',event=>{
event.preventDefault();
const title=$('#taskTitle'),subject=$('#taskSubject'),due=$('#taskDue');
title.setCustomValidity(title.value.trim().length>0&&title.value.trim().length<=60?'':'과제명을 1~60자로 입력해 주세요.');
subject.setCustomValidity(subject.value.trim().length<=20?'':'과목은 20자 이내로 입력해 주세요.');
due.setCustomValidity(validDate(due.value)?'':'올바른 마감일을 선택해 주세요.');
[title,subject,due].forEach(syncField);
if (!form.reportValidity()) return;
items.push({id:crypto.randomUUID?crypto.randomUUID():`task-${Date.now()}-${Math.random().toString(36).slice(2)}`,title:title.value.trim(),subject:subject.value.trim(),due:due.value,done:false,createdAt:Date.now()});
save(); $('#filterAll').checked=true; render();
form.reset(); [title,subject,due].forEach(input=>{input.removeAttribute('aria-invalid');interacted.delete(input);}); due.value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue();
title.focus(); message(persistent?'과제를 추가하고 이 브라우저에 저장했어요.':'과제를 추가했어요. 현재 임시 모드입니다.');
});
document.querySelectorAll('[name=filter]').forEach(input=>input.addEventListener('change',render));
$('#undoBtn').addEventListener('click',()=>{if(!removed)return;const restored=removed.item;items.splice(removed.index,0,restored);removed=null;save();$('#filterAll').checked=true;render();$('#undoBtn').hidden=true;focusTask(restored.id);message('삭제한 과제를 되돌렸어요.');});
$('#sampleBtn').addEventListener('click',()=>{
const samples=[['demo-1','국어 독서 감상문','국어',0],['demo-2','수학 연습문제 5개','수학',1],['demo-3','영어 단어 복습','영어',-1]];
samples.forEach(([id,title,subject,offset],index)=>{if(!items.some(item=>item.id===id))items.push({id,title,subject,due:shiftToday(offset),done:false,createdAt:Date.now()+index});});
save();$('#filterAll').checked=true;render();message('가상 과제 3개를 넣었어요. 오늘 마감과 기한 지남을 비교해 보세요.');
});
load(); $('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue(); render();
$('#inputFields').disabled=false;
window.addEventListener('focus',render);
</script>
</body>
</html>