mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(achievements): answer in the language the client asks for
Achievement names, descriptions and category labels, the secret placeholder and the monthly challenge are text the server writes and every client shows as is, so they stayed English in a translated web page and will in the localized iOS app. The routes that return them now read Accept-Language and answer in that language, with Vary: Accept-Language. The translations live in the web's catalogs under serverText, so Crowdin, its checks and its translators handle them through the existing pipeline; the server reads that subtree, and the image copies the catalogs beside dist. English is never looked up: a request without the header, and every client that predates it, gets the bytes it got before. A test keeps serverText in en.json equal to the seed and the constants it mirrors. The web now sends its chosen language on every API request, and the Accept-Language matcher moved into @bookorbit/types so the client and the server negotiate the same way. The iOS app is a consumer the web client does not show: it will send its own interface language. App Store builds that predate that still send the device language, so on a non-English device they will start getting translated achievement names inside English screens. That was accepted; the response shape does not change. Ref bookorbit/bookorbit-ios-support#79
This commit is contained in:
@@ -71,6 +71,8 @@ ENV PORT=3000
|
||||
|
||||
COPY --from=server-builder --chown=node:node /deploy ./
|
||||
COPY --from=client-builder --chown=node:node /app/client/dist ./public
|
||||
# The server reads its own translated text (achievements, monthly challenges) from the web's catalogs.
|
||||
COPY --from=client-builder --chown=node:node /app/client/src/locales ./locales
|
||||
COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh /app/server/file-env.sh ./
|
||||
COPY --chown=node:node LICENSE NOTICE ADDITIONAL_TERMS.md ./
|
||||
COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setAccessToken,
|
||||
setOnAuthFailure,
|
||||
} from '@/lib/api'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
/** A token shaped like a real JWT, so the client can read `exp` out of it. Never verified here. */
|
||||
function signedToken(expiresInSeconds: number): string {
|
||||
@@ -155,6 +156,37 @@ describe('api wrapper', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('language', () => {
|
||||
afterEach(async () => {
|
||||
await setI18nLocale('en')
|
||||
})
|
||||
|
||||
it('asks for server text in the language picked in Settings', async () => {
|
||||
const languages: (string | null)[] = []
|
||||
globalThis.fetch = vi.fn<typeof fetch>((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
languages.push(new Headers(init?.headers).get('Accept-Language'))
|
||||
return Promise.resolve(new Response('', { status: 200 }))
|
||||
}) as never
|
||||
await setI18nLocale('nl')
|
||||
|
||||
await api('/api/v1/achievements')
|
||||
|
||||
expect(languages).toEqual(['nl'])
|
||||
})
|
||||
|
||||
it('keeps an Accept-Language the caller set itself', async () => {
|
||||
const languages: (string | null)[] = []
|
||||
globalThis.fetch = vi.fn<typeof fetch>((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
languages.push(new Headers(init?.headers).get('Accept-Language'))
|
||||
return Promise.resolve(new Response('', { status: 200 }))
|
||||
}) as never
|
||||
|
||||
await api('/api/v1/achievements', { headers: { 'Accept-Language': 'de' } })
|
||||
|
||||
expect(languages).toEqual(['de'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('expiry awareness', () => {
|
||||
it('renews an expired token before the request, so the server never sees it', async () => {
|
||||
const calls: { url: string; auth: string | null }[] = []
|
||||
|
||||
@@ -166,6 +166,9 @@ export async function fetchWithAuthProxyRecovery(input: RequestInfo | URL, init?
|
||||
async function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (_accessToken) headers.set('Authorization', `Bearer ${_accessToken}`)
|
||||
// Text the server writes (achievement names, the monthly challenge) follows the language picked
|
||||
// in Settings, not the browser's, so the page never mixes two.
|
||||
if (!headers.has('Accept-Language')) headers.set('Accept-Language', i18n.global.locale.value)
|
||||
try {
|
||||
return await fetchWithAuthProxyRecovery(input, { ...init, headers, credentials: 'include' })
|
||||
} catch (reason) {
|
||||
|
||||
@@ -15589,5 +15589,394 @@
|
||||
"tab": "tab"
|
||||
},
|
||||
"resultCount": "{count, plural, =0 {No results} one {# result} other {# results}}"
|
||||
},
|
||||
"serverText": {
|
||||
"achievements": {
|
||||
"books_finished_1": {
|
||||
"name": "Ink Initiate",
|
||||
"description": "Finish your first book"
|
||||
},
|
||||
"books_finished_2": {
|
||||
"name": "Spine Scout",
|
||||
"description": "Finish 10 books"
|
||||
},
|
||||
"books_finished_3": {
|
||||
"name": "Story Stalwart",
|
||||
"description": "Finish 35 books"
|
||||
},
|
||||
"books_finished_4": {
|
||||
"name": "Grand Archivist",
|
||||
"description": "Finish 100 books"
|
||||
},
|
||||
"pages_read_1": {
|
||||
"name": "First Leaves",
|
||||
"description": "Read 500 pages"
|
||||
},
|
||||
"pages_read_2": {
|
||||
"name": "Chapter Weaver",
|
||||
"description": "Read 5,000 pages"
|
||||
},
|
||||
"pages_read_3": {
|
||||
"name": "Tome Tactician",
|
||||
"description": "Read 25,000 pages"
|
||||
},
|
||||
"pages_read_4": {
|
||||
"name": "Grand Lexicon",
|
||||
"description": "Read 100,000 pages"
|
||||
},
|
||||
"hours_read_1": {
|
||||
"name": "Hour Initiate",
|
||||
"description": "Read for 10 hours"
|
||||
},
|
||||
"hours_read_2": {
|
||||
"name": "Timebound Reader",
|
||||
"description": "Read for 100 hours"
|
||||
},
|
||||
"hours_read_3": {
|
||||
"name": "Chronicle Veteran",
|
||||
"description": "Read for 500 hours"
|
||||
},
|
||||
"hours_read_4": {
|
||||
"name": "Timeless Scholar",
|
||||
"description": "Read for 1,000 hours"
|
||||
},
|
||||
"marathoner": {
|
||||
"name": "Marathoner",
|
||||
"description": "Complete a reading session of 90 minutes or more"
|
||||
},
|
||||
"all_nighter": {
|
||||
"name": "All-nighter",
|
||||
"description": "Read between 1am and 4am"
|
||||
},
|
||||
"early_bird": {
|
||||
"name": "Early Bird",
|
||||
"description": "Start a reading session between 5am and 7am"
|
||||
},
|
||||
"long_book_1": {
|
||||
"name": "Story Stretch",
|
||||
"description": "Finish a book with 300 or more pages"
|
||||
},
|
||||
"page_turner": {
|
||||
"name": "Page Pathfinder",
|
||||
"description": "Finish a book with 500 or more pages"
|
||||
},
|
||||
"long_book_3": {
|
||||
"name": "Epic Voyager",
|
||||
"description": "Finish a book with 750 or more pages"
|
||||
},
|
||||
"tome_tamer": {
|
||||
"name": "Tome Sovereign",
|
||||
"description": "Finish a book with 1,000 or more pages"
|
||||
},
|
||||
"sprint": {
|
||||
"name": "Sprint",
|
||||
"description": "Finish a book in a single day"
|
||||
},
|
||||
"binge_reader": {
|
||||
"name": "Binge Reader",
|
||||
"description": "Finish 3 or more books in a single week"
|
||||
},
|
||||
"library_builder_1": {
|
||||
"name": "Shelf Seed",
|
||||
"description": "Have 50 books in your library"
|
||||
},
|
||||
"library_builder_2": {
|
||||
"name": "Collection Architect",
|
||||
"description": "Have 250 books in your library"
|
||||
},
|
||||
"library_builder_3": {
|
||||
"name": "Stack Sovereign",
|
||||
"description": "Have 1,000 books in your library"
|
||||
},
|
||||
"library_builder_4": {
|
||||
"name": "Library of Alexandria",
|
||||
"description": "Have 5,000 books in your library"
|
||||
},
|
||||
"annotator_1": {
|
||||
"name": "Note Taker",
|
||||
"description": "Create 10 annotations"
|
||||
},
|
||||
"annotator_2": {
|
||||
"name": "Active Annotator",
|
||||
"description": "Create 50 annotations"
|
||||
},
|
||||
"annotator_3": {
|
||||
"name": "Prolific Annotator",
|
||||
"description": "Create 200 annotations"
|
||||
},
|
||||
"annotator_4": {
|
||||
"name": "Master Annotator",
|
||||
"description": "Create 500 annotations"
|
||||
},
|
||||
"multi_format": {
|
||||
"name": "Multi-Format",
|
||||
"description": "Own books in 3 or more different formats"
|
||||
},
|
||||
"curator": {
|
||||
"name": "Curator",
|
||||
"description": "Create 10 or more collections"
|
||||
},
|
||||
"genre_explorer_1": {
|
||||
"name": "Genre Curious",
|
||||
"description": "Finish books in 5 different genres"
|
||||
},
|
||||
"genre_explorer_2": {
|
||||
"name": "Genre Adventurer",
|
||||
"description": "Finish books in 10 different genres"
|
||||
},
|
||||
"genre_explorer_3": {
|
||||
"name": "Genre Connoisseur",
|
||||
"description": "Finish books in 15 different genres"
|
||||
},
|
||||
"genre_explorer_4": {
|
||||
"name": "Genre Master",
|
||||
"description": "Finish books in 20 different genres"
|
||||
},
|
||||
"polyglot_1": {
|
||||
"name": "Bilingual",
|
||||
"description": "Finish books in 2 different languages"
|
||||
},
|
||||
"polyglot_2": {
|
||||
"name": "Trilingual",
|
||||
"description": "Finish books in 3 different languages"
|
||||
},
|
||||
"polyglot_3": {
|
||||
"name": "Multilingual",
|
||||
"description": "Finish books in 5 different languages"
|
||||
},
|
||||
"polyglot_4": {
|
||||
"name": "Polyglot",
|
||||
"description": "Finish books in 7 different languages"
|
||||
},
|
||||
"old_soul": {
|
||||
"name": "Old Soul",
|
||||
"description": "Finish a book published before 1900"
|
||||
},
|
||||
"new_release": {
|
||||
"name": "New Release",
|
||||
"description": "Finish a book published in the current year"
|
||||
},
|
||||
"century_span": {
|
||||
"name": "Century Span",
|
||||
"description": "Finish books from 3 or more different centuries"
|
||||
},
|
||||
"author_deep_dive": {
|
||||
"name": "Author Deep Dive",
|
||||
"description": "Read 5 or more books by the same author"
|
||||
},
|
||||
"streak_1": {
|
||||
"name": "Getting Started",
|
||||
"description": "Maintain a 7-day reading streak"
|
||||
},
|
||||
"streak_2": {
|
||||
"name": "Habitual",
|
||||
"description": "Maintain a 30-day reading streak"
|
||||
},
|
||||
"streak_3": {
|
||||
"name": "Unstoppable",
|
||||
"description": "Maintain a 100-day reading streak"
|
||||
},
|
||||
"streak_4": {
|
||||
"name": "Year-Long Flame",
|
||||
"description": "Maintain a 365-day reading streak"
|
||||
},
|
||||
"year_round_reader": {
|
||||
"name": "Year-Round Reader",
|
||||
"description": "Read in all 12 months of a calendar year"
|
||||
},
|
||||
"reborn": {
|
||||
"name": "Reborn",
|
||||
"description": "Finish a book you had abandoned for over 6 months"
|
||||
},
|
||||
"comeback_kid": {
|
||||
"name": "Comeback Kid",
|
||||
"description": "Resume reading after a 30-day break"
|
||||
},
|
||||
"bookmarked": {
|
||||
"name": "Bookmarked",
|
||||
"description": "Create your first annotation or highlight"
|
||||
},
|
||||
"new_year_reader": {
|
||||
"name": "New Year Reader",
|
||||
"description": "Complete a reading session on January 1st"
|
||||
},
|
||||
"monthly_reader_2": {
|
||||
"name": "Monthly Machine",
|
||||
"description": "Finish 10 books in a single calendar month"
|
||||
},
|
||||
"slow_burn": {
|
||||
"name": "Slow Burn",
|
||||
"description": "Take over 90 days from start to finish a book"
|
||||
},
|
||||
"one_sitting": {
|
||||
"name": "One Sitting",
|
||||
"description": "Cover 90% or more of a book in a single reading session"
|
||||
},
|
||||
"decade_sampler": {
|
||||
"name": "Decade Sampler",
|
||||
"description": "Finish books published in 5 different decades"
|
||||
},
|
||||
"short_story_fan": {
|
||||
"name": "Short Story Fan",
|
||||
"description": "Finish 5 books with fewer than 100 pages"
|
||||
},
|
||||
"thick_and_thin": {
|
||||
"name": "Thick and Thin",
|
||||
"description": "Finish at least one book under 100 pages and one over 800 pages"
|
||||
},
|
||||
"trilogy_master": {
|
||||
"name": "Trilogy Master",
|
||||
"description": "Finish a complete 3-book series"
|
||||
},
|
||||
"genre_devotee": {
|
||||
"name": "Genre Devotee",
|
||||
"description": "Finish 10 books in the same genre"
|
||||
},
|
||||
"seasonal_reader": {
|
||||
"name": "Four Seasons",
|
||||
"description": "Read in all 4 seasons within a single year"
|
||||
},
|
||||
"perfect_month": {
|
||||
"name": "Perfect Month",
|
||||
"description": "Read every day of a calendar month"
|
||||
},
|
||||
"consistent_reader": {
|
||||
"name": "Consistency Is Key",
|
||||
"description": "Read at least 30 minutes every day for 14 consecutive days"
|
||||
},
|
||||
"weekend_rhythm": {
|
||||
"name": "Weekend Rhythm",
|
||||
"description": "Read on 8 consecutive weekends"
|
||||
},
|
||||
"weekend_warrior": {
|
||||
"name": "Weekend Warrior",
|
||||
"description": "Read for 5 or more hours in a single weekend"
|
||||
},
|
||||
"yearly_finisher_12": {
|
||||
"name": "Yearly Closer",
|
||||
"description": "Finish 12 books in a single calendar year"
|
||||
},
|
||||
"note_keeper": {
|
||||
"name": "Note Keeper",
|
||||
"description": "Create 25 annotations that include a note"
|
||||
},
|
||||
"deep_dive_session": {
|
||||
"name": "Deep Dive",
|
||||
"description": "Create 10 annotations in a single day"
|
||||
},
|
||||
"first_series": {
|
||||
"name": "Series Begun",
|
||||
"description": "Finish your first book that belongs to a named series"
|
||||
},
|
||||
"category_sweeper": {
|
||||
"name": "Category Sweeper",
|
||||
"description": "Earn at least one achievement in all 4 categories"
|
||||
},
|
||||
"power_hour": {
|
||||
"name": "Power Hour",
|
||||
"description": "Read 75 or more pages in a single reading session"
|
||||
},
|
||||
"speed_reader": {
|
||||
"name": "Speed Reader",
|
||||
"description": "Read 100 or more pages in a single day"
|
||||
},
|
||||
"old_friend": {
|
||||
"name": "Old Friend",
|
||||
"description": "Finish a book you were re-reading"
|
||||
},
|
||||
"dnf_self_aware": {
|
||||
"name": "It's Not You, It's Me",
|
||||
"description": "Mark a book as abandoned"
|
||||
},
|
||||
"first_verdict": {
|
||||
"name": "First Verdict",
|
||||
"description": "Rate your first book"
|
||||
},
|
||||
"critic_1": {
|
||||
"name": "Fledgling Critic",
|
||||
"description": "Rate 10 books"
|
||||
},
|
||||
"critic_2": {
|
||||
"name": "Seasoned Critic",
|
||||
"description": "Rate 50 books"
|
||||
},
|
||||
"critic_3": {
|
||||
"name": "Esteemed Critic",
|
||||
"description": "Rate 200 books"
|
||||
},
|
||||
"critic_4": {
|
||||
"name": "Definitive Critic",
|
||||
"description": "Rate 500 books"
|
||||
},
|
||||
"standing_ovation": {
|
||||
"name": "Standing Ovation",
|
||||
"description": "Give a book a 5-star rating"
|
||||
},
|
||||
"across_the_board": {
|
||||
"name": "Across the Board",
|
||||
"description": "Use all five star ratings at least once"
|
||||
},
|
||||
"tough_crowd": {
|
||||
"name": "Tough Crowd",
|
||||
"description": "Give 10 books a rating of 2 stars or lower"
|
||||
},
|
||||
"wordsmith": {
|
||||
"name": "Wordsmith",
|
||||
"description": "Write a note longer than 280 characters"
|
||||
},
|
||||
"box_of_crayons": {
|
||||
"name": "Box of Crayons",
|
||||
"description": "Use all 5 highlight colors"
|
||||
},
|
||||
"synced_up": {
|
||||
"name": "Synced Up",
|
||||
"description": "Sync reading progress from a Kobo or KOReader device for the first time"
|
||||
},
|
||||
"full_orbit": {
|
||||
"name": "Full Orbit",
|
||||
"description": "Log reading from three different reading sources"
|
||||
},
|
||||
"two_worlds": {
|
||||
"name": "Two Worlds",
|
||||
"description": "Read the same book from two or more different sources"
|
||||
}
|
||||
},
|
||||
"achievementCategories": {
|
||||
"reading": "Reading",
|
||||
"library": "Library",
|
||||
"exploration": "Exploration",
|
||||
"dedication": "Dedication",
|
||||
"devices": "Devices"
|
||||
},
|
||||
"achievementSecret": {
|
||||
"name": "Secret Achievement",
|
||||
"description": "Keep reading to reveal this achievement."
|
||||
},
|
||||
"monthlyChallenge": {
|
||||
"short-read": {
|
||||
"title": "Quick Read",
|
||||
"description": "Finish a book under 200 pages this month"
|
||||
},
|
||||
"genre-explorer": {
|
||||
"title": "Genre Explorer",
|
||||
"description": "Read a book in a genre new to you this month"
|
||||
},
|
||||
"finish-oldest": {
|
||||
"title": "Clear the Backlog",
|
||||
"description": "Finish your oldest in-progress book"
|
||||
},
|
||||
"streak-builder": {
|
||||
"title": "Streak Builder",
|
||||
"description": "Read for 5 consecutive days this month"
|
||||
},
|
||||
"new-author": {
|
||||
"title": "New Voice",
|
||||
"description": "Read a book by an author new to you this month"
|
||||
},
|
||||
"page-milestone": {
|
||||
"title": "Page Turner",
|
||||
"description": "Read 500 pages this month"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, isSupportedLocale, type Locale } from '@bookorbit/types'
|
||||
import { DEFAULT_LOCALE, isSupportedLocale, matchSupportedLocale, type Locale } from '@bookorbit/types'
|
||||
import { storage } from '@/services/storage'
|
||||
import { activateI18nLocale, loadLocaleMessages } from '@/i18n'
|
||||
|
||||
const STORAGE_KEY = 'locale'
|
||||
|
||||
function canonicalizeLocale(value: string): string | null {
|
||||
try {
|
||||
return Intl.getCanonicalLocales(value)[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function matchSupportedLocale(candidates: readonly string[]): Locale | null {
|
||||
const supportedByCanonical = new Map<string, Locale>()
|
||||
const supportedByLanguageAndScript = new Map<string, Locale>()
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const canonical = canonicalizeLocale(locale)
|
||||
if (!canonical) continue
|
||||
supportedByCanonical.set(canonical.toLowerCase(), locale)
|
||||
const maximized = new Intl.Locale(canonical).maximize()
|
||||
if (maximized.script) {
|
||||
supportedByLanguageAndScript.set(`${maximized.language.toLowerCase()}-${maximized.script.toLowerCase()}`, locale)
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const canonical = canonicalizeLocale(candidate)
|
||||
if (!canonical) continue
|
||||
const exact = supportedByCanonical.get(canonical.toLowerCase())
|
||||
if (exact) return exact
|
||||
const maximized = new Intl.Locale(canonical).maximize()
|
||||
const language = maximized.language.toLowerCase()
|
||||
if (maximized.script) {
|
||||
const scriptMatch = supportedByLanguageAndScript.get(`${language}-${maximized.script.toLowerCase()}`)
|
||||
if (scriptMatch) return scriptMatch
|
||||
}
|
||||
const baseLocale = supportedByCanonical.get(language)
|
||||
if (baseLocale) return baseLocale
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
// The server negotiates `Accept-Language` with the same matcher, so it lives with the locale list.
|
||||
export { matchSupportedLocale }
|
||||
|
||||
export function detectInitialLocale(): Locale {
|
||||
const stored = storage.get<string>(STORAGE_KEY, '')
|
||||
|
||||
@@ -93,3 +93,78 @@ export interface LocalePreferences {
|
||||
export function isSupportedLocale(value: unknown): value is Locale {
|
||||
return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function canonicalizeLocale(value: string): string | null {
|
||||
try {
|
||||
return Intl.getCanonicalLocales(value)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The supported locale that best serves a ranked list of language tags, or null when none does.
|
||||
* An exact tag wins, then the same language and script (`zh-TW` and `zh-Hant-HK` both reach
|
||||
* `zh-Hant`), then the bare language (`pt-BR` reaches `pt`).
|
||||
*/
|
||||
export function matchSupportedLocale(candidates: readonly string[]): Locale | null {
|
||||
const supportedByCanonical = new Map<string, Locale>();
|
||||
const supportedByLanguageAndScript = new Map<string, Locale>();
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const canonical = canonicalizeLocale(locale);
|
||||
if (!canonical) continue;
|
||||
supportedByCanonical.set(canonical.toLowerCase(), locale);
|
||||
const maximized = new Intl.Locale(canonical).maximize();
|
||||
if (maximized.script) {
|
||||
supportedByLanguageAndScript.set(`${maximized.language.toLowerCase()}-${maximized.script.toLowerCase()}`, locale);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const canonical = canonicalizeLocale(candidate);
|
||||
if (!canonical) continue;
|
||||
const exact = supportedByCanonical.get(canonical.toLowerCase());
|
||||
if (exact) return exact;
|
||||
const maximized = new Intl.Locale(canonical).maximize();
|
||||
const language = maximized.language.toLowerCase();
|
||||
if (maximized.script) {
|
||||
const scriptMatch = supportedByLanguageAndScript.get(`${language}-${maximized.script.toLowerCase()}`);
|
||||
if (scriptMatch) return scriptMatch;
|
||||
}
|
||||
const baseLocale = supportedByCanonical.get(language);
|
||||
if (baseLocale) return baseLocale;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language tags of an `Accept-Language` header, most preferred first. The `*` wildcard and
|
||||
* tags weighted `q=0` are dropped; a weight that is not a number counts as 1, and equal weights
|
||||
* keep the order they were sent in.
|
||||
*/
|
||||
export function parseAcceptLanguage(header: string | null | undefined): string[] {
|
||||
if (!header) return [];
|
||||
const entries: { tag: string; quality: number; index: number }[] = [];
|
||||
header.split(",").forEach((part, index) => {
|
||||
const [rawTag, ...parameters] = part.split(";");
|
||||
const tag = rawTag?.trim();
|
||||
if (!tag || tag === "*") return;
|
||||
let quality = 1;
|
||||
for (const parameter of parameters) {
|
||||
const [name, value] = parameter.split("=").map((piece) => piece.trim());
|
||||
if (name?.toLowerCase() !== "q") continue;
|
||||
const parsed = Number(value);
|
||||
quality = Number.isFinite(parsed) ? parsed : 1;
|
||||
}
|
||||
if (quality <= 0) return;
|
||||
entries.push({ tag, quality, index });
|
||||
});
|
||||
entries.sort((left, right) => right.quality - left.quality || left.index - right.index);
|
||||
return entries.map((entry) => entry.tag);
|
||||
}
|
||||
|
||||
/** The supported locale an `Accept-Language` header asks for, or null when it names none. */
|
||||
export function localeFromAcceptLanguage(header: string | null | undefined): Locale | null {
|
||||
return matchSupportedLocale(parseAcceptLanguage(header));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { DEFAULT_LOCALE, localeFromAcceptLanguage, type Locale } from '@bookorbit/types';
|
||||
|
||||
/**
|
||||
* The supported language the request's `Accept-Language` asks for, or English when it names none.
|
||||
* Pair it with `@Header('Vary', 'Accept-Language')` on the route, so a shared cache never hands one
|
||||
* language's response to another.
|
||||
*/
|
||||
export const RequestLocale = createParamDecorator((_: unknown, ctx: ExecutionContext): Locale => {
|
||||
const request = ctx.switchToHttp().getRequest<{ headers: Record<string, string | string[] | undefined> }>();
|
||||
const header = request.headers['accept-language'];
|
||||
return localeFromAcceptLanguage(Array.isArray(header) ? header.join(',') : header) ?? DEFAULT_LOCALE;
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { ACHIEVEMENT_CATEGORY_LABELS, localeFromAcceptLanguage, parseAcceptLanguage, type ChallengeType } from '@bookorbit/types';
|
||||
|
||||
import { ACHIEVEMENT_SEED } from '../../modules/achievement/seed/achievement-seed';
|
||||
import { computeChallengeResult } from '../../modules/dashboard/dashboard-widget.calculations';
|
||||
import { englishCatalog, primeServerTextCatalog, resetServerTextCache, serverText } from './server-text';
|
||||
|
||||
const CHALLENGE_TYPES: ChallengeType[] = ['short-read', 'genre-explorer', 'finish-oldest', 'streak-builder', 'new-author', 'page-milestone'];
|
||||
|
||||
afterEach(() => resetServerTextCache());
|
||||
|
||||
describe('serverText', () => {
|
||||
it('returns the English it is given and never looks English up', () => {
|
||||
primeServerTextCatalog('en', { achievements: { books_finished_1: { name: 'Something else' } } });
|
||||
|
||||
expect(serverText('en', 'achievements.books_finished_1.name', 'Ink Initiate')).toBe('Ink Initiate');
|
||||
});
|
||||
|
||||
it('returns a translation when the language has one', () => {
|
||||
primeServerTextCatalog('de', { achievements: { books_finished_1: { name: 'Tinten-Neuling' } } });
|
||||
|
||||
expect(serverText('de', 'achievements.books_finished_1.name', 'Ink Initiate')).toBe('Tinten-Neuling');
|
||||
});
|
||||
|
||||
it('falls back to English for a missing, empty or non-string translation', () => {
|
||||
primeServerTextCatalog('de', { achievements: { books_finished_1: { name: ' ' }, books_finished_2: 'not a node' } });
|
||||
|
||||
expect(serverText('de', 'achievements.books_finished_1.name', 'Ink Initiate')).toBe('Ink Initiate');
|
||||
expect(serverText('de', 'achievements.books_finished_2.name', 'Spine Scout')).toBe('Spine Scout');
|
||||
expect(serverText('de', 'achievements.books_finished_3.name', 'Story Stalwart')).toBe('Story Stalwart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the English source catalog', () => {
|
||||
// Crowdin translates en.json, but English responses are built from the seed and the constants.
|
||||
// If the two drift, translators would be working from text no one sees.
|
||||
it('mirrors every seeded achievement', () => {
|
||||
const achievements = englishCatalog().achievements as Record<string, { name: string; description: string }>;
|
||||
|
||||
expect(Object.keys(achievements).sort()).toEqual(ACHIEVEMENT_SEED.map((achievement) => achievement.key).sort());
|
||||
for (const achievement of ACHIEVEMENT_SEED) {
|
||||
expect(achievements[achievement.key]).toEqual({ name: achievement.name, description: achievement.description });
|
||||
}
|
||||
});
|
||||
|
||||
it('mirrors the category labels, the secret placeholder and the monthly challenges', () => {
|
||||
const catalog = englishCatalog();
|
||||
|
||||
expect(catalog.achievementCategories).toEqual(ACHIEVEMENT_CATEGORY_LABELS);
|
||||
expect(catalog.achievementSecret).toEqual({ name: 'Secret Achievement', description: 'Keep reading to reveal this achievement.' });
|
||||
const challenges = catalog.monthlyChallenge as Record<string, { title: string; description: string }>;
|
||||
expect(Object.keys(challenges).sort()).toEqual([...CHALLENGE_TYPES].sort());
|
||||
for (const type of CHALLENGE_TYPES) {
|
||||
const result = computeChallengeResult(
|
||||
type,
|
||||
{
|
||||
shortBooksCompleted: 0,
|
||||
newGenresRead: 0,
|
||||
oldestInProgressFinished: false,
|
||||
maxStreakThisMonth: 0,
|
||||
newAuthorsRead: 0,
|
||||
pagesReadThisMonth: 0,
|
||||
},
|
||||
2026,
|
||||
1,
|
||||
);
|
||||
expect(challenges[type]).toEqual({ title: result.title, description: result.description });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accept-Language', () => {
|
||||
it('orders tags by weight and keeps the sent order for equal weights', () => {
|
||||
expect(parseAcceptLanguage('fr;q=0.5, de-DE, de;q=0.9, nl')).toEqual(['de-DE', 'nl', 'de', 'fr']);
|
||||
});
|
||||
|
||||
it('drops the wildcard and zero weights, and reads a broken weight as 1', () => {
|
||||
expect(parseAcceptLanguage('*, fr;q=0, it;q=abc')).toEqual(['it']);
|
||||
expect(parseAcceptLanguage('')).toEqual([]);
|
||||
expect(parseAcceptLanguage(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves what the iOS app and browsers send to a supported locale', () => {
|
||||
expect(localeFromAcceptLanguage('de-DE,de;q=0.9')).toBe('de');
|
||||
expect(localeFromAcceptLanguage('zh-Hans')).toBe('zh');
|
||||
expect(localeFromAcceptLanguage('zh-Hant')).toBe('zh-Hant');
|
||||
expect(localeFromAcceptLanguage('zh-TW')).toBe('zh-Hant');
|
||||
expect(localeFromAcceptLanguage('pt-BR')).toBe('pt');
|
||||
expect(localeFromAcceptLanguage('ca-ES, es;q=0.8')).toBe('es');
|
||||
expect(localeFromAcceptLanguage('en-US,en;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('names no locale for an unsupported or malformed header', () => {
|
||||
expect(localeFromAcceptLanguage('xx-YY')).toBeNull();
|
||||
expect(localeFromAcceptLanguage('*')).toBeNull();
|
||||
expect(localeFromAcceptLanguage('not a tag!!')).toBeNull();
|
||||
expect(localeFromAcceptLanguage(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { DEFAULT_LOCALE, type Locale } from '@bookorbit/types';
|
||||
|
||||
/**
|
||||
* Text the server writes itself and sends to clients that show it as is: achievement names and
|
||||
* descriptions, category labels, the monthly challenge. Clients ask for a language with
|
||||
* `Accept-Language`; the web sends the one picked in its settings and the iOS app the one its own
|
||||
* interface resolved to.
|
||||
*
|
||||
* The translations live in the web's catalogs, under `serverText`, so they reach Crowdin, its
|
||||
* validation and its translators through the one pipeline the web already has.
|
||||
*
|
||||
* English is never looked up. It comes from the same place it always did (the seeded rows, the
|
||||
* constants), so a request without the header, or from a client that predates it, gets exactly the
|
||||
* bytes it got before. `serverText` in `en.json` mirrors that text only so Crowdin has a source to
|
||||
* translate, and a test keeps the two equal. A translation that is missing or empty falls back to
|
||||
* English.
|
||||
*/
|
||||
type Catalog = { [key: string]: string | Catalog };
|
||||
|
||||
const NAMESPACE = 'serverText';
|
||||
|
||||
const catalogs = new Map<Locale, Catalog>();
|
||||
|
||||
/**
|
||||
* Where the web's catalogs are. In the repository they sit beside the server, whether this runs
|
||||
* from `src` or `dist`; the image copies them next to `dist`. Found on first use rather than at
|
||||
* import, so a module that mocks `fs` can still import anything that imports this.
|
||||
*/
|
||||
function localesDirectory(): string | undefined {
|
||||
const candidates = [join(__dirname, '..', '..', '..', '..', 'client', 'src', 'locales'), join(__dirname, '..', '..', '..', 'locales')];
|
||||
return candidates.find((directory) => existsSync(join(directory, `${DEFAULT_LOCALE}.json`)));
|
||||
}
|
||||
|
||||
function catalog(locale: Locale): Catalog {
|
||||
const cached = catalogs.get(locale);
|
||||
if (cached) return cached;
|
||||
const directory = localesDirectory();
|
||||
const file = directory ? join(directory, `${locale}.json`) : null;
|
||||
let loaded: Catalog = {};
|
||||
if (file && existsSync(file)) {
|
||||
try {
|
||||
const messages = JSON.parse(readFileSync(file, 'utf8')) as Catalog;
|
||||
const namespace = messages[NAMESPACE];
|
||||
loaded = namespace && typeof namespace === 'object' ? namespace : {};
|
||||
} catch {
|
||||
loaded = {};
|
||||
}
|
||||
}
|
||||
catalogs.set(locale, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function lookup(tree: Catalog, key: string): string | null {
|
||||
let node: string | Catalog | undefined = tree;
|
||||
for (const part of key.split('.')) {
|
||||
if (!node || typeof node === 'string') return null;
|
||||
node = node[part];
|
||||
}
|
||||
return typeof node === 'string' && node.trim().length > 0 ? node : null;
|
||||
}
|
||||
|
||||
/** `english` in English or when `locale` has no translation for `key`, otherwise the translation. */
|
||||
export function serverText(locale: Locale, key: string, english: string): string {
|
||||
if (locale === DEFAULT_LOCALE) return english;
|
||||
return lookup(catalog(locale), key) ?? english;
|
||||
}
|
||||
|
||||
/** The English source of `serverText`, for the test that keeps it equal to the text it mirrors. */
|
||||
export function englishCatalog(): Catalog {
|
||||
return catalog(DEFAULT_LOCALE);
|
||||
}
|
||||
|
||||
/** Stands in a `serverText` tree for `locale` until `resetServerTextCache`, for tests. */
|
||||
export function primeServerTextCatalog(locale: Locale, tree: Catalog): void {
|
||||
catalogs.set(locale, tree);
|
||||
}
|
||||
|
||||
/** Forgets loaded and primed catalogs. */
|
||||
export function resetServerTextCache(): void {
|
||||
catalogs.clear();
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common';
|
||||
import type { AchievementCatalogueResponse, AchievementCelebrationClaim, AchievementHintsResponse } from '@bookorbit/types';
|
||||
import { Controller, Get, Header, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common';
|
||||
import type { AchievementCatalogueResponse, AchievementCelebrationClaim, AchievementHintsResponse, Locale } from '@bookorbit/types';
|
||||
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequestLocale } from '../../common/decorators/request-locale.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { AchievementService } from './achievement.service';
|
||||
import { AchievementBackfillService, type BackfillResult } from './achievement-backfill.service';
|
||||
@@ -18,8 +19,9 @@ export class AchievementController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getCatalogue(@CurrentUser() user: RequestUser): Promise<AchievementCatalogueResponse> {
|
||||
return this.service.getCatalogue(user);
|
||||
@Header('Vary', 'Accept-Language')
|
||||
async getCatalogue(@CurrentUser() user: RequestUser, @RequestLocale() locale: Locale): Promise<AchievementCatalogueResponse> {
|
||||
return this.service.getCatalogue(user, locale);
|
||||
}
|
||||
|
||||
@Get('hints')
|
||||
@@ -28,8 +30,9 @@ export class AchievementController {
|
||||
}
|
||||
|
||||
@Post('celebrations/claim')
|
||||
async claimCelebration(@CurrentUser() user: RequestUser): Promise<AchievementCelebrationClaim | null> {
|
||||
return this.service.claimCelebration(user);
|
||||
@Header('Vary', 'Accept-Language')
|
||||
async claimCelebration(@CurrentUser() user: RequestUser, @RequestLocale() locale: Locale): Promise<AchievementCelebrationClaim | null> {
|
||||
return this.service.claimCelebration(user, locale);
|
||||
}
|
||||
|
||||
@Post('celebrations/:claimId/acknowledge')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { primeServerTextCatalog, resetServerTextCache } from '../../common/i18n/server-text';
|
||||
import { AchievementService } from './achievement.service';
|
||||
import { AchievementEventsService, ACHIEVEMENT_EVENT_BACKFILL, ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED } from './achievement-events.service';
|
||||
|
||||
@@ -207,6 +208,58 @@ describe('AchievementService', () => {
|
||||
expect(item?.threshold).toBeNull();
|
||||
expect(item?.context).toBeNull();
|
||||
});
|
||||
|
||||
describe('in another language', () => {
|
||||
const visible = {
|
||||
key: 'books_finished_1',
|
||||
groupKey: 'books_finished',
|
||||
tier: 1,
|
||||
category: 'reading',
|
||||
name: 'Ink Initiate',
|
||||
description: 'Finish your first book',
|
||||
iconName: 'book-open',
|
||||
rarity: 'common',
|
||||
threshold: 1,
|
||||
hidden: false,
|
||||
sortOrder: 1,
|
||||
};
|
||||
const secret = { ...visible, key: 'secret_1', groupKey: null, tier: null, name: 'Secret', description: 'Hidden thing', hidden: true };
|
||||
|
||||
beforeEach(() => {
|
||||
primeServerTextCatalog('de', {
|
||||
achievements: { books_finished_1: { name: 'Tinten-Neuling', description: 'Beende dein erstes Buch' } },
|
||||
achievementCategories: { reading: 'Lesen' },
|
||||
achievementSecret: { name: 'Geheimer Erfolg', description: 'Lies weiter, um ihn zu enthüllen.' },
|
||||
});
|
||||
repo.findUserAchievements.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => resetServerTextCache());
|
||||
|
||||
it('translates names, descriptions, category labels and the secret placeholder', async () => {
|
||||
repo.findAllAchievements.mockResolvedValue([visible, secret]);
|
||||
|
||||
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never, 'de');
|
||||
const reading = result.categories.find((c) => c.key === 'reading');
|
||||
|
||||
expect(reading?.label).toBe('Lesen');
|
||||
expect(reading?.achievements.map((item) => [item.name, item.description])).toEqual([
|
||||
['Tinten-Neuling', 'Beende dein erstes Buch'],
|
||||
['Geheimer Erfolg', 'Lies weiter, um ihn zu enthüllen.'],
|
||||
]);
|
||||
expect(result.categories.find((c) => c.key === 'library')?.label).toBe('Library');
|
||||
});
|
||||
|
||||
it('keeps the stored English when a request names no language', async () => {
|
||||
repo.findAllAchievements.mockResolvedValue([visible]);
|
||||
|
||||
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
|
||||
const reading = result.categories.find((c) => c.key === 'reading');
|
||||
|
||||
expect(reading?.label).toBe('Reading');
|
||||
expect(reading?.achievements[0]?.name).toBe('Ink Initiate');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('celebrations', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { NotificationType, ACHIEVEMENT_CATEGORY_LABELS } from '@bookorbit/types';
|
||||
import { DEFAULT_LOCALE, NotificationType, ACHIEVEMENT_CATEGORY_LABELS, type Locale } from '@bookorbit/types';
|
||||
import type {
|
||||
AchievementCatalogueResponse,
|
||||
AchievementCategoryGroup,
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AchievementCategory,
|
||||
AchievementCelebrationClaim,
|
||||
} from '@bookorbit/types';
|
||||
import { serverText } from '../../common/i18n/server-text';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { getYearInTimeZone, resolveTimeZone, toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
@@ -61,7 +62,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.seedPromise;
|
||||
}
|
||||
|
||||
async getCatalogue(user: RequestUser): Promise<AchievementCatalogueResponse> {
|
||||
async getCatalogue(user: RequestUser, locale: Locale = DEFAULT_LOCALE): Promise<AchievementCatalogueResponse> {
|
||||
const userId = user.id;
|
||||
const [allAchievements, userAchievements] = await Promise.all([this.repo.findAllAchievements(), this.repo.findUserAchievements(userId)]);
|
||||
|
||||
@@ -87,7 +88,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
|
||||
const items = grouped.get(category);
|
||||
if (!items) continue;
|
||||
|
||||
items.push(this.toItem(achievement, earned, progressMap.get(achievement.key) ?? null, accessibleBookIds));
|
||||
items.push(this.toItem(achievement, earned, progressMap.get(achievement.key) ?? null, accessibleBookIds, locale));
|
||||
}
|
||||
|
||||
let totalEarned = 0;
|
||||
@@ -102,7 +103,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
categories.push({
|
||||
key: cat,
|
||||
label: ACHIEVEMENT_CATEGORY_LABELS[cat],
|
||||
label: serverText(locale, `achievementCategories.${cat}`, ACHIEVEMENT_CATEGORY_LABELS[cat]),
|
||||
earnedCount,
|
||||
totalCount: items.length,
|
||||
achievements: items,
|
||||
@@ -112,7 +113,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
|
||||
return { categories, totalEarned, totalAvailable };
|
||||
}
|
||||
|
||||
async claimCelebration(user: RequestUser): Promise<AchievementCelebrationClaim | null> {
|
||||
async claimCelebration(user: RequestUser, locale: Locale = DEFAULT_LOCALE): Promise<AchievementCelebrationClaim | null> {
|
||||
if (!(await this.userService.isAchievementEnabled(user.id))) return null;
|
||||
|
||||
const claimedAt = new Date();
|
||||
@@ -125,7 +126,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
|
||||
return {
|
||||
claimId,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
achievement: this.toItem(claimed.achievement, claimed.award, null, accessibleBookIds),
|
||||
achievement: this.toItem(claimed.achievement, claimed.award, null, accessibleBookIds, locale),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,6 +203,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
|
||||
earned: UserAchievementRow | undefined,
|
||||
currentProgress: number | null,
|
||||
accessibleBookIds: Set<number>,
|
||||
locale: Locale,
|
||||
): AchievementItem {
|
||||
const lockedSecret = achievement.hidden && !earned;
|
||||
const context = earned?.contextJson && typeof earned.contextJson === 'object' ? (earned.contextJson as Record<string, unknown>) : null;
|
||||
@@ -214,8 +216,12 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
|
||||
groupKey: lockedSecret ? null : achievement.groupKey,
|
||||
tier: lockedSecret ? null : achievement.tier,
|
||||
category: achievement.category as AchievementCategory,
|
||||
name: lockedSecret ? 'Secret Achievement' : achievement.name,
|
||||
description: lockedSecret ? 'Keep reading to reveal this achievement.' : achievement.description,
|
||||
name: lockedSecret
|
||||
? serverText(locale, 'achievementSecret.name', 'Secret Achievement')
|
||||
: serverText(locale, `achievements.${achievement.key}.name`, achievement.name),
|
||||
description: lockedSecret
|
||||
? serverText(locale, 'achievementSecret.description', 'Keep reading to reveal this achievement.')
|
||||
: serverText(locale, `achievements.${achievement.key}.description`, achievement.description),
|
||||
iconName: lockedSecret ? 'lock' : achievement.iconName,
|
||||
rarity: lockedSecret ? 'common' : (achievement.rarity as AchievementItem['rarity']),
|
||||
threshold: lockedSecret ? null : achievement.threshold,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CurrentlyReadingWidgetData, LibraryOverviewWidgetData, NeglectedGemsWidgetData, ReadingStreakWidgetData } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { primeServerTextCatalog, resetServerTextCache } from '../../common/i18n/server-text';
|
||||
import { pickAnnotationIndex } from './dashboard-widget.calculations';
|
||||
import { DashboardWidgetService } from './dashboard-widget.service';
|
||||
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
|
||||
@@ -335,6 +336,41 @@ describe('DashboardWidgetService', () => {
|
||||
expect(typeof result.completed).toBe('boolean');
|
||||
});
|
||||
|
||||
it('translates the title and description on the way out and leaves the cached English alone', async () => {
|
||||
const { service, widgetRepo, libraryService } = makeService();
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
|
||||
widgetRepo.getChallengePatternData.mockResolvedValue({
|
||||
avgPageCount: 100,
|
||||
uniqueGenresLast6Months: 10,
|
||||
staleInProgressCount: 0,
|
||||
currentStreak: 10,
|
||||
maxStreakThisMonth: 10,
|
||||
topAuthorBookCount: 1,
|
||||
totalBooksRead: 20,
|
||||
pagesThisMonth: 200,
|
||||
shortBooksCompleted: 0,
|
||||
newGenresRead: 0,
|
||||
oldestInProgressFinished: false,
|
||||
newAuthorsRead: 0,
|
||||
pagesReadThisMonth: 200,
|
||||
});
|
||||
primeServerTextCatalog('de', {
|
||||
monthlyChallenge: { 'page-milestone': { title: 'Seitenwender', description: 'Lies diesen Monat 500 Seiten' } },
|
||||
});
|
||||
|
||||
try {
|
||||
const german = await service.getMonthlyChallenge(makeUser(), 'de');
|
||||
const english = await service.getMonthlyChallenge(makeUser());
|
||||
|
||||
expect(german.challengeType).toBe('page-milestone');
|
||||
expect([german.title, german.description]).toEqual(['Seitenwender', 'Lies diesen Monat 500 Seiten']);
|
||||
expect([english.title, english.description]).toEqual(['Page Turner', 'Read 500 pages this month']);
|
||||
expect(widgetRepo.getChallengePatternData).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
resetServerTextCache();
|
||||
}
|
||||
});
|
||||
|
||||
it('marks finish-oldest challenge complete when oldestInProgressFinished is true', async () => {
|
||||
const { service, widgetRepo, libraryService } = makeService();
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { DEFAULT_LOCALE, type Locale } from '@bookorbit/types';
|
||||
|
||||
import type {
|
||||
CurrentlyReadingWidgetData,
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
YearProjectionWidgetData,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { serverText } from '../../common/i18n/server-text';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { StatsCache } from '../../common/cache/stats-cache';
|
||||
import { mapWithConcurrency } from '../../common/utils/batch.utils';
|
||||
@@ -261,44 +263,55 @@ export class DashboardWidgetService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async getMonthlyChallenge(user: RequestUser): Promise<MonthlyChallengeWidgetData> {
|
||||
async getMonthlyChallenge(user: RequestUser, locale: Locale = DEFAULT_LOCALE): Promise<MonthlyChallengeWidgetData> {
|
||||
const accessibleLibraryIds = await this.getLibraryIds(user);
|
||||
const clock = this.readerClock(user);
|
||||
this.readingAttemptEvents.flushPendingChanges(user.id);
|
||||
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('monthly-challenge', clock), async () => {
|
||||
const contentFilters = this.getContentFilters(user);
|
||||
const { year, month } = clock;
|
||||
const monthStartDay = `${clock.today.slice(0, 7)}-01`;
|
||||
const monthStart = toTimeZoneStartOfDay(monthStartDay, clock.timeZone);
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setUTCMonth(sixMonthsAgo.getUTCMonth() - 6);
|
||||
// The cache holds the English result and the language is applied on the way out, so one entry
|
||||
// serves every language a user's clients ask in.
|
||||
const challenge = await this.staleCache.get(
|
||||
this.cacheOwnerKey(user, accessibleLibraryIds),
|
||||
this.calendarCacheKey('monthly-challenge', clock),
|
||||
async () => {
|
||||
const contentFilters = this.getContentFilters(user);
|
||||
const { year, month } = clock;
|
||||
const monthStartDay = `${clock.today.slice(0, 7)}-01`;
|
||||
const monthStart = toTimeZoneStartOfDay(monthStartDay, clock.timeZone);
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setUTCMonth(sixMonthsAgo.getUTCMonth() - 6);
|
||||
|
||||
const data = await this.widgetRepo.getChallengePatternData(
|
||||
user.id,
|
||||
accessibleLibraryIds,
|
||||
{ start: monthStart, startDay: monthStartDay, today: clock.today },
|
||||
sixMonthsAgo,
|
||||
contentFilters,
|
||||
);
|
||||
const eligible = findEligibleChallenges(data);
|
||||
const challengeType = selectChallenge(eligible, user.id, year, month);
|
||||
const data = await this.widgetRepo.getChallengePatternData(
|
||||
user.id,
|
||||
accessibleLibraryIds,
|
||||
{ start: monthStart, startDay: monthStartDay, today: clock.today },
|
||||
sixMonthsAgo,
|
||||
contentFilters,
|
||||
);
|
||||
const eligible = findEligibleChallenges(data);
|
||||
const challengeType = selectChallenge(eligible, user.id, year, month);
|
||||
|
||||
const result = computeChallengeResult(
|
||||
challengeType,
|
||||
{
|
||||
shortBooksCompleted: data.shortBooksCompleted,
|
||||
newGenresRead: data.newGenresRead,
|
||||
oldestInProgressFinished: data.oldestInProgressFinished,
|
||||
maxStreakThisMonth: data.maxStreakThisMonth,
|
||||
newAuthorsRead: data.newAuthorsRead,
|
||||
pagesReadThisMonth: data.pagesReadThisMonth,
|
||||
},
|
||||
year,
|
||||
month,
|
||||
);
|
||||
const result = computeChallengeResult(
|
||||
challengeType,
|
||||
{
|
||||
shortBooksCompleted: data.shortBooksCompleted,
|
||||
newGenresRead: data.newGenresRead,
|
||||
oldestInProgressFinished: data.oldestInProgressFinished,
|
||||
maxStreakThisMonth: data.maxStreakThisMonth,
|
||||
newAuthorsRead: data.newAuthorsRead,
|
||||
pagesReadThisMonth: data.pagesReadThisMonth,
|
||||
},
|
||||
year,
|
||||
month,
|
||||
);
|
||||
|
||||
return { challengeType, ...result };
|
||||
});
|
||||
return { challengeType, ...result };
|
||||
},
|
||||
);
|
||||
return {
|
||||
...challenge,
|
||||
title: serverText(locale, `monthlyChallenge.${challenge.challengeType}.title`, challenge.title),
|
||||
description: serverText(locale, `monthlyChallenge.${challenge.challengeType}.description`, challenge.description),
|
||||
};
|
||||
}
|
||||
|
||||
async getYearProjection(user: RequestUser): Promise<YearProjectionWidgetData> {
|
||||
@@ -398,13 +411,13 @@ export class DashboardWidgetService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private readonly widgetLoaders: Record<WidgetType, (user: RequestUser) => Promise<DashboardWidgetBatchResult['data']>> = {
|
||||
private readonly widgetLoaders: Record<WidgetType, (user: RequestUser, locale: Locale) => Promise<DashboardWidgetBatchResult['data']>> = {
|
||||
'reading-goal': (user) => this.getReadingGoal(user),
|
||||
'currently-reading': (user) => this.getCurrentlyReading(user),
|
||||
'reading-streak': (user) => this.getReadingStreak(user),
|
||||
'library-overview': (user) => this.getLibraryOverview(user),
|
||||
'highlight-of-the-day': (user) => this.getHighlightOfTheDay(user),
|
||||
'monthly-challenge': (user) => this.getMonthlyChallenge(user),
|
||||
'monthly-challenge': (user, locale) => this.getMonthlyChallenge(user, locale),
|
||||
'year-projection': (user) => this.getYearProjection(user),
|
||||
'neglected-gems': (user) => this.getNeglectedGems(user),
|
||||
'reading-dna': (user) => this.getReadingDna(user),
|
||||
@@ -421,14 +434,14 @@ export class DashboardWidgetService implements OnModuleInit, OnModuleDestroy {
|
||||
* retry, so a request lost in that crowd leaves a tile stuck on "Failed to load" for the life of
|
||||
* the page. Failures are reported per widget rather than failing the batch.
|
||||
*/
|
||||
async getWidgets(types: readonly WidgetType[], user: RequestUser): Promise<DashboardWidgetBatchResponse> {
|
||||
async getWidgets(types: readonly WidgetType[], user: RequestUser, locale: Locale = DEFAULT_LOCALE): Promise<DashboardWidgetBatchResponse> {
|
||||
const startedAt = Date.now();
|
||||
this.logger.debug(`[dashboard.widget_batch] [start] userId=${user.id} widgetCount=${types.length} - widget batch started`);
|
||||
|
||||
const items = await mapWithConcurrency(types, WIDGET_QUERY_CONCURRENCY, async (type): Promise<DashboardWidgetBatchResult> => {
|
||||
const widgetStartedAt = Date.now();
|
||||
try {
|
||||
return { type, data: await this.widgetLoaders[type](user), failed: false };
|
||||
return { type, data: await this.widgetLoaders[type](user, locale), failed: false };
|
||||
} catch (error) {
|
||||
const errorClass = error instanceof Error ? error.constructor.name : typeof error;
|
||||
const message = sanitizeLogValue(error instanceof Error ? error.message : error);
|
||||
|
||||
@@ -88,9 +88,9 @@ describe('DashboardController', () => {
|
||||
const mockResult = { items: [{ type: 'reading-goal' as const, data: null, failed: false }] };
|
||||
widgetService.getWidgets.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-dna'] });
|
||||
const result = await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-dna'] }, 'de');
|
||||
|
||||
expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user);
|
||||
expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user, 'de');
|
||||
expect(result).toBe(mockResult);
|
||||
});
|
||||
|
||||
@@ -99,9 +99,9 @@ describe('DashboardController', () => {
|
||||
const user = makeUser();
|
||||
widgetService.getWidgets.mockResolvedValue({ items: [] });
|
||||
|
||||
await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-goal', 'reading-dna'] });
|
||||
await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-goal', 'reading-dna'] }, 'en');
|
||||
|
||||
expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user);
|
||||
expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user, 'en');
|
||||
});
|
||||
|
||||
it('refresh clears cached widgets for the current user', () => {
|
||||
@@ -171,10 +171,21 @@ describe('DashboardController', () => {
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('getMonthlyChallenge passes the request language to widgetService', async () => {
|
||||
const { controller, widgetService } = makeController();
|
||||
const user = makeUser();
|
||||
const mockResult = { challengeType: 'page-milestone', progress: 0, target: 500 };
|
||||
widgetService.getMonthlyChallenge.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await controller.getMonthlyChallenge(user, 'fr');
|
||||
|
||||
expect(widgetService.getMonthlyChallenge).toHaveBeenCalledWith(user, 'fr');
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['getHighlightOfTheDay', { text: 'quote', bookId: 1 }],
|
||||
['getHighlights', [{ text: 'quote', bookId: 1 }]],
|
||||
['getMonthlyChallenge', { challengeType: 'page-milestone', progress: 0, target: 500 }],
|
||||
['getYearProjection', { projectedBooks: 30, daysRemaining: 200 }],
|
||||
['getNeglectedGems', { gems: [] }],
|
||||
['getReadingDna', { archetype: 'Reader', booksAnalyzed: 10 }],
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { Body, Controller, DefaultValuePipe, Get, HttpCode, HttpStatus, Param, ParseEnumPipe, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
Header,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseEnumPipe,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import type { Locale } from '@bookorbit/types';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequestLocale } from '../../common/decorators/request-locale.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { DashboardWidgetService } from './dashboard-widget.service';
|
||||
@@ -33,8 +48,9 @@ export class DashboardController {
|
||||
|
||||
@Post('widgets/batch')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
getWidgets(@CurrentUser() user: RequestUser, @Body() dto: DashboardWidgetBatchDto) {
|
||||
return this.widgetService.getWidgets([...new Set(dto.widgets)], user);
|
||||
@Header('Vary', 'Accept-Language')
|
||||
getWidgets(@CurrentUser() user: RequestUser, @Body() dto: DashboardWidgetBatchDto, @RequestLocale() locale: Locale) {
|
||||
return this.widgetService.getWidgets([...new Set(dto.widgets)], user, locale);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@@ -74,8 +90,9 @@ export class DashboardController {
|
||||
}
|
||||
|
||||
@Get('widgets/monthly-challenge')
|
||||
getMonthlyChallenge(@CurrentUser() user: RequestUser) {
|
||||
return this.widgetService.getMonthlyChallenge(user);
|
||||
@Header('Vary', 'Accept-Language')
|
||||
getMonthlyChallenge(@CurrentUser() user: RequestUser, @RequestLocale() locale: Locale) {
|
||||
return this.widgetService.getMonthlyChallenge(user, locale);
|
||||
}
|
||||
|
||||
@Get('widgets/year-projection')
|
||||
|
||||
Reference in New Issue
Block a user