-
Notifications
You must be signed in to change notification settings - Fork 876
Description
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>
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('')}
</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();
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