The solution is located at book.ankile.com.
This responsive single-page app allows one to keep track of what one's reading, as well as give some indication as to how long books will take to complete.
- Book Management: Add, edit, and delete books from your library
- Reading Progress: Track current page and mark books as finished
- Reading Sessions: Log reading sessions with time spent and pages read
- Session Management: View, edit, and delete individual reading sessions
-
Profile Dashboard: Comprehensive reading statistics including:
- Total books read and currently reading
- Total time spent reading and pages read
- Books per year average
- Average time per finished book
- Year-by-year breakdown with longest books
-
Reading Heatmap: GitHub-style activity visualization showing:
- Daily reading activity (pages read per day)
- Customizable 3 AM day boundary (late-night sessions count as previous day)
- Year selector (view specific years or last 12 months)
- Current reading streak and longest streak tracking
- Detailed tooltips with session information
- ISBN Lookup: One button fills title, author, page count, cover, genres and a fiction/non-fiction flag from the book's ISBN
- Book Covers: Shown on the reading and finished lists, hot-linked from the source catalogue (no image storage)
- Metadata Repair:
/isbnslists books whose ISBN is missing or mistyped — the only cases the automatic enrichment cannot fix
-
Finished Books Page: Browse completed books with:
- Sort options: recently finished, title (A-Z), length, or time spent
- Filter by year
- Summary statistics for filtered view
-
Currently Reading: View all books in progress
Covers, genres and the fiction/non-fiction flag are all derived from a book's
ISBN. Four sources are consulted in a fixed order, each filling only the fields
the previous ones left empty (mergeMetadata in src/lib/utils/googleBooks.ts).
An earlier source always wins — the order encodes which source is most
trustworthy for a given field, not which one answered first.
| # | Source | Why it is at this position | Parser |
|---|---|---|---|
| 1 | Open Library | Richest subject lists and stable, hot-linkable cover URLs. Free, no key. | utils/bookMetadata.ts |
| 2 | Google Books | BISAC top-level categories ("Business & Economics", "Science") settle fiction/non-fiction where Open Library's free-form subjects cannot. Needs an API key. | utils/googleBooks.ts |
| 3 | Nasjonalbiblioteket | The only source that reliably knows Norwegian editions. MODS genres ("Romaner", "Skuespill", the explicit notfiction marker) classify them. Free, no key. |
utils/nasjonalbiblioteket.ts |
| 4 | Goodreads | Last resort, backfill only — see the caveat below. | utils/goodreads.ts |
Books store the result in coverUrl, publisher, publishedDate, subjects
and fiction (null when genuinely unknown). The fields are advisory display
data: Firestore rules give owners blanket write access to their own book
documents, so nothing may ever depend on them being accurate.
The Look up button in the add/edit book modal queries sources 1-3 live.
Sources 1 and 3 are called straight from the browser; Google Books goes through
the booksapi-lookupisbn callable, because it proxies a metered API key and must
not be reachable unauthenticated. A failure of any single source degrades to
"one fewer source" rather than discarding the others' results.
Goodreads is not in the app and should not be added: it sends no CORS headers, so a browser cannot call it at all.
One migration per source, run in numeric order and following the
MIGRATIONS.md loop. Each is gap-fill only and idempotent, and
each caches its lookups (ol-cache.json, gb-cache.json, nb-cache.json,
gr-cache.json, all gitignored) so re-runs and the prod pass cost no requests.
Cache files are runtime-validated before any migration connects or writes. If a
cache is truncated or hand-edited into an invalid shape, the script stops with
the offending field; repair that entry or delete the cache to refetch it.
node migrate-enrich-books.ts --prod --apply # 1. Open Library
node migrate-enrich-google.ts --prod --apply # 2. Google Books (needs GOOGLE_BOOKS_KEY)
node migrate-enrich-nb.ts --prod --apply # 3. Nasjonalbiblioteket
node migrate-enrich-goodreads.ts --prod --apply # 4. GoodreadsOrder matters: running a later pass first lets it claim fields an earlier, more trustworthy source should own.
The Google Books key comes from the same secret the Cloud Function reads:
export GOOGLE_BOOKS_KEY=$(gcloud secrets versions access latest \
--secret=FUNCTIONS_CONFIG_EXPORT --project book-tracker-d8f24 \
--account=lars.ankile@gmail.com \
| python3 -c "import json,sys;print(json.load(sys.stdin)['booksapi']['key'])")Goodreads retired its public API in December 2020 and its Terms of Service
disallow automated access. migrate-enrich-goodreads.ts is therefore a
deliberate, hand-run exception rather than infrastructure, and it is written to
stay one:
- it runs only over books the three open sources left empty (a few dozen requests across the whole library), never on a schedule;
- it requests
/book/isbn/<isbn>, whichrobots.txtpermits — not/search, whichrobots.txtdisallows; - it reads schema.org JSON-LD, which is machine-intended and far more stable than the surrounding markup;
- it identifies itself, waits 5s between requests, and aborts on the first 403/429 instead of retrying into a ban.
If it ever needs to run at volume, or on a schedule, or over books that are not the owner's own, that is the point to stop and buy the data instead — Bokbasen is the authoritative commercial source for Norwegian titles.
A book with no ISBN, or a mistyped one, has nothing to look up. Those are listed
at /isbns, grouped by problem, and repaired through the normal edit modal. The
Me-page "Needs an ISBN" card links there and shows the count.
Version 2.0 brings a complete modernization of the tech stack:
- Svelte 5 with runes syntax (
$state,$derived,$effect,$props) - SvelteKit 2 with file-based routing
- Vite 7 build system (replacing Rollup)
- Firebase 12 with modular SDK
- TypeScript 5
- Bootstrap 5 for styling
- Node.js 22.18+ (pinned to Node.js 22.23.1 in
.nvmrc) - npm (comes with Node.js)
- Firebase CLI when deploying (the commands below use a pinned temporary copy)
git clone <repository-url>
cd book-tracker# Install root dependencies (for the web app)
npm install
# Install Firebase Functions dependencies
npm --prefix functions installIf this is your first time setting up the project:
# Login to Firebase
npm exec --yes --package firebase-tools@15.24.0 -- firebase login
# Initialize Firebase (if not already done)
npm exec --yes --package firebase-tools@15.24.0 -- firebase initThe project is already configured to use the Firebase project book-tracker-d8f24 (see .firebaserc).
Start the development server with HMR (Hot Module Replacement):
npm run devThis will:
- Start Vite development server with HMR
- Start a local server on http://localhost:5173
- Enable automatic browser refresh on file changes
npm run buildThis creates an optimized production build in the public/ directory using SvelteKit's static adapter.
npm run previewThis serves the built app locally to test the production build before deploying.
Profile pages are rendered as complete HTML by the publicweb HTTPS Function;
they do not need to be generated as one static file per username. Firebase
Hosting sends /profiles/** and /sitemap.xml to that Function, while the
Svelte app still hydrates the profile page for interactive visitors.
Search discovery is a separate, explicit opt-in from public sharing. A profile
owner first enables Public profile, then enables Appear in search
engines. The second switch creates profileDiscovery/<username> with the
same owner uid. The server applies these states:
- public profile plus matching discovery marker:
200, indexable metadata, canonical URL, and inclusion in/sitemap.xml; - public profile without a marker:
200withnoindex,follow; - private or missing profile: indistinguishable
404HTML withnoindex,nofollow; - a stale marker whose profile is missing, private, or owned by a different uid:
excluded from the sitemap and reported by
db-audit.ts.
npm run build creates both public/index.html and
functions/assets/profile-shell.html. They deliberately contain the same
hashed JS/CSS references. Treat the Hosting release and publicweb revision as
one coupled artifact; the build and artifact tests fail if those shells drift.
To test Firebase Functions locally using emulators:
npm --prefix functions run serve
# in another shell, route the web client to all three emulators
VITE_EMULATOR=1 npm run devThe command starts Authentication, Firestore, and Functions together so an
emulated function can never fall through to production Firestore. Toggl calls
use deterministic local responses whenever FUNCTIONS_EMULATOR=true; copied
production tokens are never sent to Toggl, and start, stop, token, and queue
flows still exercise their real Firestore state transitions. The metered Google
Books proxy also returns a local miss instead of consuming its production key.
Queued Toggl work is claimed under a server-owned ten-per-hour user quota.
Successful queue rows are deleted, while terminal rows receive a 90-day TTL;
malformed events consume quota before they are rejected.
Before Firebase starts, serve stages the checked-in dummy
functions/.secret.emulator as the ignored .secret.local; Firebase resolves
bound secrets before handler guards run, so this prevents an emulator startup
from consulting Secret Manager. Never put credentials in .secret.emulator.
If a different .secret.local already exists, serve fails without changing
it; move that file aside before starting the emulators. Do not bypass serve
with a raw firebase emulators:start command.
npm run validateThis runs Svelte diagnostics, PWA tests, Functions linting and compilation, the production web build, a bundle-size budget, and production-dependency security audits for both workspaces.
The multi-tab authentication regression uses a real browser against isolated Auth and Firestore emulators. Install its browser once, then run it separately:
npx playwright install chromium
npm run test:e2e-
Make sure you're logged into Firebase:
npm exec --yes --package firebase-tools@15.24.0 -- firebase login -
Verify you're deploying to the correct project:
npm exec --yes --package firebase-tools@15.24.0 -- firebase use default # Should show: book-tracker-d8f24
-
Before the first Functions deployment from this version, migrate the existing Runtime Config to Secret Manager:
npm exec --yes --package firebase-tools@15.24.0 -- \ firebase functions:config:export \ --project book-tracker-d8f24 \ --secret FUNCTIONS_CONFIG_EXPORT \ --forceThis preserves the existing
booksapiURL and API key without printing or copying the secret into the repository.
The first strict-TypeScript release must follow the authoritative
timer-claim rollout. Do not use an
all-at-once firebase deploy: every user needs a lifecycle document before the
claim-aware web client is exposed. Before deploying, complete the
release record and rollback gates.
After the new Hosting bundle has been exposed, keep the current schema contract
and fix forward; cached old and new bundles make a blind full-stack rollback
unsafe. With the current release artifacts, the fix-forward boundary begins
when the new Functions are deployed: the queue worker can already produce an
ambiguous remote Toggl outcome that the pre-release stack cannot reconcile.
# 1. Reject uncorrelated legacy timer writes.
npm exec --yes --package firebase-tools@15.24.0 -- firebase deploy --only firestore
# 2. Deploy the claim-aware callables.
npm exec --yes --package firebase-tools@15.24.0 -- firebase deploy --only functions
# Before migrating, let old in-flight invocations drain.
# 3. Review, snapshot, apply, and prove the timer migration is idempotent.
node migrate-timer-claims.ts --prod
node db-snapshot.ts --prod
node migrate-timer-claims.ts --prod --apply
node migrate-timer-claims.ts --prod --apply
node db-audit.ts --prod
# 4. Expose the claim-aware, progress-source-compatible client and its matching profile renderer.
npm run build
npm exec --yes --package firebase-tools@15.24.0 -- firebase deploy --only functions:publicweb,hosting
# 5. Wait the documented 7-day old-bundle overlap window before backfilling progress ownership.
node migrate-reading-progress-sources.ts --prod
node db-snapshot.ts --prod
node migrate-reading-progress-sources.ts --prod --apply
node migrate-reading-progress-sources.ts --prod --apply
node db-audit.ts --prodReview every migration line, then take each snapshot immediately before that
migration's first apply. The second applies must report zero users and zero
books. The pre-Hosting audit must contain no timer-lifecycle.* findings. In
the final audit, investigate every book.progress-source-null-baseline as a
possible missing history row; all other book.progress-source-* findings must
be absent. Record each accepted nonzero baseline in the rollout log.
After this one-time rollout has completed
successfully, routine full deployments can use the standard firebase deploy
command, but must run npm run build first so Hosting and publicweb receive
the same generated shell.
There is intentionally no Hosting-only release path. Even a frontend-only build changes the generated SvelteKit shell identifier, and the profile Function embeds that shell. Deploy both targets from one build:
# Build the web app
npm run build
# Deploy the matching renderer revision and Hosting release together
npm exec --yes --package firebase-tools@15.24.0 -- firebase deploy --only functions:publicweb,hostingTest your changes on a temporary URL before deploying to production:
# Build the app
npm run build
# Publish the matching renderer revision, then pin it to the preview release.
npm exec --yes --package firebase-tools@15.24.0 -- firebase deploy --only functions:publicweb
npm exec --yes --package firebase-tools@15.24.0 -- \
firebase hosting:channel:deploy preview --expires 30dTo deploy backend-only Firebase Functions changes that do not touch
publicweb, src/app.html, client assets, or the shell sync script:
# The predeploy hooks will automatically lint and build
npm exec --yes --package firebase-tools@15.24.0 -- firebase deploy --only functionsOr use the npm script:
npm --prefix functions run deployIf publicweb or any web-shell input changed, use Deploy Hosting and Profile
Renderer instead. Deploying either half alone can return HTML whose hashed
assets do not exist in that Hosting release.
# View function logs
npm exec --yes --package firebase-tools@15.24.0 -- firebase functions:log
# Or use the npm script
npm --prefix functions run logsbook-tracker/
├── src/ # Svelte source files
│ ├── app.html # SvelteKit HTML template
│ ├── routes/ # SvelteKit file-based routes
│ │ ├── +layout.svelte # Root layout (auth guard)
│ │ ├── +page.svelte # Home page (reading books)
│ │ ├── finished/ # Finished books page
│ │ └── me/ # User profile page
│ └── lib/ # Shared components and utilities
│ ├── components/ # Svelte 5 components
│ ├── firebase/ # Firebase configuration and utilities
│ ├── interfaces/ # TypeScript interfaces
│ └── utils/ # Utility functions
├── static/ # Static assets (favicon, manifest, etc.)
├── public/ # Build output (generated by SvelteKit)
├── functions/ # Firebase Cloud Functions
│ └── src/ # Function source code
├── svelte.config.ts # SvelteKit configuration
├── vite.config.ts # Vite bundler configuration
├── package.json # Root dependencies
└── firebase.json # Firebase configuration
- Svelte 5.56.6 - Reactive UI framework with runes
- SvelteKit 2.70.1 - Application framework with routing
- Vite 7.3.6 - Fast build tool with HMR
- TypeScript 5.9.3 - Type-safe JavaScript
- Bootstrap 5.3.8 - CSS framework
- Firebase 12.16.0 - Authentication and Firestore database
- Firebase Functions 7.3.0 on Node.js 22 - Serverless cloud functions
This project uses Svelte 5's new runes syntax:
// Reactive state
let count = $state(0);
// Derived state
let doubled = $derived(count * 2);
// Side effects
$effect(() => {
console.log(`Count is ${count}`);
});
// Component props
let { title, onclick } = $props();Routes are defined by the file structure in src/routes/:
/- Home page (reading books)/finished- Finished books page/me- User profile page
The app uses Firebase v12 modular SDK:
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
import { getFirestore, collection, query, where } from 'firebase/firestore';This project requires Node.js 22.18+. If you're running a different version, consider using a Node version manager like nvm:
nvm install 22
nvm use 22Confirm node --version satisfies package.json; with nvm, run:
nvm install
nvm usenpm run dev- Start Vite development server (http://localhost:5173)npm run build- Build for production using SvelteKitnpm run preview- Preview production build locallynpm test- Run web checks, PWA tests, and Functions testsnpm run validate- Run the complete build, test, and audit suitenpm run check- Run Svelte type checkingnpm run check:watch- Run type checking in watch mode
npm run build- Compile TypeScript functionsnpm run serve- Start Firebase emulators for local testingnpm run deploy- Deploy functions to Firebasenpm run logs- View function logsnpm run lint- Lint function code
If you're upgrading from version 1.0:
- Build system changed: Rollup → Vite (much faster builds)
- Routing changed: svelte-routing → SvelteKit file-based routing
- Firebase SDK changed: v8 compat API → v12 modular API
- Component syntax changed: Svelte 3 → Svelte 5 runes
- Event handlers changed:
on:click→onclick - Bootstrap upgraded: v4 → v5
- Port changed: 3000 → 5173 (Vite default)
MIT