Skip to main content
RapidDev - Software Development Agency
App Featurespersonalization-ux22 min read

How to Add a Personalized Journal with Mood Tracking — Copy-Paste Prompts Inside

A personalized journal with mood tracking needs a rich text editor (TipTap), a mood selector (emoji or 1-5 slider), a calendar heatmap showing entry frequency and mood history, streak calculation as a Supabase database function, and strict RLS so entries are private to each user. With Lovable you can ship a working journal in 1–2 days for $0–25/month. The streak timezone bug and TipTap's JSON storage format are the two details that break most first builds.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Intermediate

Category

personalization-ux

Build with AI

1–2 days with Lovable

Custom build

1–2 weeks custom dev

Running cost

$0–25/mo up to 1K users

Works on

WebMobile

Everything it takes to ship a Personalized Journal with Mood Tracking — parts, prompts, and real costs.

TL;DR

A personalized journal with mood tracking needs a rich text editor (TipTap), a mood selector (emoji or 1-5 slider), a calendar heatmap showing entry frequency and mood history, streak calculation as a Supabase database function, and strict RLS so entries are private to each user. With Lovable you can ship a working journal in 1–2 days for $0–25/month. The streak timezone bug and TipTap's JSON storage format are the two details that break most first builds.

What a Personalized Journal with Mood Tracking Actually Involves

A journaling app is a private writing space with behavioral tracking layered on top. Users write entries in a rich text editor, tag each entry with a mood score, and see their patterns visualized over time — a streak counter that rewards consistency and a calendar heatmap that shows emotional trends across weeks and months. The writing itself is handled by TipTap (a ProseMirror-based editor that supports bold, italic, lists, and headings). The tracking layer is a handful of Supabase database functions and a react-calendar-heatmap visualization. The most important architectural decision is privacy: every entry must be strictly private through Supabase Row Level Security. The most common failure modes are storing TipTap's JSON content as a string instead of a jsonb column, and calculating streaks in UTC instead of the user's local timezone.

What users consider table stakes in 2026

  • Mood selector visible on every new entry — emoji row (5 options) or a 1-5 color-coded slider — before the text editor
  • Streak counter showing consecutive days journaled, prominently displayed on the home screen or journal header
  • Calendar heatmap in GitHub contribution-grid style showing entry frequency and mood average color-coded per day
  • Rich text editor supporting bold, italic, bullet lists, and headings — feels like a minimal note-taking app, not a plain textarea
  • Entries are strictly private — no sharing, no public URLs, RLS enforced at the database level
  • Daily reminder notification at a user-configured time
  • Search across all past entry text content

Anatomy of the Feature

Seven components — two editors, two visualization elements, two data stores, and a notification service. TipTap's JSON format and the streak calculation timezone handling are where AI tool builds most commonly require a follow-up prompt.

Layers:UIDataBackendService

Rich Text Editor

UI

TipTap editor (tiptap.dev) with StarterKit extension for React/Next.js. Supports bold, italic, headings (h1-h3), bullet lists, ordered lists, and blockquote out of the box. Content is stored as a JSON object (ProseMirror document format), not HTML or plain text. The editor component is a client component with useEditor hook and EditorContent renderer. BlockNote (blocknotejs.org) is an alternative for a more Notion-like block-based experience.

Note: TipTap's JSON format is non-negotiable for persistence — store it in a Supabase jsonb column. Trying to serialize it as text or HTML and round-trip it back to the editor is the most common TipTap mistake. Always: JSON.stringify() on write, JSON.parse() then editor.commands.setContent() on read.

Mood Selector

UI

A row of five emoji buttons rendered inline above the text editor: 😞 (1), 😐 (2), 🙂 (3), 😊 (4), 😄 (5). Clicking an emoji sets mood_score (integer 1-5) and mood_emoji (text) in component state. The selected emoji gets a highlighted background ring. Alternatively: a horizontal slider with a gradient from red (1) to green (5) with a large emoji rendered at the current position.

Note: Store both mood_score (integer) for aggregation and mood_emoji (text) for display — querying average mood_score by day is how the calendar heatmap computes its color intensity.

Calendar Heatmap

UI

react-calendar-heatmap library (npmjs.com/package/react-calendar-heatmap) renders a GitHub-style contribution grid. Each day cell shows a color intensity mapped to the mood_score average for that day (classForValue prop mapping integer ranges to CSS color classes). Days with no entry show as empty. Hovering a day shows a tooltip with the entry count and average mood.

Note: react-calendar-heatmap expects date values as 'YYYY-MM-DD' string format. Supabase returns timestamps in ISO 8601 with timezone. Transform using date-fns format(new Date(timestamp), 'yyyy-MM-dd') before passing to the heatmap — timezone handling must match the user's local timezone, not UTC.

Streak Counter

Data

A Supabase PostgreSQL function calculate_streak(user_id uuid) that queries journal_entries, counts backward from today through consecutive days with at least one entry, and returns the current streak length. Called on app open and after each new entry save. Result cached in user_stats.current_streak to avoid recalculating on every render.

Note: The function must accept a timezone parameter and use it for date comparison — using UTC internally against entries stored with user timezone will reset streaks at midnight UTC, not midnight local time.

Journal Entry Table

Data

Supabase table journal_entries with columns: id (uuid), user_id (references auth.users), content (jsonb for TipTap document), mood_score (int 1-5), mood_emoji (text), word_count (generated column), created_at (timestamptz), updated_at (timestamptz). RLS policies ensure authenticated users can only read, insert, update, and delete their own rows — no admin access, no public visibility.

Note: The word_count generated column uses length(content::text) as an approximation. For precise word count, calculate it in the client before saving and pass it as a regular column — generated column text casting of jsonb includes structural characters.

Entry Search

Backend

Supabase full-text search using a GIN-indexed tsvector column on the journal content: to_tsvector('english', content::text). Users search via a text input that queries using to_tsquery(). Results rank by relevance and recency. For semantic search ('find entries where I felt anxious about work'), pgvector embeddings with an embedding column allow approximate nearest-neighbor search — available on Supabase Pro.

Note: Full-text search on content::text includes TipTap's JSON structural tokens — add a text_content generated column that strips JSON structure before indexing for cleaner search results.

Daily Reminder

Service

A Supabase Edge Function scheduled via pg_cron that runs daily at a configured time. Queries users who have a reminder_time set in user_stats and who have not yet journaled today (no journal_entries row for today's date in their timezone). Sends a push notification via Web Push API or an email via Resend with a prompt to journal.

Note: pg_cron schedules in UTC — to fire at each user's local reminder time, the cron job must run every hour and check which users have a reminder_time in the current UTC window corresponding to their timezone.

The data model

Two tables: journal_entries for private entry storage and user_stats for streak tracking. Run this in the Supabase SQL editor — RLS is critical here, entries must be fully private.

schema.sql
1create table public.journal_entries (
2 id uuid primary key default gen_random_uuid(),
3 user_id uuid references auth.users on delete cascade not null,
4 content jsonb not null,
5 mood_score int check (mood_score between 1 and 5),
6 mood_emoji text,
7 word_count int default 0,
8 created_at timestamptz not null default now(),
9 updated_at timestamptz not null default now()
10);
11
12alter table public.journal_entries enable row level security;
13
14create policy "Users can view own journal entries"
15 on public.journal_entries for select
16 using (auth.uid() = user_id);
17
18create policy "Users can insert own journal entries"
19 on public.journal_entries for insert
20 with check (auth.uid() = user_id);
21
22create policy "Users can update own journal entries"
23 on public.journal_entries for update
24 using (auth.uid() = user_id);
25
26create policy "Users can delete own journal entries"
27 on public.journal_entries for delete
28 using (auth.uid() = user_id);
29
30create index journal_entries_user_date_idx
31 on public.journal_entries (user_id, created_at desc);
32
33create index journal_entries_search_idx
34 on public.journal_entries
35 using gin (to_tsvector('english', content::text));
36
37create table public.user_stats (
38 user_id uuid references auth.users on delete cascade primary key,
39 current_streak int not null default 0,
40 longest_streak int not null default 0,
41 total_entries int not null default 0,
42 last_entry_date date,
43 reminder_time time,
44 reminder_timezone text default 'UTC',
45 updated_at timestamptz not null default now()
46);
47
48alter table public.user_stats enable row level security;
49
50create policy "Users can view own stats"
51 on public.user_stats for select
52 using (auth.uid() = user_id);
53
54create policy "Users can upsert own stats"
55 on public.user_stats for insert
56 with check (auth.uid() = user_id);
57
58create policy "Users can update own stats"
59 on public.user_stats for update
60 using (auth.uid() = user_id);
61
62create or replace function calculate_streak(
63 p_user_id uuid,
64 p_timezone text default 'UTC'
65)
66returns int
67language plpgsql
68security definer
69as $$
70declare
71 v_streak int := 0;
72 v_check_date date := (now() at time zone p_timezone)::date;
73 v_has_entry bool;
74begin
75 loop
76 select exists (
77 select 1 from public.journal_entries
78 where user_id = p_user_id
79 and (created_at at time zone p_timezone)::date = v_check_date
80 ) into v_has_entry;
81
82 exit when not v_has_entry;
83 v_streak := v_streak + 1;
84 v_check_date := v_check_date - interval '1 day';
85 end loop;
86
87 return v_streak;
88end;
89$$;

Heads up: The calculate_streak function accepts a timezone parameter and converts timestamps before date comparison — this is the fix for the streak-resets-at-midnight bug. Pass the user's browser timezone (Intl.DateTimeFormat().resolvedOptions().timeZone) when calling the function. The GIN index on to_tsvector enables full-text search across all journal content.

Build it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Hand-built by developersFit for this feature:

Full custom build is the right choice when privacy or AI features are the product's core differentiator — end-to-end encryption, Claude API journaling prompts, voice-to-text entry, or semantic search over entry history.

Step by step

  1. 1Implement client-side AES-256 encryption using the Web Crypto API before writing content to Supabase — the database stores ciphertext only, Supabase cannot read entries
  2. 2Add pgvector embeddings: on each entry save, call the Claude API (or OpenAI's embedding model) to generate a 1536-dimensional embedding and store it in an embedding vector column; enable semantic search via cosine similarity
  3. 3Build a voice-to-text entry flow using the Web Speech API (browser-native, free) with a microphone button that transcribes speech into the TipTap editor in real time
  4. 4Add AI journaling prompts via the Anthropic API: after the user saves an entry, optionally request a reflective question based on the entry's content and mood to encourage deeper writing

Where this path bites

  • End-to-end encryption (AES-256 client-side) makes server-side full-text search impossible — the encrypted blob is opaque to PostgreSQL; you must choose between E2E privacy and search
  • AI journaling prompts via Claude API add per-entry cost (approximately $0.002–0.008 per entry at current pricing) — model this into the pricing before building for large user bases

Third-party services you'll need

Most of the journaling feature uses open-source libraries with zero runtime cost. Third-party services only appear for reminders and optional semantic search.

ServiceWhat it doesFree tierPaid from
TipTapRich text editor for journal entries — bold, italic, headings, bullet listsOpen-source core (MIT license), fully freeTipTap Cloud for real-time collaboration $149/mo (approx) — not needed for a personal journal
react-calendar-heatmapGitHub-style contribution grid showing mood and entry frequency per dayOpen-source, fully freeFree — no paid tier
SupabaseDatabase for journal entries and user stats, auth, RLS enforcement, Edge Functions for remindersFree tier (2 projects, 500MB DB) sufficient for personal journal up to ~100 active usersPro $25/mo (8GB DB, pgvector, pg_cron for reminders) (approx)
ResendDaily reminder emails when push notifications are not available3,000 emails/month, 100/dayPro $20/mo (50K emails) (approx)

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Supabase free tier (500MB) comfortably holds 100 users' journal entries and stats. TipTap and react-calendar-heatmap are free. Resend free tier covers daily reminder emails.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

TipTap content saved as '[object Object]' in Supabase

Symptom: The AI tool generated code that called JSON.stringify() on the ProseMirror document for storage but queried it back as a plain string and passed it directly to editor.commands.setContent() without JSON.parse(). The editor receives a stringified JSON string instead of a JSON object and renders the literal text '[object Object]' or throws an error.

Fix: Always round-trip TipTap content as JSON: on save, use editor.getJSON() which returns a JavaScript object, store it in a jsonb column (Supabase handles serialization automatically). On load, fetch the jsonb column (Supabase returns a JavaScript object, not a string), then call editor.commands.setContent(content) — no JSON.parse() needed when using jsonb. Only use JSON.parse() if the column type is text instead of jsonb.

Streak resets to 0 overnight even when the user journaled today

Symptom: The streak calculation function uses UTC timestamps internally. A user in UTC-5 who journals at 11pm local time creates an entry with a UTC timestamp of 4am the next UTC day. The streak function, comparing by UTC date, sees no entry for the local 'today' and resets the streak even though the user maintained their habit.

Fix: Pass the user's browser timezone to the calculate_streak function as a parameter: Intl.DateTimeFormat().resolvedOptions().timeZone returns the IANA timezone name (e.g., 'America/New_York'). Inside the PostgreSQL function, use (created_at AT TIME ZONE p_timezone)::date for all date comparisons so 'today' means midnight in the user's local timezone.

Mood heatmap shows correct data in development but blank in production

Symptom: react-calendar-heatmap receives dates as string values but Supabase returns ISO 8601 timestamps with timezone offset (e.g., '2026-07-09T14:32:00+00:00'). The library's date matching fails because '2026-07-09T14:32:00+00:00' !== '2026-07-09', resulting in all cells rendering as empty despite the database containing entries.

Fix: Transform all Supabase timestamps before passing them to the heatmap: use date-fns format(new Date(timestamp), 'yyyy-MM-dd') to convert each ISO timestamp to a plain date string. Ensure the timezone used for this conversion matches the timezone used for the streak function — inconsistent timezone handling between the two will show different data in the heatmap vs the streak counter.

RLS policy blocks user from reading their own entries after login

Symptom: The AI tool generated RLS policies using uid() instead of auth.uid(). In Supabase, uid() is not a built-in function — auth.uid() is the correct function that returns the authenticated user's ID. Policies using uid() always evaluate the condition as NULL = user_id which never matches, blocking all access silently.

Fix: In the Supabase SQL editor, run: SELECT policyname, qual FROM pg_policies WHERE tablename = 'journal_entries'; to see the generated policy conditions. If uid() appears instead of auth.uid(), drop and recreate the policies using auth.uid(). After fixing policies, test with a logged-in user — if entries still don't appear, check that the Supabase client is initialized with the anon key and that the user session is active.

Best practices

1

Store TipTap content in a Supabase jsonb column — never text or varchar — and always use editor.getJSON() on save and editor.commands.setContent(parsedJSON) on load

2

Calculate streaks with the user's IANA timezone passed to the database function — UTC-based streak calculation breaks for every user outside UTC+0

3

Transform all Supabase timestamps to 'YYYY-MM-DD' strings using date-fns before passing to react-calendar-heatmap — ISO timestamps with timezone suffixes break date matching

4

Enforce RLS with auth.uid() (not uid()) on journal_entries and verify policies block access in the Supabase Policy Tester before shipping

5

Calculate word count client-side before each save (editor.getText().split(/\s+/).filter(Boolean).length) rather than using a generated column — generated columns on jsonb use character count, not word count

6

Index journal_entries on (user_id, created_at DESC) for the entry list query and add a GIN index on to_tsvector('english', content::text) for full-text search

7

Show the streak counter update immediately after save using optimistic UI — waiting for the database function roundtrip makes the UI feel sluggish after journaling

8

Remind users to export their journal periodically — include a 'Download all entries as PDF' feature to prevent lock-in anxiety that reduces long-term retention

When You Need Custom Development

Lovable handles the core journal + mood tracking stack reliably. These scenarios need deeper engineering investment:

  • Privacy-first product requiring client-side end-to-end AES-256 encryption so even the database provider cannot read entries — E2E encryption makes server-side search impossible
  • AI journaling coach feature requiring Claude API integration to generate personalized reflective prompts based on past entry patterns and mood trends
  • Mental health platform context requiring HIPAA-compliant data handling, Business Associate Agreement with Supabase, and audit logging of all data access
  • Team or family journaling with selective sharing — specific entries shareable with selected contacts while others remain private — requiring row-level sharing logic beyond standard RLS

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

Frequently asked questions

Is my journal data private?

Yes — if RLS is implemented correctly. Supabase Row Level Security ensures that every database query for journal_entries only returns rows where user_id matches the authenticated user's ID (auth.uid()). No other user, admin query, or API call can access your entries unless the service role key is used server-side. Verify your RLS policies in the Supabase dashboard under Authentication → Policies before launching — the most common mistake is using uid() instead of auth.uid() which silently blocks all access.

How does streak tracking work?

The streak is calculated by a PostgreSQL function (calculate_streak) that counts backward from today's date, checking for at least one journal entry per day. It stops counting when it finds a day with no entry. The function accepts a timezone parameter so 'today' means midnight in your local timezone, not UTC — without timezone handling, streaks reset at midnight UTC, which for US users means at 7–8pm local time.

Can I search my old journal entries?

Yes — full-text search uses Supabase's PostgreSQL tsvector index on the entry content, letting you search for words and phrases across all past entries. For semantic search ('find entries where I was stressed about money') the app needs pgvector embeddings — each entry gets a vector representation when saved, and search finds semantically similar entries by cosine distance. pgvector is available on Supabase Pro.

Does this work offline?

Not by default — Supabase queries require internet connectivity. For offline support, entries would need to be cached locally using IndexedDB (web) or SQLite (Flutter) and synced when connectivity returns. This is meaningful custom development — AI tools do not generate offline-first sync reliably. For most journaling use cases, a 'save draft locally' fallback (localStorage for web) is sufficient and much simpler.

Can I export my journal entries?

Exporting is worth building even in the first version — users trust apps more when they can get their data out. A basic export fetches all journal_entries for the authenticated user, converts TipTap JSON to Markdown using the @tiptap/extension-markdown package, and downloads as a .md or .zip file. PDF export requires a server-side HTML-to-PDF conversion (Puppeteer or a service like PDFMonkey). Prompt for it as a separate feature after the core journaling flow is working.

How do I set a daily reminder?

On web, a Supabase Edge Function scheduled via pg_cron sends a reminder email via Resend at the user's configured time. Store the reminder_time and reminder_timezone in the user_stats table. The Edge Function runs hourly and checks which users have a reminder scheduled in the current UTC window for their timezone. For native mobile (FlutterFlow), flutter_local_notifications schedules a local notification directly on the device without a server round-trip.

Can I attach photos to journal entries?

Yes — store images in Supabase Storage with a private bucket (no public URLs) and RLS policies matching the journal_entries policies. In TipTap, add the Image extension which lets users insert images into the editor by pasting or uploading. On save, upload the image to Supabase Storage and replace the src in the TipTap JSON with the signed URL. Signed URLs expire after 24 hours by default — regenerate them on load.

What happens to my data if I stop using the app?

Data remains in Supabase as long as the project is active. Supabase pauses free-tier projects after 1 week of inactivity — this is the main risk for small apps. Include a data export feature so users can download all entries before stopping. On the infrastructure side, Supabase Pro projects are never paused, making Pro the right tier for any app with real users even at low scale.

RapidDev

Need this feature production-ready?

RapidDev builds a personalized journal with mood tracking into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.