Skip to main content

Overview

The logic.js module contains all business logic and computational algorithms. It is pure logic with minimal DOM manipulation, making it testable and reusable. Key Areas:
  • Timer engine (start/stop/pause, Pomodoro mode)
  • Spaced repetition calculations
  • Analytics and statistics
  • Study planning algorithms

Timer Engine

Timer State Management

Location: logic.js:8-16
export let timerIntervals = {};   // eventId → intervalId
export let _pomodoroMode = false;
export let _pomodoroAlarm = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3');

export function isTimerActive(eventId) {
  if (eventId === 'crono_livre') return !!state.cronoLivre._timerStart;
  const ev = state.eventos.find(e => e.id === eventId);
  return !!(ev && ev._timerStart);
}

export function getElapsedSeconds(ev) {
  const base = ev.tempoAcumulado || 0;
  if (!ev._timerStart) return base;
  return base + Math.floor((Date.now() - ev._timerStart) / 1000);
}
Each event (or cronoLivre) tracks:
FieldTypePurpose
_timerStartnumber | nullTimestamp when timer started (null if paused)
tempoAcumuladonumberTotal elapsed seconds (persisted)
Calculation Logic:
Total Elapsed = tempoAcumulado + (now - _timerStart)
This allows pausing/resuming without losing time.

Toggle Timer (Start/Pause)

Location: logic.js:114-130
export function toggleTimer(eventId) {
  const ev = eventId === 'crono_livre' ? state.cronoLivre : state.eventos.find(e => e.id === eventId);
  if (!ev) return;
  
  if (ev._timerStart) {
    // PAUSE
    ev.tempoAcumulado = getElapsedSeconds(ev);
    delete ev._timerStart;
    if (timerIntervals[eventId]) { 
      clearInterval(timerIntervals[eventId]); 
      delete timerIntervals[eventId]; 
    }
  } else {
    // START
    ev._timerStart = Date.now();
    reattachTimers(); // Start the interval immediately so card reflects seconds
  }
  
  scheduleSave();
  document.dispatchEvent(new CustomEvent('app:refreshEventCard', { detail: { eventId } }));
  document.dispatchEvent(new Event('app:updateBadges'));
}
toggleTimer('ev_1234567890');
// Result: _timerStart = Date.now(), interval starts

Reattach Timers on View Change

Location: logic.js:60-99
export function reattachTimers() {
  Object.keys(timerIntervals).forEach(id => {
    clearInterval(timerIntervals[id]);
    delete timerIntervals[id];
  });

  const allTimers = [];
  if (state.cronoLivre && state.cronoLivre._timerStart) {
    allTimers.push({ id: 'crono_livre', ev: state.cronoLivre });
  }
  state.eventos.forEach(e => {
    if (e._timerStart) allTimers.push({ id: e.id, ev: e });
  });

  allTimers.forEach(({ id, ev }) => {
    timerIntervals[id] = setInterval(() => {
      const elapsed = getElapsedSeconds(ev);

      // POMODORO CHECK
      if (_pomodoroMode && ev._timerStart) {
        const sessionSeconds = Math.floor((Date.now() - ev._timerStart) / 1000);
        const focoTargetSecs = (state?.config?.pomodoroFoco || 25) * 60;
        const pausaTargetMins = state?.config?.pomodoroPausa || 5;
        if (sessionSeconds >= focoTargetSecs) {
          _pomodoroAlarm.play().catch(e => console.log('Audio error:', e));
          toggleTimer(id); // Auto-pause
          document.dispatchEvent(new CustomEvent('app:showToast', { 
            detail: { msg: `Pomodoro concluído! Descanse ${pausaTargetMins} minutos.`, type: 'success' } 
          }));
          if ("Notification" in window && Notification.permission === "granted") {
            new Notification("Pomodoro Concluído! 🍅", { 
              body: `Descanse ${pausaTargetMins} minutos.`, 
              icon: 'favicon.ico' 
            });
          }
          return; // Stop current interval frame
        }
      }

      document.querySelectorAll(`[data-timer="${id}"]`).forEach(el => {
        el.textContent = formatTime(elapsed);
      });
    }, 1000);
  });
}
Why Reattach?When navigating between views, event cards are destroyed and recreated. reattachTimers() ensures all active timers continue running and update the DOM.

Pomodoro Mode

Location: logic.js:24-35
export function toggleTimerMode() {
  _pomodoroMode = !_pomodoroMode;
  const btn = document.getElementById('crono-mode-btn');
  const foco = state?.config?.pomodoroFoco || 25;
  const pausa = state?.config?.pomodoroPausa || 5;
  if (btn) {
    btn.innerHTML = _pomodoroMode ? `🍅 Pomodoro (${foco}/${pausa})` : '⏱ Modo Contínuo';
    btn.style.backgroundColor = _pomodoroMode ? 'rgba(139,92,246,0.15)' : 'rgba(255,255,255,0.06)';
    btn.style.color = _pomodoroMode ? '#a371f7' : '#8b949e';
  }
  document.dispatchEvent(new CustomEvent('app:showToast', { 
    detail: { msg: _pomodoroMode ? 'Modo Pomodoro ativado.' : 'Modo Contínuo ativado.', type: 'info' } 
  }));
}
Timer runs indefinitely until manually paused.

Spaced Repetition System

Revision Date Calculation

Location: logic.js:231-245
export const _revDateCache = new Map();
export function invalidateRevCache() { _revDateCache.clear(); }

export function calcRevisionDates(dataConclusao, feitas, adiamentos = 0) {
  const freqs = state.config.frequenciaRevisao || [1, 7, 30, 90];
  const cacheKey = `${dataConclusao}:${feitas.length}:${adiamentos}:${freqs.join(',')}`;
  if (_revDateCache.has(cacheKey)) return _revDateCache.get(cacheKey);

  const base = new Date(dataConclusao + 'T00:00:00');
  base.setDate(base.getDate() + adiamentos); // shift the revision schedule by the number of postponed days

  const dates = freqs.slice(feitas.length).map(d => {
    const dt = new Date(base);
    dt.setDate(dt.getDate() + d);
    return getLocalDateStr(dt);
  });
  
  _revDateCache.set(cacheKey, dates);
  return dates;
}
1

Input

  • dataConclusao: Date the topic was completed (e.g., "2024-01-15")
  • feitas: Array of completed revision dates
  • adiamentos: Number of days to postpone the schedule
2

Algorithm

  1. Load frequency intervals (default: [1, 7, 30, 90] days)
  2. Skip intervals already completed (via feitas.length)
  3. Add base date + adiamentos + remaining intervals
  4. Return future dates in YYYY-MM-DD format
3

Output

Array of upcoming revision dates, e.g.:
["2024-01-16", "2024-01-22", "2024-02-14", "2024-04-15"]
calcRevisionDates('2024-01-15', [], 0)
// Returns: ['2024-01-16', '2024-01-22', '2024-02-14', '2024-04-15']

Get Pending Revisions

Location: logic.js:251-271
export function getPendingRevisoes() {
  if (_pendingRevCache) return _pendingRevCache;
  const today = todayStr();
  const pending = [];
  
  for (const edital of state.editais) {
    for (const disc of (edital.disciplinas || [])) {
      for (const ass of disc.assuntos) {
        if (!ass.concluido || !ass.dataConclusao) continue;
        const revDates = calcRevisionDates(ass.dataConclusao, ass.revisoesFetas || [], ass.adiamentos || 0);
        for (const rd of revDates) {
          if (rd <= today) {
            pending.push({ assunto: ass, disc, edital, data: rd });
            break; // only the next scheduled one
          }
        }
      }
    }
  }
  
  _pendingRevCache = pending;
  return pending;
}

Analytics & Statistics

Total Study Time

Location: logic.js:218-223
export function totalStudySeconds(days = null) {
  const cutoffStr = days ? cutoffDateStr(days) : null;
  return state.eventos
    .filter(e => e.status === 'estudei' && e.tempoAcumulado && (!cutoffStr || e.data >= cutoffStr))
    .reduce((s, e) => s + (e.tempoAcumulado || 0), 0);
}

Performance Stats (Questions)

Location: logic.js:304-318
export function getPerformanceStats() {
  let questionsTotal = 0;
  let questionsCorrect = 0;
  let questionsWrong = 0;

  state.eventos.forEach(ev => {
    if (ev.status === 'estudei' && ev.sessao && ev.sessao.questoes) {
      questionsTotal += ev.sessao.questoes.total || 0;
      questionsCorrect += ev.sessao.questoes.acertos || 0;
      questionsWrong += ev.sessao.questoes.erros || 0;
    }
  });

  return { questionsTotal, questionsCorrect, questionsWrong };
}

Consistency Streak

Location: logic.js:344-395
export function getConsistencyStreak() {
  const dates = new Set();
  state.eventos.forEach(ev => {
    if (ev.status === 'estudei' && ev.dataEstudo) {
      dates.add(ev.dataEstudo);
    }
  });

  const todayStrDate = getLocalDateStr();
  let currentStreak = 0;
  let maxStreak = 0;
  let tempStreak = 0;

  // Calculate max streak
  const sortedDates = Array.from(dates).sort();
  if (sortedDates.length > 0) {
    let prev = new Date(sortedDates[0]);
    tempStreak = 1;
    maxStreak = 1;
    for (let i = 1; i < sortedDates.length; i++) {
      let curr = new Date(sortedDates[i]);
      let diff = (curr - prev) / (1000 * 60 * 60 * 24);
      if (diff === 1) {
        tempStreak++;
        if (tempStreak > maxStreak) maxStreak = tempStreak;
      } else if (diff > 1) {
        tempStreak = 1;
      }
      prev = curr;
    }

    // Current Streak
    let currDay = new Date(todayStrDate);
    while (dates.has(getLocalDateStr(currDay))) {
      currentStreak++;
      currDay.setDate(currDay.getDate() - 1);
    }
  }

  // Generate last 30 days heatmap
  const heatmap = [];
  const startDay = new Date(todayStrDate);
  startDay.setDate(startDay.getDate() - 29);

  for (let i = 0; i < 30; i++) {
    const dStr = getLocalDateStr(startDay);
    heatmap.push(dates.has(dStr));
    startDay.setDate(startDay.getDate() + 1);
  }

  return { currentStreak, maxStreak, heatmap };
}

Current Streak

Days studied consecutively from today backwards

Max Streak

Longest consecutive study streak ever recorded

Heatmap

Last 30 days as boolean array (true = studied)

Weekly Stats

Location: logic.js:420-463
export function getCurrentWeekStats() {
  const now = new Date();
  const primeirodiaSemana = state.config.primeirodiaSemana || 1;
  const day = now.getDay();
  const diff = now.getDate() - day + (day === 0 && primeirodiaSemana === 1 ? -6 : (primeirodiaSemana === 1 ? 1 : 0));
  const startOfWeek = new Date(now.getFullYear(), now.getMonth(), diff, 0, 0, 0, 0);

  const endOfWeek = new Date(startOfWeek);
  endOfWeek.setDate(endOfWeek.getDate() + 6);
  endOfWeek.setHours(23, 59, 59, 999);

  const startStr = getLocalDateStr(startOfWeek);
  const endStr = getLocalDateStr(endOfWeek);

  let totalSeconds = 0;
  let totalQuestions = 0;
  const dailySeconds = [0, 0, 0, 0, 0, 0, 0];

  state.eventos.forEach(ev => {
    if (ev.status === 'estudei' && ev.dataEstudo >= startStr && ev.dataEstudo <= endStr) {
      const elapsed = ev.tempoAcumulado || 0;
      totalSeconds += elapsed;
      if (ev.sessao && ev.sessao.questoes) {
        totalQuestions += (ev.sessao.questoes.total || ev.sessao.questoes.acertos + ev.sessao.questoes.erros || 0);
      }

      const evDate = new Date(ev.dataEstudo + 'T00:00:00');
      let dIndex = evDate.getDay() - primeirodiaSemana;
      if (dIndex < 0) dIndex += 7;
      dailySeconds[dIndex] += elapsed;
    }
  });

  return { startStr, endStr, totalSeconds, totalQuestions, dailySeconds };
}

Study Planning Algorithms

Relevance Weight Calculation

Location: logic.js:537-560
export function calculateRelevanceWeights(relevanciaDraft) {
  let totalPeso = 0;
  const result = {};

  for (const discId in relevanciaDraft) {
    const r = relevanciaDraft[discId];
    const imp = parseInt(r.importancia, 10) || 3;
    const con = parseInt(r.conhecimento, 10) || 3;

    // Conhecimento 0 → Fator 6 (muita atenção)
    // Conhecimento 5 → Fator 1 (pouca atenção)
    const fatorConhecimento = 6 - con;
    const peso = imp * fatorConhecimento;

    result[discId] = { importancia: imp, conhecimento: con, peso };
    totalPeso += peso;
  }

  for (const discId in result) {
    result[discId].percentual = totalPeso > 0 ? (result[discId].peso / totalPeso) * 100 : 0;
  }

  return result;
}
Inputs:
  • importancia (1-5): How critical this subject is for the exam
  • conhecimento (0-5): User’s current knowledge level
Formula:
fatorConhecimento = 6 - conhecimento
peso = importancia × fatorConhecimento
percentual = (peso / totalPeso) × 100
Logic:
  • Lower knowledge → Higher factor → More study time allocated
  • Higher importance → More study time allocated
Example:
SubjectImportanceKnowledgeFactorWeight%
Math5152550%
History342612%
Law4241632%
Portuguese35136%
Total = 50. Math gets 50% of study time.

Generate Study Plan

Location: logic.js:562-657
export function generatePlanejamento(draft) {
  const plan = {
    ativo: true,
    tipo: draft.tipo,
    disciplinas: draft.disciplinas,
    relevancia: calculateRelevanceWeights(draft.relevancia),
    horarios: draft.horarios,
    sequencia: [],
    ciclosCompletos: 0,
    dataInicioCicloAtual: new Date().toISOString()
  };

  if (plan.tipo === 'ciclo') {
    const horasSemanais = parseFloat(plan.horarios.horasSemanais) || 0;
    const totalMinutes = horasSemanais * 60;
    const minSessao = parseInt(plan.horarios.sessaoMin, 10) || 30;
    const maxSessao = parseInt(plan.horarios.sessaoMax, 10) || 120;

    const sortedDiscs = [...plan.disciplinas].sort((a, b) => {
      const wA = plan.relevancia[a]?.peso || 0;
      const wB = plan.relevancia[b]?.peso || 0;
      return wB - wA;
    });

    sortedDiscs.forEach(discId => {
      const perc = plan.relevancia[discId]?.percentual || 0;
      let targetMinutes = Math.round((perc / 100) * totalMinutes);

      if (targetMinutes < minSessao) targetMinutes = minSessao;

      let remaining = targetMinutes;
      while (remaining > 0) {
        let block = Math.min(remaining, maxSessao);
        if (block < minSessao && remaining === targetMinutes) {
          block = minSessao;
        } else if (block < minSessao && remaining < targetMinutes) {
          break;
        }
        plan.sequencia.push({
          id: 'seq_' + uid(),
          discId: discId,
          minutosAlvo: block,
          concluido: false
        });
        remaining -= block;
      }
    });
  }
  // ... similar logic for 'semanal' type

  state.planejamento = plan;
  syncCicloToEventos();
  scheduleSave();
  return plan;
}
1

Calculate Weights

Compute relevance percentages for each discipline based on importance and knowledge.
2

Distribute Time

Allocate total weekly hours proportionally to each subject’s weight.
3

Create Blocks

Split each subject’s time into study blocks (min 30min, max 120min).
4

Generate Sequence

Order blocks by priority (highest weight first) into a study sequence.
5

Sync to Events

Auto-generate study events for the next 14 days based on the sequence.

Summary

Timer Engine

Start/pause/resume with Pomodoro support and interval management

Spaced Repetition

Automatic revision scheduling with postponement support

Analytics

Study time, streaks, performance metrics, and weekly breakdowns

Planning

Weighted study plans based on importance and knowledge levels

Build docs developers (and LLMs) love