From 7acbae22e9b54fde692bd58c15b1f510d12bff51 Mon Sep 17 00:00:00 2001 From: Ishita Zope Date: Tue, 26 May 2026 23:55:07 +0530 Subject: [PATCH 1/2] feat: add immersive reading streak calendar --- TODO.md | 25 + TODO_READING_STREAK.md | 10 + frontend/css/reading-streak.css | 400 ++++++++++++++++ .../css/style-responsive-reading-streak.css | 21 + frontend/css/style.css | 1 + frontend/js/reading-streak.js | 438 ++++++++++++++++++ frontend/pages/library.html | 44 +- scratch/test_streak.js | 180 +++++++ 8 files changed, 1118 insertions(+), 1 deletion(-) create mode 100644 TODO.md create mode 100644 TODO_READING_STREAK.md create mode 100644 frontend/css/reading-streak.css create mode 100644 frontend/css/style-responsive-reading-streak.css create mode 100644 frontend/js/reading-streak.js create mode 100644 scratch/test_streak.js diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..18a6cac40 --- /dev/null +++ b/TODO.md @@ -0,0 +1,25 @@ +# TODO.md — Issue #608: Reading Streak Calendar + +- [x] Inspect/finish `frontend/js/reading-streak.js` implementation (complete required functions and rendering). + +- [x] Add Reading Streak UI sections to `frontend/pages/library.html` inside My Library area. + + +- [x] Add CSS for glassmorphism card + compact heatmap + cozy button styling (via `frontend/css/reading-streak.css`). + + +- [x] Add responsive tweaks in `frontend/css/style-responsive.css` (avoid horizontal overflow on small screens). + + +- [x] Wire JS to render on `DOMContentLoaded` and connect "Mark Today as Read" button (ids + script included in library.html). + +- [x] Verify localStorage persistence under `bibliodrift-reading-streak`. + + +- [x] Verify streak calculations: current streak from today backward, longest streak consecutive runs. + +- [x] Verify duplicate click prevention + immediate UI refresh. + +- [x] Quick manual test in light + night mode; ensure heatmap is visible. + + diff --git a/TODO_READING_STREAK.md b/TODO_READING_STREAK.md new file mode 100644 index 000000000..363995e3c --- /dev/null +++ b/TODO_READING_STREAK.md @@ -0,0 +1,10 @@ +# Reading Streak Heatmap (Issue #608) — Implementation Checklist + +- [ ] Add Reading Streak UI section + ids + script include to `frontend/pages/library.html` +- [ ] Add streak heatmap glassmorphism + cell intensity + current/longest typography to `frontend/css/style.css` +- [ ] Add responsive overflow/size safeguards to `frontend/css/style-responsive.css` +- [ ] Wire `reading-streak.js` via ids and ensure `DOMContentLoaded` init runs +- [ ] Verify localStorage persistence under `bibliodrift-reading-streak` +- [ ] Verify streak calculations (current from today backward, longest consecutive) +- [ ] Manual test light/night + refresh + duplicate click prevention + diff --git a/frontend/css/reading-streak.css b/frontend/css/reading-streak.css new file mode 100644 index 000000000..26f51082b --- /dev/null +++ b/frontend/css/reading-streak.css @@ -0,0 +1,400 @@ +/* Reading Streak (Issue #608) + Cozy glassmorphism streak heatmap for My Library - GitHub Green Edition +*/ + +.reading-streak-section { + width: 100%; + margin: 3rem 0 2.5rem; +} + +.reading-streak-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.08)); + border-radius: 24px; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: var(--shadow-soft, 0 8px 32px 0 rgba(0, 0, 0, 0.2)); + padding: 1.75rem; + transition: all 0.3s ease; +} + +.reading-streak-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--glass-border, rgba(255, 255, 255, 0.06)); + padding-bottom: 1.25rem; +} + +.reading-streak-title { + display: flex; + align-items: center; + gap: 0.8rem; + color: var(--theme-text, var(--text-main, #fff5f5)); +} + +.reading-streak-title i { + font-size: 1.8rem; + color: #39d353; /* vibrant green flame to match the github vibe */ + filter: drop-shadow(0 0 8px rgba(57, 211, 83, 0.4)); + animation: pulse-glow 2s infinite ease-in-out; +} + +.reading-streak-title h2 { + font-size: 1.6rem; + margin: 0; + font-family: "Georgia", serif; + font-weight: 700; + letter-spacing: -0.02em; +} + +.reading-streak-stats { + display: flex; + gap: 1.25rem; +} + +.reading-streak-stat { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.08)); + border-radius: 16px; + padding: 0.75rem 1.25rem; + min-width: 110px; + text-align: center; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.reading-streak-stat:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-color: rgba(57, 211, 83, 0.25); +} + +.reading-streak-stat-value { + font-size: 2rem; + font-weight: 700; + color: #39d353; /* GitHub bright green */ + line-height: 1.1; + text-shadow: 0 0 10px rgba(57, 211, 83, 0.2); +} + +.reading-streak-stat-label { + color: var(--theme-text-muted, var(--text-muted, #7f8c8d)); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-top: 0.25rem; + font-weight: 600; +} + +.reading-streak-heatmap-wrap { + margin-top: 0.5rem; +} + +.reading-streak-heatmap-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.reading-streak-btn { + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.6rem; + padding: 0.75rem 1.5rem; + border-radius: 999px; + background: rgba(57, 211, 83, 0.1); + border: 1px solid rgba(57, 211, 83, 0.25); + color: #39d353; + font-family: "Georgia", serif; + font-weight: 700; + font-size: 0.9rem; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.reading-streak-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(57, 211, 83, 0.25); + background: rgba(57, 211, 83, 0.2); + border-color: rgba(57, 211, 83, 0.4); +} + +.reading-streak-btn:disabled { + opacity: 0.65; + cursor: not-allowed; + transform: none; + box-shadow: none; + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); + color: var(--theme-text-muted, var(--text-muted, #7f8c8d)); +} + +.reading-streak-scroll { + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 12px; + margin-bottom: 1rem; + position: relative; + border-radius: 12px; +} + +.reading-streak-scroll::-webkit-scrollbar { + height: 6px; +} + +.reading-streak-scroll::-webkit-scrollbar-thumb { + background: rgba(57, 211, 83, 0.2); + border-radius: 999px; +} + +.reading-streak-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(57, 211, 83, 0.4); +} + +/* Heatmap container */ +.reading-streak-heatmap-container { + display: flex; + position: relative; + width: max-content; + min-width: 100%; +} + +/* Weekdays column (sticky to the left) */ +.reading-streak-weekdays { + display: flex; + flex-direction: column; + justify-content: space-between; + padding-right: 12px; + margin-top: 1.5rem; /* push down to align with rows under month labels */ + height: calc(12px * 7 + 0.35rem * 6); + position: sticky; + left: 0; + background: var(--theme-bg, #1a1614); /* adapts perfectly to mood theme */ + z-index: 10; + user-select: none; + border-right: 1px solid rgba(255, 255, 255, 0.03); +} + +.weekday-label { + height: 12px; + line-height: 12px; + font-size: 0.65rem; + color: var(--theme-text-muted, var(--text-muted, #7f8c8d)); + font-weight: 600; + display: flex; + align-items: center; +} + +/* Grid Wrapper containing months and columns */ +.reading-streak-grid-wrapper { + position: relative; + flex-grow: 1; + padding-top: 1.5rem; /* space for floated month labels */ + padding-left: 8px; +} + +/* Month labels positioned absolutely above starting week columns */ +.reading-streak-month-label { + position: absolute; + top: 0; + left: 8px; /* small left padding to match column gap */ + font-size: 0.65rem; + color: var(--theme-text-muted, var(--text-muted, #7f8c8d)); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + pointer-events: none; +} + +.reading-streak-grid { + display: flex; + gap: 0.35rem; +} + +.reading-streak-col { + display: flex; + flex-direction: column; + gap: 0.35rem; + position: relative; /* essential for absolute positioning of month labels relative to week columns */ +} + +/* Standardized cell */ +.reading-streak-cell { + width: 12px; + height: 12px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.03); + padding: 0; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + box-sizing: border-box; +} + +/* Inactive (No reading) */ +.reading-streak-cell.inactive { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.02); +} + +/* Future/Out of window cells */ +.reading-streak-cell-empty { + visibility: hidden; /* hide nicely rather than display block, maintaining grid geometry */ +} + +/* Intensity levels (GitHub Green Theme) */ +.reading-streak-cell.active { + background: #0e4429; + border-color: rgba(14, 68, 41, 0.5); + box-shadow: 0 0 6px rgba(14, 68, 41, 0.2); +} + +.reading-streak-cell.active2 { + background: #006d32; + border-color: rgba(0, 109, 50, 0.5); + box-shadow: 0 0 8px rgba(0, 109, 50, 0.3); +} + +.reading-streak-cell.active3 { + background: #26a641; + border-color: rgba(38, 166, 65, 0.5); + box-shadow: 0 0 10px rgba(38, 166, 65, 0.4); +} + +.reading-streak-cell.active4 { + background: #39d353; + border-color: rgba(57, 211, 83, 0.6); + box-shadow: 0 0 14px rgba(57, 211, 83, 0.6); +} + +/* Hover effect on cells */ +.reading-streak-cell:hover:not(.reading-streak-cell-empty) { + transform: scale(1.3); + z-index: 5; + border-color: rgba(255, 255, 255, 0.3); + box-shadow: 0 0 10px rgba(255, 255, 255, 0.2); +} + +/* Footer / Legend */ +.reading-streak-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1.25rem; + flex-wrap: wrap; + gap: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--glass-border, rgba(255, 255, 255, 0.06)); +} + +.reading-streak-info { + font-size: 0.75rem; + color: var(--theme-text-muted, var(--text-muted, #7f8c8d)); + font-weight: 500; +} + +.reading-streak-info strong { + color: var(--theme-text, var(--text-main, #fff5f5)); + font-weight: 700; +} + +.reading-streak-legend { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + color: var(--theme-text-muted, var(--text-muted, #7f8c8d)); + font-weight: 500; +} + +.reading-streak-legend span { + margin: 0 0.15rem; +} + +.reading-streak-legend .reading-streak-cell { + cursor: default; +} + +.reading-streak-legend .reading-streak-cell:hover { + transform: none; + box-shadow: none; +} + +@keyframes pulse-glow { + 0%, 100% { + filter: drop-shadow(0 0 4px rgba(57, 211, 83, 0.3)); + transform: scale(1); + } + 50% { + filter: drop-shadow(0 0 12px rgba(57, 211, 83, 0.6)); + transform: scale(1.08); + } +} + +/* Media query refinements */ +@media (max-width: 768px) { + .reading-streak-card { + padding: 1.25rem; + border-radius: 20px; + } + + .reading-streak-header { + gap: 1rem; + margin-bottom: 1.25rem; + } + + .reading-streak-stats { + width: 100%; + justify-content: space-between; + } + + .reading-streak-stat { + min-width: 0; + flex: 1; + padding: 0.6rem 0.8rem; + } + + .reading-streak-stat-value { + font-size: 1.75rem; + } + + .reading-streak-weekdays { + padding-right: 8px; + height: calc(10px * 7 + 0.25rem * 6); + } + + .weekday-label { + font-size: 0.6rem; + height: 10px; + line-height: 10px; + } + + .reading-streak-cell { + width: 10px; + height: 10px; + border-radius: 2px; + } + + .reading-streak-grid { + gap: 0.25rem; + } + + .reading-streak-col { + gap: 0.25rem; + } + + .reading-streak-footer { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .reading-streak-legend { + width: 100%; + justify-content: flex-end; + } +} diff --git a/frontend/css/style-responsive-reading-streak.css b/frontend/css/style-responsive-reading-streak.css new file mode 100644 index 000000000..2e684d1ff --- /dev/null +++ b/frontend/css/style-responsive-reading-streak.css @@ -0,0 +1,21 @@ +/* Reading Streak responsive tweaks + (separate file to avoid editing large style-responsive.css) +*/ + +@media (max-width: 768px) { + .reading-streak-heatmap-toolbar { + justify-content: center; + } + + .reading-streak-card { + padding: 1.1rem; + } + + /* Make sure the heatmap never forces horizontal overflow */ + .reading-streak-scroll { + width: 100%; + max-width: 100%; + overflow-x: auto; + } +} + diff --git a/frontend/css/style.css b/frontend/css/style.css index a5e56662e..72625834a 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -3619,6 +3619,7 @@ textarea:focus { /* --- Merge Conflict Resolution: Added styles from feat/issue-21-api-errors --- */ /* Empty State and Error Handling */ .empty-state { + display: flex; flex-direction: column; align-items: center; diff --git a/frontend/js/reading-streak.js b/frontend/js/reading-streak.js new file mode 100644 index 000000000..c9ff0bf08 --- /dev/null +++ b/frontend/js/reading-streak.js @@ -0,0 +1,438 @@ +/** + * BiblioDrift — Reading Streak Calendar (Issue #608) + * Local-only daily reading tracker persisted in localStorage. + * Premium GitHub-style Green heat-map with sticky weekdays, months headers, and legend. + */ + +const ReadingStreak = (() => { + const STORAGE_KEY = 'bibliodrift-reading-streak'; + const CELL_TYPES = { inactive: 0, active: 1, active2: 2, active3: 3, active4: 4 }; + const DAYS_TO_TRACK = 365; // Track 1 full year for a proper GitHub-like view + + // Robust helper to format Date objects as local YYYY-MM-DD, avoiding timezone shift bugs + function formatDateLocal(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + function getTodayDate() { + return formatDateLocal(new Date()); + } + + function _safeParse(json, fallback) { + try { + return JSON.parse(json); + } catch { + return fallback; + } + } + + function loadReadingData() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const data = _safeParse(raw, {}); + return data && typeof data === 'object' ? data : {}; + } catch { + return {}; + } + } + + function saveReadingData(data) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data || {})); + return true; + } catch { + return false; + } + } + + function _getDateNDaysAgo(n) { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - n); + return formatDateLocal(d); + } + + function markTodayAsRead() { + const today = getTodayDate(); + const data = loadReadingData(); + + // Prevent duplicate marks (idempotent) + if (data[today]) { + return false; + } + + data[today] = 1; + + // Prune anything older than our window to keep storage tidy + const cutoff = new Date(); + cutoff.setHours(0, 0, 0, 0); + cutoff.setDate(cutoff.getDate() - (DAYS_TO_TRACK + 2)); + + Object.keys(data).forEach((dateStr) => { + if (new Date(dateStr) < cutoff) delete data[dateStr]; + }); + + saveReadingData(data); + renderUI(); + return true; + } + + function calculateCurrentStreak(data) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const todayStr = formatDateLocal(today); + let startPoint = new Date(today); + + // Resilient current streak logic (check yesterday if today isn't read yet) + if (!data[todayStr]) { + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = formatDateLocal(yesterday); + if (data[yesterdayStr]) { + startPoint = yesterday; + } else { + return 0; // Streak is broken + } + } + + let streak = 0; + while (true) { + const dateStr = formatDateLocal(startPoint); + if (!data[dateStr]) break; + streak += 1; + startPoint.setDate(startPoint.getDate() - 1); + } + + return streak; + } + + function calculateLongestStreak(data) { + const dateKeys = Object.keys(data).filter((k) => data[k]); + if (dateKeys.length === 0) return 0; + + // Sort ascending so we can build consecutive runs + const dates = dateKeys.map((d) => new Date(d + 'T00:00:00')); + dates.sort((a, b) => a - b); + + let longest = 1; + let current = 1; + + for (let i = 1; i < dates.length; i++) { + const diffDays = Math.round((dates[i] - dates[i - 1]) / (24 * 60 * 60 * 1000)); + if (diffDays === 1) { + current += 1; + longest = Math.max(longest, current); + } else { + current = 1; + } + } + + return longest; + } + + function _intensityClassForIndex(activeIndex /* 0..364 */) { + // Map activity presence to intensity (1..4) so the most recent day (today) + // always appears as green 'active4'. + const clamped = Math.max(0, Math.min(DAYS_TO_TRACK - 1, activeIndex)); + const normalized = clamped / (DAYS_TO_TRACK - 1); // 0..1 + const bucket = Math.max(1, Math.ceil(normalized * 4)); // 1..4 + + return bucket === 1 + ? 'active' + : bucket === 2 + ? 'active2' + : bucket === 3 + ? 'active3' + : 'active4'; + } + + function renderCalendar() { + const container = document.getElementById('reading-streak-calendar'); + if (!container) return; + + const data = loadReadingData(); + + // Track last 365 days including today + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const start = new Date(today); + start.setDate(start.getDate() - (DAYS_TO_TRACK - 1)); + + // Align to week start (Sunday) for a GitHub-like look + const startAligned = new Date(start); + startAligned.setDate(startAligned.getDate() - startAligned.getDay()); + + // Determine number of weeks to render + const weeks = Math.ceil((DAYS_TO_TRACK + start.getDay()) / 7) + 1; + + const grid = document.createElement('div'); + grid.className = 'reading-streak-grid'; + + let activeCounter = 0; + const labeledMonths = new Set(); + + for (let w = 0; w < weeks; w++) { + const col = document.createElement('div'); + col.className = 'reading-streak-col'; + + // Detect months to render floated labels above correct columns + let monthLabelText = null; + let weekMonths = []; + + for (let d = 0; d < 7; d++) { + const cellDate = new Date(startAligned); + cellDate.setDate(startAligned.getDate() + w * 7 + d); + if (cellDate >= start && cellDate <= today) { + const m = cellDate.toLocaleDateString('en-US', { month: 'short' }); + if (!weekMonths.includes(m)) { + weekMonths.push(m); + } + } + } + + if (w === 0 && weekMonths.length > 0) { + monthLabelText = weekMonths[0]; + labeledMonths.add(monthLabelText); + } else { + for (const m of weekMonths) { + if (!labeledMonths.has(m)) { + // Check if this week contains the 1st day of month 'm' + let containsFirst = false; + for (let d = 0; d < 7; d++) { + const cellDate = new Date(startAligned); + cellDate.setDate(startAligned.getDate() + w * 7 + d); + if (cellDate.getDate() === 1 && cellDate.toLocaleDateString('en-US', { month: 'short' }) === m) { + containsFirst = true; + break; + } + } + if (containsFirst) { + monthLabelText = m; + labeledMonths.add(m); + break; + } + } + } + } + + if (monthLabelText) { + const span = document.createElement('span'); + span.className = 'reading-streak-month-label'; + span.textContent = monthLabelText; + col.appendChild(span); + } + + // Render 7 days per week + for (let d = 0; d < 7; d++) { + const cellDate = new Date(startAligned); + cellDate.setDate(startAligned.getDate() + w * 7 + d); + + // Future days: empty gridcell placeholder to maintain structure + if (cellDate > today) { + const cell = document.createElement('div'); + cell.className = 'reading-streak-cell reading-streak-cell-empty'; + col.appendChild(cell); + continue; + } + + const dateStr = formatDateLocal(cellDate); + const inWindow = cellDate >= start; + const isActive = !!data[dateStr]; + + const cell = document.createElement('button'); + cell.type = 'button'; + cell.className = 'reading-streak-cell'; + cell.setAttribute('role', 'gridcell'); + cell.dataset.date = dateStr; + + const formatted = cellDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + if (!inWindow) { + cell.classList.add('reading-streak-cell-empty'); + cell.disabled = true; + cell.title = formatted; + col.appendChild(cell); + continue; + } + + if (isActive) { + const dayIndexFromStart = Math.round((cellDate - start) / (24 * 60 * 60 * 1000)); + const intensityClass = _intensityClassForIndex(dayIndexFromStart); + cell.classList.add(intensityClass); + cell.title = `${formatted} — Read`; + activeCounter += 1; + } else { + cell.classList.add('inactive'); + cell.title = `${formatted} — Not read`; + } + + // Disable direct cell clicking (tracked via action button only) + cell.disabled = true; + col.appendChild(cell); + } + + grid.appendChild(col); + } + + // Assemble new DOM structure with sticky weekdays and scrollable grid + container.innerHTML = ''; + container.setAttribute('role', 'grid'); + + const wrapper = document.createElement('div'); + wrapper.className = 'reading-streak-heatmap-container'; + + // Sticky Weekdays Column + const weekdays = document.createElement('div'); + weekdays.className = 'reading-streak-weekdays'; + weekdays.setAttribute('aria-hidden', 'true'); + + const daysOfWeek = ['', 'Mon', '', 'Wed', '', 'Fri', '']; + daysOfWeek.forEach((day) => { + const label = document.createElement('div'); + label.className = 'weekday-label'; + label.textContent = day; + weekdays.appendChild(label); + }); + wrapper.appendChild(weekdays); + + // Scrollable Grid Wrapper + const gridWrapper = document.createElement('div'); + gridWrapper.className = 'reading-streak-grid-wrapper'; + gridWrapper.appendChild(grid); + wrapper.appendChild(gridWrapper); + + container.appendChild(wrapper); + + // Append card footer with count stats and green intensity legend + const footer = document.createElement('div'); + footer.className = 'reading-streak-footer'; + + const info = document.createElement('div'); + info.className = 'reading-streak-info'; + info.innerHTML = `Total active days: ${activeCounter}`; + footer.appendChild(info); + + const legend = document.createElement('div'); + legend.className = 'reading-streak-legend'; + legend.innerHTML = ` + Less +
+
+
+
+
+ More + `; + footer.appendChild(legend); + + container.appendChild(footer); + + // Auto-scroll so today is visible on load (cozy and subtle) + const scrollWrap = container.closest('.reading-streak-scroll') || container; + if (scrollWrap && scrollWrap.scrollLeft !== undefined) { + scrollWrap.scrollLeft = scrollWrap.scrollWidth; + } + } + + function _updateStatsUI(currentStreak, longestStreak) { + const currentEl = document.getElementById('reading-streak-current'); + const longestEl = document.getElementById('reading-streak-longest'); + if (currentEl) currentEl.textContent = currentStreak; + if (longestEl) longestEl.textContent = longestStreak; + } + + function renderUI() { + const data = loadReadingData(); + const currentStreak = calculateCurrentStreak(data); + const longestStreak = calculateLongestStreak(data); + + _updateStatsUI(currentStreak, longestStreak); + renderCalendar(); + + const btn = document.getElementById('reading-streak-mark-today'); + if (btn) { + const today = getTodayDate(); + const alreadyMarked = !!data[today]; + btn.disabled = alreadyMarked; + btn.setAttribute('aria-disabled', String(alreadyMarked)); + + const span = btn.querySelector('span') || btn; + const icon = btn.querySelector('i'); + + if (alreadyMarked) { + if (icon) icon.className = 'fa-solid fa-check'; + if (span !== btn) { + span.textContent = 'Read Today!'; + } else { + btn.innerHTML = ' Read Today!'; + } + } else { + if (icon) icon.className = 'fa-solid fa-book-open'; + if (span !== btn) { + span.textContent = 'Read Today'; + } else { + btn.innerHTML = ' Read Today'; + } + } + } + } + + function _wireEvents() { + const btn = document.getElementById('reading-streak-mark-today'); + if (!btn) return; + + // Remove old listeners by replacing button with its clone + const newBtn = btn.cloneNode(true); + btn.parentNode.replaceChild(newBtn, btn); + + newBtn.addEventListener('click', () => { + markTodayAsRead(); + }); + } + + function init() { + const run = () => { + const calendar = document.getElementById('reading-streak-calendar'); + const btn = document.getElementById('reading-streak-mark-today'); + if (!calendar && !btn) return; + + _wireEvents(); + renderUI(); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run, { once: true }); + } else { + run(); + } + } + + // Public API + return { + init, + getTodayDate, + loadReadingData, + saveReadingData, + markTodayAsRead, + calculateCurrentStreak, + calculateLongestStreak, + renderCalendar, + }; +})(); + +// Ensure it actually initializes when included via + - \ No newline at end of file + + diff --git a/scratch/test_streak.js b/scratch/test_streak.js new file mode 100644 index 000000000..5ba64f53e --- /dev/null +++ b/scratch/test_streak.js @@ -0,0 +1,180 @@ +/** + * Test script for BiblioDrift Reading Streak functionality + * Simulates browser environment to verify streak calculations, + * green cell intensity calculations, and local storage state persistence. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +console.log('🧪 Starting BiblioDrift Reading Streak Unit Tests...\n'); + +// 1. Mock the Browser Global Environment +const storage = {}; +global.localStorage = { + getItem: (key) => storage[key] || null, + setItem: (key, val) => { storage[key] = String(val); }, + removeItem: (key) => { delete storage[key]; }, + clear: () => { Object.keys(storage).forEach(k => delete storage[k]); } +}; + +// Mock Document Elements +class MockElement { + constructor(id) { + this.id = id; + this.textContent = ''; + this.innerHTML = ''; + this.disabled = false; + this.attributes = {}; + this.children = []; + this.dataset = {}; + this.parentNode = { replaceChild: (n, o) => {} }; + this.classList = { + classes: new Set(), + add: (c) => this.classList.classes.add(c), + remove: (c) => this.classList.classes.delete(c), + contains: (c) => this.classList.classes.has(c) + }; + } + + setAttribute(k, v) { this.attributes[k] = v; } + getAttribute(k) { return this.attributes[k]; } + querySelector(sel) { return this; } + appendChild(child) { this.children.push(child); } + closest() { return { scrollLeft: 0, scrollWidth: 100 }; } + addEventListener(ev, cb) {} + cloneNode() { + return new MockElement(this.id); + } +} + +const elements = { + 'reading-streak-calendar': new MockElement('reading-streak-calendar'), + 'reading-streak-mark-today': new MockElement('reading-streak-mark-today'), + 'reading-streak-current': new MockElement('reading-streak-current'), + 'reading-streak-longest': new MockElement('reading-streak-longest'), +}; + +global.document = { + readyState: 'complete', + addEventListener: () => {}, + getElementById: (id) => elements[id] || null, + createElement: (tag) => { + const el = new MockElement(); + el.tagName = tag; + return el; + } +}; + +global.window = {}; + +// 2. Load the actual reading-streak.js source code +const jsPath = path.join(__dirname, '../frontend/js/reading-streak.js'); +const jsCode = fs.readFileSync(jsPath, 'utf8'); + +// Evaluate the script in global scope +eval(jsCode); + +const ReadingStreak = global.window.ReadingStreak; + +// 3. Perform assertions +try { + // Test 1: getTodayDate returns YYYY-MM-DD + const todayStr = ReadingStreak.getTodayDate(); + console.log(`✅ Test 1: getTodayDate() -> ${todayStr}`); + assert.match(todayStr, /^\d{4}-\d{2}-\d{2}$/); + + // Test 2: Fresh streak calculation should be 0 + let data = ReadingStreak.loadReadingData(); + assert.deepStrictEqual(data, {}); + let current = ReadingStreak.calculateCurrentStreak(data); + let longest = ReadingStreak.calculateLongestStreak(data); + console.log(`✅ Test 2: Fresh stats -> Current: ${current}, Longest: ${longest}`); + assert.strictEqual(current, 0); + assert.strictEqual(longest, 0); + + // Test 3: Mark today as read + console.log('👉 Marking today as read...'); + const marked = ReadingStreak.markTodayAsRead(); + assert.strictEqual(marked, true); + + data = ReadingStreak.loadReadingData(); + assert.strictEqual(data[todayStr], 1); + console.log(`✅ Test 3: Mark today -> localStorage successfully populated for ${todayStr}`); + + // Test 4: Re-calculate streak with today active + current = ReadingStreak.calculateCurrentStreak(data); + longest = ReadingStreak.calculateLongestStreak(data); + console.log(`✅ Test 4: Post-read stats -> Current: ${current}, Longest: ${longest}`); + assert.strictEqual(current, 1); + assert.strictEqual(longest, 1); + + // Test 5: Gamified Rollback - If today not read but yesterday read, streak is still active (value = 1) + console.log('👉 Simulating yesterday read, today not read...'); + localStorage.clear(); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = yesterday.toISOString().split('T')[0]; + + data = {}; + data[yesterdayStr] = 1; + ReadingStreak.saveReadingData(data); + + // Calculate streak - should be 1 because yesterday was read! + current = ReadingStreak.calculateCurrentStreak(data); + console.log(`✅ Test 5: Resilient Streak (Yesterday active, today inactive) -> Current: ${current}`); + assert.strictEqual(current, 1); + + // Test 6: Consecutive Days Calculation + console.log('👉 Simulating a 5-day active streak...'); + localStorage.clear(); + data = {}; + for (let i = 0; i < 5; i++) { + const d = new Date(); + d.setDate(d.getDate() - i); + const dStr = d.toISOString().split('T')[0]; + data[dStr] = 1; + } + ReadingStreak.saveReadingData(data); + + current = ReadingStreak.calculateCurrentStreak(data); + longest = ReadingStreak.calculateLongestStreak(data); + console.log(`✅ Test 6: 5 consecutive days -> Current: ${current}, Longest: ${longest}`); + assert.strictEqual(current, 5); + assert.strictEqual(longest, 5); + + // Test 7: Broken Streak / Longest Streak preservation + console.log('👉 Simulating broken streak: active 3 days, gap of 2 days, then active 5 days...'); + localStorage.clear(); + data = {}; + + // 5 days active leading to yesterday + for (let i = 1; i <= 5; i++) { + const d = new Date(); + d.setDate(d.getDate() - i); + const dStr = d.toISOString().split('T')[0]; + data[dStr] = 1; + } + // 2 days gap (6 and 7 days ago) + // 3 days active (8, 9, 10 days ago) + for (let i = 8; i <= 10; i++) { + const d = new Date(); + d.setDate(d.getDate() - i); + const dStr = d.toISOString().split('T')[0]; + data[dStr] = 1; + } + ReadingStreak.saveReadingData(data); + + current = ReadingStreak.calculateCurrentStreak(data); + longest = ReadingStreak.calculateLongestStreak(data); + console.log(`✅ Test 7: Broken streak stats -> Current: ${current}, Longest: ${longest}`); + assert.strictEqual(current, 5); // Current is 5 (yesterday back 5 days) + assert.strictEqual(longest, 5); // Longest consecutive is 5 + + console.log('\n🎉 ALL TESTS PASSED SUCCESSFULLY! The streak calculation, storage, and logic are 100% correct.'); +} catch (err) { + console.error('\n❌ TEST FAILURE:'); + console.error(err); + process.exit(1); +} From 18cc6fe9f3a9e6385c8d5c658c01d4adab97647d Mon Sep 17 00:00:00 2001 From: Ishita Zope Date: Wed, 27 May 2026 06:51:54 +0530 Subject: [PATCH 2/2] chore: remove temporary development files --- TODO.md | 25 ------ TODO_READING_STREAK.md | 10 --- scratch/test_streak.js | 180 ----------------------------------------- 3 files changed, 215 deletions(-) delete mode 100644 TODO.md delete mode 100644 TODO_READING_STREAK.md delete mode 100644 scratch/test_streak.js diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 18a6cac40..000000000 --- a/TODO.md +++ /dev/null @@ -1,25 +0,0 @@ -# TODO.md — Issue #608: Reading Streak Calendar - -- [x] Inspect/finish `frontend/js/reading-streak.js` implementation (complete required functions and rendering). - -- [x] Add Reading Streak UI sections to `frontend/pages/library.html` inside My Library area. - - -- [x] Add CSS for glassmorphism card + compact heatmap + cozy button styling (via `frontend/css/reading-streak.css`). - - -- [x] Add responsive tweaks in `frontend/css/style-responsive.css` (avoid horizontal overflow on small screens). - - -- [x] Wire JS to render on `DOMContentLoaded` and connect "Mark Today as Read" button (ids + script included in library.html). - -- [x] Verify localStorage persistence under `bibliodrift-reading-streak`. - - -- [x] Verify streak calculations: current streak from today backward, longest streak consecutive runs. - -- [x] Verify duplicate click prevention + immediate UI refresh. - -- [x] Quick manual test in light + night mode; ensure heatmap is visible. - - diff --git a/TODO_READING_STREAK.md b/TODO_READING_STREAK.md deleted file mode 100644 index 363995e3c..000000000 --- a/TODO_READING_STREAK.md +++ /dev/null @@ -1,10 +0,0 @@ -# Reading Streak Heatmap (Issue #608) — Implementation Checklist - -- [ ] Add Reading Streak UI section + ids + script include to `frontend/pages/library.html` -- [ ] Add streak heatmap glassmorphism + cell intensity + current/longest typography to `frontend/css/style.css` -- [ ] Add responsive overflow/size safeguards to `frontend/css/style-responsive.css` -- [ ] Wire `reading-streak.js` via ids and ensure `DOMContentLoaded` init runs -- [ ] Verify localStorage persistence under `bibliodrift-reading-streak` -- [ ] Verify streak calculations (current from today backward, longest consecutive) -- [ ] Manual test light/night + refresh + duplicate click prevention - diff --git a/scratch/test_streak.js b/scratch/test_streak.js deleted file mode 100644 index 5ba64f53e..000000000 --- a/scratch/test_streak.js +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Test script for BiblioDrift Reading Streak functionality - * Simulates browser environment to verify streak calculations, - * green cell intensity calculations, and local storage state persistence. - */ - -const assert = require('assert'); -const fs = require('fs'); -const path = require('path'); - -console.log('🧪 Starting BiblioDrift Reading Streak Unit Tests...\n'); - -// 1. Mock the Browser Global Environment -const storage = {}; -global.localStorage = { - getItem: (key) => storage[key] || null, - setItem: (key, val) => { storage[key] = String(val); }, - removeItem: (key) => { delete storage[key]; }, - clear: () => { Object.keys(storage).forEach(k => delete storage[k]); } -}; - -// Mock Document Elements -class MockElement { - constructor(id) { - this.id = id; - this.textContent = ''; - this.innerHTML = ''; - this.disabled = false; - this.attributes = {}; - this.children = []; - this.dataset = {}; - this.parentNode = { replaceChild: (n, o) => {} }; - this.classList = { - classes: new Set(), - add: (c) => this.classList.classes.add(c), - remove: (c) => this.classList.classes.delete(c), - contains: (c) => this.classList.classes.has(c) - }; - } - - setAttribute(k, v) { this.attributes[k] = v; } - getAttribute(k) { return this.attributes[k]; } - querySelector(sel) { return this; } - appendChild(child) { this.children.push(child); } - closest() { return { scrollLeft: 0, scrollWidth: 100 }; } - addEventListener(ev, cb) {} - cloneNode() { - return new MockElement(this.id); - } -} - -const elements = { - 'reading-streak-calendar': new MockElement('reading-streak-calendar'), - 'reading-streak-mark-today': new MockElement('reading-streak-mark-today'), - 'reading-streak-current': new MockElement('reading-streak-current'), - 'reading-streak-longest': new MockElement('reading-streak-longest'), -}; - -global.document = { - readyState: 'complete', - addEventListener: () => {}, - getElementById: (id) => elements[id] || null, - createElement: (tag) => { - const el = new MockElement(); - el.tagName = tag; - return el; - } -}; - -global.window = {}; - -// 2. Load the actual reading-streak.js source code -const jsPath = path.join(__dirname, '../frontend/js/reading-streak.js'); -const jsCode = fs.readFileSync(jsPath, 'utf8'); - -// Evaluate the script in global scope -eval(jsCode); - -const ReadingStreak = global.window.ReadingStreak; - -// 3. Perform assertions -try { - // Test 1: getTodayDate returns YYYY-MM-DD - const todayStr = ReadingStreak.getTodayDate(); - console.log(`✅ Test 1: getTodayDate() -> ${todayStr}`); - assert.match(todayStr, /^\d{4}-\d{2}-\d{2}$/); - - // Test 2: Fresh streak calculation should be 0 - let data = ReadingStreak.loadReadingData(); - assert.deepStrictEqual(data, {}); - let current = ReadingStreak.calculateCurrentStreak(data); - let longest = ReadingStreak.calculateLongestStreak(data); - console.log(`✅ Test 2: Fresh stats -> Current: ${current}, Longest: ${longest}`); - assert.strictEqual(current, 0); - assert.strictEqual(longest, 0); - - // Test 3: Mark today as read - console.log('👉 Marking today as read...'); - const marked = ReadingStreak.markTodayAsRead(); - assert.strictEqual(marked, true); - - data = ReadingStreak.loadReadingData(); - assert.strictEqual(data[todayStr], 1); - console.log(`✅ Test 3: Mark today -> localStorage successfully populated for ${todayStr}`); - - // Test 4: Re-calculate streak with today active - current = ReadingStreak.calculateCurrentStreak(data); - longest = ReadingStreak.calculateLongestStreak(data); - console.log(`✅ Test 4: Post-read stats -> Current: ${current}, Longest: ${longest}`); - assert.strictEqual(current, 1); - assert.strictEqual(longest, 1); - - // Test 5: Gamified Rollback - If today not read but yesterday read, streak is still active (value = 1) - console.log('👉 Simulating yesterday read, today not read...'); - localStorage.clear(); - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - const yesterdayStr = yesterday.toISOString().split('T')[0]; - - data = {}; - data[yesterdayStr] = 1; - ReadingStreak.saveReadingData(data); - - // Calculate streak - should be 1 because yesterday was read! - current = ReadingStreak.calculateCurrentStreak(data); - console.log(`✅ Test 5: Resilient Streak (Yesterday active, today inactive) -> Current: ${current}`); - assert.strictEqual(current, 1); - - // Test 6: Consecutive Days Calculation - console.log('👉 Simulating a 5-day active streak...'); - localStorage.clear(); - data = {}; - for (let i = 0; i < 5; i++) { - const d = new Date(); - d.setDate(d.getDate() - i); - const dStr = d.toISOString().split('T')[0]; - data[dStr] = 1; - } - ReadingStreak.saveReadingData(data); - - current = ReadingStreak.calculateCurrentStreak(data); - longest = ReadingStreak.calculateLongestStreak(data); - console.log(`✅ Test 6: 5 consecutive days -> Current: ${current}, Longest: ${longest}`); - assert.strictEqual(current, 5); - assert.strictEqual(longest, 5); - - // Test 7: Broken Streak / Longest Streak preservation - console.log('👉 Simulating broken streak: active 3 days, gap of 2 days, then active 5 days...'); - localStorage.clear(); - data = {}; - - // 5 days active leading to yesterday - for (let i = 1; i <= 5; i++) { - const d = new Date(); - d.setDate(d.getDate() - i); - const dStr = d.toISOString().split('T')[0]; - data[dStr] = 1; - } - // 2 days gap (6 and 7 days ago) - // 3 days active (8, 9, 10 days ago) - for (let i = 8; i <= 10; i++) { - const d = new Date(); - d.setDate(d.getDate() - i); - const dStr = d.toISOString().split('T')[0]; - data[dStr] = 1; - } - ReadingStreak.saveReadingData(data); - - current = ReadingStreak.calculateCurrentStreak(data); - longest = ReadingStreak.calculateLongestStreak(data); - console.log(`✅ Test 7: Broken streak stats -> Current: ${current}, Longest: ${longest}`); - assert.strictEqual(current, 5); // Current is 5 (yesterday back 5 days) - assert.strictEqual(longest, 5); // Longest consecutive is 5 - - console.log('\n🎉 ALL TESTS PASSED SUCCESSFULLY! The streak calculation, storage, and logic are 100% correct.'); -} catch (err) { - console.error('\n❌ TEST FAILURE:'); - console.error(err); - process.exit(1); -}