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: Field Type Purpose _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' }
}));
}
Continuous Mode
Pomodoro Mode
Timer runs indefinitely until manually paused.
Timer auto-pauses after focus duration (default 25 min), plays alarm, and prompts for break.
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 ;
}
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
Algorithm
Load frequency intervals (default: [1, 7, 30, 90] days)
Skip intervals already completed (via feitas.length)
Add base date + adiamentos + remaining intervals
Return future dates in YYYY-MM-DD format
Output
Array of upcoming revision dates, e.g.: [ "2024-01-16" , "2024-01-22" , "2024-02-14" , "2024-04-15" ]
Example 1: First Revision
Example 2: After 2 Revisions
Example 3: Postponed by 3 Days
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 );
}
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 ;
}
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 ;
}
Calculate Weights
Compute relevance percentages for each discipline based on importance and knowledge.
Distribute Time
Allocate total weekly hours proportionally to each subject’s weight.
Create Blocks
Split each subject’s time into study blocks (min 30min, max 120min).
Generate Sequence
Order blocks by priority (highest weight first) into a study sequence.
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