Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions app/src/Stats.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,26 @@ export { decodeHistory } from './readingHistory.js'
const SHADES = ['bg-stone-100', 'bg-brand-200', 'bg-brand-300', 'bg-brand-400', 'bg-brand-500']
const FALLBACK_SECONDS = 30 * 60 // median assumed until there is enough history
const FALLBACK_PAGES = 20
// A day the reader marked read but recorded no amount for: outlined, not shaded.
const UNKNOWN_SHADE = 'bg-stone-100 ring-1 ring-inset ring-brand-400'
const UNMEASURED = 'read, no time recorded'

function ShadeLegend() {
function ShadeLegend({ unknown }) {
return (
<div className="flex items-center gap-1 font-mono text-[0.65rem] text-stone-400">
Less
{SHADES.map((c) => (
<span key={c} aria-hidden="true" className={`size-2.5 rounded-[2px] ${c}`} />
))}
More
<div className="font-mono text-[0.65rem] text-stone-400">
<div className="flex items-center gap-1">
Less
{SHADES.map((c) => (
<span key={c} aria-hidden="true" className={`size-2.5 rounded-[2px] ${c}`} />
))}
More
</div>
{unknown && (
<div className="mt-1 flex items-center gap-1">
<span aria-hidden="true" className={`size-2.5 rounded-[2px] ${UNKNOWN_SHADE}`} />
{unknown}
</div>
)}
</div>
)
}
Expand Down Expand Up @@ -357,7 +368,10 @@ function ReadingCalendar({ summary, activity, books = [] }) {
if (time.get(day) !== undefined) return timeLevel(time.get(day))
return 2
}
// Read, but with neither recorded time nor synced pages: nothing to measure it by (#63).
const unmeasured = (day) => hasDaily && byDay.has(day) && !(pages.get(day) > 0) && time.get(day) === undefined
const tipFor = (day) => {
if (unmeasured(day)) return UNMEASURED
const labels = [
pages.get(day) > 0 ? PAGES_VIEW.tip(pages.get(day)) : null,
time.get(day) !== undefined ? TIME_VIEW.tip(time.get(day)) : null,
Expand All @@ -369,7 +383,11 @@ function ReadingCalendar({ summary, activity, books = [] }) {
const { weeks, first, last, label } = calendarRange(scale, back, today)
const shown = (d) => d >= first && d <= last && d <= today
let count = 0
for (let d = first; d <= last && d <= today; d = new Date(d.getTime() + DAY_MS)) if (byDay.has(localDay(d))) count++
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.
Expand All @@ -384,7 +402,7 @@ function ReadingCalendar({ summary, activity, books = [] }) {
if (!shown(d)) return <div key={d.getTime()} className="aspect-square" />
const key = localDay(d)
const tip = `${fmt(d, { weekday: 'short', month: 'short', day: 'numeric' })}: ${byDay.has(key) ? tipFor(key) : 'no reading'}`
return <div key={d.getTime()} data-tip={tip} aria-label={tip} className={`aspect-square ${round} ${SHADES[level(key)]}`} />
return <div key={d.getTime()} data-tip={tip} aria-label={tip} className={`aspect-square ${round} ${unmeasured(key) ? UNKNOWN_SHADE : SHADES[level(key)]}`} />
}
// Share the visible period, reading days, available page/time totals and longest streak.
const share = () => {
Expand All @@ -396,7 +414,7 @@ function ReadingCalendar({ summary, activity, books = [] }) {
run = byDay.has(key) ? run + 1 : 0
streak = Math.max(streak, run)
}
const lv = (d) => (shown(d) ? level(localDay(d)) : null)
const lv = (d) => (!shown(d) ? null : unmeasured(localDay(d)) ? 'unknown' : level(localDay(d)))
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)))),
Expand Down Expand Up @@ -548,7 +566,7 @@ function ReadingCalendar({ summary, activity, books = [] }) {
</div>
)}
</div>
{(hasPages || hasDaily) && <div className="mt-3"><ShadeLegend /></div>}
{(hasPages || hasDaily) && <div className="mt-3"><ShadeLegend unknown={anyUnmeasured ? UNMEASURED : null} /></div>}
</Card>
)
}
Expand Down
10 changes: 5 additions & 5 deletions app/src/Stats.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ describe('reading grids', () => {
expect(queryByRole('button', { name: 'Pages read' })).toBeNull()
expect(queryByRole('button', { name: 'Reading time' })).toBeNull()
expect(getByLabelText(`Wed, Sep 30: 5 pages${hasDaily ? ' · 1h 10m reading time' : ''}`)).toBeTruthy()
expect(getByLabelText('Tue, Sep 29: read')).toBeTruthy()
expect(getByLabelText(hasDaily ? 'Tue, Sep 29: read, no time recorded' : 'Tue, Sep 29: read')).toBeTruthy()
expect(getByLabelText('Mon, Sep 28: 20 pages')).toBeTruthy()
fireEvent.click(getByRole('button', { name: 'Share your reading days' }))
fireEvent.click(getByRole('button', { name: 'Render calendar share' }))
Expand Down Expand Up @@ -312,7 +312,7 @@ describe('reading grids', () => {
pointer(cell('Tue, Sep 29'), 'pointerover', 'mouse')
expect(getByRole('tooltip').textContent).toBe('Tue, Sep 29: 0m reading time')
pointer(cell('Mon, Sep 28'), 'pointerover', 'mouse')
expect(getByRole('tooltip').textContent).toBe('Mon, Sep 28: read')
expect(getByRole('tooltip').textContent).toBe('Mon, Sep 28: read, no time recorded')
pointer(cell('Sun, Sep 27'), 'pointerover', 'mouse')
expect(getByRole('tooltip').textContent).toBe('Sun, Sep 27: no reading')
pointer(cell('Wed, Sep 30'), 'pointerdown', 'touch')
Expand Down Expand Up @@ -366,7 +366,7 @@ describe('reading grids', () => {
expect(queryByText('Time read')).toBeNull()
})

it('shades device calendar days against the median day and gives unknown time the median shade', () => {
it('shades device calendar days against the median day and outlines days with no recorded time', () => {
const summary = {
devices: [{ device: 'CrossInk' }], history_b64: btoa(String.fromCharCode(15)),
anchor_day: anchorDay('2026-09-30'), seconds: 9600, pages: 0, sessions: 0, completed: 0,
Expand All @@ -378,7 +378,7 @@ describe('reading grids', () => {
expect(cls('Wed, Sep 30')).toContain('bg-brand-200')
expect(cls('Tue, Sep 29')).toContain('bg-brand-300')
expect(cls('Mon, Sep 28')).toContain('bg-brand-500')
expect(cls('Sun, Sep 27')).toContain('bg-brand-300')
expect(cls('Sun, Sep 27')).toContain('ring-brand-400')
expect(cls('Sat, Sep 26')).toContain('bg-stone-100')
expect(container.querySelectorAll('[aria-hidden="true"]').length).toBeGreaterThanOrEqual(5)
})
Expand All @@ -396,7 +396,7 @@ describe('reading grids', () => {
fireEvent.click(getByText('Month'))
const tips = [...container.querySelectorAll('[data-tip]')].map((el) => el.dataset.tip)
expect(tips).toContain('Wed, Sep 30: 1h 10m reading time')
expect(tips).toContain('Tue, Sep 29: read')
expect(tips).toContain('Tue, Sep 29: read, no time recorded')
expect(tips).toContain('Mon, Sep 28: no reading')
expect(tips.length).toBe(30)
})
Expand Down
11 changes: 9 additions & 2 deletions app/src/shareCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,8 @@ export async function renderStatsCard({ heading, tiles, covers, chart, moods = [
}

// Reading-days calendar card. `blocks` are stacked calendars, each with `grid`
// rows of shade levels (0-4, null = outside the period) and optional `top`
// rows of shade levels (0-4, 'unknown' = read with no amount, null = outside
// the period) and optional `top`
// (column), `left` (row) and `bottom` (column) labels.
const DAY_SHADES = ['#e7e5df', '#b3cfc2', '#8fb9a6', '#69917d', '#4a7a62']
export async function renderCalendarCard({ eyebrow, heading, subtitle, tiles, blocks, moods = [] }) {
Expand Down Expand Up @@ -455,10 +456,16 @@ export async function renderCalendarCard({ eyebrow, heading, subtitle, tiles, bl
grid.forEach((row, r) =>
row.forEach((lv, c) => {
if (lv == null) return
ctx.fillStyle = DAY_SHADES[lv]
const unknown = lv === 'unknown'
ctx.fillStyle = DAY_SHADES[unknown ? 0 : lv]
ctx.beginPath()
ctx.roundRect(x0 + c * step, y0 + r * step, size, size, radius)
ctx.fill()
if (unknown) {
ctx.strokeStyle = DAY_SHADES[3]
ctx.lineWidth = Math.max(2, size * 0.08)
ctx.stroke()
}
})
)
y = y0 + gridH + bottomH + blockGap
Expand Down
Loading