diff --git a/assets/fonts/sleep-journal/OFL.txt b/assets/fonts/sleep-journal/OFL.txt new file mode 100644 index 00000000..372d3cb8 --- /dev/null +++ b/assets/fonts/sleep-journal/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". Lora-Italic[wght].ttf: Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/sleep-journal/lora-bold.woff b/assets/fonts/sleep-journal/lora-bold.woff new file mode 100644 index 00000000..77bdfb72 Binary files /dev/null and b/assets/fonts/sleep-journal/lora-bold.woff differ diff --git a/assets/fonts/sleep-journal/lora-regular.woff b/assets/fonts/sleep-journal/lora-regular.woff new file mode 100644 index 00000000..1fc4302a Binary files /dev/null and b/assets/fonts/sleep-journal/lora-regular.woff differ diff --git a/assets/fonts/sleep-journal/lora-semibold.woff b/assets/fonts/sleep-journal/lora-semibold.woff new file mode 100644 index 00000000..d4c9c1e2 Binary files /dev/null and b/assets/fonts/sleep-journal/lora-semibold.woff differ diff --git a/docs/API.md b/docs/API.md index c7f82ad0..af0bba25 100644 --- a/docs/API.md +++ b/docs/API.md @@ -985,3 +985,19 @@ progress. These rows have `history_only: true`, null `percentage`, `progress`, or metadata. Pagination works across current and historical books. The app uses this option for its Library. Without this option, the endpoint continues to return only synced progress; the KOReader sync and progress change endpoints are unchanged. + +### Sleep-image plugin support + +Authenticated with the usual session or `x-auth-user` / `x-auth-key` headers. +These routes require a server version including migration `0049_sleep_backgrounds.sql`. + +- `GET /api/v1/stats/sleep-image/status` → `{ "version": 1, "background": false }`. +- `GET /api/v1/stats/sleep-image?mode=overlay&width=480&height=800&timezone=America%2FNew_York&placement=bottom&metrics=books,goal,current,streak` → transparent PNG. `mode=custom` returns a full native 2-bit grayscale BMP using the account background, or built-in journal artwork when none exists (an open-book illustration, with an achievement medallion when the goal is reached). Dimensions are integers 240–1600, at most 2,000,000 pixels; encoded output is below 1 MB. Top/bottom placement and the listed metric names are supported. `preview=1` returns a browser-compatible PNG for Custom, with the same four native gray levels; omit it for the reader BMP. At the configured screen resolution, 2-bit BMP avoids per-refresh firmware conversion caching. Unknown options values return 400. +- `X-Sleep-Percent: 42` supplies the current visible-page percentage (0–100). Missing, unresolved, or invalid values omit that metric. This does not write progress, finish books, or trust a queued event as a new completion. Account totals reflect already-synced history; annual goals reuse the existing account goal. +- `PUT /api/v1/stats/sleep-image/background`, `Content-Type: image/png`: normalized PNG upload, at most 8 MB, 1600 pixels per axis and 2,000,000 decoded pixels. The app/plugin should resize and convert JPEG/other artwork to PNG before uploading. Invalid uploads preserve the previous background. PNG chunk structure is validated and IDAT inflation is bounded by the validated scanline budget before decoding, including interlaced input. +- `GET /api/v1/stats/sleep-image/background` → the signed-in account's PNG (404 when absent). +- `DELETE /api/v1/stats/sleep-image/background` → `{ "background": false }`. + +All image/background responses are private and not cached. Backgrounds belong to one account, are cleared when its reading data/account is deleted, and are presentation data outside reading-history exports. No external image URL fetching is performed. IANA timezone rules are evaluated at each historical timestamp, including DST offsets. Dates already stored as local calendar days retain their original day. These routes work under `BASE_PATH` using the same prefix as all other APIs. + +The sleep images use a reading-journal design: a compact transparent bookplate with serif type and an annual-goal seal tilted approximately ten degrees, or a full-page Custom composition with framed account artwork. Without artwork, Custom draws an original open-book illustration. Top/bottom placement and metric omission work in both modes; short landscape screens use the compact composition. Fonts (Lora regular/semibold/bold) and their SIL OFL license are bundled under `assets/fonts/sleep-journal`; rendering uses pure JavaScript and no runtime downloads or native graphics libraries. diff --git a/migrations/0049_sleep_backgrounds.sql b/migrations/0049_sleep_backgrounds.sql new file mode 100644 index 00000000..56214381 --- /dev/null +++ b/migrations/0049_sleep_backgrounds.sql @@ -0,0 +1,4 @@ +CREATE TABLE sleep_backgrounds ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + png BLOB NOT NULL +); diff --git a/package-lock.json b/package-lock.json index d2a19d59..6bc4038c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,14 @@ "@hono/node-server": "^1.13.7", "@xmldom/xmldom": "^0.9.12", "hono": "^4.6.16", + "opentype.js": "^1.3.4", + "pngjs": "^7.0.0", "yauzl": "^3.4.0" }, "devDependencies": { "@types/node": "^22.10.5", + "@types/opentype.js": "^1.3.10", + "@types/pngjs": "^6.0.5", "@types/yauzl": "^3.4.0", "tsx": "^4.19.2", "typescript": "^5.7.3", @@ -910,6 +914,23 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/opentype.js": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.10.tgz", + "integrity": "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz", @@ -1280,6 +1301,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/opentype.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", + "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", + "license": "MIT", + "dependencies": { + "string.prototype.codepointat": "^0.2.1", + "tiny-inflate": "^1.0.3" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1323,6 +1360,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.18", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", @@ -1428,6 +1474,12 @@ "dev": true, "license": "MIT" }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "license": "MIT" + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -1441,6 +1493,12 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index fcbe7137..26dfcdca 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,14 @@ "@hono/node-server": "^1.13.7", "@xmldom/xmldom": "^0.9.12", "hono": "^4.6.16", + "opentype.js": "^1.3.4", + "pngjs": "^7.0.0", "yauzl": "^3.4.0" }, "devDependencies": { "@types/node": "^22.10.5", + "@types/opentype.js": "^1.3.10", + "@types/pngjs": "^6.0.5", "@types/yauzl": "^3.4.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/src/app.ts b/src/app.ts index 0ee84607..4a760c73 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import { progressRoutes } from './routes/v1/progress.js'; import { bookmarkRoutes } from './routes/v1/bookmarks.js'; import { clippingRoutes } from './routes/v1/clippings.js'; import { statsRoutes } from './routes/v1/stats.js'; +import { sleepImageRoutes } from './routes/v1/sleep-image.js'; import { documentRoutes } from './routes/v1/documents.js'; import { connectorRoutes, oauthCallbackRoutes } from './routes/v1/connectors.js'; import { profileSettingsRoutes, publicProfileRoutes } from './routes/profile.js'; @@ -40,7 +41,7 @@ export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono column.name === 'attempted_at'); - if (!hasBookorbitAttempt) db.exec(sql); + // Development installs recorded this identical migration as 0044 before + // upstream assigned 0048. Adopt only that known history and exact schema; + // never drop goals or silently accept an unrelated existing table. + let hasLegacyReadingGoals = false; + if (file === '0048_reading_goals.sql' && applied.has('0044_reading_goals.sql')) { + const table = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='reading_goals'").get(); + if (table) { + const normalized = (value: string) => value.trim().replace(/;$/, '').replace(/\s+/g, ' ').toLowerCase(); + if (normalized(String(table.sql)) !== normalized(sql)) { + throw new Error('Cannot reconcile legacy reading_goals migration: existing table has an unexpected schema.'); + } + hasLegacyReadingGoals = true; + } + } + if (!hasBookorbitAttempt && !hasLegacyReadingGoals) db.exec(sql); db.prepare('INSERT INTO migrations (name, applied_at) VALUES (?, ?)').run( file, Math.floor(Date.now() / 1000) diff --git a/src/models/activity.ts b/src/models/activity.ts index 27268a2d..9330febf 100644 --- a/src/models/activity.ts +++ b/src/models/activity.ts @@ -21,6 +21,10 @@ import { readBookorbitStats } from './bookorbit-stats.js'; * since a book can be finished offline and only synced days later. * - Start and finish dates the user set by hand win over everything else. */ +/** Minutes west of UTC, fixed for legacy APIs or resolved per Unix timestamp (seconds). */ +export type ActivityTimezone = number | ((at: number) => number); +export const activityOffset = (timezone: ActivityTimezone, at: number) => typeof timezone === 'number' ? timezone : timezone(at); + export const FINISHED_AT = 0.98; export interface LogRow { @@ -84,13 +88,13 @@ export interface DayBook { export function computeActivity( rows: LogRow[], docs: Map, - tzOffsetMinutes = 0, + tzOffsetMinutes: ActivityTimezone = 0, finishedDates: Map = new Map(), startDates: Map = new Map() ): Activity { // Device dates are calendar dates: pin them to local noon in the client // timezone so they land on that same day however they're displayed. - const localNoon = (date: number | undefined) => (date ? date + 12 * 3600 + tzOffsetMinutes * 60 : null); + const localNoon = (date: number | undefined) => (date ? date + 12 * 3600 + activityOffset(tzOffsetMinutes, date + 12 * 3600) * 60 : null); const byDoc = new Map(); for (const r of rows) { const list = byDoc.get(r.document) ?? []; @@ -109,7 +113,7 @@ export function computeActivity( const readingDays = new Set(); const days = new Map }>(); // The day bucket for a sync, and this book's entry in it (opened at position `from`). - const dayKey = (at: number) => new Date((at - tzOffsetMinutes * 60) * 1000).toISOString().slice(0, 10); + const dayKey = (at: number) => new Date((at - activityOffset(tzOffsetMinutes, at) * 60) * 1000).toISOString().slice(0, 10); const dayOf = (at: number, document: string, from: number) => { const key = dayKey(at); readingDays.add(key); @@ -223,7 +227,7 @@ export function computeActivity( /** A user's activity from the database: progress history, plus finish and start * dates from device stats and reading days from a BookOrbit import. */ -export function readingActivity(db: DB, userId: number, tz: number, options: { includeStatsOnly?: boolean } = {}): Activity { +export function readingActivity(db: DB, userId: number, tz: ActivityTimezone, options: { includeStatsOnly?: boolean } = {}): Activity { const rows = db .prepare('SELECT document, percentage, at FROM progress_log WHERE user_id = ?') .all(userId) as unknown as LogRow[]; diff --git a/src/models/reading-goals.ts b/src/models/reading-goals.ts index 3d05d75d..71c6e2b0 100644 --- a/src/models/reading-goals.ts +++ b/src/models/reading-goals.ts @@ -1,5 +1,5 @@ import type { DB } from '../db/db.js'; -import { readingActivity } from './activity.js'; +import { readingActivity, activityOffset, type ActivityTimezone } from './activity.js'; import { aliasesByDocument } from './merge.js'; export const goalYear = (value: unknown): value is number => Number.isInteger(value) && Number(value) >= 2000 && Number(value) <= 9999; @@ -15,12 +15,21 @@ export function readingGoals(db: DB, userId: number, tz: number) { const year = Number(today.slice(0, 4)); const targets = db.prepare('SELECT year, target FROM reading_goals WHERE user_id=? ORDER BY year').all(userId) as { year: number; target: number }[]; if (!targets.length) return { year, goals: [] }; + const { counts, recent_finished } = readingYearSummary(db, userId, tz); + return { year, goals: targets.map(g => ({ ...g, completed: counts.get(g.year) ?? 0 })), recent_finished }; +} + +/** Shared canonical completion calendar, including books with only uploaded stats. */ +export function readingYearSummary(db: DB, userId: number, tz: ActivityTimezone) { + const now = Date.now(); + const today = new Date(now - activityOffset(tz, now / 1000) * 60000).toISOString().slice(0, 10); + const year = Number(today.slice(0, 4)); const aliases = new Set([...aliasesByDocument(db, userId).values()].flat()); const counts = new Map(); const recent: { document: string; at: number }[] = []; for (const b of readingActivity(db, userId, tz, { includeStatsOnly: true }).books) { if (!b.finished_at || aliases.has(b.document)) continue; - const day = new Date((b.finished_at - tz * 60) * 1000).toISOString().slice(0, 10); + const day = new Date((b.finished_at - activityOffset(tz, b.finished_at) * 60) * 1000).toISOString().slice(0, 10); if (day > today) continue; const finishedYear = Number(day.slice(0, 4)); counts.set(finishedYear, (counts.get(finishedYear) ?? 0) + 1); @@ -31,5 +40,5 @@ export function readingGoals(db: DB, userId: number, tz: number) { const info = title.get(userId, b.document); return { document: b.document, title: String(info?.title || info?.filename || 'Untitled book') }; }); - return { year, goals: targets.map(g => ({ ...g, completed: counts.get(g.year) ?? 0 })), recent_finished }; + return { year, counts, recent_finished }; } diff --git a/src/models/sleep-image.ts b/src/models/sleep-image.ts new file mode 100644 index 00000000..7dfb9bcd --- /dev/null +++ b/src/models/sleep-image.ts @@ -0,0 +1,91 @@ +import type { DB } from '../db/db.js'; +import { readingActivity } from './activity.js'; +import { readingYearSummary } from './reading-goals.js'; +import { combineReadingStreak } from './reading-streak.js'; +import { readBookorbitStats } from './bookorbit-stats.js'; +import type { GlobalStatsSnapshot } from './stats.js'; + +export class SleepImageError extends Error {} +export const MAX_SLEEP_PIXELS = 2_000_000; +export const MAX_BACKGROUND_BYTES = 8 * 1024 * 1024; +export const SLEEP_METRICS = ['books', 'goal', 'current', 'streak'] as const; +export type SleepMetric = typeof SLEEP_METRICS[number]; +export interface SleepOptions { + width: number; + height: number; + mode: 'overlay' | 'custom'; + placement: 'top' | 'bottom'; + timezone: string; + metrics: SleepMetric[]; + preview?: boolean; +} +export interface SleepMetrics { + year: number; + finished: number; + target: number | null; + currentPercent: number | null; + streak: number; +} + +/** Reuse one IANA formatter per render, resolving the offset at each instant. */ +function timezoneResolver(timezone: string): (date: Date) => number { + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', + }); + } catch { throw new SleepImageError('Choose a valid IANA timezone.'); } + return date => { + const parts = formatter.formatToParts(date); + const get = (key: string) => Number(parts.find(p => p.type === key)!.value); + const local = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')); + return Math.round((Math.floor(date.getTime() / 1000) * 1000 - local) / 60000); + }; +} + +/** JS getTimezoneOffset sign for a specific instant, including historical DST. */ +export function timezoneOffset(timezone: string, date = new Date()): number { + return timezoneResolver(timezone)(date); +} + +export function parseSleepOptions(query: Record): SleepOptions { + const dimension = (raw: string | undefined, fallback: number) => { + const n = raw === undefined ? fallback : /^\d+$/.test(raw) ? Number(raw) : NaN; + if (!Number.isInteger(n) || n < 240 || n > 1600) throw new SleepImageError('Dimensions must be integers from 240 to 1600.'); + return n; + }; + const width = dimension(query.width, 480), height = dimension(query.height, 800); + if (width * height > MAX_SLEEP_PIXELS) throw new SleepImageError('Image exceeds 2,000,000 pixels.'); + const mode = query.mode ?? 'overlay', placement = query.placement ?? 'bottom'; + if (mode !== 'overlay' && mode !== 'custom') throw new SleepImageError('Choose overlay or custom mode.'); + if (placement !== 'top' && placement !== 'bottom') throw new SleepImageError('Choose top or bottom placement.'); + const metrics = [...new Set((query.metrics ?? SLEEP_METRICS.join(',')).split(','))]; + if (!metrics.length || metrics.some(m => !SLEEP_METRICS.includes(m as SleepMetric))) throw new SleepImageError('Choose supported metrics.'); + const timezone = query.timezone ?? 'UTC'; + if (timezone.length > 100) throw new SleepImageError('Invalid timezone.'); + timezoneOffset(timezone); + if (query.preview !== undefined && query.preview !== '0' && query.preview !== '1') throw new SleepImageError('Preview must be 0 or 1.'); + return { width, height, mode, placement, timezone, metrics: metrics as SleepMetric[], preview: query.preview === '1' }; +} + +export function sleepMetrics(db: DB, userId: number, timezone: string, percent?: string): SleepMetrics { + const offset = timezoneResolver(timezone); + const tz = offset(new Date()); + const historicalOffset = (at: number) => offset(new Date(at * 1000)); + const { year, counts } = readingYearSummary(db, userId, historicalOffset); + const goal = db.prepare('SELECT target FROM reading_goals WHERE user_id=? AND year=?').get(userId, year); + const activity = readingActivity(db, userId, historicalOffset); + const snapshots = db.prepare('SELECT payload FROM stats_device_global WHERE user_id=?').all(userId) + .map(r => JSON.parse(r.payload as string) as GlobalStatsSnapshot); + const imported = readBookorbitStats(db, userId); + if (imported.global.sessions) snapshots.push(imported.global); + const daily = db.prepare('SELECT DISTINCT date FROM stats_device_day WHERE user_id=? AND seconds>0').all(userId).map(r => r.date as string); + daily.push(...imported.daily.filter(d => d.seconds > 0).map(d => d.date)); + const n = percent !== undefined && /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(percent) ? Number(percent) : NaN; + return { + year, finished: counts.get(year) ?? 0, target: goal ? Number(goal.target) : null, + currentPercent: Number.isFinite(n) && n >= 0 && n <= 100 ? n : null, + streak: combineReadingStreak(snapshots, [...activity.reading_days, ...daily], tz).current_streak, + }; +} diff --git a/src/render/sleep-graphics.ts b/src/render/sleep-graphics.ts new file mode 100644 index 00000000..89a82142 --- /dev/null +++ b/src/render/sleep-graphics.ts @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import opentype, { type Font, type PathCommand } from 'opentype.js'; +import { PNG } from 'pngjs'; + +type Point = { x: number; y: number }; +type Rotate = { cx: number; cy: number; angle: number }; +const fonts = new Map(); +function fontFor(weight: 'regular' | 'semibold' | 'bold'): Font { + if (!fonts.has(weight)) { + const bytes = fs.readFileSync(new URL(`../../assets/fonts/sleep-journal/lora-${weight}.woff`, import.meta.url)); + fonts.set(weight, opentype.parse(new Uint8Array(bytes).buffer)); + } + return fonts.get(weight)!; +} + +/** Small pure-JS vector rasterizer. Two scanlines per pixel retain fine serif edges. */ +export class SleepGraphics { + readonly image: PNG; + constructor(width: number, height: number, paper = false) { + this.image = new PNG({ width, height }); + if (paper) this.image.data.fill(255); + } + get width() { return this.image.width; } + get height() { return this.image.height; } + pixel(x: number, y: number, gray: number, alpha = 1) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height || alpha <= 0) return; + const p = (y * this.width + x) * 4, oldAlpha = this.image.data[p + 3] / 255; + const combined = alpha + oldAlpha * (1 - alpha); + const value = Math.round((gray * alpha + this.image.data[p] * oldAlpha * (1 - alpha)) / combined); + this.image.data[p] = this.image.data[p + 1] = this.image.data[p + 2] = value; + this.image.data[p + 3] = Math.round(combined * 255); + } + fill(contours: Point[][], gray = 0) { + const edges: [Point, Point][] = []; + let minY = this.height, maxY = 0; + for (const points of contours) for (let i = 0; i < points.length; i++) { + const a = points[i], b = points[(i + 1) % points.length]; + if (a.y !== b.y) edges.push([a,b]); + minY = Math.min(minY, a.y); maxY = Math.max(maxY, a.y); + } + for (let y = Math.max(0, Math.floor(minY)); y < Math.min(this.height, Math.ceil(maxY)); y++) { + const coverage = new Float32Array(this.width); + for (const dy of [.25,.75]) { + const scan = y + dy, xs: number[] = []; + for (const [a,b] of edges) if ((a.y <= scan && b.y > scan) || (b.y <= scan && a.y > scan)) xs.push(a.x + (scan - a.y) * (b.x - a.x) / (b.y - a.y)); + xs.sort((a,b) => a - b); + for (let i = 0; i + 1 < xs.length; i += 2) { + const left = Math.max(0, xs[i]), right = Math.min(this.width, xs[i + 1]); + for (let x = Math.floor(left); x < Math.ceil(right); x++) if (x >= 0 && x < this.width) coverage[x] += .5 * Math.max(0, Math.min(x + 1, right) - Math.max(x, left)); + } + } + for (let x = 0; x < this.width; x++) if (coverage[x]) this.pixel(x, y, gray, Math.min(1, coverage[x])); + } + } + line(x1: number, y1: number, x2: number, y2: number, thickness = 1, gray = 0) { + const length = Math.hypot(x2 - x1, y2 - y1); + if (!length) return; + const dx = -(y2 - y1) / length * thickness / 2, dy = (x2 - x1) / length * thickness / 2; + this.fill([[{ x:x1+dx,y:y1+dy },{ x:x2+dx,y:y2+dy },{ x:x2-dx,y:y2-dy },{ x:x1-dx,y:y1-dy }]], gray); + } + circlePoints(cx: number, cy: number, radius: number): Point[] { + return Array.from({ length: 80 }, (_, i) => ({ x: cx + Math.cos(i / 80 * Math.PI * 2) * radius, y: cy + Math.sin(i / 80 * Math.PI * 2) * radius })); + } + disc(cx: number, cy: number, radius: number, gray = 255) { this.fill([this.circlePoints(cx, cy, radius)], gray); } + ring(cx: number, cy: number, radius: number, thickness = 1, gray = 0) { + this.fill([this.circlePoints(cx,cy,radius), this.circlePoints(cx,cy,radius-thickness)], gray); + } + roundRect(x: number, y: number, width: number, height: number, radius: number, gray = 255, border = 0) { + const points: Point[] = []; + for (const [cx,cy,start] of [[x+width-radius,y+radius,-90],[x+width-radius,y+height-radius,0],[x+radius,y+height-radius,90],[x+radius,y+radius,180]]) { + for (let i = 0; i <= 8; i++) { + const angle = (start + i / 8 * 90) * Math.PI / 180; + points.push({ x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) }); + } + } + if (border) { + this.fill([points], 0); + this.roundRect(x+border,y+border,width-2*border,height-2*border,Math.max(0,radius-border),gray); + } else this.fill([points], gray); + } + text(text: string, x: number, baseline: number, size: number, options: { weight?: 'regular' | 'semibold' | 'bold'; align?: 'left' | 'center' | 'right'; maxWidth?: number; tracking?: number; gray?: number; rotate?: Rotate } = {}) { + const font = fontFor(options.weight ?? 'regular'); + const settings = { kerning: true, tracking: options.tracking ?? 0 }; + const measured = font.getAdvanceWidth(text, size, settings); + if (options.maxWidth && measured > options.maxWidth) size *= options.maxWidth / measured; + const width = font.getAdvanceWidth(text, size, settings); + if (options.align === 'center') x -= width / 2; + if (options.align === 'right') x -= width; + this.path(font.getPath(text, x, baseline, size, settings).commands, options.gray ?? 0, options.rotate); + } + path(commands: PathCommand[], gray = 0, rotate?: Rotate) { + const contours: Point[][] = []; + let points: Point[] = [], previous = { x: 0, y: 0 }; + const add = (point: Point) => { + previous = point; + if (rotate) { + const x = point.x - rotate.cx, y = point.y - rotate.cy; + points.push({ x: rotate.cx + x * Math.cos(rotate.angle) - y * Math.sin(rotate.angle), y: rotate.cy + x * Math.sin(rotate.angle) + y * Math.cos(rotate.angle) }); + } else points.push(point); + }; + for (const command of commands) { + if (command.type === 'M') { + if (points.length) contours.push(points); + points = []; add({ x: command.x, y: command.y }); + } else if (command.type === 'L') add({ x: command.x, y: command.y }); + else if (command.type === 'Q' || command.type === 'C') { + const a = previous, b = { x: command.x1, y: command.y1 }, end = { x: command.x, y: command.y }; + const c = command.type === 'C' ? { x: command.x2, y: command.y2 } : end; + const steps = Math.max(6, Math.min(40, Math.ceil((Math.hypot(b.x-a.x,b.y-a.y)+Math.hypot(c.x-b.x,c.y-b.y)+Math.hypot(end.x-c.x,end.y-c.y))/3))); + for (let i = 1; i <= steps; i++) { + const t = i / steps, u = 1 - t; + add(command.type === 'Q' + ? { x:u*u*a.x+2*u*t*b.x+t*t*end.x, y:u*u*a.y+2*u*t*b.y+t*t*end.y } + : { x:u*u*u*a.x+3*u*u*t*b.x+3*u*t*t*c.x+t*t*t*end.x, y:u*u*u*a.y+3*u*u*t*b.y+3*u*t*t*c.y+t*t*t*end.y }); + } + } else if (command.type === 'Z') { if (points.length) contours.push(points); points = []; } + } + if (points.length) contours.push(points); + this.fill(contours, gray); + } +} diff --git a/src/render/sleep-image.ts b/src/render/sleep-image.ts new file mode 100644 index 00000000..cafc29cb --- /dev/null +++ b/src/render/sleep-image.ts @@ -0,0 +1,80 @@ +import { PNG } from 'pngjs'; +import zlib from 'node:zlib'; +import { MAX_BACKGROUND_BYTES, MAX_SLEEP_PIXELS, SleepImageError, type SleepMetrics, type SleepOptions } from '../models/sleep-image.js'; +import { drawJournal } from './sleep-journal.js'; + +export function normalizeBackground(bytes: Buffer): Buffer { + if (bytes.length > MAX_BACKGROUND_BYTES) throw new SleepImageError('Background exceeds 8 MB.'); + if (bytes.length < 33 || !bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) || bytes.toString('ascii', 12, 16) !== 'IHDR') { + throw new SleepImageError('Upload a PNG background.'); + } + const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20); + if (!width || !height || width > 1600 || height > 1600 || width * height > MAX_SLEEP_PIXELS) throw new SleepImageError('Background dimensions exceed the pixel limit.'); + validatePngStream(bytes, width, height); + try { return PNG.sync.write(PNG.sync.read(bytes, { checkCRC: true }), { colorType: 6 }); } + catch { throw new SleepImageError('Invalid PNG background.'); } +} + +/** pngjs accepts repeated IHDRs and inflates interlaced data without a limit. + * Validate the actual chunks and bounded scanline stream before invoking it. */ +function validatePngStream(bytes: Buffer, width: number, height: number): void { + const fail = (): never => { throw new SleepImageError('Invalid PNG structure or decompressed scanline data.'); }; + const depth = bytes[24], color = bytes[25], interlace = bytes[28]; + const allowed: Record = { 0: [1,2,4,8,16], 2: [8,16], 3: [1,2,4,8], 4: [8,16], 6: [8,16] }; + if (!allowed[color]?.includes(depth) || bytes[26] !== 0 || bytes[27] !== 0 || interlace > 1) fail(); + const data: Buffer[] = []; + let offset = 8, chunks = 0, seenData = false, endedData = false, ended = false; + while (offset < bytes.length) { + if (++chunks > 4096 || offset + 12 > bytes.length) fail(); + const length = bytes.readUInt32BE(offset), type = bytes.toString('ascii', offset + 4, offset + 8); + if (offset + 12 + length > bytes.length) fail(); + if (chunks === 1 ? type !== 'IHDR' || length !== 13 : type === 'IHDR') fail(); + if (type === 'IDAT') { + if (endedData) fail(); + seenData = true; data.push(bytes.subarray(offset + 8, offset + 8 + length)); + } else if (seenData) endedData = true; + offset += 12 + length; + if (type === 'IEND') { + if (length !== 0 || offset !== bytes.length || !seenData) fail(); + ended = true; break; + } + } + if (!ended) fail(); + const channels: Record = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }; + const bpp = channels[color] * depth; + const passes = interlace ? [[0,0,8,8],[4,0,8,8],[0,4,4,8],[2,0,4,4],[0,2,2,4],[1,0,2,2],[0,1,1,2]] : [[0,0,1,1]]; + const budget = passes.reduce((sum, [x, y, dx, dy]) => { + const w = Math.max(0, Math.ceil((width - x) / dx)), h = Math.max(0, Math.ceil((height - y) / dy)); + return sum + (w && h ? (Math.ceil(w * bpp / 8) + 1) * h : 0); + }, 0); + try { + if (zlib.inflateSync(Buffer.concat(data), { maxOutputLength: budget }).length !== budget) fail(); + } catch { fail(); } +} + +export function renderSleepImage(stats: SleepMetrics, options: SleepOptions, background?: Buffer): { bytes: Buffer; contentType: 'image/png' | 'image/bmp' } { + const png = drawJournal(stats, options, background); + if (options.mode === 'custom') { + // Match the display's native four shades and bypass firmware conversion caches. + for (let p = 0; p < png.data.length; p += 4) png.data[p] = png.data[p + 1] = png.data[p + 2] = Math.round(png.data[p] / 85) * 85; + } + const asPng = options.mode === 'overlay' || options.preview; + const bytes = asPng ? PNG.sync.write(png, { colorType: 6 }) : grayBmp(png); + if (bytes.length >= 1024 * 1024) throw new SleepImageError('Rendered image exceeds the reader download limit.'); + return { bytes, contentType: asPng ? 'image/png' : 'image/bmp' }; +} + +function grayBmp(png: PNG): Buffer { + const stride = Math.ceil(png.width / 16) * 4; + const offset = 54 + 4 * 4; + const out = Buffer.alloc(offset + stride * png.height); + out.write('BM'); out.writeUInt32LE(out.length, 2); out.writeUInt32LE(offset, 10); + out.writeUInt32LE(40, 14); out.writeInt32LE(png.width, 18); out.writeInt32LE(png.height, 22); + out.writeUInt16LE(1, 26); out.writeUInt16LE(2, 28); out.writeUInt32LE(stride * png.height, 34); out.writeUInt32LE(4, 46); + for (let i = 0; i < 4; i++) out.fill(i * 85, 54 + i * 4, 54 + i * 4 + 3); + for (let y = 0; y < png.height; y++) for (let x = 0; x < png.width; x++) { + const value = Math.round(png.data[(y * png.width + x) * 4] / 85); + out[offset + (png.height - y - 1) * stride + (x >> 2)] |= value << (6 - (x % 4) * 2); + } + return out; +} diff --git a/src/render/sleep-journal.ts b/src/render/sleep-journal.ts new file mode 100644 index 00000000..2fc1e8d8 --- /dev/null +++ b/src/render/sleep-journal.ts @@ -0,0 +1,202 @@ +import { PNG } from 'pngjs'; +import type { SleepMetrics, SleepOptions } from '../models/sleep-image.js'; +import { SleepGraphics } from './sleep-graphics.js'; + +interface Rect { x: number; y: number; width: number; height: number } +interface Cell { value: string; label: string; fraction?: number } +interface Seal { cx: number; cy: number; radius: number; angle: number; count: string; reached: boolean; fraction: number } +export interface JournalLayout { scale: number; headline: string; card: Rect; art?: Rect; fullPage: boolean; cells: Cell[]; seal?: Seal } + +export function journalLayout(stats: SleepMetrics, options: SleepOptions): JournalLayout { + const { width, height, metrics } = options; + const scale = Math.max(.5, Math.min(width / 480, height / 360)); + const margin = Math.max(10, 22 * scale), padding = 20 * scale; + const cells: Cell[] = []; + if (metrics.includes('books')) cells.push({ value: String(stats.finished), label: 'books this year' }); + if (metrics.includes('current') && stats.currentPercent !== null) cells.push({ value: `${Math.floor(stats.currentPercent)}%`, label: 'current book', fraction: stats.currentPercent / 100 }); + if (metrics.includes('streak')) cells.push({ value: String(stats.streak), label: 'day streak' }); + const hasSeal = metrics.includes('goal') && stats.target !== null; + const radius = Math.max(42, 64 * scale), rise = hasSeal ? radius * .72 : 0; + const cardHeight = Math.max(128, (cells.length ? 178 : 116) * scale); + const fullPage = options.mode === 'custom' && height >= width * 1.25 && height >= 480; + const inset = fullPage ? 12 * scale : 0; + const card: Rect = { x: margin + inset, y: 0, width: width - 2 * (margin + inset), height: cardHeight }; + const bottom = height - margin - inset; + card.y = options.placement === 'bottom' ? bottom - cardHeight : margin + (fullPage ? 108 * scale : 0) + rise; + const seal: Seal | undefined = hasSeal ? { + cx: card.x + card.width - padding - radius, + cy: card.y + radius - rise - 8 * scale, + radius, angle: -10 * Math.PI / 180, count: `${stats.finished} / ${stats.target}`, + reached: stats.finished >= stats.target!, + fraction: Math.max(0, Math.min(1, stats.finished / stats.target!)), + } : undefined; + let art: Rect | undefined; + if (fullPage) { + const top = options.placement === 'bottom' ? margin + 108 * scale : card.y + card.height + 24 * scale; + const end = options.placement === 'bottom' ? card.y - 24 * scale : bottom; + art = { x: card.x + 4 * scale, y: top, width: card.width - 8 * scale, height: end - top }; + } + const remaining = seal ? Math.max(0, stats.target! - stats.finished) : 0; + const headline = seal ? (seal.reached ? 'You did it!' : `${remaining} ${remaining === 1 ? 'book' : 'books'} to go.`) : 'Between chapters'; + return { scale, headline, card, art, fullPage, cells, seal }; +} + +function star(g: SleepGraphics, cx: number, cy: number, size: number, gray = 0) { + g.line(cx-size,cy,cx+size,cy,2.4,gray); g.line(cx,cy-size,cx,cy+size,2.4,gray); + g.line(cx-size*.45,cy-size*.45,cx+size*.45,cy+size*.45,1.7,gray); + g.line(cx+size*.45,cy-size*.45,cx-size*.45,cy+size*.45,1.7,gray); +} + +function book(g: SleepGraphics, cx: number, cy: number, width: number, height: number, stroke: number) { + // Two bowed pages form an original open-book motif. + for (const direction of [-1,1]) { + const edge = cx + direction * width / 2; + g.path([ + { type:'M',x:cx,y:cy }, + { type:'Q',x1:cx+direction*width*.25,y1:cy-height*.26,x:edge,y:cy-height*.13 }, + { type:'L',x:edge,y:cy+height*.68 }, + { type:'Q',x1:cx+direction*width*.25,y1:cy+height*.53,x:cx,y:cy+height*.83 }, + { type:'Z' }, + ], 0); + g.path([ + { type:'M',x:cx+direction*stroke,y:cy+stroke }, + { type:'Q',x1:cx+direction*width*.25,y1:cy-height*.22+stroke,x:edge-direction*stroke,y:cy-height*.13+stroke }, + { type:'L',x:edge-direction*stroke,y:cy+height*.68-stroke }, + { type:'Q',x1:cx+direction*width*.25,y1:cy+height*.53-stroke,x:cx+direction*stroke,y:cy+height*.83-stroke }, + { type:'Z' }, + ], 255); + for (let row = 0; row < 4; row++) { + const y = cy + height * (.15 + row * .12); + g.line(cx+direction*width*.1,y,edge-direction*width*.1,y-height*.06,stroke*.8,0); + } + } +} + +function celebration(g: SleepGraphics, rect: Rect, scale: number, finished: number) { + const { x, y, width, height } = rect; + const cx = x + width / 2, cy = y + height * .40; + const radius = Math.min(width * .29, height * .21); + // A burst of ink around the book turns the quiet journal into an award page. + for (let i = 0; i < 20; i++) { + const angle = i * Math.PI / 10; + const inner = radius * 1.15, outer = radius * (i % 2 ? 1.34 : 1.52); + g.line(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner, + cx + Math.cos(angle) * outer, cy + Math.sin(angle) * outer, Math.max(2, 3.8 * scale)); + } + g.ring(cx, cy, radius, Math.max(2, 4 * scale)); + book(g, cx, cy - radius * .24, radius * 1.35, radius * .73, Math.max(2, 4 * scale)); + for (const [dx, dy, size] of [[-.39,-.32,9],[.39,-.27,7],[-.37,.25,6],[.38,.28,10]]) { + star(g, cx + width * dx, cy + height * dy, size * scale); + } + g.text('GOAL COMPLETE!', cx, y + height * .79, 25 * scale, + { align: 'center', weight: 'bold', maxWidth: width - 24 * scale }); + g.text(`${finished} books. A year of stories.`, cx, y + height * .88, 16 * scale, + { weight: 'bold', align: 'center', maxWidth: width - 24 * scale }); +} + +function artwork(g: SleepGraphics, rect: Rect, scale: number, background?: Buffer) { + const { x,y,width,height } = rect; + const radius = 12 * scale; + g.roundRect(x-2*scale,y-2*scale,width+4*scale,height+4*scale,radius,255,Math.max(2,3*scale)); + if (background) { + const source = PNG.sync.read(background); + const ratio = Math.max(width/source.width,height/source.height); + const left = (source.width-width/ratio)/2, top = (source.height-height/ratio)/2; + for (let yy = Math.ceil(y); yy < y+height; yy++) for (let xx = Math.ceil(x); xx < x+width; xx++) { + const localX = xx+.5-x, localY = yy+.5-y; + const cornerX = Math.max(radius-localX,localX-(width-radius),0), cornerY = Math.max(radius-localY,localY-(height-radius),0); + if (cornerX*cornerX+cornerY*cornerY > radius*radius) continue; + const sx = Math.max(0,Math.min(source.width-1,Math.floor(left+localX/ratio))); + const sy = Math.max(0,Math.min(source.height-1,Math.floor(top+localY/ratio))); + const p = (sy*source.width+sx)*4, alpha = source.data[p+3]/255; + g.pixel(xx,yy,Math.round((source.data[p]*.299+source.data[p+1]*.587+source.data[p+2]*.114)*alpha+255*(1-alpha))); + } + } else { + const cx = x+width/2, cy = y+height*.49; + const bw = Math.min(width*.65,height*.73), bh = bw*.53; + book(g,cx,cy-bh*.25,bw,bh,Math.max(2,3*scale)); + star(g,cx+width*.25,cy-height*.23,8*scale); + star(g,cx-width*.22,cy-height*.18,5*scale,0); + star(g,cx+width*.08,cy-height*.31,4*scale,0); + for (const direction of [-1,1]) g.line(cx+direction*bw*.53,cy+bh*.73,cx+direction*bw*.63,cy+bh*.73,1.6*scale,0); + g.text('Good stories, quiet moments.',cx,y+height-26*scale,Math.max(11,14*scale),{ align:'center',weight:'bold',maxWidth:width-24*scale }); + } +} + +export function drawJournal(stats: SleepMetrics, options: SleepOptions, background?: Buffer): PNG { + const g = new SleepGraphics(options.width,options.height,options.mode==='custom'); + const layout = journalLayout(stats,options); + const { scale:s,card,seal,cells } = layout; + const pad = 20*s, stroke = Math.max(2,3*s); + if (layout.fullPage) { + const m = Math.max(10,22*s); + g.roundRect(m-7*s,m-7*s,options.width-2*m+14*s,options.height-2*m+14*s,16*s,255,stroke); + book(g,options.width/2,m+7*s,26*s,14*s,Math.max(1,1.4*s)); + g.text('THE READING JOURNAL',options.width/2,m+48*s,Math.max(11,13*s),{ align:'center',weight:'bold',tracking:100,maxWidth:options.width-2*m }); + g.text(seal?.reached ? 'You did it!' : 'One more chapter.',options.width/2,m+81*s,29*s,{ weight:'bold',align:'center',maxWidth:options.width-2*m-16*s }); + if (layout.art && layout.art.height > 0) { + if (seal?.reached && !background) celebration(g,layout.art,s,stats.finished); + else artwork(g,layout.art,s,background); + } + } else if (options.mode==='custom') { + artwork(g,{ x:12*s,y:12*s,width:options.width-24*s,height:options.height-24*s },s,background); + } + if (!cells.length && !seal) return g.image; + g.roundRect(card.x,card.y,card.width,card.height,12*s,255,stroke); + if (seal?.reached) { + g.roundRect(card.x,card.y,card.width,card.height * .47,12*s,0); + } + const titleWidth = seal ? seal.cx-seal.radius-card.x-pad-6*s : card.width-2*pad; + g.text('YOUR READING YEAR',card.x+pad,card.y+27*s,Math.max(11,13*s),{ weight:'bold',tracking:40,maxWidth:titleWidth,gray:seal?.reached ? 255 : 0 }); + g.text(layout.headline,card.x+pad,card.y+64*s,Math.max(19,27*s),{ maxWidth:titleWidth,weight:'bold',gray:seal?.reached ? 255 : 0 }); + if (cells.length) { + const divider = card.y+card.height*.47; + g.line(card.x+pad,divider,card.x+card.width-pad,divider,Math.max(1.8,2.7*s),0); + const cellWidth = (card.width-2*pad)/cells.length; + cells.forEach((cell,index) => { + const cx = card.x+pad+cellWidth*(index+.5); + const numberY = divider+(card.height-(divider-card.y))*.43; + g.text(cell.value,cx,numberY,Math.max(22,38*s),{ align:'center',weight:'bold',maxWidth:cellWidth-8*s }); + g.text(cell.label,cx,numberY+23*s,Math.max(11,14.5*s),{ align:'center',weight:'bold',maxWidth:cellWidth-4*s }); + if (index) g.line(card.x+pad+cellWidth*index,divider+18*s,card.x+pad+cellWidth*index,card.y+card.height-27*s,Math.max(1.5,2.2*s),0); + if (cell.fraction !== undefined) { + const w = Math.min(cellWidth-14*s,88*s), y = card.y+card.height-15*s; + g.roundRect(cx-w/2,y-2.5*s,w,5*s,1*s,255,Math.max(.8,s)); + if (cell.fraction > 0) g.line(cx-w/2,y,cx-w/2+w*cell.fraction,y,3.5*s,0); + } + }); + } + if (seal) { + const { cx,cy,radius:r,angle } = seal; + g.disc(cx,cy,r+2*s); + if (seal.reached) g.disc(cx,cy,r,0); + const ink = seal.reached ? 255 : 0; + g.ring(cx,cy,r,Math.max(1.2,1.6*s),ink); + // The heavier arc is the year's actual progress, starting at the tilted top. + const points = [], steps = Math.max(1,Math.ceil(seal.fraction*120)); + for (let i=0;i<=steps;i++) { + const a = -Math.PI/2+angle+i/steps*seal.fraction*Math.PI*2; + points.push({x:cx+Math.cos(a)*r,y:cy+Math.sin(a)*r}); + } + for (let i=steps;i>=0;i--) { + const a = -Math.PI/2+angle+i/steps*seal.fraction*Math.PI*2; + points.push({x:cx+Math.cos(a)*(r-5.5*s),y:cy+Math.sin(a)*(r-5.5*s)}); + } + if (seal.fraction>0) g.fill([points],ink); + g.ring(cx,cy,r-8*s,Math.max(1.2,1.5*s),ink); + const rotate = { cx,cy,angle }; + g.text(seal.reached?'GOAL COMPLETE!':'YEARLY GOAL',cx,cy-14*s,Math.max(9.5,11*s),{ align:'center',weight:'bold',tracking:30,maxWidth:r*1.32,gray:ink,rotate }); + g.text(seal.count,cx,cy+8*s,Math.max(16,25*s),{ align:'center',weight:'bold',maxWidth:r*1.5,gray:ink,rotate }); + g.text(String(stats.year),cx,cy+29*s,Math.max(11,13*s),{ align:'center',weight:'bold',tracking:100,gray:ink,rotate }); + if (seal.reached) { + for (let i = 0; i < 12; i++) { + const a = i * Math.PI / 6; + g.line(cx+Math.cos(a)*(r+4*s),cy+Math.sin(a)*(r+4*s), + cx+Math.cos(a)*(r+9*s),cy+Math.sin(a)*(r+9*s),Math.max(2,3.2*s)); + } + star(g,cx-r*.55,cy-r*.53,4*s,255); + star(g,cx+r*.55,cy+r*.53,4*s,255); + } + } + return g.image; +} diff --git a/src/routes/account.ts b/src/routes/account.ts index c15863bf..40c5dfe1 100644 --- a/src/routes/account.ts +++ b/src/routes/account.ts @@ -17,6 +17,7 @@ export function deleteKosyncUserData(db: DB, userId: number, username: string, { const links = revocations(db, userId); withTransaction(db, () => { for (const table of [ + 'sleep_backgrounds', 'reading_goals', 'microblog_clipping_selections', 'microblog_clipping_posts', diff --git a/src/routes/v1/sleep-image.ts b/src/routes/v1/sleep-image.ts new file mode 100644 index 00000000..3288efdf --- /dev/null +++ b/src/routes/v1/sleep-image.ts @@ -0,0 +1,53 @@ +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import type { AppEnv } from '../../auth/middleware.js'; +import type { DB } from '../../db/db.js'; +import { MAX_BACKGROUND_BYTES, parseSleepOptions, sleepMetrics, SleepImageError } from '../../models/sleep-image.js'; +import { normalizeBackground, renderSleepImage } from '../../render/sleep-image.js'; + +export function sleepImageRoutes(db: DB): Hono { + const app = new Hono(); + const path = '/stats/sleep-image'; + const background = (userId: number) => { + const row = db.prepare('SELECT png FROM sleep_backgrounds WHERE user_id=?').get(userId); + return row ? Buffer.from(row.png as Uint8Array) : undefined; + }; + for (const match of [path, path + '/*']) app.use(match, async (c, next) => { + c.header('Cache-Control', 'private, no-store'); + c.header('X-Content-Type-Options', 'nosniff'); + await next(); + }); + + app.get(path + '/status', c => c.json({ version: 1, background: !!background(c.get('user').id) })); + app.get(path + '/background', c => { + const png = background(c.get('user').id); + return png ? c.body(new Uint8Array(png), 200, { 'Content-Type': 'image/png' }) : c.json({ message: 'No background uploaded.' }, 404); + }); + app.put(path + '/background', bodyLimit({ maxSize: MAX_BACKGROUND_BYTES }), async c => { + if (c.req.header('Content-Type')?.split(';')[0].trim().toLowerCase() !== 'image/png') return c.json({ message: 'Upload a PNG background.' }, 400); + try { + const png = normalizeBackground(Buffer.from(await c.req.arrayBuffer())); + db.prepare('INSERT INTO sleep_backgrounds (user_id,png) VALUES (?,?) ON CONFLICT(user_id) DO UPDATE SET png=excluded.png').run(c.get('user').id, png); + return c.json({ background: true }); + } catch (error) { + if (error instanceof SleepImageError) return c.json({ message: error.message }, 400); + throw error; + } + }); + app.delete(path + '/background', c => { + db.prepare('DELETE FROM sleep_backgrounds WHERE user_id=?').run(c.get('user').id); + return c.json({ background: false }); + }); + app.get(path, c => { + try { + const options = parseSleepOptions(c.req.query()); + const stats = sleepMetrics(db, c.get('user').id, options.timezone, c.req.header('X-Sleep-Percent')); + const result = renderSleepImage(stats, options, options.mode === 'custom' ? background(c.get('user').id) : undefined); + return c.body(new Uint8Array(result.bytes), 200, { 'Content-Type': result.contentType }); + } catch (error) { + if (error instanceof SleepImageError) return c.json({ message: error.message }, 400); + throw error; + } + }); + return app; +} diff --git a/test/db.test.ts b/test/db.test.ts index c133ab39..9487b636 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -1,6 +1,34 @@ import { expect, it } from 'vitest'; import { migrate, openDatabase, withTransaction } from '../src/db/db.js'; +it('upgrades the renamed legacy reading-goals migration without losing targets', () => { + const db = openDatabase(':memory:'); + try { + migrate(db); + db.prepare("INSERT INTO users (username, key_hash, created_at) VALUES ('legacy-reader', 'test-key', 0)").run(); + const userId = Number(db.prepare("SELECT id FROM users WHERE username='legacy-reader'").get()!.id); + db.prepare('INSERT INTO reading_goals (user_id, year, target) VALUES (?, 2026, 24)').run(userId); + db.prepare("UPDATE migrations SET name='0044_reading_goals.sql' WHERE name='0048_reading_goals.sql'").run(); + migrate(db); + migrate(db); + expect(db.prepare('SELECT year, target FROM reading_goals WHERE user_id=?').get(userId)).toEqual({ year: 2026, target: 24 }); + expect(db.prepare("SELECT COUNT(*) AS n FROM migrations WHERE name='0048_reading_goals.sql'").get()).toEqual({ n: 1 }); + expect(() => db.prepare('INSERT INTO reading_goals (user_id,year,target) VALUES (?,2027,0)').run(userId)).toThrow(); + } finally { db.close(); } +}); + +it('does not adopt an incompatible table when repairing the legacy goal migration', () => { + const db = openDatabase(':memory:'); + try { + migrate(db); + db.prepare("UPDATE migrations SET name='0044_reading_goals.sql' WHERE name='0048_reading_goals.sql'").run(); + db.exec('DROP TABLE reading_goals; CREATE TABLE reading_goals (unexpected TEXT); INSERT INTO reading_goals VALUES (\'keep this\')'); + expect(() => migrate(db)).toThrow(/reading.goals|reading_goals/i); + expect(db.prepare('SELECT unexpected FROM reading_goals').get()).toEqual({ unexpected: 'keep this' }); + expect(db.prepare("SELECT COUNT(*) AS n FROM migrations WHERE name='0048_reading_goals.sql'").get()).toEqual({ n: 0 }); + } finally { db.close(); } +}); + it.each([false, true])('repairs BookOrbit imports with an existing attempted_at column: %s', (hasColumn) => { const db = openDatabase(':memory:'); try { diff --git a/test/sleep-image-render.test.ts b/test/sleep-image-render.test.ts new file mode 100644 index 00000000..5d8469ff --- /dev/null +++ b/test/sleep-image-render.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PNG } from 'pngjs'; +import { deflateSync } from 'node:zlib'; +import zlib from 'node:zlib'; +import { normalizeBackground, renderSleepImage } from '../src/render/sleep-image.js'; +import { journalLayout } from '../src/render/sleep-journal.js'; +import { parseSleepOptions } from '../src/models/sleep-image.js'; + +const stats = { year: 2026, finished: 12, target: 24, currentPercent: 42, streak: 3 }; +describe('sleep image rendering', () => { + it('keeps the cover visible outside the readable panel', () => { + const result = renderSleepImage(stats, parseSleepOptions({})); + const png = PNG.sync.read(result.bytes); + expect(result.contentType).toBe('image/png'); + expect([png.width, png.height]).toEqual([480, 800]); + expect(png.data[3]).toBe(0); + const alpha = (x: number, y: number) => png.data[(y * png.width + x) * 4 + 3]; + expect(alpha(30, 770)).toBe(255); + const top = PNG.sync.read(renderSleepImage(stats, parseSleepOptions({ placement: 'top' })).bytes); + expect(top.data[(200 * 480 + 240) * 4 + 3]).toBe(255); + expect(top.data[(770 * 480 + 30) * 4 + 3]).toBe(0); + }); + + it('celebrates reached and exceeded goals and hides absent values', () => { + const options = parseSleepOptions({}); + expect(journalLayout(stats, options).seal!.count).toBe('12 / 24'); + expect(journalLayout({ ...stats, target: 12 }, options).seal!.reached).toBe(true); + expect(journalLayout({ ...stats, target: 10 }, options).seal!.count).toBe('12 / 10'); + const absent = journalLayout({ ...stats, target: null, currentPercent: null }, options); + expect(absent.seal).toBeUndefined(); + expect(absent.cells.map(c => c.label)).not.toContain('current book'); + expect(journalLayout(stats, parseSleepOptions({ metrics: 'streak' })).cells.map(c => c.label)).toEqual(['day streak']); + }); + + it('encodes screen-ready 2-bit BMPs below the firmware cap even with noisy backgrounds', () => { + const background = new PNG({ width: 480, height: 800 }); + for (let i = 0; i < background.data.length; i += 4) { + const n = ((i * 1664525 + (i >>> 5) * 1013904223) >>> 8) & 255; + background.data.set([n, n, n, 255], i); + } + for (const [width, height] of [[481, 800], [1200, 1600]]) { + const result = renderSleepImage(stats, parseSleepOptions({ width: String(width), height: String(height), mode: 'custom' }), PNG.sync.write(background)); + expect(result.contentType).toBe('image/bmp'); + expect(result.bytes.toString('ascii', 0, 2)).toBe('BM'); + expect(result.bytes.readUInt32LE(18)).toBe(width); + expect(result.bytes.readUInt32LE(22)).toBe(height); + expect(result.bytes.readUInt16LE(28)).toBe(2); + expect(result.bytes.length).toBe(70 + Math.ceil(width / 16) * 4 * height); + expect(result.bytes.length).toBeLessThan(1048576); + expect(result.bytes.readUInt32LE(2)).toBe(result.bytes.length); + expect([0,1,2,3].map(index => result.bytes[54 + index * 4])).toEqual([0,85,170,255]); + } + }); + + it('provides a browser-compatible PNG preview for custom wallpapers', () => { + const preview = renderSleepImage(stats, parseSleepOptions({ mode: 'custom', preview: '1' })); + expect(preview.contentType).toBe('image/png'); + expect(PNG.sync.read(preview.bytes).data[3]).toBe(255); + }); + + it('rejects invalid and oversized decoded backgrounds before allocation', () => { + expect(() => normalizeBackground(Buffer.from('not PNG'))).toThrow(/PNG/i); + const large = PNG.sync.write(new PNG({ width: 1, height: 1 })); + large.writeUInt32BE(100000, 16); + expect(() => normalizeBackground(large)).toThrow(/dimension|pixel/i); + const valid = PNG.sync.write(new PNG({ width: 240, height: 240 })); + expect(PNG.sync.read(normalizeBackground(valid)).width).toBe(240); + }); + + it('keeps the entire panel inside short landscape screens', () => { + const png = PNG.sync.read(renderSleepImage(stats, parseSleepOptions({ width: '1200', height: '240' })).bytes); + for (let x = 0; x < png.width; x++) { + expect(png.data[x * 4 + 3]).toBe(0); + expect(png.data[((png.height - 1) * png.width + x) * 4 + 3]).toBe(0); + } + }); + + it('rejects a second IHDR before the decoder can replace validated dimensions', () => { + const first = PNG.sync.write(new PNG({ width: 1, height: 1 })); + const oversized = PNG.sync.write(new PNG({ width: 1601, height: 1 })); + const attack = Buffer.concat([first.subarray(0, 33), oversized.subarray(8)]); + expect(() => normalizeBackground(attack)).toThrow(/PNG|header|IHDR/i); + }); + + it('bounds IDAT decompression even for interlaced images with tiny headers', () => { + // A valid CRC lets the real decoder reach the allocation that must be bounded. + const chunk = (type: string, data: Buffer) => { + const body = Buffer.concat([Buffer.from(type), data]); + let crc = 0xffffffff; + for (const byte of body) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + const out = Buffer.alloc(data.length + 12); + out.writeUInt32BE(data.length); body.copy(out, 4); out.writeUInt32BE((crc ^ 0xffffffff) >>> 0, out.length - 4); + return out; + }; + const ihdr = Buffer.from([0,0,0,1, 0,0,0,1, 8,6,0,0,1]); + const attack = Buffer.concat([ + Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(Buffer.alloc(10000))), chunk('IEND', Buffer.alloc(0)), + ]); + const inflate = vi.spyOn(zlib, 'inflateSync'); + try { + expect(() => normalizeBackground(attack)).toThrow(); + expect(inflate).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({ maxOutputLength: 5 })); + } finally { inflate.mockRestore(); } + const valid = Buffer.concat([ + Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(Buffer.from([0,0,0,0,255]))), chunk('IEND', Buffer.alloc(0)), + ]); + expect([...PNG.sync.read(normalizeBackground(valid)).data]).toEqual([0,0,0,255]); + }); +}); diff --git a/test/sleep-image.test.ts b/test/sleep-image.test.ts new file mode 100644 index 00000000..1ccf7747 --- /dev/null +++ b/test/sleep-image.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { makeTestApp, registerUser, DOC } from './helpers.js'; +import { parseSleepOptions, sleepMetrics, timezoneOffset } from '../src/models/sleep-image.js'; +import { PNG } from 'pngjs'; + +afterEach(() => vi.useRealTimers()); + +describe('sleep image data', () => { + it('uses the account goal and canonical completion dates without writing progress', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2027-01-01T02:00:00Z')); + const s = makeTestApp(); + try { + const u = await registerUser(s.app); + const id = Number(s.db.prepare('SELECT id FROM users WHERE username=?').get(u.username)!.id); + s.db.prepare('INSERT INTO progress_log (user_id,document,device_id,percentage,at) VALUES (?,?,?,1,?)') + .run(id, DOC, 'reader', Date.parse('2027-01-01T01:00:00Z') / 1000); + const before = s.db.prepare('SELECT COUNT(*) AS n FROM progress_log').get()!.n; + expect(sleepMetrics(s.db, id, 'America/New_York', '42')).toMatchObject({ year: 2026, finished: 1, target: null, currentPercent: 42, streak: 1 }); + s.db.prepare('INSERT INTO reading_goals (user_id,year,target) VALUES (?,?,?)').run(id, 2026, 1); + expect(sleepMetrics(s.db, id, 'America/New_York', '100')).toMatchObject({ year: 2026, finished: 1, target: 1, currentPercent: 100 }); + expect(sleepMetrics(s.db, id, 'UTC', undefined)).toMatchObject({ year: 2027, finished: 1, target: null, currentPercent: null }); + expect(s.db.prepare('SELECT COUNT(*) AS n FROM progress_log').get()!.n).toBe(before); + } finally { s.db.close(); } + }); + + it('keeps winter completions in their historical local year when rendered in summer', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2027-01-10T12:00:00Z')); + const s = makeTestApp(); + try { + const u = await registerUser(s.app); + const id = Number(s.db.prepare('SELECT id FROM users WHERE username=?').get(u.username)!.id); + s.db.prepare('INSERT INTO progress_log (user_id,document,device_id,percentage,at) VALUES (?,?,?,1,?)') + .run(id, DOC, 'reader', Date.parse('2027-01-01T04:30:00Z') / 1000); + // A chosen calendar date must remain Jan 1 rather than being re-zoned as an instant. + s.db.prepare('INSERT INTO documents (user_id,document,status,finished_date,updated_at) VALUES (?,?,?,?,?)') + .run(id, 'manual-finish', 'finished', Date.parse('2027-01-01T00:00:00Z') / 1000, Date.now() / 1000); + expect(sleepMetrics(s.db, id, 'America/New_York').finished).toBe(1); + vi.setSystemTime(new Date('2027-07-01T12:00:00Z')); + expect(sleepMetrics(s.db, id, 'America/New_York').finished).toBe(1); + } finally { s.db.close(); } + }); + + it.each([ + ['2027-03-14T12:00:00Z', '2027-03-13T04:30:00Z', 0], + ['2026-11-02T12:00:00Z', '2026-11-01T04:30:00Z', 1], + ])('keeps near-midnight streak days on their historical side of DST (%s)', async (now, event, expected) => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(now)); + const s = makeTestApp(); + try { + const u = await registerUser(s.app); + const id = Number(s.db.prepare('SELECT id FROM users WHERE username=?').get(u.username)!.id); + s.db.prepare('INSERT INTO progress_log (user_id,document,device_id,percentage,at) VALUES (?,?,?,.5,?)') + .run(id, DOC, 'reader', Date.parse(event) / 1000); + expect(sleepMetrics(s.db, id, 'America/New_York').streak).toBe(expected); + } finally { s.db.close(); } + }); + + it.each([undefined, '', '{event.percent}', '-1', '101', 'NaN', 'Infinity', '4x'])('omits unavailable percentage %s', async value => { + const s = makeTestApp(); + try { + const u = await registerUser(s.app); + const id = Number(s.db.prepare('SELECT id FROM users WHERE username=?').get(u.username)!.id); + expect(sleepMetrics(s.db, id, 'UTC', value).currentPercent).toBeNull(); + } finally { s.db.close(); } + }); + + it('uses seasonal timezone offsets and rejects invalid zones', () => { + expect(timezoneOffset('America/New_York', new Date('2026-07-01T12:00Z'))).toBe(240); + expect(timezoneOffset('America/New_York', new Date('2026-12-01T12:00Z'))).toBe(300); + expect(() => timezoneOffset('Imaginary/Place')).toThrow(/timezone/i); + }); + + it('bounds output and accepts all supported metric selections', () => { + expect(parseSleepOptions({ width: '1200', height: '1600', mode: 'custom', metrics: 'books,goal,current,streak', placement: 'top', timezone: 'UTC' })) + .toMatchObject({ width: 1200, height: 1600, mode: 'custom', placement: 'top' }); + for (const query of [{ width: '239' }, { width: '1601' }, { width: '1600', height: '1600' }, { height: 'NaN' }, { mode: 'cover' }, { metrics: 'secrets' }, { placement: 'middle' }, { timezone: 'bad' }]) { + expect(() => parseSleepOptions(query)).toThrow(); + } + expect(() => parseSleepOptions({ metrics: '' })).toThrow(); + }); +}); + +describe('authenticated sleep image API', () => { + it('renders account images, supports prefixed installs, and keeps backgrounds private', async () => { + const s = makeTestApp({ basePath: '/sync' }); + try { + const user = async (name: string) => { + const key = 'a'.repeat(32); + expect((await s.app.request('/sync/users/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: name, password: key }) })).status).toBe(201); + return { 'x-auth-user': name, 'x-auth-key': key }; + }; + const first = await user('sleep-first'), second = await user('sleep-second'); + const base = '/sync/api/v1/stats/sleep-image'; + expect((await s.app.request(base)).status).toBe(401); + expect((await s.app.request(base, { headers: { ...first, 'x-auth-key': 'b'.repeat(32) } })).status).toBe(401); + const status = async (headers: Record) => (await s.app.request(base + '/status', { headers })).json(); + expect(await status(first)).toMatchObject({ version: 1, background: false }); + const image = await s.app.request(base + '?mode=overlay&timezone=America%2FNew_York', { headers: { ...first, 'X-Sleep-Percent': '56' } }); + expect(image.status).toBe(200); + expect(image.headers.get('content-type')).toBe('image/png'); + expect(image.headers.get('cache-control')).toBe('private, no-store'); + expect(PNG.sync.read(Buffer.from(await image.arrayBuffer())).width).toBe(480); + const bytes = PNG.sync.write(new PNG({ width: 240, height: 240 })); + const put = (body: Buffer) => s.app.request(base + '/background', { method: 'PUT', headers: { ...first, 'Content-Type': 'image/png' }, body: new Uint8Array(body) }); + expect((await put(bytes)).status).toBe(200); + expect(await status(first)).toMatchObject({ background: true }); + expect(await status(second)).toMatchObject({ background: false }); + expect((await s.app.request(base + '/background', { headers: second })).status).toBe(404); + const tiny = PNG.sync.write(new PNG({ width: 1, height: 1 })); + const oversized = PNG.sync.write(new PNG({ width: 1601, height: 1 })); + expect((await put(Buffer.concat([tiny.subarray(0, 33), oversized.subarray(8)]))).status).toBe(400); + expect((await put(Buffer.from('bad'))).status).toBe(400); + const saved = await s.app.request(base + '/background', { headers: first }); + expect(PNG.sync.read(Buffer.from(await saved.arrayBuffer())).width).toBe(240); + expect((await put(Buffer.alloc(8 * 1024 * 1024 + 1))).status).toBe(413); + expect((await s.app.request(base + '?width=9999', { headers: first })).status).toBe(400); + const bmp = await s.app.request(base + '?mode=custom', { headers: first }); + expect(bmp.headers.get('content-type')).toBe('image/bmp'); + expect(Buffer.from(await bmp.arrayBuffer()).toString('ascii', 0, 2)).toBe('BM'); + expect((await s.app.request(base + '/background', { method: 'DELETE', headers: first })).status).toBe(200); + expect(await status(first)).toMatchObject({ background: false }); + await put(bytes); + expect((await s.app.request('/sync/api/v1/account/data', { method: 'DELETE', headers: first })).status).toBe(200); + expect(await status(first)).toMatchObject({ background: false }); + } finally { s.db.close(); } + }); +}); diff --git a/test/sleep-journal.test.ts b/test/sleep-journal.test.ts new file mode 100644 index 00000000..e41fdbcf --- /dev/null +++ b/test/sleep-journal.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { PNG } from 'pngjs'; +import { journalLayout } from '../src/render/sleep-journal.js'; +import { renderSleepImage } from '../src/render/sleep-image.js'; +import { parseSleepOptions } from '../src/models/sleep-image.js'; + +const stats = { year: 2026, finished: 12, target: 24, currentPercent: 42, streak: 7 }; + +describe('reading journal composition', () => { + it('overlaps the compact bookplate with a tilted annual-goal seal', () => { + const layout = journalLayout(stats, parseSleepOptions({})); + expect(layout.card.y).toBeGreaterThan(500); + expect(layout.seal!.angle).toBeCloseTo(-10 * Math.PI / 180); + expect(layout.seal!.cy - layout.seal!.radius).toBeLessThan(layout.card.y); + expect(layout.seal!.cy + layout.seal!.radius).toBeGreaterThan(layout.card.y); + expect(layout.cells.map(cell => cell.value)).toEqual(['12', '42%', '7']); + expect(layout.art).toBeUndefined(); + }); + + it('uses a framed artwork region for full-page Custom and retains paper margins', () => { + const options = parseSleepOptions({ mode: 'custom', preview: '1' }); + const layout = journalLayout(stats, options); + expect(layout.fullPage).toBe(true); + expect(layout.art!.height).toBeGreaterThan(160); + expect(layout.art!.y + layout.art!.height).toBeLessThan(layout.card.y); + const background = new PNG({ width: 240, height: 240 }); + for (let p = 0; p < background.data.length; p += 4) background.data.set([85,85,85,255], p); + const png = PNG.sync.read(renderSleepImage(stats, options, PNG.sync.write(background)).bytes); + expect([...png.data.subarray(0, 4)]).toEqual([255,255,255,255]); + const art = layout.art!; + const center = ((Math.floor(art.y + art.height / 2) * png.width) + Math.floor(art.x + art.width / 2)) * 4; + expect([...png.data.subarray(center, center + 4)]).toEqual([85,85,85,255]); + }); + + it('hides absent and disabled metrics including their seal', () => { + const layout = journalLayout({ ...stats, target: null, currentPercent: null }, parseSleepOptions({ metrics: 'goal,current,streak' })); + expect(layout.seal).toBeUndefined(); + expect(layout.cells.map(cell => cell.value)).toEqual(['7']); + const reached = journalLayout({ ...stats, finished: 30 }, parseSleepOptions({})); + expect(reached.seal!.reached).toBe(true); + expect(reached.seal!.count).toBe('30 / 24'); + }); + + it('makes the headline and goal ring reflect the remaining books', () => { + const options = parseSleepOptions({}); + const progress = journalLayout({ ...stats, finished: 4, target: 6 }, options); + expect(progress.headline).toBe('2 books to go.'); + expect(progress.seal!.fraction).toBeCloseTo(2 / 3); + expect(progress.card.height).toBeLessThan(190); + expect(journalLayout({ ...stats, finished: 5, target: 6 }, options).headline).toBe('1 book to go.'); + const exceeded = journalLayout({ ...stats, finished: 7, target: 6 }, options); + expect(exceeded.headline).toBe('You did it!'); + expect(exceeded.seal!.fraction).toBe(1); + expect(journalLayout(stats, parseSleepOptions({ metrics: 'books,current' })).headline).toBe('Between chapters'); + expect(journalLayout({ ...stats, target: null }, options).headline).toBe('Between chapters'); + }); + + it('keeps a clear band of paper between goal text and the inner ring', () => { + const sample = { ...stats, finished: 4, target: 6 }; + const options = parseSleepOptions({}); + const { seal, scale } = journalLayout(sample, options); + const png = PNG.sync.read(renderSleepImage(sample, options).bytes); + const innerEdge = seal!.radius - 9.5 * scale; + let ink = 0; + for (let y = Math.floor(seal!.cy - seal!.radius); y <= seal!.cy + seal!.radius; y++) { + for (let x = Math.floor(seal!.cx - seal!.radius); x <= seal!.cx + seal!.radius; x++) { + const distance = Math.hypot(x + .5 - seal!.cx, y + .5 - seal!.cy); + if (distance >= innerEdge - 4 * scale && distance <= innerEdge - scale) { + const p = (y * png.width + x) * 4; + if (png.data[p+3] > 0 && png.data[p] < 170) ink++; + } + } + } + expect(ink).toBe(0); + }); + + it('keeps cards and stamps in bounds at supported sizes and placements', () => { + for (const [width, height] of [[240,320], [480,800], [1200,1600], [1200,240]]) { + for (const mode of ['overlay','custom']) for (const placement of ['top','bottom']) { + const layout = journalLayout(stats, parseSleepOptions({ width: String(width), height: String(height), mode, placement })); + const card = layout.card; + expect(card.x).toBeGreaterThanOrEqual(1); + expect(card.y).toBeGreaterThanOrEqual(1); + expect(card.x + card.width).toBeLessThan(width); + expect(card.y + card.height).toBeLessThan(height); + if (layout.seal) { + const { cx, cy, radius } = layout.seal; + expect(cx - radius).toBeGreaterThanOrEqual(1); + expect(cy - radius).toBeGreaterThanOrEqual(1); + expect(cx + radius).toBeLessThan(width); + expect(cy + radius).toBeLessThan(height); + } + } + } + }); +});