Salta el contingut

logo_header_borja

Solució comentada i pas a pas — Gestor de tasques (exercici intermedi)

Aquest document inclou: 1. Una implementació funcional (HTML + CSS mínim + JavaScript) que satisfà els requisits de l'exercici. 2. Comentaris detallats i explicacions pas a pas per entendre la solució.

Per executar: copia tot el contingut del bloc HTML següent a un fitxer index.html i obre'l en un navegador modern.

<!doctype html>
<html lang="ca">
<head>
  <meta charset="utf-8" />
  <title>Gestor de Tasques — Solució</title>
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <style>
    /* Estil senzill per a demostració */
    body { font-family: Arial, sans-serif; margin: 18px; max-width: 900px; }
    header { margin-bottom: 12px; }
    form { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
    form .full { grid-column: 1 / -1; }
    label { display:block; font-weight:600; margin-bottom:4px; }
    input[type="text"], input[type="date"], select, textarea { width:100%; padding:6px; box-sizing:border-box; }
    button { padding:8px 12px; }
    .controls { display:flex; gap:8px; align-items:center; margin-bottom:12px; flex-wrap:wrap; }
    .stats { margin-bottom:12px; }
    ul.task-list { list-style:none; padding:0; margin:0; }
    li.task { border:1px solid #ddd; padding:8px; margin-bottom:8px; display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
    .meta { font-size:0.9em; color:#444; }
    .tags { margin-top:6px; font-size:0.85em; color:#006; }
    .completed { background:#f0fdf0; text-decoration:line-through; opacity:0.9; }
    footer { margin-top:18px; font-size:0.9em; color:#666; }
    .priority-high { color: #c00; font-weight:700; }
    .priority-medium { color: #e67e22; font-weight:600; }
    .priority-low { color: #2d9f4a; font-weight:600; }
  </style>
</head>
<body>
  <header>
    <h1>Gestor de Tasques — Solució</h1>
    <p>Exercici intermedi: exemples d'ús de bucles, condicionals, switch/case, arrays, DOM, objectes i esdeveniments.</p>
  </header>

  <!-- Formulari per a noves tasques -->
  <form id="task-form" autocomplete="off">
    <div class="full">
      <label for="title">Títol *</label>
      <input id="title" name="title" type="text" required />
    </div>

    <div class="full">
      <label for="description">Descripció</label>
      <textarea id="description" name="description" rows="2"></textarea>
    </div>

    <div>
      <label for="dueDate">Data de venciment</label>
      <input id="dueDate" name="dueDate" type="date" />
    </div>

    <div>
      <label for="priority">Prioritat</label>
      <select id="priority" name="priority">
        <option value="medium" selected>Medium</option>
        <option value="high">High</option>
        <option value="low">Low</option>
      </select>
    </div>

    <div class="full">
      <label for="tags">Etiquetes (separades per comes)</label>
      <input id="tags" name="tags" type="text" placeholder="ex: feina, urgent" />
    </div>

    <div class="full">
      <button type="submit">Afegir tasca</button>
      <button id="load-sample" type="button">Carregar mostres</button>
      <button id="clear-all" type="button">Esborrar totes</button>
    </div>
  </form>

  <!-- Controls de filtrat/ordenació -->
  <div class="controls">
    <label>
      Filtrar estat:
      <select id="filter-state">
        <option value="all">Totes</option>
        <option value="pending">Pendents</option>
        <option value="completed">Completades</option>
      </select>
    </label>

    <label>
      Filtrar prioritat:
      <select id="filter-priority">
        <option value="all">Totes</option>
        <option value="high">High</option>
        <option value="medium">Medium</option>
        <option value="low">Low</option>
      </select>
    </label>

    <label>
      Ordenar per:
      <select id="sort-by">
        <option value="dueDateAsc">Data venciment ↑</option>
        <option value="dueDateDesc">Data venciment ↓</option>
        <option value="priority">Prioritat (High → Low)</option>
      </select>
    </label>

    <label>
      Cerca (títol/descripció):
      <input id="search" type="text" placeholder="cercar..." />
    </label>
  </div>

  <!-- Estadístiques -->
  <div class="stats" id="stats">
    <!-- Actualitzat per JavaScript -->
  </div>

  <!-- Llista de tasques -->
  <section>
    <ul id="taskList" class="task-list"></ul>
  </section>

  <footer>
    <p>Solució amb comentaris i explicacions pas a pas inclosos al codi.</p>
  </footer>

<script>
/*
  Solució comentada:
  - Classe Task: model per a cada tasca.
  - Classe TaskManager: encapsula la llista de tasques i operacions.
  - DOM interactions: event listeners per formulari i controls.
  - Re-render: cada canvi re-calcula i actualitza la vista i les estadístiques.
*/

// ======= Helpers i utilitats =======

// Genera un id únic senzill
const generateId = () => Date.now().toString(36) + Math.random().toString(36).slice(2,8);

// Normalitza etiquetes: separa per comes, neteja espais i elimina buits i duplicats
function parseTags(tagsString) {
  if (!tagsString) return [];
  const parts = tagsString.split(',').map(t => t.trim()).filter(Boolean);
  return [...new Set(parts)]; // elimina duplicats
}

// Mapeig de prioritat a valor numèric per ajudar a ordenar
function priorityValue(priority) {
  switch (priority) {
    case 'high': return 3;
    case 'medium': return 2;
    case 'low': return 1;
    default: return 0;
  }
}

// Format de data legible (gestiona dates null/indefinides)
function formatDate(dateISO) {
  if (!dateISO) return '-';
  const d = new Date(dateISO);
  if (isNaN(d)) return '-';
  return d.toLocaleDateString();
}

// ======= Classes de negoci =======

// Classe que representa una tasca
class Task {
  constructor({ id = generateId(), title, description = '', dueDate = null, priority = 'medium', completed = false, tags = [] }) {
    this.id = id;
    this.title = title;
    this.description = description;
    this.dueDate = dueDate; // guardar com ISO (YYYY-MM-DD) per facilitar ordenació i persistència
    this.priority = priority; // 'low' | 'medium' | 'high'
    this.completed = completed;
    this.tags = tags;
    this.createdAt = new Date().toISOString();
  }
}

// Gestor que manté l'array de tasques i opera sobre ell
class TaskManager {
  constructor(storageKey = 'task_manager_tasks_v1') {
    this.tasks = [];
    this.storageKey = storageKey;
    this.loadFromStorage();
  }

  addTask(taskData) {
    const task = taskData instanceof Task ? taskData : new Task(taskData);
    this.tasks.push(task);
    this.saveToStorage();
    return task;
  }

  removeTask(taskId) {
    this.tasks = this.tasks.filter(t => t.id !== taskId);
    this.saveToStorage();
  }

  toggleCompleted(taskId) {
    for (let t of this.tasks) {
      if (t.id === taskId) {
        t.completed = !t.completed;
        break;
      }
    }
    this.saveToStorage();
  }

  updateTask(taskId, patch) {
    for (let t of this.tasks) {
      if (t.id === taskId) {
        Object.assign(t, patch);
        break;
      }
    }
    this.saveToStorage();
  }

  // Filtrat i ordenació segons criteris passats; retorna una nova array
  query({ state = 'all', priority = 'all', search = '', sortBy = 'dueDateAsc' } = {}) {
    // normalitzar text de cerca
    const q = search.trim().toLowerCase();

    let result = this.tasks;

    // Filtrar per estat
    if (state === 'pending') result = result.filter(t => !t.completed);
    else if (state === 'completed') result = result.filter(t => t.completed);

    // Filtrar per prioritat
    if (priority === 'high' || priority === 'medium' || priority === 'low') {
      result = result.filter(t => t.priority === priority);
    }

    // Cerca simple per títol o descripció
    if (q) {
      result = result.filter(t => {
        return (t.title && t.title.toLowerCase().includes(q)) ||
               (t.description && t.description.toLowerCase().includes(q));
      });
    }

    // Ordenació
    if (sortBy === 'dueDateAsc') {
      result = result.slice().sort((a,b) => {
        if (!a.dueDate && !b.dueDate) return 0;
        if (!a.dueDate) return 1;
        if (!b.dueDate) return -1;
        // compara strings ISO directament
        return a.dueDate.localeCompare(b.dueDate);
      });
    } else if (sortBy === 'dueDateDesc') {
      result = result.slice().sort((a,b) => {
        if (!a.dueDate && !b.dueDate) return 0;
        if (!a.dueDate) return 1;
        if (!b.dueDate) return -1;
        return b.dueDate.localeCompare(a.dueDate);
      });
    } else if (sortBy === 'priority') {
      result = result.slice().sort((a,b) => priorityValue(b.priority) - priorityValue(a.priority));
    }

    return result;
  }

  // Estadístiques amb reduce i Map
  stats() {
    const total = this.tasks.length;
    const completed = this.tasks.filter(t => t.completed).length;
    const pending = total - completed;
    const percentCompleted = total === 0 ? 0 : Math.round((completed / total) * 100);

    // nombre per prioritat
    const perPriority = this.tasks.reduce((acc, t) => {
      acc[t.priority] = (acc[t.priority] || 0) + 1;
      return acc;
    }, {});

    return { total, completed, pending, percentCompleted, perPriority };
  }

  saveToStorage() {
    try {
      localStorage.setItem(this.storageKey, JSON.stringify(this.tasks));
    } catch (e) {
      console.warn('No s\'ha pogut guardar a localStorage', e);
    }
  }

  loadFromStorage() {
    try {
      const raw = localStorage.getItem(this.storageKey);
      if (raw) {
        const arr = JSON.parse(raw);
        // reconvertir a instàncies Task si cal (opc)
        this.tasks = arr.map(o => new Task(o));
      }
    } catch (e) {
      console.warn('No s\'ha pogut carregar de localStorage', e);
      this.tasks = [];
    }
  }

  clearAll() {
    this.tasks = [];
    this.saveToStorage();
  }
}

// ======= Instància i DOM =========

const manager = new TaskManager();

// Elements DOM
const form = document.getElementById('task-form');
const titleInput = document.getElementById('title');
const descInput = document.getElementById('description');
const dateInput = document.getElementById('dueDate');
const priorityInput = document.getElementById('priority');
const tagsInput = document.getElementById('tags');

const taskList = document.getElementById('taskList');
const statsEl = document.getElementById('stats');

const filterState = document.getElementById('filter-state');
const filterPriority = document.getElementById('filter-priority');
const sortBy = document.getElementById('sort-by');
const searchInput = document.getElementById('search');

const loadSampleBtn = document.getElementById('load-sample');
const clearAllBtn = document.getElementById('clear-all');

// Estat de la UI (mantenir valors actuals de filtratge/ordenació)
let uiState = {
  state: filterState.value,
  priority: filterPriority.value,
  sortBy: sortBy.value,
  search: searchInput.value
};

// RENDER: re-renderitzar la llista segons l'estat
function render() {
  // Obtenir tasques segons filtres i ordre actual
  const tasksToShow = manager.query(uiState);

  // Netejar llistat DOM
  taskList.innerHTML = '';

  // Recorrem amb for...of (demostrem bucle) i creem elements DOM
  for (const task of tasksToShow) {
    const li = document.createElement('li');
    li.className = 'task' + (task.completed ? ' completed' : '');
    li.dataset.id = task.id;

    // Contingut esquerre
    const left = document.createElement('div');
    left.style.flex = '1';

    const title = document.createElement('div');
    title.innerHTML = `<strong>${escapeHtml(task.title)}</strong> `;
    left.appendChild(title);

    const meta = document.createElement('div');
    meta.className = 'meta';
    meta.innerHTML = `Venciment: ${formatDate(task.dueDate)} — Prioritat: <span class="priority-${task.priority}">${task.priority}</span>`;
    left.appendChild(meta);

    if (task.description) {
      const p = document.createElement('p');
      p.textContent = task.description;
      left.appendChild(p);
    }

    if (task.tags && task.tags.length) {
      const t = document.createElement('div');
      t.className = 'tags';
      t.textContent = 'Etiquetes: ' + task.tags.join(', ');
      left.appendChild(t);
    }

    // Contingut dret (accions)
    const right = document.createElement('div');
    right.style.display = 'flex';
    right.style.flexDirection = 'column';
    right.style.gap = '6px';

    const toggleBtn = document.createElement('button');
    toggleBtn.textContent = task.completed ? 'Marcar pend.' : 'Marcar com a compl.';
    toggleBtn.addEventListener('click', () => {
      manager.toggleCompleted(task.id);
      render();
    });
    right.appendChild(toggleBtn);

    const delBtn = document.createElement('button');
    delBtn.textContent = 'Eliminar';
    delBtn.addEventListener('click', () => {
      if (confirm('Segur que vols eliminar aquesta tasca?')) {
        manager.removeTask(task.id);
        render();
      }
    });
    right.appendChild(delBtn);

    li.appendChild(left);
    li.appendChild(right);

    taskList.appendChild(li);
  }

  // Actualitzar estadístiques
  updateStats();
}

// Evitem injecció: simple escapada per text pla (més senzill que innerHTML directe)
function escapeHtml(text = '') {
  return text.replace(/[&<>"']/g, function(m) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]); });
}

// Actualitzar estadístiques a la interfície
function updateStats() {
  const s = manager.stats();
  // Mostrar nombre per prioritat utilitzant Map-like (object)
  const perPriority = s.perPriority;
  statsEl.innerHTML = `
    <strong>Total:</strong> ${s.total}
    <strong>Pendents:</strong> ${s.pending}
    <strong>Completades:</strong> ${s.completed}
    <strong>% Completat:</strong> ${s.percentCompleted}% <br />
    <strong>Per prioritat:</strong>
     High: ${perPriority.high || 0},
     Medium: ${perPriority.medium || 0},
     Low: ${perPriority.low || 0}
  `;
}

// ======= Gestors d'esdeveniments =======

// Enviar formulari: crear i afegir nova tasca
form.addEventListener('submit', (e) => {
  e.preventDefault();

  const title = titleInput.value.trim();
  if (!title) {
    alert('El títol és obligatori.');
    return;
  }

  const description = descInput.value.trim();
  const dueDate = dateInput.value ? dateInput.value : null; // ISO YYYY-MM-DD o null
  const priority = priorityInput.value;
  const tags = parseTags(tagsInput.value);

  // Construir objecte i afegir
  manager.addTask({ title, description, dueDate, priority, tags });
  // netejar formulari
  form.reset();
  // restaurar valor per defecte de priority (reset va fer que torni a medium segons HTML)
  priorityInput.value = 'medium';
  render();
});

// Controls de filtrat i ordenació: actualitzen l'uiState i fan render
filterState.addEventListener('change', () => { uiState.state = filterState.value; render(); });
filterPriority.addEventListener('change', () => { uiState.priority = filterPriority.value; render(); });
sortBy.addEventListener('change', () => { uiState.sortBy = sortBy.value; render(); });
searchInput.addEventListener('input', () => { uiState.search = searchInput.value; render(); });

// Botó per carregar mostres (ajuda a provar l'aplicació)
loadSampleBtn.addEventListener('click', () => {
  const sample = [
    { title: 'Enviar informe', description: 'Informe trimestral', dueDate: tomorrowISO(2), priority: 'high', tags: ['feina','urgent'] },
    { title: 'Compres', description: '', dueDate: null, priority: 'low', tags: ['personal'] },
    { title: 'Estudiar JS', description: 'Repassar closures i promeses', dueDate: tomorrowISO(7), priority: 'medium', tags: ['estudi'] },
    { title: 'Reunió amb equip', description: 'Repasar backlog', dueDate: tomorrowISO(1), priority: 'high', tags: ['feina','meeting'] },
  ];
  // Afegim si no existeixen (evita duplicats per múltiples clics)
  for (const s of sample) {
    manager.addTask(s);
  }
  render();
});

// Botó esborrar tot
clearAllBtn.addEventListener('click', () => {
  if (confirm('Esborrar totes les tasques?')) {
    manager.clearAll();
    render();
  }
});

// Helper per dates: retornen ISO de demà + n dies
function tomorrowISO(days = 1) {
  const d = new Date();
  d.setDate(d.getDate() + days);
  return d.toISOString().slice(0,10); // YYYY-MM-DD
}

// Inicialitzar: renderitzar si ja hi ha dades a localStorage
render();

/* 
  Això cobreix les funcionalitats principals. A continuació afegim una funció
  addicional que demostra l'ús de switch/case per tractar accions diverses.
*/

// Exemple d'ús de switch/case: tractament d'accions de menú (pot ser estès)
function handleAction(action, payload) {
  switch(action) {
    case 'filter-state':
      uiState.state = payload || 'all';
      break;
    case 'filter-priority':
      uiState.priority = payload || 'all';
      break;
    case 'sort':
      uiState.sortBy = payload || 'dueDateAsc';
      break;
    case 'clear':
      manager.clearAll();
      break;
    default:
      console.warn('Acció desconeguda:', action);
  }
  render();
}

// Exemple: es pot invocar: handleAction('filter-priority', 'high');

</script>
</body>
</html>

Explicació pas a pas (resum de la solució) 1. Modelització (Task, TaskManager) - S'ha creat la classe Task per encapsular les propietats d'una tasca. Això facilita la creació d'objectes homogènis i la conversió al desar/recuperar. - TaskManager conté l'array tasks i totes les operacions sobre ell: afegir, eliminar, toggle completat, actualitzar, filtrar, ordenar, guardar i carregar de localStorage. Així se separa la lògica de dades de la presentació.

  1. Manipulació d'arrays - Afegir: this.tasks.push(task). - Eliminar: filter per obtenir una nova array sense l'element. - Filtrar: filter segons estat, prioritat i cerca. - Ordenar: sort sobre còpies (slice()) per evitar modificar l'array original no ordenat. - Estadístiques: reduce s'utilitza per obtenir el comptador per prioritat.

  2. Esdeveniments i DOM - addEventListener al formulari per interceptar submit i crear una tasca nova. - Controls per filtrar/ordenar tenen change o input per actualitzar l'estat (uiState) i re-renderitzar la vista. - La vista es re-renderitza completament cridant render(), que consulta manager.query(uiState) i construeix elements DOM amb createElement. Això evita problemes de sincronització i demostra bucles (for...of) per crear la llista.

  3. Funcions natives i pròpies - Funcions d'array natives: map, filter, reduce, forEach, sort s'utilitzen en diferents punts. - Funcions pròpies d'utilitat: generateId, parseTags, formatDate, escapeHtml. - switch/case s'utilitza dins handleAction per processar accions (exigència de l'enunciat).

  4. Objectes nadius - Date s'utilitza per generar IDs relacionats amb temps i per formatar dates (toLocaleDateString()). - localStorage s'utilitza per persistència (s'erialitza amb JSON.stringify).

  5. Validacions bàsiques - Es comprova que el títol no estigui buit abans d'afegir una tasca. Es poden afegir més validacions (p. ex. data no anterior a avui).

Notes finals i possibles millores - Aquesta implementació re-renderitza tota la llista en cada canvi: per a llistes molt llargues, convé actualitzar només l'element afectat per millorar rendiment. - Per a una interfície més rica, es podria afegir edició inline, paginació, o ordenar per múltiples criteris. - Les funcions estan comentades per ajudar a seguir la lògica. L'arquitectura suggereix separar el codi en fitxers (HTML/JS/CSS) en projectes reals. - A nivell d'avaluació, es pot demanar a l'estudiant que comenti priorityValue o query per mostrar domini de switch/sort/filter/map.

Si vols, puc: - proporcionar una versió amb proves automàtiques (unit tests) per a algunes funcions (per exemple parseTags, priorityValue, els mètodes de TaskManager), - o dividir el codi en fitxers separats (app.js, styles.css) i preparar un repositori d'exemple.

Vols que generi també una versió amb tests o que transformi la solució en un paquet per pujar a un repo?