Back to blog
FILE 0xC8·AN ON-THIS-DAY JOURNAL FEATURE FROM 3,000 DAY ONE ENTRIES

An on-this-day journal feature from 3,000 Day One entries

April 28, 2026 · personal-tools, dynamodb, journals

I exported 3,060 Day One journal entries as JSON, imported them into DynamoDB, and built an "on this day" widget for a home dashboard. Once it was running, the widget started surfacing things I had no memory of writing. That was the whole point.

What was happening

Day One exports give you a JSON array of entries with creationDate, text, attached photo references, and a few metadata fields. The naive shape is fine for a one-shot export but useless for "show me what I wrote on this date" because you'd scan the whole table on every request.

The fix is to index by month-day in addition to full date. Every entry gets a synthetic monthday attribute (MM-DD) so a Query can pull just the entries that match today.

What I found

DynamoDB single-table layout:

Importing was a BatchWriteItem loop in chunks of 25. The hot path is the widget:

import boto3
from datetime import datetime
from zoneinfo import ZoneInfo
import random

ddb = boto3.resource("dynamodb").Table("journal_entries")

def on_this_day():
    md = datetime.now(ZoneInfo("America/Chicago")).strftime("%m-%d")
    resp = ddb.query(
        IndexName="GSI1",
        KeyConditionExpression="GSI1PK = :pk",
        ExpressionAttributeValues={":pk": f"MD#{md}"},
    )
    items = resp.get("Items", [])
    # tiered fallback: prefer substantive entries
    pool = [i for i in items if i["word_count"] >= 80]
    if not pool: pool = [i for i in items if i["word_count"] >= 30]
    if not pool: pool = items
    return random.choice(pool) if pool else None

Three tiers because some days I wrote two sentences and some days I wrote a thousand words. Always picking the longest entry was repetitive; always picking randomly surfaced "ran errands, made pasta" too often. The tiered fallback prefers anything substantive but doesn't lose the day entirely if all I had was a one-liner.

The widget renders a word-wrapped block at 416 × 186 pixels on an e-ink display. Word wrap with no JavaScript is annoying enough that I do it server-side and serve a static <pre>:

import textwrap
def wrap_for_display(text, cols=42, rows=8):
    lines = []
    for para in text.splitlines():
        if not para.strip():
            lines.append("")
            continue
        lines.extend(textwrap.wrap(para, width=cols))
    if len(lines) > rows:
        lines = lines[:rows-1] + ["…"]
    return "\n".join(lines)

The response is cached six hours. The cache key is just the month-day, so the widget shows the same entry all day, which is the right behavior — it's a daily glance, not a refresh button.

What I'd do differently

Leap year. Feb 29 entries surface only one year in four with the current implementation, which is technically correct but feels weird. A future change is to fold Feb 29 into Mar 1's pool with a tag so I can tell visually that it was originally Feb 29. The math isn't hard; I just haven't done it yet.

The other thing I'd add: a "do not surface" flag. There are entries I'm fine never having appear on the dashboard. A single boolean attribute filtered at query time would handle it. I keep almost building this and then deciding it's a problem for the entries themselves, not the widget.