fix(stats): save reading history share images

Wait for the canvas export callback before starting the download so the browser can complete the PNG export.

Closes #1967
This commit is contained in:
neon
2026-10-28 14:15:56 +00:00
parent 329e266962
commit a9e63a60e1
3 changed files with 29 additions and 6 deletions
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { ReadingHistoryItem } from '@bookorbit/types' import type { ReadingHistoryItem } from '@bookorbit/types'
import { filtersFromQuery, queryFromFilters } from '../composables/useReadingHistoryFilters' import { filtersFromQuery, queryFromFilters } from '../composables/useReadingHistoryFilters'
import { exportFileName, toCsv, toMarkdown } from '../lib/history-export' import { canvasToBlob, exportFileName, toCsv, toMarkdown } from '../lib/history-export'
import { readSpanDays } from '../lib/history-format' import { readSpanDays } from '../lib/history-format'
import { historyRouteForChartClick } from '../lib/chart-month-link' import { historyRouteForChartClick } from '../lib/chart-month-link'
import { fitTiles, withAlpha } from '../lib/share-poster' import { fitTiles, withAlpha } from '../lib/share-poster'
@@ -117,6 +117,24 @@ describe('reading history export', () => {
expect(exportFileName('Q3 2026', 'md')).toBe('reading-history-q3-2026.md') expect(exportFileName('Q3 2026', 'md')).toBe('reading-history-q3-2026.md')
expect(exportFileName('All time', 'csv')).toBe('reading-history-all-time.csv') expect(exportFileName('All time', 'csv')).toBe('reading-history-all-time.csv')
}) })
it('waits for the canvas export callback when toBlob returns undefined', async () => {
const blob = new Blob(['poster'], { type: 'image/png' })
const canvas = {
toBlob(callback: BlobCallback) {
queueMicrotask(() => callback(blob))
},
} as HTMLCanvasElement
await expect(canvasToBlob(canvas, 'image/png')).resolves.toBe(blob)
})
it('rejects when the canvas is unavailable or produces no data', async () => {
const canvas = { toBlob: (callback: BlobCallback) => callback(null) } as HTMLCanvasElement
await expect(canvasToBlob(null, 'image/png')).rejects.toThrow('Canvas is unavailable')
await expect(canvasToBlob(canvas, 'image/png')).rejects.toThrow('Canvas export returned no data')
})
}) })
describe('reading history helpers', () => { describe('reading history helpers', () => {
@@ -13,7 +13,7 @@ import { useAuth } from '@/features/auth/composables/useAuth'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions' import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { fetchReadingHistoryPage, fetchReadingHistorySummary } from '../api/reading-history.api' import { fetchReadingHistoryPage, fetchReadingHistorySummary } from '../api/reading-history.api'
import { formatHours, formatMonthName } from '../lib/history-format' import { formatHours, formatMonthName } from '../lib/history-format'
import { saveBlob } from '../lib/history-export' import { canvasToBlob, saveBlob } from '../lib/history-export'
import { POSTER_SIZES, drawPoster, type PosterLook, type PosterShape, type PosterTile } from '../lib/share-poster' import { POSTER_SIZES, drawPoster, type PosterLook, type PosterShape, type PosterTile } from '../lib/share-poster'
type SharePeriod = 'year' | 'quarter' | 'month' type SharePeriod = 'year' | 'quarter' | 'month'
@@ -240,10 +240,7 @@ watch(books, (list) => {
watch([books, shape, look, showTitles, showRereads, showRatings, imageVersion, canvas, loading], () => void nextTick(scheduleDraw)) watch([books, shape, look, showTitles, showRereads, showRatings, imageVersion, canvas, loading], () => void nextTick(scheduleDraw))
function posterBlob(): Promise<Blob> { function posterBlob(): Promise<Blob> {
return new Promise( return canvasToBlob(canvas.value, 'image/png')
(resolve, reject) =>
canvas.value?.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('empty'))), 'image/png') ?? reject(new Error('no canvas')),
)
} }
function fileName() { function fileName() {
@@ -123,3 +123,11 @@ export function saveBlob(blob: Blob, fileName: string) {
link.remove() link.remove()
setTimeout(() => URL.revokeObjectURL(url), 30_000) setTimeout(() => URL.revokeObjectURL(url), 30_000)
} }
export function canvasToBlob(canvas: HTMLCanvasElement | null, type: string): Promise<Blob> {
if (!canvas) return Promise.reject(new Error('Canvas is unavailable'))
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Canvas export returned no data'))), type)
})
}