diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..600d2d3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.vscode
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..7488843
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,12 @@
+{
+ "printWidth": 80,
+ "tabWidth": 2,
+ "useTabs": false,
+ "semi": true,
+ "singleQuote": true,
+ "trailingComma": "es5",
+ "bracketSpacing": true,
+ "arrowParens": "always",
+ "htmlWhitespaceSensitivity": "css",
+ "cssWhitespaceSensitivity": "css"
+}
diff --git a/README.md b/README.md
index c258cda..3ab89f7 100644
--- a/README.md
+++ b/README.md
@@ -1,60 +1,54 @@
# 1주차 미션: Vanilla-Todo
-# 서론
+# 결과물
-안녕하세요 🙌🏻 20기 프론트엔드 운영진 **이지인**입니다.
+배포 링크 :
+https://vanilla-todo-20th-lovat.vercel.app/
-이번 미션은 개발 환경 구축과 스터디 진행 방식에 익숙해지실 수 있도록 간단한 **to-do list** 만들기를 진행합니다. 무작정 첫 스터디부터 React를 다루는 것보다는 왜 React가 필요한지, React가 없으면 무엇이 불편한지 느껴 보고 본격적인 스터디에 들어가는 것이 React를 이해하는 데 더 많은 도움이 될 것이라 생각합니다.
+## 기능 구현
-비교적 가벼운 미션인 만큼 코드를 짜는 데 있어 여러분의 **창의성**을 충분히 발휘해 보시기 바랍니다. 작동하기만 하면 되는 것보다 같은 코드를 짜는 여러가지 방식과 패턴에 대해 고민해 보시고, 본인이 생각한 가장 창의적인 방법으로 코드를 작성해 주세요. 여러분이 미션을 수행하는 과정에서 겪는 고민과 생각의 깊이만큼 스터디에서 더 많은 것을 얻어가실 수 있을 것입니다.
+- Open, In Progress, Done column별로 목표를 확인할 수 있다.
+- 각 Column에서 input field를 통해 새로운 목표를 추가할 수 있다.
+- 목표는 X 버튼을 통해서 삭제할 수 있다.
+- 목표 내에서 input field를 통해 새로운 할 일을 추가할 수 있다.
+- 할 일은 삭제 버튼을 통해서 삭제할 수 있다.
+- 할 일 요소의 체크박스틀 통해 할 일을 완료/해제할 수 있다.
-막히는 부분이 있더라도 우선은 스스로 공부하고 찾아보는 방법을 권고드리지만, 운영진의 도움이 필요하시다면 얼마든지 프론트엔드 카톡방에 편하게 질문을 남겨 주세요!
+# Key Questions
-# 미션
+## DOM은 무엇인가요?
-## 미션 목표
+DOM(Document Object Model)은 HTML 또는 XML 문서의 구조를 표현하는 인터페이스로, 프로그래밍 언어가 DOM 구조에 접근할 수 있는 방법을 제공합니다.
-- VSCode, Prettier를 이용하여 개발 환경을 관리합니다.
-- HTML/CSS의 기초를 이해합니다.
-- JavaScript를 이용한 DOM 조작을 이해합니다.
-- Vanilla Js를 이용한 어플리케이션 상태 관리 방법을 이해합니다.
+예를 들어서, JS라는 프로그래밍 언어로 HTML 문서의 구조, 스타일, 내용을 변경할 수 있습니다.
-## 기한
+
-- 2024년 9월 7일 토요일
+HTML 문서가 있다면, 브라우저는 HTML 문서를 읽어들이고 HTML의 각 요소(element)를 Node라는 객체로 표현합니다. `
`~`
` 태그는 `HTMLHeadingElement` 객체로, `
`는 `HTMLParagraphElement` 객체로 표현하며, 이런 `HTMLElement` 인터페이스를 통해서 HTML의 element를 수정할 수 있습니다.
-## Key Questions
+## 이벤트 흐름 제어(버블링 & 캡처링)이 무엇인가요?
-- DOM은 무엇인가요?
-- 이벤트 흐름 제어(버블링 & 캡처링)이 무엇인가요?
-- 클로저와 스코프가 무엇인가요?
+- **이벤트 버블링**이란, HTML 문서에서 이벤트가 발생했을 때, 하위 element에서부터 상위 element로 이벤트가 전달되는 방식입니다.
+- **이벤트 캡처링**이란, HTML 문서에서 이벤트가 발생했을 때, 하위 element까지 이벤트가 전달되는 방식입니다.
+- 이벤트는 캡처링 단계, 타깃 단계, 버블링 단계를 거칩니다.
+ - **캡처링 단계**에서는 이벤트가 최상위 조상(window또는 document)에서부터 하위 element로 전달됩니다. 기본적으로 캡처링 단계에서는 이벤트 핸들러가 실행되지 않지만, 이벤트 핸들러에 옵션을 주어 이 단계에서 이벤트를 핸들링할 수 있습니다.
+ - **타깃 단계**에서는 이벤트가 실제 타깃 요소에 전달됩니다. 이벤트가 타깃 element에 도착하면 타깃 element에 부착된 이벤트 핸들러가 동작합니다.
+ - **버블링 단계**에서는 이벤트가 상위 element로 전달됩니다. `focus`, `blur` 등의 일부 이벤트는 버블링 단계를 거치지 않습니다.
-## 필수 요건
+## 클로저와 스코프가 무엇인가요?
-- [결과 화면](https://vanilla-todo-19th-dh.vercel.app/)의 기능을 구현합니다. (날짜, 요일별 todo 개수)
-- 결과 링크의 화면 디자인 그대로 구현해도 좋고, 자신만의 디자인을 적용해도 좋습니다.
-- CSS의 Flexbox를 이용하여 레이아웃을 구성합니다.
-- JQuery, React, Bootstrap 등 외부 라이브러리를 사용하지 않습니다.
-- 함수와 변수의 이름은 lowerCamelCase로 짓습니다.
-- 코딩의 단위를 기능별로 나누어 Commit 메세지를 작성합니다.
-- Semantic tag를 활용하여 HTML 구조를 완성합니다.
+- 스코프는 값과 표현식이 참조될 수 있는 컨텍스트를 의미합니다.
+ - 하위 스코프에서는 상위 스코프에 접근할 수 있지만, 상위 스코프에서는 하위 스코프에 접근할 수 없습니다.
+ - JS에는 3가지 종류의 스코프가 있습니다.
+ - Global scope(전역 스코프)
+ - Module scope(모듈 스코프)
+ - Function scope(함수 스코프)
+ - Block scope(블록 스코프)
+- 클로저는 함수와 그 함수가 선언될 때의 lexical environment의 조합입니다. 함수가 생성될 때마다 클로저도 생성되며, 함수는 클로저를 통해 자신이 선언된 시점의 lexical environment(변수나 함수들)을 참조할 수 있습니다.
-## 선택 요건
+## 참고
-- 외부 폰트 Pretendard를 적용합니다.
-- 브라우저의 `localStorage` 혹은 `sessionStorage`를 이용하여 다음 번 접속 시에 기존의 투두 데이터를 불러옵니다.
-- 이 외에도 추가하고 싶은 기능이 있다면 마음껏 추가하셔도 됩니다.
-- 미디어쿼리를 이용해서 반응형을 적용합니다.
-
-# 링크 및 참고자료
-
-- [HTML/CSS 기초](https://heropy.blog/2019/04/24/html-css-starter/)
-- [HTML 태그](https://heropy.blog/2019/05/26/html-elements/)
-- [FlexBox 가이드](https://heropy.blog/2018/11/24/css-flexible-box/)
-- [JS를 통한 DOM 조작](https://velog.io/@bining/javascript-DOM-%EC%A1%B0%EC%9E%91%ED%95%98%EA%B8%B0#append)
-- [localStorage, sessionStorage](https://www.daleseo.com/js-web-storage/)
-- [git 사용법](https://wayhome25.github.io/git/2017/07/08/git-first-pull-request-story/)
-- [좋은 코드리뷰 방법](https://tech.kakao.com/2022/03/17/2022-newkrew-onboarding-codereview/)
-- [MDN 공식문서-createElement()](https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement)
-- [MDN 공식문서-appendChild()](https://developer.mozilla.org/ko/docs/Web/API/Node/appendChild)
-- [DOM 개념,HTML 요소 조작](https://poiemaweb.com/js-dom#3-dom-query--traversing-%EC%9A%94%EC%86%8C%EC%97%90%EC%9D%98-%EC%A0%91%EA%B7%BC)
+- https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction
+- https://ko.javascript.info/bubbling-and-capturing
+- https://developer.mozilla.org/en-US/docs/Glossary/Scope
+- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
diff --git a/assets/addIcon.svg b/assets/addIcon.svg
new file mode 100644
index 0000000..9ced21e
--- /dev/null
+++ b/assets/addIcon.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/assets/deleteIcon.svg b/assets/deleteIcon.svg
new file mode 100644
index 0000000..e287d7f
--- /dev/null
+++ b/assets/deleteIcon.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/css/global.css b/css/global.css
new file mode 100644
index 0000000..ee0a9e3
--- /dev/null
+++ b/css/global.css
@@ -0,0 +1,30 @@
+* {
+ color: var(--text-color);
+}
+
+body {
+ background-color: var(--background);
+}
+
+h1 {
+ font-size: 4rem;
+
+ color: transparent;
+ -webkit-text-stroke: 1px var(--text-color);
+}
+
+form {
+ display: flex;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+input[type='text'] {
+ flex: 1;
+
+ background: transparent;
+
+ border: none;
+ border-bottom: 1px solid var(--border);
+ outline: none;
+}
diff --git a/css/kanban.css b/css/kanban.css
new file mode 100644
index 0000000..6ad8209
--- /dev/null
+++ b/css/kanban.css
@@ -0,0 +1,73 @@
+.wrapper {
+ display: flex;
+ justify-content: center;
+}
+
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+
+ padding: 2rem;
+ width: 100%;
+ max-width: 1200px;
+}
+
+.kanban-board {
+ display: flex;
+ gap: 1rem;
+
+ width: 100%;
+}
+
+.kanban-board section {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+
+ flex: 1;
+
+ height: fit-content;
+ padding: 1rem;
+
+ border: 3px solid;
+ border-radius: 1rem;
+}
+
+@media (max-width: 1000px) {
+ .container {
+ max-width: 600px;
+ }
+
+ .kanban-board {
+ flex-direction: column;
+ }
+}
+
+#open-column {
+ background-color: var(--open-column-background);
+ border-color: var(--open-column-border);
+}
+
+#in-progress-column {
+ background-color: var(--in-progress-column-background);
+ border-color: var(--in-progress-column-border);
+}
+
+#done-column {
+ background-color: var(--done-column-background);
+ border-color: var(--done-column-border);
+}
+
+.open-hr {
+ border: 1px solid var(--open-column-border);
+}
+
+.in-progress-hr {
+ border: 1px solid var(--in-progress-column-border);
+}
+
+.done-hr {
+ border: 1px solid var(--done-column-border);
+}
diff --git a/css/reset.css b/css/reset.css
new file mode 100644
index 0000000..d2ccfd0
--- /dev/null
+++ b/css/reset.css
@@ -0,0 +1,14 @@
+/* 기본 margin, padding 제거 */
+*,
+*::before,
+*::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+/* 리스트 스타일 제거 */
+ul,
+ol {
+ list-style: none;
+}
diff --git a/css/subject.css b/css/subject.css
new file mode 100644
index 0000000..b188934
--- /dev/null
+++ b/css/subject.css
@@ -0,0 +1,33 @@
+.subject-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.subject {
+ padding: 1rem;
+
+ background: var(--subject-background);
+
+ border: 2px solid var(--border);
+ border-radius: 1rem;
+}
+
+.subject header {
+ display: flex;
+ justify-content: space-between;
+}
+
+.add-subject-card button,
+.delete-subject-button {
+ background: transparent;
+ border: none;
+ outline: none;
+ cursor: pointer;
+}
+
+.add-subject-card button img,
+.delete-subject-button img {
+ width: 2rem;
+ height: 2rem;
+}
diff --git a/css/task.css b/css/task.css
new file mode 100644
index 0000000..59f92dd
--- /dev/null
+++ b/css/task.css
@@ -0,0 +1,34 @@
+.task-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.task-list button {
+ padding: 0.25rem 0.5rem;
+ background: transparent;
+
+ border: 1px solid var(--button-border);
+ border-radius: 1rem;
+ outline: none;
+
+ color: var(--text-color);
+ white-space: nowrap;
+
+ cursor: pointer;
+}
+
+.task {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.task p {
+ flex: 1;
+}
+
+.isCompleted > p {
+ color: var(--cancel-text-color);
+ text-decoration: line-through;
+}
diff --git a/css/variables.css b/css/variables.css
new file mode 100644
index 0000000..0757ad3
--- /dev/null
+++ b/css/variables.css
@@ -0,0 +1,18 @@
+:root {
+ --background: #121212;
+
+ --open-column-background: #31627e1a;
+ --in-progress-column-background: #7267451a;
+ --done-column-background: #2a5f2d1a;
+ --open-column-border: #0d5580;
+ --in-progress-column-border: #785e17;
+ --done-column-border: #0b5c18;
+
+ --subject-background: #121212ee;
+
+ --text-color: #ffffff;
+ --cancel-text-color: #505050;
+
+ --border: #505050;
+ --button-border: #ffffff;
+}
diff --git a/index.html b/index.html
index d241b1b..af62680 100644
--- a/index.html
+++ b/index.html
@@ -1,14 +1,51 @@
-
+
Vanilla Todo
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
Open
+
+
+
+
+
+
In Progress
+
+
+
+
+
+
Done
+
+
+
+
+
+
-
+
+
+
+
+
+
+
diff --git a/js/constant.js b/js/constant.js
new file mode 100644
index 0000000..f0c8b2d
--- /dev/null
+++ b/js/constant.js
@@ -0,0 +1,13 @@
+// kanban board columns name
+const OPEN = 'open';
+const IN_PROGRESS = 'in-progress';
+const DONE = 'done';
+const STATE_LIST = [OPEN, IN_PROGRESS, DONE];
+
+// default name for constructor
+const NEW_SUBJECT_NAME = 'New Subject';
+const NEW_TASK_NAME = 'New Task';
+
+// placeholder for input field
+const NEW_SUBJECT_PLACEHOLDER = '새로운 목표를 입력해주세요';
+const NEW_TASK_PLACEHOLDER = '할 일을 입력해주세요';
diff --git a/js/index.js b/js/index.js
new file mode 100644
index 0000000..1203229
--- /dev/null
+++ b/js/index.js
@@ -0,0 +1,15 @@
+document.addEventListener('DOMContentLoaded', () => {
+ // Set title date.
+ const titleElement = document.getElementById('title');
+ titleElement.innerText = new Intl.DateTimeFormat('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).format(new Date());
+
+ // Init kanban board
+ const taskViewModel = new TaskViewModel();
+ const subjectViewModel = new SubjectViewModel(taskViewModel);
+
+ subjectViewModel.render();
+});
diff --git a/js/models/subject.js b/js/models/subject.js
new file mode 100644
index 0000000..78ad436
--- /dev/null
+++ b/js/models/subject.js
@@ -0,0 +1,27 @@
+class Subject {
+ #title;
+ #state;
+ #id;
+
+ constructor({ title = NEW_SUBJECT_NAME, state = OPEN }) {
+ this.#title = title;
+ this.#state = state;
+ this.#id = getRandomId();
+ }
+
+ setState(state) {
+ this.#state = state;
+ }
+
+ getTitle() {
+ return this.#title;
+ }
+
+ getState() {
+ return this.#state;
+ }
+
+ getId() {
+ return this.#id;
+ }
+}
diff --git a/js/models/task.js b/js/models/task.js
new file mode 100644
index 0000000..e54b79d
--- /dev/null
+++ b/js/models/task.js
@@ -0,0 +1,33 @@
+class Task {
+ #title;
+ #id;
+ #isCompleted;
+ #subjectId;
+
+ constructor({ title = NEW_TASK_NAME, isCompleted = false, subjectId }) {
+ this.#title = title;
+ this.#id = getRandomId();
+ this.#isCompleted = isCompleted;
+ this.#subjectId = subjectId;
+ }
+
+ setIsCompleted(isCompleted) {
+ this.#isCompleted = isCompleted;
+ }
+
+ getTitle() {
+ return this.#title;
+ }
+
+ getId() {
+ return this.#id;
+ }
+
+ getIsCompleted() {
+ return this.#isCompleted;
+ }
+
+ getSubjectId() {
+ return this.#subjectId;
+ }
+}
diff --git a/js/utils/index.js b/js/utils/index.js
new file mode 100644
index 0000000..e7ea300
--- /dev/null
+++ b/js/utils/index.js
@@ -0,0 +1,41 @@
+/**
+ * Creates a new HTML element with specified attributes.
+ *
+ * @param {string} tag - The type of element.
+ * @param {Object} [attributes={}] - An object of the attributes to set on the element.
+ *
+ * @returns {HTMLElement} - The created element with the specified attributes.
+ */
+function createElement(tag, attributes = {}) {
+ const element = document.createElement(tag);
+
+ // Iterate provided attributes
+ Object.entries(attributes).forEach(([key, value]) => {
+ if (key in element) {
+ // Case #1
+ // If the attribute key is a property of the element, set it.
+ // i.e., properties like `value`, `checked`, `textContent`
+ element[key] = value;
+ } else {
+ // Case #2
+ // If it is not, set it as an HTML attribute.
+ // i.e., attributes like `type`, `id`, `class`
+ element.setAttribute(key, value);
+ }
+ });
+
+ return element;
+}
+
+function getRandomId() {
+ return Math.random() // Generate random number between 0 to 1.
+ .toString(36) // Convert to base-36 string.
+ .substring(2); // Remove leading '0.'.
+}
+
+function dispatchTaskChangeEvent(element) {
+ const taskChangeEvent = new CustomEvent('taskChange', {
+ bubbles: true,
+ });
+ element.dispatchEvent(taskChangeEvent);
+}
diff --git a/js/viewModels/subjectViewModel.js b/js/viewModels/subjectViewModel.js
new file mode 100644
index 0000000..2090f20
--- /dev/null
+++ b/js/viewModels/subjectViewModel.js
@@ -0,0 +1,198 @@
+class SubjectViewModel {
+ #subjectList;
+ #taskViewModel;
+
+ constructor(taskViewModel) {
+ this.#subjectList = new Map();
+ STATE_LIST.forEach((state) => this.#subjectList.set(state, [])); // Init all columns
+ this.#taskViewModel = taskViewModel;
+ }
+
+ /**
+ * Adds a new subject to the specified state column and updates the view.
+ *
+ * @param {string} params.title - The title of the new subject.
+ * @param {string} [params.state=OPEN] - The state column where the subject will be added. Default value is `OPEN`.
+ *
+ * @returns {Subject} The newly created subject.
+ */
+ addSubject({ title, state = OPEN }) {
+ const newSubject = new Subject({ title, state });
+
+ this.#subjectList
+ .get(state) // Get the column where the subject will be inserted
+ .push(newSubject);
+
+ this.render();
+ return newSubject;
+ }
+
+ /**
+ * Removes a subject from the specified state column and updates the view.
+ *
+ * @param {string} params.targetId - The ID of the subject to be removed.
+ * @param {string} params.state - The state column from which the subject will be removed.
+ */
+ deleteSubject({ targetId, state }) {
+ // Get the column from which the subject will be removed
+ const subjectList = this.#subjectList.get(state);
+
+ // Get the index of the target subject by its ID
+ const targetIndex = subjectList.findIndex(
+ (subject) => subject.getId() === targetId
+ );
+ // Remove the target subject from the column
+ subjectList.splice(targetIndex, 1);
+
+ this.render();
+ }
+
+ /**
+ * Retrieves the list of subjects for a given state.
+ *
+ * @param {string} state - The state for which to retrieve subjects.
+ * @returns {Subject[]} - An array of subjects associated with the specified state.
+ */
+ #getSubjectsByState(state) {
+ return this.#subjectList.get(state) || [];
+ }
+
+ /**
+ * Creates and returns a form element for adding a new subject.
+ *
+ * @param {string} state - The state in which the new subject will be added.
+ * @returns {HTMLElement} - The created form element.
+ */
+ #createFormElement(state) {
+ const containerElement = createElement('li', {
+ class: 'subject add-subject-card',
+ });
+ const formElement = createElement('form');
+ const inputElement = createElement('input', {
+ type: 'text',
+ placeholder: NEW_SUBJECT_PLACEHOLDER,
+ name: 'subject-title',
+ });
+ const buttonElement = createElement('button', {
+ id: `add-subject-button-${state}`,
+ });
+ const iconElement = createElement('img', {
+ src: 'assets/addIcon.svg',
+ });
+
+ containerElement.appendChild(formElement);
+ buttonElement.appendChild(iconElement);
+ formElement.append(inputElement, buttonElement);
+
+ formElement.addEventListener('submit', (event) =>
+ this.#onSubmitAddSubject({
+ event,
+ state,
+ formElement,
+ inputElement,
+ })
+ );
+
+ return containerElement;
+ }
+
+ #onSubmitAddSubject({ event, state, formElement, inputElement }) {
+ event.preventDefault();
+ this.addSubject({ title: inputElement.value, state });
+ dispatchTaskChangeEvent(formElement);
+ }
+
+ /**
+ * Creates a card element for a subject.
+ *
+ * @param {Subject} subject - The subject object used to create the DOM element.
+ * @returns {HTMLLIElement} The created element of a subject.
+ */
+ #createSubjectElement(subject) {
+ const subjectId = subject.getId();
+ const subjectElement = createElement('li', {
+ class: 'subject',
+ innerHTML: `
+
+
${subject.getTitle()}
+
+
+
+
+
+ `,
+ });
+
+ return subjectElement;
+ }
+
+ #addSubjectEventHandler(subjectElement, subjectId, state) {
+ // When a taskChange event occurs, update the subject's task list.
+ subjectElement.addEventListener('taskChange', () =>
+ this.#onChangeTask(subjectId, state)
+ );
+
+ // When the delete button is clicked, remove the subject from the list.
+ document
+ .getElementById(`${subjectId}-delete-button`)
+ .addEventListener('click', () =>
+ this.deleteSubject({ targetId: subjectId, state })
+ );
+ }
+
+ #onChangeTask(subjectId, state) {
+ /// Get the next state of the subject.
+ const nextState = this.#taskViewModel.getSubjectState(subjectId);
+ // Get the subject list in the current column.
+ const currentColumnSubjectList = this.#subjectList.get(state);
+ // Find the subject to update based on its ID
+ const subjectToUpdate = currentColumnSubjectList.find(
+ (currentSubject) => currentSubject.getId() === subjectId
+ );
+ // Update the subject's state
+ subjectToUpdate.setState(nextState);
+
+ if (state === nextState) {
+ // Case #1
+ // If the state is not changed, only update the task list for the subject
+ this.#taskViewModel.render(subjectId);
+ return;
+ }
+
+ currentColumnSubjectList.splice(
+ currentColumnSubjectList.indexOf(subjectToUpdate),
+ 1
+ );
+ this.#subjectList.get(nextState).push(subjectToUpdate);
+
+ // Case #2
+ // Re-render the entire column
+ this.render();
+ }
+
+ render() {
+ STATE_LIST.forEach((state) => {
+ const subjectListElement = document.getElementById(
+ `${state}-subject-list`
+ );
+
+ // Init column
+ subjectListElement.innerHTML = '';
+
+ // Render form to add subject
+ if (state === OPEN) {
+ subjectListElement.appendChild(this.#createFormElement(state));
+ }
+
+ // Render subject
+ this.#getSubjectsByState(state).forEach((subject) => {
+ const subjectElement = this.#createSubjectElement(subject);
+ subjectListElement.appendChild(subjectElement);
+ this.#addSubjectEventHandler(subjectElement, subject.getId(), state);
+ dispatchTaskChangeEvent(subjectElement);
+ });
+ });
+ }
+}
diff --git a/js/viewModels/taskViewModel.js b/js/viewModels/taskViewModel.js
new file mode 100644
index 0000000..db7bd2e
--- /dev/null
+++ b/js/viewModels/taskViewModel.js
@@ -0,0 +1,148 @@
+class TaskViewModel {
+ #taskList;
+
+ constructor() {
+ this.#taskList = new Map();
+ }
+
+ addTask({ title, subjectId, isCompleted = false }) {
+ if (!this.#taskList.has(subjectId)) {
+ this.#taskList.set(subjectId, []);
+ }
+ this.#taskList
+ .get(subjectId)
+ .push(new Task({ title, isCompleted, subjectId }));
+ }
+
+ deleteTask(targetId, subjectId) {
+ // Get the task list from which the task will be removed
+ const taskList = this.#taskList.get(subjectId);
+
+ // Get the index of the target task by its ID
+ const targetIndex = taskList.findIndex((task) => task.getId() === targetId);
+
+ // Remove the target task from the list
+ taskList.splice(targetIndex, 1);
+ }
+
+ #getTasksBySubject(subjectId) {
+ return this.#taskList.get(subjectId) || [];
+ }
+
+ #createFormElement(subjectId) {
+ const formElement = createElement('form', {
+ id: `${subjectId}-add-task-form`,
+ });
+ const inputElement = createElement('input', {
+ type: 'text',
+ placeholder: NEW_TASK_PLACEHOLDER,
+ name: 'task-title',
+ });
+ const buttonElement = createElement('button', {
+ innerText: '추가',
+ class: 'add-task-button',
+ });
+
+ formElement.append(inputElement, buttonElement);
+
+ formElement.addEventListener('submit', (event) => {
+ event.preventDefault();
+ if (!inputElement.value) {
+ return;
+ }
+
+ this.addTask({ title: inputElement.value, subjectId });
+
+ dispatchTaskChangeEvent(formElement);
+ });
+
+ return formElement;
+ }
+
+ #createTaskElement(task) {
+ const taskElement = createElement('li', {
+ class: 'task',
+ });
+ const checkboxElement = createElement('input', {
+ type: 'checkbox',
+ checked: task.getIsCompleted(),
+ name: 'checkbox',
+ });
+ const titleElement = createElement('p', {
+ innerText: task.getTitle(),
+ });
+ const deleteButtonElement = createElement('button', {
+ innerText: '삭제',
+ });
+ if (task.getIsCompleted()) {
+ taskElement.classList.toggle('isCompleted');
+ }
+
+ taskElement.append(
+ checkboxElement,
+
+ titleElement,
+ deleteButtonElement
+ );
+ this.#addTaskEventHandler({
+ task,
+ taskElement,
+ deleteButtonElement,
+ checkboxElement,
+ });
+
+ return taskElement;
+ }
+
+ #addTaskEventHandler({
+ task,
+ taskElement,
+ deleteButtonElement,
+ checkboxElement,
+ }) {
+ deleteButtonElement.addEventListener('click', () => {
+ this.deleteTask(task.getId(), task.getSubjectId());
+ dispatchTaskChangeEvent(deleteButtonElement);
+ });
+
+ checkboxElement.addEventListener('change', (event) => {
+ task.setIsCompleted(event.target.checked);
+ taskElement.classList.toggle('isCompleted');
+
+ dispatchTaskChangeEvent(checkboxElement);
+ });
+ }
+
+ getSubjectState(subjectId) {
+ const currentTaskList = this.#taskList.get(subjectId);
+ if (!currentTaskList) {
+ return OPEN;
+ }
+
+ const totalTaskCount = currentTaskList.length;
+ const doneTaskCount = currentTaskList.filter((task) =>
+ task.getIsCompleted()
+ ).length;
+
+ if (doneTaskCount === 0) {
+ return OPEN;
+ }
+ if (totalTaskCount === doneTaskCount) {
+ return DONE;
+ }
+ return IN_PROGRESS;
+ }
+
+ render(subjectId) {
+ const taskListElement = document.getElementById(`${subjectId}-task-list`);
+ taskListElement.innerHTML = '';
+
+ // Render form to add task
+ taskListElement.appendChild(this.#createFormElement(subjectId));
+
+ // Render task
+ this.#getTasksBySubject(subjectId).forEach((task) => {
+ taskListElement.appendChild(this.#createTaskElement(task));
+ });
+ }
+}
diff --git a/script.js b/script.js
deleted file mode 100644
index 355dcc2..0000000
--- a/script.js
+++ /dev/null
@@ -1 +0,0 @@
-//😍CEOS 20기 프론트엔드 파이팅😍
diff --git a/style.css b/style.css
deleted file mode 100644
index 599136a..0000000
--- a/style.css
+++ /dev/null
@@ -1 +0,0 @@
-/* 본인의 디자인 감각을 최대한 발휘해주세요! */
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000..34bb4ce
--- /dev/null
+++ b/todo.md
@@ -0,0 +1,24 @@
+# 피쳐 리스트
+
+| priority | 기능 | 태스크 |
+| -------- | ---------------------------------- | ----------------------------------- |
+| high | **기본 레이아웃** | 헤더, column(open, done) 배치 |
+| | | CSS 스타일 적용 |
+| high | **데이터 구조 작성** | 목표 및 태스크 class 정의 |
+| high | **목표 추가** | 목표 추가 버튼과 모달 구현 |
+| | | 목표 추가 버튼 이벤트 핸들러 작성 |
+| high | **목표 삭제** | 목표 삭제 버튼 구현 |
+| | | 목표 삭제 버튼 이벤트 핸들러 작성 |
+| high | **목표 상태 토글** | 목표 완료 체크박스 추가 |
+| | | 목표 상태에 따른 스타일 변경 |
+| | | 목표 상태 변경 이벤트 핸들러 작성 |
+| high | **태스크 추가** | 태스크 input 필드 구현 |
+| | | submit 이벤트 핸들러 작성 |
+| high | **태스크 삭제** | 태스크 삭제 버튼 구현 |
+| | | submit 삭제 버튼 이벤트 핸들러 작성 |
+| high | **태스크 상태 토글** | 태스크 완료 체크박스 추가 |
+| | | 태스크 상태에 따른 스타일 변경 |
+| | | 태스크 상태 변경 이벤트 핸들러 작성 |
+| medium | **드래그 앤 드롭** | 드래그 앤 드롭 이벤트 핸들러 작성 |
+| low | **로컬 스토리지 저장 및 불러오기** | 보드 상태 저장 함수 작성 |
+| | | 페이지 로드 시 저장된 보드 불러오기 |