La lliçó anterior ha consolidat els tres components que mostren dades: <user-avatar>, <task-card> i <task-filter>. Aquesta lliçó tanca l'arbre de TaskFlow amb les dues peces que l'orquestren —<task-board> i <task-list>— juntament amb tota la infraestructura compartida que ambdues usen: el context de filtre, el controlador reactiu de proximitat de data, el mixin d'estat de càrrega, i el servei que simula l'obtenció de tasques des d'una font externa. Pel camí es tanca, finalment, una peça que va quedar pendent des del mòdul 7: com fer que <task-list> continuï rebent tasques actualitzades després que la càrrega inicial, resolta una única vegada amb until, ja hagi acabat.

Contingut

  1. Què queda per consolidar
  2. El context de filtre, complet
  3. El controlador i el mixin, complets
  4. El servei de càrrega simulada, complet
  5. <task-list>, complet
  6. <task-board>, complet: propietats, context i càrrega inicial
  7. Tancant un buit: mantenir <task-list> sincronitzada després de until
  8. Taula final: què connecta cada parell de components
  9. Cap al tancament del curs

  1. Què queda per consolidar

<task-board> i <task-list> són els dos components de TaskFlow amb més peces d'infraestructura al voltant: un mixin, un context, un servei simulat i una directiva de renderitzat asíncron, totes explicades als mòduls 6 i 7. Abans d'escriure el codi complet d'ambdós, aquesta lliçó reuneix primer, un a un, els tres fitxers de suport que tots dos donen per fet: el context de filtre (src/context/filtro-context.js), el controlador i el mixin (src/controllers/ i src/mixins/), i el servei de càrrega (src/services/tareas-service.js).

  1. El context de filtre, complet

El fitxer de context és, dels tres, el més petit: una única clau, sense cap valor per defecte incrustat en el propi mòdul (el valor inicial es declara on s'instancia el ContextProvider, dins de <task-board>).

// src/context/filtro-context.js
import { createContext } from '@lit/context';

// Mòdul 7 (07-04): identificador únic de context, no el valor en si.
export const filtroContext = createContext('filtro-tareas');

  1. El controlador i el mixin, complets

ContadorTiempoRestanteController, utilitzat per <task-card> des del mòdul 6, no ha canviat ni una línia des de la seva lliçó d'origen:

// src/controllers/contador-tiempo-restante-controller.js
// Mòdul 6 (06-03): lògica amb estat propi i cicle de vida, reutilitzable
// per qualsevol host que exposi una propietat `fechaLimite`.
export class ContadorTiempoRestanteController {
  constructor(host, { margenMs = 24 * 60 * 60 * 1000, intervaloMs = 60000 } = {}) {
    this.host = host;
    this.margenMs = margenMs;
    this.intervaloMs = intervaloMs;
    this.cercaDeVencer = false;
    host.addController(this);
  }

  hostConnected() {
    this._comprobar();
    this._idIntervalo = setInterval(() => this._comprobar(), this.intervaloMs);
  }

  hostDisconnected() {
    clearInterval(this._idIntervalo);
  }

  _comprobar() {
    const nuevoValor = this._calcularSiCercaDeVencer(this.host.fechaLimite);
    if (nuevoValor !== this.cercaDeVencer) {
      this.cercaDeVencer = nuevoValor;
      this.host.requestUpdate();
    }
  }

  _calcularSiCercaDeVencer(fechaLimite) {
    if (!fechaLimite) {
      return false;
    }
    const msRestantes = fechaLimite.getTime() - Date.now();
    return msRestantes > 0 && msRestantes <= this.margenMs;
  }
}

ConEstadoCarga, el mixin que <task-board> utilitzarà a l'apartat 6, tampoc canvia des del mòdul 6:

// src/mixins/con-estado-carga.js
// Mòdul 6 (06-04): comportament que s'integra en la pròpia API del
// component, no en un objecte separat com el controlador anterior.
import { html } from 'lit';

export const ConEstadoCarga = (Base) => class extends Base {
  static properties = {
    ...Base.properties,
    cargando: { state: true },
  };

  constructor(...args) {
    super(...args);
    this.cargando = false;
  }

  conIndicadorDeCarga(plantilla) {
    if (this.cargando) {
      return html`<p class="cargando">Cargando…</p>`;
    }
    return plantilla;
  }
};

<task-board> continua estenent ConEstadoCarga(LitElement), tal com es va decidir al mòdul 6, encara que —com es veurà a l'apartat 6— la càrrega inicial de tasques utilitzi until en lloc de this.cargando; el mixin queda disponible, amb la seva propietat cargando i el seu mètode conIndicadorDeCarga, per a qualsevol operació futura de TaskFlow (desar canvis, sincronitzar amb un servidor) que encaixi millor amb aquest patró, seguint exactament el criteri de la taula comparativa de la lliçó "Renderitzat Asíncron amb until" (07-03, apartat 7).

  1. El servei de càrrega simulada, complet

El servei de la lliçó 07-03 retornava tasques amb només quatre camps, suficients en aquell moment per explicar until. Amb <task-card> ja complet des de la lliçó anterior, amb suport per a persona assignada i data límit, aquest és el moment de completar també les dades d'exemple:

// src/services/tareas-service.js
// Mòdul 7 (07-03): simula una font de dades externa amb un retard.
// Les dades d'exemple es completen aquí amb els camps que <task-card>
// va acabar necessitant en mòduls posteriors (assignat, data límit).
export function cargarTareas() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        {
          id: 1,
          titulo: 'Preparar la demo del sprint',
          estado: 'en-progreso',
          prioridad: 4,
          urgente: true,
          asignadoA: 'Ana Costa',
          asignadoImagen: '',
          fechaLimite: new Date(Date.now() + 12 * 60 * 60 * 1000),
        },
        {
          id: 2,
          titulo: 'Revisar el PR de autenticación',
          estado: 'pendiente',
          prioridad: 2,
          urgente: false,
          asignadoA: 'Marc Puig',
          asignadoImagen: '',
          fechaLimite: null,
        },
        {
          id: 3,
          titulo: 'Desplegar a producción',
          estado: 'hecha',
          prioridad: 5,
          urgente: false,
          asignadoA: 'Ana Costa',
          asignadoImagen: '',
          fechaLimite: null,
        },
      ]);
    }, 1200);
  });
}

La primera tasca, amb una data límit dins de les properes dotze hores, garanteix que ContadorTiempoRestanteController i resaltarSiUrgente tinguin, des del primer moment en què TaskFlow arrenca, un cas real que activar sense necessitat d'esperar ni de modificar les dades a mà.

  1. <task-list>, complet

<task-list> reuneix el consum del context de filtre (07-04), el filtratge derivat (07-04, refinat a 09-03) i el reenviament de l'esdeveniment cap a <task-board> (05-04):

// src/components/task-list.js
import { LitElement, html, css } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { ContextConsumer } from '@lit/context';
import { filtroContext } from '../context/filtro-context.js';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './task-card.js';

class TaskList extends LitElement {
  static properties = {
    tareas: { type: Array },
  };

  static styles = [
    estilosCompartidos,
    css`
      .lista {
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
      }
    `,
  ];

  constructor() {
    super();
    this.tareas = [];
    // Mòdul 7 (07-04): consumidor del context de filtre publicat per <task-board>.
    this._filtro = new ContextConsumer(this, { context: filtroContext, subscribe: true });
  }

  // Mòdul 7 (07-04) / Mòdul 9 (09-03): getter simple, sense memòria cau; per al
  // volum de dades de TaskFlow, moure'l a willUpdate no aporta cap
  // millora perceptible, tal com es va raonar a la lliçó de rendiment.
  get tareasFiltradas() {
    const { texto, estado } = this._filtro.value ?? { texto: '', estado: 'todas' };
    const textoNormalizado = texto.toLowerCase();
    return this.tareas.filter((tarea) => {
      const coincideEstado = estado === 'todas' || tarea.estado === estado;
      const coincideTexto = tarea.titulo.toLowerCase().includes(textoNormalizado);
      return coincideEstado && coincideTexto;
    });
  }

  // Mòdul 5 (05-04): no gestiona l'esdeveniment, el reenvia cap amunt amb
  // l'idTarea afegit explícitament, perquè <task-board> no té
  // accés al tancament sobre `tarea.id` que sí que existeix aquí dins del repeat.
  reenviarTareaCambiada(idTarea, event) {
    this.dispatchEvent(
      new CustomEvent('tarea-cambiada', {
        detail: { idTarea, nuevoEstado: event.detail.nuevoEstado },
        bubbles: true,
        composed: true,
      })
    );
  }

  // Mòdul 2 (02-04) / Mòdul 9 (09-03): repeat amb clau estable, perquè
  // el filtre pugui inserir i eliminar targetes sense perdre l'estat
  // intern (com `expandida`) de les que romanen visibles.
  render() {
    return html`
      <section>
        <h2>Mis tareas</h2>
        <div class="lista">
          ${repeat(
            this.tareasFiltradas,
            (tarea) => tarea.id,
            (tarea) => html`
              <task-card
                .titulo="${tarea.titulo}"
                .estado="${tarea.estado}"
                .prioridad="${tarea.prioridad}"
                .urgente="${tarea.urgente}"
                .fechaLimite="${tarea.fechaLimite}"
                asignado-a="${tarea.asignadoA}"
                asignado-imagen="${tarea.asignadoImagen}"
                @tarea-cambiada="${(event) => this.reenviarTareaCambiada(tarea.id, event)}"
              ></task-card>
            `
          )}
        </div>
      </section>
    `;
  }
}

customElements.define('task-list', TaskList);

Un detall que cap lliçó anterior havia mostrat de forma explícita: .fechaLimite="${tarea.fechaLimite}" utilitza el binding de propietat amb punt, no l'atribut fecha-limite amb el conversor de la lliçó 03-03. Quan el valor ja és, en origen, un objecte Date real (com aquí, procedent de tareas-service.js), no cal passar per cap conversió de text: el conversor de <task-card> només entra en joc quan la dada arriba com a atribut HTML, no quan s'assigna directament des de JavaScript, exactament la distinció explicada a la lliçó 03-03 (apartat 2) per als tipus Array i Object.

  1. <task-board>, complet: propietats, context i càrrega inicial

<task-board> combina, en el seu constructor, el mixin de l'apartat 3, el proveïdor de context de la lliçó 07-04, i la càrrega inicial amb until de la lliçó 07-03:

// src/components/task-board.js
import { LitElement, html, css } from 'lit';
import { until } from 'lit/directives/until.js';
import { ContextProvider } from '@lit/context';
import { filtroContext } from '../context/filtro-context.js';
import { ConEstadoCarga } from '../mixins/con-estado-carga.js';
import { cargarTareas } from '../services/tareas-service.js';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './task-filter.js';
import './task-list.js';

class TaskBoard extends ConEstadoCarga(LitElement) {
  // Mòdul 6 (06-04): cada nivell que afegeix les seves pròpies propietats ha
  // de propagar també les heretades del mixin.
  static properties = {
    ...ConEstadoCarga(LitElement).properties,
    tareas: { type: Array },
  };

  constructor() {
    super();
    this.tareas = [];
    this._cargaCompletada = false;

    // Mòdul 7 (07-04): proveïdor del context de filtre compartit amb
    // <task-filter> (lectura i escriptura) i <task-list> (només lectura).
    this._filtroProvider = new ContextProvider(this, {
      context: filtroContext,
      initialValue: {
        texto: '',
        estado: 'todas',
        actualizar: (cambios) => this._actualizarFiltro(cambios),
      },
    });

    // Mòdul 7 (07-03): promesa creada una única vegada en el constructor,
    // mai dins de render(), per no reiniciar la càrrega simulada.
    this._tareasTemplate = cargarTareas().then((tareas) => {
      this.tareas = tareas;
      this._cargaCompletada = true;
      return html`
        <task-list .tareas="${this.tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>
      `;
    });
  }

  _actualizarFiltro(cambios) {
    this._filtroProvider.value = { ...this._filtroProvider.value, ...cambios };
  }

  // Mòdul 5 (05-04): únic punt de l'aplicació que modifica `tareas`,
  // a partir de l'esdeveniment ja reenviat per <task-list> amb `idTarea` inclòs.
  gestionarTareaCambiada(event) {
    const { idTarea, nuevoEstado } = event.detail;
    this.tareas = this.tareas.map((tarea) =>
      tarea.id === idTarea ? { ...tarea, estado: nuevoEstado } : tarea
    );
  }

  renderEsqueleto() {
    return html`
      <div class="esqueleto" aria-busy="true" aria-label="Cargando tareas">
        <div class="esqueleto__linea"></div>
        <div class="esqueleto__linea"></div>
        <div class="esqueleto__linea"></div>
      </div>
    `;
  }

  // ...continua a l'apartat següent amb render() i una peça pendent...
}

  1. Tancant un buit: mantenir <task-list> sincronitzada després de until

Aquí apareix la peça que la lliçó 07-03 va deixar, sense dir-ho explícitament, sense resoldre del tot. until substitueix l'esquelet de càrrega pel resultat de this._tareasTemplate una única vegada, en l'instant en què la promesa es resol; el <task-list> que apareix en aquell moment queda amb .tareas enllaçada al valor de this.tareas que existia exactament en aquell instant. El problema és que <task-board> continua reassignant this.tareas més endavant, cada vegada que gestionarTareaCambiada processa un canvi d'estat —i aquesta reassignació posterior mai no torna a passar per la promesa ja resolta, perquè until, un cop un valor s'ha resolt, no torna a avaluar res mentre la referència de la promesa no canviï. Sense cap ajust addicional, <task-list> es quedaria mostrant, per sempre, la fotografia de tareas presa just després de la càrrega inicial.

La solució no necessita cap concepte nou: updated(), el hook de la lliçó "Hooks Reactius" (06-02), permet actualitzar de forma imperativa la propietat de l'element <task-list> ja inserit al DOM, cada vegada que this.tareas canviï després de la càrrega inicial:

  // Mòdul 6 (06-02): updated() actua després que el DOM ja reflecteixi el
  // canvi; aquí empeny el nou valor de `tareas` cap al <task-list>
  // que `until` va inserir una única vegada, perquè continuï reflectint canvis
  // posteriors com els que arriben mitjançant gestionarTareaCambiada.
  updated(changedProperties) {
    if (changedProperties.has('tareas') && this._cargaCompletada) {
      const listaElemento = this.renderRoot.querySelector('task-list');
      if (listaElemento) {
        listaElemento.tareas = this.tareas;
      }
    }
  }

  render() {
    return html`
      <div class="tablero">
        <h1>TaskFlow</h1>
        <task-filter></task-filter>
        ${until(this._tareasTemplate, this.renderEsqueleto())}
      </div>
    `;
  }

  static styles = [
    estilosCompartidos,
    css`
      .tablero {
        display: flex;
        flex-direction: column;
        gap: 1rem;
        padding: 1rem;
      }

      .esqueleto__linea {
        height: 3rem;
        margin-bottom: 0.5rem;
        border-radius: 4px;
        background: linear-gradient(90deg, #e0e0e0 25%, #ececec 50%, #e0e0e0 75%);
        background-size: 200% 100%;
        animation: pulso 1.4s ease-in-out infinite;
      }

      @keyframes pulso {
        0% { background-position: 200% 0; }
        100% { background-position: -200% 0; }
      }
    `,
  ];
}

customElements.define('task-board', TaskBoard);

El guardià changedProperties.has('tareas') && this._cargaCompletada evita dos casos que no interessa gestionar: abans que la càrrega acabi, <task-list> encara no existeix al DOM (l'esquelet ocupa el seu lloc), de manera que renderRoot.querySelector('task-list') retornaria null; i la pròpia assignació inicial de this.tareas = tareas dins del .then() ja arriba a <task-list> de forma normal, mitjançant el seu propi .tareas="${this.tareas}" en el moment en què until insereix aquesta plantilla per primera vegada, de manera que no cal cap feina addicional d'updated() per a aquest primer cas. Aquest ajust no canvia res del que s'ha explicat a les lliçons 06-02 o 07-03 per separat; simplement aplica la primera per completar un cas que la segona, centrada únicament en la càrrega inicial, encara no necessitava resoldre.

  1. Taula final: què connecta cada parell de components

Parell de components Mecanisme Direcció
<task-board><task-filter> Context de filtre (filtroContext), lectura i escriptura via actualizar Ambdues direccions
<task-board><task-list> Context de filtre (només lectura) + propietat .tareas Context ↑↓, propietat ↓
<task-list><task-board> Esdeveniment tarea-cambiada, amb idTarea afegit Esdeveniment ↑
<task-list><task-card> Propietats per tasca (repeat + clau tarea.id) Propietat ↓
<task-card><task-list> Esdeveniment tarea-cambiada original Esdeveniment ↑
<task-card><user-avatar> Atribut nombre + contingut distribuït (<slot>) Propietat ↓ / slot

Errors Comuns i Consells

  • Esperar que until reaccioni a canvis posteriors de la propietat utilitzada dins del seu valor resolt: com s'ha explicat a l'apartat 7, un cop la promesa passada a until es resol, el valor mostrat queda fixat fins que la pròpia promesa canviï de referència; qualsevol actualització posterior d'una propietat utilitzada dins d'aquell valor resolt necessita un mecanisme a part, com l'updated() d'aquest apartat.
  • Aplicar l'ajust d'updated() sense comprovar que <task-list> ja existeix: sense la comprovació if (listaElemento), qualsevol canvi de tareas que arribés abans que la càrrega inicial acabés (una cosa que no hauria de passar a la pràctica, però que convé protegir) llançaria un error en intentar assignar una propietat sobre null.
  • Oblidar propagar ConEstadoCarga(LitElement).properties en afegir tareas a <task-board>: exactament el mateix risc assenyalat a la lliçó 06-04; sense l'operador de propagació, la propietat cargando del mixin deixaria de registrar-se com a reactiva.
  • Passar fechaLimite com a atribut de text en lloc de com a propietat quan la dada ja és un objecte Date: com s'ha explicat a l'apartat 5, .fechaLimite="${tarea.fechaLimite}" evita una conversió d'anada i tornada innecessària; utilitzar fecha-limite="${...}" obligaria a serialitzar el Date a text només perquè el conversor de <task-card> el reconstruís de nou.

Exercicis

  1. Afegeix a tareas-service.js una quarta tasca amb asignadoImagen no buida, i comprova, seguint el flux de dades de l'apartat 5 i de la lliçó 04-04, que <user-avatar> la mostra com a imatge en lloc de mostrar inicials.
  2. Explica, basant-te en l'apartat 7 i en la lliçó 09-04 (apartat 7, sobre la neteja simètrica de recursos), si updated() a <task-board> necessita algun tipus de neteja a disconnectedCallback, o si, a diferència d'un setInterval, no arrossega cap recurs pendent d'alliberar.
  3. Un company d'equip proposa eliminar el ConEstadoCarga(LitElement) de <task-board>, argumentant que la càrrega de tasques ja utilitza until i no el mixin. Basant-te en l'apartat 3, explica per què conservar-lo continua sent una decisió raonable encara que, en l'estat actual del projecte, cap funcionalitat visible l'estigui utilitzant encara.

Solucions

{
  id: 4,
  titulo: 'Actualizar la documentación de la API',
  estado: 'pendiente',
  prioridad: 1,
  urgente: false,
  asignadoA: 'Marc Puig',
  asignadoImagen: 'https://ejemplo.test/avatares/marc.jpg',
  fechaLimite: null,
},

Amb aquesta tasca a l'array, <task-list> la passa com qualsevol altra cap a una nova <task-card>, el renderAvatar() de la qual (lliçó 04-04, consolidat a 10-02) comprova this.asignadoImagen i, en no estar buit, distribueix un <img> dins de <user-avatar> en lloc de deixar que calculi les inicials de recanvi.

  1. updated() a <task-board> no engega cap recurs persistent (ni setInterval, ni cap subscripció activa): simplement llegeix this.tareas i assigna una propietat sobre un element ja existent, una operació puntual que acaba en el mateix instant en què s'executa. A diferència del temporitzador de la lliçó 06-01 o del controlador de la lliçó 06-03, no hi ha cap recurs "encès" que hagi de "apagar-se" a disconnectedCallback; per tant, no cal cap neteja simètrica addicional en aquest cas.
  2. El mixin continua sent una decisió raonable perquè, com es va explicar a la lliçó 06-04 i es va recordar a l'apartat 3 d'aquesta lliçó, cargando i conIndicadorDeCarga no estan pensats en exclusiva per a la càrrega inicial de tasques (que, efectivament, utilitza until), sinó per a qualsevol operació futura de <task-board> que necessiti comunicar un estat d'espera repetible, com desar canvis o sincronitzar amb un servidor. Retirar el mixin estalviaria, com a molt, una línia de codi; mantenir-lo deixa preparada, sense cap cost real, l'extensibilitat que el propi mòdul 6 va argumentar al seu favor davant d'until per a aquest tipus d'operacions.

Conclusió

Amb aquesta lliçó, TaskFlow queda completament muntat: el context de filtre, el controlador de proximitat de data, el mixin d'estat de càrrega i el servei simulat, tots consolidats en els seus fitxers finals, i <task-board> i <task-list> complets, coordinant-se entre si i amb els tres components de la lliçó anterior exactament com es va traçar en el mapa de la primera lliçó d'aquest mòdul. Pel camí s'ha tancat, amb updated(), una peça que la combinació d'until amb canvis posteriors d'estat deixava pendent des del mòdul 7.

TaskFlow, com a aplicació, ja està completa i funcional de principi a fi. Queda per resoldre com es comprova que continua funcionant amb proves automatitzades, com s'empaqueta per a la seva publicació o desplegament, i com es tanca el curs complet: aquesta és la tasca de l'última lliçó, "Proves, Empaquetatge i Desplegament Final".

Curs de Lit

Mòdul 1: Introducció a Lit i Web Components

Mòdul 2: Plantilles Reactives i Renderitzat

Mòdul 3: Propietats i Estat Reactiu

Mòdul 4: Estils en Components Lit

Mòdul 5: Esdeveniments i Comunicació entre Components

Mòdul 6: Cicle de Vida i Comportament Avançat

Mòdul 7: Directives i Funcionalitats Avançades de Plantilles

Mòdul 8: Integració, Interoperabilitat i Desplegament

Mòdul 9: Proves i Bones Pràctiques

Mòdul 10: Projecte: Construint TaskFlow

© Copyright 2026. Tots els drets reservats