1
0
mirror of https://github.com/morris/vanilla-todo.git synced 2025-08-21 05:11:20 +02:00

refactor for pure functional business logic

This commit is contained in:
Morris Brodersen
2023-11-30 11:42:02 +01:00
parent 2815a1eb4c
commit dd8dc8c4af
15 changed files with 449 additions and 252 deletions

View File

@@ -4,19 +4,14 @@ import { AppFps } from './AppFps.js';
import { AppIcon } from './AppIcon.js';
import { TodoFrameCustom } from './TodoFrameCustom.js';
import { TodoFrameDays } from './TodoFrameDays.js';
import { TodoLogic } from './TodoLogic.js';
import { TodoStore } from './TodoStore.js';
import { formatDateId } from './util.js';
/**
* @param {HTMLElement} el
*/
export function TodoApp(el) {
let todoData = {
items: [],
customLists: [],
at: formatDateId(new Date()),
customAt: 0,
};
let todoData = TodoLogic.initTodoData();
el.innerHTML = `
<header class="app-header">

View File

@@ -82,7 +82,7 @@ export function TodoCustomList(el) {
}
el.dispatchEvent(
new CustomEvent('deleteTodoList', {
new CustomEvent('deleteCustomTodoList', {
detail: list,
bubbles: true,
}),
@@ -115,15 +115,15 @@ export function TodoCustomList(el) {
e.detail.index = e.detail.index ?? 0;
});
el.addEventListener('todoCustomList', (e) => {
el.addEventListener('customTodoList', (e) => {
list = e.detail;
update();
});
function save() {
el.dispatchEvent(
new CustomEvent('saveTodoList', {
detail: { list, title: inputEl.value.trim() },
new CustomEvent('editCustomTodoList', {
detail: { ...list, title: inputEl.value.trim() },
bubbles: true,
}),
);

View File

@@ -1,16 +1,13 @@
import { AppIcon } from './AppIcon.js';
import { AppSortable } from './AppSortable.js';
import { TodoCustomList } from './TodoCustomList.js';
import { TodoLogic } from './TodoLogic.js';
/**
* @param {HTMLElement} el
*/
export function TodoFrameCustom(el) {
let todoData = {
customLists: [],
items: [],
customAt: 0,
};
let todoData = TodoLogic.initTodoData();
el.innerHTML = `
<div class="leftcontrols">
@@ -42,9 +39,7 @@ export function TodoFrameCustom(el) {
);
el.querySelector('.add').addEventListener('click', () => {
el.dispatchEvent(
new CustomEvent('addTodoList', { detail: {}, bubbles: true }),
);
el.dispatchEvent(new CustomEvent('addCustomTodoList', { bubbles: true }));
// TODO seek if not at end
});
@@ -52,9 +47,9 @@ export function TodoFrameCustom(el) {
if (!e.detail.data.list) return;
el.dispatchEvent(
new CustomEvent('moveTodoList', {
new CustomEvent('moveCustomTodoList', {
detail: {
list: e.detail.data.list,
...e.detail.data.list,
index: e.detail.index,
},
bubbles: true,
@@ -63,9 +58,7 @@ export function TodoFrameCustom(el) {
});
el.addEventListener('draggableOver', (e) => {
if (!e.detail.data.list) return;
updatePositions();
if (e.detail.data.list) updatePositions();
});
el.addEventListener('todoData', (e) => {
@@ -74,14 +67,15 @@ export function TodoFrameCustom(el) {
});
function update() {
const lists = getLists();
const customLists = TodoLogic.getCustomTodoLists(todoData);
const container = el.querySelector('.container');
const obsolete = new Set(container.children);
const childrenByKey = new Map();
obsolete.forEach((child) => childrenByKey.set(child.dataset.key, child));
const children = lists.map((list) => {
const children = customLists.map((list) => {
let child = childrenByKey.get(list.id);
if (child) {
@@ -93,7 +87,7 @@ export function TodoFrameCustom(el) {
TodoCustomList(child);
}
child.dispatchEvent(new CustomEvent('todoCustomList', { detail: list }));
child.dispatchEvent(new CustomEvent('customTodoList', { detail: list }));
return child;
});
@@ -136,21 +130,4 @@ export function TodoFrameCustom(el) {
// Update collapsible on changing heights
el.dispatchEvent(new CustomEvent('collapse', { bubbles: true }));
}
function getLists() {
return todoData.customLists
.map((list) => ({
id: list.id,
index: list.index,
title: list.title,
items: getItemsForList(list.id),
}))
.sort((a, b) => a.index - b.index);
}
function getItemsForList(listId) {
return todoData.items
.filter((item) => item.listId === listId)
.sort((a, b) => a.index - b.index);
}
}

View File

@@ -1,18 +1,14 @@
import { AppDatePicker } from './AppDatePicker.js';
import { AppIcon } from './AppIcon.js';
import { TodoDay } from './TodoDay.js';
import { formatDateId } from './util.js';
import { TodoLogic } from './TodoLogic.js';
/**
* @param {HTMLElement} el
*/
export function TodoFrameDays(el) {
const RANGE = 14;
let todoData = {
items: [],
at: formatDateId(new Date()),
};
let todoData = TodoLogic.initTodoData();
el.innerHTML = `
<nav class="leftcontrols">
@@ -100,7 +96,7 @@ export function TodoFrameDays(el) {
});
function update() {
const days = getDays();
const listsByDay = TodoLogic.getTodoListsByDay(todoData, RANGE);
const container = el.querySelector('.container');
const obsolete = new Set(container.children);
@@ -108,7 +104,7 @@ export function TodoFrameDays(el) {
obsolete.forEach((child) => childrenByKey.set(child.dataset.key, child));
const children = days.map((day) => {
const children = listsByDay.map((day) => {
let child = childrenByKey.get(day.id);
if (child) {
@@ -147,28 +143,4 @@ export function TodoFrameDays(el) {
el.style.height = `${height + 50}px`;
}
function getDays() {
const days = [];
for (let i = 0; i < 2 * RANGE; ++i) {
const t = new Date(todoData.at);
t.setDate(t.getDate() - RANGE + i);
const id = formatDateId(t);
days.push({
id,
items: getItemsForDay(id),
position: -RANGE + i,
});
}
return days;
}
function getItemsForDay(dateId) {
return todoData.items
.filter((item) => item.listId === dateId)
.sort((a, b) => a.index - b.index);
}
}

View File

@@ -50,7 +50,7 @@ export function TodoItem(el) {
el.dispatchEvent(
new CustomEvent('checkTodoItem', {
detail: {
item,
...item,
done: !item.done,
},
bubbles: true,
@@ -121,9 +121,9 @@ export function TodoItem(el) {
}
el.dispatchEvent(
new CustomEvent('saveTodoItem', {
new CustomEvent('editTodoItem', {
detail: {
item,
...item,
label,
},
bubbles: true,

View File

@@ -20,7 +20,7 @@ export function TodoList(el) {
el.dispatchEvent(
new CustomEvent('moveTodoItem', {
detail: {
item: e.detail.data.item,
...e.detail.data.item,
index: e.detail.index,
},
bubbles: true,

212
public/scripts/TodoLogic.js Normal file
View File

@@ -0,0 +1,212 @@
import { formatDateId } from './util.js';
import { uuid } from './uuid.js';
export class TodoLogic {
static initTodoData(now = new Date()) {
return {
items: [],
customLists: [],
at: formatDateId(now),
customAt: 0,
};
}
static getTodoListsByDay(data, range) {
const listsByDay = [];
for (let i = 0; i < 2 * range; ++i) {
const t = new Date(data.at);
t.setDate(t.getDate() - range + i);
const id = formatDateId(t);
listsByDay.push({
id,
items: TodoLogic.getTodoItemsForList(data, id),
position: -range + i,
});
}
return listsByDay;
}
static getTodoItemsForList(data, listId) {
return data.items
.filter((item) => item.listId === listId)
.sort((a, b) => a.index - b.index);
}
static addTodoItem(data, input) {
let index = 0;
for (const item of data.items) {
if (item.listId === input.listId) {
index = Math.max(index, item.index + 1);
}
}
return {
...data,
items: [
...data.items,
{
...input,
id: uuid(),
index,
done: false,
},
],
};
}
static checkTodoItem(data, input) {
return {
...data,
items: data.items.map((item) =>
item.id === input.id ? { ...item, done: input.done } : item,
),
};
}
static editTodoItem(data, input) {
return {
...data,
items: data.items.map((item) =>
item.id === input.id ? { ...item, label: input.label } : item,
),
};
}
static moveTodoItem(data, input) {
const itemToMove = data.items.find((item) => item.id === input.id);
// Reinsert item at target list and index
let list = data.items.filter(
(item) => item.listId === input.listId && item.id !== input.id,
);
list.splice(input.index, 0, { ...itemToMove, listId: input.listId });
list = TodoLogic.setIndexes(list);
// Reinsert updated list
let items = data.items.filter(
(item) => item.listId !== input.listId && item.id !== input.id,
);
items = [...items, ...list];
return {
...data,
items,
};
}
static deleteTodoItem(data, input) {
return {
...data,
items: data.items.filter((item) => item.id !== input.id),
};
}
//
static getCustomTodoLists(data) {
return data.customLists
.map((list) => ({
id: list.id,
index: list.index,
title: list.title,
items: TodoLogic.getTodoItemsForList(data, list.id),
}))
.sort((a, b) => a.index - b.index);
}
static addCustomTodoList(data) {
let index = 0;
for (const customList of data.customLists) {
index = Math.max(index, customList.index + 1);
}
return {
...data,
customLists: [
...data.customLists,
{
id: uuid(),
index,
title: '',
},
],
};
}
static editCustomTodoList(data, input) {
return {
...data,
customLists: data.customLists.map((customList) =>
customList.id === input.id
? { ...customList, title: input.title }
: customList,
),
};
}
static moveCustomTodoList(data, input) {
const customListToMove = data.customLists.find(
(customList) => customList.id === input.id,
);
let customLists = data.customLists
.filter((customList) => customList.id !== input.id)
.sort((a, b) => a.index - b.index);
customLists.splice(input.index, 0, customListToMove);
customLists = TodoLogic.setIndexes(customLists);
return {
...data,
customLists,
};
}
static deleteCustomTodoList(data, input) {
return {
...data,
customLists: data.customLists.filter(
(customList) => customList.id !== input.id,
),
};
}
//
static seekDays(data, delta) {
const t = new Date(`${data.at}T00:00:00`);
t.setDate(t.getDate() + delta);
return { ...data, at: formatDateId(t) };
}
static seekToToday(data) {
return { ...data, at: formatDateId(new Date()) };
}
static seekToDate(data, date) {
return { ...data, at: formatDateId(date) };
}
static seekCustomTodoLists(data, delta) {
return {
...data,
customAt: Math.max(
0,
Math.min(data.customLists.length - 1, data.customAt + delta),
),
};
}
//
static setIndexes(array) {
return array.map((item, index) =>
item.index === index ? item : { ...item, index },
);
}
}

View File

@@ -1,158 +1,36 @@
import { formatDateId, uuid } from './util.js';
import { TodoLogic } from './TodoLogic.js';
/**
* @param {HTMLElement} el
*/
export function TodoStore(el) {
const todoData = {
items: [],
customLists: [],
at: formatDateId(new Date()),
customAt: 0,
};
let storeTimeout;
let todoData = TodoLogic.initTodoData();
let saveTimeout;
el.addEventListener('loadTodoStore', load);
el.addEventListener('addTodoItem', (e) => {
let index = 0;
for (const item of todoData.items) {
if (item.listId === e.detail.listId) {
index = Math.max(index, item.index + 1);
}
}
todoData.items.push({
id: uuid(),
listId: e.detail.listId,
index,
label: e.detail.label,
done: false,
for (const action of [
'addTodoItem',
'checkTodoItem',
'editTodoItem',
'moveTodoItem',
'deleteTodoItem',
'addCustomTodoList',
'editCustomTodoList',
'moveCustomTodoList',
'deleteCustomTodoList',
'seekDays',
'seekToToday',
'seekToDate',
'seekCustomTodoLists',
]) {
el.addEventListener(action, (e) => {
todoData = TodoLogic[action](todoData, e.detail);
update();
});
}
dispatch({ items: todoData.items });
});
el.addEventListener('checkTodoItem', (e) => {
if (e.detail.item.done === e.detail.done) return;
e.detail.item.done = e.detail.done;
dispatch({ items: todoData.items });
});
el.addEventListener('saveTodoItem', (e) => {
if (e.detail.item.label === e.detail.label) return;
e.detail.item.label = e.detail.label;
dispatch({ items: todoData.items });
});
el.addEventListener('moveTodoItem', (e) => {
const movedItem = todoData.items.find(
(item) => item.id === e.detail.item.id,
);
const listItems = todoData.items.filter(
(item) => item.listId === e.detail.listId && item !== movedItem,
);
listItems.sort((a, b) => a.index - b.index);
movedItem.listId = e.detail.listId;
listItems.splice(e.detail.index, 0, movedItem);
listItems.forEach((item, index) => {
item.index = index;
});
dispatch({ items: todoData.items });
});
el.addEventListener('deleteTodoItem', (e) =>
dispatch({
items: todoData.items.filter((item) => item.id !== e.detail.id),
}),
);
el.addEventListener('addTodoList', (e) => {
let index = 0;
for (const customList of todoData.customLists) {
index = Math.max(index, customList.index + 1);
}
todoData.customLists.push({
id: uuid(),
index,
title: e.detail.title || '',
});
dispatch({ customLists: todoData.customLists });
});
el.addEventListener('saveTodoList', (e) => {
const list = todoData.customLists.find((l) => l.id === e.detail.list.id);
if (list.title === e.detail.title) return;
list.title = e.detail.title;
dispatch({ customLists: todoData.customLists });
});
el.addEventListener('moveTodoList', (e) => {
const movedListIndex = todoData.customLists.findIndex(
(list) => list.id === e.detail.list.id,
);
const movedList = todoData.customLists[movedListIndex];
todoData.customLists.splice(movedListIndex, 1);
todoData.customLists.sort((a, b) => a.index - b.index);
todoData.customLists.splice(e.detail.index, 0, movedList);
todoData.customLists.forEach((item, index) => {
item.index = index;
});
dispatch({ customLists: todoData.customLists });
});
el.addEventListener('deleteTodoList', (e) =>
dispatch({
customLists: todoData.customLists.filter(
(customList) => customList.id !== e.detail.id,
),
}),
);
el.addEventListener('seekDays', (e) => {
const t = new Date(`${todoData.at}T00:00:00`);
t.setDate(t.getDate() + e.detail);
dispatch({ at: formatDateId(t) });
});
el.addEventListener('seekToToday', () =>
dispatch({ at: formatDateId(new Date()) }),
);
el.addEventListener('seekToDate', (e) =>
dispatch({ at: formatDateId(e.detail) }),
);
el.addEventListener('seekCustomTodoLists', (e) =>
dispatch({
customAt: Math.max(
0,
Math.min(todoData.customLists.length - 1, todoData.customAt + e.detail),
),
}),
);
function dispatch(next) {
Object.assign(todoData, next);
function update() {
save();
el.dispatchEvent(
@@ -164,23 +42,22 @@ export function TodoStore(el) {
}
function load() {
if (!localStorage || !localStorage.todo) {
dispatch(todoData);
return;
}
try {
dispatch(JSON.parse(localStorage.todo));
if (localStorage?.todo) {
todoData = { ...todoData, ...JSON.parse(localStorage.todo) };
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn(err);
}
update();
}
function save() {
clearTimeout(storeTimeout);
clearTimeout(saveTimeout);
storeTimeout = setTimeout(() => {
saveTimeout = setTimeout(() => {
try {
localStorage.todo = JSON.stringify(todoData);
} catch (err) {

View File

@@ -1,12 +1,3 @@
export function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* @param {Date} date
* @returns {string}

8
public/scripts/uuid.js Normal file
View File

@@ -0,0 +1,8 @@
export function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0,
v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}