diff --git a/src/data/backup.ts b/src/data/backup.ts index b3fdf74..2b00f1f 100644 --- a/src/data/backup.ts +++ b/src/data/backup.ts @@ -1,6 +1,6 @@ import { exportAll, importAll, type ImportCounts } from './db'; import { APP_VERSION } from '../legalNotices'; -import type { Card, Deck, Note, PracticeDay, StreakExtension, ReviewLogEntry } from './types'; +import type { Card, Deck, LanguageCode, Note, PracticeDay, StreakExtension, ReviewLogEntry } from './types'; /** Identifies the file as ours before anything reads its contents. */ export const BACKUP_FORMAT = 'lingo-toolbox/backup'; @@ -12,13 +12,13 @@ export const BACKUP_FORMAT = 'lingo-toolbox/backup'; */ /** * 2 added notes, 3 the practice log, 4 the repaired days, 5 the extensions that - * replaced them. A version 4 file's repairs are read as extensions already spent + * replaced them, 6 the packs. A version 4 file's repairs are read as extensions already spent * on the day they repaired. An older file has no key at all for what it * predates, which is why the reader treats those as optional rather than as * missing fields — an old backup is still a complete backup of everything the * app had when it was written. */ -export const BACKUP_VERSION = 5; +export const BACKUP_VERSION = 6; export interface Backup { format: typeof BACKUP_FORMAT; @@ -34,6 +34,8 @@ export interface Backup { /** Streak extensions, held and spent. Restoring without these hands back points that were already spent, and breaks the streaks they were holding. */ extensions: StreakExtension[]; + /** Which catalogue packs were added. */ + packs: { id: string; language: LanguageCode; at: number }[]; } /** @@ -44,7 +46,7 @@ export interface Backup { * backup on a laptop should not drag a phone's theme along with it. */ export async function buildBackup(now: number = Date.now()): Promise { - const { decks, cards, reviews, notes, practice, extensions } = await exportAll(); + const { decks, cards, reviews, notes, practice, extensions, packs } = await exportAll(); return { format: BACKUP_FORMAT, version: BACKUP_VERSION, @@ -56,6 +58,7 @@ export async function buildBackup(now: number = Date.now()): Promise { notes, practice, extensions, + packs, }; } @@ -102,6 +105,9 @@ const looksLikeExtension = (v: unknown): boolean => isObject(v) && typeof v.id === 'string' && typeof v.at === 'number' && typeof v.cost === 'number'; /** Version 4's shape, so a backup written by it can still be read. */ +const looksLikePack = (v: unknown): boolean => isObject(v) + && typeof v.id === 'string' && typeof v.language === 'string' && typeof v.at === 'number'; + const looksLikeRepair = (v: unknown): boolean => isObject(v) && typeof v.day === 'string' && typeof v.at === 'number' && typeof v.cost === 'number'; @@ -155,6 +161,9 @@ export function parseBackup(text: string): Backup { if (raw.extensions !== undefined && (!Array.isArray(raw.extensions) || !raw.extensions.every(looksLikeExtension))) { throw new BackupError('That backup has a streak extension with missing fields.'); } + if (raw.packs !== undefined && (!Array.isArray(raw.packs) || !raw.packs.every(looksLikePack))) { + throw new BackupError('That backup has an added deck with missing fields.'); + } if (raw.repairs !== undefined && (!Array.isArray(raw.repairs) || !raw.repairs.every(looksLikeRepair))) { throw new BackupError('That backup has a repaired day with missing fields.'); } @@ -180,6 +189,7 @@ export function parseBackup(text: string): Backup { ...((raw.repairs ?? []) as { day: string; at: number; cost: number }[]) .map((r) => ({ id: `repair-${r.day}`, at: r.at, cost: r.cost, usedOn: r.day })), ], + packs: (raw.packs ?? []) as { id: string; language: LanguageCode; at: number }[], }; } @@ -200,6 +210,7 @@ export async function restoreBackup(backup: Backup): Promise { notes: backup.notes, practice: backup.practice, extensions: backup.extensions, + packs: backup.packs, }); } diff --git a/src/data/db.ts b/src/data/db.ts index dfc6fe4..d49dc3e 100644 --- a/src/data/db.ts +++ b/src/data/db.ts @@ -1,18 +1,20 @@ import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; import type { Card, Deck, Direction, Grade, LanguageCode, Note, PracticeDay, PracticeTool, Prefs, StreakExtension, ReviewLogEntry } from './types'; import { asLevel } from './types'; -import { buildSeed, buildSeedNotes, WORKSPACES } from './seed'; +import { buildDeck, SEED, SEED_NOTES, WORKSPACES } from './seed'; +import { packById, starterFor, type Pack } from './packs'; import { schedule, scheduleOf, withSchedule } from './scheduler'; const DB_NAME = 'lingo-toolbox'; /** * 2 added the notes store, 3 the practice log, 4 the repaired days, 5 the streak - * extensions that replaced them. The upgrade below is written to + * extensions that replaced them, 6 the installed packs. The upgrade below is + * written to * run from whatever version a reader is on rather than assuming an empty * database, which is what version 1's did — it created all three stores * unconditionally, which is only correct the first time anyone opens the app. */ -const DB_VERSION = 5; +const DB_VERSION = 6; const PREFS_KEY = 'lingo-toolbox:prefs'; interface LingoDB extends DBSchema { @@ -56,6 +58,19 @@ interface LingoDB extends DBSchema { key: string; value: StreakExtension; }; + /** + * Which catalogue packs a reader has added. + * + * The decks and notes a pack installs are ordinary records and could be read + * back to guess at this, but a guess is all it would be: a deck can be + * renamed, emptied or deleted, and none of that means the pack was never + * added. This says what was chosen, which is a different fact from what + * survives of it. + */ + packs: { + key: string; + value: { id: string; language: LanguageCode; at: number }; + }; } let dbPromise: Promise> | null = null; @@ -95,6 +110,9 @@ function getDB() { if (oldVersion < 5) { db.createObjectStore('extensions', { keyPath: 'id' }); } + if (oldVersion < 6) { + db.createObjectStore('packs', { keyPath: 'id' }); + } }, }); } @@ -150,23 +168,86 @@ export async function migrateLevels(): Promise<{ cards: number; decks: number }> */ let seeding: Promise | null = null; -export function ensureSeeded(): Promise { +/* ── the catalogue ───────────────────────────────────────────────────────── */ + +/** Which packs have been added, newest first. */ +export async function installedPacks(): Promise<{ id: string; language: LanguageCode; at: number }[]> { + const db = await getDB(); + return (await db.getAll('packs')).sort((a, b) => b.at - a.at); +} + +/** + * Adds a pack: its deck, its cards, its notes, and the record that it was + * chosen. + * + * Idempotent in the way that matters. The deck and its cards are keyed by ids + * derived from the pack's own content, so adding a pack twice writes the same + * rows rather than a second copy — but a deck already there is left exactly as + * it is, because the reader has been grading it and a re-add must not reset + * their schedule. Notes are the same: present is left alone. + */ +export async function installPack(pack: Pack, now: number = Date.now()): Promise { + const db = await getDB(); + const seedDeck = SEED[pack.language].find((d) => d.id === pack.deck); + if (!seedDeck) return; + + const existingDeck = await db.get('decks', pack.deck); + const existingNotes = new Set((await db.getAll('notes')).map((n) => n.id)); + + const { deck, cards } = buildDeck(pack.language, seedDeck, now); + const notes = SEED_NOTES[pack.language] + .filter((n) => pack.notes.includes(n.id) && !existingNotes.has(n.id)) + .map((n) => ({ ...n, language: pack.language, createdAt: now })); + + const tx = db.transaction(['decks', 'cards', 'notes', 'packs'], 'readwrite'); + await Promise.all([ + ...(existingDeck ? [] : [tx.objectStore('decks').put(deck)]), + ...(existingDeck ? [] : cards.map((c) => tx.objectStore('cards').put(c))), + ...notes.map((n) => tx.objectStore('notes').put(n)), + tx.objectStore('packs').put({ id: pack.id, language: pack.language, at: now }), + tx.done, + ]); +} + +/** + * The verbs the drill should favour, from the packs a reader has added. + * + * Empty means no opinion, and the drill asks about the whole language — which + * is what it did before packs existed and what it should keep doing for anyone + * whose packs name no verbs. + */ +export async function focusVerbs(language: LanguageCode): Promise { + const installed = await installedPacks(); + const verbs = installed + .filter((p) => p.language === language) + .flatMap((p) => packById(p.id)?.verbs ?? []); + return [...new Set(verbs)]; +} + +/** + * A fresh install arrives with one pack, in the language it opens in. + * + * It used to arrive with everything: twenty-two decks and a hundred and + * sixty-five cards across all four workspaces, most of them in languages the + * reader had not chosen and would never see. That is the opposite of a + * catalogue — there is nothing to add when everything is already there, and no + * way to tell what you picked from what you were given. + * + * Guarded on the database being empty, so nobody who already has decks loses + * them or gains a starter beside them. + */ +export function ensureSeeded(language: LanguageCode = 'NL'): Promise { if (!seeding) { seeding = (async () => { const db = await getDB(); if (await db.count('decks')) return; - - const { decks, cards } = buildSeed(); - const tx = db.transaction(['decks', 'cards'], 'readwrite'); - await Promise.all([ - ...decks.map((d) => tx.objectStore('decks').put(d)), - ...cards.map((c) => tx.objectStore('cards').put(c)), - tx.done, - ]); + const starter = starterFor(language); + if (starter) await installPack(starter); })().catch((err) => { // Let a failed seed be retried rather than caching the rejection forever. seeding = null; throw err; + }); } return seeding; @@ -322,12 +403,19 @@ export async function deleteNote(id: string): Promise { * emptiness check would mean the starter notes only ever reached people who * installed after they existed. */ +/** + * Notes arrive with the pack that explains them, not in a heap at install. + * + * This used to write all twenty-four of them — every rule in every language — + * the first time the app opened. Under a catalogue that is the same mistake the + * deck seed was making: the notes *are* the grammar themes, so handing them all + * over at once gives away the thing the packs are offering. + * + * Kept as a no-op rather than deleted so that a database seeded by an older + * build is left exactly as it is. Nobody loses a note they already have. + */ export async function ensureNotesSeeded(): Promise { - const db = await getDB(); - if (await db.count('notes')) return; - const notes = buildSeedNotes(); - const tx = db.transaction('notes', 'readwrite'); - await Promise.all([...notes.map((n: Note) => tx.store.put(n)), tx.done]); + /* Intentionally empty — see above. */ } export async function reviewsSince(since: number): Promise { @@ -700,17 +788,19 @@ export async function reviewsPerDay(days = 7, now: number = Date.now()): Promise export async function exportAll(): Promise<{ decks: Deck[]; cards: Card[]; reviews: ReviewLogEntry[]; notes: Note[]; practice: PracticeDay[]; extensions: StreakExtension[]; + packs: { id: string; language: LanguageCode; at: number }[]; }> { const db = await getDB(); - const [decks, cards, reviews, notes, practice, extensions] = await Promise.all([ + const [decks, cards, reviews, notes, practice, extensions, packs] = await Promise.all([ db.getAll('decks'), db.getAll('cards'), db.getAll('reviews'), db.getAll('notes'), db.getAll('practice'), db.getAll('extensions'), + db.getAll('packs'), ]); - return { decks, cards, reviews, notes, practice, extensions }; + return { decks, cards, reviews, notes, practice, extensions, packs }; } export interface ImportCounts { @@ -738,6 +828,7 @@ export async function importAll( data: { decks: Deck[]; cards: Card[]; reviews: ReviewLogEntry[]; notes?: Note[]; practice?: PracticeDay[]; extensions?: StreakExtension[]; + packs?: { id: string; language: LanguageCode; at: number }[]; }, ): Promise { const db = await getDB(); @@ -747,6 +838,11 @@ export async function importAll( // Restoring the practice without the extensions would hand back the points // that were already spent, and quietly break every streak they were holding. const incomingExtensions = data.extensions ?? []; + /* Which packs were chosen. A restore can rebuild the decks from the file and + still lose this, and then the catalogue offers back what the reader already + picked — the deck-on-the-shelf fallback covers most of it, but not a pack + whose deck was later deleted. */ + const incomingPacks = data.packs ?? []; const has = { decks: new Set(existing.decks.map((d) => d.id)), cards: new Set(existing.cards.map((c) => c.id)), @@ -754,6 +850,7 @@ export async function importAll( notes: new Set(existing.notes.map((n) => n.id)), practice: new Set(existing.practice.map((p) => p.id)), extensions: new Set(existing.extensions.map((e) => e.id)), + packs: new Set(existing.packs.map((p) => p.id)), }; const fresh = { @@ -763,9 +860,10 @@ export async function importAll( notes: incomingNotes.filter((n) => !has.notes.has(n.id)), practice: incomingPractice.filter((p) => !has.practice.has(p.id)), extensions: incomingExtensions.filter((e) => !has.extensions.has(e.id)), + packs: incomingPacks.filter((p) => !has.packs.has(p.id)), }; - const tx = db.transaction(['decks', 'cards', 'reviews', 'notes', 'practice', 'extensions'], 'readwrite'); + const tx = db.transaction(['decks', 'cards', 'reviews', 'notes', 'practice', 'extensions', 'packs'], 'readwrite'); await Promise.all([ ...fresh.decks.map((d) => tx.objectStore('decks').put(d)), ...fresh.cards.map((c) => tx.objectStore('cards').put(c)), @@ -773,6 +871,7 @@ export async function importAll( ...fresh.notes.map((n) => tx.objectStore('notes').put(n)), ...fresh.practice.map((p) => tx.objectStore('practice').put(p)), ...fresh.extensions.map((e) => tx.objectStore('extensions').put(e)), + ...fresh.packs.map((p) => tx.objectStore('packs').put(p)), tx.done, ]); @@ -828,7 +927,7 @@ export function savePrefs(prefs: Prefs): void { /** Clears every store — used by the "Reset local data" action in Settings. */ export async function resetAll(): Promise { const db = await getDB(); - const tx = db.transaction(['decks', 'cards', 'reviews', 'notes', 'practice', 'extensions'], 'readwrite'); + const tx = db.transaction(['decks', 'cards', 'reviews', 'notes', 'practice', 'extensions', 'packs'], 'readwrite'); await Promise.all([ tx.objectStore('decks').clear(), tx.objectStore('cards').clear(), diff --git a/src/data/packs.ts b/src/data/packs.ts new file mode 100644 index 0000000..e575863 --- /dev/null +++ b/src/data/packs.ts @@ -0,0 +1,186 @@ +import type { LanguageCode } from './types'; + +/** + * The catalogue: grammar themes you can add to a workspace. + * + * A pack is a rule, the words that exercise it, and the verbs to drill it on. + * Not a deck with a nicer name — a deck is a list of words, and a list of words + * does not tell you where the prefix goes in a separable verb or why everything + * is -inho. The note is what makes it a grammar pack; the cards are what make + * it practice. + * + * It is a view over content that already exists rather than a second copy of + * it. `deck` and `notes` are ids into SEED and SEED_NOTES, so a pack cannot + * drift from the material it offers, and adding one months later builds the + * same records the first install did — see buildDeck. + * + * `verbs` is a focus list, not data. The drill already loads every verb the + * language has: NL, ES and PT ship whole conjugation tables, thousands of + * words, and it picks from all of them. What a pack contributes is *which* of + * them this theme is about, so drilling a pack asks about its own verbs + * instead of the language's entire vocabulary. + */ +export interface Pack { + id: string; + language: LanguageCode; + /** The theme, as a learner would name it. */ + name: string; + /** One sentence: what the rule is, not what the pack contains. */ + blurb: string; + /** The seeded deck this installs. */ + deck: string; + /** Grammar notes it installs alongside. Ids into SEED_NOTES. */ + notes: string[]; + /** Lemmas the drill should favour while this pack is installed. */ + verbs?: string[]; + /** + * Offered first in its language, and the one a fresh install arrives with. + * + * Exactly one per language. It is the phrase pack rather than the most + * interesting grammar, because the first thing a workspace needs is + * something to say, not a rule about how to say it. + */ + starter?: boolean; +} + +/** + * The catalogue itself. + * + * Ordered as a course would be: what to say, then the rules that keep tripping + * people up, then the ones you only meet once you are reading. Every pack has + * a note except where the language genuinely has no rule to state — a list of + * false friends is a warning, not a grammar. + */ +export const PACKS: Pack[] = [ + /* ── Dutch ─────────────────────────────────────────────────────────────── */ + { + id: 'nl-starter', language: 'NL', name: 'Everyday phrases', starter: true, + blurb: 'The sentences a day actually needs, and the rule that the verb comes second.', + deck: 'nl-everyday', notes: ['nl-word-order'], + }, + { + id: 'nl-separable-pack', language: 'NL', name: 'Separable verbs', + blurb: 'Verbs that come apart in a main clause and put themselves back together in a subordinate one.', + deck: 'nl-separable', notes: ['nl-separable'], + /* Checked against NL.json rather than written from memory: four of the + eight this list started with — opbellen, uitgaan, meedoen, opruimen — + are not in the table Wiktionary gave us, and a focus list naming verbs + the drill cannot ask about is a pack that quietly does nothing. */ + verbs: ['meenemen', 'aankomen', 'afspreken', 'weggaan', 'terugkomen', 'opgeven', 'uitnodigen', 'ophouden'], + }, + { + id: 'nl-particles-pack', language: 'NL', name: 'Little words', + blurb: 'toch, wel, even, hoor — the words that carry the tone and never translate.', + deck: 'nl-particles', notes: ['nl-particles', 'nl-er'], + }, + { + id: 'nl-market-pack', language: 'NL', name: 'At the market', + blurb: 'Food, quantities and asking for them — with the de/het problem the nouns bring with them.', + deck: 'nl-market', notes: ['nl-de-het', 'nl-adjective-e'], + }, + { + id: 'nl-work-pack', language: 'NL', name: 'At work', + blurb: 'Meetings, deadlines and the polite forms an office runs on.', + deck: 'nl-work', notes: [], + }, + + /* ── Portuguese ────────────────────────────────────────────────────────── */ + { + id: 'pt-starter', language: 'PT', name: 'Everyday phrases', starter: true, + blurb: 'The sentences a day actually needs, and who você is talking to.', + deck: 'pt-everyday', notes: ['pt-voce', 'pt-contractions'], + }, + { + id: 'pt-verbs-pack', language: 'PT', name: 'Everyday verbs', + blurb: 'The verbs everything else is built from — including the two that both mean "to be".', + deck: 'pt-verbs', notes: ['pt-ser-estar', 'pt-gerund'], + verbs: ['ser', 'estar', 'ter', 'ir', 'fazer', 'poder', 'querer', 'dizer', 'ver', 'saber'], + }, + { + id: 'pt-cafe-pack', language: 'PT', name: 'At the café', + blurb: 'Ordering, paying and the diminutive that turns a coffee into a cafezinho.', + deck: 'pt-cafe', notes: ['pt-diminutive'], + }, + { + id: 'pt-false-friends-pack', language: 'PT', name: 'False friends', + blurb: 'Words that look like English and mean something else entirely.', + deck: 'pt-false-friends', notes: [], + }, + { + id: 'pt-feelings-pack', language: 'PT', name: 'How you feel', + blurb: 'Saying how you are, and the por/para choice that keeps coming with it.', + deck: 'pt-feelings', notes: ['pt-por-para'], + }, + + /* ── Spanish ───────────────────────────────────────────────────────────── */ + { + id: 'es-starter', language: 'ES', name: 'Moods', starter: true, + blurb: 'Saying how you are — and the two verbs that both mean "to be".', + deck: 'es-feelings', notes: ['es-ser-estar'], + }, + { + id: 'es-verbs-pack', language: 'ES', name: 'Irregular verbs', + blurb: 'The verbs that refuse the pattern, and the two past tenses they refuse it in.', + deck: 'es-verbs', notes: ['es-preterito', 'es-subjunctive'], + verbs: ['ser', 'estar', 'ir', 'tener', 'hacer', 'poder', 'querer', 'decir', 'venir', 'poner', 'saber', 'dar'], + }, + { + id: 'es-kitchen-pack', language: 'ES', name: 'Kitchen Spanish', + blurb: 'Food and cooking, and the gender the nouns bring with them.', + deck: 'es-kitchen', notes: ['es-gender'], + }, + { + id: 'es-travel-pack', language: 'ES', name: 'Getting around', + blurb: 'Directions and transport, with the por/para choice that decides half of them.', + deck: 'es-travel', notes: ['es-por-para', 'es-personal-a'], + }, + { + id: 'es-linking-pack', language: 'ES', name: 'Linking words', + blurb: 'The joins that turn sentences into paragraphs.', + deck: 'es-linking', notes: [], + }, + { + id: 'es-idioms-pack', language: 'ES', name: 'Idioms that lie', + blurb: 'Phrases whose words tell you nothing about what they mean.', + deck: 'es-idioms', notes: [], + }, + + /* ── English ───────────────────────────────────────────────────────────── */ + { + id: 'en-starter', language: 'EN', name: 'Words worth knowing', starter: true, + blurb: 'Precise words for things you already say the long way round.', + deck: 'en-precise', notes: ['en-countable'], + }, + { + id: 'en-phrasal-pack', language: 'EN', name: 'Phrasal verbs', + blurb: 'Verb plus particle, where the particle changes everything — and can be split.', + deck: 'en-phrasal', notes: ['en-phrasal-split'], + }, + { + id: 'en-linking-pack', language: 'EN', name: 'Linking words', + blurb: 'however, but, although — the joins, and which one takes which clause.', + deck: 'en-linking', notes: ['en-however'], + }, + { + id: 'en-work-pack', language: 'EN', name: 'At work', + blurb: 'Meetings and email, and the tense the updates are written in.', + deck: 'en-work', notes: ['en-present-perfect'], + }, + { + id: 'en-directions-pack', language: 'EN', name: 'Getting around', + blurb: 'Directions and transport, with the articles they keep needing.', + deck: 'en-directions', notes: ['en-articles', 'en-adjective-order'], + }, + { + id: 'en-idioms-pack', language: 'EN', name: 'Everyday idioms', + blurb: 'Phrases everyone uses and nobody explains.', + deck: 'en-idioms', notes: [], + }, +]; + +export const packsFor = (language: LanguageCode) => PACKS.filter((p) => p.language === language); + +export const starterFor = (language: LanguageCode) => + PACKS.find((p) => p.language === language && p.starter) ?? packsFor(language)[0]; + +export const packById = (id: string) => PACKS.find((p) => p.id === id); diff --git a/src/data/seed.ts b/src/data/seed.ts index da1f100..7bb1f64 100644 --- a/src/data/seed.ts +++ b/src/data/seed.ts @@ -92,7 +92,7 @@ interface SeedDeck { * A starter workspace so the app is never empty on first open. Everything here is * ordinary vocabulary; the user can delete the decks and add their own. */ -const SEED: Record = { +export const SEED: Record = { EN: [ { id: 'en-phrasal', @@ -109,6 +109,14 @@ const SEED: Record = { { front: 'call off', back: 'to cancel something already arranged', illustration: '274C', level: 'B1', tags: ['verb'] }, { front: 'take on', back: 'to accept work or responsibility', illustration: '1F4AA', level: 'B2', tags: ['verb'] }, { front: 'come across', back: 'to find by chance, or to give an impression', illustration: '1F440', level: 'B2', tags: ['verb'] }, + { front: 'put up with', back: 'to tolerate', level: 'B2', tags: ['verb'] }, + { front: 'get over', back: 'to recover from', level: 'B1', tags: ['verb'] }, + { front: 'turn down', back: 'to refuse, or to lower', illustration: '274C', level: 'B1', tags: ['verb'] }, + { front: 'work out', back: 'to figure out — and to exercise', illustration: '1F4AA', level: 'B1', tags: ['verb'] }, + { front: 'run out of', back: 'to have none left', level: 'A2', tags: ['verb'] }, + { front: 'bring about', back: 'to cause to happen', level: 'B2', tags: ['verb'] }, + { front: 'sort out', back: 'to fix or organise', level: 'B1', tags: ['verb'] }, + { front: 'go over', back: 'to review something carefully', illustration: '1F440', level: 'B1', tags: ['verb'] }, ], }, { @@ -125,6 +133,12 @@ const SEED: Record = { { front: 'ambiguous', back: 'open to more than one reading', phonetic: '/æmˈbɪɡjuəs/', illustration: '1F914', level: 'B2', tags: ['adj'] }, { front: 'pragmatic', back: 'guided by what works rather than by theory', phonetic: '/præɡˈmætɪk/', level: 'B2', tags: ['adj'] }, { front: 'succinct', back: 'said in few words', phonetic: '/səkˈsɪŋkt/', illustration: '2702', level: 'C1', tags: ['adj'] }, + { front: 'tenuous', back: 'so slight it barely holds', level: 'C1', tags: ['adj'] }, + { front: 'scrupulous', back: 'careful to do the right thing', level: 'C1', tags: ['adj'] }, + { front: 'innocuous', back: 'harmless, and duller than it looks', level: 'C1', tags: ['adj'] }, + { front: 'volatile', back: 'liable to change without warning', illustration: '1F525', level: 'B2', tags: ['adj'] }, + { front: 'astute', back: 'quick to see what matters', illustration: '1F440', level: 'C1', tags: ['adj'] }, + { front: 'redundant', back: 'more than is needed', level: 'B2', tags: ['adj'] }, ], }, { @@ -139,6 +153,10 @@ const SEED: Record = { { front: 'cut corners', back: 'to do something cheaply or carelessly', illustration: '2702', level: 'B2', tags: ['idiom'] }, { front: 'on the fence', back: 'undecided between two options', level: 'B2', tags: ['idiom'] }, { front: 'a blessing in disguise', back: 'something bad that turns out well', illustration: '1F3AD', level: 'B2', tags: ['idiom'] }, + { front: 'the last straw', back: 'the small thing that finally breaks it', level: 'B2', tags: ['idiom'] }, + { front: 'bite the bullet', back: 'to get an unpleasant thing over with', level: 'B2', tags: ['idiom'] }, + { front: 'hit the sack', back: 'to go to bed', illustration: '1F634', level: 'B1', tags: ['idiom'] }, + { front: 'call it a day', back: 'to stop working for now', illustration: '23F0', level: 'B1', tags: ['idiom'] }, ], }, { @@ -156,6 +174,14 @@ const SEED: Record = { { front: 'to miss a train', back: 'to arrive after it has already gone', illustration: '1F682', level: 'A2', tags: ['verb'] }, { front: 'to change at', back: 'to get off and take another line partway', level: 'A2', tags: ['verb'] }, { front: 'timetable', back: 'the printed list of departure times', illustration: '23F0', level: 'A2', tags: ['noun'] }, + { front: 'head towards', back: 'to go in the direction of', level: 'A2', tags: ['phrase'] }, + { front: 'on your left', back: 'to your left', level: 'A1', tags: ['phrase'] }, + { front: 'the far end', back: 'the end furthest from here', level: 'B1', tags: ['phrase'] }, + { front: 'a dead end', back: 'a road with no way through', illustration: '274C', level: 'B1', tags: ['noun'] }, + { front: 'the crossing', back: 'where you walk across the road', level: 'A2', tags: ['noun'] }, + { front: 'two stops away', back: 'two stops from here', illustration: '1F68C', level: 'A2', tags: ['phrase'] }, + { front: 'the platform', back: 'where you wait for the train', illustration: '1F686', level: 'A2', tags: ['noun'] }, + { front: "it's a short walk", back: "it isn't far on foot", illustration: '1F45F', level: 'A2', tags: ['phrase'] }, ], }, { @@ -173,6 +199,12 @@ const SEED: Record = { { front: 'to sign off on', back: 'to give something formal approval', level: 'B2', tags: ['verb'] }, { front: 'a backlog', back: 'work that has piled up while you were elsewhere', level: 'B2', tags: ['noun'] }, { front: 'to run something by someone', back: 'to check an idea with them before acting', level: 'B2', tags: ['verb'] }, + { front: 'the deliverable', back: 'the thing you owe by the deadline', illustration: '23F0', level: 'B2', tags: ['noun'] }, + { front: 'to loop someone in', back: 'to add them to the conversation', illustration: '1F4AC', level: 'B2', tags: ['phrase'] }, + { front: 'a blocker', back: 'the thing stopping the work', illustration: '274C', level: 'B2', tags: ['noun'] }, + { front: 'to follow up', back: 'to come back to it later', level: 'B1', tags: ['phrase'] }, + { front: 'the takeaway', back: 'the one thing worth remembering', illustration: '1F4DD', level: 'B2', tags: ['noun'] }, + { front: 'out of office', back: 'away, and not answering', illustration: '1F3E0', level: 'A2', tags: ['phrase'] }, ], }, // Abstract by nature, so no illustrations — see the note on SeedCard. @@ -191,6 +223,12 @@ const SEED: Record = { { front: 'hence', back: 'from this it follows', level: 'C1', tags: ['adverb'] }, { front: 'albeit', back: 'although — before a short phrase, not a clause', level: 'C1', tags: ['conjunction'] }, { front: 'notwithstanding', back: 'despite, in spite of', level: 'C1', tags: ['preposition'] }, + { front: 'provided that', back: 'as long as', level: 'B2', tags: ['linking'] }, + { front: 'in other words', back: 'put another way', level: 'B1', tags: ['linking'] }, + { front: 'even so', back: 'despite that', level: 'B2', tags: ['linking'] }, + { front: 'as a result', back: 'so, therefore', level: 'B1', tags: ['linking'] }, + { front: 'on the other hand', back: 'looking at the opposite side', level: 'B1', tags: ['linking'] }, + { front: 'that said', back: 'having admitted that', level: 'B2', tags: ['linking'] }, ], }, ], @@ -208,6 +246,15 @@ const SEED: Record = { { front: 'tá tudo bem', back: 'everything is fine — está, worn down in speech', illustration: '1F44D', level: 'A1', tags: ['phrase'] }, { front: 'sei lá', back: 'I dunno — a shrug with words', illustration: '1F914', level: 'A2', tags: ['phrase'] }, { front: 'dar um jeitinho', back: 'to find a way around it, improvised', phonetic: '/daʁ ũ ʒejˈtʃĩɲu/', illustration: '1F527', level: 'B1', tags: ['phrase'] }, + { front: 'tudo bem?', back: 'everything good? — the standard hello', illustration: '1F44B', level: 'A1', tags: ['phrase'] }, + { front: 'valeu', back: 'cheers, thanks — informal', illustration: '1F44D', level: 'A2', tags: ['phrase'] }, + { front: 'nossa!', back: 'wow — from Nossa Senhora, and said constantly', level: 'A2', tags: ['phrase'] }, + { front: 'com licença', back: 'excuse me — when passing or leaving', level: 'A1', tags: ['phrase'] }, + { front: 'desculpa', back: 'sorry', illustration: '1F64F', level: 'A1', tags: ['phrase'] }, + { front: 'tá bom', back: 'alright, fine', level: 'A1', tags: ['phrase'] }, + { front: 'daqui a pouco', back: 'in a little while', illustration: '23F0', level: 'A2', tags: ['phrase'] }, + { front: 'de nada', back: "you're welcome", level: 'A1', tags: ['phrase'] }, + { front: 'beleza?', back: 'all good? — literally beauty', level: 'B1', tags: ['phrase'] }, ], }, { @@ -222,6 +269,10 @@ const SEED: Record = { { front: 'livraria', back: 'bookshop — a library is a biblioteca', phonetic: '/livɾaˈɾia/', illustration: '1F4DA', level: 'A2', tags: ['noun'] }, { front: 'pasta', back: 'folder or briefcase — the food is massa', illustration: '1F45C', level: 'A2', tags: ['noun'] }, { front: 'êxito', back: 'success — not an exit', illustration: '1F3C6', level: 'B1', tags: ['noun'] }, + { front: 'assistir', back: 'to watch — not to assist', illustration: '1F440', level: 'A2', tags: ['verb'] }, + { front: 'costume', back: 'habit — not a costume', level: 'B1', tags: ['noun'] }, + { front: 'realizar', back: 'to carry out — only sometimes to realise', level: 'B1', tags: ['verb'] }, + { front: 'atualmente', back: 'currently — not actually', level: 'B1', tags: ['adv'] }, ], }, { @@ -239,6 +290,13 @@ const SEED: Record = { { front: 'um suco de laranja', back: 'an orange juice — suco, where Portugal says sumo', illustration: '1F34A', level: 'A1', tags: ['noun'] }, { front: 'um copo de água', back: 'a glass of water', illustration: '1F4A7', level: 'A1', tags: ['phrase'] }, { front: 'a conta, por favor', back: 'the bill, please', level: 'A1', tags: ['phrase'] }, + { front: 'um pastel', back: 'a fried pastry, savoury', level: 'A2', tags: ['noun'] }, + { front: 'uma coxinha', back: 'a teardrop of shredded chicken in dough', level: 'A2', tags: ['noun'] }, + { front: 'um misto quente', back: 'a toasted ham and cheese', illustration: '1F9C0', level: 'A2', tags: ['noun'] }, + { front: 'sem açúcar', back: 'without sugar', level: 'A1', tags: ['phrase'] }, + { front: 'para viagem', back: 'to take away', level: 'A2', tags: ['phrase'] }, + { front: 'uma água com gás', back: 'sparkling water', level: 'A1', tags: ['noun'] }, + { front: 'está ótimo', back: "it's great", illustration: '1F60A', level: 'A1', tags: ['phrase'] }, ], }, { @@ -256,6 +314,11 @@ const SEED: Record = { { front: 'dar certo', back: 'to work out, to come off', level: 'B1', tags: ['verb'] }, { front: 'estar a fim de', back: 'to be up for something', level: 'B1', tags: ['verb'] }, { front: 'se dar bem', back: 'to get on well with someone', level: 'B1', tags: ['verb'] }, + { front: 'dar', back: 'to give — and half the idioms in the language', level: 'A1', tags: ['verb'] }, + { front: 'conseguir', back: 'to manage to, to pull off', illustration: '1F4AA', level: 'B1', tags: ['verb'] }, + { front: 'precisar', back: 'to need', level: 'A1', tags: ['verb'] }, + { front: 'achar', back: 'to think, to reckon — and to find', illustration: '1F914', level: 'A2', tags: ['verb'] }, + { front: 'deixar', back: 'to leave something, to let', level: 'A2', tags: ['verb'] }, ], }, { @@ -272,6 +335,14 @@ const SEED: Record = { { front: 'que saco', back: 'what a drag', illustration: '1F644', level: 'B1', tags: ['phrase'] }, { front: 'não aguento mais', back: 'I cannot take any more of it', illustration: '1F612', level: 'B1', tags: ['phrase'] }, { front: 'estou de boa', back: 'I am fine — relaxed, nothing wrong', illustration: '1F60C', level: 'B1', tags: ['phrase'] }, + { front: 'com sono', back: 'sleepy', illustration: '1F634', level: 'A1', tags: ['phrase'] }, + { front: 'com pressa', back: 'in a hurry', illustration: '23F0', level: 'A2', tags: ['phrase'] }, + { front: 'com raiva', back: 'angry', illustration: '1F620', level: 'A2', tags: ['phrase'] }, + { front: 'com saudade', back: 'missing someone or somewhere', illustration: '1F614', level: 'B1', tags: ['phrase'] }, + { front: 'chateado', back: 'annoyed, upset', level: 'B1', tags: ['adj'] }, + { front: 'animado', back: 'excited, up for it', illustration: '1F389', level: 'A2', tags: ['adj'] }, + { front: 'cansado', back: 'tired', illustration: '1F634', level: 'A1', tags: ['adj'] }, + { front: 'tranquilo', back: 'relaxed — and also no worries', illustration: '1F642', level: 'A2', tags: ['adj'] }, ], }, ], @@ -288,6 +359,15 @@ const SEED: Record = { { front: 'alsjeblieft', back: 'please — and also here you go', phonetic: '/ɑlsjəˈblift/', illustration: '1F64F', level: 'A1', tags: ['phrase'] }, { front: 'doe maar normaal', back: 'just act normal — a whole national attitude', illustration: '1F610', level: 'B1', tags: ['phrase'] }, { front: 'afspraak', back: 'an appointment or an agreement', phonetic: '/ˈɑfspraːk/', illustration: '23F0', level: 'A2', tags: ['noun'] }, + { front: 'even', back: 'just, for a moment — softens any request', level: 'A1', tags: ['adv'] }, + { front: 'hoor', back: 'a tag that takes the edge off what you just said', level: 'A2', tags: ['particle'] }, + { front: 'het maakt niet uit', back: "it doesn't matter", level: 'A2', tags: ['phrase'] }, + { front: 'ik snap het', back: 'I get it', illustration: '1F642', level: 'A1', tags: ['phrase'] }, + { front: 'geen probleem', back: 'no problem', illustration: '1F44D', level: 'A1', tags: ['phrase'] }, + { front: 'tot straks', back: 'see you later today', illustration: '1F44B', level: 'A1', tags: ['phrase'] }, + { front: 'sorry, hoor', back: 'sorry — the hoor makes it lighter, not heavier', level: 'A2', tags: ['phrase'] }, + { front: 'dat is jammer', back: "that's a shame", illustration: '1F614', level: 'A2', tags: ['phrase'] }, + { front: 'weet je wat', back: 'you know what — the way a suggestion starts', level: 'B1', tags: ['phrase'] }, ], }, { @@ -303,6 +383,14 @@ const SEED: Record = { { front: 'uitgaan', back: 'to go out — ik ga uit', phonetic: '/ˈœytɣaːn/', illustration: '1F6AA', level: 'A2', tags: ['verb'] }, { front: 'afspreken', back: 'to arrange to meet — ik spreek af', phonetic: '/ˈɑfspreːkə(n)/', illustration: '1F91D', level: 'B1', tags: ['verb'] }, { front: 'meevallen', back: 'to turn out better than feared', illustration: '1F605', level: 'B1', tags: ['verb'] }, + { front: 'weggaan', back: 'to leave — ik ga weg', illustration: '1F6AA', level: 'A2', tags: ['verb'] }, + { front: 'terugkomen', back: 'to come back — ik kom terug', level: 'A2', tags: ['verb'] }, + { front: 'opgeven', back: 'to give up — ik geef op', level: 'B1', tags: ['verb'] }, + { front: 'uitnodigen', back: 'to invite — ik nodig uit', illustration: '1F389', level: 'B1', tags: ['verb'] }, + { front: 'ophouden', back: 'to stop — hou op!', illustration: '274C', level: 'B1', tags: ['verb'] }, + { front: 'aanraken', back: 'to touch — raak niet aan', level: 'B1', tags: ['verb'] }, + { front: 'uitzoeken', back: 'to figure out, to pick out — ik zoek uit', illustration: '1F440', level: 'B2', tags: ['verb'] }, + { front: 'opschieten', back: 'to hurry up — schiet op!', illustration: '23F0', level: 'B1', tags: ['verb'] }, ], }, { @@ -320,6 +408,15 @@ const SEED: Record = { { front: 'lekker', back: 'tasty — and by extension, good in general', illustration: '1F60B', level: 'A1', tags: ['adj'] }, { front: 'mag ik...?', back: 'may I have...?', level: 'A1', tags: ['phrase'] }, { front: 'de rekening', back: 'the bill', level: 'A2', tags: ['noun'] }, + { front: 'de kip', back: 'chicken', level: 'A1', tags: ['noun'] }, + { front: 'het ei', back: 'egg', illustration: '1F95A', level: 'A1', tags: ['noun'] }, + { front: 'de vis', back: 'fish', illustration: '1F41F', level: 'A1', tags: ['noun'] }, + { front: 'de wortel', back: 'carrot', illustration: '1F955', level: 'A1', tags: ['noun'] }, + { front: 'het pond', back: '500 grams — what a Dutch market means by a pound', level: 'A2', tags: ['noun'] }, + { front: 'een onsje', back: '100 grams, roughly — always asked for in the diminutive', level: 'B1', tags: ['noun'] }, + { front: 'vers', back: 'fresh', level: 'A1', tags: ['adj'] }, + { front: 'goedkoop', back: 'cheap', illustration: '1F4B0', level: 'A1', tags: ['adj'] }, + { front: 'mag het ietsje meer zijn', back: 'can it be slightly more — the question every counter asks', level: 'B1', tags: ['phrase'] }, ], }, { @@ -337,6 +434,9 @@ const SEED: Record = { { front: 'thuiswerken', back: 'to work from home', illustration: '1F3E0', level: 'A2', tags: ['verb'] }, { front: 'het rooster', back: 'the schedule, the rota', illustration: '23F0', level: 'B1', tags: ['noun'] }, { front: 'het overleg', back: 'talking it through together before deciding', level: 'B1', tags: ['noun'] }, + { front: 'de deadline', back: 'the deadline — borrowed whole', illustration: '23F0', level: 'A2', tags: ['noun'] }, + { front: 'even bellen', back: 'to give someone a quick call', level: 'A2', tags: ['phrase'] }, + { front: 'ik ben er even niet', back: "I'm away for a bit", level: 'B1', tags: ['phrase'] }, ], }, // Dutch particles: the hardest thing to look up and the easiest to drill. @@ -354,6 +454,12 @@ const SEED: Record = { { front: 'gezellig', back: 'warm, companionable, good to be in', level: 'B1', tags: ['adj'] }, { front: 'maar', back: 'go on, help yourself — not the "but" you know', level: 'B2', tags: ['particle'] }, { front: 'eens', back: 'sometime — turns an order into a suggestion', level: 'B2', tags: ['particle'] }, + { front: 'zeg', back: 'hey, say — tacked on to get attention', level: 'B1', tags: ['particle'] }, + { front: 'dus', back: 'so — often just a filler on the way to the point', level: 'A2', tags: ['particle'] }, + { front: 'echt waar', back: 'really? — said back to something surprising', illustration: '1F440', level: 'A2', tags: ['phrase'] }, + { front: 'eigenlijk', back: 'actually, when you think about it', illustration: '1F914', level: 'A2', tags: ['adv'] }, + { front: 'gewoon', back: 'just, simply — and also ordinary', level: 'A2', tags: ['adv'] }, + { front: 'misschien', back: 'maybe', level: 'A1', tags: ['adv'] }, ], }, ], @@ -373,6 +479,14 @@ const SEED: Record = { { front: 'a fuego lento', back: 'on a low heat', phonetic: '/a ˈfwe.ɣo ˈlen.to/', illustration: '1F525', level: 'B1', tags: ['phrase'] }, { front: 'el aliño', back: 'the dressing', phonetic: '/el aˈli.ɲo/', illustration: '1F957', level: 'B1', tags: ['noun'] }, { front: 'soso', back: 'bland, under-salted', phonetic: '/ˈso.so/', illustration: '1F615', level: 'B1', tags: ['adj'] }, + { front: 'el horno', back: 'the oven', illustration: '1F525', level: 'A1', tags: ['noun'] }, + { front: 'la olla', back: 'the pot', level: 'A1', tags: ['noun'] }, + { front: 'el cuchillo', back: 'the knife', level: 'A1', tags: ['noun'] }, + { front: 'probar', back: 'to taste — and to try anything', level: 'A2', tags: ['verb'] }, + { front: 'aliñar', back: 'to dress a salad', level: 'B1', tags: ['verb'] }, + { front: 'el aceite de oliva', back: 'olive oil', level: 'A1', tags: ['noun'] }, + { front: 'a la plancha', back: 'cooked on the griddle', level: 'A2', tags: ['phrase'] }, + { front: 'está riquísimo', back: "it's delicious — the -ísimo does the work", illustration: '1F60A', level: 'A2', tags: ['phrase'] }, ], }, { @@ -387,6 +501,10 @@ const SEED: Record = { { front: 'no tener pelos en la lengua', back: 'to speak bluntly', phonetic: '/no teˈneɾ ˈpe.los/', illustration: '1F624', level: 'C1', tags: ['idiom'] }, { front: 'echar de menos', back: 'to miss someone', phonetic: '/eˈtʃaɾ de ˈme.nos/', illustration: '1F494', level: 'B1', tags: ['idiom'] }, { front: 'dar en el clavo', back: 'to hit the nail on the head', phonetic: '/daɾ en el ˈkla.βo/', illustration: '1F528', level: 'B2', tags: ['idiom'] }, + { front: 'costar un ojo de la cara', back: 'to cost an eye from your face', illustration: '1F440', level: 'B2', tags: ['idiom'] }, + { front: 'estar como una cabra', back: 'to be completely mad', level: 'B2', tags: ['idiom'] }, + { front: 'ponerse las pilas', back: 'to get your act together — put your batteries in', illustration: '1F4AA', level: 'B1', tags: ['idiom'] }, + { front: 'tirar la toalla', back: 'to throw in the towel', level: 'B1', tags: ['idiom'] }, ], }, { @@ -403,6 +521,12 @@ const SEED: Record = { { front: 'poder', back: 'to be able — yo puedo', phonetic: '/poˈðeɾ/', illustration: '1F4AA', level: 'A1', tags: ['verb'] }, { front: 'huir', back: 'to flee — yo huyo', phonetic: '/wiɾ/', illustration: '1F3C3', level: 'B1', tags: ['verb'] }, { front: 'valer', back: 'to be worth — yo valgo', phonetic: '/baˈleɾ/', illustration: '1F4B0', level: 'B1', tags: ['verb'] }, + { front: 'venir', back: 'to come — vengo, vienes, viene', level: 'A1', tags: ['verb'] }, + { front: 'poner', back: 'to put — pongo, and puse in the preterite', level: 'A2', tags: ['verb'] }, + { front: 'salir', back: 'to leave, to go out — salgo', illustration: '1F6AA', level: 'A1', tags: ['verb'] }, + { front: 'conocer', back: 'to know a person or place — conozco', illustration: '1F91D', level: 'A2', tags: ['verb'] }, + { front: 'pedir', back: 'to ask for — pido, pidió', level: 'A2', tags: ['verb'] }, + { front: 'dormir', back: 'to sleep — duermo, durmió', illustration: '1F634', level: 'A1', tags: ['verb'] }, ], }, { @@ -420,6 +544,11 @@ const SEED: Record = { { front: 'el horario', back: 'the timetable', illustration: '23F0', level: 'A2', tags: ['noun'] }, { front: 'perder el tren', back: 'to miss the train', illustration: '1F682', level: 'A2', tags: ['verb'] }, { front: 'hacer transbordo', back: 'to change lines partway', level: 'B1', tags: ['verb'] }, + { front: 'la vuelta', back: 'the change, and the return', level: 'A2', tags: ['noun'] }, + { front: 'perderse', back: 'to get lost', level: 'A2', tags: ['verb'] }, + { front: 'a la vuelta de la esquina', back: 'just around the corner', level: 'B1', tags: ['phrase'] }, + { front: '¿está lejos?', back: 'is it far?', level: 'A1', tags: ['phrase'] }, + { front: 'todo recto', back: 'straight ahead', level: 'A1', tags: ['phrase'] }, ], }, { @@ -436,6 +565,13 @@ const SEED: Record = { { front: 'me hace ilusión', back: 'I am looking forward to it', illustration: '1F929', level: 'B1', tags: ['phrase'] }, { front: 'estoy hecho polvo', back: 'I am shattered — literally, turned to dust', illustration: '1F62A', level: 'B1', tags: ['phrase'] }, { front: 'me da corte', back: 'it makes me self-conscious', illustration: '1FAE2', level: 'B1', tags: ['phrase'] }, + { front: 'tener ganas de', back: 'to feel like doing something', level: 'A2', tags: ['phrase'] }, + { front: 'estar harto', back: 'to be fed up', illustration: '1F624', level: 'B1', tags: ['phrase'] }, + { front: 'darle vergüenza', back: 'to be embarrassed — the shame gives itself to you', illustration: '1F971', level: 'B1', tags: ['phrase'] }, + { front: 'estar agobiado', back: 'to be overwhelmed', illustration: '1F630', level: 'B1', tags: ['adj'] }, + { front: 'qué rabia', back: 'how annoying', illustration: '1F620', level: 'B1', tags: ['phrase'] }, + { front: 'estar ilusionado', back: 'to be excited about something coming', illustration: '1F389', level: 'B1', tags: ['adj'] }, + { front: 'tener sueño', back: 'to be sleepy', illustration: '1F634', level: 'A1', tags: ['phrase'] }, ], }, { @@ -453,6 +589,9 @@ const SEED: Record = { { front: 'a pesar de', back: 'in spite of', level: 'B2', tags: ['phrase'] }, { front: 'no obstante', back: 'nevertheless', level: 'C1', tags: ['phrase'] }, { front: 'de ahí que', back: 'hence, and that is why', level: 'C1', tags: ['phrase'] }, + { front: 'en cambio', back: 'whereas, on the other hand', level: 'B1', tags: ['linking'] }, + { front: 'o sea', back: 'that is to say — and a filler everywhere', level: 'B1', tags: ['linking'] }, + { front: 'de hecho', back: 'in fact', level: 'B1', tags: ['linking'] }, ], }, ], @@ -468,6 +607,50 @@ const id = (deckId: string, index: number) => `${deckId}-${index}`; * Builds the starter decks and cards. Cards are staggered so the first session has * a realistic mix rather than everything arriving new at once. */ +/** + * One seeded deck, turned into records. + * + * Split out of buildSeed so a pack added from the catalogue months later is + * built by the same rule as one that shipped with the app — the alternative is + * two places that decide what a new deck's cards look like, which drift. + */ +export function buildDeck(language: LanguageCode, seedDeck: SeedDeck, now: number): { deck: Deck; cards: Card[] } { + const deck: Deck = { + id: seedDeck.id, + language, + name: seedDeck.name, + accent: seedDeck.accent, + reversed: seedDeck.reversed, + tags: seedDeck.tags, + createdAt: now, + }; + const cards: Card[] = seedDeck.cards.map((c, i) => { + // Roughly half of each deck starts as review cards already due, so the + // first session isn't a wall of brand-new words. + const seeded = i % 2 === 0 && i < 6; + return { + id: id(seedDeck.id, i), + deckId: seedDeck.id, + front: c.front, + back: c.back, + phonetic: c.phonetic, + illustration: c.illustration, + tags: c.tags, + level: c.level, + // The deck's answer, unless the card carries its own. + reversed: c.reversed ?? seedDeck.reversed, + createdAt: now, + state: seeded ? 'review' : 'new', + due: seeded ? now - (i + 1) * 60 * 60 * 1000 : now, + interval: seeded ? 1 + i : 0, + ease: START_EASE, + reps: seeded ? 1 + i : 0, + lapses: 0, + }; + }); + return { deck, cards }; +} + export function buildSeed(now: number = Date.now()): { decks: Deck[]; cards: Card[] } { const decks: Deck[] = []; const cards: Card[] = []; @@ -534,7 +717,7 @@ interface SeedNote { * Each is short on purpose. A rule you have to scroll is one you will not read * halfway through a review, which is the moment this exists for. */ -const SEED_NOTES: Record = { +export const SEED_NOTES: Record = { EN: [ { id: 'en-phrasal-split', diff --git a/src/shell/AppShell.tsx b/src/shell/AppShell.tsx index 4d57a65..d27b61b 100644 --- a/src/shell/AppShell.tsx +++ b/src/shell/AppShell.tsx @@ -215,6 +215,8 @@ export function AppShell() { * all, and the error went nowhere because the handler was async. */ const [naming, setNaming] = React.useState(false); + /* Flashcards and everything under it — a deck, a card, a session. */ + const onCards = location.pathname.startsWith('/app/cards') || location.pathname.startsWith('/app/review'); const [deckName, setDeckName] = React.useState(''); const newDeck = async () => { @@ -354,23 +356,10 @@ export function AppShell() {
-
+ {/* Just the label now. The plus that used to sit at its right has + moved to the top bar, where a phone can reach it too. */} +
Decks - {/* Stays a bare button rather than becoming an IconButton: this sits - in an 11px caps label row, and the smallest IconButton is a 28px - box — 44 under a finger — which would make the plus heavier than - the word it sits beside and push the deck list down. It borrows - the sound instead: `tap`, the default an IconButton here would - have had, because this is a press that goes and does something - rather than one that flips a state. */} -
@@ -390,7 +379,7 @@ export function AppShell() { })} {!visibleDecks.length && ( - {search ? 'No deck matches that.' : 'No decks yet. Add one with the plus above.'} + {search ? 'No deck matches that.' : 'No decks yet. Add one with the plus in the bar.'} )}
@@ -543,6 +532,29 @@ export function AppShell() { {title} + {/* + * New deck, in the bar rather than in the deck sidebar's header. + * + * It lived beside the word "Decks" in that sidebar, which a phone + * never shows — so the one way to write your own deck was reachable + * on a desktop and nowhere else, on an app whose whole point is the + * cards you write. The bar is on every screen and every size. + * + * Only inside Flashcards, though. It is an action on decks, and a + * button that makes one while you are reading an etymology is a + * control that has wandered away from what it acts on. + */} + {onCards && ( + + { playSound('tap'); setDeckName(''); setNaming(true); }} + > + + + + )} {/* Filled by whichever screen renders ; empty otherwise. */} {/* Parts the screen's own actions from the streak and the help menu. diff --git a/src/state/store.tsx b/src/state/store.tsx index 6226779..0f322a7 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -4,6 +4,7 @@ import type { Card, Deck, Direction, Grade, LanguageCode, Note, PracticeTool, Pr import * as db from '../data/db'; import { dueDirections } from '../data/scheduler'; import { WORKSPACES } from '../data/seed'; +import { PACKS, type Pack } from '../data/packs'; interface StoreValue { ready: boolean; @@ -50,6 +51,10 @@ interface StoreValue { points: db.Points; /** Buys one extension. False if the slots are full or the points are short. */ buyExtension: () => Promise; + /** Catalogue packs added in this workspace, by pack id. */ + installed: Set; + /** Adds a pack — its deck, its cards and its notes — then re-reads. */ + addPack: (pack: Pack) => Promise; grade: (card: Card, direction: Direction, grade: Grade) => Promise; /** Notes that a tool was used for its exercise today. Cheap to call on every answer. */ @@ -92,6 +97,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { const [notes, setNotes] = React.useState([]); const [streak, setStreak] = React.useState(0); const [points, setPoints] = React.useState({ earned: 0, spent: 0, balance: 0, held: 0, used: 0 }); + const [installed, setInstalled] = React.useState>(new Set()); const [weeklyReviews, setWeeklyReviews] = React.useState(() => Array(7).fill(0)); // `tick` exists only to re-run the due derivations as minute-scale cards come @@ -128,13 +134,14 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { * after the first one those days are covered. */ await db.settleExtensions(); - const [nextDecks, nextCards, nextNotes, nextStreak, nextWeek, nextPoints] = await Promise.all([ + const [nextDecks, nextCards, nextNotes, nextStreak, nextWeek, nextPoints, nextPacks] = await Promise.all([ db.listDecks(language), db.listCardsForLanguage(language), db.listNotes(language), db.computeStreak(), db.reviewsPerDay(), db.points(), + db.installedPacks(), ]); setDecks(nextDecks); setCards(nextCards); @@ -142,12 +149,27 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { setStreak(nextStreak); setWeeklyReviews(nextWeek); setPoints(nextPoints); + /* + * Added, or already here. + * + * The packs store only knows about choices made since the catalogue + * existed. Anyone using the app before it shipped has all twenty-two decks + * from the old seed and no pack records at all, so the catalogue would + * offer them everything they already own. A pack whose deck is on the shelf + * counts as added — which is a weaker claim than the store's, and exactly + * the right one for the case it covers. + */ + const here = new Set(nextDecks.map((d) => d.id)); + setInstalled(new Set([ + ...nextPacks.map((p) => p.id), + ...PACKS.filter((p) => here.has(p.deck)).map((p) => p.id), + ])); }, []); React.useEffect(() => { let cancelled = false; (async () => { - await db.ensureSeeded(); + await db.ensureSeeded(prefs.language); if (cancelled) return; // Its own gate, so a reader who already had decks still gets the notes. await db.ensureNotesSeeded(); @@ -327,6 +349,13 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { * already been missed — which is a walk over the same records rather than a * number to adjust. */ + /* A pack writes a deck, its cards and its notes, so everything the screens + read has moved — a full re-read rather than three local patches. */ + const addPack = React.useCallback(async (pack: Pack) => { + await db.installPack(pack); + await refresh(prefs.language); + }, [prefs.language, refresh]); + const buyExtension = React.useCallback(async () => { const done = await db.buyExtension(); if (done) await refresh(prefs.language); @@ -370,8 +399,9 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { const reset = React.useCallback(async () => { await db.resetAll(); - await db.ensureSeeded(); - await db.ensureNotesSeeded(); + // The workspace they are standing in, so a reset lands them where a fresh + // install would rather than back in Dutch. + await db.ensureSeeded(prefs.language); await refresh(prefs.language); }, [prefs.language, refresh]); @@ -415,6 +445,8 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { streak, points, buyExtension, + installed, + addPack, weeklyReviews, practise, grade, diff --git a/src/tools/conjugation/ConjugationDrill.tsx b/src/tools/conjugation/ConjugationDrill.tsx index 8a93688..0746e1f 100644 --- a/src/tools/conjugation/ConjugationDrill.tsx +++ b/src/tools/conjugation/ConjugationDrill.tsx @@ -4,6 +4,7 @@ import { Button, Card, Icon, Input, ProgressBar, Tabs, Tag, playSound, usePrefer import { useChrome } from '../../shell/chrome'; import { DOCK_HEIGHT } from '../../shell/Dock'; import { useStore } from '../../state/store'; +import { packsFor } from '../../data/packs'; import { EmptyTool } from '../EmptyTool'; import { HAS_CONJUGATION, cellName, groupsOf, loadConjugation, mark, @@ -51,11 +52,25 @@ function pick( language: Parameters[0], misses: Record, avoid: string | null, + focus: string[] = [], ): Question | null { const cells = data.cells.filter((c) => groups.has(cellName(language, c).group)); if (!cells.length) return null; - const words = Object.keys(data.words); + /* + * The verbs the packs you have added are about, where there are any. + * + * The table holds every verb Wiktionary had — thousands of them — and asking + * across all of it is right for someone with no packs and wrong for someone + * who added Separable verbs this morning. So a focus narrows the pool rather + * than reweighting it: the point of the pack is that it is *these* verbs. + * + * Filtered against the table rather than trusted, and dropped entirely if + * nothing survives — a focus list that matched no verb would leave the drill + * with nothing to ask, which is worse than asking broadly. + */ + const focused = focus.filter((w) => data.words[w]); + const words = focused.length ? focused : Object.keys(data.words); const pool: { word: string; cell: string; weight: number }[] = []; for (const word of words) { const table = data.words[word].c; @@ -90,7 +105,8 @@ function pick( options.push(siblings.splice(Math.floor(Math.random() * siblings.length), 1)[0]); } while (options.length < 4) { - const other = data.words[words[Math.floor(Math.random() * words.length)]].c[chosen.cell]; + const all = Object.keys(data.words); + const other = data.words[all[Math.floor(Math.random() * all.length)]].c[chosen.cell]; if (other && !options.includes(other)) options.push(other); else break; } @@ -325,7 +341,18 @@ function QuestionCard({ } export function ConjugationDrill() { - const { language, workspace, prefs, practise } = useStore(); + const { language, workspace, prefs, practise, installed } = useStore(); + + /* + * The verbs this workspace's added packs are about. + * + * Module-scope stable: it is a dependency of the picker, and a fresh array + * every render would be a new one on every keystroke of an answer. + */ + const focus = React.useMemo( + () => [...new Set(packsFor(language).filter((p) => installed.has(p.id)).flatMap((p) => p.verbs ?? []))], + [language, installed], + ); const [data, setData] = React.useState(null); const [state, setState] = React.useState<'loading' | 'ready' | 'unavailable'>('loading'); @@ -408,14 +435,14 @@ export function ConjugationDrill() { const next = React.useCallback((avoid: string | null = null) => { if (!data) return; - setQuestion(pick(data, groups, language, misses, avoid)); + setQuestion(pick(data, groups, language, misses, avoid, focus)); setTyped(''); setVerdict(null); }, [data, groups, language, misses]); // A new question whenever the scope changes, so the screen is never showing a // cell the reader has just switched off. React.useEffect(() => { - if (state === 'ready' && data && groups.size) setQuestion((q) => q ?? pick(data, groups, language, misses, null)); + if (state === 'ready' && data && groups.size) setQuestion((q) => q ?? pick(data, groups, language, misses, null, focus)); }, [state, data, groups, language, misses]); React.useEffect(() => { @@ -456,7 +483,7 @@ export function ConjugationDrill() { setOutgoing(null); setTyped(''); setVerdict(null); - if (data) setQuestion(pick(data, groups, language, misses, null)); + if (data) setQuestion(pick(data, groups, language, misses, null, focus)); }; /** Freeze what is on screen, then ask the next one — the two overlap. */ @@ -658,7 +685,7 @@ export function ConjugationDrill() { else if (!on) nextGroups.add(g.id); setGroups(nextGroups); save(GROUP_KEY, [...nextGroups]); - setQuestion(pick(data, nextGroups, language, misses, null)); + setQuestion(pick(data, nextGroups, language, misses, null, focus)); setTyped(''); setVerdict(null); setOutgoing(null); // From the top: a run half-answered in one set of tenses and // half in another is not a score of anything. diff --git a/src/tools/flashcards/DeckList.tsx b/src/tools/flashcards/DeckList.tsx index 2466278..9a33aa8 100644 --- a/src/tools/flashcards/DeckList.tsx +++ b/src/tools/flashcards/DeckList.tsx @@ -7,6 +7,8 @@ import { levelRange } from '../../data/types'; import type { CEFRLevel } from '../../data/types'; import { LevelFilter } from './LevelFilter'; import { EmptyTool } from '../EmptyTool'; +import { PackCatalogue } from './PackCatalogue'; +import { packsFor } from '../../data/packs'; import { isDue } from '../../data/scheduler'; const page: React.CSSProperties = { @@ -16,8 +18,12 @@ const page: React.CSSProperties = { }; export function DeckList() { + const [catalogue, setCatalogue] = React.useState(false); const isMobile = useIsMobile(); - const { decks, cardsInDeck, dueCount, workspace } = useStore(); + const { decks, cardsInDeck, dueCount, workspace, language, installed } = useStore(); + /* How many packs this workspace still has to offer. Both the button and the + empty state are about this number, so it is worked out once. */ + const remaining = packsFor(language).filter((p) => !installed.has(p.id)).length; const navigate = useNavigate(); const totalCards = decks.reduce((n, d) => n + cardsInDeck(d.id).length, 0); @@ -116,14 +122,39 @@ export function DeckList() {
)} + + {/* The way in to the catalogue, and only while there is something in + it to add. A door to an empty room teaches you to stop opening + it. */} + {remaining > 0 && ( +
+ +
+ )} + setCatalogue(false)} /> + {decks.length === 0 ? ( + +
+ } /> ) : ( // A guard rather than a state you can reach: a chip with no cards diff --git a/src/tools/flashcards/PackCatalogue.tsx b/src/tools/flashcards/PackCatalogue.tsx new file mode 100644 index 0000000..488b4c9 --- /dev/null +++ b/src/tools/flashcards/PackCatalogue.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { Badge, Button, Card, Dialog, Icon, playSound } from 'lingo-ds'; +import { useStore } from '../../state/store'; +import { packsFor, type Pack } from '../../data/packs'; +import { SEED, SEED_NOTES } from '../../data/seed'; +import { HAS_CONJUGATION } from '../../data/conjugation'; + +/** + * The catalogue: grammar themes you can add to this workspace. + * + * Called decks to the reader and packs in the code, deliberately. What arrives + * is a deck — the word the app already uses for a set of cards you practise — + * and inventing a second one for the thing that becomes a deck would make + * people learn a word for something they can already name. The code cannot + * borrow it: `Deck` is the record this installs, so `Pack` stays as the name + * for the offer rather than for the thing offered. + * + * Content used to be pushed. Every install wrote twenty-two decks across four + * languages whether or not the reader would ever open them, which left nothing + * to choose and no way to tell what you had picked from what you had been + * given. A fresh install now arrives with one pack and this is where the rest + * lives. + * + * It sits with the decks rather than in the rail: a pack becomes decks, and + * this is where decks are. It is also where somebody notices they want more to + * practise, which is the moment worth catching. + */ + +/** What a pack would add, counted from the material it names. */ +function contents(pack: Pack) { + const deck = SEED[pack.language].find((d) => d.id === pack.deck); + const notes = SEED_NOTES[pack.language].filter((n) => pack.notes.includes(n.id)); + return { + cards: deck?.cards.length ?? 0, + notes: notes.length, + /* Only where the language has tables to drill. A verb list in a workspace + with no conjugation data is a promise nothing can keep. */ + verbs: HAS_CONJUGATION[pack.language] ? pack.verbs?.length ?? 0 : 0, + noteTitles: notes.map((n) => n.title), + }; +} + +function Row({ pack, added, onAdd }: { pack: Pack; added: boolean; onAdd: () => void }) { + const [busy, setBusy] = React.useState(false); + const has = contents(pack); + + const add = async () => { + if (busy || added) return; + setBusy(true); + await onAdd(); + // The sound a thing arriving makes, not a celebration — the pack is the + // start of the work rather than the end of it. + playSound('toggle'); + setBusy(false); + }; + + return ( + +
+
+
+

+ {pack.name} +

+ {pack.starter && Starter} +
+ +

+ {pack.blurb} +

+ + {/* What arrives, named by the tool it arrives in — a count of "items" + would be true and useless. */} +
+ + {has.cards} cards + + {has.notes > 0 && ( + + {has.notes === 1 ? '1 note' : `${has.notes} notes`} + + )} + {has.verbs > 0 && ( + + {has.verbs} verbs to drill + + )} +
+ + {/* The rules by name. This is the part that makes it a grammar pack + rather than a word list, so it is stated rather than counted. */} + {has.noteTitles.length > 0 && ( +

+ {has.noteTitles.join(' · ')} +

+ )} +
+ +
+ {added ? ( + /* Not a disabled button. There is nothing to press, and a greyed + control invites the press anyway. */ + + Added + + ) : ( + + )} +
+
+
+ ); +} + +export function PackCatalogue({ open, onClose }: { open: boolean; onClose: () => void }) { + const { workspace, language, installed, addPack } = useStore(); + const packs = packsFor(language); + const left = packs.filter((p) => !installed.has(p.id)).length; + + return ( + + {/* The body is padded at the sides only — a footer would close it off and + there is none, so it closes its own box, as the FAQ dialog does. */} +
+ {packs.map((p) => ( + addPack(p)} /> + ))} +
+
+ ); +}