Skip to content

Instantly share code, notes, and snippets.

@bluntbrain
Created May 13, 2026 17:32
Show Gist options
  • Select an option

  • Save bluntbrain/1c9e27581c25b8d8e01197d0b249aade to your computer and use it in GitHub Desktop.

Select an option

Save bluntbrain/1c9e27581c25b8d8e01197d0b249aade to your computer and use it in GitHub Desktop.
Talkamore — Unified JournalBlock Model: Backend + Frontend Implementation Prompts

Backend Prompt — Unified JournalBlock Model + Session-Based Summaries

Context

Talkamore currently has two separate systems writing journal content:

  1. JournalEntry (table: journal_entries) — AI-generated daily roll-ups of chat messages. Created by flush.ts when the user hits 10 unflushed messages or the 8-hour stale cron fires. One row per user per day. Contains a raw transcript + an AI-generated first-person summary.

  2. Journal (table: journals) — User-authored documents created in the web frontend's BlockNote editor. Content is opaque JSON ({ title, mode, pages: [...] }). Autosaved every ~1.5s via PUT /api/v1/drafts/:id.

The problem: These two systems overlap. The "remembering" page shows JournalEntry summaries but ignores what the user wrote in Journal. If a user manually journals and also chats with Maya on the same day, they see the AI's rewrite instead of their own writing. Confusing and disrespectful of user effort.

What we're building: A unified JournalBlock model that replaces both tables. Every piece of journal content — whether AI-generated from a chat session or manually written by the user — becomes a "block" on a chronological timeline. A single day can have multiple blocks from different sources, interleaved by time.

Additionally, we're replacing the current flush system with session-based detection: instead of flushing after 10 messages or 8 hours, we detect natural conversation "sessions" (separated by 2+ hours of silence) and generate one summary block per session.


Current Codebase (what you're working with)

Key backend files:

  • backend/prisma/schema.prismaJournal model (user-written, BlockNote JSON), JournalEntry model (AI-generated, one per day), ChatMessage model, User model
  • backend/src/jobs/flush.tsflushUserBuffer() groups unflushed USER messages by calendar date, concatenates as [HH:MM] message lines, upserts into journal_entries, fires summary generation + mood extraction + supermemory ingest. flushStaleBuffers() is hourly cron. FLUSH_THRESHOLD = 10
  • backend/src/lib/chat.tspersistMessage() saves a ChatMessage, processChat() handles the full chat turn. After each USER message, increments unflushedCount on User and calls flushUserBuffer when threshold is hit (around line 1010-1025)
  • backend/src/lib/summarize.tsgenerateJournalSummary() calls GPT-4o to rewrite day's transcript into first-person narrative. Also has generateConversationTitle()
  • backend/src/lib/openai-extract.tsextractMetadata() extracts mood, energy, themes, moodReason from text
  • backend/src/lib/crypto.tsencryptForUser() / decryptForUser() for at-rest encryption with per-user DEKs
  • backend/src/lib/supermemory.tsaddDocument(), deleteSmDocument(), fireAndLog(), flattenBlockNoteToText() for semantic memory ingest
  • backend/src/lib/time.tstoLocalHHMM(), toLocalDateString(), toLocalDateUTC() for timezone-aware formatting
  • backend/src/lib/usage.tsrecordUsage() for tracking LLM token costs
  • backend/src/lib/analytics.tstrack() for event analytics
  • backend/src/lib/style.tsextractStyleSignals() for writing style profiling
  • backend/src/lib/pi-runtime.tsevictUserSessions() for cache invalidation
  • backend/src/api/me-routes.tshandleMeJournal() (GET /api/me/journal) returns entries list, handleMeJournalDay() returns one day, matchMeRoute() dispatches. Response shape: { user, count, entries: [{ date, summary, content, mood, energy, moodReason, themes, smStatus, createdAt, updatedAt }] }
  • backend/src/api/journals.ts — CRUD for user-written Journal (BlockNote editor): create, get, list as drafts, autosave PUT, rename PATCH, delete. Routes: /api/v1/journals, /api/v1/drafts
  • backend/src/api/helpers.tssendJson(), sendError(), parseJsonBodyOrError()
  • backend/src/lib/auth.tsrequireJwt() for endpoint auth
  • backend/src/index.ts — cron schedules (hourly flushStaleBuffers)

Key patterns to follow:

  • ALL text that touches the DB must go through encryptForUser()/decryptForUser()
  • Use toLocalHHMM(), toLocalDateString(), toLocalDateUTC() from lib/time.ts
  • Use fireAndLog() from lib/supermemory.ts for fire-and-forget async operations
  • Use recordUsage() for ALL LLM calls
  • Use track() for analytics events
  • Use in-memory Set<string> for per-user/per-session locks (same pattern as flushesInFlight in flush.ts)
  • Never block the chat response with heavy operations — use setImmediate() for fire-and-forget

Phase 1: Schema Migration

1.1 Add ChatSession model

model ChatSession {
  id        String   @id @default(uuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  startedAt DateTime
  endedAt   DateTime
  closedAt  DateTime?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  messages     ChatMessage[]
  journalBlock JournalBlock?

  @@index([userId, closedAt])
  @@index([userId, endedAt(sort: Desc)])
  @@map("chat_sessions")
}

1.2 Add JournalBlock model

model JournalBlock {
  id        String   @id @default(uuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  entryDate DateTime @db.Date

  source    JournalBlockSource

  startedAt DateTime
  endedAt   DateTime

  // for CHAT_SESSION: AI summary as plain text, encrypted
  // for USER_WRITTEN / USER_EDITED: BlockNote JSON document, encrypted (as JSON string)
  content       String
  contentLength Int?

  // for CHAT_SESSION: the raw "[HH:MM] message" transcript, encrypted
  // for USER_WRITTEN: null (the content IS the user's work)
  rawTranscript       String?
  rawTranscriptLength Int?

  // first-person narrative summary
  // for CHAT_SESSION: AI-generated from the session
  // for USER_WRITTEN: optionally AI-generated from user's text (for supermemory/search)
  summary       String?
  summaryLength Int?

  mood             MoodLevel?
  energy           EnergyLevel?
  moodReason       String?
  moodReasonLength Int?
  themes           String[]

  // only set for source = CHAT_SESSION
  chatSessionId String?       @unique
  chatSession   ChatSession?  @relation(fields: [chatSessionId], references: [id])

  // the name of the journal (for USER_WRITTEN, maps from old Journal.name)
  name String?

  smDocumentId String?
  smStatus     SmStatus @default(PENDING)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // No unique constraint on (userId, entryDate) — multiple blocks per day is the point

  @@index([userId, entryDate(sort: Desc)])
  @@index([userId, entryDate, startedAt])
  @@index([userId, source])
  @@map("journal_blocks")
}

enum JournalBlockSource {
  CHAT_SESSION
  USER_WRITTEN
  USER_EDITED
}

1.3 Add to ChatMessage model

chatSessionId String?
chatSession   ChatSession? @relation(fields: [chatSessionId], references: [id])

1.4 Add to User model

chatSessions  ChatSession[]
journalBlocks JournalBlock[]

1.5 DO NOT drop Journal or JournalEntry yet

Keep both old tables intact. They'll be read by the old code paths during the parallel-running period. We'll deprecate them in Phase 5.

1.6 Run migration

npx prisma migrate dev --name add-journal-blocks-and-chat-sessions

Phase 2: Session Detection

2.1 New file: backend/src/lib/sessions.ts

// session-based journal grouping. replaces the old 10-message / 8h-stale flush.
// a "session" = a window of chat activity. when the user goes silent for
// SESSION_GAP_MS, the session closes and a JournalBlock is created.

import { db } from "./db.js";
import { log } from "./log.js";
import { toLocalHHMM, toLocalDateString, toLocalDateUTC } from "./time.js";
import { extractMetadata } from "./openai-extract.js";
import { generateSessionSummary } from "./summarize.js";
import { addDocument, fireAndLog } from "./supermemory.js";
import { recordUsage } from "./usage.js";
import { track } from "./analytics.js";
import { decryptForUser, encryptForUser } from "./crypto.js";

export const SESSION_GAP_MS = 2 * 60 * 60 * 1000; // 2 hours

const closesInFlight = new Set<string>();

2.2 assignMessageToSession(userId, messageId, messageCreatedAt)

Called on every USER message after it's persisted to chat_messages.

Logic:

  1. Find the most recent open session for this user (closedAt: null, orderBy: { endedAt: "desc" })
  2. If found AND (messageCreatedAt - session.endedAt) < SESSION_GAP_MS:
    • Update session.endedAt = messageCreatedAt and chatMessage.chatSessionId = session.id in one transaction
    • Return session.id
  3. If found AND gap >= SESSION_GAP_MS:
    • Fire-and-forget closeSession(openSession.id) via setImmediate (DO NOT block the chat response)
    • Fall through to create new session
  4. Create new ChatSession with startedAt = messageCreatedAt, endedAt = messageCreatedAt
  5. Update chatMessage.chatSessionId = newSession.id
  6. Return newSession.id

2.3 closeSession(sessionId)

This is the core function. It:

  1. Acquires the in-memory lock (skip if already in closesInFlight)
  2. Fetches the session with user (timezone, displayName) and messages (role: USER, orderBy createdAt asc)
  3. If no messages or already closed, skip
  4. Sets closedAt = new Date() immediately (prevents re-processing)
  5. Decrypts all message contents
  6. Builds transcript as [HH:MM] message lines
  7. Generates session summary via generateSessionSummary() (see Phase 3)
  8. Extracts mood/themes via extractMetadata()
  9. Encrypts summary, moodReason, transcript, content
  10. Creates a JournalBlock with:
    • source: "CHAT_SESSION"
    • entryDate: toLocalDateUTC(session.startedAt, timezone)
    • startedAt: session.startedAt
    • endedAt: session.endedAt
    • content: encrypted summary (this is what the user sees)
    • rawTranscript: encrypted transcript (the original [HH:MM] lines)
    • summary: encrypted summary (same as content for chat sessions)
    • mood, energy, themes, moodReason
    • chatSessionId: sessionId
  11. ALSO upserts into the old JournalEntry table (for backwards compat during migration):
    • Same logic as flush.ts flushGroup — append transcript to existing entry or create new one
    • This ensures the old frontend code and old API endpoints still work
  12. Marks all USER messages in the session as flushed: true
  13. Fire-and-forget: supermemory ingest with customId: journal_block_${block.id}
  14. Fire-and-forget: style profile refresh (same as flush.ts)
  15. Analytics: track("session_closed", { message_count, duration_minutes, ... })
  16. Records usage for summary + extract LLM calls

2.4 closeStaleSessionsCron()

Run every 15 minutes via node-cron.

  1. Find all ChatSessions where closedAt: null AND endedAt < (now - SESSION_GAP_MS)
  2. For each, call closeSession(id) with try/catch

Phase 3: Session Summary Prompt

3.1 Add to backend/src/lib/summarize.ts

Add generateSessionSummary() alongside the existing generateJournalSummary(). Same structure, same MODEL (gpt-4o), same error handling.

System prompt:

Rewrite a chat session transcript as a first-person journal entry, written in the user's own voice. Use "I" statements. Preserve their actual words and phrasing where possible, don't over-literary or clean up their grammar. Keep it honest, not preachy or over-reflective.

Capture the ARC of the session — how the conversation started, what shifted, where it landed. If multiple topics came up, show how they connected (they usually do emotionally, even when they seem unrelated).

Pull 1-2 direct quotes from the transcript that capture the most raw/honest moments. Wrap them naturally into the prose.

Target 80-200 words depending on session length. A 5-minute session might only need 2 sentences. A 2-hour session might need a full paragraph.

Plain prose, no bullet points, no headers, no timestamps (the frontend renders the time range separately). Never mention an AI assistant, chatbot, Maya, Sage, or Luna — write as if this is the user's own journal.

User message format:

NAME: {displayName}
DATE: {dateStr}
SESSION: {startTime} – {endTime}

CHAT TRANSCRIPT:
{transcript}

First-person summary:

Temperature 0.3, max_tokens 400. Apply stripEmDashes(). Build usage via buildExtractUsage().

Function signature:

export interface GenerateSessionSummaryInput {
  displayName: string;
  dateStr: string;
  startTime: string;
  endTime: string;
  transcript: string;
}

export async function generateSessionSummary(opts: GenerateSessionSummaryInput): Promise<SummaryResult>

Phase 4: New API Endpoints

4.1 New unified endpoint: GET /api/me/journal-blocks

Returns all journal blocks (chat sessions + user-written) grouped by date, ordered chronologically within each day.

Query params: ?limit=90 (max days to return, default 90)

Response shape:

{
  "user": { "id": "...", "displayName": "...", "timezone": "..." },
  "days": [
    {
      "date": "2026-05-13",
      "blocks": [
        {
          "id": "uuid",
          "source": "CHAT_SESSION",
          "startedAt": "2026-05-13T14:00:00Z",
          "endedAt": "2026-05-13T16:15:00Z",
          "startTime": "2:00 PM",
          "endTime": "4:15 PM",
          "content": "Work was brutal today...",
          "summary": "Work was brutal today...",
          "mood": "LOW",
          "energy": "MEDIUM",
          "moodReason": "deadline pressure",
          "themes": ["work stress"],
          "name": null,
          "createdAt": "...",
          "updatedAt": "..."
        },
        {
          "id": "uuid",
          "source": "USER_WRITTEN",
          "startedAt": "2026-05-13T20:30:00Z",
          "endedAt": "2026-05-13T20:45:00Z",
          "startTime": "8:30 PM",
          "endTime": "8:45 PM",
          "content": { "title": {...}, "mode": "manual", "pages": [...] },
          "summary": null,
          "mood": null,
          "energy": null,
          "moodReason": null,
          "themes": [],
          "name": "balcony moment",
          "createdAt": "...",
          "updatedAt": "..."
        }
      ]
    }
  ]
}

Implementation notes:

  • Fetch JournalBlocks for the user, ordered by entryDate desc, then startedAt asc within each date
  • Group by entryDate in application code
  • Decrypt content, summary, moodReason per block
  • For CHAT_SESSION blocks: content is plain text (the summary)
  • For USER_WRITTEN / USER_EDITED blocks: content is the BlockNote JSON. Parse the encrypted string back to JSON before sending. Content was stored as JSON.stringify(blockNoteDoc) then encrypted.
  • Format startTime / endTime using toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: user.timezone })
  • Limit: count distinct dates, stop at limit days

4.2 New endpoint: GET /api/me/journal-blocks/:date

Returns all blocks for a specific date. Same block shape as above, just for one day.

4.3 New endpoint: PUT /api/v1/journal-blocks/:id

Autosave for user-edited blocks. Used when:

  • User creates a new manual entry (source: USER_WRITTEN)
  • User edits a chat session block (source flips: CHAT_SESSION → USER_EDITED)

Body: { content: { title, mode, pages } } (same BlockNote shape as current drafts PUT)

Logic:

  1. requireJwt, owner-check
  2. Fetch the block
  3. If block.source === "CHAT_SESSION", flip to "USER_EDITED"
  4. Encrypt JSON.stringify(body.content) and update the block
  5. Fire-and-forget: supermemory re-ingest (same debounce pattern as journals.ts — 2-min window)
  6. Return updated block

4.4 New endpoint: POST /api/v1/journal-blocks

Create a new user-written block.

Body: { name: string, content?: { title, mode, pages } }

Logic:

  1. requireJwt
  2. Create JournalBlock with:
    • source: "USER_WRITTEN"
    • entryDate: toLocalDateUTC(now, user.timezone)
    • startedAt: now
    • endedAt: now
    • name: body.name
    • content: encrypted default or provided content
  3. Return created block

4.5 New endpoint: DELETE /api/v1/journal-blocks/:id

Same pattern as journal delete in journals.ts. Owner-scoped, SM cleanup fire-and-forget.

4.6 Keep old endpoints running

DO NOT remove or change these during Phase 4:

  • GET /api/me/journal (reads from journal_entries)
  • GET /api/me/journal/:date (reads from journal_entries)
  • POST/GET/PUT/PATCH/DELETE /api/v1/journals and /api/v1/drafts (reads from journals)

They continue serving the old data while the frontend migrates to the new endpoints. The dual-write in closeSession() (Phase 2) ensures journal_entries stays populated.

4.7 Update route matcher

Add the new routes to the server dispatcher:

  • /api/me/journal-blocks → handleMeJournalBlocks
  • /api/me/journal-blocks/:date → handleMeJournalBlocksDay
  • /api/v1/journal-blocks → handleJournalBlockCreate (POST)
  • /api/v1/journal-blocks/:id → handleJournalBlockPut (PUT), handleJournalBlockDelete (DELETE)

Phase 5: Wire Session Detection Into Chat Handler

5.1 In backend/src/lib/chat.ts

After the USER message is persisted (around line 738 where persistMessage is called), add:

import { assignMessageToSession } from "./sessions.js";

// After: const userMessageId = await persistMessage(...)
// Add:
assignMessageToSession(user.id, userMessageId, new Date()).catch((err) => {
  log.error("session assignment failed", {
    userId: user.id,
    messageId: userMessageId,
    err: err instanceof Error ? err.message : String(err),
  });
});

5.2 Keep existing flush logic running

The existing unflushedCount increment + flushUserBuffer threshold at lines 1010-1025 stays in place. Both systems run in parallel:

  • Sessions create JournalBlocks (new system)
  • Flush creates JournalEntries (old system, for backwards compat)

Both mark messages as flushed: true, but they won't conflict because:

  • Session detection runs on every message (assigns to session, potentially closes old session)
  • Flush runs on threshold (10 messages) or stale cron (8h)
  • The flushed: true flag is set by whichever runs first; the other sees no unflushed messages and no-ops

5.3 Add cron in backend/src/index.ts

import { closeStaleSessionsCron } from "./lib/sessions.js";

cron.schedule("*/15 * * * *", () => {
  closeStaleSessionsCron().catch((err) =>
    log.error("stale session cron failed", {
      err: err instanceof Error ? err.message : String(err),
    }),
  );
});

Phase 6: Data Migration Script

6.1 New file: backend/scripts/migrate-to-journal-blocks.ts

Run ONCE after all code is deployed. Backfills existing data into JournalBlock.

From journal_entries → JournalBlock (source: CHAT_SESSION):

  • For each JournalEntry:
    • Decrypt content and summary
    • Create JournalBlock with source CHAT_SESSION
    • entryDate = JournalEntry.entryDate
    • startedAt = JournalEntry.createdAt (or derive from first [HH:MM] in content)
    • endedAt = JournalEntry.updatedAt (or derive from last [HH:MM] in content)
    • content = re-encrypt the summary (or content if no summary)
    • rawTranscript = re-encrypt the raw content
    • summary = re-encrypt the summary
    • Copy mood, energy, moodReason, themes, smDocumentId, smStatus
    • DO NOT delete the JournalEntry row

From journals → JournalBlock (source: USER_WRITTEN):

  • For each Journal:
    • entryDate = toLocalDateUTC(journal.createdAt, user.timezone)
    • startedAt = journal.createdAt
    • endedAt = journal.updatedAt
    • content = encrypt JSON.stringify(journal.content) (the BlockNote JSON)
    • name = journal.name
    • Copy smDocumentId, smStatus
    • DO NOT delete the Journal row

Safety measures:

  • Process in batches of 50 users
  • Skip users who already have JournalBlock rows (idempotent re-runs)
  • Log progress every 10 users
  • Dry-run flag that counts what would be migrated without writing

Phase 7: Deprecation (LATER, not in this PR)

After the frontend is fully switched to the new endpoints and the migration script has run:

  1. Remove the dual-write to journal_entries from closeSession()
  2. Remove old flush logic from chat.ts (unflushedCount increment + flushUserBuffer call)
  3. Remove flushStaleBuffers cron
  4. Remove unflushedCount from User model
  5. Mark old API endpoints as deprecated (add deprecation header), then remove after 30 days
  6. Drop journal_entries and journals tables after 30 days of new system running clean

Files Changed Summary

File Action Phase
backend/prisma/schema.prisma Add ChatSession, JournalBlock, JournalBlockSource enum, update ChatMessage and User 1
backend/src/lib/sessions.ts NEWassignMessageToSession, closeSession, closeStaleSessionsCron 2
backend/src/lib/summarize.ts Add generateSessionSummary 3
backend/src/api/journal-blocks.ts NEW — CRUD endpoints for JournalBlock 4
backend/src/api/me-routes.ts Add handleMeJournalBlocks, handleMeJournalBlocksDay, update matchMeRoute 4
backend/src/api/server.ts Add route dispatch for new endpoints 4
backend/src/lib/chat.ts Wire assignMessageToSession after USER message persist 5
backend/src/index.ts Add 15-min cron for closeStaleSessionsCron 5
backend/scripts/migrate-to-journal-blocks.ts NEW — one-time backfill script 6

Config / Tunables

Parameter Default Location
SESSION_GAP_MS 2 hours sessions.ts
Cron interval 15 min index.ts
Summary max_tokens 400 summarize.ts
Summary temperature 0.3 summarize.ts
SM ingest debounce 2 min journal-blocks.ts

Critical Safety Rules

  1. Never drop old tables until Phase 7 (30+ days after full migration)
  2. Dual-write to both JournalEntry (old) and JournalBlock (new) during transition
  3. Never block the chat response — session assignment and closing are fire-and-forget
  4. All text encrypted — every string that touches the DB goes through encryptForUser/decryptForUser
  5. Migration script is idempotent — safe to re-run, skips users who already have JournalBlock rows
  6. Old API endpoints stay untouched — no changes to /api/me/journal, /api/v1/journals, /api/v1/drafts

Frontend Prompt — Unified JournalBlock Timeline

Context

The backend has been updated with a new unified JournalBlock model. Instead of two separate systems (JournalEntry for AI chat summaries + Journal for user-written entries), everything is now a "block" on a chronological timeline. A single day can have multiple blocks from different sources:

  • CHAT_SESSION — AI-generated summary from a conversation window (e.g. "4:00 PM – 6:30 PM")
  • USER_WRITTEN — manually written by the user in the BlockNote editor
  • USER_EDITED — was originally an AI summary, user clicked to edit it

The journal day view becomes a timeline where chat summaries and manual entries are interleaved by time. This replaces the current single-summary-per-day view.


Current Codebase (what you're working with)

Key frontend files:

  • lib/api-client.tsJournalEntry type (date, summary, content, mood, energy, moodReason, themes, smStatus, createdAt, updatedAt), JournalDocDto type (user-written journal), api.listJournal(), api.getJournalDay()
  • hooks/use-me-journal.tsuseMeJournal() hook, calls /api/me/journal?limit=90, returns JournalEntry[]
  • app/(main)/journal/DaySummaryCard.tsx — renders one card per day with paper texture, ruled lines, red margin, mood/themes pills, date stamp, summary text, click-to-edit
  • app/(journal)/journal/new/HomeView.tsx — main journal view, uses useMeJournal(), renders week strip + DaySummaryCard for selected date, openEntryInEditor() converts summary to BlockNote blocks for editing
  • app/(journal)/journal/new/PageCard.tsx — the editable BlockNote journal page with autosave (PUTs to /api/v1/drafts/:id)
  • app/(journal)/journal/new/atoms.ts — Jotai atoms for journal state (pages, title, mode, etc.)
  • app/(journal)/journal/new/types.tsJournalPage, PAGE_THEMES, DEFAULT_TITLE, Block types

Design system:

  • Paper texture backgrounds: cream (#fdf6dc) light, olive (#0e1408) dark
  • Ruled lines: RULE_HEIGHT = 36px, blue rules light, grey rules dark
  • Margin line: red/oxblood left margin
  • Typography: var(--cr-heading, serif) for body prose, 16px, line-height 36px
  • Color palette: LP.TX (#1a2614), LP.GOLD_INK (#6e4e14), LP.OXBLOOD (#a14c3a), LP.PARCHMENT (#ede6cc)
  • Mood pills: styled with per-mood colors (oxblood/olive/gold palette)
  • Dark mode: full dark variants for all colors

1. Types Update

File: lib/api-client.ts

Add new types:

export type JournalBlockSource = "CHAT_SESSION" | "USER_WRITTEN" | "USER_EDITED";

export interface JournalBlock {
  id: string;
  source: JournalBlockSource;
  startedAt: string;        // ISO string
  endedAt: string;          // ISO string
  startTime: string;        // formatted "4:00 PM"
  endTime: string;          // formatted "6:30 PM"
  // for CHAT_SESSION: plain text summary string
  // for USER_WRITTEN / USER_EDITED: BlockNote JSON object
  content: string | Record<string, unknown>;
  summary: string | null;
  mood: Mood | null;
  energy: Energy | null;
  moodReason: string | null;
  themes: string[];
  name: string | null;      // journal name (USER_WRITTEN only)
  createdAt: string;
  updatedAt: string;
}

export interface JournalDay {
  date: string;             // YYYY-MM-DD
  blocks: JournalBlock[];   // ordered by startedAt asc
}

Add API methods:

// In the api object:
listJournalBlocks: (limit = 90) =>
  get<{ user: { id: string; displayName: string | null; timezone: string }; days: JournalDay[] }>(
    `/api/me/journal-blocks?limit=${limit}`
  ),

getJournalBlocksDay: (date: string) =>
  get<{ blocks: JournalBlock[] }>(`/api/me/journal-blocks/${date}`),

createJournalBlock: (name: string, content?: Record<string, unknown>) =>
  post<{ block: JournalBlock }>(`/api/v1/journal-blocks`, { name, content }),

updateJournalBlock: (id: string, content: Record<string, unknown>) =>
  put<{ block: JournalBlock }>(`/api/v1/journal-blocks/${id}`, { content }),

deleteJournalBlock: (id: string) =>
  del<{ ok: boolean }>(`/api/v1/journal-blocks/${id}`),

Keep existing types

DO NOT remove JournalEntry, JournalDocDto, or the old api.listJournal() / api.getJournalDay() methods. The old types and methods stay for backwards compat during migration. Components will switch to the new types one at a time.


2. New Hook: useJournalBlocks

File: hooks/use-journal-blocks.ts — NEW FILE

"use client";

import { useQuery } from "@tanstack/react-query";
import { api, type JournalDay } from "@/lib/api-client";
import { useMayaAuth } from "@/hooks/use-maya-auth";

export function useJournalBlocks(opts?: { limit?: number }) {
  const { session, hydrated } = useMayaAuth();
  const enabled = hydrated && !!session?.token;

  return useQuery<JournalDay[]>({
    queryKey: ["journal-blocks", "list", opts?.limit ?? 90],
    queryFn: async () => {
      const res = await api.listJournalBlocks(opts?.limit ?? 90);
      return res.days;
    },
    staleTime: 60_000,
    enabled,
  });
}

3. New Component: SessionBlob

File: app/(main)/journal/SessionBlob.tsx — NEW FILE

Renders ONE chat-session block inside a day's card. Layout: time range on the left, vertical divider, summary text on the right.

[time range]  │  [summary text]

Props:

interface Props {
  block: JournalBlock;   // source will be CHAT_SESSION
  dark: boolean;
  bodyColor: string;
  stampColor: string;
  ruleHeight: number;
}

Layout:

  • Left column (width ~72px, shrink-0): Time range, right-aligned

    • Format: "4:00 PM – 6:30 PM" or just "4:00 PM" if start equals end
    • Font: var(--cr-heading, serif), italic, 11px, weight 500, letter-spacing 0.08em
    • Color: stampColor at 0.85 opacity
    • Line-height: matches ruleHeight (36px) so the first line aligns with the summary text
  • Middle (1px, shrink-0): Vertical divider

    • Color: rgba(158,125,47,0.28) light, rgba(212,190,120,0.22) dark
    • alignSelf: stretch to fill full height
  • Right column (flex-1): Summary text

    • Font: var(--cr-heading, serif), 16px, line-height ruleHeight (36px)
    • Color: bodyColor
    • whitespace-pre-wrap
    • If summary is null, show italic "summarizing…" at 0.5 opacity
    • If moodReason exists, render below summary with oxblood left border (same style as DaySummaryCard)

Small source indicator:

Below the summary, show a subtle 💬 from chat label:

  • Font: 10px, weight 500, 0.45 opacity
  • Color: stampColor
  • This helps distinguish chat blocks from user-written blocks at a glance

4. New Component: UserEntryBlob

File: app/(main)/journal/UserEntryBlob.tsx — NEW FILE

Renders ONE user-written block inside a day's card. Same layout as SessionBlob but with different content rendering.

Props:

interface Props {
  block: JournalBlock;   // source will be USER_WRITTEN or USER_EDITED
  dark: boolean;
  bodyColor: string;
  stampColor: string;
  ruleHeight: number;
}

Layout:

Same three-column layout as SessionBlob, but:

  • Left column: Single timestamp (the startTime), not a range. Preceded by a ✍️ emoji.

    • Format: "✍️ 8:30 PM"
  • Right column: Rendered BlockNote content

    • block.content is a BlockNote JSON object { title, mode, pages }. Extract the text from pages[0].blocks using the same plainTextFromBlocks logic that's already in PageCard.tsx
    • Render as plain prose (same serif font, same line-height)
    • If the block has a name that isn't "untitled", show it as a small title above the text

Small source indicator:

  • If source is USER_WRITTEN: show ✍️ written at 0.45 opacity
  • If source is USER_EDITED: show ✍️ edited at 0.45 opacity

5. Update DaySummaryCard

File: app/(main)/journal/DaySummaryCard.tsx

Add new prop:

// Alongside existing props:
blocks?: JournalBlock[];    // from the new unified API — may be undefined for old entries

Import new components:

import { SessionBlob } from "./SessionBlob";
import { UserEntryBlob } from "./UserEntryBlob";

Replace the body rendering section:

Find the current summary rendering block ({summaryPresent ? ( ... ) : ( ... )}). Replace with:

{/* Unified block timeline — when blocks are available */}
{entry.blocks && entry.blocks.length > 0 ? (
  <div className="flex flex-col gap-6">
    {entry.blocks.map((block) => {
      if (block.source === "CHAT_SESSION") {
        return (
          <SessionBlob
            key={block.id}
            block={block}
            dark={dark}
            bodyColor={bodyColor}
            stampColor={stampColor}
            ruleHeight={RULE_HEIGHT}
          />
        );
      }
      // USER_WRITTEN or USER_EDITED
      return (
        <UserEntryBlob
          key={block.id}
          block={block}
          dark={dark}
          bodyColor={bodyColor}
          stampColor={stampColor}
          ruleHeight={RULE_HEIGHT}
        />
      );
    })}
  </div>
) : summaryPresent ? (
  /* Fallback: old-style daily summary for pre-migration or not-yet-migrated entries */
  <div
    style={{
      position: "relative",
      maxHeight: showToggle && !expanded ? COLLAPSED_HEIGHT_PX : undefined,
      overflow: showToggle && !expanded ? "hidden" : undefined,
    }}
  >
    <p
      className="whitespace-pre-wrap"
      style={{
        fontFamily: "var(--cr-heading, serif)",
        fontSize: 16,
        lineHeight: `${RULE_HEIGHT}px`,
        color: bodyColor,
        margin: 0,
        fontWeight: 400,
      }}
    >
      {entry.summary}
    </p>
    {/* ... existing fade-out gradient for collapsed state ... */}
  </div>
) : (
  <p
    style={{
      fontFamily: "var(--cr-heading, serif)",
      fontStyle: "italic",
      fontSize: 14,
      lineHeight: `${RULE_HEIGHT}px`,
      color: subtleColor,
      margin: 0,
    }}
  >
    today&rsquo;s page is still being written…
  </p>
)}

Mood/themes pills:

Keep the existing mood/energy/themes pills in the header. These represent the "day level" mood. Individual blocks may also have mood/themes, but for v1, the day-level display uses the first block's mood (or aggregate later).

Click-to-edit behavior:

Update onOpenInJournal to handle blocks:

  • If clicking a CHAT_SESSION block → convert its summary text to BlockNote blocks (same as existing openEntryInEditor logic), then call the new updateJournalBlock API endpoint (which flips source to USER_EDITED)
  • If clicking a USER_WRITTEN / USER_EDITED block → load its BlockNote content directly into the editor
  • The activeJournalIdAtom should be set to the block's id so autosave PUTs to /api/v1/journal-blocks/:id instead of /api/v1/drafts/:journalId

6. Update HomeView

File: app/(journal)/journal/new/HomeView.tsx

Switch data source:

Replace useMeJournal() with useJournalBlocks():

import { useJournalBlocks } from "@/hooks/use-journal-blocks";

// Replace:
// const journalQuery = useMeJournal({ limit: 90 });
// With:
const journalBlocksQuery = useJournalBlocks({ limit: 90 });

Update entriesByDate:

const blocksByDate = useMemo(() => {
  const map = new Map<string, JournalBlock[]>();
  for (const day of journalBlocksQuery.data ?? []) {
    map.set(day.date, day.blocks);
  }
  return map;
}, [journalBlocksQuery.data]);

Update streak computation:

function computeCurrentStreak(days: JournalDay[]): number {
  if (days.length === 0) return 0;
  const daySet = new Set(days.map((d) => d.date));
  // ... same streak logic, just different input shape
}

Update selectedEntry:

Instead of selectedEntry: JournalEntry | null, use selectedBlocks: JournalBlock[] | null:

const selectedBlocks = blocksByDate.get(selectedKey) ?? null;

Update DaySummaryCard rendering:

{selectedBlocks && selectedBlocks.length > 0 && (
  <DaySummaryCard
    entry={{
      // Build a backwards-compatible entry shape for DaySummaryCard
      date: selectedKey,
      summary: selectedBlocks.find(b => b.source === "CHAT_SESSION")?.summary ?? null,
      content: "",
      mood: selectedBlocks[0]?.mood ?? null,
      energy: selectedBlocks[0]?.energy ?? null,
      moodReason: selectedBlocks[0]?.moodReason ?? null,
      themes: [...new Set(selectedBlocks.flatMap(b => b.themes))],
      smStatus: null,
      createdAt: selectedBlocks[0]?.createdAt ?? "",
      updatedAt: selectedBlocks[selectedBlocks.length - 1]?.updatedAt ?? "",
      blocks: selectedBlocks,  // NEW prop
    }}
    dark={dark}
    onOpenInJournal={() => {
      // Updated to work with blocks
      // ... see click-to-edit section above
    }}
  />
)}

Update openEntryInEditor:

The callback now receives blocks instead of a single entry. When opening in the editor:

  1. CHAT_SESSION blocks: Convert summary text to BlockNote paragraph blocks (existing logic)
  2. USER_WRITTEN / USER_EDITED blocks: Use the BlockNote content directly from block.content
  3. Multiple blocks: If the user clicks the whole card (not a specific block), concatenate all summaries/content into one editor view, separated by dividers

For v1, keep it simple: clicking the card opens the first block. Individual block click-to-edit is a v2 feature.


7. Update Autosave

File: app/(journal)/journal/new/PageCard.tsx

Dual autosave path:

The autosave currently PUTs to /api/v1/drafts/:journalId (updating the journals table). Add a branch:

// In the autosave useEffect timer callback:
if (activeJournalId?.startsWith("journal-block-")) {
  // New path: save to JournalBlock
  const blockId = activeJournalId.replace("journal-block-", "");
  api.updateJournalBlock(blockId, draftState);
} else if (activeJournalId) {
  // Old path: save to Journal (drafts)
  saveMutateRef.current({ journalId: activeJournalId, content: draftState });
}

This way, existing user-written journals (old Journal table) continue to autosave to the old endpoint, while blocks opened from the timeline autosave to the new endpoint.

Setting activeJournalId:

When opening a block for editing, prefix the id:

// In openEntryInEditor or equivalent:
setActiveJournalId(`journal-block-${block.id}`);

8. New Journal Entry Button

Creating a new user-written block:

The existing "+ new journal" button currently creates a Journal row via POST /api/v1/journals. Update it to also create a JournalBlock:

// When user clicks "+ new entry" in the journal view:
const block = await api.createJournalBlock("untitled");
setActiveJournalId(`journal-block-${block.block.id}`);
// Load empty BlockNote editor...

For v1, you can keep the old Journal creation path AND create a JournalBlock simultaneously (dual-write on the frontend, matching the backend's dual-write). Or just switch fully to the new endpoint if the backend supports it.


Visual Reference

Day with 2 chat sessions + 1 manual entry (interleaved):

┌──────────────────────────────────────────────────────┐
│  ● low     work stress    relationships              │
│                              wednesday, may 13, 2026 │
│                                                      │
│    2:00 PM │ Work was brutal today. I kept telling   │
│    4:15 PM │ myself "just get through this week"     │
│            │ but that's what I said last week too.   │
│            │ The deadline isn't the problem — it's   │
│            │ the feeling that nobody notices...      │
│            │ 💬 from chat                            │
│            │                                         │
│  ✍️ 8:30 PM │ Sitting on the balcony. The            │
│            │ conversation earlier stuck with me.     │
│            │ I think I'm not "getting through"       │
│            │ anything — I'm just waiting.            │
│            │ ✍️ written                               │
│            │                                         │
│   10:00 PM │ Couldn't sleep. "That's not a          │
│   10:20 PM │ realization, that's a decision."       │
│            │ That hit.                               │
│            │ 💬 from chat                            │
│                                                      │
│                                   edit this page ✏️  │
└──────────────────────────────────────────────────────┘

Day with only chat sessions (most common):

┌──────────────────────────────────────────────────────┐
│  ● good    self-care                                 │
│                             thursday, may 14, 2026   │
│                                                      │
│   10:30 AM │ Good morning for once. Actually went    │
│   11:00 AM │ for a walk before opening the laptop.   │
│            │ "I forgot how quiet it is at 10am"      │
│            │ 💬 from chat                            │
│                                                      │
│                                   edit this page ✏️  │
└──────────────────────────────────────────────────────┘

Old entry (no blocks — backwards compat fallback):

┌──────────────────────────────────────────────────────┐
│  ● okay    family                                    │
│                              tuesday, may 12, 2026   │
│                                                      │
│  Had a long talk with dad about the move. He's       │
│  worried but trying not to show it. I told him       │
│  I'd visit every month...                           │
│                                                      │
│                                   edit this page ✏️  │
└──────────────────────────────────────────────────────┘

Mobile Considerations

The three-column layout (time | divider | text) works on mobile but the time column (72px) might feel tight on screens < 380px wide.

Option A (recommended for v1): Keep the three-column layout. 72px + 1px + remaining ≈ 270px of text on a 375px screen. Tight but readable.

Option B (v2): On mobile, stack the time ABOVE the text instead of beside it:

4:00 PM – 6:30 PM
Work was brutal today...

With a left border instead of a vertical divider. Implement this only if testing shows the three-column layout is too cramped.


Files Changed Summary

File Action Priority
lib/api-client.ts Add JournalBlock, JournalDay, JournalBlockSource types + API methods Must
hooks/use-journal-blocks.ts NEWuseJournalBlocks() hook Must
app/(main)/journal/SessionBlob.tsx NEW — chat session block renderer Must
app/(main)/journal/UserEntryBlob.tsx NEW — user-written block renderer Must
app/(main)/journal/DaySummaryCard.tsx Add blocks prop, render blocks when available, keep fallback Must
app/(journal)/journal/new/HomeView.tsx Switch to useJournalBlocks(), update data flow Must
app/(journal)/journal/new/PageCard.tsx Add dual autosave path (journal-block vs drafts) Must
hooks/use-me-journal.ts Keep as-is (backwards compat) No change

Important Rules

  1. Don't remove anything — all existing types, hooks, components, and API calls stay. New code is additive.
  2. Backwards compatibility — entries without blocks (pre-migration) must render identically to how they do today. The fallback path in DaySummaryCard handles this.
  3. Same design system — use the exact same LP palette, serif fonts, paper texture, ruled lines, mood pill styles. SessionBlob and UserEntryBlob should feel native to the existing journal.
  4. Both light and dark mode — every new component must have dark mode variants matching DaySummaryCard.
  5. No new dependencies — don't add any npm packages.
  6. Mobile responsive — test on 375px width. Don't break existing mobile behavior.
  7. Content shape mattersCHAT_SESSION content is a plain text string. USER_WRITTEN / USER_EDITED content is a BlockNote JSON object. The renderer must handle both shapes correctly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment