Skip to content

prueba1 #93

@creativeimpactx-arch

Description

@creativeimpactx-arch

This script creates a ready-to-deploy PWA bundle with a simple, functional

housekeeping gamified app. It then zips the folder so the user can download

and upload it to Netlify Drop or any static hosting to install as a PWA.

import os, json, zipfile, textwrap, io
from PIL import Image, ImageDraw, ImageFont

base = "/mnt/data/limpieza-pwa"
icons_dir = os.path.join(base, "icons")
os.makedirs(icons_dir, exist_ok=True)

1) Create icons 192 and 512

def make_icon(size, filename):
img = Image.new("RGBA", (size, size), (79, 70, 229, 255)) # #4F46E5
draw = ImageDraw.Draw(img)
# Draw a simple white "L+"
text = "L+"
# Use default font (no internet to load custom). Try to fit roughly.
# We'll center text based on font measurement using a default font size proportionally
font_size = int(size * 0.55)
try:
font = ImageFont.truetype("DejaVuSans-Bold.ttf", font_size)
except:
font = ImageFont.load_default()
w, h = draw.textbbox((0,0), text, font=font)[2:]
draw.text(((size - w)//2, (size - h)//2), text, fill="white", font=font)
img.save(os.path.join(icons_dir, filename))

make_icon(192, "icon-192.png")
make_icon(512, "icon-512.png")

2) Create manifest.json

manifest = {
"short_name": "Limpieza+",
"name": "App Gamificada de Limpieza",
"icons": [
{"src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png"}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#4F46E5",
"background_color": "#FFFFFF"
}
with open(os.path.join(base, "manifest.json"), "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)

3) Create service-worker.js (cache-first basic)

service_worker = """
const CACHE_NAME = "limpieza-pwa-v1";
const URLS_TO_CACHE = ["/", "/index.html", "/manifest.json", "/app.js"];

self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(URLS_TO_CACHE))
);
});

self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
))
);
});

self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});
"""
with open(os.path.join(base, "service-worker.js"), "w", encoding="utf-8") as f:
f.write(service_worker.strip())

4) Create index.html with simple UI and PWA registration

index_html = """
<!doctype html>

<title>Limpieza+</title> <style> :root { --primary: #4F46E5; --bg: #ffffff; --text: #111827; --muted:#6b7280; } * { box-sizing: border-box; } body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; background: var(--bg); color: var(--text); } header { position: sticky; top: 0; z-index: 10; background: var(--bg); border-bottom: 1px solid #e5e7eb; } .bar { display:flex; align-items:center; justify-content:space-between; padding: 12px 14px; } .brand { font-weight: 800; letter-spacing: .2px; } .pill { background: #eef2ff; color: var(--primary); padding: 4px 8px; border-radius: 999px; font-size: 12px; } .tabs { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; padding: 10px; } .tab-btn { padding:10px; border-radius:12px; background:#f3f4f6; border: none; font-weight:600; } .tab-btn.active { background: var(--primary); color:white; } main { padding: 12px; max-width: 900px; margin: 0 auto; } .card { background:white; border:1px solid #e5e7eb; border-radius:16px; padding:14px; margin-bottom:14px; box-shadow: 0 1px 0 rgba(0,0,0,.02); } .row { display:flex; gap: 10px; flex-wrap: wrap; } .col { flex:1 1 250px; } button.primary { background: var(--primary); color: white; border: none; border-radius: 12px; padding: 10px 14px; font-weight:700; } button.ghost { background:#f3f4f6; color:#111827; border: none; border-radius: 12px; padding: 10px 14px; font-weight:700; } select, input, textarea { width:100%; padding:10px; border-radius:10px; border:1px solid #e5e7eb; background:white; } h2 { font-size: 18px; margin:6px 0 12px; } h3 { font-size: 16px; margin:0 0 8px; } small { color: var(--muted); } .grid { display:grid; gap:10px; } .grid-2 { grid-template-columns: repeat(2, 1fr); } .grid-3 { grid-template-columns: repeat(3, 1fr); } .chip { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; background:#F9FAFB; border:1px solid #e5e7eb; border-radius:999px; font-size:12px; } .bar-fill { height: 12px; background:#EEF2FF; border-radius:999px; overflow:hidden; } .bar-fill > b { display:block; height:100%; background: var(--primary); width:0%; transition: width .4s ease; } .kpi { font-weight:800; font-size: 22px; } .muted { color:#6b7280; } .footer-space { height: 56px; } nav.bottom { position: fixed; bottom: 8px; left: 0; right: 0; padding:8px 12px; } nav.bottom .wrap { display:flex; gap:8px; max-width:900px; margin:0 auto; } nav.bottom button { flex:1; } .day { padding:6px; border: 1px solid #e5e7eb; border-radius: 10px; text-align:center; } .day.done { background:#ECFDF5; border-color:#10B981; } .tag { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: #F3F4F6; } .tag.low { background:#E0F2FE; } .tag.med { background:#FEF3C7; } .tag.high { background:#FEE2E2; } </style>
Limpieza+
Pareja · Puntos · Rutinas
Hoy Semana Mes Tablero Tareas Recompensas Estancias Ajustes
Perfil Exportar Ayuda
<script> // --- Simple state in localStorage --- const todayStr = () => new Date().toISOString().slice(0,10); const weekStart = (d = new Date()) => { const date = new Date(d); const day = (date.getDay()+6)%7; // Monday-based date.setDate(date.getDate() - day); return date.toISOString().slice(0,10); }; const monthStr = (d = new Date()) => d.toISOString().slice(0,7); const defaultState = () => ({ profiles: [ { id: 'p1', name: 'Persona A', points: 0, streak: 0 }, { id: 'p2', name: 'Persona B', points: 0, streak: 0 } ], activeProfileId: 'p1', rooms: [ { id: 'r1', name: 'Dormitorio' }, { id: 'r2', name: 'Cocina' }, { id: 'r3', name: 'Baño' }, { id: 'r4', name: 'Salón' } ], tasks: [ { id: 't1', title: 'Hacer la cama', energy:'low', roomId:'r1', type:'D', points:1 }, { id: 't2', title: 'Recoger ropa', energy:'low', roomId:'r1', type:'D', points:1 }, { id: 't3', title: 'Fregar platos', energy:'med', roomId:'r2', type:'D', points:2 }, { id: 't4', title: 'Baño rápido', energy:'med', roomId:'r3', type:'D', points:2 }, { id: 't5', title: 'Ventanas', energy:'high', roomId:'r4', type:'F', points:3 }, { id: 't6', title: 'Ducha a fondo', energy:'high', roomId:'r3', type:'F', points:3 } ], rewards: [ { id:'rw1', title:'Elegir peli', cost:10 }, { id:'rw2', title:'Cena especial', cost:20 }, { id:'rw3', title:'Día libre', cost:30 } ], couple: { jointGoal: 100, challenges: [ { id:'c1', kind:'coop', title:'Limpiar todas las estancias esta semana', active:true }, { id:'c2', kind:'comp', title:'Gana la semana y eliges plan', active:true } ] }, settings: { notify: false, notifyTime: '21:00' }, answers: null, logs: [] // {date, profileId, taskId, energy, points} }); const loadState = () => { try { return JSON.parse(localStorage.getItem('lpwa_state')) || defaultState(); } catch(e){ return defaultState(); } }; const saveState = () => localStorage.setItem('lpwa_state', JSON.stringify(state)); let state = loadState(); const $ = sel => document.querySelector(sel); const $$ = sel => Array.from(document.querySelectorAll(sel)); function setActiveTab(tab) { state.uiTab = tab; $$('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); render(); } function selectProfile(id) { state.activeProfileId = id; saveState(); render(); } function activeProfile(){ return state.profiles.find(p => p.id === state.activeProfileId) } // --- Suggest task by energy --- function suggestTask(energy){ const tasks = state.tasks.filter(t => t.energy === energy); if (tasks.length === 0) return null; // Prefer tasks in rooms not done today by this profile const today = todayStr(); const doneTaskIdsToday = new Set(state.logs.filter(l => l.profileId===state.activeProfileId && l.date===today).map(l => l.taskId)); const candidates = tasks.filter(t => !doneTaskIdsToday.has(t.id)); const pick = candidates.length ? candidates[Math.floor(Math.random()*candidates.length)] : tasks[Math.floor(Math.random()*tasks.length)]; return pick; } function completeTask(task, energy){ const prof = activeProfile(); const date = todayStr(); state.logs.push({ date, profileId: prof.id, taskId: task.id, energy, points: task.points }); // points & streak prof.points += task.points; const didYesterday = state.logs.some(l => l.profileId===prof.id && l.date === new Date(Date.now()-86400000).toISOString().slice(0,10)); if (!state.logs.some(l => l.profileId===prof.id && l.date===date)) { // first log today for this profile, treat as streak increment prof.streak = didYesterday ? (prof.streak+1) : 1; } saveState(); render(); } function formatTime(hhmm){ const [h,m]=hhmm.split(':').map(n=>parseInt(n,10)); const d = new Date(); d.setHours(h); d.setMinutes(m); d.setSeconds(0); return d; } // Notifications (only when app abierta) let notifyTimer = null; function setupNotifications(){ if (!('Notification' in window)) return; if (state.settings.notify){ Notification.requestPermission().then(res => { if (res!=='granted') return; if (notifyTimer) clearInterval(notifyTimer); notifyTimer = setInterval(()=>{ const now = new Date(); const target = formatTime(state.settings.notifyTime); if (now.getHours()===target.getHours() && now.getMinutes()===target.getMinutes()){ new Notification('Limpieza+', { body: '¿Tu nivel de energía hoy? ¡Suma puntos fáciles!', icon:'icons/icon-192.png' }); } }, 60000); }); } else { if (notifyTimer) clearInterval(notifyTimer); } } // CSV export function exportCSV(){ const headers = ['date','profile','task','energy','points']; const rows = state.logs.map(l => { const p = state.profiles.find(x=>x.id===l.profileId)?.name || ''; const t = state.tasks.find(x=>x.id===l.taskId)?.title || ''; return [l.date, p, t, l.energy, l.points]; }); const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'limpieza_logs.csv'; a.click(); URL.revokeObjectURL(url); } // Print to PDF (via browser print) function exportPDF(){ const w = window.open('', '_blank'); const prof = activeProfile(); const total = state.logs.filter(l=>l.profileId===prof.id).reduce((a,b)=>a+b.points,0); const html = ` <title>Informe Limpieza+</title>

Informe de progreso - ${prof.name}

Total puntos: ${total}

Historial

    ${state.logs.filter(l=>l.profileId===prof.id).map(l=>{ const t= state.tasks.find(x=>x.id===l.taskId)?.title || ''; return '
  • '+l.date+' - '+t+' ('+l.energy+') +'+l.points+' pts
  • ' }).join('')}
<script>window.onload=()=>window.print();</script>
    </body></html>`;
  w.document.write(html);
  w.document.close();
}

// Calendar helpers
function getMonthDays(year, monthIdx){ // monthIdx 0..11
  const first = new Date(year, monthIdx, 1);
  const last = new Date(year, monthIdx+1, 0).getDate();
  const days = [];
  for (let d=1; d<=last; d++) days.push(new Date(year, monthIdx, d));
  return days;
}

// UI renderers
function viewHoy(){
  return `
    <div class="card">
      <h2>¿Cómo está tu energía hoy?</h2>
      <div class="row">
        <button class="primary" data-energy="low">Baja</button>
        <button class="primary" data-energy="med">Media</button>
        <button class="primary" data-energy="high">Alta</button>
      </div>
      <div id="suggestion" style="margin-top:12px;"></div>
    </div>
    <div class="card">
      <h3>Racha: <span class="kpi">${activeProfile().streak}</span> · Puntos: <span class="kpi">${activeProfile().points}</span></h3>
      <small class="muted">Mantén constancia para multiplicar tus puntos.</small>
    </div>
  `;
}

function attachHoyEvents(){
  ['low','med','high'].forEach(level => {
    document.querySelector(`button[data-energy="${level}"]`)?.addEventListener('click', () => {
      const t = suggestTask(level);
      const box = $('#suggestion');
      if (!t){ box.innerHTML = '<small class="muted">No hay tareas configuradas para este nivel.</small>'; return; }
      box.innerHTML = `
        <div class="card">
          <h3>Tarea sugerida</h3>
          <p><b>${t.title}</b> · <span class="tag ${t.energy}">${t.energy}</span> · <span class="tag">${t.type}</span></p>
          <div class="row">
            <button class="primary" id="doTask">Marcar como hecha (+${t.points})</button>
            <button class="ghost" id="other">Sugerir otra</button>
          </div>
        </div>`;
      $('#doTask').addEventListener('click', ()=>{ completeTask(t, level); });
      $('#other').addEventListener('click', ()=>{ document.querySelector(`button[data-energy="${level}"]`).click(); });
    });
  });
}

function viewSemana(){
  const start = new Date(weekStart());
  const days = Array.from({length:7}, (_,i)=>{
    const d = new Date(start); d.setDate(start.getDate()+i);
    const ds = d.toISOString().slice(0,10);
    const done = state.logs.some(l => l.profileId===state.activeProfileId && l.date===ds);
    return { d, done };
  });
  const items = days.map(o=>{
    const label = o.d.toLocaleDateString('es-ES', { weekday:'short', day:'2-digit' });
    return `<div class="day ${o.done?'done':''}">${label}</div>`;
  }).join('');
  return `
    <div class="card">
      <h2>Semana</h2>
      <div class="grid grid-7" style="grid-template-columns: repeat(7, 1fr); gap:8px;">${items}</div>
    </div>
  `;
}

function viewMes(){
  const now = new Date();
  const days = getMonthDays(now.getFullYear(), now.getMonth());
  const items = days.map(d=>{
    const ds = d.toISOString().slice(0,10);
    const done = state.logs.some(l => l.profileId===state.activeProfileId && l.date===ds);
    return `<div class="day ${done?'done':''}">${d.getDate()}</div>`;
  }).join('');
  return `
    <div class="card">
      <h2>${now.toLocaleDateString('es-ES', { month:'long', year:'numeric' })}</h2>
      <div class="grid" style="grid-template-columns: repeat(7, 1fr); gap:8px;">${items}</div>
    </div>
  `;
}

function viewTablero(){
  // ranking semanal y mensual
  const week = weekStart();
  const month = monthStr();
  function pointsInRange(profileId, filterFn){
    return state.logs.filter(l=>l.profileId===profileId && filterFn(l.date)).reduce((a,b)=>a+b.points,0);
  }
  const weekly = state.profiles.map(p => ({name:p.name, pts: pointsInRange(p.id, d=>d>=week)}));
  const monthly = state.profiles.map(p => ({name:p.name, pts: pointsInRange(p.id, d=>d.startsWith(month))}));
  const rankW = weekly.sort((a,b)=>b.pts-a.pts).map((r,i)=>`<div>${i+1}. <b>${r.name}</b> — ${r.pts} pts</div>`).join('');
  const rankM = monthly.sort((a,b)=>b.pts-a.pts).map((r,i)=>`<div>${i+1}. <b>${r.name}</b> — ${r.pts} pts</div>`).join('');

  // progreso conjunto hacia meta
  const totalPts = state.profiles.reduce((acc,p)=>acc+p.points,0);
  const pct = Math.min(100, Math.round((totalPts/state.couple.jointGoal)*100));
  return `
    <div class="card">
      <h2>Ranking semanal</h2>
      ${rankW || '<small class="muted">Sin datos aún.</small>'}
    </div>
    <div class="card">
      <h2>Ranking mensual</h2>
      ${rankM || '<small class="muted">Sin datos aún.</small>'}
    </div>
    <div class="card">
      <h2>Meta conjunta: ${state.couple.jointGoal} pts</h2>
      <div class="bar-fill"><b style="width:${pct}%"></b></div>
      <small class="muted">Total actual: ${totalPts} pts</small>
    </div>
    <div class="card">
      <h2>Desafíos</h2>
      <div>✅ Coop: ${state.couple.challenges.find(c=>c.id==='c1').title}</div>
      <div>🏁 Comp: ${state.couple.challenges.find(c=>c.id==='c2').title}</div>
    </div>
    <div class="card">
      <div class="row">
        <button class="ghost" id="btnCSV">Exportar CSV</button>
        <button class="ghost" id="btnPDF">Exportar PDF</button>
      </div>
    </div>
  `;
}

function attachTablero(){
  $('#btnCSV')?.addEventListener('click', exportCSV);
  $('#btnPDF')?.addEventListener('click', exportPDF);
}

function viewTareas(){
  const roomOptions = state.rooms.map(r=>`<option value="${r.id}">${r.name}</option>`).join('');
  const items = state.tasks.map(t=>{
    const room = state.rooms.find(r=>r.id===t.roomId)?.name || '';
    return `<div class="card">
      <b>${t.title}</b>
      <div class="row"><span class="chip"><small>Energía</small> ${t.energy}</span><span class="chip"><small>Estancia</small> ${room}</span><span class="chip"><small>Tipo</small> ${t.type}</span><span class="chip"><small>Pts</small> ${t.points}</span></div>
      <div class="row"><button class="ghost" data-del="${t.id}">Eliminar</button></div>
    </div>`
  }).join('');
  return `
    <div class="card">
      <h2>Nueva tarea</h2>
      <div class="grid grid-3">
        <input id="tTitle" placeholder="Nombre de la tarea">
        <select id="tEnergy">
          <option value="low">Baja</option>
          <option value="med">Media</option>
          <option value="high">Alta</option>
        </select>
        <select id="tRoom">${roomOptions}</select>
        <select id="tType">
          <option value="D">Diaria</option>
          <option value="F">Fondo</option>
        </select>
        <input id="tPoints" type="number" min="1" value="1" placeholder="Puntos">
        <button class="primary" id="addTask">Añadir</button>
      </div>
    </div>
    ${items || '<div class="card"><small class="muted">Aún no hay tareas.</small></div>'}
  `;
}

function attachTareas(){
  $('#addTask')?.addEventListener('click', ()=>{
    const title = $('#tTitle').value.trim();
    const energy = $('#tEnergy').value;
    const roomId = $('#tRoom').value;
    const type = $('#tType').value;
    const points = Math.max(1, parseInt($('#tPoints').value||'1',10));
    if (!title) return alert('Pon un nombre');
    state.tasks.push({ id: 't'+(Date.now()), title, energy, roomId, type, points });
    saveState(); render();
  });
  $$('#view [data-del]').forEach(btn => {
    btn.addEventListener('click', ()=>{
      const id = btn.getAttribute('data-del');
      state.tasks = state.tasks.filter(t=>t.id!==id);
      saveState(); render();
    });
  });
}

function viewRecompensas(){
  const items = state.rewards.map(r=>`<div class="card">
    <div class="row" style="align-items:center; justify-content:space-between;">
      <div><b>${r.title}</b><br><small class="muted">${r.cost} pts</small></div>
      <div><button class="primary" data-redeem="${r.id}">Canjear</button></div>
    </div>
  </div>`).join('');
  return `
    <div class="card">
      <h2>Nueva recompensa</h2>
      <div class="grid grid-3">
        <input id="rwTitle" placeholder="Nombre de la recompensa">
        <input id="rwCost" type="number" min="1" value="10" placeholder="Coste en puntos">
        <button class="primary" id="addRw">Añadir</button>
      </div>
    </div>
    ${items || '<div class="card"><small class="muted">Aún no hay recompensas.</small></div>'}
  `;
}

function attachRecompensas(){
  $('#addRw')?.addEventListener('click', ()=>{
    const title = $('#rwTitle').value.trim();
    const cost = Math.max(1, parseInt($('#rwCost').value||'1',10));
    if (!title) return alert('Pon un nombre');
    state.rewards.push({ id:'rw'+Date.now(), title, cost });
    saveState(); render();
  });
  $$('#view [data-redeem]').forEach(btn=>{
    btn.addEventListener('click', ()=>{
      const r = state.rewards.find(x=>x.id===btn.getAttribute('data-redeem'));
      const p = activeProfile();
      if (p.points < r.cost) return alert('Aún no tienes suficientes puntos');
      if (!confirm('Canjear '+r.title+' por '+r.cost+' pts?')) return;
      p.points -= r.cost;
      saveState(); render();
    });
  });
}

function viewEstancias(){
  const items = state.rooms.map(r=>`<div class="card">
    <div class="row" style="align-items:center; justify-content:space-between;">
      <b>${r.name}</b>
      <button class="ghost" data-del-room="${r.id}">Eliminar</button>
    </div>
  </div>`).join('');
  return `
    <div class="card">
      <h2>Nueva estancia</h2>
      <div class="grid grid-3">
        <input id="roomName" placeholder="Nombre de la estancia">
        <button class="primary" id="addRoom">Añadir</button>
      </div>
    </div>
    ${items || '<div class="card"><small class="muted">Aún no hay estancias.</small></div>'}
  `;
}

function attachEstancias(){
  $('#addRoom')?.addEventListener('click', ()=>{
    const name = $('#roomName').value.trim();
    if (!name) return alert('Pon un nombre');
    state.rooms.push({ id:'r'+Date.now(), name });
    saveState(); render();
  });
  $$('#view [data-del-room]').forEach(btn=>{
    btn.addEventListener('click', ()=>{
      const id = btn.getAttribute('data-del-room');
      state.rooms = state.rooms.filter(r=>r.id!==id);
      state.tasks = state.tasks.filter(t=>t.roomId!==id);
      saveState(); render();
    });
  });
}

function viewAjustes(){
  const profs = state.profiles.map(p=>`<div class="card">
    <div class="grid grid-3">
      <input value="${p.name}" data-rename="${p.id}" />
      <button class="ghost" data-reset="${p.id}">Reiniciar puntos</button>
    </div>
  </div>`).join('');
  return `
    <div class="card">
      <h2>Notificaciones</h2>
      <div class="grid grid-3">
        <label><input type="checkbox" id="notifyToggle" ${state.settings.notify?'checked':''}> Activar recordatorio diario</label>
        <input id="notifyTime" type="time" value="${state.settings.notifyTime}">
        <button class="ghost" id="saveNotify">Guardar</button>
      </div>
      <small class="muted">Las notificaciones locales funcionan si la app está abierta. Para notificaciones en segundo plano, haría falta activar \"push\" (podemos añadirlo después).</small>
    </div>
    <div class="card">
      <h2>Perfiles</h2>
      ${profs}
    </div>
    <div class="card">
      <button class="ghost" id="resetAll">Reiniciar TODO</button>
    </div>
  `;
}

function attachAjustes(){
  $('#saveNotify')?.addEventListener('click', ()=>{
    state.settings.notify = $('#notifyToggle').checked;
    state.settings.notifyTime = $('#notifyTime').value || '21:00';
    saveState(); setupNotifications(); alert('Guardado');
  });
  $$('#view [data-rename]').forEach(inp=>{
    inp.addEventListener('change', ()=>{
      const id = inp.getAttribute('data-rename');
      const p = state.profiles.find(x=>x.id===id); p.name = inp.value.trim()||p.name;
      saveState(); render();
    });
  });
  $$('#view [data-reset]').forEach(btn=>{
    btn.addEventListener('click', ()=>{
      const id = btn.getAttribute('data-reset');
      const p = state.profiles.find(x=>x.id===id);
      if (!confirm('Reiniciar puntos y racha de '+p.name+'?')) return;
      p.points = 0; p.streak = 0;
      state.logs = state.logs.filter(l=>l.profileId!==id);
      saveState(); render();
    });
  });
  $('#resetAll')?.addEventListener('click', ()=>{
    if (!confirm('¿Seguro que quieres reiniciar todo?')) return;
    state = defaultState(); saveState(); render();
  });
}

function viewPerfil(){
  // simple questionnaire
  const a = state.answers || {};
  return `
    <div class="card">
      <h2>Cuestionario inicial</h2>
      <div class="grid">
        <label>¿Qué tareas te gustan menos? (coma separadas)<br>
          <input id="qDislikes" placeholder="ej.: ventanas, baño" value="${a.dislikes||''}">
        </label>
        <label>¿Qué te resulta más fácil?<br>
          <input id="qEasy" placeholder="ej.: hacer cama, recoger" value="${a.easy||''}">
        </label>
        <label>¿A qué hora te va mejor?<br>
          <select id="qHour">
            <option value="mañana" ${a.hour==='mañana'?'selected':''}>Mañana</option>
            <option value="tarde" ${a.hour==='tarde'?'selected':''}>Tarde</option>
            <option value="noche" ${a.hour==='noche'?'selected':''}>Noche</option>
          </select>
        </label>
        <label>Objetivo semanal de puntos<br>
          <input id="qGoal" type="number" min="10" value="${a.goal||50}">
        </label>
        <button class="primary" id="saveQ">Guardar perfil</button>
      </div>
      <small class="muted">Usaremos esto para sugerir tareas que eviten tus \"odios\" cuando tengas baja energía, y empujen retos cuando tengas energía alta.</small>
    </div>
  `;
}

function attachPerfil(){
  $('#saveQ')?.addEventListener('click', ()=>{
    const a = {
      dislikes: $('#qDislikes').value.trim(),
      easy: $('#qEasy').value.trim(),
      hour: $('#qHour').value,
      goal: Math.max(10, parseInt($('#qGoal').value||'50',10))
    };
    state.answers = a;
    // apply simple strategy: set notify time based on hour
    const hourMap = { 'mañana':'09:00','tarde':'16:00','noche':'21:00' };
    state.settings.notifyTime = hourMap[a.hour] || '21:00';
    state.settings.notify = true;
    saveState(); setupNotifications(); alert('Perfil guardado y recordatorios activados');
  });
}

function viewExportar(){
  return `
    <div class="card">
      <h2>Exportar datos</h2>
      <div class="row">
        <button class="ghost" id="btnCSV2">Exportar CSV</button>
        <button class="ghost" id="btnPDF2">Exportar PDF</button>
      </div>
    </div>
  `;
}

function attachExportar(){
  $('#btnCSV2')?.addEventListener('click', exportCSV);
  $('#btnPDF2')?.addEventListener('click', exportPDF);
}

function viewAyuda(){
  return `
    <div class="card">
      <h2>Cómo usar</h2>
      <ol>
        <li>Elige el perfil arriba.</li>
        <li>Ve a <b>Hoy</b>, marca tu energía y completa la tarea sugerida.</li>
        <li>Consulta <b>Semana</b> y <b>Mes</b> para ver progreso.</li>
        <li>Edita <b>Tareas</b>, <b>Recompensas</b> y <b>Estancias</b> a tu gusto.</li>
        <li>En <b>Ajustes</b> activa recordatorios diarios.</li>
        <li>En <b>Tablero</b> verás ranking y meta conjunta.</li>
      </ol>
      <small class="muted">Para instalar como app: abre esta web en tu móvil y usa “Añadir a pantalla de inicio”.</small>
    </div>
  `;
}

// Render main
function render(){
  const view = $('#view');
  switch(state.uiTab||'hoy'){
    case 'hoy': view.innerHTML = viewHoy(); attachHoyEvents(); break;
    case 'semana': view.innerHTML = viewSemana(); break;
    case 'mes': view.innerHTML = viewMes(); break;
    case 'tablero': view.innerHTML = viewTablero(); attachTablero(); break;
    case 'tareas': view.innerHTML = viewTareas(); attachTareas(); break;
    case 'recompensas': view.innerHTML = viewRecompensas(); attachRecompensas(); break;
    case 'estancias': view.innerHTML = viewEstancias(); attachEstancias(); break;
    case 'ajustes': view.innerHTML = viewAjustes(); attachAjustes(); break;
    case 'perfil': view.innerHTML = viewPerfil(); attachPerfil(); break;
    case 'exportar': view.innerHTML = viewExportar(); attachExportar(); break;
    case 'ayuda': view.innerHTML = viewAyuda(); break;
  }
  // fill profile select
  const sel = $('#profileSelect'); sel.innerHTML = state.profiles.map(p=>`<option value="${p.id}" ${p.id===state.activeProfileId?'selected':''}>${p.name}</option>`).join('');
  sel.onchange = e => selectProfile(e.target.value);
  // setup tab buttons
  $$('.tab-btn').forEach(b=> b.addEventListener('click', ()=> setActiveTab(b.dataset.tab)));
}

// PWA: register SW
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('./service-worker.js').catch(console.error);
  });
}

render();
setupNotifications();
</script> """ with open(os.path.join(base, "index.html"), "w", encoding="utf-8") as f: f.write(index_html.strip())

5) (Optional) app.js could be separate; but we've embedded script in index.html for simplicity.

6) Zip the folder

zip_path = "/mnt/data/limpieza-pwa.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(base):
for file in files:
full = os.path.join(root, file)
rel = os.path.relpath(full, base)
zipf.write(full, arcname=rel)

zip_path

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions