diff --git a/css/index.css b/css/index.css index 28fbfd3..abcffb5 100644 --- a/css/index.css +++ b/css/index.css @@ -1336,6 +1336,44 @@ body { gap: 24px; } +.focus-stats-panel { + width: 100%; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.focus-stat-card { + background: var(--color-background-secondary); + border: 1px solid var(--color-border-secondary); + border-radius: var(--border-radius-md); + padding: 18px 16px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.focus-stat-label { + font-size: 12px; + font-weight: 700; + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.focus-stat-value { + font-size: 24px; + font-weight: 700; + color: var(--color-text-primary); +} + +@media (max-width: 820px) { + .focus-stats-panel { + grid-template-columns: 1fr; + } +} + .timer-container { position: relative; width: 240px; @@ -4161,6 +4199,19 @@ body { .tasks-section { flex: 1; overflow-y: auto; padding: 0 24px 24px; scroll-behavior: smooth; } .tasks-section::-webkit-scrollbar { width: 6px; } .tasks-section::-webkit-scrollbar-thumb { background: var(--color-border-secondary); border-radius: 10px; } +.profile-section { flex: 1; overflow-y: auto; padding: 24px; display: flex; flex-direction: column; gap: 24px; } +.profile-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; } +.profile-page-title { font-size: 24px; font-weight: 700; color: var(--color-text-primary); } +.profile-page-subtitle { margin: 8px 0 0; color: var(--color-text-secondary); max-width: 640px; line-height: 1.6; } +.profile-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; } +.profile-card { background: var(--color-background-primary); border: 1px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); padding: 24px; box-shadow: var(--shadow-sm); } +.profile-card h2 { margin: 0 0 16px; font-size: 16px; font-weight: 700; color: var(--color-text-primary); } +.profile-field { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; font-size: 14px; color: var(--color-text-secondary); } +.profile-field-label { color: var(--color-text-tertiary); font-weight: 600; } +.profile-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.profile-stat-value { display: block; font-size: 22px; font-weight: 700; color: var(--color-text-primary); margin-bottom: 4px; } +.profile-summary-card p { margin: 0; color: var(--color-text-secondary); line-height: 1.8; } +@media (max-width: 900px) { .profile-grid { grid-template-columns: 1fr; } } .tasks-actions-bar { margin-top: 18px; display: flex; @@ -5018,7 +5069,6 @@ body { font-size: 14px; transition: opacity 0.2s ease; } - .subject-sidebar-item:hover .delete-subject-btn { opacity: 1; } diff --git a/database.js b/database.js index 33a1b17..311bf7f 100644 --- a/database.js +++ b/database.js @@ -33,6 +33,15 @@ function initDb() { FOREIGN KEY (subject_id) REFERENCES subjects(id) )`); + // Focus Sessions Table + db.run(`CREATE TABLE IF NOT EXISTS focus_sessions ( + id TEXT PRIMARY KEY, + task_id TEXT, + duration_seconds INTEGER NOT NULL, + completed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks(id) + )`); + db.all("PRAGMA table_info(tasks)", (err, rows) => { if (err) return; diff --git a/index.html b/index.html index 543be76..0bfbb85 100644 --- a/index.html +++ b/index.html @@ -62,7 +62,7 @@

StudyPlan

- +
@@ -191,6 +191,21 @@

StudyPlan

+ +
+
+
Sessions completed
+
0
+
+
+
Total focus time
+
0m
+
+
+
Current streak
+
0 days
+
+
Current Focus Task
@@ -207,6 +222,61 @@

StudyPlan

+ + + @@ -471,7 +541,9 @@

{ - if (e.target.checked) { + const isCompact = e.target.checked; + localStorage.setItem('studyplan_compact_view', isCompact); + if (isCompact) { document.body.classList.add('compact-view'); } else { document.body.classList.remove('compact-view'); diff --git a/js/app.js b/js/app.js index 2bc3bce..b2c53ae 100644 --- a/js/app.js +++ b/js/app.js @@ -99,6 +99,10 @@ const downloadBtn = document.getElementById('download-btn'); const calendarDownloadBtn = document.getElementById('calendar-download-btn'); const newTaskBtn = document.getElementById('add-task-btn'); const labelFilterSelect = document.getElementById('label-filter'); +const focusStatsPanel = document.getElementById('focus-stats-panel'); +const focusTotalSessions = document.getElementById('focus-total-sessions'); +const focusTotalTime = document.getElementById('focus-total-time'); +const focusCurrentStreak = document.getElementById('focus-current-streak'); if (labelFilterSelect) { labelFilterSelect.addEventListener('change', (e) => { @@ -323,16 +327,16 @@ function loadTimerState() { saveTimerState(); if (timeLeft <= 0) { - clearInterval(timerInterval); - timerInterval = null; - localStorage.removeItem('focusTimerState'); + clearInterval(timerInterval); + timerInterval = null; + localStorage.removeItem('focusTimerState'); - playCompletionSound(); - showBrowserNotification(); - Toast.show('Focus session complete!', 'success'); + playCompletionSound(); + showBrowserNotification(); + Toast.show('Focus session complete!', 'success'); - resetTimer(); -} + resetTimer(); + } }, 250); timerPauseBtn.classList.remove('hidden'); @@ -343,6 +347,7 @@ function loadTimerState() { console.error('Failed to load timer state', err); } } + function getTimerColor(timeLeft, totalTime) { const fraction = timeLeft / totalTime; if (fraction <= 0.1) return '#ef4444'; @@ -393,6 +398,53 @@ function showBrowserNotification() { } } +async function recordCompletedFocusSession(durationSeconds) { + await store.addFocusSession({ + task_id: activeFocusTaskId || null, + duration_seconds: durationSeconds, + completed_at: new Date().toISOString() + }); +} + +function formatSessionDuration(seconds) { + const mins = Math.floor(seconds / 60); + const hrs = Math.floor(mins / 60); + const remainder = mins % 60; + if (hrs > 0) { + return `${hrs}h ${remainder}m`; + } + return `${remainder}m`; +} + +function calculateFocusStreak(sessions) { + const daySet = new Set( + (sessions || []).map(s => new Date(s.completed_at).toISOString().substring(0, 10)) + ); + let streak = 0; + const today = new Date(); + today.setHours(0, 0, 0, 0); + let cursor = new Date(today); + + while (daySet.has(cursor.toISOString().substring(0, 10))) { + streak += 1; + cursor.setDate(cursor.getDate() - 1); + } + + return streak; +} + +function renderFocusStats() { + if (!focusStatsPanel) return; + const sessions = store.focusSessions || []; + const totalSessions = sessions.length; + const totalSeconds = sessions.reduce((acc, session) => acc + Number(session.duration_seconds || 0), 0); + const streak = calculateFocusStreak(sessions); + + focusTotalSessions.textContent = totalSessions; + focusTotalTime.textContent = formatSessionDuration(totalSeconds); + focusCurrentStreak.textContent = `${streak} day${streak === 1 ? '' : 's'}`; +} + function startTimer() { if (timerInterval) return; @@ -405,7 +457,7 @@ function startTimer() { startTime = Date.now() - (timePassed * 1000); saveTimerState(); - timerInterval = setInterval(() => { + timerInterval = setInterval(() => { timePassed = Math.floor((Date.now() - startTime) / 1000); timeLeft = TIME_LIMIT - timePassed; @@ -421,6 +473,7 @@ function startTimer() { playCompletionSound(); showBrowserNotification(); Toast.show('Focus session complete!', 'success'); + recordCompletedFocusSession(TIME_LIMIT).then(() => renderFocusStats()); resetTimer(); } }, 1000); @@ -578,6 +631,89 @@ function renderFocusTasks() { } } +function renderProfileSection() { + if (!profileSection) return; + + const tasks = store.tasks || []; + const subjects = store.subjects || []; + const completedCount = tasks.filter(t => t.status === 'Done').length; + const pendingCount = tasks.filter(t => t.status !== 'Done' && !t.archived).length; + const archivedCount = tasks.filter(t => t.archived).length; + const subjectsCount = subjects.length; + const username = localStorage.getItem('studyplan_username') || 'StudyPlan User'; + const email = localStorage.getItem('studyplan_email') || 'user@studyplan.app'; + const joinedDate = localStorage.getItem('studyplan_joined') || 'June 2026'; + + profileSection.innerHTML = ` +
+
+
Profile
+

View your account summary, study stats, and future account settings in one place.

+
+
+ +
+
+

Account details

+
+ Username + ${escapeHtml(username)} +
+
+ Email + ${escapeHtml(email)} +
+
+ Member since + ${escapeHtml(joinedDate)} +
+
+ +
+

Study statistics

+
+
+ ${completedCount} + Completed +
+
+ ${pendingCount} + Pending +
+
+ ${archivedCount} + Archived +
+
+ ${subjectsCount} + Subjects +
+
+
+
+ +
+

Account overview

+

Your profile information and study statistics will update automatically as you use StudyPlan.

+
+ `; +} + +function showProfileSection() { + currentView = 'profile'; + document.querySelector('.cal-section')?.classList.add('hidden'); + document.getElementById('tasks-section')?.classList.add('hidden'); + document.getElementById('focus-section')?.classList.add('hidden'); + profileSection?.classList.remove('hidden'); + topbar?.classList.add('hidden'); + renderProfileSection(); +} + +function hideProfileSection() { + profileSection?.classList.add('hidden'); + topbar?.classList.remove('hidden'); +} + function formatDate(dateStr) { if (!dateStr) return 'No Date'; const d = new Date(dateStr); @@ -1219,6 +1355,8 @@ store.subscribe(renderTasks); store.subscribe(renderExtraction); store.subscribe(renderCalendar); store.subscribe(renderFocusTasks); +store.subscribe(renderFocusStats); +store.subscribe(renderProfileSection); store.subscribe(renderSidebarSubjects); document.addEventListener('DOMContentLoaded', () => { @@ -1277,8 +1415,9 @@ document.addEventListener('DOMContentLoaded', () => { } store.fetchInitialData(); + store.fetchFocusSessions(); loadTimerState(); - + const calendarBtn = document.getElementById('calendar-btn'); const allTasksBtn = document.getElementById('all-tasks-btn'); const archivedTasksBtn = document.getElementById('archived-tasks-btn'); @@ -1291,6 +1430,7 @@ document.addEventListener('DOMContentLoaded', () => { calendarBtn.addEventListener('click', () => { currentView = 'calendar'; + hideProfileSection(); document.querySelector('.cal-section').classList.remove('hidden'); document.getElementById('tasks-section').classList.remove('hidden'); document.getElementById('focus-section').classList.add('hidden'); @@ -1300,6 +1440,7 @@ document.addEventListener('DOMContentLoaded', () => { allTasksBtn.addEventListener('click', () => { currentView = 'all-tasks'; + hideProfileSection(); document.querySelector('.cal-section').classList.add('hidden'); document.getElementById('tasks-section').classList.remove('hidden'); document.getElementById('focus-section').classList.add('hidden'); @@ -1309,6 +1450,7 @@ document.addEventListener('DOMContentLoaded', () => { archivedTasksBtn.addEventListener('click', () => { currentView = 'archived'; + hideProfileSection(); document.querySelector('.cal-section').classList.add('hidden'); document.getElementById('tasks-section').classList.remove('hidden'); document.getElementById('focus-section').classList.add('hidden'); @@ -1316,9 +1458,10 @@ document.addEventListener('DOMContentLoaded', () => { renderTasks(); }); - if(focusModeBtn) { + if (focusModeBtn) { focusModeBtn.addEventListener('click', () => { currentView = 'focus'; + hideProfileSection(); document.querySelector('.cal-section').classList.add('hidden'); document.getElementById('tasks-section').classList.add('hidden'); document.getElementById('focus-section').classList.remove('hidden'); @@ -1327,121 +1470,130 @@ document.addEventListener('DOMContentLoaded', () => { }); } + if (profileBtn) { + profileBtn.addEventListener('click', () => { + showProfileSection(); + }); + } + + document.getElementById('cal-prev').addEventListener('click', () => { + currentMonthDate.setMonth(currentMonthDate.getMonth() - 1); + renderCalendar(); + }); + document.getElementById('cal-next').addEventListener('click', () => { currentMonthDate.setMonth(currentMonthDate.getMonth() + 1); renderCalendar(); }); + // New Task addition event listeners + newTaskBtn.addEventListener('click', () => { + if (!store.subjects || store.subjects.length === 0) { + Toast.show('Subjects are still loading. Please try again in a moment.', 'warning'); + return; + } -//NEw Task addition event listeners -newTaskBtn.addEventListener('click', () => { - - if (!store.subjects || store.subjects.length === 0) { - Toast.show('Subjects are still loading. Please try again in a moment.', 'warning'); - return; - } - - newTaskSubject.innerHTML = store.subjects - .map(s => ``) - .join(''); - - - if (selectedDate) { - const d = new Date(selectedDate); - d.setHours(18, 0, 0, 0); - newTaskDate.value = d.toISOString().substring(0, 16); - } else { - newTaskDate.value = ''; - } + newTaskSubject.innerHTML = store.subjects + .map(s => ``) + .join(''); - newTaskTitle.value = ''; - newTaskNotes.value = ''; - if (newTaskEstimatedDuration) newTaskEstimatedDuration.value = ''; - setNewTaskDurationUnit('minutes'); + if (selectedDate) { + const d = new Date(selectedDate); + d.setHours(18, 0, 0, 0); + newTaskDate.value = d.toISOString().substring(0, 16); + } else { + newTaskDate.value = ''; + } - newTaskModal.style.display = 'flex'; -}); + newTaskTitle.value = ''; + newTaskNotes.value = ''; + if (newTaskEstimatedDuration) newTaskEstimatedDuration.value = ''; + setNewTaskDurationUnit('minutes'); -newTaskCancel.addEventListener('click', () => { - newTaskModal.style.display = 'none'; -}); + newTaskModal.style.display = 'flex'; + }); -newTaskModal.addEventListener('click', (e) => { - if (e.target === newTaskModal) { + newTaskCancel.addEventListener('click', () => { newTaskModal.style.display = 'none'; - } -}); + }); -function setNewTaskDurationUnit(unit) { - selectedTaskDurationUnit = unit; - newTaskDurationSwitch?.setAttribute('data-unit', unit); - newTaskDurationMin?.classList.toggle('active', unit === 'minutes'); - newTaskDurationHr?.classList.toggle('active', unit === 'hours'); -} + newTaskModal.addEventListener('click', (e) => { + if (e.target === newTaskModal) { + newTaskModal.style.display = 'none'; + } + }); -newTaskDurationMin?.addEventListener('click', () => setNewTaskDurationUnit('minutes')); -newTaskDurationHr?.addEventListener('click', () => setNewTaskDurationUnit('hours')); - -newTaskSave.addEventListener('click', async () => { - const rawTitle = newTaskTitle.value.trim(); - const subject_id = newTaskSubject.value; - const notes = newTaskNotes.value.trim(); - const dateVal = newTaskDate.value; - const durationValue = newTaskEstimatedDuration ? Number(newTaskEstimatedDuration.value) : 0; - const estimated_duration = durationValue > 0 - ? Math.round(selectedTaskDurationUnit === 'hours' ? durationValue * 60 : durationValue) - : null; - - if (!rawTitle) { - alert('Please enter a task name'); - return; + function setNewTaskDurationUnit(unit) { + selectedTaskDurationUnit = unit; + newTaskDurationSwitch?.setAttribute('data-unit', unit); + newTaskDurationMin?.classList.toggle('active', unit === 'minutes'); + newTaskDurationHr?.classList.toggle('active', unit === 'hours'); } - if (!dateVal) { - alert('Please enter a deadline'); - return; -} + newTaskDurationMin?.addEventListener('click', () => setNewTaskDurationUnit('minutes')); + newTaskDurationHr?.addEventListener('click', () => setNewTaskDurationUnit('hours')); + + newTaskSave.addEventListener('click', async () => { + const rawTitle = newTaskTitle.value.trim(); + const subject_id = newTaskSubject.value; + const notes = newTaskNotes.value.trim(); + const dateVal = newTaskDate.value; + const durationValue = newTaskEstimatedDuration ? Number(newTaskEstimatedDuration.value) : 0; + const estimated_duration = durationValue > 0 + ? Math.round(selectedTaskDurationUnit === 'hours' ? durationValue * 60 : durationValue) + : null; + + if (!rawTitle) { + alert('Please enter a task name'); + return; + } -if (!subject_id) { - alert('Please select a subject'); - return; -} - const { cleanTitle, labels } = extractLabels(rawTitle); - const due_at = dateVal ? new Date(dateVal).toISOString() : ''; - - const newTask = { - title: cleanTitle || rawTitle, - subject_id, - due_at, - notes, - priority: 'medium', - status: 'Not Started', - archived: 0, - estimated_duration, - is_estimated_duration_min: selectedTaskDurationUnit === 'minutes' ? 1 : 0, - labels - }; + if (!dateVal) { + alert('Please enter a deadline'); + return; + } - await store.addTasks([newTask]); - newTaskModal.style.display = 'none'; -}); + if (!subject_id) { + alert('Please select a subject'); + return; + } -addItemsBtn.addEventListener('click', () => { - if (store.currentPaste) { - const pasteWithLabels = store.currentPaste.map(t => { - const { cleanTitle, labels } = extractLabels(t.title); - return { ...t, title: cleanTitle || t.title, labels }; - }); - store.addTasks(pasteWithLabels); - store.clearExtracted(); - pasteInput.value = ''; - } -}); + const { cleanTitle, labels } = extractLabels(rawTitle); + const due_at = dateVal ? new Date(dateVal).toISOString() : ''; + + const newTask = { + title: cleanTitle || rawTitle, + subject_id, + due_at, + notes, + priority: 'medium', + status: 'Not Started', + archived: 0, + estimated_duration, + is_estimated_duration_min: selectedTaskDurationUnit === 'minutes' ? 1 : 0, + labels + }; + + await store.addTasks([newTask]); + newTaskModal.style.display = 'none'; + }); + + addItemsBtn.addEventListener('click', () => { + if (store.currentPaste) { + const pasteWithLabels = store.currentPaste.map(t => { + const { cleanTitle, labels } = extractLabels(t.title); + return { ...t, title: cleanTitle || t.title, labels }; + }); + store.addTasks(pasteWithLabels); + store.clearExtracted(); + pasteInput.value = ''; + } + }); }); // Ensures the button is hidden on initial page load if the textarea is empty if (pasteInput.value.trim() === "") { - clearBtn.style.display = 'none'; + clearBtn.style.display = 'none'; } extractBtn.addEventListener('click', async () => { @@ -1461,19 +1613,19 @@ extractBtn.addEventListener('click', async () => { // Wipes the text, clears the store, hides the button, and refocuses the cursor clearBtn.addEventListener('click', () => { - pasteInput.value = ''; - store.clearExtracted(); - clearBtn.style.display = 'none'; // Hides the clear button instantly - pasteInput.focus(); // Puts the typing cursor back in the box + pasteInput.value = ''; + store.clearExtracted(); + clearBtn.style.display = 'none'; + pasteInput.focus(); }); // Listens to typing/pasting to show or hide the button dynamically pasteInput.addEventListener('input', () => { - if (pasteInput.value.trim().length > 0) { - clearBtn.style.display = 'block'; - } else { - clearBtn.style.display = 'none'; - } + if (pasteInput.value.trim().length > 0) { + clearBtn.style.display = 'block'; + } else { + clearBtn.style.display = 'none'; + } }); downloadBtn.addEventListener('click', () => { diff --git a/js/store.js b/js/store.js index b78dd42..b5e0ef1 100644 --- a/js/store.js +++ b/js/store.js @@ -5,6 +5,7 @@ export const store = { subjects: [], tasks: [], currentPaste: null, + focusSessions: [], listeners: [], isSameCalendarDate(dateA, dateB) { @@ -358,6 +359,36 @@ export const store = { } }, + async fetchFocusSessions() { + try { + const res = await fetch('/api/focus-sessions'); + if (!res.ok) throw new Error('Failed to fetch focus sessions'); + this.focusSessions = await res.json(); + this.notify(); + } catch (e) { + console.error('Failed to load focus sessions', e); + } + }, + + async addFocusSession(sessionData) { + try { + const res = await fetch('/api/focus-sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sessionData) + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Failed to record focus session'); + } + + await this.fetchFocusSessions(); + } catch (e) { + console.error('Failed to save focus session', e); + } + }, + clearExtracted() { this.currentPaste = null; this.notify(); diff --git a/server.js b/server.js index 68dadff..e7b2fe8 100644 --- a/server.js +++ b/server.js @@ -356,6 +356,45 @@ app.get('/api/tasks', (req, res) => { }); }); +app.get('/api/focus-sessions', (req, res) => { + const query = ` + SELECT fs.*, t.title AS task_title + FROM focus_sessions fs + LEFT JOIN tasks t ON t.id = fs.task_id + ORDER BY datetime(fs.completed_at) DESC + `; + db.all(query, (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(rows); + }); +}); + +app.get('/api/focus-stats', (req, res) => { + db.all('SELECT duration_seconds, completed_at FROM focus_sessions ORDER BY datetime(completed_at) ASC', (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + + const totalSessions = rows.length; + const totalSeconds = rows.reduce((sum, row) => sum + Number(row.duration_seconds || 0), 0); + const daySet = new Set(rows.map(row => new Date(row.completed_at).toISOString().substring(0, 10))); + + let streak = 0; + const today = new Date(); + today.setHours(0, 0, 0, 0); + let cursor = new Date(today); + + while (daySet.has(cursor.toISOString().substring(0, 10))) { + streak += 1; + cursor.setDate(cursor.getDate() - 1); + } + + res.json({ + totalSessions, + totalSeconds, + currentStreakDays: streak, + }); + }); +}); + // ================= ADD TASKS ================= app.post('/api/tasks', (req, res) => { try { @@ -471,6 +510,25 @@ app.post('/api/tasks', (req, res) => { } }); +app.post('/api/focus-sessions', (req, res) => { + const { task_id, duration_seconds, completed_at } = req.body; + if (!Number.isFinite(Number(duration_seconds)) || Number(duration_seconds) <= 0) { + return res.status(400).json({ error: 'duration_seconds must be a positive number' }); + } + + const id = `focus_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const timestamp = completed_at ? completed_at : new Date().toISOString(); + + db.run( + 'INSERT INTO focus_sessions (id, task_id, duration_seconds, completed_at) VALUES (?, ?, ?, ?)', + [id, task_id || null, Number(duration_seconds), timestamp], + function (err) { + if (err) return res.status(500).json({ error: err.message }); + res.status(201).json({ success: true, id, task_id: task_id || null, duration_seconds: Number(duration_seconds), completed_at: timestamp }); + } + ); +}); + // ================= UPDATE ================= app.put('/api/tasks/:id', (req, res) => {