Skip to content

Core UI Features: Dashboard & Progress Tracking

Part 1 of 5 in the Core UI Features series.

Task 1 documented the infrastructure underneath Ediky — Firestore, SQLite, IPC. This series documents the layer students actually touch: the dashboard, notes, theming, search, notifications, and the performance work that keeps all of it fast. We start where every session starts — the dashboard.

What a student sees first

Dashboard.jsx (HomePage.jsx on desktop, a direct port) composes a stack of independent widgets under MainLayout: stat cards, a weekly activity chart (Recharts BarChart), a preparation breakdown (DSA / CS Fundamentals / Aptitude / CP), a grid of active-subject cards with per-subject percentages, an upcoming-OA panel, and a revision-due count. Each widget is its own component reading its own data source — nothing here is one big query.

That last point matters for accuracy: not every widget is wired to live data yet. SubjectsProgress.jsx is real, driven by useUserProgress(). StatsCards.jsx and WeeklyChart.jsx currently render fixture data while the aggregate-stats pipeline is finished, and the calendar-backed panels (CalendarPanel, upcoming-OA list) pull from the same event data that drives Scheduling & Reminders rather than a dashboard-specific source. Documenting that honestly is more useful than describing an idealized dashboard that doesn't match the repo — the architecture below is what's real today.

Real-time progress: web listener vs. desktop refresh

On web, useUserProgress opens a single Firestore onSnapshot on progress/{uid} when the dashboard mounts. Firestore pushes a new snapshot the instant any client writes a solve, and React re-renders with the new percentages — no polling, no manual refresh.

diagram100%
rendering diagram…

drag to pan · ctrl / ⌘ + scroll to zoom

Desktop can't hold an open socket to Firestore the way a browser tab can, so it doesn't try to. useUserProgress on desktop reads once from SQLite on mount (getMeta/setMeta) and stays put until something tells it to re-read. That "something" is a plain browser event — CONTENT_SYNCED_EVENT, dispatched on window after invoke("sync_content") finishes — not a Tauri backend push and not a polling loop. It's event-driven, just triggered by content sync rather than by Firestore's fanout. Desktop Features & Data Flows covers the SQLite side of that sync in full.

javascript
// Simplified real shape of useUserProgress (web)
function useUserProgress(uid) {
  const [progress, setProgress] = useState(FALLBACK_PROGRESS);
  useEffect(() => {
    return onSnapshot(doc(db, "progress", uid), (snap) => {
      setProgress(snap.data() ?? FALLBACK_PROGRESS);
    });
  }, [uid]);
  return progress;
}

// Desktop equivalent: read once, refresh on a named DOM event
function useUserProgressDesktop(uid) {
  const [progress, setProgress] = useState(FALLBACK_PROGRESS);
  const load = useCallback(() => getMeta(uid).then(setProgress), [uid]);
  useEffect(() => {
    load();
    window.addEventListener(CONTENT_SYNCED_EVENT, load);
    return () => window.removeEventListener(CONTENT_SYNCED_EVENT, load);
  }, [load]);
  return progress;
}
WebDesktop
Data sourceFirestore progress/{uid}Local SQLite (getMeta)
Update triggeronSnapshot fanout, instantCONTENT_SYNCED_EVENT after a sync completes
Works offlineNoYes
Cross-deviceInstantOnly after next sync
diagram100%
rendering diagram…

drag to pan · ctrl / ⌘ + scroll to zoom

Spaced repetition: what "revision due" actually means

src/lib/revision.js is not a flat "7 days later" rule — it's an expanding-interval schedule close to a simplified Anki. DEFAULT_INTERVALS = [1, 3, 7, 14, 30] (days). makeSchedule() chains concrete due-dates from an anchor date using those gaps; each slot is overdue, due-today, or upcoming depending on how dueAt compares to the start of today.

javascript
// Conceptual shape of the real algorithm
function makeSchedule(anchorDate, intervals = DEFAULT_INTERVALS) {
  return intervals.map((days) => ({
    dueAt: addDays(anchorDate, days),
    status: "pending",
  }));
}

function slotState(slot, now = new Date()) {
  if (slot.status !== "pending") return slot.status;
  if (slot.dueAt < startOfDay(now)) return "overdue";
  if (isSameDay(slot.dueAt, now)) return "due-today";
  return "upcoming";
}

Completing a revision doesn't just tick a box — completeNextSlot() marks the earliest pending slot done, then recomputePendingTail() re-chains the remaining pending slots from the completion time. Revise early and the rest of the schedule shifts earlier too; revise late and it shifts later. undoLastCompletion() reverses the most recent completion for corrections. The dashboard's "revision due" count is just slots.filter(s => slotState(s) !== "upcoming").length across all tracked items.

Why circular progress and streaks

Circular progress indicators read as "almost done" faster than a percentage number — the Gestalt closure effect makes a 90%-filled ring feel more urgent to complete than "90%" in text. Streaks (revisionStreak: consecutive days with at least one completed revision, ending today or yesterday) exploit loss aversion — breaking a streak feels like losing something already earned, which is a stronger motivator than the abstract goal of "revise consistently." Color coding follows one consistent rule across the app: green for solved/on-track, amber for due-soon, red for overdue — the same three-state palette ThemeContext exposes as CSS variables, covered next in Dark Mode, Split-Pane & Search.

Self-assessment

Word count for this page: 852 words.


→ Next: Notes System & Annotations

Ediky Workflow — internal engineering documentation.