Skip to main content

Overview

The store.js module is the single source of truth for application state in Estudo Organizado. It handles:
  • State Schema: Centralized application state
  • IndexedDB Persistence: Asynchronous storage with fallback to localStorage
  • Migrations: Schema versioning and data transformations
  • Save Debouncing: Optimized write operations
  • Sync Queue: Sequential cloud sync operations

State Schema

Default State Structure

Location: store.js:36-57
export let state = {
  schemaVersion: DEFAULT_SCHEMA_VERSION,
  ciclo: { ativo: false, ciclosCompletos: 0, disciplinas: [] },
  planejamento: { 
    ativo: false, 
    tipo: null, 
    disciplinas: [], 
    relevancia: {}, 
    horarios: {}, 
    sequencia: [], 
    ciclosCompletos: 0, 
    dataInicioCicloAtual: null 
  },
  editais: [],
  eventos: [],
  arquivo: [], // concluded events older than 90 days
  habitos: { 
    questoes: [], 
    revisao: [], 
    discursiva: [], 
    simulado: [], 
    leitura: [], 
    informativo: [], 
    sumula: [], 
    videoaula: [] 
  },
  revisoes: [],
  config: {
    visualizacao: 'mes',
    primeirodiaSemana: 1,
    mostrarNumeroSemana: false,
    agruparEventos: true,
    frequenciaRevisao: [1, 7, 30, 90],
    materiasPorDia: 3
  },
  cronoLivre: { _timerStart: null, tempoAcumulado: 0 },
  bancaRelevance: { hotTopics: [], userMappings: {}, lessonMappings: {} },
  driveFileId: null,
  lastSync: null
};
PropertyTypePurpose
schemaVersionnumberCurrent schema version for migrations
cicloobjectStudy cycle configuration and progress
planejamentoobjectAdvanced study planning (weekly/cycle)
editaisarrayExam syllabi with disciplines and topics
eventosarrayStudy events (scheduled, in-progress, completed)
arquivoarrayArchived old events for data cleanup
habitosobjectHabit tracking logs (questions, simulations, etc)
revisoesarrayRevision history
configobjectUser preferences and settings
cronoLivreobjectFree-form timer state
bancaRelevanceobjectExam board analysis data
driveFileIdstringGoogle Drive file ID for sync
lastSyncstringLast sync timestamp

Database Initialization

IndexedDB Setup

Location: store.js:60-84
export function initDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onerror = (event) => {
      console.error('IndexedDB Error:', event.target.error);
      loadLegacyState(); // Fallback to localStorage
      resolve();
    };

    request.onupgradeneeded = (event) => {
      db = event.target.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME);
      }
    };

    request.onsuccess = (event) => {
      db = event.target.result;
      loadStateFromDB().then(() => {
        resolve();
      });
    };
  });
}
Database Constants:
  • DB_NAME: 'EstudoOrganizadoDB'
  • DB_VERSION: 1
  • STORE_NAME: 'app_state'
  • DEFAULT_SCHEMA_VERSION: 7

Loading State from IndexedDB

Location: store.js:86-123
export function loadStateFromDB() {
  return new Promise((resolve) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const store = transaction.objectStore(STORE_NAME);
    const request = store.get('main_state');

    request.onsuccess = (event) => {
      if (request.result) {
        const loadedState = request.result;

        // BUG FIX: Prevent timer persistence across sessions
        const isSameSession = sessionStorage.getItem('estudo_session_active');
        if (!isSameSession) {
          if (loadedState.cronoLivre && loadedState.cronoLivre._timerStart) {
            loadedState.cronoLivre._timerStart = null;
          }
          if (loadedState.eventos) {
            loadedState.eventos.forEach(ev => {
              if (ev._timerStart) ev._timerStart = null;
            });
          }
        }
        sessionStorage.setItem('estudo_session_active', '1');

        setState(loadedState);
        runMigrations();
      } else {
        loadLegacyState(); // Try migration from localStorage
      }
      resolve();
    };

    request.onerror = () => {
      loadLegacyState();
      resolve();
    };
  });
}
Timer Reset on New SessionTo prevent timer drift when the app is closed and reopened, all _timerStart timestamps are cleared unless the session is continuous (tracked via sessionStorage).

State Normalization

The setState() Function

Location: store.js:14-34
export function setState(newState) {
  const normalized = {
    schemaVersion: newState.schemaVersion || DEFAULT_SCHEMA_VERSION,
    ciclo: newState.ciclo || { ativo: false, ciclosCompletos: 0, disciplinas: [] },
    planejamento: newState.planejamento || { 
      ativo: false, 
      tipo: null, 
      disciplinas: [], 
      relevancia: {}, 
      horarios: {}, 
      sequencia: [], 
      ciclosCompletos: 0, 
      dataInicioCicloAtual: null 
    },
    editais: newState.editais || [],
    eventos: newState.eventos || [],
    arquivo: newState.arquivo || [],
    habitos: Object.assign({ 
      questoes: [], 
      revisao: [], 
      discursiva: [], 
      simulado: [], 
      leitura: [], 
      informativo: [], 
      sumula: [], 
      videoaula: [] 
    }, newState.habitos || {}),
    revisoes: newState.revisoes || [],
    config: Object.assign({ 
      visualizacao: 'mes', 
      primeirodiaSemana: 1, 
      mostrarNumeroSemana: false, 
      agruparEventos: true, 
      frequenciaRevisao: [1, 7, 30, 90], 
      materiasPorDia: 3 
    }, newState.config || {}),
    cronoLivre: newState.cronoLivre || { _timerStart: null, tempoAcumulado: 0 },
    bancaRelevance: newState.bancaRelevance || { hotTopics: [], userMappings: {}, lessonMappings: {} },
    driveFileId: newState.driveFileId || null,
    lastSync: newState.lastSync || null
  };

  // Replace the state object properties instead of the reference
  Object.keys(state).forEach(k => delete state[k]);
  Object.assign(state, normalized);
}
Why Not Reassign state?Direct reassignment (state = newState) would break references. Instead, we mutate the existing object in place using Object.assign() to preserve reactivity.

Saving State

Debounced Save with scheduleSave()

Location: store.js:182-192
export function scheduleSave() {
  document.dispatchEvent(new Event('app:invalidateCaches'));
  if (saveTimeout) clearTimeout(saveTimeout);

  // Update badges instantly without waiting for the save
  document.dispatchEvent(new Event('app:updateBadges'));

  saveTimeout = setTimeout(() => {
    saveStateToDB();
  }, 2000); // 2 second debounce
}
Frequent state updates (e.g., timer ticking every second) would cause excessive writes to IndexedDB. Debouncing ensures we only save after 2 seconds of inactivity.

Immediate Save with saveStateToDB()

Location: store.js:195-219
export function saveStateToDB() {
  if (saveTimeout) {
    clearTimeout(saveTimeout);
    saveTimeout = null;
  }
  if (!db) return Promise.resolve();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readwrite');
    const store = transaction.objectStore(STORE_NAME);
    const request = store.put(state, 'main_state');

    request.onsuccess = () => {
      document.dispatchEvent(new Event('stateSaved'));

      // Cascade Sync: Local -> Cloudflare
      if (state.config && state.config.cfSyncSyncEnabled) {
        SyncQueue.add(() => pushToCloudflare());
      }

      resolve();
    };
    request.onerror = (e) => reject(e);
  });
}

Sync Queue

The sync queue ensures cloud operations run sequentially, preventing race conditions. Location: store.js:151-180
export const SyncQueue = {
  isProcessing: false,
  tasks: [],
  add(taskFn) {
    return new Promise((resolve, reject) => {
      this.tasks.push(async () => {
        try {
          await taskFn();
          resolve();
        } catch (err) {
          reject(err);
        }
      });
      this.process();
    });
  },
  async process() {
    if (this.isProcessing) return;
    this.isProcessing = true;
    while (this.tasks.length > 0) {
      const fn = this.tasks.shift();
      try {
        await fn();
      } catch (err) {
        console.error('SyncQueue Error:', err);
      }
    }
    this.isProcessing = false;
  }
};
SyncQueue.add(() => pushToCloudflare());

Schema Migrations

Migration System

As the app evolves, the state schema changes. The migration system ensures backward compatibility. Location: store.js:222-343
1

Version 2: Add Events & Config

if (!state.schemaVersion || state.schemaVersion < 2) {
  if (!state.eventos) state.eventos = [];
  if (!state.revisoes) state.revisoes = [];
  if (!state.config) state.config = { visualizacao: 'mes', agruparEventos: true };
  if (!state.config.frequenciaRevisao) state.config.frequenciaRevisao = [1, 7, 30, 90];
  if (!state.habitos) state.habitos = { questoes: [], revisao: [], ... };
  
  // Add missing IDs to existing data
  state.editais.forEach(ed => {
    if (!ed.id) ed.id = 'ed_' + uid();
    if (!ed.cor) ed.cor = '#10b981';
    // ... more fixes
  });
  
  state.schemaVersion = 2;
  changed = true;
}
2

Version 3: Archive System

if (state.schemaVersion === 2) {
  if (!state.arquivo) state.arquivo = [];
  if (state.config.frequenciaRevisao && typeof state.config.frequenciaRevisao === 'string') {
    state.config.frequenciaRevisao = state.config.frequenciaRevisao.split(',').map(Number).filter(n => !isNaN(n));
  }
  state.schemaVersion = 3;
  changed = true;
}
3

Version 4: Study Cycles

if (state.schemaVersion === 3) {
  if (!state.ciclo) {
    state.ciclo = { ativo: false, ciclosCompletos: 0, disciplinas: [] };
  }
  state.schemaVersion = 4;
  changed = true;
}
4

Version 5: Planning Module

if (state.schemaVersion === 4) {
  if (!state.planejamento) {
    state.planejamento = { 
      ativo: false, 
      tipo: null, 
      disciplinas: [], 
      relevancia: {}, 
      horarios: {}, 
      sequencia: [] 
    };
  }
  state.schemaVersion = 5;
  changed = true;
}
5

Version 7: Lessons vs Topics

if (state.schemaVersion < 7) {
  if (!state.bancaRelevance) state.bancaRelevance = { hotTopics: [], userMappings: {}, lessonMappings: {} };
  if (!state.bancaRelevance.lessonMappings) state.bancaRelevance.lessonMappings = {};

  const classRegex = /(^aula\s*\d+)|(^modulo\s*\d+)/i;

  state.editais.forEach(ed => {
    ed.disciplinas.forEach(d => {
      if (!d.aulas) d.aulas = [];
      
      const remainingAssuntos = [];
      d.assuntos.forEach(ass => {
        if (classRegex.test(ass.nome.trim())) {
          // It's a lesson! Move to d.aulas
          const newAula = {
            id: 'aula_' + uid(),
            legacyAssid: ass.id,
            nome: ass.nome,
            descricao: ass.descricao || '',
            estudada: !!ass.concluido,
            dataEstudo: ass.dataConclusao || null,
            progress: 0,
            linkedAssuntoIds: [],
            _migratedFromV6: true
          };
          d.aulas.push(newAula);
        } else {
          remainingAssuntos.push(ass);
        }
      });

      d.assuntos = remainingAssuntos;
    });
  });

  state.schemaVersion = 7;
  changed = true;
}
This migration separates course lessons (“Aula 1”, “Módulo 2”) from exam topics for better organization.

Data Cleanup

Clear All Data

Location: store.js:346-366
export function clearData() {
  setState({
    schemaVersion: DEFAULT_SCHEMA_VERSION,
    ciclo: { ativo: false, ciclosCompletos: 0, disciplinas: [] },
    planejamento: { ativo: false, tipo: null, disciplinas: [], relevancia: {}, horarios: {}, sequencia: [], ciclosCompletos: 0, dataInicioCicloAtual: null },
    editais: [],
    eventos: [],
    arquivo: [],
    habitos: { questoes: [], revisao: [], discursiva: [], simulado: [], leitura: [], informativo: [], sumula: [], videoaula: [] },
    revisoes: [],
    config: { visualizacao: 'mes', primeirodiaSemana: 1, mostrarNumeroSemana: false, agruparEventos: true, frequenciaRevisao: [1, 7, 30, 90] },
    cronoLivre: { _timerStart: null, tempoAcumulado: 0 },
    bancaRelevance: { hotTopics: [], userMappings: {}, lessonMappings: {} },
    driveFileId: null,
    lastSync: null
  });
  saveStateToDB().then(() => {
    document.dispatchEvent(new CustomEvent('app:showToast', { detail: { msg: 'Dados apagados com sucesso.', type: 'info' } }));
    document.dispatchEvent(new Event('app:renderCurrentView'));
  });
}
This action is irreversible!Users should be prompted with a double-confirmation before calling clearData().

Summary

State Management

Centralized state object with normalized schema via setState()

Persistence

IndexedDB with localStorage fallback, debounced saves, and unsaved warning

Migrations

Schema versioning from v2 to v7 with backward compatibility

Sync Queue

Sequential cloud operations to prevent race conditions

Build docs developers (and LLMs) love