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) => (
))}
More
{unknown && (
{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 (
{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 (