Build an AI Mood Journal with Flask
Build a Flask journal that uses Gemini for structured mood tags.
Introduction
30 Second Summary
A journal can fill with useful reflections before patterns become easy to spot. Free-form entries make emotional trends hard to scan at a glance.
In this project, you will build a local mood journal with Flask that keeps saved entries after a refresh. Gemini assigns each fictional entry one validated mood tag plus a short explanation.
What You'll Build
You'll watch each fictional journal entry appear in a polished history with a color-coded mood badge plus a short explanation.
By the end of this project, you'll have:
- A persistent browser journal where you can submit fictional entries that return after a refresh.
- Validated mood tags that classify each entry as joyful, calm, sad, anxious, angry, or mixed.
- Short explanations displayed beside color-coded badges in a polished journal history.
- Secret Mission: Filter the journal by mood so you can focus on entries with a selected tag.
Are there any prerequisites?
The prerequisites are Python 3.10 or newer plus a Gemini API key. Fictional or non-sensitive entries are safest because Gemini API Free tier content is used to improve Google's products.
Before We Start
Before any setup begins, let's lock in the purpose of your local mood journal. A clear goal keeps every later decision focused on consistent mood labels and private local storage.
Set Up the Flask and Gemini Workspace
Your mood journal depends on Flask for the web interface. Gemini supplies structured mood classifications.
These tools share one Python environment. A clean virtual environment with pinned packages prevents dependency conflicts before the web app meets the AI client.
This setup also keeps your API key in the current terminal session. The SDK can access the credential without placing it in a project file.
In this step, get ready to:
- Prepare the mood-journal workspace with Python 3.10 or newer.
- Create an isolated .venv environment with pinned dependencies.
- Export the Gemini API key for the current terminal session.
Prepare the Python workspace
The Google Gen AI SDK requires Python 3.10 or newer. Checking the version first prevents an older interpreter from creating an incompatible environment.
- Press Cmd+Space on macOS to open Spotlight.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The cd command moves the terminal to your Desktop. This gives the new project a predictable location.
- Create the mood-journal folder and move into it by running these commands:
mkdir mood-journal
cd mood-journal
What do these commands do?
- The mkdir command creates the mood-journal folder on your Desktop.
- The cd command moves the terminal into that folder.
You now have an empty project folder ready for its Python environment.
- Open the current folder in VS Code by running this command:
code .
What does this command do?
The code command launches VS Code. The dot tells it to use the current mood-journal folder as the workspace.
Your mood-journal folder opens in VS Code. That gives every file in this project one clear home.
Code command not found?
The VS Code command-line shortcut may be missing from your shell path.
- Press Cmd+Space to open Spotlight.
- Type VS Code into Spotlight.
- Press Enter to open VS Code.
- Click File in the top menu bar.
- Click Open Folder.
- Select the mood-journal folder on your Desktop.
- Click Open.
If you want the terminal shortcut later, help me add the VS Code command to my macOS shell path.
- Press Ctrl+` in VS Code to open its integrated terminal.
- Check your installed Python version by running this command:
python3 --version
What does this command do?
The --version option prints the Python version used by the python3 command. The project needs version 3.10 or newer.
✔️ I see version 3.10 or higher
Good. Your Python installation can run every pinned package in this project.
ⓧ I see an older version
An older Python version cannot run the pinned Google Gen AI SDK. The current Python.org installer can coexist with the macOS-managed Python installation.
- Download Python 3.15.1 from the Python.org macOS downloads page.
- Open the downloaded macOS installer.
- Complete the installation with its default options.
- Close the current VS Code terminal.
- Press Ctrl+` to open a fresh terminal in the mood-journal workspace.
- Check the newly installed version by running this command:
python3 --version
What should I see?
The output should report Python 3.10 or newer. The new terminal reads the path configured by the installer.
Still seeing the older version?
Restart VS Code so its integrated terminal reloads your shell path. Leave the macOS-managed Python installation unchanged.
If the older version remains active, help me select my Python.org installation in VS Code.
ⓧ Command not found
Your shell cannot find a Python installation yet. The current macOS installer from Python.org adds Python to your shell path.
- Download Python 3.15.1 from the Python.org macOS downloads page.
- Open the downloaded macOS installer.
- Complete the installation with its default options.
- Close the current VS Code terminal.
- Press Ctrl+` to open a fresh terminal in the mood-journal workspace.
- Confirm Python is available by running this command:
python3 --version
What should I see?
The terminal should print a Python version of 3.10 or newer. This confirms the installer added Python to your shell path.
Command still unavailable?
Restart VS Code before checking again. A terminal that stayed open during installation can keep the previous shell path.
If the command remains unavailable, help me find my Python.org installation on macOS.
Install the pinned dependencies
A virtual environment keeps this project's packages separate from your other Python work. The .venv folder becomes the isolated package home for the journal.
- Create the .venv environment by running this command:
python3 -m venv .venv
What does this command do?
The venv module creates an isolated Python environment inside .venv. It uses the compatible Python version you just checked.
- Confirm the .venv folder appears in the VS Code Explorer sidebar.
- Activate the environment in the current terminal by running this command:
. .venv/bin/activate
What does this command do?
Activation points python and pip at the copies inside .venv. The environment name should now appear at the start of your terminal prompt.
Your terminal is now isolated from global Python packages. Anything you install next stays with this project.
Environment name missing?
Confirm the terminal is inside the mood-journal folder. Check that the .venv folder appears in the Explorer sidebar.
If activation still does not change the prompt, help me activate this macOS virtual environment.
Pinned package versions make the setup repeatable. Everyone following this project installs the same tested combination.
- Click the new-file icon beside mood-journal in the VS Code Explorer sidebar.
- Type requirements.txt into the filename field.
- Press Enter to create the file.
- Fill requirements.txt with the pinned dependencies by pasting this code:
Flask==3.1.3
google-genai==2.33.0
pydantic==2.14.1
What do these dependencies provide?
- Flask 3.1.3 provides the local web server plus the journal routes.
- Google Gen AI SDK 2.33.0 connects Python to the Gemini API.
- Pydantic 2.14.1 validates the structured mood response.
- Save requirements.txt by pressing Cmd+S on macOS.
The first package installation can take a minute while pip downloads each dependency. A stream of download messages means the installation is progressing.
- Install every pinned dependency into the active environment by running this command:
python -m pip install -r requirements.txt
What does this command do?
Python runs pip from the active virtual environment. The -r option tells it to install every package listed in requirements.txt.
The terminal returns to its prompt after installing the packages. Your virtual environment now contains the exact dependency versions required by the journal.
Package installation failed?
Check that the environment name appears at the start of your prompt. Confirm each line in requirements.txt matches the reference below.
If the installation still fails, help me troubleshoot the pinned mood journal dependencies.
✔️ Awesome, I've got everything!
Great. Your pinned dependencies are saved in requirements.txt and installed inside the active environment.
ⓧ I'd like to double check the full code
Compare your complete requirements.txt file with this reference.
Flask==3.1.3
google-genai==2.33.0
pydantic==2.14.1
What should match?
Your file should contain these three lines in this order. Each double equals sign pins one exact package version.
Connect the Gemini API key
The SDK reads your Gemini API key from GEMINI_API_KEY. Exporting it in the terminal keeps the credential outside your source files.
Keep the key private
Your API key grants access to your Gemini account. Keep the export command out of screenshots plus shared terminal logs.
This environment value lasts for the current terminal session. A fresh terminal needs a fresh export.
- Export your existing Gemini API key by replacing YOUR_API_KEY in this command with the real key:
export GEMINI_API_KEY="YOUR_API_KEY"
What does this command do?
The export command adds GEMINI_API_KEY to the current terminal environment. The Google Gen AI client can read it when the app creates an API connection.
Before you run the final check, do you expect all three imports to succeed inside the active virtual environment?
- Verify the Flask import plus the Google Gen AI import plus the Pydantic import by running this command:
python -c "from flask import Flask; from google import genai; from pydantic import BaseModel; print('Setup ready')"
What does this command prove?
Python imports one class from each installed dependency. It prints Setup ready only after all three imports succeed.
This confirms the packages are available inside the active environment. The API key remains ready for the Gemini client you add later.
That is the workspace ready. Your virtual environment can now load every library needed for the Flask and Gemini app.
Import check failed?
Confirm the virtual environment is still active. Rerun the dependency installation if you opened a new terminal after the earlier setup.
If one import keeps failing, help me diagnose the failed Python import.
- Keep the API key export command outside the screenshot frame.
- Frame the terminal so the verification command plus Setup ready are visible.
Your workspace has compatible Python plus isolated dependencies plus terminal access to Gemini. Next, you will turn this empty folder into a working journal that saves entries locally.
Build the Journal Without AI
Your Flask workspace is ready. Now you can build the first working version of the journal.
First, you need to prove that entries survive a browser refresh. A local JSON file keeps this persistence test separate from mood classification.
In this step, get ready to:
- Create a Flask route that saves journal entries to a local JSON file.
- Render the journal form plus saved history in a Jinja template.
- Run the baseline journal to expose its Untagged limitation.
Build the Flask route and JSON storage
The Flask route coordinates the browser request with the saved journal history. Two helper functions keep the file operations separate from the request logic.
- Select the Explorer view in VS Code.
- Select the New File button.
- Enter app.py.
- Press Enter.
- Set up the Flask application by pasting this code into app.py:
from datetime import datetime
import json
from pathlib import Path
from flask import Flask, redirect, render_template, request, url_for
app = Flask(__name__)
DATA_FILE = Path("entries.json")
What Sets Up the App?
- The imports provide timestamps, JSON conversion, file paths, redirects, templates, form access, plus URL generation.
- The app object becomes the Flask application that receives browser requests.
- The DATA_FILE path gives every file operation one consistent destination.
- Save app.py with Cmd+S.
- Confirm app.py appears in the Explorer view.
Is app.py Missing?
Confirm the Explorer view shows the mood-journal folder. Create app.py from that folder so it sits beside requirements.txt.
If the file still does not appear, help me create app.py inside my open VS Code project.
- Leave two blank lines below the DATA_FILE line.
- Add the journal-loading helper by pasting this code:
def load_entries() -> list[dict]:
if not DATA_FILE.exists():
return []
try:
return json.loads(DATA_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return []
How Are Entries Loaded?
- The missing-file check returns an empty list when the journal has no history yet.
- The JSON reader turns the saved text into Python dictionaries that Flask can pass to the template.
- The error handler returns an empty list when the saved file does not contain valid JSON.
- Save app.py.
- Confirm the unsaved-change dot disappears from the app.py tab.
Seeing Python Warnings?
Check that every line inside load_entries() uses four spaces of indentation. Keep except aligned with try.
For help with the structure, help me check the indentation in load_entries().
- Leave two blank lines below load_entries().
- Add the journal-saving helper by pasting this code:
def save_entries(entries: list[dict]) -> None:
DATA_FILE.write_text(
json.dumps(entries, indent=2, ensure_ascii=False),
encoding="utf-8",
)
How Are Entries Saved?
- The entries parameter holds the complete journal history.
- The JSON conversion adds indentation so the saved file remains readable.
- The UTF-8 encoding preserves text entered by the learner.
- Save app.py.
- Confirm the unsaved-change dot disappears from the editor tab.
Is save_entries() Underlined?
Check the closing parentheses at the bottom of save_entries(). The inner JSON conversion closes before the outer file-writing call.
If the nesting looks unclear, help me check the parentheses in save_entries().
- Leave two blank lines below save_entries().
- Add the journal route by pasting this code:
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
entry_text = request.form.get("entry", "").strip()
if entry_text:
entries = load_entries()
entries.insert(
0,
{
"text": entry_text,
"mood": "Untagged",
"created_at": datetime.now().astimezone().strftime("%b %d, %Y at %I:%M %p"),
},
)
save_entries(entries)
return redirect(url_for("index"))
return render_template("index.html", entries=load_entries())
How Does the Route Work?
- The route accepts page loads plus form submissions at the same URL.
- A submitted entry is stripped of surrounding whitespace before it is saved.
- Each saved dictionary contains the entry text, a mood value, plus a local timestamp.
- The redirect prevents the browser from submitting the same form again during a refresh.
- Save app.py.
- Confirm the file contains load_entries(), save_entries(), plus index().
Is the Route Decorator Underlined?
Check that @app.route begins at the far left. Keep the body of index() indented beneath the function definition.
If the route structure still looks wrong, help me compare my Flask index route with the expected code.
Create the journal page
A Jinja template mixes HTML with values supplied by Flask. The browser gets a form plus a history generated from the saved entry list.
- Select the top-level mood-journal folder in the Explorer view.
- Select the New Folder button.
- Enter templates.
- Press Enter.
You will see the new templates folder beneath mood-journal.
- Select the templates folder.
- Select the New File button.
- Enter index.html.
- Press Enter.
- Build the journal interface by pasting this code into templates/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mood Journal</title>
</head>
<body>
<main>
<h1>Mood Journal</h1>
<form method="post">
<label for="entry">Journal entry</label>
<textarea id="entry" name="entry" rows="6" required></textarea>
<button type="submit">Save entry</button>
</form>
<h2>Journal history</h2>
{% for item in entries %}
<article>
<strong>{{ item['mood'] }}</strong>
<time>{{ item['created_at'] }}</time>
<p>{{ item['text'] }}</p>
</article>
{% else %}
<p>No entries yet.</p>
{% endfor %}
</main>
</body>
</html>
What Does the Template Render?
- The form sends the textarea value to the current Flask route.
- The entries loop creates one article for every saved journal entry.
- Each article displays the stored mood, timestamp, plus entry text.
- The fallback message gives an empty journal a clear starting state.
- Save templates/index.html.
- Confirm index.html appears inside the templates folder.
Is index.html in the Wrong Place?
Check that index.html is nested inside templates. Flask looks in that folder when render_template() runs.
If the file appears beside the folder, help me move index.html into the templates folder in VS Code.
Run the baseline journal
The app generates entries.json when the first entry is saved. A .gitignore file keeps that private journal history out of future Git commits.
- Select the top-level mood-journal folder in the Explorer view.
- Select the New File button.
- Enter .gitignore.
- Press Enter.
- Add the project exclusions by pasting this configuration:
.venv/
__pycache__/
entries.json
.DS_Store
What Does .gitignore Protect?
- The virtual environment plus Python cache stay outside version control.
- The generated journal history stays local to this project.
- The macOS metadata file stays out of future commits.
- Save .gitignore.
- Confirm .gitignore appears beside app.py.
Is .gitignore Empty?
Check that the filename begins with a dot. Confirm the four exclusions each occupy their own line.
If the filename changed during creation, help me fix my .gitignore filename and contents.
Before you run the server, what do you expect the browser to show before any entries have been saved?
- Return to the activated terminal from the previous step.
- Start the local development server by running this command:
flask run --debug
What Does Debug Mode Do?
Flask starts a local development server with debug reloading. The server reloads when you save changes to the application.
Keep this terminal running while you test the journal. The active process handles each browser request.
The terminal shows the local address http://127.0.0.1:5000/.
- Open a new browser tab.
- Enter http://127.0.0.1:5000/ in the address bar.
- Press Enter.
Your first journal page is live. You will see the Mood Journal heading, a text area, plus the empty-history message.
Before you submit an entry, do you think this version can distinguish one emotional tone from another?
- Select the Journal entry text area.
- Enter I finished organizing my desk and feel relieved..
- Select Save entry.
You will see the fictional entry at the top of the history with an Untagged label. That visible shortfall is the baseline you needed to uncover.
- Refresh the browser page.
- Confirm the same fictional entry remains in the journal history.
The surviving entry proves that entries.json is preserving the journal history. Every saved entry still receives the same placeholder label.
Journal Page Not Loading?
Confirm the Flask process is still running in the activated terminal. Check that the terminal is inside the mood-journal folder.
If the terminal reports a Python problem, compare the indentation in app.py with the full code below. For more help, help me debug my local Flask mood journal.
✔️ Awesome, I've got everything!
Your journal saves fictional entries locally. Refreshing the page keeps the history visible with its Untagged label.
ⓧ I'd like to double check the full code
Compare each file with these complete versions. Keep the Flask server running after you save any corrections.
from datetime import datetime
import json
from pathlib import Path
from flask import Flask, redirect, render_template, request, url_for
app = Flask(__name__)
DATA_FILE = Path("entries.json")
def load_entries() -> list[dict]:
if not DATA_FILE.exists():
return []
try:
return json.loads(DATA_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return []
def save_entries(entries: list[dict]) -> None:
DATA_FILE.write_text(
json.dumps(entries, indent=2, ensure_ascii=False),
encoding="utf-8",
)
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
entry_text = request.form.get("entry", "").strip()
if entry_text:
entries = load_entries()
entries.insert(
0,
{
"text": entry_text,
"mood": "Untagged",
"created_at": datetime.now().astimezone().strftime("%b %d, %Y at %I:%M %p"),
},
)
save_entries(entries)
return redirect(url_for("index"))
return render_template("index.html", entries=load_entries())
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mood Journal</title>
</head>
<body>
<main>
<h1>Mood Journal</h1>
<form method="post">
<label for="entry">Journal entry</label>
<textarea id="entry" name="entry" rows="6" required></textarea>
<button type="submit">Save entry</button>
</form>
<h2>Journal history</h2>
{% for item in entries %}
<article>
<strong>{{ item['mood'] }}</strong>
<time>{{ item['created_at'] }}</time>
<p>{{ item['text'] }}</p>
</article>
{% else %}
<p>No entries yet.</p>
{% endfor %}
</main>
</body>
</html>
.venv/
__pycache__/
entries.json
.DS_Store
Flask==3.1.3
google-genai==2.33.0
pydantic==2.14.1
Your journal now accepts entries plus restores them after a refresh. Next, you will replace the placeholder with structured mood tags from Gemini.
Add Structured AI Mood Tags
Your working Flask journal accepts entries. Every saved card still uses the same Untagged label, so the history cannot distinguish one emotional tone from another.
In this step, Gemini classifies each entry through structured outputs. A Pydantic schema limits the result to six mood labels and validates the explanation before your journal saves it.
In this step, get ready to:
- Define a schema for six allowed mood labels.
- Send each fictional journal entry to Gemini for classification.
- Display the validated mood and explanation in the journal history.
Define the mood schema
A schema gives the model a fixed result shape. Your schema accepts one mood from a short list plus one non-clinical explanation.
- Switch back to app.py in VS Code.
- Replace the import section at the top of app.py with this code:
from datetime import datetime
import json
from pathlib import Path
from typing import Literal
from flask import Flask, redirect, render_template, request, url_for
from google import genai
from google.genai import errors
from pydantic import BaseModel, Field
What Do These Imports Add?
- Literal defines the exact text values that the mood field accepts.
- genai provides the client that sends your classification request to Gemini.
- BaseModel and Field define the shape and purpose of the structured result.
- errors makes the SDK API error type available to the app.
- Save app.py.
- Inspect the new import lines for warning underlines in the editor.
You should see the imports without missing-package warnings because the pinned dependencies are installed in your active virtual environment.
Seeing Import Warnings?
- Confirm VS Code is using the Python interpreter inside .venv.
- Confirm the terminal prompt still shows the activated virtual environment.
Help me fix unresolved imports in this Flask project.
- Add this model below DATA_FILE = Path("entries.json") in app.py by pasting the code:
class MoodResult(BaseModel):
mood: Literal["joyful", "calm", "sad", "anxious", "angry", "mixed"]
explanation: str = Field(
description="A brief, non-clinical explanation of the emotional tone."
)
How Does the Schema Protect the Result?
- MoodResult describes the JSON structure that the app expects from Gemini.
- mood accepts only joyful, calm, sad, anxious, angry, or mixed.
- explanation stores a brief description of the emotional tone.
- Field tells the model to keep the explanation non-clinical.
- Save app.py.
- Return to the Flask terminal from earlier.
- Stop the running server by pressing Control-C.
Before you restart Flask, do you think the schema can load without sending an entry to Gemini?
- Restart the local development server by running this command:
flask run --debug
What Does This Command Do?
The command starts Flask's local development server with debug reloading. Saving app.py now reloads the app while this process stays active.
You should see the local server start without a traceback. The journal remains available at http://127.0.0.1:5000/.
Does the Server Stop During Startup?
- Check the model for matching quotation marks around all six mood values.
- Check that the closing parenthesis for Field lines up with the field assignment.
Help me debug the MoodResult schema in app.py.
Build the Gemini classification function
The classification function turns a journal entry into a validated MoodResult. Its prompt asks for emotional tone while avoiding medical conclusions.
- Add this function below save_entries() in app.py by pasting the code:
def tag_mood(entry_text: str) -> MoodResult:
prompt = f"""
Classify the emotional tone of this journal entry.
Choose the closest mood allowed by the response schema.
Give a short, supportive explanation without making a medical diagnosis.
Journal entry:
{entry_text}
""".strip()
with genai.Client() as client:
interaction = client.interactions.create(
model="gemini-3.8-flash",
input=prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": MoodResult.model_json_schema(),
},
)
if not interaction.output_text:
raise ValueError("Gemini returned no mood result.")
return MoodResult.model_validate_json(interaction.output_text)
What Does This Function Do?
- prompt combines the classification instructions with the submitted journal entry.
- genai.Client() reads the API key from the current terminal environment.
- client.interactions.create() sends the prompt to gemini-3.8-flash with the generated JSON schema.
- MoodResult.model_validate_json() validates the returned text before the route uses it.
- Save app.py.
- Return to the Flask terminal to confirm the debug server reloads without a traceback.
The successful reload proves Python can parse the prompt, client call, and validation logic.
Does the Function Break the Reload?
- Check that the prompt starts and ends with three double quotation marks.
- Check that response_format contains matching braces.
- Check that tag_mood() sits above the route decorator.
Help me find a syntax problem in tag_mood().
The route now needs to call the classifier before saving an entry. It stores the validated mood and explanation at the start of the history.
- Add one blank line between return [] and try: inside load_entries().
- Replace the existing index() route with this code:
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
entry_text = request.form.get("entry", "").strip()
if entry_text:
result = tag_mood(entry_text)
entries = load_entries()
entries.insert(
0,
{
"text": entry_text,
"mood": result.mood,
"explanation": result.explanation,
"created_at": datetime.now()
.astimezone()
.strftime("%b %d, %Y at %I:%M %p"),
},
)
save_entries(entries)
return redirect(url_for("index"))
return render_template("index.html", entries=load_entries())
How Does the Route Use the Result?
- tag_mood() classifies the submitted entry before the app loads the saved history.
- result.mood and result.explanation provide validated values for the new entry.
- entries.insert() places the new entry at position 0 so the latest result appears first.
- save_entries() writes the enriched entry to entries.json.
- Save app.py.
Keep Practice Entries Fictional
The app sends each submitted entry to Gemini for classification. Use fictional or non-sensitive text while you test the AI workflow.
Before you submit the first entry, do you think the old Untagged label will survive or be replaced by one of the six allowed moods?
- Return to http://127.0.0.1:5000/ in your browser.
- Enter I finished a difficult task and feel proud but exhausted. in the journal field.
- Click Save entry.
You should see a new card with one allowed mood instead of Untagged. The card should appear above the earlier entry.
That is the core AI loop working. Your Flask route now turns free-form text into a validated classification before saving it.
Does the Submission Fail?
- Confirm the Flask server is running in the terminal where GEMINI_API_KEY is available.
- Confirm your internet connection can reach the Gemini API.
- Compare the model name and schema keys with the code above.
Help me troubleshoot a failed Gemini mood classification.
✔️ Awesome, My AI Route Works
Your new entry has a validated mood. Save app.py before updating the journal page.
ⓧ I'd Like to Double Check the Full Code
Compare your complete app.py with this reference.
from datetime import datetime
import json
from pathlib import Path
from typing import Literal
from flask import Flask, redirect, render_template, request, url_for
from google import genai
from google.genai import errors
from pydantic import BaseModel, Field
app = Flask(__name__)
DATA_FILE = Path("entries.json")
class MoodResult(BaseModel):
mood: Literal["joyful", "calm", "sad", "anxious", "angry", "mixed"]
explanation: str = Field(
description="A brief, non-clinical explanation of the emotional tone."
)
def load_entries() -> list[dict]:
if not DATA_FILE.exists():
return []
try:
return json.loads(DATA_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return []
def save_entries(entries: list[dict]) -> None:
DATA_FILE.write_text(
json.dumps(entries, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def tag_mood(entry_text: str) -> MoodResult:
prompt = f"""
Classify the emotional tone of this journal entry.
Choose the closest mood allowed by the response schema.
Give a short, supportive explanation without making a medical diagnosis.
Journal entry:
{entry_text}
""".strip()
with genai.Client() as client:
interaction = client.interactions.create(
model="gemini-3.8-flash",
input=prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": MoodResult.model_json_schema(),
},
)
if not interaction.output_text:
raise ValueError("Gemini returned no mood result.")
return MoodResult.model_validate_json(interaction.output_text)
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
entry_text = request.form.get("entry", "").strip()
if entry_text:
result = tag_mood(entry_text)
entries = load_entries()
entries.insert(
0,
{
"text": entry_text,
"mood": result.mood,
"explanation": result.explanation,
"created_at": datetime.now()
.astimezone()
.strftime("%b %d, %Y at %I:%M %p"),
},
)
save_entries(entries)
return redirect(url_for("index"))
return render_template("index.html", entries=load_entries())
Show explanations in the journal
The explanation is already saved in each new structured entry. The template now needs a place to render it beside the mood and timestamp.
- Switch back to templates/index.html in VS Code.
- Select the whole file by pressing Cmd+A on macOS or Ctrl+A on Windows.
- Replace the selected template with this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mood Journal</title>
</head>
<body>
<main>
<h1>Mood Journal</h1>
<form method="post">
<label for="entry">Journal entry</label>
<textarea id="entry" name="entry" rows="6" required></textarea>
<button type="submit">Tag my mood</button>
</form>
<h2>Journal history</h2>
{% for item in entries %}
<article>
<strong>{{ item['mood'] }}</strong>
<time>{{ item['created_at'] }}</time>
<p>{{ item['text'] }}</p>
<p>{{ item['explanation'] }}</p>
</article>
{% else %}
<p>No entries yet.</p>
{% endfor %}
</main>
</body>
</html>
What Changes in the Template?
- Tag my mood describes the AI-powered result that the form now creates.
- item['mood'] renders the validated classification.
- item['explanation'] renders the short reason returned with that classification.
- Save templates/index.html.
- Refresh the journal page in your browser.
The structured entry you submitted should now show its mood and short explanation. Earlier Untagged entries can remain without an explanation.
Is the Explanation Missing?
- Confirm you refreshed the page after saving templates/index.html.
- Confirm the visible card came from a submission made after adding tag_mood().
- Check that the template uses the exact explanation key saved by the route.
Help me debug a missing mood explanation in the Jinja template.
Before you test a contrasting entry, which part of the card do you expect to stay structurally consistent even when the emotional tone changes?
- Enter I spent a quiet evening reading and feel settled. in the journal field.
- Click Tag my mood.
You should see a new card with one allowed mood plus a short explanation. Its structure should match the first AI-tagged card even when the selected mood differs.
- Refresh the browser to verify the saved history.
Both AI-tagged entries should remain visible after the refresh. The latest entry should appear first.
✔️ My Mood Cards Include Explanations
Your journal now displays structured mood results. Double check that templates/index.html is saved.
ⓧ I'd Like to Double Check the Full Code
Compare your complete templates/index.html with this reference.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mood Journal</title>
</head>
<body>
<main>
<h1>Mood Journal</h1>
<form method="post">
<label for="entry">Journal entry</label>
<textarea id="entry" name="entry" rows="6" required></textarea>
<button type="submit">Tag my mood</button>
</form>
<h2>Journal history</h2>
{% for item in entries %}
<article>
<strong>{{ item['mood'] }}</strong>
<time>{{ item['created_at'] }}</time>
<p>{{ item['text'] }}</p>
<p>{{ item['explanation'] }}</p>
</article>
{% else %}
<p>No entries yet.</p>
{% endfor %}
</main>
</body>
</html>
Your journal now replaces Untagged placeholders with validated AI mood results. Next, you will protect the experience from invalid input and failed model requests while giving the page a finished visual design.
Polish and Protect the Experience
Your Flask journal now turns each fictional entry into a validated mood result from Gemini. The saved history already proves the classification workflow works.
A failed model request can still expose a traceback. Invalid input can also leave the user without useful feedback. This step protects the journal from those failures before giving the interface a finished visual hierarchy.
In this step, get ready to:
- Add server-side validation plus graceful API error handling.
- Upgrade the journal template with browser validation plus visible feedback.
- Create a responsive interface with color-coded mood badges.
Protect submissions on the server
Browser validation helps people complete the form correctly. Server-side validation protects the route when a request reaches it through another path.
- Switch back to app.py in VS Code.
- Find the existing index() route.
- Replace the route from @app.route("/", methods=["GET", "POST"]) through the existing if entry_text: line with this first section:
@app.route("/", methods=["GET", "POST"])
def index():
error = None
if request.method == "POST":
entry_text = request.form.get("entry", "").strip()
if not entry_text:
error = "Write a journal entry before asking for a mood tag."
elif len(entry_text) > 2000:
error = "Keep the entry under 2,000 characters for this demo."
else:
What Do These Checks Protect?
- The error value starts empty so a normal page load has no warning to display.
- The first condition rejects text that becomes empty after surrounding spaces are removed.
- The second condition limits each entry to 2,000 characters on the server.
- Replace the remaining success path with this protected section directly beneath the new else: line:
try:
result = tag_mood(entry_text)
entries = load_entries()
entries.insert(
0,
{
"text": entry_text,
"mood": result.mood,
"explanation": result.explanation,
"created_at": datetime.now()
.astimezone()
.strftime("%b %d, %Y at %I:%M %p"),
},
)
save_entries(entries)
return redirect(url_for("index"))
except (errors.APIError, ValueError):
app.logger.exception("Mood tagging failed")
error = (
"Mood tagging is unavailable right now. "
"Check your API key and try again."
)
return render_template("index.html", entries=load_entries(), error=error)
How Does This Preserve the Journal?
- The try branch saves an entry only after tag_mood() returns a valid MoodResult.
- The except branch catches Gemini service failures plus missing or invalid model output.
- A failed request never reaches save_entries(). Existing entries remain unchanged.
- The final render_template() call makes the current error available to the page.
- Save app.py.
- Return to the terminal from earlier.
- Start the protected app by running this command:
flask run --debug
What Does This Command Do?
This starts Flask's local development server with debug reloading. Saved Python changes reload while you work.
- Return to http://127.0.0.1:5000/ in your browser.
- Enter I completed my work and feel calm about the result. in the journal field.
- Click Tag my mood.
You should see a new mood-tagged entry at the top of the history. Your existing entries should remain below it.
Seeing a Traceback?
Check that except (errors.APIError, ValueError): aligns with try:. Confirm the success-path lines sit inside the try block.
Check that your virtual environment remains active in the terminal. Help me debug the protected Flask route.
Upgrade the journal template
The route now produces useful error feedback. The HTML template needs a visible error region plus form rules that stop common input mistakes in the browser.
- Switch back to templates/index.html.
- Find the page opening from <!doctype html> through <h1>Mood Journal</h1>.
- Replace that opening with this hero section:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mood Journal</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page-shell">
<header class="hero">
<p class="eyebrow">AI Mood Journal</p>
<h1>Write it down. See the emotional tone.</h1>
<p>Your entry stays in a local JSON file and is sent to Gemini for tagging.</p>
</header>
What Does the Hero Add?
- The stylesheet link asks Flask for static/style.css through its static file route.
- The hero explains the journal's purpose before the form.
- The class names provide styling hooks for the finished interface.
- Save templates/index.html.
- Refresh the browser.
You should see the AI Mood Journal label above a new heading. The existing form and history should remain below the hero.
Still Seeing the Old Heading?
Confirm you edited templates/index.html inside the mood-journal folder. Check the terminal for a template syntax error.
Help me find why the updated hero is missing.
- Find the existing <form method="post"> block.
- Replace the complete form with this panel:
<section class="panel">
<form method="post">
<label for="entry">Journal entry</label>
<textarea
id="entry"
name="entry"
rows="6"
maxlength="2000"
placeholder="Today I..."
required
></textarea>
<button type="submit">Tag my mood</button>
</form>
{% if error %}
<p class="error" role="alert">{{ error }}</p>
{% endif %}
</section>
How Does the Form Protect Input?
- The required attribute asks the browser to block a blank submission.
- The maxlength="2000" attribute matches the limit enforced by the route.
- The error region displays the server message when an entry cannot be saved.
- Save templates/index.html.
- Refresh the browser.
Before you test, predict whether the browser will send an empty journal entry to Flask.
- Leave the journal field empty.
- Click Tag my mood.
The browser should focus the empty field without submitting the form. No blank entry should appear in the history.
Does the Empty Form Submit?
Confirm the required attribute remains inside the textarea element. Check that the opening tag closes after the attribute.
Help me fix the journal form validation.
- Find the existing history from <h2>Journal history</h2> through {% endfor %}.
- Replace that history with this structured section:
<section class="history">
<div class="section-heading">
<h2>Journal history</h2>
<span>{{ entries|length }} entries</span>
</div>
{% if entries %}
<div class="entry-grid">
{% for item in entries %}
<article class="entry-card">
<div class="entry-meta">
<span class="mood mood-{{ item['mood'] }}">{{ item['mood'] }}</span>
<time>{{ item['created_at'] }}</time>
</div>
<p class="entry-text">{{ item['text'] }}</p>
<p class="explanation">{{ item['explanation'] }}</p>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">No entries yet. Add a fictional entry to test the app.</p>
{% endif %}
</section>
How Does the History Support Styling?
- The entry count reflects the list passed into the template.
- Each mood becomes part of a class such as mood-calm.
- The timestamp plus explanation receive dedicated styling hooks.
- Save templates/index.html.
- Refresh the browser.
You should see an entry count above the history. Every saved card should still show its mood, timestamp, text, and explanation.
Is the History Missing?
Check that every template condition has a matching closing tag. Confirm the new history section remains inside <main class="page-shell">.
Help me debug the journal history template.
Style the finished journal
The template now exposes each interface element through class names. A CSS stylesheet can turn those hooks into a responsive page with distinct mood badges.
- Use the file sidebar to create a folder named static inside mood-journal.
You should see the new static folder beside templates.
- Use the file sidebar to create style.css inside the new static folder.
The file sidebar should now show static/style.css.
- Add the page colors plus base sizing to static/style.css with this code:
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #f5f3ff;
color: #231942;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(160deg, #f5f3ff 0%, #fff7ed 100%);
}
button,
textarea {
font: inherit;
}
What Does the Foundation Style?
- The root rules establish the typeface plus the main page colors.
- The sizing rule keeps padding inside each element's width.
- The body rule paints the full browser background.
- Save static/style.css.
- Refresh the browser.
You should see a pale purple-to-cream background. The page should use the system font stack.
Is the Background Unchanged?
Confirm the file path is exactly static/style.css. Check that the stylesheet link remains inside the template head.
Help me fix the stylesheet connection.
- Add the centered page layout plus hero label below the existing CSS:
.page-shell {
width: min(880px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 72px;
}
.hero {
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #6d28d9;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
How Does the Layout Stay Readable?
The page shell limits the content width while preserving space at both sides. The eyebrow separates the journal label from the main heading.
- Save static/style.css.
- Refresh the browser.
You should see a centered content column. The AI Mood Journal label should appear small, purple, and uppercase.
Is the Content Still Full Width?
Check that the main element uses class="page-shell". Confirm the stylesheet selector begins with .page-shell.
Help me match the page layout classes.
- Add the heading plus shared surface styles below the hero rules:
h1,
h2,
p {
margin-top: 0;
}
h1 {
max-width: 680px;
margin-bottom: 12px;
font-size: clamp(2.2rem, 7vw, 4.5rem);
line-height: 0.98;
}
.hero > p:last-child {
max-width: 620px;
color: #5b556e;
}
.panel,
.entry-card,
.empty-state {
border: 1px solid rgba(109, 40, 217, 0.12);
border-radius: 20px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 45px rgba(35, 25, 66, 0.08);
}
.panel {
padding: 24px;
}
What Creates the Visual Hierarchy?
- The heading size responds to the available screen width.
- The shared surface rules give the form, cards, and empty state the same visual treatment.
- The panel padding creates space around the form.
- Save static/style.css.
- Refresh the browser.
You should see a large hero heading plus a rounded white form panel. Existing entries should use the same surface treatment.
Are the Cards Still Plain?
Confirm each saved entry uses class="entry-card". Check that the grouped selectors retain their commas.
Help me debug the shared surface styles.
- Add the form layout plus textarea states below the panel styles:
form {
display: grid;
gap: 12px;
}
label {
font-weight: 800;
}
textarea {
width: 100%;
resize: vertical;
border: 1px solid #d8d2e8;
border-radius: 14px;
padding: 14px;
background: #fff;
color: #231942;
}
textarea:focus {
outline: 3px solid rgba(109, 40, 217, 0.18);
border-color: #6d28d9;
}
Why Style the Focus State?
The form grid creates consistent space between controls. The focus outline makes the active writing area easy to identify.
- Save static/style.css.
- Refresh the browser.
- Click inside the journal field.
The textarea should span the panel width. You should see a purple focus ring around the active field.
Is the Focus Ring Missing?
Check that the selector includes the colon in textarea:focus. Confirm the text cursor is active inside the field.
Help me debug the textarea focus style.
- Add the button plus error message styles below the textarea rules:
button {
width: fit-content;
border: 0;
border-radius: 999px;
padding: 11px 18px;
background: #6d28d9;
color: #fff;
font-weight: 800;
cursor: pointer;
}
button:hover {
background: #5b21b6;
}
.error {
margin: 16px 0 0;
border-radius: 12px;
padding: 12px 14px;
background: #fee2e2;
color: #991b1b;
}
How Does Feedback Stand Out?
The purple button becomes the form's primary action. The red error surface separates a failed request from normal journal content.
- Save static/style.css.
- Refresh the browser.
- Move the pointer over Tag my mood.
The button should appear as a purple pill. Its background should darken while the pointer is over it.
Is the Button Unchanged?
Confirm the template still uses a button element. Check that the hover selector is button:hover.
Help me debug the journal button styles.
- Add the history layout plus metadata styles below the error rules:
.history {
margin-top: 36px;
}
.section-heading,
.entry-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.section-heading span,
time {
color: #746d86;
font-size: 0.85rem;
}
.entry-grid {
display: grid;
gap: 16px;
}
How Is the History Organized?
The flex layouts position related metadata across each available row. The entry grid keeps a consistent gap between saved cards.
- Save static/style.css.
- Refresh the browser.
The history title should sit opposite the entry count. Each saved card should have an even gap from the next card.
Does the Count Sit Below the Title?
Confirm the heading plus count share <div class="section-heading">. Check that the grouped selector includes both class names.
Help me debug the history layout.
- Add the card padding plus color-coded mood badges below the entry grid:
.entry-card,
.empty-state {
padding: 20px;
}
.mood {
border-radius: 999px;
padding: 5px 10px;
font-size: 0.76rem;
font-weight: 900;
text-transform: uppercase;
}
.mood-joyful { background: #fef3c7; color: #92400e; }
.mood-calm { background: #dbeafe; color: #1e40af; }
.mood-sad { background: #e0e7ff; color: #3730a3; }
.mood-anxious { background: #ffedd5; color: #9a3412; }
.mood-angry { background: #fee2e2; color: #991b1b; }
.mood-mixed { background: #ede9fe; color: #5b21b6; }
How Do the Mood Colors Work?
Every allowed mood has a matching class from the template. The shared badge style controls the shape while each mood class supplies its colors.
- Save static/style.css.
- Refresh the browser.
Each saved mood should appear inside a colored pill. Contrasting mood labels should use different color pairs.
Are the Badges Uncolored?
Confirm the mood element has both the mood class and its generated mood class. Check that the saved mood values use lowercase text.
Help me match the mood values to their styles.
- Finish the stylesheet with entry text plus responsive rules:
.entry-text {
margin: 18px 0 10px;
white-space: pre-wrap;
}
.explanation,
.empty-state {
margin-bottom: 0;
color: #625b74;
}
@media (max-width: 600px) {
.page-shell {
padding-top: 32px;
}
.entry-meta,
.section-heading {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
}
What Changes on a Small Screen?
The responsive rule stacks metadata vertically on narrow screens. Preserved whitespace keeps line breaks from a journal entry visible.
- Save static/style.css.
- Refresh the browser.
- Narrow the browser window.
The history heading plus card metadata should stack vertically on a narrow screen. The content should remain inside the viewport.
Does the Mobile Layout Overflow?
Confirm the media rule closes with its final brace. Check that the .page-shell width rule remains in the stylesheet.
Help me debug the responsive journal layout.
Before you restart the app, predict whether the entries stored in entries.json will survive the new server process.
- Return to the terminal running Flask.
- Stop the server with Control-C.
- Restart the finished app by running this command:
flask run --debug
What Does the Restart Prove?
The new server process loads the history from entries.json. Entries that return after the restart have persisted beyond process memory.
- Refresh http://127.0.0.1:5000/.
- Confirm your existing entries remain visible.
- Enter I feel nervous about tomorrow but hopeful that I prepared well. in the journal field.
- Click Tag my mood.
- Refresh the page once more.
You should see the new entry first. Earlier cards should remain below it with their timestamps, explanations, and color-coded mood badges.
Did an Earlier Entry Disappear?
Confirm entries.json remains inside mood-journal. Check that entries.insert() runs before save_entries().
Help me debug JSON persistence and ordering.
✔️ Awesome, I've got everything!
Great work. Save every project file before completing the final checkpoint.
ⓧ I'd like to double check the full code
Compare your project files with these completed versions. Keep the saved data in entries.json unchanged.
.venv/
__pycache__/
entries.json
.DS_Store
Flask==3.1.3
google-genai==2.33.0
pydantic==2.14.1
from datetime import datetime
import json
from pathlib import Path
from typing import Literal
from flask import Flask, redirect, render_template, request, url_for
from google import genai
from google.genai import errors
from pydantic import BaseModel, Field
app = Flask(__name__)
DATA_FILE = Path("entries.json")
class MoodResult(BaseModel):
mood: Literal["joyful", "calm", "sad", "anxious", "angry", "mixed"]
explanation: str = Field(
description="A brief, non-clinical explanation of the emotional tone."
)
def load_entries() -> list[dict]:
if not DATA_FILE.exists():
return []
try:
return json.loads(DATA_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return []
def save_entries(entries: list[dict]) -> None:
DATA_FILE.write_text(
json.dumps(entries, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def tag_mood(entry_text: str) -> MoodResult:
prompt = f"""
Classify the emotional tone of this journal entry.
Choose the closest mood allowed by the response schema.
Give a short, supportive explanation without making a medical diagnosis.
Journal entry:
{entry_text}
""".strip()
with genai.Client() as client:
interaction = client.interactions.create(
model="gemini-3.8-flash",
input=prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": MoodResult.model_json_schema(),
},
)
if not interaction.output_text:
raise ValueError("Gemini returned no mood result.")
return MoodResult.model_validate_json(interaction.output_text)
@app.route("/", methods=["GET", "POST"])
def index():
error = None
if request.method == "POST":
entry_text = request.form.get("entry", "").strip()
if not entry_text:
error = "Write a journal entry before asking for a mood tag."
elif len(entry_text) > 2000:
error = "Keep the entry under 2,000 characters for this demo."
else:
try:
result = tag_mood(entry_text)
entries = load_entries()
entries.insert(
0,
{
"text": entry_text,
"mood": result.mood,
"explanation": result.explanation,
"created_at": datetime.now()
.astimezone()
.strftime("%b %d, %Y at %I:%M %p"),
},
)
save_entries(entries)
return redirect(url_for("index"))
except (errors.APIError, ValueError):
app.logger.exception("Mood tagging failed")
error = (
"Mood tagging is unavailable right now. "
"Check your API key and try again."
)
return render_template("index.html", entries=load_entries(), error=error)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mood Journal</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page-shell">
<header class="hero">
<p class="eyebrow">AI Mood Journal</p>
<h1>Write it down. See the emotional tone.</h1>
<p>Your entry stays in a local JSON file and is sent to Gemini for tagging.</p>
</header>
<section class="panel">
<form method="post">
<label for="entry">Journal entry</label>
<textarea
id="entry"
name="entry"
rows="6"
maxlength="2000"
placeholder="Today I..."
required
></textarea>
<button type="submit">Tag my mood</button>
</form>
{% if error %}
<p class="error" role="alert">{{ error }}</p>
{% endif %}
</section>
<section class="history">
<div class="section-heading">
<h2>Journal history</h2>
<span>{{ entries|length }} entries</span>
</div>
{% if entries %}
<div class="entry-grid">
{% for item in entries %}
<article class="entry-card">
<div class="entry-meta">
<span class="mood mood-{{ item['mood'] }}">{{ item['mood'] }}</span>
<time>{{ item['created_at'] }}</time>
</div>
<p class="entry-text">{{ item['text'] }}</p>
<p class="explanation">{{ item['explanation'] }}</p>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">No entries yet. Add a fictional entry to test the app.</p>
{% endif %}
</section>
</main>
</body>
</html>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #f5f3ff;
color: #231942;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(160deg, #f5f3ff 0%, #fff7ed 100%);
}
button,
textarea {
font: inherit;
}
.page-shell {
width: min(880px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 72px;
}
.hero {
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #6d28d9;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
max-width: 680px;
margin-bottom: 12px;
font-size: clamp(2.2rem, 7vw, 4.5rem);
line-height: 0.98;
}
.hero > p:last-child {
max-width: 620px;
color: #5b556e;
}
.panel,
.entry-card,
.empty-state {
border: 1px solid rgba(109, 40, 217, 0.12);
border-radius: 20px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 45px rgba(35, 25, 66, 0.08);
}
.panel {
padding: 24px;
}
form {
display: grid;
gap: 12px;
}
label {
font-weight: 800;
}
textarea {
width: 100%;
resize: vertical;
border: 1px solid #d8d2e8;
border-radius: 14px;
padding: 14px;
background: #fff;
color: #231942;
}
textarea:focus {
outline: 3px solid rgba(109, 40, 217, 0.18);
border-color: #6d28d9;
}
button {
width: fit-content;
border: 0;
border-radius: 999px;
padding: 11px 18px;
background: #6d28d9;
color: #fff;
font-weight: 800;
cursor: pointer;
}
button:hover {
background: #5b21b6;
}
.error {
margin: 16px 0 0;
border-radius: 12px;
padding: 12px 14px;
background: #fee2e2;
color: #991b1b;
}
.history {
margin-top: 36px;
}
.section-heading,
.entry-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.section-heading span,
time {
color: #746d86;
font-size: 0.85rem;
}
.entry-grid {
display: grid;
gap: 16px;
}
.entry-card,
.empty-state {
padding: 20px;
}
.mood {
border-radius: 999px;
padding: 5px 10px;
font-size: 0.76rem;
font-weight: 900;
text-transform: uppercase;
}
.mood-joyful { background: #fef3c7; color: #92400e; }
.mood-calm { background: #dbeafe; color: #1e40af; }
.mood-sad { background: #e0e7ff; color: #3730a3; }
.mood-anxious { background: #ffedd5; color: #9a3412; }
.mood-angry { background: #fee2e2; color: #991b1b; }
.mood-mixed { background: #ede9fe; color: #5b21b6; }
.entry-text {
margin: 18px 0 10px;
white-space: pre-wrap;
}
.explanation,
.empty-state {
margin-bottom: 0;
color: #625b74;
}
@media (max-width: 600px) {
.page-shell {
padding-top: 32px;
}
.entry-meta,
.section-heading {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
}
That completes the finished experience. Your journal now protects its saved history while giving every successful mood result a clear visual home.
Secret mission
Filter the Journal by Mood
Turn your AI-generated mood tags into browsing controls for the journal. You will add seven filters that change the visible history without changing the saved entries.
Clean Up Your Resources
Clean Up Your Resources
Your Flask app runs locally, so it creates no paid infrastructure. Calls to gemini-3.8-flash remain free within the Gemini API Free tier limits.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Protect Your Journal Data
Your entries.json file stores journal text in plaintext. Submitted text also goes to the Gemini API for classification.
Free tier content is used to improve Google's products.
- Use fictional or non-sensitive entries while learning.
Resources you used:
- The Flask development server running at http://127.0.0.1:5000/.
- The mood-journal folder containing your source files.
- The .venv virtual environment inside mood-journal.
- The plaintext journal history stored in entries.json.
- The current terminal session containing the exported GEMINI_API_KEY.
Keep everything running
No action is needed. Choose this if you are still testing mood tags or filters.
- Leave the Flask server running at http://127.0.0.1:5000/.
- Keep the mood-journal folder in its current location.
- Keep entries.json if you want to preserve your journal history.
- Keep the current terminal open so GEMINI_API_KEY stays available to Flask.
- Continue using fictional or non-sensitive journal entries.
Pause - I'll come back to this later
Stop Flask to free the local port. Keep the project files so you can continue later.
- Return to the terminal where Flask is serving the app.
- Press Control-C to stop the development server.
- Close the terminal window.
Your mood-journal folder remains intact. Your saved entries remain in entries.json.
- Resume later by returning to the mood-journal folder in a new terminal.
- Reactivate .venv using the activation process from Step 1.
- Export GEMINI_API_KEY again using the process from Step 1.
- Restart the Flask development server using the process from Step 2.
Delete - I don't want to use this again
Remove the complete local project if you no longer need its code or journal history. This permanently deletes entries.json with the rest of the folder.
- Return to the terminal where Flask is serving the app.
- Press Control-C to stop the development server.
- Remove the mood-journal folder by using the commands for your platform below:
macOS
cd ..
rm -rf mood-journal
ls
What Do These Commands Do?
The first line moves the terminal to the folder containing your project. The second line permanently deletes mood-journal.
The final line lists the remaining items so you can verify the deletion.
- Review the final listing. You should no longer see mood-journal.
Windows
Set-Location ..
Remove-Item -Recurse -Force mood-journal
Get-ChildItem
What Do These Commands Do?
The first line moves the terminal to the folder containing your project. The second line permanently deletes mood-journal.
The final line lists the remaining items so you can verify the deletion.
- Review the final listing. You should no longer see mood-journal.
Still See the Project Folder?
Confirm that the terminal was inside mood-journal before you ran the commands. Check that the folder name uses the same spelling.
Help me safely remove my mood-journal folder.
- Close the terminal window after confirming the folder is gone.
Closing the terminal ends the current shell session. The exported GEMINI_API_KEY value is cleared from that session.
The app created no paid cloud infrastructure, so no cloud resource teardown is required.
Nice Work!
Nice Work!
You made it! You built a polished Flask mood journal that keeps entries available across refreshes. Each fictional entry becomes a validated mood tag with a short explanation.
You've learned how to:
- Build a Flask form that turns submissions into a Jinja-rendered journal history.
- Protect local JSON persistence so refreshes, server restarts, invalid input, or API failures do not erase existing entries.
- Build a structured classification pipeline that uses the Google Gen AI SDK to request output from gemini-3.8-flash that Pydantic validates against six allowed mood labels.
- Secret Mission: Added query-string mood filters that display one selected tag while All restores the complete journal history.
Ready to quiz yourself?