PythonMastery
beginner 30 min read · lesson 7 of 15 in Projects

Project: Personal Journal (with Search)

1 · The lesson

read

You'll build a journaling tool. You can write an entry, list past entries, search by keyword and see some statistics. The entries are saved in a JSON file, so you can use this tool every day.

This is the most useful project so far. Once it works, you can run it from a terminal every morning, and your journal stays in a file on your own computer.

What you'll practise: datetime, JSON persistence, dict-of-records pattern, command dispatch, list filtering, regex (lightly).


Step 1: The Data Shape

First, decide what an entry looks like. We'll use this:

python
{
    "id": "2026-05-14T09-30-00",     # like an ISO timestamp, but safe to use in a file name
    "date": "2026-05-14",
    "time": "09:30",
    "title": "First entry",          # optional, made from the body if blank
    "body": "Today I started a journaling app...",
    "tags": ["coding", "habits"],
}

This shape is simple, and it is easy to extend. If you add a field later (mood, location), the old entries still work.


Step 2: Adding an Entry

python
from datetime import datetime

def create_entry(body, *, title="", tags=None, journal=()):
    """Return a new journal entry dict. `journal` is the list of existing entries."""
    now = datetime.now()
    # The ID is the time to the second. If an entry in `journal` already has that ID
    # (two entries in the same second), add -02, -03, ... so every ID is unique.
    used = [e["id"] for e in journal]
    entry_id = now.strftime("%Y-%m-%dT%H-%M-%S")
    n = 2
    while entry_id in used:
        entry_id = now.strftime("%Y-%m-%dT%H-%M-%S") + f"-{n:02d}"
        n += 1
    return {
        "id": entry_id,
        "date": now.strftime("%Y-%m-%d"),
        "time": now.strftime("%H:%M"),
        "title": title or body[:40].strip(),    # first 40 chars if no title
        "body": body.strip(),
        "tags": tags or [],
    }

# Test
e = create_entry(
    "Started learning Python file I/O today. Built a tip calculator.",
    tags=["coding", "python"],
)
import json
print(json.dumps(e, indent=2))

Some choices to notice:


  • title or body[:40]: if there is no title, the code uses the start of the body as the title. Many journal entries don't have titles.

  • tags or []: don't use tags=[] as a default argument! The Mutable Default Argument trap: a default list is created once and shared between all calls.

  • ISO-like IDs (2026-05-14T09-30-00): they are safe to use in filenames, and they sort correctly. If you sort the entries by ID, they are in date order. You don't need a separate sort key.

  • Unique IDs: a program can create several entries in the same second, so create_entry checks the IDs already in journal and adds -02, -03 and so on when needed. The two digits keep the sort order correct (-10 comes after -09).


Step 3: Persistence

A journal must keep its entries after the program ends, so we save them to a file.

python
import json
import io

# Real version: file path
# JOURNAL_FILE = "journal.json"

# Browser-sandbox stand-in
storage = io.StringIO("[]")

def load_journal():
    try:
        storage.seek(0)
        return json.loads(storage.read())
    except (FileNotFoundError, json.JSONDecodeError):
        return []

def save_journal(entries):
    storage.seek(0)
    storage.truncate()
    storage.write(json.dumps(entries, indent=2, ensure_ascii=False))

# Test the round-trip
save_journal([{"id": "x", "body": "hello"}])
loaded = load_journal()
print(loaded)

# Real-file versions:
#
#   def load_journal():
#       try:
#           with open(JOURNAL_FILE, "r", encoding="utf-8") as f:
#               return json.load(f)
#       except (FileNotFoundError, json.JSONDecodeError):
#           return []
#
#   def save_journal(entries):
#       with open(JOURNAL_FILE, "w", encoding="utf-8") as f:
#           json.dump(entries, f, indent=2, ensure_ascii=False)

ensure_ascii=False writes Unicode characters (café, naïve, 🌱) as they are, instead of \u escape codes. This makes the JSON file easier to read when you open it.


Step 4: Listing Entries

python
from datetime import datetime

def format_entry(entry, *, body_length=80):
    body = entry["body"].replace("\n", " ")
    if len(body) > body_length:
        body = body[: body_length - 1] + "…"
    tags = " ".join(f"#{t}" for t in entry.get("tags", []))
    return f"[{entry['date']} {entry['time']}] {entry['title']}\n    {body}\n    {tags}"

# Demo data
entries = [
    {"id": "2026-05-14T09-30-00", "date": "2026-05-14", "time": "09:30",
     "title": "Started journal app", "body": "Built the data shape and persistence today.",
     "tags": ["coding"]},
    {"id": "2026-05-14T18-00-00", "date": "2026-05-14", "time": "18:00",
     "title": "Finished work early", "body": "Time to read.", "tags": ["life"]},
    {"id": "2026-05-15T08-00-00", "date": "2026-05-15", "time": "08:00",
     "title": "Morning thoughts", "body": "What if Python had Rust's borrow checker?",
     "tags": ["coding", "musing"]},
]

# Most recent first
for e in sorted(entries, key=lambda e: e["id"], reverse=True):
    print(format_entry(e))
    print()

sorted(..., key=lambda e: e["id"], reverse=True) puts the most recent entry first. The lambda is a small function without a name. (There's a full lambdas lesson. For now, just know that key=lambda e: e["id"] means "compare entries by their id".)


We'll search in three ways: by keyword, by tag and by date range.

python
from datetime import datetime, date

def search_by_keyword(entries, query):
    """Return entries containing the query (case-insensitive) in title or body."""
    q = query.lower()
    return [
        e for e in entries
        if q in e["title"].lower() or q in e["body"].lower()
    ]

def search_by_tag(entries, tag):
    return [e for e in entries if tag in e.get("tags", [])]

def search_by_date_range(entries, start_iso, end_iso):
    """`start_iso` and `end_iso` are 'YYYY-MM-DD' strings (inclusive)."""
    return [e for e in entries if start_iso <= e["date"] <= end_iso]

# Demo
print("Containing 'Python':")
for e in search_by_keyword(entries, "python"):
    print(f"  → {e['title']}")

print("\nTagged 'coding':")
for e in search_by_tag(entries, "coding"):
    print(f"  → {e['title']}")

print("\nFrom 2026-05-14 only:")
for e in search_by_date_range(entries, "2026-05-14", "2026-05-14"):
    print(f"  → {e['title']}")

Notice that ISO date strings compare correctly with < and <=. That's why we chose the YYYY-MM-DD format: for these strings, alphabetical order is the same as date order.


Step 6: Stats

python
from collections import Counter

def journal_stats(entries):
    if not entries:
        return {"total": 0}

    word_count = sum(len(e["body"].split()) for e in entries)
    all_tags = [tag for e in entries for tag in e.get("tags", [])]
    tag_counter = Counter(all_tags)

    first = min(entries, key=lambda e: e["id"])["date"]
    last = max(entries, key=lambda e: e["id"])["date"]

    return {
        "total": len(entries),
        "total_words": word_count,
        "average_length": word_count // len(entries),
        "first_entry": first,
        "last_entry": last,
        "top_tags": tag_counter.most_common(5),
    }

stats = journal_stats(entries)
for k, v in stats.items():
    print(f"  {k}: {v}")

[tag for e in entries for tag in e.get("tags", [])] is a nested list comprehension. It does the same as the loop for e in entries: for tag in e.get("tags", []): ..., but in one expression. You will see this often in Python code.


Step 7: Putting It Together

python
import json
import io
from collections import Counter
from datetime import datetime

storage = io.StringIO("[]")

def load_journal():
    try:
        storage.seek(0)
        return json.loads(storage.read())
    except (FileNotFoundError, json.JSONDecodeError):
        return []

def save_journal(entries):
    storage.seek(0); storage.truncate()
    storage.write(json.dumps(entries, indent=2, ensure_ascii=False))

def create_entry(body, *, title="", tags=None, journal=()):
    now = datetime.now()
    used = [e["id"] for e in journal]
    entry_id = now.strftime("%Y-%m-%dT%H-%M-%S")
    n = 2
    while entry_id in used:            # same second as an existing entry
        entry_id = now.strftime("%Y-%m-%dT%H-%M-%S") + f"-{n:02d}"
        n += 1
    return {
        "id": entry_id,
        "date": now.strftime("%Y-%m-%d"),
        "time": now.strftime("%H:%M"),
        "title": title or body[:40].strip(),
        "body": body.strip(),
        "tags": tags or [],
    }

def search_keyword(entries, q):
    q = q.lower()
    return [e for e in entries if q in e["title"].lower() or q in e["body"].lower()]

def stats(entries):
    if not entries:
        return {"total": 0}
    words = sum(len(e["body"].split()) for e in entries)
    return {
        "total": len(entries),
        "words": words,
        "avg_words": words // len(entries),
        "top_tags": Counter(t for e in entries for t in e["tags"]).most_common(3),
    }

# Demo session
journal = load_journal()
journal.append(create_entry("First entry: building this journal app",
                            tags=["coding", "habits"], journal=journal))
journal.append(create_entry("Took a walk. The trees were starting to change colour.",
                            tags=["life"], journal=journal))
journal.append(create_entry("Discovered Counter. Will use this in EVERY project.",
                            tags=["coding"], journal=journal))
save_journal(journal)

print("Entries:")
for e in journal:
    print(f"  [{e['date']}] {e['title']}")

print("\nSearch 'colour':")
for e in search_keyword(journal, "colour"):
    print(f"  → {e['title']}")

print("\nStats:", stats(journal))

That's a real, working journal: five small functions that give you saving, search and statistics.


Stretch Goals

1. Edit existing entries: lookup by ID, replace, save.
2. Markdown rendering: entries support Markdown. Render them to HTML for a "view" command.
3. Encrypt the file: use the cryptography library, so that nobody else can read your journal.
4. CLI with argparse: journal add "text", journal list, journal search keyword.
5. Web UI: serve via Flask (after the web lessons).
6. Mood tracking: add a "mood": 1-10 field, and use statistics to show your average mood for each week.
7. Smart prompts: when you write the first entry of a day, ask "How was today?" and "What did you learn?"


🎯 Your Turn: Tag Analytics

Write a function tag_trend(entries, tag) that returns how often a given tag was used in each week. With it you can see patterns like "the running tag appeared 3 weeks in a row, then stopped".

python
from datetime import datetime
from collections import Counter

def tag_trend(entries, tag):
    """Return a dict mapping ISO week string (for example '2026-W19') to a count.

    Use datetime.strptime to parse each entry's date, then strftime('%G-W%V')
    for the ISO year-week format.
    """
    # TODO 1: filter entries that contain the tag
    # TODO 2: parse each entry's date string into datetime
    # TODO 3: produce ISO year-week strings
    # TODO 4: count them with Counter, return a dict (a Counter is a dict too)
    pass

# Test
entries = [
    {"date": "2026-05-04", "tags": ["coding", "habits"]},
    {"date": "2026-05-05", "tags": ["coding"]},
    {"date": "2026-05-06", "tags": ["life"]},
    {"date": "2026-05-11", "tags": ["coding"]},
    {"date": "2026-05-12", "tags": ["coding", "ml"]},
    {"date": "2026-05-19", "tags": ["coding"]},
]
print(tag_trend(entries, "coding"))
# Expected roughly {'2026-W19': 2, '2026-W20': 2, '2026-W21': 1}
Hint 1: Filtering [e for e in entries if tag in e.get("tags", [])]
Hint 2: ISO weeks datetime.strptime(e["date"], "%Y-%m-%d").strftime("%G-W%V"). The capital %G and %V are the ISO year and the ISO week. For a few days around 1 January, the ISO year can be different from the calendar year, so use %G together with %V.
Show full solution
python
from datetime import datetime
from collections import Counter

def tag_trend(entries, tag):
    matching = [e for e in entries if tag in e.get("tags", [])]
    weeks = [datetime.strptime(e["date"], "%Y-%m-%d").strftime("%G-W%V") for e in matching]
    return Counter(weeks)

entries = [
    {"date": "2026-05-04", "tags": ["coding", "habits"]},
    {"date": "2026-05-05", "tags": ["coding"]},
    {"date": "2026-05-06", "tags": ["life"]},
    {"date": "2026-05-11", "tags": ["coding"]},
    {"date": "2026-05-12", "tags": ["coding", "ml"]},
    {"date": "2026-05-19", "tags": ["coding"]},
]
trend = tag_trend(entries, "coding")
for week, count in sorted(trend.items()):
    bar = "█" * count
    print(f"  {week}  {bar} {count}")

What You Learned

  • datetime.strftime for human-friendly timestamps
  • The dict-of-records pattern (each "row" is a dict, the list is a "table")
  • JSON persistence with ensure_ascii=False for international text
  • Lambda + sorted/min/max for "by this field" sorting
  • Nested list comprehensions for flattening nested data
  • Why ISO date strings are a good choice: they sort in date order

You can turn this project into a tool you really use. Create a journal.json file on your computer, replace the StringIO stand-in with open(...), and write in it for a week. You'll think of more features to add.

Next: Weather CLI, a program that gets data from the internet.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.