Most writing apps are built to keep you inside them. I wanted one that lets you go. 1k.gg is a single page where you write 1,000 characters a day. When you reach the thousandth one, a quiet note appears: Today's 1k is written. One thousand characters. The rest of the day is yours. No confetti and no particle layer. You can keep writing if you like, but the day is done.
That small idea turned out to need some careful engineering. Here is how it works, taken straight from the source.
Why a writing app should end
A goal that never ends isn't really a goal. A fixed daily target makes starting cheap: you know how much is "enough" before you write the first word. So the app has one constant, GOAL = 1000, and one moment that matters, when the count crosses it. Completion is recorded once (completedAt) and stays recorded. If you delete a sentence afterwards, the day doesn't become unfinished.
That puts all the pressure on one function. If the count is wrong or easy to game, the full stop means nothing.
Counting correctly
"Characters" sounds simple until you remember what string.length actually returns: UTF-16 code units. 'π'.length is 2, a flag is 4, and 'cafe\u0301' (an e plus a combining accent) is 5. For a writer, each of those should count as what they see on screen.
There's a second problem: whitespace. If spaces count, someone can hold down the space bar and "finish". If spaces don't count, ordinary prose is penalised, because the spaces between words are part of writing. The rule I settled on:
- every user-perceived character counts: letters, digits, punctuation, emoji, CJK ideographs, and the spaces between words;
- a run of whitespace (spaces, tabs, line breaks) counts as one character;
- leading and trailing whitespace counts nothing.
Honestly, I didn't get there in one go. The git history has a commit that made every whitespace character count separately, followed about an hour later by a revert to "runs count once". The revert message gives the reasons: it matched the copy on the site, and it kept the drawing of the page (more on that below) consistent.
Here is the counter, slightly trimmed:
const segmenter = (() => {
try {
return Intl.Segmenter
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null;
} catch { return null; }
})();
const PRINTABLE_ASCII = /^[\x21-\x7e]*$/;
export function graphemeCount(s: string): number {
if (PRINTABLE_ASCII.test(s)) return s.length; // fast path
if (segmenter) { let n = 0; for (const _ of segmenter.segment(s)) n++; return n; }
return [...s].length; // fallback: code points
}
export function charRuns(text: string): number[] {
if (!text) return [];
const out: number[] = [];
for (const run of text.split(/\s+/u)) if (run) out.push(graphemeCount(run));
return out;
}
export function countChars(text: string): number {
const runs = charRuns(text);
let n = runs.length ? runs.length - 1 : 0; // one counted space between runs
for (const r of runs) n += r;
return n;
}
A few details worth pointing out:
-
Grapheme clusters come from
Intl.Segmenter. Where it's missing, the code falls back to code points ([...s]), which still handles astral characters like π, though not ZWJ sequences. -
The ASCII fast path. Most runs in English text are plain printable ASCII, where one code unit is one character, so the code skips the segmenter for them. This matters because the count runs on every
inputevent. -
Splitting on
/\s+/ugives you the whitespace rule for free. Empty strings at the edges are dropped, so leading and trailing whitespace disappears. Each gap between runs adds exactly one.
That gives a useful invariant: countChars(t) === sum(charRuns(t)) + max(0, runs - 1). The unit tests check it, along with the cases I worried about most:
expect(countChars('cafe\u0301')).toBe(4); // e + combining acute
expect(countChars('π©βπ»')).toBe(1); // ZWJ sequence
expect(countChars('πΈπ¦')).toBe(1); // flag
expect(countChars('a' + ' '.repeat(500) + 'b')).toBe(3);
expect(countChars(' \n\n ')).toBe(0); // can't pad your way to 1,000
Local-first: no account, and export
The writing never leaves the device. Storage sits behind a small Store interface with three implementations. It prefers IndexedDB (one object store, entries, keyed by day). If that fails, as it does in some private browsing modes, it falls back to localStorage. If that's blocked too, it uses an in-memory map, and the Data page tells you plainly: "memory only (this browser blocks storage; export before closing)".
export async function openStore(name: string): Promise<Store> {
try {
if (typeof indexedDB !== 'undefined') return await openIdb(name);
} catch { /* fall through */ }
try {
localStorage.setItem('1k:probe', '1'); localStorage.removeItem('1k:probe');
return kvStore(name, localStorage);
} catch { return kvStore(name, null); } // memory only
}
Writes are debounced by 400 ms. The pending save is flushed when the tab is hidden or closing, and saved immediately at the moment you cross 1,000. An end-to-end test types 600 characters, waits, and asserts that zero network requests were made. The Content-Security-Policy (default-src 'self'; connect-src 'self') backs that up at the browser level.
With no account, export is the backup plan, so there are three formats:
- Markdown: every page, oldest first, each with its date, count and a "done" marker;
-
JSON: a versioned backup (
format: '1k.gg/backup',version: 2) that can be imported again; - TXT: today's page exactly as written.
Import never trusts the numbers in the file. parseBackup validates each entry, rejects oversized pages, and recomputes every count with countChars. That's how older word-count backups still import cleanly after the switch to characters. When the same day appears in both the backup and your current data, the merge keeps the copy edited most recently, and completion stays sticky.
The share card that never shows your words
People want to share that they finished. I didn't want them to share what they wrote, and I didn't want a share button that leaks a private journal by accident.
The answer is "the Thousand": today's page drawn as strokes. Each run of non-space characters becomes one rounded stroke, as long as the run, and each gap is the one counted space. Because of the invariant above, ink plus gaps adds up exactly to the day's count, and a vermilion dot lands right after the stroke holding the 1,000th character. Anything written after that is drawn fainter. The layout is a pure function of number[]. It never receives the text.
The share card paints that layout onto a 1080Γ1080 canvas, adds the line "my 1k of the day", the date and the character count, and exports a PNG. The drawing function only needs a small subset of CanvasRenderingContext2D, so I can unit-test it with a recording fake and assert that no word from the page ever reaches fillText:
drawShareCard(r.ctx, { runs: charRuns(page), chars: 1049, dateLabel: 'Tuesday 13 October' });
expect(all).toContain('my 1k of the day');
expect(all.join(' ')).not.toMatch(/w\drd/); // the writing itself is never drawn
Sharing goes through the Web Share API when the browser can share files, and falls back to a download otherwise:
if (typeof File === 'function' && typeof nav.share === 'function' && typeof nav.canShare === 'function') {
const file = new File([blob], name, { type: 'image/png' });
if (nav.canShare({ files: [file] })) {
try { await nav.share({ files: [file], title: SHARE_LINE }); return 'shared'; }
catch (e) { if (e.name === 'AbortError') return 'cancelled'; }
}
}
downloadBlob(blob, name);
return 'downloaded';
One practical trick: navigator.share() needs a user gesture, and rendering a PNG is async. So the app pre-renders the card while idle once the day is complete and caches it by day and count. The click can then call share() right away.
Testing a tiny static app
The whole thing is a Vite build of plain TypeScript with no framework, and the test suite is far bigger than the app:
-
44 Vitest unit tests covering counting, the Thousand layout and its invariant, export/import (including old backups), dates and streaks, storage (with
fake-indexeddb), and the share card; - 127 Playwright tests across Chromium, Firefox, WebKit, a Pixel 7 and an iPhone 14 profile. They cover the 999 β 1,000 transition, a check that the downloaded PNG really is 1080Γ1080 with a PNG signature, Web Share (stubbed) and the download fallback, axe accessibility scans, and every page loaded under the production CSP.
The lesson I'd pass on: keep the interesting logic in pure functions (countChars, thousandLayout, drawShareCard, parseBackup). Then the browser tests only have to prove the wiring.
Try it: https://1k.gg
Top comments (1)
One edge case I'd add to the counting tests is the difference between the
Intl.Segmenterpath and the code-point fallback.[...text]avoids splitting UTF-16 surrogate pairs, but it still doesn't count extended grapheme clusters. A family emoji joined with U+200D or a base letter followed by a combining mark can therefore contribute different counts depending on which path runs.For a writing goal, that could mean the same saved draft reaches 1,000 on one device but not on another.
I maintain a reference covering these boundary rules: letrasdiferentess.github.io/us/ext...
It may be worth making the fallback behavior explicit, or using a compatible segmentation implementation when
Intl.Segmenterisn't available, so the counting policy stays consistent.