Aplicación de lista de tareas
En este tutorial, crearemos una aplicación de lista de tareas muy sencilla. La aplicación debe cumplir los siguientes requisitos:
- Permitir al usuario crear y eliminar tareas
- Las tareas se pueden marcar como completadas
- Las tareas se pueden filtrar para mostrar las tareas activas/completadas
Este proyecto será una oportunidad para descubrir y aprender algunos conceptos importantes de Owl, como componentes, tienda y cómo organizar una aplicación.
1. Configuración del proyecto
En este tutorial, realizaremos un proyecto muy simple, con archivos estáticos y sin herramientas adicionales. El primer paso es crear la siguiente estructura de archivos:
todoapp/ index.html app.css app.js owl.js
El punto de entrada para esta aplicación es el archivo index.html
, que debe tener el siguiente contenido:
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <title>OWL Todo App</title> <link rel="stylesheet" href="app.css" /> </head> <body> <script src="owl.js"></script> <script src="app.js"></script> </body></html>
Por ahora, podemos dejar app.css
vacío. Será útil más adelante para darle estilo a nuestra aplicación. app.js
es donde escribiremos todo nuestro código. Por ahora, simplemente pongamos el siguiente código:
(function () { console.log("hello owl", owl.__info__.version);})();
Tenga en cuenta que colocamos todo dentro de una función ejecutada inmediatamente para evitar filtrar algo al ámbito global.
Por último, owl.js
debe ser la última versión descargada del repositorio Owl (puedes usar owl.min.js
si lo prefieres). Ten en cuenta que debes descargar owl.iife.js
o owl.iife.min.js
, ya que estos archivos están diseñados para ejecutarse directamente en el navegador, y cambiarles el nombre a owl.js
(otros archivos como owl.cjs.js
están diseñados para ser incluidos en otras herramientas).
Ahora, el proyecto debería estar listo. Al cargar el archivo index.html
en un navegador, debería aparecer una página vacía con el título Owl Todo App
y debería aparecer un mensaje como hello owl 2.x.y
en la consola.
2. Añadiendo un primer componente
Una aplicación Owl está formada por componentes, con un único componente raíz. Empecemos por definir un componente Root
. Reemplace el contenido de la función en app.js
por el siguiente código:
const { Component, mount, xml } = owl;
// Owl Componentsclass Root extends Component { static template = xml`<div>todo app</div>`;}
mount(Root, document.body);
Ahora, al volver a cargar la página en un navegador debería aparecer un mensaje.
El código es bastante simple: definimos un componente con una plantilla en línea y luego lo montamos en el cuerpo del documento.
Nota 1: en un proyecto más grande, dividiríamos el código en varios archivos, con los componentes en una subcarpeta y un archivo principal que inicializaría la aplicación. Sin embargo, se trata de un proyecto muy pequeño y queremos que sea lo más simple posible.
Nota 2: este tutorial utiliza la sintaxis de campo de clase estática. Esto aún no es compatible con todos los navegadores. La mayoría de los proyectos reales transpilarán su código, por lo que esto no es un problema, pero para este tutorial, si necesita que el código funcione en todos los navegadores, deberá traducir cada palabra clave
static
a una asignación a la clase:
class App extends Component {}App.template = xml`<div>todo app</div>`;
Nota 3: escribir plantillas en línea con el asistente
xml
es bueno, pero no hay resaltado de sintaxis, y esto hace que sea muy fácil tener un xml mal formado. Algunos editores admiten el resaltado de sintaxis para esta situación. Por ejemplo, VS Code tiene un complementoPlantilla etiquetada para comentarios
, que, si se instala, mostrará correctamente las plantillas etiquetadas:
static template = xml /* xml */`<div>todo app</div>`;
Nota 4: Las aplicaciones grandes probablemente querrán poder traducir plantillas. El uso de plantillas en línea lo hace un poco más difícil, ya que necesitamos herramientas adicionales para extraer el xml del código y reemplazarlo con los valores traducidos.
3. Visualización de una lista de tareas
Ahora que ya hemos hecho lo básico, es hora de empezar a pensar en las tareas. Para lograr lo que necesitamos, haremos un seguimiento de las tareas como una matriz de objetos con las siguientes claves:
id
: un número. Es extremadamente útil tener una forma de identificar tareas de forma única. Dado que el título es algo creado o editado por el usuario, no ofrece garantía de que sea único. Por lo tanto, generaremos un númeroid
único para cada tarea.text
: una cadena, para explicar de qué se trata la tarea.isCompleted
: un valor booleano, para realizar un seguimiento del estado de la tarea
Ahora que decidimos el formato interno del estado, agreguemos algunos datos de demostración y una plantilla al componente App
:
class Root extends Component { static template = xml/* xml */ ` <div class="task-list"> <t t-foreach="tasks" t-as="task" t-key="task.id"> <div class="task"> <input type="checkbox" t-att-checked="task.isCompleted"/> <span><t t-esc="task.text"/></span> </div> </t> </div>`;
tasks = [ { id: 1, text: "buy milk", isCompleted: true, }, { id: 2, text: "clean house", isCompleted: false, }, ];}
La plantilla contiene un bucle t-foreach
para iterar a través de las tareas. Puede encontrar la lista tasks
del componente, ya que el contexto de representación contiene las propiedades del componente. Tenga en cuenta que usamos el id
de cada tarea como una t-key
, lo cual es muy común. Hay dos clases CSS: task-list
y task
, que usaremos en la siguiente sección.
Por último, observe el uso del atributo t-att-checked
: anteponer un atributo con t-att
lo hace dinámico. Owl evaluará la expresión y la establecerá como el valor del atributo.
4. Diseño: algunos CSS básicos
Hasta ahora, nuestra lista de tareas parece bastante mala. Agreguemos lo siguiente a app.css
:
.task-list { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px;}
.task { font-size: 18px; color: #111111;}
Esto es mejor. Ahora, agreguemos una característica adicional: las tareas completadas deben tener un estilo un poco diferente, para que quede más claro que no son tan importantes. Para ello, agregaremos una clase CSS dinámica en cada tarea:
<div class="task" t-att-class="task.isCompleted ? 'done' : ''">
.task.done { opacity: 0.7;}
Observe que aquí tenemos otro uso de un atributo dinámico.
5. Extraer una tarea como subcomponente
Ahora está claro que debería haber un componente ‘Tarea’ para encapsular la apariencia y el comportamiento de una tarea.
Este componente Task
mostrará una tarea, pero no puede poseer el estado de la tarea: un fragmento de datos solo debe tener un propietario. Hacer lo contrario es buscar problemas. Por lo tanto, el componente Task
obtendrá sus datos como una prop
. Esto significa que los datos aún son propiedad del componente App
, pero pueden ser utilizados por el componente Task
(sin modificarlos).
Dado que estamos moviendo el código, es una buena oportunidad para refactorizarlo un poco:
// -------------------------------------------------------------------------// Task Component// -------------------------------------------------------------------------class Task extends Component { static template = xml /* xml */` <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-checked="props.task.isCompleted"/> <span><t t-esc="props.task.text"/></span> </div>`; static props = ["task"];}
// -------------------------------------------------------------------------// Root Component// -------------------------------------------------------------------------class Root extends Component { static template = xml /* xml */` <div class="task-list"> <t t-foreach="tasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div>`; static components = { Task };
tasks = [ ... ];}
// -------------------------------------------------------------------------// Setup// -------------------------------------------------------------------------mount(Root, document.body, {dev: true});
Aquí pasaron muchas cosas:
- Primero, ahora tenemos un subcomponente
Tarea
, definido en la parte superior del archivo, - siempre que definimos un subcomponente, es necesario agregarlo a la clave estática
components
de su padre, para que Owl pueda obtener una referencia a él. - El componente
Task
tiene una claveprops
: esto solo es útil para fines de validación. Dice que a cadaTask
se le debe asignar exactamente una propiedad, llamadatask
. Si este no es el caso, Owl lanzará un error. Esto es extremadamente útil al refactorizar componentes. - Finalmente, para activar la validación de props, necesitamos configurar el mode de Owl en
dev
. Esto se hace en el último argumento de la funciónmount
. Ten en cuenta que esto se debe eliminar cuando se utiliza una aplicación en un entorno de producción real, ya que el mododev
es un poco más lento, debido a comprobaciones y validaciones adicionales.
6. Agregar tareas (parte 1)
Todavía utilizamos una lista de tareas codificadas. Es hora de darle al usuario una forma de agregar tareas por sí mismo. El primer paso es agregar una entrada al componente Root
. Pero esta entrada estará fuera de la lista de tareas, por lo que necesitamos adaptar la plantilla Root
, js y css:
<div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask"/> <div class="task-list"> <t t-foreach="tasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div></div>
addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { const text = ev.target.value.trim(); ev.target.value = ""; console.log('adding task', text); // todo }}
.todo-app { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px;}
.todo-app > input { display: block; margin: auto;}
.task-list { margin-top: 8px;}
Ahora tenemos una entrada funcional que se registra en la consola cada vez que el usuario agrega una tarea. Observe que cuando carga la página, la entrada no está enfocada. Sin embargo, agregar tareas es una característica fundamental de una lista de tareas, así que hagámoslo lo más rápido posible enfocando la entrada.
Necesitamos ejecutar el código cuando el componente Root
esté listo (montado). Hagámoslo usando el gancho onMounted
. También necesitaremos obtener una referencia a la entrada, usando la directiva t-ref
con el gancho useRef
:
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
// on top of file:const { Component, mount, xml, useRef, onMounted } = owl;
// in Appsetup() { const inputRef = useRef("add-input"); onMounted(() => inputRef.el.focus());}
Esta es una situación muy común: siempre que necesitamos realizar algunas acciones dependiendo del ciclo de vida de un componente, necesitamos hacerlo en el método setup
, utilizando uno de los ganchos de ciclo de vida. Aquí, primero obtenemos una referencia a inputRef
, luego en el gancho onMounted
, simplemente enfocamos el elemento html.
7. Agregar tareas (parte 2)
En la sección anterior, hicimos todo excepto implementar el código que realmente crea las tareas. Hagámoslo ahora.
Necesitamos una forma de generar números id
únicos. Para ello, simplemente agregaremos un número nextId
en App
. Al mismo tiempo, eliminemos las tareas de demostración en App
:
nextId = 1;tasks = [];
Ahora, se puede implementar el método addTask
:
addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { const text = ev.target.value.trim(); ev.target.value = ""; if (text) { const newTask = { id: this.nextId++, text: text, isCompleted: false, }; this.tasks.push(newTask); } }}
Esto funciona casi por completo, pero si lo pruebas, notarás que nunca se muestra ninguna tarea nueva cuando el usuario presiona Enter. Pero si agregas una instrucción debugger
o console.log
, verás que el código se está ejecutando como se esperaba. El problema es que Owl no tiene forma de saber que necesita volver a renderizar la interfaz de usuario. Podemos solucionar el problema haciendo que tasks
sea reactiva, con el gancho useState
:
// on top of the fileconst { Component, mount, xml, useRef, onMounted, useState } = owl;
// replace the task definition in App with the following:tasks = useState([]);
¡Ahora funciona como se esperaba!
8. Alternar tareas
Si intentaste marcar una tarea como completada, es posible que hayas notado que el texto no cambió su opacidad. Esto se debe a que no hay código para modificar el indicador isCompleted
.
Ahora bien, esta es una situación interesante: la tarea se muestra mediante el componente Task
, pero no es el propietario de su estado, por lo que idealmente no debería modificarlo. Sin embargo, por ahora, eso es lo que haremos (esto se mejorará en un paso posterior). En Task
, cambie input
a:
<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>
y agrega el método toggleTask
:
toggleTask() { this.props.task.isCompleted = !this.props.task.isCompleted;}
9. Eliminando tareas
Ahora, agreguemos la posibilidad de eliminar tareas. Esto es diferente de la función anterior: la eliminación de tareas debe realizarse en la tarea misma, pero la operación real debe realizarse en la lista de tareas. Por lo tanto, debemos comunicar la solicitud al componente “Root”. Esto generalmente se hace proporcionando una devolución de llamada en una propiedad.
Primero, actualicemos la plantilla Tarea
, css y js:
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/> <span><t t-esc="props.task.text"/></span> <span class="delete" t-on-click="deleteTask">🗑</span></div>
.task { font-size: 18px; color: #111111; display: grid; grid-template-columns: 30px auto 30px;}
.task > input { margin: auto;}
.delete { opacity: 0; cursor: pointer; text-align: center;}
.task:hover .delete { opacity: 1;}
static props = ["task", "onDelete"];
deleteTask() { this.props.onDelete(this.props.task);}
Y ahora, necesitamos proporcionar la devolución de llamada onDelete
a cada tarea en el componente Root
:
<Task task="task" onDelete.bind="deleteTask"/>
deleteTask(task) { const index = this.tasks.findIndex(t => t.id === task.id); this.tasks.splice(index, 1);}
Tenga en cuenta que la propiedad onDelete
está definida con un sufijo .bind
: este es un sufijo especial que asegura que la devolución de llamada de la función esté vinculada al componente.
Observe también que tenemos dos funciones denominadas deleteTask
. La que se encuentra en el componente Tarea simplemente delega el trabajo al componente Raíz que posee la lista de tareas a través de la propiedad onDelete
.
10. Usando el almacenamiento
Al observar el código, es evidente que todas las tareas de gestión del código están dispersas por toda la aplicación. Además, mezcla código de interfaz de usuario y código de lógica empresarial. Owl no proporciona ninguna abstracción de alto nivel para gestionar la lógica empresarial, pero es fácil hacerlo con las primitivas de reactividad básicas (useState
y reactive
).
Utilicémoslo en nuestra aplicación para implementar un almacén central. Se trata de una refactorización bastante grande (para nuestra aplicación), ya que implica extraer todo el código relacionado con las tareas de los componentes. Aquí se muestra el nuevo contenido del archivo app.js
:
const { Component, mount, xml, useRef, onMounted, useState, reactive, useEnv } = owl;
// -------------------------------------------------------------------------// Store// -------------------------------------------------------------------------function useStore() { const env = useEnv(); return useState(env.store);}
// -------------------------------------------------------------------------// TaskList// -------------------------------------------------------------------------class TaskList { nextId = 1; tasks = [];
addTask(text) { text = text.trim(); if (text) { const task = { id: this.nextId++, text: text, isCompleted: false, }; this.tasks.push(task); } }
toggleTask(task) { task.isCompleted = !task.isCompleted; }
deleteTask(task) { const index = this.tasks.findIndex((t) => t.id === task.id); this.tasks.splice(index, 1); }}
function createTaskStore() { return reactive(new TaskList());}
// -------------------------------------------------------------------------// Task Component// -------------------------------------------------------------------------class Task extends Component { static template = xml/* xml */ ` <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="() => store.toggleTask(props.task)"/> <span><t t-esc="props.task.text"/></span> <span class="delete" t-on-click="() => store.deleteTask(props.task)">🗑</span> </div>`;
static props = ["task"];
setup() { this.store = useStore(); }}
// -------------------------------------------------------------------------// Root Component// -------------------------------------------------------------------------class Root extends Component { static template = xml/* xml */ ` <div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/> <div class="task-list"> <t t-foreach="store.tasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div> </div>`; static components = { Task };
setup() { const inputRef = useRef("add-input"); onMounted(() => inputRef.el.focus()); this.store = useStore(); }
addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { this.store.addTask(ev.target.value); ev.target.value = ""; } }}
// -------------------------------------------------------------------------// Setup// -------------------------------------------------------------------------const env = { store: createTaskStore(),};mount(Root, document.body, { dev: true, env });
11. Guardar tareas en el almacenamiento local
Ahora, nuestra TodoApp funciona muy bien, ¡excepto si el usuario cierra o actualiza el navegador! Es realmente incómodo mantener solo el estado de la aplicación en la memoria. Para solucionar esto, guardaremos las tareas en el almacenamiento local. Con nuestra base de código actual, es un cambio simple: necesitamos guardar las tareas en el almacenamiento local y escuchar cualquier cambio.
class TaskList { constructor(tasks) { this.tasks = tasks || []; const taskIds = this.tasks.map((t) => t.id); this.nextId = taskIds.length ? Math.max(...taskIds) + 1 : 1; } // ...}
function createTaskStore() { const saveTasks = () => localStorage.setItem("todoapp", JSON.stringify(taskStore.tasks)); const initialTasks = JSON.parse(localStorage.getItem("todoapp") || "[]"); const taskStore = reactive(new TaskList(initialTasks), saveTasks); saveTasks(); return taskStore;}
El punto clave es que la función reactive
recibe una devolución de llamada que se llamará cada vez que se modifique un valor observado. Tenga en cuenta que debemos llamar al método saveTasks
inicialmente para asegurarnos de que observamos todos los valores actuales.
12. Filtrando tareas
Ya casi hemos terminado, podemos agregar, actualizar o eliminar tareas. La única característica que falta es la posibilidad de mostrar las tareas según su estado de finalización. Tendremos que hacer un seguimiento del estado del filtro en Root
y luego filtrar las tareas visibles según su valor.
class Root extends Component { static template = xml /* xml */` <div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/> <div class="task-list"> <t t-foreach="displayedTasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div> <div class="task-panel" t-if="store.tasks.length"> <div class="task-counter"> <t t-esc="displayedTasks.length"/> <t t-if="displayedTasks.length lt store.tasks.length"> / <t t-esc="store.tasks.length"/> </t> task(s) </div> <div> <span t-foreach="['all', 'active', 'completed']" t-as="f" t-key="f" t-att-class="{active: filter.value===f}" t-on-click="() => this.setFilter(f)" t-esc="f"/> </div> </div> </div>`;
setup() { ... this.filter = useState({ value: "all" }); }
get displayedTasks() { const tasks = this.store.tasks; switch (this.filter.value) { case "active": return tasks.filter(t => !t.isCompleted); case "completed": return tasks.filter(t => t.isCompleted); case "all": return tasks; } }
setFilter(filter) { this.filter.value = filter; }}
.task-panel { color: #0088ff; margin-top: 8px; font-size: 14px; display: flex;}
.task-panel .task-counter { flex-grow: 1;}
.task-panel span { padding: 5px; cursor: pointer;}
.task-panel span.active { font-weight: bold;}
Observe aquí que configuramos dinámicamente la clase CSS del filtro con la sintaxis del objeto.
13. El toque final
Nuestra lista incluye todas las funciones. Aún podemos agregar algunos detalles adicionales para mejorar la experiencia del usuario.
- Agregar una respuesta visual cuando el mouse del usuario esté sobre una tarea:
.task:hover { background-color: #def0ff;}
- Hacer que el texto de una tarea sea cliqueable, para alternar su casilla de verificación:
<input type="checkbox" t-att-checked="props.task.isCompleted" t-att-id="props.task.id" t-on-click="() => store.toggleTask(props.task)"/><label t-att-for="props.task.id"><t t-esc="props.task.text"/></label>
- Tacha el texto de la tarea completada:
.task.done label { text-decoration: line-through;}
Código final
Nuestra aplicación ya está completa. Funciona, el código de la interfaz de usuario está bien separado del código de lógica empresarial, se puede probar y todo en menos de 150 líneas de código (¡incluye plantilla!).
Como referencia, aquí está el código final:
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <title>OWL Todo App</title> <link rel="stylesheet" href="app.css" /> </head> <body> <script src="owl.js"></script> <script src="app.js"></script> </body></html>
(function () { const { Component, mount, xml, useRef, onMounted, useState, reactive, useEnv } = owl;
// ------------------------------------------------------------------------- // Store // ------------------------------------------------------------------------- function useStore() { const env = useEnv(); return useState(env.store); }
// ------------------------------------------------------------------------- // TaskList // ------------------------------------------------------------------------- class TaskList { constructor(tasks) { this.tasks = tasks || []; const taskIds = this.tasks.map((t) => t.id); this.nextId = taskIds.length ? Math.max(...taskIds) + 1 : 1; }
addTask(text) { text = text.trim(); if (text) { const task = { id: this.nextId++, text: text, isCompleted: false, }; this.tasks.push(task); } }
toggleTask(task) { task.isCompleted = !task.isCompleted; }
deleteTask(task) { const index = this.tasks.findIndex((t) => t.id === task.id); this.tasks.splice(index, 1); } }
function createTaskStore() { const saveTasks = () => localStorage.setItem("todoapp", JSON.stringify(taskStore.tasks)); const initialTasks = JSON.parse(localStorage.getItem("todoapp") || "[]"); const taskStore = reactive(new TaskList(initialTasks), saveTasks); saveTasks(); return taskStore; }
// ------------------------------------------------------------------------- // Task Component // ------------------------------------------------------------------------- class Task extends Component { static template = xml/* xml */ ` <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-id="props.task.id" t-att-checked="props.task.isCompleted" t-on-click="() => store.toggleTask(props.task)"/> <label t-att-for="props.task.id"><t t-esc="props.task.text"/></label> <span class="delete" t-on-click="() => store.deleteTask(props.task)">🗑</span> </div>`;
static props = ["task"];
setup() { this.store = useStore(); } }
// ------------------------------------------------------------------------- // Root Component // ------------------------------------------------------------------------- class Root extends Component { static template = xml/* xml */ ` <div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/> <div class="task-list"> <t t-foreach="displayedTasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div> <div class="task-panel" t-if="store.tasks.length"> <div class="task-counter"> <t t-esc="displayedTasks.length"/> <t t-if="displayedTasks.length lt store.tasks.length"> / <t t-esc="store.tasks.length"/> </t> task(s) </div> <div> <span t-foreach="['all', 'active', 'completed']" t-as="f" t-key="f" t-att-class="{active: filter.value===f}" t-on-click="() => this.setFilter(f)" t-esc="f"/> </div> </div> </div>`; static components = { Task };
setup() { const inputRef = useRef("add-input"); onMounted(() => inputRef.el.focus()); this.store = useStore(); this.filter = useState({ value: "all" }); }
addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { this.store.addTask(ev.target.value); ev.target.value = ""; } }
get displayedTasks() { const tasks = this.store.tasks; switch (this.filter.value) { case "active": return tasks.filter((t) => !t.isCompleted); case "completed": return tasks.filter((t) => t.isCompleted); case "all": return tasks; } }
setFilter(filter) { this.filter.value = filter; } }
// ------------------------------------------------------------------------- // Setup // ------------------------------------------------------------------------- const env = { store: createTaskStore() }; mount(Root, document.body, { dev: true, env });})();
.todo-app { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px;}
.todo-app > input { display: block; margin: auto;}
.task-list { margin-top: 8px;}
.task { font-size: 18px; color: #111111; display: grid; grid-template-columns: 30px auto 30px;}
.task:hover { background-color: #def0ff;}
.task > input { margin: auto;}
.delete { opacity: 0; cursor: pointer; text-align: center;}
.task:hover .delete { opacity: 1;}
.task.done { opacity: 0.7;}.task.done label { text-decoration: line-through;}
.task-panel { color: #0088ff; margin-top: 8px; font-size: 14px; display: flex;}
.task-panel .task-counter { flex-grow: 1;}
.task-panel span { padding: 5px; cursor: pointer;}
.task-panel span.active { font-weight: bold;}