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

- Tool: App Features
- Last updated: July 2026

## 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.

## 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.

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## 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.

```sql
create table public.journal_entries (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users on delete cascade not null,
  content jsonb not null,
  mood_score int check (mood_score between 1 and 5),
  mood_emoji text,
  word_count int default 0,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.journal_entries enable row level security;

create policy "Users can view own journal entries"
  on public.journal_entries for select
  using (auth.uid() = user_id);

create policy "Users can insert own journal entries"
  on public.journal_entries for insert
  with check (auth.uid() = user_id);

create policy "Users can update own journal entries"
  on public.journal_entries for update
  using (auth.uid() = user_id);

create policy "Users can delete own journal entries"
  on public.journal_entries for delete
  using (auth.uid() = user_id);

create index journal_entries_user_date_idx
  on public.journal_entries (user_id, created_at desc);

create index journal_entries_search_idx
  on public.journal_entries
  using gin (to_tsvector('english', content::text));

create table public.user_stats (
  user_id uuid references auth.users on delete cascade primary key,
  current_streak int not null default 0,
  longest_streak int not null default 0,
  total_entries int not null default 0,
  last_entry_date date,
  reminder_time time,
  reminder_timezone text default 'UTC',
  updated_at timestamptz not null default now()
);

alter table public.user_stats enable row level security;

create policy "Users can view own stats"
  on public.user_stats for select
  using (auth.uid() = user_id);

create policy "Users can upsert own stats"
  on public.user_stats for insert
  with check (auth.uid() = user_id);

create policy "Users can update own stats"
  on public.user_stats for update
  using (auth.uid() = user_id);

create or replace function calculate_streak(
  p_user_id uuid,
  p_timezone text default 'UTC'
)
returns int
language plpgsql
security definer
as $$
declare
  v_streak int := 0;
  v_check_date date := (now() at time zone p_timezone)::date;
  v_has_entry bool;
begin
  loop
    select exists (
      select 1 from public.journal_entries
      where user_id = p_user_id
        and (created_at at time zone p_timezone)::date = v_check_date
    ) into v_has_entry;

    exit when not v_has_entry;
    v_streak := v_streak + 1;
    v_check_date := v_check_date - interval '1 day';
  end loop;

  return v_streak;
end;
$$;
```

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 paths

### Lovable — fit 4/10, 1–2 days

Lovable's Supabase integration handles the full stack — auth, RLS, Edge Functions for streak calculation, and TipTap editor install. Best all-round path for a complete journaling app.

1. Create a new Lovable project with Supabase Cloud enabled in the Cloud tab so auth is provisioned automatically
2. Paste the prompt below — specify TipTap, mood selector type (emoji row), and heatmap; Lovable's first pass will build the entry editor, mood picker, and data persistence
3. Review the generated RLS policies: open Cloud tab → Database → Policies and verify journal_entries has SELECT/INSERT/UPDATE/DELETE policies for authenticated users using auth.uid() (not uid())
4. For the calendar heatmap, send a follow-up prompt if the first pass generates a custom grid instead of using react-calendar-heatmap — specify the library explicitly
5. Test entry save and retrieval, then test streak calculation by creating entries on consecutive days and verifying the counter increments

Starter prompt:

```
Build a complete personal journaling app with mood tracking. Requirements: (1) Install tiptap and @tiptap/starter-kit; create a JournalEditor component using useEditor hook with StarterKit, EditorContent renderer, and a toolbar with Bold, Italic, BulletList, and Heading buttons. Store content as jsonb in Supabase — use JSON.stringify(editor.getJSON()) on save and editor.commands.setContent(JSON.parse(content)) on load. Never convert to HTML or plain text for storage. (2) Add a MoodSelector component above the editor: a row of 5 emoji buttons (😞 😐 🙂 😊 😄) that sets mood_score (1-5 integer) and mood_emoji (text) in component state; highlight the selected emoji with a colored ring. (3) Create journal_entries Supabase table (id uuid, user_id references auth.users, content jsonb, mood_score int check between 1 and 5, mood_emoji text, word_count int, created_at timestamptz, updated_at timestamptz) with RLS: authenticated users SELECT/INSERT/UPDATE/DELETE own rows using auth.uid() (not uid()). (4) Create user_stats table (user_id primary key, current_streak int, longest_streak int, total_entries int, last_entry_date date, reminder_time time) with RLS. (5) Add the calculate_streak(user_id, timezone) SQL function from the data model above. Call it on app load and after each new entry, passing the user's browser timezone via Intl.DateTimeFormat().resolvedOptions().timeZone. (6) Install react-calendar-heatmap; create a MoodHeatmap component that fetches journal_entries grouped by date with average mood_score per day, transforms dates to YYYY-MM-DD format using date-fns, and passes them to react-calendar-heatmap with classForValue returning 'mood-1' through 'mood-5' CSS classes mapped to a color gradient. (7) Add a search bar that queries journal_entries using Supabase's .textSearch('content', query) method. (8) Calculate word_count in the client (editor.getText().split(/\s+/).filter(Boolean).length) before saving each entry. (9) Edge case: when user edits a past entry, update updated_at but do NOT recalculate streak for that date — editing should not affect streak history. (10) Show a StreakCounter component on the main journal page displaying current_streak with a flame emoji and 'day streak' label.
```

Limitations:

- TipTap JSON storage requires Lovable to understand ProseMirror format — if the first pass stores content incorrectly, send a follow-up: 'store content as jsonb using editor.getJSON(), not editor.getHTML() or getText()'
- Calendar heatmap may require a second prompt iteration — Lovable sometimes generates a custom grid component instead of using react-calendar-heatmap; specify the library name explicitly
- Semantic search via pgvector requires a separate prompt pass after the base build is complete — pgvector needs an embedding column and a separate upsert step for the vector data

### V0 — fit 3/10, 1–2 days

V0 generates excellent UI for the journal editor, mood picker, and heatmap visualization in Next.js — clean TypeScript and good component structure. Best when the journal is one feature inside a larger app.

1. Paste the prompt below to generate the journal UI components and Supabase query hooks
2. Run the SQL data model from this page in the Supabase SQL editor — V0 does not auto-provision the database schema
3. Add Supabase env vars in the Vars panel: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
4. Review the generated streak calculation — V0 may simplify the SQL function and miss the timezone parameter; compare against the calculate_streak function in the data model above and correct if needed

Starter prompt:

```
Add a personal journal feature to this Next.js App Router project. Requirements: (1) Install @tiptap/react @tiptap/starter-kit react-calendar-heatmap date-fns. (2) Create a client component JournalEditor in components/journal/editor.tsx using TipTap useEditor hook with StarterKit; add a Toolbar component with Bold/Italic/BulletList/Heading buttons using editor.chain().focus().toggleBold().run() pattern; save content via editor.getJSON() to a Supabase jsonb column, load via editor.commands.setContent(JSON.parse(content)). (3) Create MoodSelector component with 5 emoji buttons (😞😐🙂😊😄), setting moodScore (1-5) and moodEmoji state, with selected state shown via Tailwind ring classes. (4) Create a Server Component JournalPage in app/journal/page.tsx that fetches today's entry and user stats via Supabase server client. (5) Create a MoodHeatmap client component using react-calendar-heatmap; fetch mood averages per day from journal_entries using Supabase group-by query; transform ISO timestamps to 'yyyy-MM-dd' strings using date-fns format(); use classForValue to map mood scores 1-5 to CSS classes mood-1 through mood-5 with colors from red to green. (6) Create a Server Action in lib/actions/journal.ts for creating and updating entries; accept content (JSON), moodScore, moodEmoji, wordCount; upsert to journal_entries; call the calculate_streak RPC with the user timezone after save and update user_stats. (7) Add a search Server Action that uses Supabase .textSearch() on the content column. (8) Word count: calculate client-side as editor.getText().split(/\s+/).filter(Boolean).length before submitting the form. Ensure all Supabase queries use the authenticated user's ID and RLS handles authorization.
```

Limitations:

- V0 does not auto-provision Supabase — run the full SQL data model manually in the Supabase SQL editor including the calculate_streak function
- Streak calculation logic is moderately complex SQL that V0 may simplify or miss the timezone parameter — review and replace with the calculate_streak function from the data model
- V0 generates excellent frontend components but the Server Actions connecting TipTap JSON to Supabase jsonb sometimes need a fix-up prompt to ensure correct JSON serialization

### Flutterflow — fit 4/10, 1–2 days

FlutterFlow with Firebase Firestore handles the data layer naturally — flutter_quill for rich text, local push notifications for reminders. Right choice when building a native iOS/Android journaling app.

1. Set up Firebase Firestore with security rules: allow read, write: if request.auth != null && request.auth.uid == userId — replace userId with the document field referencing the owner
2. Add flutter_quill from pub.dev in FlutterFlow Settings → Pubspec Dependencies for the rich text editor; create a Custom Widget wrapping the QuillEditor widget
3. Build the mood selector as a Row of 5 GestureDetector widgets with emoji Text children; store selection in Page State variables moodScore and moodEmoji
4. Use flutter_local_notifications package for daily reminders — schedule a daily notification in the onboarding flow when the user sets their reminder time

Limitations:

- Calendar heatmap requires a custom Flutter widget — no built-in FlutterFlow equivalent exists; use the table_calendar or custom painter package in a Custom Widget
- Streak calculation must run client-side in Dart or via a Firebase Cloud Function — FlutterFlow has no SQL function equivalent; implement as a Dart function that queries Firestore and counts backward through consecutive days
- flutter_quill data format is different from TipTap's ProseMirror JSON — if switching platforms later, the content format is not directly portable

### Custom — fit 5/10, 1–2 weeks

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.

1. Implement 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. Add 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. Build 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. Add 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

Limitations:

- 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

## Gotchas

- **TipTap content saved as '[object Object]' in Supabase** — 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** — 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** — 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** — 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

- 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
- Calculate streaks with the user's IANA timezone passed to the database function — UTC-based streak calculation breaks for every user outside UTC+0
- 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
- Enforce RLS with auth.uid() (not uid()) on journal_entries and verify policies block access in the Supabase Policy Tester before shipping
- 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
- 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
- Show the streak counter update immediately after save using optimistic UI — waiting for the database function roundtrip makes the UI feel sluggish after journaling
- 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

## 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.

---

Source: https://www.rapidevelopers.com/app-features/personalized-journal-with-mood-tracking
© RapidDev — https://www.rapidevelopers.com/app-features/personalized-journal-with-mood-tracking
