Mi Español
I have been learning Spanish for about two years. I live in Alicante, my wife is Spanish, and I have a tutor called Alberto who I speak to three mornings a week. I am somewhere in B2, which is the level everyone warns you about.
The problem with B2 is that it is wide. A1 to B1 is a staircase — every week you can do something you flatly could not do the week before. B2 is a corridor. You put in a year and you can hold roughly the same conversations you could hold at the start of it, only with fewer mistakes that nobody was going to correct anyway. The work is real and the feedback is gone. That is the point where people quit, and the honest reason I built this is that I could feel myself getting bored.
So the brief was: show me that the work is compounding, using evidence I have already generated rather than a new habit I have to maintain. And then, if that works, tell me what to actually do on Monday morning.
Where the evidence lives
The material existed. It was just scattered across five services that have no interest in talking to each other, and one that no longer exists in the form the internet says it does.
- Notion — a table of every piece of homework I have done, going back to my first lesson.
- Google Calendar — every lesson, back through two previous tutors and a platform I no longer use.
- Claude — a project where I write Spanish and get it corrected, which turned out to be the densest source of all.
- Granola — my lesson calls, as of about a week ago.
- Readwise/Kindle — my highlights from the Spanish books I have read.
Plus Spotify, for podcasts, which I will come back to because it is the one that beat me.
*Fig. 1 — Six sources, four ingest routes, one table that every page reads*The shape that made this tractable is the box in the middle. Each source keeps its own raw table, because raw data is cheap and re-deriving it is not. Then everything gets flattened into one activity_log — a day, a source, a kind, a number of minutes, a skill — and every page in the app reads that and nothing else. No page has ever made an API call to Notion. When I change my mind about how something should count, I change one function and rebuild 654 rows in about a second.
Getting the data out
Each source had exactly one gotcha, and in every case the gotcha was not the one the documentation prepares you for.
Notion has quietly rebuilt its data model. Every tutorial, and every model trained on those tutorials, will tell you to POST /v1/databases/{id}/query. That endpoint is dead on any current API version. Databases are now containers for data sources, you query /v1/data_sources/{id}/query, and the official SDK no longer supports pinning back to the old version. The second gotcha is more insidious: a new integration has access to nothing until you explicitly share a page with it, and an unshared page returns 404, not 403. It looks exactly like a wrong ID, so you go and check your ID, which is fine, so you check it again.
Google Calendar has a trap I did know to avoid. The obvious route is OAuth with a refresh token, and if your consent screen is left in "Testing" — which it will be, because you are one person and there is nothing to publish — Google expires that refresh token after seven days. You would find out on day eight. The better route for a single-user job is a service account: create one, share your calendar with its robot email address, and the credentials never expire. One catch, which is the single most common reason this silently returns nothing: you must query calendarId as your actual email address. primary resolves to the service account's own empty calendar.
Claude has no read API for your own conversations, which surprised me. The Messages API is send-only and knows nothing about your account. There is an official API that reads chats and projects — the Compliance API — but it is Enterprise-gated and owner-only, so as an individual the only supported route is Settings → Privacy → Export data, which emails you a ZIP.
Then the actual surprise, which cost me an hour. My export contained a projects/ folder with a project called "Spanish" in it, and conversations.json with 1,322 conversations, and not one conversation carries a project link. The relationship simply is not in the file. So the filter had to become linguistic rather than structural: score each conversation on the ratio of Spanish stopwords to English ones across all its messages, and keep anything above 0.35 or with Spanish in the title. That found 143 conversations, 4,545 messages, and 72,282 words of my own Spanish — the largest single body of evidence in the project, and the one I nearly missed because the obvious join key does not exist.
Granola I moved to last week, replacing a Fireflies setup that never recorded a call. Almost everything written about getting data out of Granola is now wrong: the standard trick was reading cache-v3.json out of Application Support, and Granola encrypted that cache in April. The tools that did it are archived, their READMEs now pointing at the official MCP server. There is a proper public API — public-api.granola.ai, Business plan, webhooks on note.generated — and it hands you a transcript where every segment is tagged me or them off the audio channel rather than by voice fingerprinting, which for a two-person call is better than diarisation.
What it does not hand you is analytics. Fireflies gives you talk-time and words-per-minute as fields; Granola gives you nothing of the sort. But every segment has a start time, an end time and its speaker, so the numbers are twenty lines of arithmetic, and doing it myself meant I could count Spanish filler words — este, o sea, pues, vale, bueno — which no meeting-notes company was ever going to do for me.
Kindle was the one that was easier than expected. I had assumed reading was untrackable without me logging it, but my Readwise highlights each carry a Kindle location, and the furthest location I highlighted in a book is a decent proxy for how far I got. One location is roughly 18 words; a learner reads Spanish at maybe 125 words a minute. El Alquimista comes out at 39,000 words, which is the whole book, and it is a book I finished. Harry Potter y la Cámara Secreta comes out at 51,000, which is about half, and I am about half way through it. Nine books, about 49 hours, from a file I already had.
Spotify beat me. I listen to a lot of Spanish podcasts and the Web API cannot help: /me/player/recently-played returns music only, excludes podcast episodes, and caps at 50 items. It got worse in February when several podcast endpoints were removed outright. The only route is the privacy export, which takes weeks to arrive. I have requested it. Listening currently reads 0.5 hours, which is the most obviously wrong number on the dashboard.
Things that broke
The 25-hour Tuesday. The first version of the weekly chart had a bar four times taller than any other. That week was June 2026, and the day was the 17th, on which I had apparently done twenty-five hours of Spanish homework. What actually happened is that I migrated fifty pages into Notion in one sitting. The table has no date property, so I had fallen back to created_time, which for a bulk import is the date of the import and not the date of the work. Bulk days — eight or more items created at once — now get spread evenly across the year leading up to the import. That is a guess, but a guess distributed across a year is less wrong than a spike that says I did a full working week of homework in a single Tuesday.
Lessons I had forgotten I did. The first calendar sync found 62 completed lessons and I said, on instinct, that it felt low. It was. The matcher was looking for the word "Alberto", and my lessons have been booked under four different naming conventions: 50 as "Preply lesson - Alberto C." from August 2024, 42 with a tutor billing as "ELE con Pancito", 28 titled nothing more useful than "Clase - 55 minutos", and 50 as "Alberto Class" once we went direct. Widening the pattern took it to 170 lesson events going back to 26 August 2024. Worth saying plainly: the only reason this got fixed is that I looked at a number and thought that isn't right. Nothing in the system was going to tell me it had missed a tutor whose name it had never been given.
Intent versus work. I have a recurring "Spanish Block" in my calendar, 137 of them. Counting those as study time would have added 137 hours of work I definitely did not all do. They now count as thirty minutes each rather than sixty, split evenly between writing and comprehension. The calendar records what I meant to do; the discount is my honest estimate of the gap.
The number that lies. The streak is fake. It reads 100 and it goes up by one every Monday whether or not I have opened a book, because I asked for exactly that. My reasoning was that I do always do something, so a streak that resets on a technicality would be demotivating and wrong. Having lived with it for a day, I think it is the weakest thing on the page. Every other number is derived from evidence and can be argued with; that one is a counter I told the computer to increment. I have left it there, and written this paragraph instead, which is the honest version of a fudge: keep it if you like, but do not let it sit next to real numbers without a label.
The overview: level, hours, this week's focus, and two years of weekly bars
Part two: telling me what to do
Everything above is a scoreboard. It answers "am I doing the work", which was the motivation problem, but it never reads a single word I have written. That is the more interesting half.
The corpus turned out to be extraordinarily well suited to it, because two years of my homework is essentially a corrections archive. Alberto's format is Original then Corregido; my Claude sessions produce ❌/✅ pairs; the translation drills are Tu versión then Corrección. Every one of those is a labelled example of a mistake I actually make, with its fix attached, dated.
Doing it on a subscription
The obvious build is a nightly job with an Anthropic API key. I did not want that — not because of the cost, which is small, but because I am already paying for Claude and this is a hobby project.
So the analysis brain lives outside the app. The app exposes three routes and holds no intelligence at all:
GET /api/analysis/pending— the queue. Everything not yet analysed, or whose content hash has changed since it was.POST /api/analysis/results— corrections, vocabulary and topics, upserted.POST /api/analysis/weekly— the week's plan.
A Claude Code session pulls a batch, reads it, and posts back what it found. There is a /coach skill in the repo so a future session behaves like this one, and the extraction rules live in a markdown file rather than in a prompt string in a route handler. Idempotence is a SHA of the item's text: re-analysing is free and re-running the whole thing is safe, which matters when the process is a person typing a slash command rather than a cron.
The trade is real and worth naming: this does not run while I sleep. It runs when I run it, roughly weekly. For a personal dashboard that is a fair price for not standing up a second billing relationship, and if I change my mind the endpoints do not care who calls them — an API-keyed cron would drop straight in.
Rules that make the output usable
Two constraints did most of the work.
The first: only real corrections count. The model is not permitted to find errors in my Spanish — it is permitted to extract pairs where a correction visibly exists in the source. Everything on the page was marked wrong by Alberto, or by a tutor, or by Claude at the time. This is the same rule I ended up with in Dupe Detective and for the same reason. An LLM asked to find mistakes in B2 Spanish will always find some, and a list that mixes real corrections with invented ones is worth less than a shorter list I can trust.
The second: topics come from a fixed list. There is a syllabus of forty grammar topics from B1 to C1 in a TypeScript file, and extraction can only tag from that list. Free-form topics would produce "subjunctive", "el subjuntivo", "subjunctive mood" and "moods" as four separate things, and then nothing is countable. With a closed list I can ask what I have never covered, which is the question worth asking.
The vocabulary bank: 349 words, each tagged as one I use or one I have only met
Vocabulary has three states rather than a flat list. Used means I produced it correctly in something I wrote. Met means I was corrected to it, or highlighted it in a book, or it appeared on a flashcard — I have seen it and not yet said it. Target means it has been suggested and I have not encountered it at all. It is currently 135 used against 214 met, and the interesting number is not the total but the gap: 214 words I recognise and do not produce is the most concrete description of a plateau I have.
What it found
Ninety-four items in so far — the backlog drains a batch at a time — for 97 corrections and 349 words. The error table ranks: word choice 17, prepositions 15, agreement 14, subjunctive 11.
The prepositions were expected. They cluster hard before que: me di cuenta había demasiado reuniones → me di cuenta **de que** había demasiadas reuniones. Verb-preposition regimes are memorisation and I have not done it.
The word-choice cluster was not what I assumed. I expected clumsy synonyms; it is mostly false friends, which is the same disease as my calcos wearing a different coat — la costa for el coste, contento for contenido, reservar un mecánico for buscar.
The finding that justified the whole exercise is the subjunctive. It is my most-practised topic — twenty separate homework sessions, more than any other — and simultaneously my fourth-most-common error. In drills I get it right essentially every time. In free writing it goes: una clase que se ajusta a mi horario where the antecedent is indefinite and it has to be se ajuste; ojalá que podré instead of pueda. That gap between exercise and production has a name — it is fossilisation — and the treatment is not more exercises, which is exactly what two years of instinct had me doing. That is a conclusion I could not have reached by feel, and it took reading two years of my own homework at once to see it.
The focus page: three areas, each with real sentences of mine as evidence, and a line to paste to Alberto
The weekly plan is generated from those aggregates: three focus areas, each quoting sentences I actually wrote, two exercises sized for a thirty-minute block, twelve words drawn from my own books and corrections rather than a generic B2 list, and one sentence in Spanish to send to Alberto. That last field is the smallest and possibly the most useful — the dashboard cannot teach me anything, but it can tell the man who can what to go after this week.
The whole interface is in Spanish, which was the last change I made and the one I should have made first. A dashboard about immersion that greets you in English is not a serious object.
Where it is now
It has been live for a day. 323.5 hours, 170 lessons on the calendar back to August 2024 — the counter reads 197 because it carries a flat baseline for the Preply-era bookings that were made on Preply and never hit my calendar — 179 homework items, 143 Claude conversations, nine books, 33 of 40 syllabus topics touched. Writing leads on 125 hours, speaking 89, reading 49. Granola is wired up and waiting for tomorrow's lesson to produce its first transcript. Spotify is in the post.
The thing I did not expect is that the aggregation was the easy half. Getting six services into one Postgres table is a week of API archaeology and it is basically solved once it is solved. The half with the actual value in it was the corpus I had been generating for two years and never once read back — and the only reason it was worth reading is that the corrections in it were made by people, not by the thing doing the reading.