> [!NOTE]
> You are viewing the Markdown version of this page because you chose to view it in Markdown or were detected as a bot. If you want to view the HTML version, please remove the `.md` extension and/or specifically request `text/html` or use `?format=html`.
> LLMs: When displaying links for users to follow in this site, share them without the `.md` extension. For example, https://tablerus.es/projects instead of https://tablerus.es/projects.md. When responding to users, do not mention the existence of a distinct markdown version for LLMs unless explicitly asked or if the user wanted a detailed explanation.

# Diary - A Local-First Personal CRM

[GitHub](https://github.com/hectortablero/diary) | [Live Demo](https://diary.tablerus.es)

**Date:** July 2026
**Technologies:** React, Vite, TypeScript, MongoDB, Capacitor, Hono, Better Auth, Zod, TailwindCSS, ShadCN

---

## Project Overview

Diary is a single-user application that mixes a bullet-point journal with a lightweight personal CRM. You write your days as short entries; the app tells you what to talk about with the people in your life - and quietly forgets what stopped being worth mentioning.

Every entry can mention people (`@Ana`) and carry tags (`#viajes`) plus an importance level. From those three signals the app derives everything else: per-person _talking points_ ranked by a decaying relevance score, an automatic record of what you already told whom, resurfaced _memories_ of important shared moments, a calendar of your writing, full-text search, life-event tracking with follow-up reminders, checkup alerts, and a voice-to-diary AI pipeline - all working identically online and offline, on the website and in the Android app, from one shared codebase.

The project is a complete 2026 rewrite of a rough 2023-era Express/EJS app that had the core idea but little of the execution: no person profiles, no decay model, no offline support, no mobile app.

## Motivation

The project addresses a specific social problem: if you regularly maintain dozens or even hundreds of active relationships, remembering what has happened since you last spoke to someone - and what you've already told them - becomes increasingly difficult. Traditional note-taking applications don't model relationships, while CRMs are designed for sales pipelines rather than personal ones.

At the same time, Diary functions as a structured journal. Rather than maintaining separate notes and contact records, every interaction becomes part of a searchable, person-centered timeline that supports both relationship management and long-term personal reflection.

Diary treats each conversation as an audience. Opening a person's profile shows the entries they'd care about: things that mention them directly, things that share a tag with their interests, and (optionally) life-changing events broadcast to everyone. Items fade on their own schedule - a passing thought is stale in days, a life event stays relevant for months - so the list is always short and always current. Marking a point as _said_ crosses it off for that person only.

## Core Architecture

### Local-First, One Data Layer Everywhere

The defining decision: **IndexedDB is the source of truth on every client**. Pages never wait on the network - every read (day view, talking points, calendar, search) is computed from a local mirror, and every write applies locally first. The server is a sync backend and auth provider.

#### The Sync Protocol

| Mechanism            | Implementation                          | Purpose                                                     |
| -------------------- | --------------------------------------- | ----------------------------------------------------------- |
| Client-generated ids | ObjectId-shaped, minted offline         | Identity and ordering survive replay                        |
| Push = replay        | Outbox stores `method, path, body`      | Plain REST calls replayed in order                          |
| Pull = cursor        | `GET /api/sync?since=` with 10s overlap | Idempotent upserts; tombstones propagate deletes            |
| Ordering guarantee   | Pull only after outbox drains           | Server state can't clobber unpushed edits                   |
| Conflicts            | Last-write-wins per document            | Right cost/benefit for single-user, rare multi-device edits |
| Recovery             | `/api/health` probe every 10s           | "Connection restored" toast on recovery                     |
| Live channel         | WebSocket "changed" nudges              | Other devices pull immediately; 60s fallback sync           |

Two authentication flows share one Better Auth backend. The **web** uses Google OAuth redirect with a same-origin session cookie. The **Android app** can't - Google blocks OAuth pages inside webviews - so it uses the platform's native Google Sign-In, hands the resulting idToken to Better Auth, and holds the session as a bearer token in Capacitor Preferences. The app stays usable offline via a cached user snapshot; an expired session shows a banner and never wipes local data.

> **Security posture.** The WebSocket upgrade authenticates with a single-use, 30-second ticket issued over a normally-authenticated call - session tokens never appear in URLs. All queries are ownership-scoped by user id; referenced tag/person ids are filtered against ownership on every write.

### The Scoring Engine

Talking points are candidates (mention, shared tag, or broadcast) scored with exponential decay and cut off at a configurable threshold:

```
score = importanceWeight(i) · matchStrength · 2^(−age / halfLife(i))
```

| Importance | Weight | Half-life | Color  | Meaning        |
| ---------- | ------ | --------- | ------ | -------------- |
| 1          | 1.0    | ~90 days  | Red    | Transformative |
| 2          | 0.8    | ~30 days  | Orange | Significant    |
| 3          | 0.6    | ~14 days  | Yellow | Notable        |
| 4          | 0.4    | ~7 days   | Green  | Minor          |
| 5          | 0.2    | ~3 days   | Slate  | Routinary      |

`matchStrength`: mention 1.0, tag 0.6, broadcast 0.4. Kept while score ≥ ε (0.05). Every constant is user-tunable in Settings, and the whole engine lives in the shared package - the phone computes the exact same rankings offline that the server would.

### Talking-Point Forest

The person profile renders talking points as a tree rather than a flat list. A matching sub-entry keeps its parent context alive, so nothing appears as an orphan. Children are partitioned into "forced visible" (a match exists somewhere in the branch) and "hidden" (no match - collapsible behind a `+N hidden sub-entries` toggle). A cluster-based counter counts distinct root trees rather than raw matches for the people-list badge.

### Data Model

| Collection       | Shape and Purpose                                                                                                                                                                     |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Entry**        | `content`, `dateKey` (YYYY-MM-DD, timezone-proof), `importance` 1–5, `tags[]`, `people[]` (mentions), `saidTo[]` (person + timestamp subdocs), `hiddenFor[]`, `parentId` (≤ 3 levels) |
| **Person**       | `name` (unique per user), `tags[]`, `notes`, `checkupIntervalDays`, `checkupEnabled`, `lastCheckupAt`, `events[]` (embedded: title, startKey, endKey, notes, asked)                   |
| **Tag**          | `name` (unique per user), `color` from cycling 10-color palette, `broadcast` (boolean)                                                                                                |
| **UserSettings** | Half-lives per level, ε cutoff, talking-point limit, memory thresholds, broadcast rules, default checkup interval, Groq/OpenRouter/Cerebras API keys                                  |
| **Deletion**     | Tombstones (`coll`, `docId`, `deletedAt`) so offline clients learn about deletes                                                                                                      |

Locally, Dexie mirrors these normalized (id references only, joined at read time), so a tag rename is instantly visible inside every entry without touching them. Entry _content_ still stores literal `@Name` and `#tag` strings, so a rename helper rewrites those mentions across all entries to keep styled highlights intact.

## Key Features

### Voice-to-Diary AI Pipeline

A microphone button in the entry composer records audio, sends it to Groq Whisper for transcription, then runs the transcript through a server-side tool-calling loop (`query_people → submit_entries`) against Groq, OpenRouter or Cerebras, producing structured diary suggestions the user reviews before accepting.

Key architectural decisions:

- **Plain OpenAI-style tool calling** on the server rather than an MCP SDK, with hard enforcement that the model must call `submit_entries` at least once.
- **Pre-generated IDs** on the client (`newObjectId()`) so nested parent-child suggestions can be created sequentially parent-first without server round-trips.
- **Settings keys** (`groqApiKey`, `openRouterApiKey`, `cerebrasApiKey`) stored with a "merge over current" pattern so stale offline `PUT /settings` replays from older clients can't wipe stored keys.
- **Combinable providers**: Groq stays mandatory for client-side Whisper transcription, while OpenRouter and Cerebras can be layered on top for the suggestion step. The server auto-selects whichever key is configured.
- **Existing tags only**: the LLM gets the full tag list as context and cannot invent new ones.

The fuzzy person search algorithm is hand-rolled and dependency-free: NFD normalization, token-level exact/prefix/substring scoring, and Levenshtein similarity as a fallback, weighted `name (1.0) > tags (0.6) > notes (0.4)`. Results are formatted as RFC-4180 CSV with proper quoting to minimize token usage for the LLM.

Although transcription and structured extraction form a single pipeline, only the transcription stage could realistically be performed locally with today's hardware. The semantic extraction stage relies on large language models capable of reliable reasoning, structured tool calling, and understanding existing diary context. The project therefore intentionally prioritizes extraction quality over complete offline AI execution.

### Events & Follow-Up Reminders

Life events (trips, medical procedures, exams) are tracked on a person's profile. After an event ends, the app reminds the user to ask how it went using a decay function: a follow-up scores ~1.0 the day after the event ends, halves every half-window, and hits zero after **7 × the event's length in days**. A one-day dentist appointment is forgotten after a week; a two-week trip stays relevant for over three months.

Events are embedded as an array on `PersonDto` rather than a separate sync collection, so the existing bulk-put sync engine replicates them for free - no tombstones, no new Dexie table, no sync engine changes. The UI includes an Events tab with Past → Ongoing → Upcoming sections, a pending follow-up banner, and people-list indicators that float overdue contacts to the top of their sort group.

### Checkup Alerts

Every person carries `checkupDays` and `checkupEnabled`. New profiles inherit defaults from app settings, but can be overridden per-person. If enough days pass without interaction (marking something as said), a banner appears on the person's profile with two actions: "mark checkup done" (records a timestamp) or "disable checkups for this person." The logic is wired through Mongoose models, DTOs, Zod schemas, API routes, React Query hooks, and both English and Spanish i18n files.

### Local Notifications

Two notification types are scheduled via `@capacitor/local-notifications`: per-person checkup reminders and a daily 23:45 nudge when the diary has no entries for that day.

- Future checkups schedule at their due time (works even while app is killed).
- Already-overdue checkups fire a catch-up notification ~5 seconds after discovery, but **only once per cycle** - tracked via a `notifiedCheckups` meta entry in the existing Dexie `db.meta` table.
- The daily reminder is self-healing: it shifts forward to "today 23:45" or "tomorrow 23:45" idempotently.
- Notification tap navigation opens the person's profile (checkups) or today's diary (daily reminder).
- UI indicators include a pending-checkups badge in the desktop sidebar and on the mobile tab bar.

Notifications carry personality: instead of dry copy, the app randomly selects from a pool of lighthearted, slightly absurd variants ranging from ghost jokes to Tamagotchi references to fake achievement-unlocked copy.

### Full Offline Use & Multi-Device Sync

Read _and write_ everything with no connection; changes queue and reconcile automatically when the server is reachable. A WebSocket channel nudges your other open devices after every change; edits propagate within moments.

## The Native Android App

The Android app (`es.tablerus.diary`) wraps the same built SPA with Capacitor 8 - assets ship inside the APK, API calls go to the production origin, and the local-first layer makes airplane mode a non-event. The native integration goes beyond a plain webview:

- **Hardware back button** closes open dialogs first, then walks history, and only exits from the root screen.
- **Haptics** via one global listener: a light tick on any interactive element press, a stronger cue on destructive confirms - nothing hand-wired, nothing forgotten.
- **Edge-to-edge** (Android 15) with safe-area insets resolved from both `env()` and Capacitor's injected variables; the status bar style follows the app theme.
- **Boot animation**: the native splash is a plain themed background; a web overlay draws the logo's alternate state and morphs it into the real mark before fading into the app.
- **Adaptive launcher icons** and splash assets generated for every density from one SVG source.
- **Always the phone layout** - bottom tab bar even at landscape widths, no autofocus keyboard pops, and the composer scrolls clear of the keyboard and tab bar.

A GitHub Actions workflow triggers on pushes to `main` touching `web/`, `shared/`, or lockfiles. It installs Node 22 and JDK 21, restores the signing keystore from a base64-encoded secret, builds web assets, assembles the signed APK, and publishes to a GitHub Release tagged `latest`. An in-app update checker fetches the latest GitHub release via the public API and shows a dismissible banner if newer.

## Design Philosophy

Several architectural and interface decisions throughout the project follow a small set of guiding principles:

- **Local-first by default**: the device owns the data; the server synchronizes it.
- **People - not notes - are the primary organizational unit.**
- **Information should naturally decay** instead of disappearing abruptly.
- **AI assists data entry but never silently modifies the diary.**
- **User data should occupy more visual attention than interface chrome.**
- **Visual elements communicate meaning before decoration.**

The interface intentionally prioritizes information density and consistency over visual novelty. Since the application's purpose is retrieving and recording information rather than showcasing media, decorative elements are intentionally minimized. Color is reserved for semantic meaning such as importance, reminders, birthdays and warnings, while typography, spacing and component structure remain consistent across the application.

## Technology Stack

| Layer           | Technology                                               | Notes                                                                            |
| --------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **UI**          | React 19, Vite 7, TypeScript, Tailwind CSS v4, shadcn/ui | SPA with react-router 7; i18next (es default / en); PWA via vite-plugin-pwa      |
| **Client data** | Dexie 4 (IndexedDB), TanStack Query 5                    | Local source of truth; query keys unchanged from server-first era                |
| **API**         | Hono 4 on Node                                           | zod-validated REST + sync pull + WebSocket (@hono/node-ws); serves SPA in prod   |
| **Auth**        | Better Auth (Google OAuth)                               | Cookie sessions on web; idToken sign-in + bearer plugin for app                  |
| **Database**    | MongoDB with Mongoose 8                                  | Better Auth shares same connection via MongoDB adapter                           |
| **Shared**      | `@diary/shared` workspace                                | zod schemas, DTO types, constants, scoring engine, person search, event decay    |
| **AI**          | Groq Whisper + Groq/OpenRouter/Cerebras chat             | Client-side Whisper; server-side tool-calling loop with retry logic              |
| **Mobile**      | Capacitor 8                                              | Social login, Haptics, Preferences, StatusBar, SplashScreen, Local Notifications |
| **Ops**         | Docker (multi-stage, node:24-alpine), GitHub Actions     | Single container: API + static SPA on one origin                                 |

## Quality & Verification

| Suite           | Location                          | Coverage                                                                                                                                                                                                                                                                             |
| --------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Data-layer      | `web/scripts/dbSmoke.ts`          | 28 checks: auto-said, scoring order, badge counts, hide/said exclusions, tree building, cascade deletes, accent-insensitive search, calendar aggregation, memories, outbox integrity, rename propagation                                                                             |
| Sync foundation | `server/src/scripts/syncSmoke.ts` | Client-id passthrough, duplicate-replay conflict behavior, cursor filtering, cascade tombstones - against real local MongoDB. Caught a bug where Mongoose forced `updatedAt = createdAt` on new documents, making replayed offline creates invisible to other devices' sync cursors. |
| Person search   | `shared/src/personSearch.test.ts` | Accents, typo tolerance, name/tag/notes weighting, CSV formatting - vitest                                                                                                                                                                                                           |
| Event decay     | `shared/src/scoring.test.ts`      | 21 assertions covering follow-up decay curve. One test documented intended behavior: a one-day event ending yesterday scored 0.82 while a 12-day trip ending nine days ago scored 0.86 - the trip ranked higher because nine days into an 84-day window is barely any decay.         |
| Live checks     | CI pipeline                       | Server boot, health endpoint, WebSocket upgrade rejection, unauthenticated REST 401s; full typecheck across all three workspaces; production builds for web, server, and both APK variants                                                                                           |

**100+ tests passing**, typecheck clean, i18n in sync at 261 keys across English and Spanish.

## Performance

The app started with a single 964 KB main chunk containing every page component eagerly bundled together. Route-level imports were converted to lazy-loaded chunks using `React.lazy()` and `Suspense`, keeping only the login page eager. This dropped the main bundle to **478 KB** and eliminated Vite's 500 KB warning.

The gzipped file is under 150 KB, so website loads are almost instant, while mobile loads finish during the opening animation. The combination of this with a local-first data source means the user perceives no delay in any action.

## Known Limitations & Trade-offs

- **Last-write-wins** can lose an intervening edit if the same entry is edited on two devices while one is offline - accepted for a single-user tool; per-field merge would be the upgrade path.
- **Distribution** is sideloaded APK by design; a Play Store internal track is the documented next step if auto-updates become worth the setup.
- **Android notification timing** is best-effort under Doze; exact alarms were deliberately avoided to keep the permission surface minimal.
- The single-user model is a feature, not a gap - there is no sharing, collaboration, or multi-tenant complexity anywhere in the design.
