F1N2G6/ORGANICER
<!DOCTYPE html> <html lang="es" data-color-scheme="light"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Planificador Mensual de Hábitos</title> <style> / ===== PERPLEXITY DESIGN SYSTEM (recortado a lo necesario) ===== / :root { --color-white: rgba(255, 255, 255, 1); --color-cream-50: rgba(252, 252, 249, 1); --color-cream-100: rgba(255, 255, 253, 1); --color-gray-200: rgba(245, 245, 245, 1); --color-gray-300: rgba(167, 169, 169, 1); --color-gray-400: rgba(119, 124, 124, 1); --color-slate-500: rgba(98, 108, 113, 1); --color-brown-600: rgba(94, 82, 64, 1); --color-charcoal-700: rgba(31, 33, 33, 1); --color-charcoal-800: rgba(38, 40, 40, 1); --color-slate-900: rgba(19, 52, 59, 1); --color-teal-300: rgba(50, 184, 198, 1); --color-teal-400: rgba(45, 166, 178, 1); --color-teal-500: rgba(33, 128, 141, 1); --color-teal-600: rgba(29, 116, 128, 1); --color-teal-700: rgba(26, 104, 115, 1); --color-red-400: rgba(255, 84, 89, 1); --color-orange-400: rgba(230, 129, 97, 1);
--color-brown-600-rgb: 94, 82, 64; --color-teal-500-rgb: 33, 128, 141; --color-slate-900-rgb: 19, 52, 59; --color-slate-500-rgb: 98, 108, 113;
--color-background: var(--color-cream-50); --color-surface: var(--color-cream-100); --color-text: var(--color-slate-900); --color-text-secondary: var(--color-slate-500); --color-primary: var(--color-teal-500); --color-primary-hover: var(--color-teal-600); --color-primary-active: var(--color-teal-700); --color-secondary: rgba(var(--color-brown-600-rgb), 0.12); --color-secondary-hover: rgba(var(--color-brown-600-rgb), 0.2); --color-secondary-active: rgba(var(--color-brown-600-rgb), 0.25); --color-border: rgba(var(--color-brown-600-rgb), 0.2); --color-btn-primary-text: var(--color-cream-50); --color-card-border: rgba(var(--color-brown-600-rgb), 0.12); --color-card-border-inner: rgba(var(--color-brown-600-rgb), 0.12); --color-focus-ring: rgba(var(--color-teal-500-rgb), 0.4);
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-size-sm: 12px; --font-size-base: 14px; --font-size-lg: 16px; --font-size-2xl: 20px; --font-weight-medium: 500; --font-weight-semibold: 550;
--space-4: 4px; --space-8: 8px; --space-12: 12px; --space-16: 16px; --space-20: 20px; --space-24: 24px; --space-32: 32px;
--radius-sm: 6px; --radius-base: 8px; --radius-lg: 12px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.04), 0 2px 4px -1px rgba(0, 0, 0, 0.02);
--duration-fast: 150ms; --duration-normal: 250ms; --ease-standard: cubic-bezier(0.16, 1, 0.3, 1); }
html { font-size: var(--font-size-base); font-family: var(--font-family-base); color: var(--color-text); background-color: var(--color-background); -webkit-font-smoothing: antialiased; box-sizing: border-box; } , ::before, *::after { box-sizing: inherit; } body { margin: 0; }
h1, h2, h3 { margin: 0; font-weight: var(--font-weight-semibold); } h1 { font-size: 24px; } h2 { font-size: 20px; } h3 { font-size: 16px; }
.container { max-width: 1200px; margin: 0 auto; padding: var(--space-16); }
.card { background-color: var(--color-surface); border-radius: var(--radius-lg); border: 1px solid var(--color-card-border); box-shadow: var(--shadow-sm); padding: var(--space-16); margin-bottom: var(--space-16); }
.btn { display: inline-flex; align-items: center; justify-content: center; padding: var(--space-8) var(--space-16); border-radius: var(--radius-base); font-size: var(--font-size-base); font-weight: var(--font-weight-medium); cursor: pointer; border: none; transition: all var(--duration-normal) var(--ease-standard); } .btn-primary { background: var(--color-primary); color: var(--color-btn-primary-text); } .btn-primary:hover { background: var(--color-primary-hover); } .btn-secondary { background: var(--color-secondary); color: var(--color-text); } .btn-secondary:hover { background: var(--color-secondary-hover); }
.form-group { margin-bottom: var(--space-12); } .form-label { display: block; margin-bottom: var(--space-4); font-size: var(--font-size-sm); color: var(--color-text-secondary); } .form-control { width: 100%; padding: var(--space-8) var(--space-12); border-radius: var(--radius-base); border: 1px solid var(--color-border); background-color: var(--color-surface); font-size: var(--font-size-base); } .form-control:focus { outline: 2px solid var(--color-primary); outline-offset: 1px; } textarea.form-control { min-height: 120px; resize: vertical; }
.layout-main { display: grid; grid-template-columns: 260px minmax(260px, 1fr) 300px; gap: var(--space-16); } @media (max-width: 1024px) { .layout-main { grid-template-columns: 1fr; } }
.habit-list-item { display: grid; grid-template-columns: auto 1fr auto; gap: var(--space-8); align-items: center; padding: var(--space-8); border-radius: var(--radius-base); border: 1px solid rgba(0,0,0,0.03); margin-bottom: var(--space-8); background-color: rgba(0,0,0,0.01); } .habit-number { font-weight: var(--font-weight-semibold); font-size: var(--font-size-sm); color: var(--color-text-secondary); width: 24px; text-align: right; } .habit-name-input { border-radius: var(--radius-sm); border: 1px solid var(--color-border); padding: var(--space-4) var(--space-8); font-size: var(--font-size-sm); width: 100%; } .habit-meta { display: flex; flex-direction: column; gap: var(--space-4); align-items: flex-end; } .habit-toggle-row { display: flex; align-items: center; gap: var(--space-4); font-size: var(--font-size-sm); } .habit-toggle-row input[type="checkbox"] { width: 16px; height: 16px; } .habit-color-input { width: 24px; height: 24px; padding: 0; border-radius: var(--radius-sm); border: 1px solid var(--color-border); background: transparent; cursor: pointer; }
.month-header { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-12); margin-bottom: var(--space-16); } .month-header > * { flex: 0 0 auto; } .month-header-title { font-size: var(--font-size-2xl); font-weight: var(--font-weight-semibold); }
.month-select-row { display: flex; gap: var(--space-8); flex-wrap: wrap; margin-bottom: var(--space-12); }
.month-list { display: flex; flex-wrap: wrap; gap: var(--space-8); } .month-pill { padding: var(--space-4) var(--space-12); border-radius: 999px; border: 1px solid var(--color-border); font-size: var(--font-size-sm); cursor: pointer; background-color: var(--color-surface); } .month-pill.active { background-color: var(--color-primary); color: var(--color-btn-primary-text); border-color: var(--color-primary); }
/ ===== CÍRCULO MENSUAL ===== / .circle-container-card { display: flex; flex-direction: column; align-items: center; justify-content: center; } .circle-wrapper { position: relative; width: min(420px, 90vw); height: min(420px, 90vw); } .circle-svg { width: 100%; height: 100%; display: block; } .circle-legend { margin-top: var(--space-12); display: flex; flex-wrap: wrap; gap: var(--space-8); justify-content: center; font-size: var(--font-size-sm); } .circle-legend-item { display: inline-flex; align-items: center; gap: var(--space-4); padding: 2px 8px; border-radius: 999px; background-color: rgba(0,0,0,0.02); } .circle-legend-color { width: 10px; height: 10px; border-radius: 999px; border: 1px solid rgba(0,0,0,0.1); }
.circle-center-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; font-size: var(--font-size-sm); pointer-events: none; }
/ Tooltip simple para día/hábito / .tooltip { position: fixed; background: var(--color-charcoal-800); color: white; padding: 4px 8px; border-radius: 4px; font-size: 11px; pointer-events: none; z-index: 1000; display: none; white-space: nowrap; } </style> </head> <body> <div class="container"> <h1>Planificador mensual de hábitos</h1>
<!-- Selección de mes --> <div class="card"> <div class="month-select-row"> <div class="form-group" style="min-width: 160px;"> <label class="form-label" for="select-mes-num">Mes</label> <select id="select-mes-num" class="form-control"> <option value="1">01 - Enero</option> <option value="2">02 - Febrero</option> <option value="3">03 - Marzo</option> <option value="4">04 - Abril</option> <option value="5">05 - Mayo</option> <option value="6">06 - Junio</option> <option value="7">07 - Julio</option> <option value="8">08 - Agosto</option> <option value="9">09 - Septiembre</option> <option value="10">10 - Octubre</option> <option value="11">11 - Noviembre</option> <option value="12">12 - Diciembre</option> </select> </div> <div class="form-group" style="min-width: 120px;"> <label class="form-label" for="input-anio">Año</label> <input type="number" id="input-anio" class="form-control" value="2026" /> </div> <div class="form-group" style="align-self: flex-end;"> <button id="btn-nuevo-mes" class="btn btn-primary">+ Nuevo mes</button> </div> </div> <div class="form-group"> <label class="form-label">Meses creados</label> <div id="lista-meses" class="month-list"></div> </div> </div>
<!-- Pantalla principal Mes --> <div class="card"> <div id="mes-activo-header" class="month-header" style="margin-bottom: 0;"> <div class="month-header-title" id="titulo-mes-visual">Sin mes seleccionado</div> </div> <div class="layout-main" style="margin-top: var(--space-16);"> <!-- Zona izquierda: hábitos --> <div> <h3 style="margin-bottom: var(--space-8);">Hábitos (1–8)</h3> <div id="lista-habitos"></div> </div>
<!-- Zona central: círculo mensual --> <div class="circle-container-card"> <div class="circle-wrapper"> <svg id="habit-circle" class="circle-svg" viewBox="0 0 400 400"></svg> <div class="circle-center-label" id="circle-center-label"></div> </div> <div class="circle-legend" id="circle-legend"></div> </div>
<!-- Zona derecha: observaciones --> <div> <h3 style="margin-bottom: var(--space-8);">Observaciones</h3> <textarea id="textarea-observaciones" class="form-control" placeholder="Notas, reflexiones, objetivos del mes..."></textarea> </div> </div> </div> </div>
<div id="tooltip" class="tooltip"></div>
<script> // ===================================== // MODELO EN MEMORIA (Mes, HabitoMes, RegistroDia) // =====================================
function uuid() { return crypto.randomUUID ? crypto.randomUUID() : "xxxx-4xxx-yxxx-xxxx".replace(/[xy]/g, function (c) { var r = (Math.random() * 16) | 0, v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); }
/**
- Mes: {
- id_mes,
- mes (1-12),
- anio,
- titulo_mes,
- observaciones
- } *
- HabitoMes: {
- idhabitomes,
- id_mes,
- numero_habito (1-8),
- nombre_habito,
- activo (bool),
- color (string)
- } *
- RegistroDia: {
- id_registro,
- id_mes,
- numero_habito,
- dia (1-31),
- cumplido (bool)
- } */
const DB = { meses: [], habitosMes: [], registrosDia: [] };
let mesActivoId = null;
const COLORPORDEFECTO = [ "#3B82F6", // azul "#10B981", // verde "#F59E0B", // amarillo "#EF4444", // rojo "#8B5CF6", // violeta "#EC4899", // rosa "#14B8A6", // teal "#6366F1" // índigo ];
function crearMes(mesNumero, anio) { const idmes = uuid(); const nombreMeses = [ "Enero","Febrero","Marzo","Abril","Mayo","Junio", "Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre" ]; const titulomes = ${nombreMeses[mesNumero - 1]} ${anio};
const nuevoMes = { idmes, mes: mesNumero, anio, titulomes, observaciones: "" }; DB.meses.push(nuevoMes);
// Crear 8 filas en HabitoMes for (let i = 1; i <= 8; i++) { DB.habitosMes.push({ idhabitomes: uuid(), idmes, numerohabito: i, nombrehabito: `Hábito ${i}`, activo: false, color: COLORPOR_DEFECTO[i - 1] || "#999999" }); }
return nuevoMes; }
function obtenerMesesOrdenados() { return [...DB.meses].sort((a, b) => { if (a.anio !== b.anio) return a.anio - b.anio; return a.mes - b.mes; }); }
function getMesActivo() { if (!mesActivoId) return null; return DB.meses.find(m => m.id_mes === mesActivoId) || null; }
function getHabitosDeMes(idmes) { return DB.habitosMes .filter(h => h.idmes === idmes) .sort((a, b) => a.numerohabito - b.numero_habito); }
function getRegistroDia(idmes, numerohabito, dia) { return DB.registrosDia.find( r => r.idmes === idmes && r.numerohabito === numerohabito && r.dia === dia ) || null; }
function toggleRegistroDia(idmes, numerohabito, dia) { const existente = getRegistroDia(idmes, numerohabito, dia); if (!existente) { const nuevo = { idregistro: uuid(), idmes, numero_habito, dia, cumplido: true }; DB.registrosDia.push(nuevo); } else { existente.cumplido = !existente.cumplido; } }
function getDiasDelMes(mes, anio) { return new Date(anio, mes, 0).getDate(); }
// ===================================== // RENDER UI // =====================================
const listaMesesEl = document.getElementById("lista-meses"); const selectMesNumEl = document.getElementById("select-mes-num"); const inputAnioEl = document.getElementById("input-anio"); const btnNuevoMesEl = document.getElementById("btn-nuevo-mes");
const tituloMesVisualEl = document.getElementById("titulo-mes-visual"); const listaHabitosEl = document.getElementById("lista-habitos"); const textareaObsEl = document.getElementById("textarea-observaciones");
const svgCircleEl = document.getElementById("habit-circle"); const circleCenterLabelEl = document.getElementById("circle-center-label"); const circleLegendEl = document.getElementById("circle-legend"); const tooltipEl = document.getElementById("tooltip");
function renderMeses() { listaMesesEl.innerHTML = ""; const meses = obtenerMesesOrdenados(); if (meses.length === 0) { const span = document.createElement("span"); span.style.fontSize = "12px"; span.style.color = "var(--color-text-secondary)"; span.textContent = "Aún no hay meses creados. Usa \"+ Nuevo mes\" para empezar."; listaMesesEl.appendChild(span); return; }
meses.forEach(m => { const btn = document.createElement("button"); btn.className = "month-pill" + (m.idmes === mesActivoId ? " active" : ""); btn.textContent = m.titulomes; btn.addEventListener("click", () => { mesActivoId = m.id_mes; renderMeses(); renderPantallaMes(); }); listaMesesEl.appendChild(btn); }); }
function renderPantallaMes() { const mes = getMesActivo(); if (!mes) { tituloMesVisualEl.textContent = "Sin mes seleccionado"; listaHabitosEl.innerHTML = ""; textareaObsEl.value = ""; svgCircleEl.innerHTML = ""; circleCenterLabelEl.textContent = ""; circleLegendEl.innerHTML = ""; return; }
tituloMesVisualEl.textContent = mes.titulo_mes; textareaObsEl.value = mes.observaciones || ""; renderHabitos(mes); renderCircle(mes); }
function renderHabitos(mes) { const habitos = getHabitosDeMes(mes.id_mes); listaHabitosEl.innerHTML = "";
habitos.forEach(h => { const row = document.createElement("div"); row.className = "habit-list-item";
const numEl = document.createElement("div"); numEl.className = "habit-number"; numEl.textContent = h.numero_habito;
const inputNombre = document.createElement("input"); inputNombre.className = "habit-name-input"; inputNombre.value = h.nombrehabito || ""; inputNombre.placeholder = `Hábito ${h.numerohabito}`; inputNombre.addEventListener("input", () => { h.nombre_habito = inputNombre.value; renderCircle(mes); });
const meta = document.createElement("div"); meta.className = "habit-meta";
const toggleRow = document.createElement("div"); toggleRow.className = "habit-toggle-row"; const chk = document.createElement("input"); chk.type = "checkbox"; chk.checked = !!h.activo; chk.addEventListener("change", () => { h.activo = chk.checked; renderCircle(mes); }); const lbl = document.createElement("span"); lbl.textContent = "Activo"; toggleRow.appendChild(chk); toggleRow.appendChild(lbl);
const colorInput = document.createElement("input"); colorInput.type = "color"; colorInput.className = "habit-color-input"; colorInput.value = h.color || "#999999"; colorInput.addEventListener("input", () => { h.color = colorInput.value; renderCircle(mes); });
meta.appendChild(toggleRow); meta.appendChild(colorInput);
row.appendChild(numEl); row.appendChild(inputNombre); row.appendChild(meta);
listaHabitosEl.appendChild(row); }); }
function renderCircle(mes) { const habitos = getHabitosDeMes(mes.id_mes).filter(h => h.activo); const diasMes = getDiasDelMes(mes.mes, mes.anio);
svgCircleEl.innerHTML = ""; circleLegendEl.innerHTML = ""; if (habitos.length === 0) { circleCenterLabelEl.textContent = "Activa algún hábito\npara ver el círculo"; return; } else { circleCenterLabelEl.textContent = ${diasMes} días; }
const centerX = 200; const centerY = 200; const innerRadius = 60; const bandWidth = 20; const gapBetweenBands = 4; const totalDays = 31; // estructura de 31 segmentos, pero se "grisearán" los días > diasMes const angleStep = (2 * Math.PI) / totalDays;
habitos.forEach((habito, index) => { const r0 = innerRadius + index * (bandWidth + gapBetweenBands); const r1 = r0 + bandWidth;
// Leyenda const legItem = document.createElement("div"); legItem.className = "circle-legend-item"; const col = document.createElement("div"); col.className = "circle-legend-color"; col.style.backgroundColor = habito.color || "#999999"; const txt = document.createElement("span"); txt.textContent = habito.nombrehabito || `Hábito ${habito.numerohabito}`; legItem.appendChild(col); legItem.appendChild(txt); circleLegendEl.appendChild(legItem);
for (let dia = 1; dia <= totalDays; dia++) { const startAngle = (dia - 1) angleStep - Math.PI / 2; const endAngle = dia angleStep - Math.PI / 2;
const x0 = centerX + r0 Math.cos(startAngle); const y0 = centerY + r0 Math.sin(startAngle); const x1 = centerX + r1 Math.cos(startAngle); const y1 = centerY + r1 Math.sin(startAngle); const x2 = centerX + r1 Math.cos(endAngle); const y2 = centerY + r1 Math.sin(endAngle); const x3 = centerX + r0 Math.cos(endAngle); const y3 = centerY + r0 Math.sin(endAngle);
const largeArcFlag = 0;
const pathData = [ "M", x0, y0, "L", x1, y1, "A", r1, r1, 0, largeArcFlag, 1, x2, y2, "L", x3, y3, "A", r0, r0, 0, largeArcFlag, 0, x0, y0, "Z" ].join(" ");
const registro = getRegistroDia(mes.idmes, habito.numerohabito, dia); const isCumplido = registro && registro.cumplido;
const pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path"); pathEl.setAttribute("d", pathData);
if (dia > diasMes) { pathEl.setAttribute("fill", "#f1f5f9"); pathEl.setAttribute("stroke", "#e5e7eb"); pathEl.setAttribute("stroke-width", "0.5"); pathEl.setAttribute("fill-opacity", "0.6"); } else if (isCumplido) { pathEl.setAttribute("fill", habito.color || "#22c55e"); pathEl.setAttribute("fill-opacity", "0.9"); } else { pathEl.setAttribute("fill", "#ffffff"); pathEl.setAttribute("fill-opacity", "0.9"); pathEl.setAttribute("stroke", "#e2e8f0"); pathEl.setAttribute("stroke-width", "0.5"); }
pathEl.style.cursor = dia <= diasMes ? "pointer" : "default";
pathEl.addEventListener("click", () => { if (dia > diasMes) return; toggleRegistroDia(mes.idmes, habito.numerohabito, dia); renderCircle(mes); });
const habitName = habito.nombrehabito || `Hábito ${habito.numerohabito}; pathEl.addEventListener("mousemove", (ev) => { tooltipEl.style.display = "block"; tooltipEl.textContent = ${habitName} · Día ${dia}`; tooltipEl.style.left = ev.clientX + 12 + "px"; tooltipEl.style.top = ev.clientY + 12 + "px"; }); pathEl.addEventListener("mouseleave", () => { tooltipEl.style.display = "none"; });
svgCircleEl.appendChild(pathEl); } }); }
// Observaciones textareaObsEl.addEventListener("input", () => { const mes = getMesActivo(); if (!mes) return; mes.observaciones = textareaObsEl.value; });
// Botón "Nuevo mes" btnNuevoMesEl.addEventListener("click", () => { const mesNum = parseInt(selectMesNumEl.value, 10); const anio = parseInt(inputAnioEl.value, 10) || new Date().getFullYear(); const nuevoMes = crearMes(mesNum, anio); mesActivoId = nuevoMes.id_mes; renderMeses(); renderPantallaMes(); });
// Estado inicial: crear un mes por defecto (mes actual) (function init() { const hoy = new Date(); const mesNum = hoy.getMonth() + 1; const anio = hoy.getFullYear(); const mesInicial = crearMes(mesNum, anio); mesActivoId = mesInicial.id_mes; renderMeses(); renderPantallaMes(); })(); </script> </body> </html>
