import { Fragment, useEffect, useRef, useState } from 'react' import { BookOpen, CalendarDays, ChevronLeft, ChevronRight, CircleCheck } from 'lucide-react' import ShareSheet from './ShareSheet.jsx' import { renderCalendarCard, renderStatsCard } from './shareCard.js' import { Card, Cover, EmptyState, ProgressBar, ShareIcon, ViaHardcover, duration, pct } from './ui.jsx' import { isPace, moodEmoji } from './moods.js' import { decodeHistory, hasReadingTime, localDay, longestReadingStreak, readingStreak, shadeLevels } from './readingHistory.js' export { decodeHistory } from './readingHistory.js' // Shades for the reading calendar, light to dark; levels 1-4 are relative to your median day. const SHADES = ['bg-stone-100', 'bg-brand-200', 'bg-brand-300', 'bg-brand-400', 'bg-brand-500'] // A day the reader marked read but recorded no amount for (before daily time existed): outlined, not shaded. const UNKNOWN_SHADE = 'bg-stone-100 ring-1 ring-inset ring-brand-400' const FALLBACK_SECONDS = 30 * 60 // median assumed until there is enough history const FALLBACK_PAGES = 20 function ShadeLegend({ unknown }) { return (
Less {SHADES.map((c) => (
{unknown && (
)}
) } // A tooltip for grid cells carrying data-tip: follows the mouse on hover, and on // touch shows while a finger is held down (sliding across cells updates it). function useCellTip() { const box = useRef(null) const [tip, setTip] = useState(null) const show = (el) => { const cell = el?.closest?.('[data-tip]') if (!cell || !box.current?.contains(cell)) return setTip(null) const outer = box.current.getBoundingClientRect() const r = cell.getBoundingClientRect() const x = r.left + r.width / 2 - outer.left // Keep the bubble inside the card near the edges. setTip({ text: cell.dataset.tip, x: Math.min(Math.max(x, 128), outer.width - 128), y: r.top - outer.top }) } const hide = () => setTip(null) const handlers = { onPointerOver: (e) => e.pointerType === 'mouse' && show(e.target), onPointerLeave: (e) => e.pointerType === 'mouse' && hide(), onPointerDown: (e) => e.pointerType !== 'mouse' && show(e.target), onPointerMove: (e) => e.pointerType !== 'mouse' && tip && show(document.elementFromPoint(e.clientX, e.clientY)), onPointerUp: (e) => e.pointerType !== 'mouse' && hide(), onPointerCancel: hide, onContextMenu: (e) => e.nativeEvent.pointerType === 'touch' && e.preventDefault(), } const bubble = tip && (
{tip.text}
) return { box, handlers, bubble } } // Cells are pressed and held for their tooltip: no long-press text selection or callout. const NO_CALLOUT = { WebkitTouchCallout: 'none' } // Readers that sent stats, with repeats grouped: "2 CrossInk readers, KOReader". // Device names come from the firmware, so every CrossInk reader is "CrossInk". const deviceList = (devices) => { const counts = new Map() for (const d of devices) { const name = d.device || d.device_id counts.set(name, (counts.get(name) ?? 0) + 1) } return [...counts].map(([name, n]) => (n > 1 ? `${n} ${name} readers` : name)).join(', ') } function Bars({ title, labels, values }) { const max = Math.max(...values, 1) return (

{title}

{/* One grid for all rows, so the value column fits the widest duration and the bars share what's left. */}
{labels.map((l, i) => ( {l}
{duration(values[i])} ))}
) } // Moods and genres across your books (from Hardcover's catalog, via the server). // Counts each book once per tag; DNF books are left out of your taste profile. function tagCounts(books, field) { const counts = new Map() for (const b of books) for (const t of b[field] ?? []) counts.set(t, (counts.get(t) ?? 0) + 1) return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) } // Top moods across the given documents (most common first), for share cards. function topMoods(books, documents, n = 3) { const byDoc = new Map(books.map((b) => [b.document, b])) const picked = [...new Set(documents)].map((d) => byDoc.get(d)).filter((b) => b && b.status !== 'dnf') return tagCounts(picked, 'moods').filter(([m]) => !isPace(m)).slice(0, n).map(([m]) => m) } // Moods as outlined stickers, with how many of your books carry each. function MoodStickers({ rows }) { return (
{rows.map(([mood, n]) => ( {mood} {n} ))}
) } // Genres as book spines on a shelf: taller spine, more books. const SPINES = [ 'bg-brand-500 text-white', 'bg-brand-300 text-stone-900', 'bg-stone-300 text-stone-900', 'bg-brand-200 text-stone-900', 'bg-brand-400 text-white', ] const SPINE_WIDTHS = ['w-14', 'w-12', 'w-16', 'w-11', 'w-13'] function GenreShelf({ rows }) { const max = Math.max(...rows.map(([, n]) => n), 1) return (
{rows.map(([genre, n], i) => (
{/* Two thin bands near the top, like a printed spine */} 14 ? 'text-xs' : 'text-sm' }`} > {genre} {n}
))}
) } function WhatYouRead({ books }) { const read = books.filter((b) => b.status !== 'dnf') const all = tagCounts(read, 'moods') const moods = all.filter(([m]) => !isPace(m)).slice(0, 5) const pace = all.find(([m]) => isPace(m))?.[0] const genres = tagCounts(read, 'genres').slice(0, 5) if (!moods.length && !genres.length) return null return (

What you read

{moods.length > 0 && (

Your moods

{pace && (

Mostly {pace.toLowerCase()} {moodEmoji(pace)}

)}
)} {genres.length > 0 && (

Your genres

)}
) } function Tiles({ tiles }) { // Four columns keep eight tiles from leaving an empty cell. const cols = tiles.length % 4 === 0 ? 'md:grid-cols-4' : 'md:grid-cols-3' return ( {tiles.map(([l, v]) => (

{l}

{v}

))}
) } const DAY_MS = 86400000 // Once daily history exists, days without reading count as zero in the 7-day average. function dailyReading(daily) { const byDay = new Map(daily.map((d) => [d.date, d.seconds])) const now = new Date() now.setHours(12, 0, 0, 0) let total = 0 for (let i = 0; i < 7; i++) total += byDay.get(localDay(new Date(now.getTime() - i * DAY_MS))) ?? 0 return { today: byDay.get(localDay(now)) ?? 0, average: total / 7 } } // Print pages per week (Monday start), newest week last. function weeklyPages(days, weeks = 12) { const byDay = new Map(days.map((d) => [d.day, d.pages])) const monday = new Date() monday.setHours(12, 0, 0, 0) monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7)) return Array.from({ length: weeks }, (_, i) => { const start = new Date(monday.getTime() - (weeks - 1 - i) * 7 * DAY_MS) let pages = 0 for (let d = 0; d < 7; d++) pages += byDay.get(localDay(new Date(start.getTime() + d * DAY_MS))) ?? 0 return { start, pages } }) } function WeeklyPages({ days, weeks = 12 }) { const cols = weeklyPages(days, weeks) const max = Math.max(...cols.map((c) => c.pages), 1) return (

Pages per week

print pages

{cols.map((c) => (

{c.pages || ''}

))}
{cols.map((c, i) => (

{c.start.getDate() <= 7 || i === 0 ? c.start.toLocaleDateString(undefined, { month: 'short' }) : ''}

))}
) } // Calendar of reading days: one square per day, shaded by pages or reading time // against your median day. Weekday rows (Monday first), weeks run left to right. const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const SCALES = [ ['month', 'Month'], ['quarter', '3 months'], ['year', 'Year'], ] const mondayOf = (d) => new Date(d.getTime() - ((d.getDay() + 6) % 7) * DAY_MS) const fmt = (d, o) => d.toLocaleDateString(undefined, o) // Weeks (Monday dates) shown for a scale, `back` periods before now, plus the visible day range. function calendarRange(scale, back, today) { if (scale === 'month') { const first = new Date(today.getFullYear(), today.getMonth() - back, 1, 12) const last = new Date(first.getFullYear(), first.getMonth() + 1, 0, 12) const weeks = [] for (let m = mondayOf(first); m <= last; m = new Date(m.getTime() + 7 * DAY_MS)) weeks.push(m) return { weeks, first, last, label: fmt(first, { month: 'long', year: 'numeric' }) } } const count = scale === 'quarter' ? 13 : 52 const end = new Date(mondayOf(today).getTime() - back * count * 7 * DAY_MS) const weeks = Array.from({ length: count }, (_, i) => new Date(end.getTime() - (count - 1 - i) * 7 * DAY_MS)) const first = weeks[0] const last = new Date(end.getTime() + 6 * DAY_MS) const opts = { month: 'short', year: first.getFullYear() === last.getFullYear() ? undefined : 'numeric' } return { weeks, first, last, label: `${fmt(first, opts)} to ${fmt(last > today ? today : last, { month: 'short', year: 'numeric' })}` } } const HISTORY_DAYS = 730 // Read days from sync history: any sync marks a day, mapped to pages read (chosen dates count as 0). function pagesByDay(days, readingDays = []) { const byDay = new Map(days.filter((d) => d.syncs > 0 || d.pages > 0).map((d) => [d.day, d.pages])) for (const day of readingDays) if (!byDay.has(day)) byDay.set(day, 0) return byDay } // Read days from device history and chosen dates, mapped to recorded seconds (undefined when unknown). function timeByDay(summary, activity) { const { read, anchor } = decodeHistory(summary.history_b64, summary.anchor_day, activity?.reading_days) const seconds = new Map((summary.daily ?? []).map((d) => [d.date, d.seconds])) const byDay = new Map() for (let n = 0; n < HISTORY_DAYS; n++) { if (!read(n)) continue const key = new Date(anchor.getTime() - n * DAY_MS).toISOString().slice(0, 10) byDay.set(key, seconds.get(key)) } for (const [key, s] of seconds) if (s > 0 && !byDay.has(key)) byDay.set(key, s) // Synced days older than the device history window. for (const day of activity?.reading_days ?? []) if (!byDay.has(day)) byDay.set(day, undefined) return byDay } const PAGES_VIEW = { source: 'Print pages estimated from synced progress', fallback: FALLBACK_PAGES, tip: (pages) => (pages ? `${Math.round(pages)} pages` : 'read'), total: ['pages read', (n) => Math.round(n).toLocaleString()], } const TIME_VIEW = { source: 'Reading time recorded by your reader', fallback: FALLBACK_SECONDS, tip: (seconds) => (seconds === undefined ? 'read' : `${duration(seconds)} reading time`), total: ['time read', duration], unknown: 'read, no time recorded', } // Older firmware sends read-day flags without daily time counters. const DAYS_VIEW = { fallback: FALLBACK_SECONDS, tip: () => 'read', } // byDay: every read day -> amount (undefined when unknown); view describes the available data. // pages: synced print pages per day, shown for a day whose amount is unknown (#63). function ReadingCalendar({ byDay, view, days = [], books = [], pages }) { const [scale, setScale] = useState('year') const [back, setBack] = useState(0) const [sharing, setSharing] = useState(false) const scroller = useRef(null) const { level: amountLevel } = shadeLevels(byDay, { fallback: view.fallback }) const { level: pageLevel } = shadeLevels(pages ?? new Map(), { fallback: FALLBACK_PAGES }) const syncedPages = (day) => (byDay.get(day) === undefined ? pages?.get(day) ?? 0 : 0) const level = (day) => (!byDay.has(day) ? 0 : syncedPages(day) > 0 ? pageLevel(syncedPages(day)) : amountLevel(byDay.get(day))) const usesPages = [...byDay.keys()].some((day) => syncedPages(day) > 0) // Read, but with neither recorded time nor synced pages: nothing to measure it by. const unmeasured = (day) => !!view.unknown && byDay.has(day) && byDay.get(day) === undefined && !(syncedPages(day) > 0) const tipFor = (day) => unmeasured(day) ? view.unknown : syncedPages(day) > 0 ? PAGES_VIEW.tip(syncedPages(day)) : view.tip(byDay.get(day)) const today = new Date() today.setHours(12, 0, 0, 0) const { weeks, first, last, label } = calendarRange(scale, back, today) const shown = (d) => d >= first && d <= last && d <= today let count = 0 let anyUnmeasured = false for (let d = first; d <= last && d <= today; d = new Date(d.getTime() + DAY_MS)) { if (byDay.has(localDay(d))) count++ if (unmeasured(localDay(d))) anyUnmeasured = true } const month = scale === 'month' const year = scale === 'year' // The newest weeks sit on the right; start the (phone-scrollable) year there. useEffect(() => { if (scroller.current) scroller.current.scrollLeft = scroller.current.scrollWidth }, [scale, back]) const pick = (v) => { setScale(v) setBack(0) } const cell = (d, round) => { if (!shown(d)) return
const key = localDay(d) const tip = `${fmt(d, { weekday: 'short', month: 'short', day: 'numeric' })}: ${byDay.has(key) ? tipFor(key) : 'no reading'}` return
} // Share the visible period, reading days, available page/time totals and longest streak. const share = () => { let total = 0, streak = 0, run = 0 for (let d = first; d <= last && d <= today; d = new Date(d.getTime() + DAY_MS)) { const key = localDay(d) total += byDay.get(key) ?? 0 run = byDay.has(key) ? run + 1 : 0 streak = Math.max(streak, run) } const lv = (d) => (shown(d) ? level(localDay(d)) : null) const day = (monday, r) => new Date(monday.getTime() + r * DAY_MS) const strip = (ws) => ({ grid: WEEKDAYS.map((_, r) => ws.map((m) => lv(day(m, r)))), left: WEEKDAYS.map((w, r) => (r % 2 === 0 ? w : '')), bottom: ws.map((m, i) => (m.getDate() <= 7 || i === 0 ? fmt(m, { month: 'short' }) : '')), }) // A year is two stacked half-year strips so the squares stay a readable size. const blocks = month ? [{ grid: weeks.map((m) => WEEKDAYS.map((_, r) => lv(day(m, r)))), top: WEEKDAYS }] : year ? [strip(weeks.slice(0, 26)), strip(weeks.slice(26))] : [strip(weeks)] const eyebrow = { month: back ? `My ${fmt(first, { month: 'long' })}` : 'My month', quarter: back ? 'My 3 months' : 'My last 3 months', year: back ? 'My year' : 'My past year', }[scale] // Moods of the books read in this period. const inPeriod = days.filter((d) => { const at = dayDate(d.day) return at >= first && at <= last }) const moods = topMoods(books, inPeriod.flatMap((d) => (d.books ?? []).map((b) => b.document))) return renderCalendarCard({ eyebrow, moods, heading: 'Reading days', subtitle: label, tiles: [ ['reading days', count], ...(view.total ? [[view.total[0], view.total[1](total)]] : []), ['day streak', streak], ], blocks, }) } const { box, handlers, bubble } = useCellTip() const arrow = 'grid size-9 place-items-center rounded-full text-stone-600 active:bg-stone-100 md:hover:bg-stone-100 disabled:opacity-30' return ( {sharing && ( setSharing(false)} render={share} /> )}

Reading days

{SCALES.map(([v, l]) => ( ))}
{view.source && (

{usesPages ? `${view.source}; synced pages where no time was recorded` : view.source}

)}

{label}

{count} reading {count === 1 ? 'day' : 'days'}

{bubble} {month ? ( // Month: a regular calendar, weekdays across the top and one row per week.
{WEEKDAYS.map((w) => (

{w}

))} {weeks.map((monday) => WEEKDAYS.map((w, r) => cell(new Date(monday.getTime() + r * DAY_MS), 'rounded-md')))}
) : (
{WEEKDAYS.map((w, r) => (

{!year || r % 2 === 0 ? w[0] : ''}

))} {weeks.map((monday) => ( {WEEKDAYS.map((w, r) => cell(new Date(monday.getTime() + r * DAY_MS), 'rounded-[2px]'))}

{monday.getDate() <= 7 || monday.getTime() === weeks[0].getTime() ? fmt(monday, { month: 'short' }) : ''}

))}
)}
{view.total &&
}
) } const FINISHED_PER_PAGE = 5 function Finished({ list, titles }) { const [shown, setShown] = useState(FINISHED_PER_PAGE) if (!list.length) return null return (

Finished

{list.slice(0, shown).map((b) => ( {titles.get(b.document) ?? b.document} {b.finished_at ? new Date(b.finished_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : 'Date unknown'} ))} {list.length > shown && ( )}
) } function PagesAndBooks({ activity: all, books }) { const titles = new Map(books.map((b) => [b.document, b.title || b.filename || 'Untitled book'])) // Only books the library shows (metadata-less ones are hidden everywhere unless Settings shows them). const shown = all.books.filter((b) => titles.has(b.document)) const activity = { ...all, books: shown, pages_total: shown.reduce((n, b) => n + (b.pages_read ?? 0), 0) } const finished = activity.books.filter((b) => b.completed || b.finished_at).sort((a, b) => (b.finished_at ?? 0) - (a.finished_at ?? 0)) const spans = finished.map((b) => b.days_to_finish).filter(Boolean) const known = activity.books.filter((b) => b.page_count).length const tiles = [ ['Pages read', activity.pages_total.toLocaleString()], ['Books finished', activity.books.filter(b => b.completed || b.finished_at).length], ['Reading now', books.filter((b) => b.status === 'reading').length], ['Finished this year', finished.filter((b) => b.finished_at && new Date(b.finished_at * 1000).getFullYear() === new Date().getFullYear()).length], ['Pages per book', known ? Math.round(activity.books.reduce((n, b) => n + (b.page_count ?? 0), 0) / known) : '-'], ['Days per book', spans.length ? Math.round(spans.reduce((a, b) => a + b, 0) / spans.length) : '-'], ] return (

Pages are print-edition pages (your progress x the book's page count from Open Library), so they don't depend on font size.{' '} {known < activity.books.length && `${activity.books.length - known} book(s) have no known page count yet. `} Weekly pages start from the first sync after history began.

) } // The stats share card for this week, month or year: headline numbers, covers, pages over time. const PERIODS = [ ['week', 'Week'], ['month', 'Month'], ['year', 'Year'], ] // The period so far, as local days, with chart bars: days for a week or month, months for a year. export function sharePeriod(period, now = new Date()) { const today = new Date(now) today.setHours(12, 0, 0, 0) const start = new Date(today) if (period === 'week') start.setDate(start.getDate() - ((start.getDay() + 6) % 7)) else if (period === 'month') start.setDate(1) else start.setMonth(0, 1) const year = today.getFullYear() const name = { week: 'This week', month: fmt(today, { month: 'long' }), year: String(year), }[period] const slots = period === 'year' ? Array.from({ length: 12 }, (_, m) => ({ key: `${year}-${String(m + 1).padStart(2, '0')}`, current: m === today.getMonth() })) : Array.from({ length: period === 'week' ? 7 : new Date(year, today.getMonth() + 1, 0).getDate() }, (_, i) => { const key = localDay(new Date(start.getTime() + i * DAY_MS)) return { key, current: key === localDay(today) } }) return { from: localDay(start), to: localDay(today), name, chartLabel: period === 'year' ? 'PAGES PER MONTH' : 'PAGES PER DAY', // A day key falls in a month slot by its YYYY-MM prefix. slotOf: (day) => (period === 'year' ? day.slice(0, 7) : day), slots, } } function StatsShare({ summary, activity, books, onClose }) { const [period, setPeriod] = useState('year') const p = sharePeriod(period) const inPeriod = (day) => day >= p.from && day <= p.to const shown = new Map(books.map((b) => [b.document, b])) const finished = activity.books .filter((b) => b.finished_at && shown.has(b.document) && inPeriod(localDay(new Date(b.finished_at * 1000)))) .sort((a, b) => b.finished_at - a.finished_at) const days = activity.days.filter((d) => inPeriod(d.day)) const pages = Math.round(days.reduce((n, d) => n + d.pages, 0)) // Books read in the period, newest first, for covers and moods when nothing was finished. const read = [...new Set(days.slice().reverse().flatMap((d) => (d.books ?? []).map((b) => b.document)))].filter((d) => shown.has(d)) const daily = summary?.daily ?? [] const seconds = daily.filter((d) => inPeriod(d.date)).reduce((n, d) => n + d.seconds, 0) const tiles = [ [period === 'week' ? 'finished this week' : `finished in ${p.name}`, finished.length], ['pages read', pages.toLocaleString()], ...(daily.length ? [ ['hours read', Math.round(seconds / 3600).toLocaleString()], ['day streak', readingStreak(summary, activity)], ] : [['days read', days.filter((d) => d.pages > 0).length]]), ] const coverDocs = [...new Set([...finished.map((b) => b.document), ...read])] const covers = coverDocs.map((d) => shown.get(d).cover_url).filter(Boolean) const moods = topMoods(books, finished.length ? finished.map((b) => b.document) : read) const totals = new Map() for (const d of days) totals.set(p.slotOf(d.day), (totals.get(p.slotOf(d.day)) ?? 0) + d.pages) const chart = { label: p.chartLabel, bars: p.slots.map((s) => ({ pages: totals.get(s.key) ?? 0, current: s.current })) } const heading = `${p.name} in books` const mine = period === 'week' ? 'My week in books' : `My ${p.name} in books` const meta = { title: heading, postTitle: mine, fileName: `${heading}.png`, text: `${mine}: ${finished.length} finished, ${pages.toLocaleString()} pages read. Tracked with CrossPoint Sync.`, } return ( renderStatsCard({ heading, tiles, covers, chart, moods })} >
{PERIODS.map(([v, label]) => ( ))}
) } // Overview / Timeline tabs, kept in the URL (#/stats, #/stats/timeline) so back returns to the same tab. const TABS = [ ['', 'Overview'], ['timeline', 'Timeline'], ] function StatsTabs({ tab }) { return ( ) } const dayDate = (day) => new Date(`${day}T12:00`) const shortDate = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date().getFullYear() && { year: 'numeric' }) }) // What happened to one book on one day, in words. function dayEvent(entry, act, day) { const on = (unix) => unix && localDay(new Date(unix * 1000)) === day if (on(act?.finished_at)) return { icon: CircleCheck, text: 'Finished' } if (entry.started || (on(act?.started_at) && entry.from <= 0.02)) return { icon: BookOpen, text: 'Started reading' } const span = entry.to > entry.from ? `${pct(entry.from)} to ${pct(entry.to)}` : `at ${pct(entry.to)}` if (entry.pages > 0) return { icon: BookOpen, text: `Read ${entry.pages} page${entry.pages === 1 ? '' : 's'} · ${span}` } return { icon: BookOpen, text: entry.to > entry.from ? `Read ${span}` : `Opened ${span}` } } // A finished book that only got opened again (no pages, no movement): a reader // re-syncing its last position, not reading. Leave it off the timeline. const idleAfterFinish = (entry, act, day) => entry.pages <= 0 && entry.to <= entry.from && act?.finished_at && localDay(new Date(act.finished_at * 1000)) < day const DAYS_PER_PAGE = 21 function Timeline({ session, activity, books }) { const [shown, setShown] = useState(DAYS_PER_PAGE) const byDoc = new Map(books.map((b) => [b.document, b])) const acts = new Map(activity.books.map((b) => [b.document, b])) const reading = books.filter((b) => b.status === 'reading').sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)) // The first uploaded position is only a baseline. A chosen start date supplies // its timeline event; keep later opens and any actual movement on their days. const firstSyncDays = new Map() for (const d of activity.days) { for (const b of d.books ?? []) { if (b.syncs > 0 && (!firstSyncDays.has(b.document) || d.day < firstSyncDays.get(b.document))) { firstSyncDays.set(b.document, d.day) } } } const startDays = new Map(activity.days.flatMap((d) => (d.books ?? []).filter((b) => b.started).map((b) => [b.document, d.day]))) const days = activity.days .map((d) => ({ ...d, books: (d.books ?? []).filter((b) => { const act = acts.get(b.document) const initialUpload = startDays.has(b.document) && firstSyncDays.get(b.document) === d.day && startDays.get(b.document) !== d.day && b.pages <= 0 && b.to <= b.from && localDay(new Date((act?.finished_at ?? 0) * 1000)) !== d.day return byDoc.has(b.document) && !initialUpload && !idleAfterFinish(b, act, d.day) }) })) .filter((d) => d.books.length) .reverse() return (
{reading.length > 0 && (

{reading.length} reading

{reading.map((b) => (

{b.title || b.filename || 'Untitled book'}

{b.timestamp &&

Last read {shortDate(new Date(b.timestamp * 1000))}

}
{pct(b.percentage)}
))}
)} {!days.length ? (
Each day you read shows up here, book by book, as your reader syncs.
) : (
    {days.slice(0, shown).map((d) => { const date = dayDate(d.day) return (
  1. {/* Dot and connecting line */}

    {date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })} {date.getFullYear() !== new Date().getFullYear() && {date.getFullYear()}}

    {d.books.map((entry) => { const b = byDoc.get(entry.document) const ev = dayEvent(entry, acts.get(entry.document), d.day) return (

    {b.title || b.filename || 'Untitled book'}

    {ev.text}

    ) })}
  2. ) })}
)} {days.length > shown && ( )}
) } export default function Stats({ session, tab = '', summary, activity, books, activityError, activityLoading, activityUpdatedAt, onRetryActivity }) { const hasTime = hasReadingTime(summary) // Reveal daily tiles and calendar time labels together after any history, even a zero counter. const daily = summary?.daily?.length ? dailyReading(summary.daily) : null const [sharing, setSharing] = useState(false) const header = ( <>

How you read

{activityError && (

{activity ? activityUpdatedAt ? <>Showing data from . : 'Showing saved activity.' : 'Reading activity is unavailable.'}

)} ) if (tab === 'timeline') { return (
{header} {activity ? : !activityError &&

Loading…

}
) } return (
{header} {sharing && setSharing(false)} />} {/* Share sits right of the title, as on Reading days. */}

Pages & books

{activity && ( )}
{activity ? : !activityError &&

Loading…

} {summary?.bookorbit?.excluded_sessions > 0 && (

Duplicate BookOrbit session records are counted once. Reading from your other devices is included.

)} {hasTime ? ( <>

Reading time

b.status === 'finished').length], ]} />

Combined from {deviceList(summary.devices)}

) : ( activity && ( ) )}
) }