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 +