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
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 = """
<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><!doctype html>
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>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