A Dad Built a Flashcard App to Replace the Chaos of After-School English Review
Background
Before my daughter started kindergarten, I told my wife very firmly: no academic pressure during kindergarten, just happy education. It turned out that saying it was easy. When she actually started school and we saw other classmates signing up, I couldn't resist and enrolled her in an English class with a foreign teacher.
The class itself wasn't a problem. What really gave me a headache was the after-class review. After each class, the teacher would send the day's learning content in the group chat, including vocabulary, sentence patterns, letter exercises, class videos, and homework. For example:
📆【Today's Learning Content】 LG1
————————————
👩🏫Teaching Objectives
I. Review Student book—《Classroom》
1. Key Vocabulary + Sentence Patterns
window 🪟
door 🚪
desk 桌子
chair 🪑
book 📖
II. Learn 《Super ABC》P12 Letter Cc
❤️Learn Letter Cc exercise
❤️Learn words starting with letter c
1️⃣cat 猫🐱
2️⃣cap 帽子🧢
3️⃣cake 蛋糕🍰
4️⃣car 汽车🚗
❤️Related sentence pattern learning
III. Pre-class Oral Dialogue
IV. Writing Practice
————————————
🌞【Homework】:
1️⃣Oral homework💬
Read aloud the words starting with letter c in 《Super ABC》P12
————————————
[Sun]【Today's Class Summary】
The kids were very active in class today 👏 Please listen to the audio more at home to practice! ❤️Please make sure all kids complete their homework 💕💕
Or:
📆【Today's Learning Content】
————————————
👩🏫Teaching Objectives
1. Free Talk: Pre-class Q&A
2. Review content learned for Letter A
3. ❤️Learn content related to Letter B
— Learn Letter B exercise
— Initial recognition of the shape of Letter B
— Learn key words starting with letter b
1️⃣bee 蜜蜂🐝
2️⃣ball 球
3️⃣balloon 气球🎈
4️⃣bear 熊🐻
Extended vocabulary: box 📦 bike 🚴
— Sentence pattern learning: B is for __
— Sentence pattern comprehension input:
1. I see a bee.
2. I have a ball.
3. The balloon is big.
4. The bear is brown.
4. Writing Practice
————————————
🌞【Homework】:
Oral homework 💬
Listen to the audio and read aloud 《Super ABC book》P8
————————————
[Sun]【Today's Class Summary】
The kids were very active in class today 👏 Please listen to the audio more at home to practice! ❤️Please make sure all kids complete their homework 💕💕
The teacher's notes were actually quite detailed, but they were more like a "class record" rather than a product suitable for a 4-year-old to practice with repeatedly.
My initial review method was very basic: find the video the teacher sent, open it, drag it back to the part that needed reviewing, play it, pause, repeat after it, and continue.
It was fine once or twice, but after doing it many times, I found it very inefficient.
A child's attention span is short to begin with. While the parent is still scrolling through the group chat looking for the video and finding the progress bar, the child has already run off to play something else. Sometimes, just wanting to review the four words cat, cap, cake, car meant replaying the entire class video.
Later, I tried using apps like Oulu Dictionary or Baicizhan to input new words, hoping they would auto-play them in a loop.
Baicizhan's custom word bank had too much unfocused content during learning.
Oulu Dictionary either only had text, or if it had videos, the content was too complex.
These apps are all great on their own, but in my scenario, they always fell a bit short:
- Some classroom words didn't have suitable illustrations;
- Some explanations were written for adults and were too complex for young children;
- Dictionaries focused on phonetics, parts of speech, and definitions; I just wanted my child to see a clear picture;
- The learning path was pre-designed by the product and hard to perfectly match what the teacher taught that day;
- There were too many interactive elements on the page, and the child could easily tap on things they didn't need to.
Ultimately, what I needed wasn't a massive English curriculum.
I just wanted to organize the few words and sentences the teacher taught that day into a set of review cards that could be seen, heard, and auto-played.
This need was so small that in the past, I probably wouldn't have written an app specifically for it.
But the AI era brings a very interesting change: previously, this kind of need that "only serves one family" had too low a return on investment; now I can use AI to quickly turn an idea into a prototype, and then use my frontend experience to slowly refine it into a truly handy tool.
So, after previously making an English learning app for myself, I made a second version.
This time, it wasn't written for myself, but for my daughter.
Requirements
I didn't start by wanting to build check-ins, points, leaderboards, or a children's version of Baicizhan.
I first minimized the requirements.
Although the content the teacher sent each time was long, the core that truly needed repeated review was only two types:
Words + Sentences
Content like "Teaching Objectives," "Class Summary," "Writing Practice," and "Please make sure all kids complete their homework" is useful for parents but shouldn't appear on a child's review cards.
Therefore, the core workflow of this app was organized by me as:
The teacher sends the class content
↓
Extract English words and sentences
↓
Supplement Chinese, Emoji, or images
↓
Play and read aloud card by card
↓
Record the day's review progress
On top of this core workflow, I added a few requirements that were unavoidable in actual use.
1. Content must be organized by class date
The child learns different content in each class. Today is Letter B, the next might be Letter C, and the one after that might switch to fruits.
So the content can't be mixed into an ever-growing word list; it needs to create "review days" by date. After switching dates, continue from the last review progress.
2. One card does one thing
Each card only displays one letter, word, or sentence, keeping the English, Chinese, picture, and pronunciation.
The child doesn't need to understand complex operations; they just look at the current content, listen to the pronunciation, and tap "next."
3. It should play as soon as it opens, and ideally auto-play
During review, parents shouldn't have to click play for every single word.
The page automatically reads aloud when switching to a new card, and can also enable learning mode: read the current content, pause for a few seconds, then automatically jump to the next card.
4. Images should be simple, intuitive, and replaceable
For a young child, bear corresponding to a picture of a bear is much more effective than a long Chinese explanation.
The system's built-in Emoji is the most effortless solution: no downloading, no storage needed, and fast loading. cat uses 🐱, bee uses 🐝, chair uses 🪑; most common words can be found directly.
But Emoji can't cover everything. Some words have no corresponding icon, and some icons aren't accurate enough, so it also needs to support online image search and local uploads.
5. Different devices at home should see the same content
If the data is only stored in the browser's localStorage, it's gone when switching to another phone. Content recorded by dad can't be seen on mom's phone.
Therefore, it ultimately couldn't just be a purely static page; it also needed a very lightweight cloud storage.
Implementation
The tech stack wasn't deliberately novel; it's still the set I'm most familiar with:
Vue 3
Vite
TypeScript
Element Plus
Supabase
GitHub Pages
For this kind of personal tool, the most important thing in tech selection isn't "advanced," but whether I can modify it quickly and dare to keep using it after modifications.
1. Separating the teacher's original text from the generated content
When adding a review day, I kept two copies of the content:
teacherText: The complete original class text sent by the teacher, only used as a basis for later verification;generatedContent: The words and sentences actually used to generate the cards.
This step is very important.
If the entire group message was directly used for rendering, things like LG1, Super ABC, teaching objectives, and homework would all be stuffed into the question bank. Rather than pursuing a "100% understanding of all teacher copy" universal parser, it's better to let AI first organize the core content, and then give parents an input box for manual confirmation.
For example, for the Letter B class, the content finally entering the app could be simplified to:
Words
bee 蜜蜂🐝
ball 球
balloon 气球🎈
bear 熊🐻
box 盒子📦
bike 自行车🚲
Sentences
B is for __
I see a bee.
I have a ball.
The balloon is big.
The bear is brown.
The app parses line by line, automatically determines if it's a word or a sentence, and removes duplicates:
export function parseReviewText(rawText: string) {
let currentCategory: ReviewItemCategory | undefined;
const parsed = rawText.split(/\r?\n/).flatMap((line) => {
const heading = cleanLine(line).replace(/[::]$/g, "").trim();
if (/^(words?|单词)$/i.test(heading)) {
currentCategory = "word";
return [];
}
if (/^(sentences?|句子)$/i.test(heading)) {
currentCategory = "sentence";
return [];
}
return parseReviewLine(line).map((item) => ({
...item,
category: currentCategory || item.category
}));
});
return assignContextualEmojis(dedupeReviewItems(parsed));
}
If you just want to get words from the teacher's original text, you can also directly extract all English words, deduplicate, and then edit:
function extractEnglishWords(sourceText: string) {
const matches = sourceText.match(/[A-Za-z]+(?:['’-][A-Za-z]+)*/g) || [];
const seenWords = new Set<string>();
return matches.reduce<string[]>((words, match) => {
const normalizedWord = match.toLocaleLowerCase();
if (!normalizedWord || seenWords.has(normalizedWord)) return words;
seenWords.add(normalizedWord);
words.push(normalizedWord);
return words;
}, []);
}
Here, AI wasn't stuffed into every runtime flow. It's more of an assistant during the development and organization phase: helping me converge the teacher's long text into structured content; when actually playing, the app still uses simple, stable, manually editable data.
2. Auto-completing Chinese translation, Emoji, and sentences
Parents can just input English.
The app will first try the browser's built-in translation capability, and if not supported, call an online translation service, caching the result locally to avoid repeated requests for the same word.
The Emoji strategy is also kept as simple as possible:
- If the teacher's original text already has an Emoji, keep it first;
- If the local mapping table has a corresponding word, match it directly;
- If a sentence contains a word that has already appeared, reuse that word's Emoji;
- If it really can't be found, display a generic placeholder icon.
For example:
function buildReviewItem(english: string, chinese = "") {
const explicitEmoji = extractReviewEmoji(chinese);
return {
id: createReviewId("item"),
english,
chinese,
category: inferCategory(english),
emoji: explicitEmoji || pickEmoji(english, chinese)
};
}
Why use system Emoji first, instead of searching for images online for every word from the start?
Because "reliable display" is more important than "particularly beautiful images."
Emoji is unaffected by image hotlinking protection, API rate limits, and network fluctuations. For early childhood vocabulary like apple, cat, car, it's already clear enough.
3. When Emoji isn't enough, search for images online
When encountering content where Emoji isn't accurate enough, an image picker can be opened directly on the card.
The app integrates Openverse and Wikimedia Commons; entering an English keyword returns several candidate images. If the Openverse request fails, it automatically tries Wikimedia.
async function fetchReferenceImages(item, cursor, source) {
if (source === "openverse") {
try {
return await fetchOpenverseReferenceImages(item.english, cursor);
} catch {
return await fetchCommonsReferenceImages(item.english, cursor);
}
}
return fetchCommonsReferenceImages(item.english, cursor);
}
If the open image libraries still can't find a suitable image, you can also jump to external sources like Baidu Images or Sogou Images to search, and copy the image URL back.
If that still doesn't work, upload locally.
Local images aren't uploaded as-is. The page first uses Canvas to compress the longest side to 720px, then converts to WebP, controlling the size before uploading to Supabase Storage:
const maxDimension = 720;
const scale = Math.min(
1,
maxDimension / Math.max(image.naturalWidth, image.naturalHeight)
);
context.drawImage(image, 0, 0, width, height);
const result = canvas.toDataURL("image/webp", 0.72);
This image strategy is essentially a three-tier fallback:
System Emoji
↓ Not suitable
Openverse / Wikimedia online image search
↓ Still not suitable
Local upload to Supabase Storage
It doesn't pursue perfection in one go, but ensures parents always have a way to swap in an image the child can understand.
4. Pronunciation doesn't require a separate paid service; use browser capabilities first
English reading uses the browser's built-in speechSynthesis.
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "en-US";
utterance.pitch = 1.08;
utterance.rate = 0.78;
window.speechSynthesis.speak(utterance);
I set the speech rate slightly slower than the default, making it more suitable for children to repeat after.
To prevent some browsers from not properly triggering the end event, a fallback timer was also added. This way, the auto-learning mode won't get stuck on a single card forever.
This solution's voice might not be as natural as professional foreign teacher audio, but it's free, simple to call, and sufficient for "repeatedly listening to a few words of the day."
5. Local first, then sync to cloud
Review content and learning progress are first written to localStorage.
This is because the biggest fear for a home learning tool is the entire page becoming unusable if the network stutters. Local caching at least ensures that already entered content can still be reviewed.
Cloud sync then handles multi-device sharing.
I didn't put Supabase's service role key on the frontend, but instead routed all requests through Supabase Edge Functions:
Vue Page
↓
Supabase Edge Function
├── PostgreSQL: Save review content and version numbers
└── Storage: Save locally uploaded reference images
Reading can be done publicly, but writing requires entering a family edit code.
At the same time, each piece of cloud data has a revision. When saving, the frontend sends the version number it read; if the cloud has already been modified by another device, the API returns 409, prompting to sync the latest content first, preventing a later save from directly overwriting a previous modification.
For a small tool used by only a few people at home, this permission setup isn't complex, but it's just enough.
Deployment Pitfalls
Initially, I wanted to put both the frontend and the data interface on Cloudflare.
Frontend on Cloudflare Pages, data in D1, and providing read/save interfaces through Pages Functions. The plan looked very complete, and all had free tiers.
I had even already connected the table structure, D1 binding, and REVIEW_EDIT_CODE.
But during actual debugging, the problem wasn't that the code couldn't run, but that the services were often inaccessible. The deployed pages, interfaces, and console were all unstable in my network environment. Debugging a small issue required repeatedly confirming whether it was a code error or the network wasn't connected.
What drains people the most in personal projects is often not difficult problems, but this kind of uncertainty.
In the end, I didn't continue to force it and directly migrated the data layer to Supabase:
- D1 replaced with PostgreSQL;
- Pages Functions replaced with Supabase Edge Functions;
- Reference images placed in Supabase Storage;
- RLS enabled on data tables, frontend cannot access directly;
- Write operations continue to use the family edit code;
- Keep
revisionas an optimistic lock to prevent multi-device overwrites.
The frontend itself is still a Vite static build, so there's no need to bind it with the data service. I handed the pages to GitHub Pages, using GitHub Actions to automatically build on every push to main:
- name: Build
run: pnpm build:github
env:
VITE_REVIEW_SYNC_URL: ${{ vars.VITE_REVIEW_SYNC_URL }}
- name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4
There's another easily missed point here: GitHub Pages deploys under a repository sub-path, so Vite's base needs to correspond to the repository name.
const base =
process.env.VITE_DEPLOY_TARGET === "github-pages"
? "/vancy-learn-english/"
: "/";
The final architecture became:
GitHub Pages: Deploy Vue static pages
Supabase PostgreSQL: Save review content
Supabase Storage: Save uploaded images
Supabase Edge Function: Unified read/write entry point
After separating the pages and data, it actually became clearer.
It should be noted that services like GitHub, Supabase, Openverse, and Wikimedia may all have access fluctuations in domestic network environments. When initializing the project, deploying functions, and troubleshooting interfaces, I needed to prepare a stable network environment.
This is also why the app always retains localStorage: when cloud sync fails, the local cache can still be used. A day's review shouldn't be delayed just because a free service is temporarily unreachable.
Results
The final product turned out simpler than I initially thought.
Overall Effect
Settings
Converting Content
Setting Custom Images
Learning Mode
After opening the page, the child sees a large card for the current review day. In the middle is an Emoji or reference image, with English and Chinese below. The card auto-reads aloud when switching, and can also enter learning mode to auto-play at set intervals.
Parent-side operations are concentrated in "Review Settings":
- Switch the day's learning content by date;
- Paste the teacher's original text, extract words, and organize sentences;
- Add, edit, and delete review days;
- Adjust the auto-play interval;
- Replace Emoji or reference images for unsuitable words;
- Sync cloud content for other devices at home to use directly.
Before, reviewing Letter B meant first scrolling through the group chat, finding the video, dragging the progress bar, and pausing one by one.
Now, just open the day's review day:
bee → ball → balloon → bear
After the words are played, continue listening to:
I see a bee.
I have a ball.
The balloon is big.
The bear is brown.
It has no complex learning system, nor an addictive points design.
But it turned a review session that was originally a bit taxing for parents into a process the child is willing to tap through a few times on their own.
That's enough for me.
Summary
After finishing this app, I increasingly feel that good software isn't necessarily the software with the most features.
For commercial products, we care about user scale, growth, and universality; but for personal products, value can be very specific: it just needs to save the family ten minutes of repetitive, inefficient, and easily frustrating operations every day, and it already has meaning for existing.
What really solved the problem this time wasn't any single technical point among Vue, Supabase, or AI.
It was first clearly articulating the pain point:
I don't need another English curriculum.
I just need to turn the words and sentences the teacher taught today into cards my child is willing to listen to repeatedly.
Once the requirements converge, the subsequent implementation naturally becomes simple.
AI lowered the cost of "making software that only serves one's own family," and frontend experience allowed me to continue refining this prototype into something handy, stable, and usable long-term.
Project URL: github.com/mjsong07/vancy-learn-english
Live URL: mjsong07.github.io/vancy-learn-english
"Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away." — Antoine de Saint-Exupéry
Perfection is not when there is nothing more to add, but when there is nothing left to take away.
Top 4 from juejin.cn, machine-translated. The original thread is authoritative.
King of the grind
Uncle, I want one too. I'm preparing for CET-4 and CET-6 and I'm missing one.
Way too intense
Isn't this app a bit lacking in fun for kindergarten kids? Wouldn't watching English cartoons be more effective?