DEV Community

SEN LLC
SEN LLC

Posted on

A Browser-Only Diary in 350 Lines — Month-Grid Math, Code-Point Character Counts, and Streak Boundaries

A journaling app is one of the rare cases where the server side adds nothing: only the writer reads it, search load is tiny, no images required and 5 MB of localStorage covers decades. This is the 350-line browser-only diary that ended up working — a month calendar with click-to-pick, a textarea that saves on a 280 ms debounce, and a stat row showing streak + character count. The interesting parts are the 6×7 month grid math, code-point character counting for CJK + emoji, and the boundary conditions for the "current streak" counter.

diary-local UI: dark layout with a month calendar grid (6 rows × 7 columns) labelled

🌐 Demo: https://sen.ltd/portfolio/diary-local/
📦 GitHub: https://github.com/sen-ltd/diary-local

Month calendar math: always 6 × 7

The 6 × 7 calendar grid is the standard layout — even months that only "need" five rows (February in non-leap years, or short months starting on a Sunday) get rendered as 42 cells to keep the row count stable across navigation. The math comes down to one offset:

export function monthGrid(year, month, todayIso = null, weekStart = 0) {
  const first = new Date(Date.UTC(year, month - 1, 1));
  const firstWeekday = first.getUTCDay();              // 0..6, Sun..Sat
  const offset = (firstWeekday - weekStart + 7) % 7;   // blank cells before day 1
  const start = new Date(Date.UTC(year, month - 1, 1 - offset));
  const today = todayIso || formatDate(new Date());
  const cells = [];
  for (let i = 0; i < 42; i++) {
    const d = new Date(start.getTime() + i * 86400_000);
    const iso = formatDate(d);
    cells.push({
      iso,
      day: d.getUTCDate(),
      inMonth: d.getUTCMonth() + 1 === month && d.getUTCFullYear() === year,
      isToday: iso === today,
    });
  }
  return { year, month, cells };
}
Enter fullscreen mode Exit fullscreen mode

Four design decisions worth stating:

  1. Everything in UTC. Local-time new Date(year, month, day) is full of tz-edge bugs at midnight. A diary cell is a date, not a time, so UTC is the right unit.
  2. (firstWeekday - weekStart + 7) % 7 handles Sunday-first vs Monday-first locales in one expression.
  3. 42 cells, always. Some months need 5 rows; rendering them as 5 rows breaks the UI layout when you flip between months. Fix the grid at 6 × 7 and tag cells as inMonth: false for the spillovers.
  4. Adding 86400000 ms is safe here because we never cross DST in a calendar-day calculation (we're working in UTC). Walking with setUTCDate(getUTCDate() + 1) would be equivalent but less compact.

The unit test pins the cell count:

test("monthGrid produces 42 cells and marks in-month vs adjacent days", () => {
  const grid = monthGrid(2026, 5, "2026-05-18");
  assert.equal(grid.cells.length, 42);
  const inMay = grid.cells.filter((c) => c.inMonth);
  assert.equal(inMay.length, 31);   // May has 31 days
});
Enter fullscreen mode Exit fullscreen mode

Character count: code-points, not UTF-16 units

"💖".length === 2 in JavaScript, because String.length returns the number of UTF-16 code units. For a diary app — where the user wants to see "how much did I write today" — counting an emoji as 2 characters is just wrong.

Use the iteration protocol, which iterates code points:

export function characterCount(text) {
  if (typeof text !== "string") return 0;
  const trimmed = text.trim();
  if (!trimmed) return 0;
  const collapsed = trimmed.replace(/\s+/g, " ");
  return [...collapsed].length;     // ← code-point iteration
}
Enter fullscreen mode Exit fullscreen mode

[...string] (and Array.from(string), and for...of over a string) all use the same iterator, which yields one element per code point. So [...'日本語'] is ["日", "本", "語"] (length 3), and [...'a💖b'] is ["a", "💖", "b"] (length 3, not 4).

Trim + collapse whitespace before counting because users almost always want the "actual content" count, not the count-including-trailing-newlines. \n\nhello\n\n should read as 5 characters, not 9.

test("characterCount counts code points, not UTF-16 units", () => {
  assert.equal(characterCount("日本語"), 3);
  assert.equal(characterCount("a💖b"), 3);
});
Enter fullscreen mode Exit fullscreen mode

There's still an edge case for grapheme clusters (a ZWJ-joined family emoji like 👨‍👩‍👧 is one user-perceived character but several code points). The fully correct count uses Intl.Segmenter:

const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
const n = [...seg.segment(text)].length;
Enter fullscreen mode Exit fullscreen mode

For a diary tool, I left it at code-point counting. The difference between "1 grapheme cluster" and "5 code points" doesn't materially affect the "how much did I write" feel.

Streak counter, three boundary conditions

currentStreak(dates, todayIso) returns the number of consecutive days, ending at today, that have an entry. The implementation is small but the boundary cases are easy to get wrong:

export function currentStreak(dates, todayIso) {
  const set = new Set(dates);
  if (!set.has(todayIso)) return 0;
  let n = 0;
  let cur = parseDate(todayIso);
  while (set.has(formatDate(cur))) {
    n++;
    cur = new Date(cur.getTime() - 86400_000);
  }
  return n;
}
Enter fullscreen mode Exit fullscreen mode

The three load-bearing decisions:

  1. No entry today → streak is 0. This is the Duolingo-style "you have to write today to keep the streak" semantics. The alternative is "streak is the run ending at the most recent entry", which would report 30 days even when the user hasn't written today. Both are defensible; I picked the stricter one because it's the version that motivates the writer to come back.
  2. Any missing day breaks the chain. 5/15-5/17 + 5/18 (today) but 5/16 absent → streak is 2 (today + yesterday), not 4 and not 3.
  3. Empty input → 0. Trivial but worth pinning in a test.

Tests for each:

test("currentStreak is zero when today has no entry", () => {
  assert.equal(currentStreak(["2026-05-15", "2026-05-16", "2026-05-17"], "2026-05-18"), 0);
});

test("currentStreak breaks at the first missing day", () => {
  // 5/16 is missing.
  assert.equal(currentStreak(["2026-05-15", "2026-05-17", "2026-05-18"], "2026-05-18"), 2);
});
Enter fullscreen mode Exit fullscreen mode

The companion longestStreak(dates) is independent of today — it just finds the longest run anywhere. Useful for the "personal best" stat.

Storage: 5 MB is plenty without images

localStorage is specced at 5 MB per origin (Chrome / Safari / Firefox all sit at exactly this value). Rough sizing for a diary:

  • A daily entry averages ~300 characters → ~600 bytes UTF-16 / ~900 bytes UTF-8.
  • 1825 days (5 years) × 600 bytes ≈ 1.1 MB.

→ Decades of writing fits in 5 MB as long as there are no images. Image attachments at 100-500 KB per shot would blow the quota in a few weeks. The app deliberately doesn't support images for this reason; if you want them, IndexedDB-with-Blob is the right tool.

The write path is wrapped in a try/catch for the QuotaExceededError that fires when (eventually) the limit is hit:

function saveEntry(iso, text) {
  try {
    if (text.trim().length === 0) {
      localStorage.removeItem(keyForDate(iso));   // empty → delete the key
    } else {
      localStorage.setItem(keyForDate(iso), text);
    }
    flashStatus("ok", "saved");
  } catch (err) {
    flashStatus("bad", `save failed: ${err.message ?? err}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Two small ergonomics in there:

  • Empty text deletes the key instead of storing an empty string. This means readEntries(localStorage) doesn't have to filter empties later, and the quota usage stays at the actual data size.
  • Status flashes on every save (auto-clears after 1.8s) so the user knows their text is persisted without having to think about it.

Key namespace: diary:YYYY-MM-DD

localStorage is a flat key-value store across the entire origin. Anything sharing the origin sees your keys. The standard fix is a prefix:

export function keyForDate(iso)  { return "diary:" + iso; }
export function isDiaryKey(k)    { return k.startsWith("diary:"); }
export function dateFromKey(k)   { return isDiaryKey(k) ? k.slice("diary:".length) : null; }
Enter fullscreen mode Exit fullscreen mode

Three things this buys:

  1. Collision-free: another app on the same origin can use localStorage for its own purposes without conflicting.
  2. Easy enumeration: for (let i = 0; i < localStorage.length; i++) + isDiaryKey(localStorage.key(i)) lists only diary entries.
  3. Targeted clear: the "delete all" button removes only diary:* keys, not anything else on the origin. localStorage.clear() would be too aggressive.

readEntries(storageLike) accepts a Storage-like object (the standard length / key(i) / getItem(k) shape), which lets node --test drive it with a fake:

const storage = {
  get length() { return items.length; },
  key(i) { return items[i][0]; },
  getItem(k) {
    const hit = items.find((p) => p[0] === k);
    return hit ? hit[1] : null;
  },
};
const out = readEntries(storage);
Enter fullscreen mode Exit fullscreen mode

localStorage doesn't exist in Node — passing the test through an interface keeps the pure layer testable without polyfills.

TL;DR

  • Month calendar = always 6 × 7 = 42 cells. The offset before day 1 is (firstWeekday - weekStart + 7) % 7.
  • Use [...string].length for character counting; never trust text.length if your content might include emoji or CJK.
  • Streak design has multiple defensible semantics; pick the "must write today" one if you want it to motivate. Test the boundary cases first.
  • 5 MB of localStorage is plenty for a text-only diary spanning decades. Drop empty entries to save the cleanup loop later.
  • Prefix every key with the app's name (diary:YYYY-MM-DD). Targeted enumeration and targeted delete are both worth the 6 characters.

Source: https://github.com/sen-ltd/diary-local — MIT, ~350 lines of JS, 20 unit tests, no build step, zero runtime dependencies.


🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.

Top comments (0)