feat: v0.1.0 - geolocation capture, calendar, search, Starlight docs site
- Automatic browser geolocation capture on event creation - Reverse geocoding via Nominatim API for place names - Full-text search with SQLite FTS5 - Calendar view for browsing past entries - DateNavigator component for day navigation - SearchModal with Ctrl+K shortcut - QuickAddWidget with Ctrl+J shortcut - Starlight documentation site with GitHub Pages deployment - Multiple AI provider support (Groq, OpenAI, Anthropic, Ollama, LM Studio) - Multi-user registration support BREAKING: Events now include latitude/longitude/placeName fields
This commit is contained in:
57
.github/workflows/docs.yml
vendored
Normal file
57
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'docs/node_modules'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: docs
|
||||
run: npm run build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,10 +2,12 @@
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
docs/node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
docs/dist/
|
||||
|
||||
# Data
|
||||
data/
|
||||
|
||||
201
AGENTS.md
Normal file
201
AGENTS.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# DearDiary - AI-Powered Daily Journal
|
||||
|
||||
Self-hosted journaling app where users capture events throughout the day and AI generates diary pages. Events become immutable once a diary is generated (locked).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Bun + Hono + Prisma (SQLite)
|
||||
- **Frontend**: React + Vite + TypeScript + Tailwind CSS
|
||||
- **Database**: SQLite (file-based)
|
||||
- **Deployment**: Docker
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/backend
|
||||
/prisma
|
||||
schema.prisma # Database schema (models: User, Event, Journal, Task, Settings)
|
||||
/src
|
||||
index.ts # Main API routes, journal generation logic
|
||||
/services/ai # AI provider implementations (Groq, OpenAI, Anthropic, Ollama, LMStudio)
|
||||
|
||||
/frontend
|
||||
/src
|
||||
/pages # Page components (Dashboard, Home, Journal, Diary, Settings, Day, Tasks, Calendar)
|
||||
/components # Reusable components (QuickAddWidget, SearchModal, DateNavigator, EntryList, etc.)
|
||||
/lib
|
||||
api.ts # API client with typed methods
|
||||
geolocation.ts # Browser geolocation with reverse geocoding
|
||||
ThemeContext.tsx # Dark/light theme
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Event vs Journal
|
||||
- **Event**: User input during the day (immutable after journal is generated)
|
||||
- **Journal**: AI-generated diary page from events (can regenerate, title + content)
|
||||
|
||||
### Terminology
|
||||
- "Diary Page" not "Journal"
|
||||
- "Event" not "Entry"
|
||||
- "Generate" not "Create"
|
||||
- "Rewrite" not "Regenerate"
|
||||
- "Today" is the event stream page (`/today`)
|
||||
|
||||
### Routes
|
||||
- `/` - Dashboard (recent diary pages with excerpts)
|
||||
- `/today` - Today's event stream (main capture page)
|
||||
- `/diary` - Paginated diary reader (10/50/100 per page)
|
||||
- `/journal/:date` - View/edit diary page with generation tasks
|
||||
- `/day/:date` - View day's events with DateNavigator
|
||||
- `/settings` - Configuration
|
||||
- `/calendar` - Month calendar view
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Journal Model (key fields)
|
||||
```
|
||||
id, userId, date, title, content, eventCount, generatedAt
|
||||
```
|
||||
|
||||
### Task Model
|
||||
Stores generation attempts with full request/response JSON for debugging.
|
||||
```
|
||||
id, userId, journalId, type, status, provider, model, prompt, request, response, error, title, createdAt, completedAt
|
||||
```
|
||||
|
||||
### Event Model (key fields)
|
||||
```
|
||||
id, userId, date, type, content, mediaPath, metadata, latitude, longitude, placeName, createdAt
|
||||
```
|
||||
- Location is captured automatically from browser geolocation when creating events
|
||||
- Reverse geocoding via OpenStreetMap Nominatim API provides place names
|
||||
|
||||
## API Design
|
||||
|
||||
All endpoints return: `{ data: T | null, error: { code, message } | null }`
|
||||
|
||||
### Authentication
|
||||
- API key in `Authorization: Bearer <key>` header
|
||||
- Keys stored as SHA-256 hashes
|
||||
|
||||
### Key Endpoints
|
||||
```
|
||||
POST /api/v1/journal/generate/:date # Generate diary (with optional instructions)
|
||||
GET /api/v1/journal/:date # Get diary page
|
||||
DELETE /api/v1/journal/:date # Delete to unlock events
|
||||
GET /api/v1/journal/:date/tasks # Generation tasks (includes title per task)
|
||||
GET /api/v1/journals # List journals with pagination (?page=1&limit=10)
|
||||
GET /api/v1/days # List days with journal info (includes excerpt)
|
||||
POST /api/v1/events # Create event
|
||||
GET /api/v1/export # Export all user data (JSON)
|
||||
POST /api/v1/import # Import data (with version checking)
|
||||
```
|
||||
|
||||
## Export/Import
|
||||
|
||||
### Export Format
|
||||
Exports include:
|
||||
- `version`: DearDiary version string (e.g., "0.1.0")
|
||||
- `exportedAt`: ISO timestamp of export
|
||||
- `settings`: User settings including AI provider configuration
|
||||
- `events`: All user events (includes latitude, longitude, placeName)
|
||||
- `journals`: All generated diary pages
|
||||
- `tasks`: All generation tasks
|
||||
|
||||
### Version Compatibility
|
||||
- Minimum supported import version: 0.0.3
|
||||
- Import validates version compatibility
|
||||
- Warns if importing older/newer version
|
||||
- Older exports may fail or lose data
|
||||
|
||||
### Import Behavior
|
||||
- Duplicates are skipped (based on date + content + timestamp for events)
|
||||
- Journals matched by date
|
||||
- Tasks linked to journals by date
|
||||
- Settings are overwritten
|
||||
|
||||
## AI Integration
|
||||
|
||||
### Providers
|
||||
- Groq (default, uses llama-3.3-70b-versatile)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Ollama (local)
|
||||
- LM Studio (local)
|
||||
|
||||
### Prompt System
|
||||
- Default system prompt is defined in `backend/src/index.ts` (hardcoded, not user-configurable)
|
||||
- User can add custom instructions via `settings.journalPrompt` field (labeled "Prompt" in UI)
|
||||
- Custom instructions are prepended to the default prompt when set
|
||||
- Default prompt includes anti-hallucination rules and structure guidelines
|
||||
|
||||
### JSON Mode
|
||||
AI is configured to return JSON with `response_format: { type: "json_object" }` where supported. Journal generation prompts instruct AI to return:
|
||||
```json
|
||||
{"title": "Short title", "content": "Diary text..."}
|
||||
```
|
||||
|
||||
### Provider Settings Storage
|
||||
Settings stored as `providerSettings: { "groq": { apiKey, model, baseUrl }, ... }` with `aiProvider` determining which is active.
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### TypeScript
|
||||
- Use explicit interfaces for API responses
|
||||
- Avoid `any` types
|
||||
- Use optional chaining and nullish coalescing
|
||||
|
||||
### React Components
|
||||
- Functional components with hooks
|
||||
- Props interfaces defined at top of file
|
||||
- Use `useState` for local state, `useEffect` for data loading
|
||||
|
||||
### Tailwind CSS
|
||||
- Dark theme by default (slate color palette)
|
||||
- Use `text-slate-400` for muted text
|
||||
- Use `purple-*` for primary actions
|
||||
- Use `slate-900` for cards/containers
|
||||
|
||||
### API Response Handling
|
||||
```typescript
|
||||
const res = await api.someMethod();
|
||||
if (res.error) {
|
||||
// handle error
|
||||
} else if (res.data) {
|
||||
// use res.data
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Backend returns `{ code, message }` errors
|
||||
- Frontend displays errors inline or as toast notifications
|
||||
- Generation errors shown in red banner
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a new AI provider
|
||||
1. Create `/backend/src/services/ai/<provider>.ts`
|
||||
2. Implement `AIProvider` interface with `generate(prompt, systemPrompt, options?)`
|
||||
3. Add to `provider.ts` `createAIProvider()` switch
|
||||
4. Add `jsonMode` parsing if supported
|
||||
|
||||
### Database migrations
|
||||
```bash
|
||||
cd backend
|
||||
bunx prisma migrate dev --name migration_name
|
||||
```
|
||||
|
||||
### Docker rebuild
|
||||
```bash
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
## Version History
|
||||
- 0.1.0: Automatic geolocation capture, Starlight documentation site
|
||||
- 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names
|
||||
- 0.0.5: Export/Import feature with version checking
|
||||
- 0.0.4: /diary page with pagination (10/50/100), Task.title field, dashboard excerpts
|
||||
- 0.0.3: AI returns JSON with title + content, UI shows titles
|
||||
- 0.0.2: Dashboard, Quick Add widget (Ctrl+J), rewrite modal
|
||||
- 0.0.1: Initial release with Entry->Event terminology fix
|
||||
176
README.md
176
README.md
@@ -1,133 +1,127 @@
|
||||
# DearDiary
|
||||
|
||||
> Your day, analyzed. A journal that writes itself.
|
||||
Self-hosted AI-powered daily journaling application. Capture events throughout the day and let AI generate thoughtful diary pages from your entries.
|
||||
|
||||
AI-powered daily journal that captures life through multiple input methods and generates thoughtful, reflective journal entries.
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for detailed version history.
|
||||
[](https://github.com/anomalyco/totalrecall)
|
||||
[](https://anomalyco.github.io/totalrecall)
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Input Types**: Text notes, photos, voice memos, health data
|
||||
- **AI Journal Generation**: OpenAI, Anthropic, Ollama, or LM Studio
|
||||
- **Self-Hostable**: Run it yourself or use any hosted version
|
||||
- **Same Codebase**: Single deployment works for single-user or multi-tenant
|
||||
- **Task History**: Full logging of AI requests and responses
|
||||
- Quick event capture with keyboard shortcut (Ctrl+J)
|
||||
- Multiple event types: text, health, photo, voice
|
||||
- **Automatic geolocation** - events tagged with your location
|
||||
- AI-powered diary page generation with customizable providers
|
||||
- Events become immutable once a diary is generated (prevents editing past memories)
|
||||
- Regenerate diary pages with additional instructions
|
||||
- Generation tasks with title tracking per task
|
||||
- Read all diaries with pagination (10/50/100 per page)
|
||||
- Dashboard with diary excerpts
|
||||
- Calendar view for browsing past entries
|
||||
- Full-text search across events and diaries
|
||||
- Export/Import data with version checking
|
||||
- Dark/light theme support
|
||||
- Self-hostable with Docker
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-repo/deardiary.git
|
||||
cd deardiary
|
||||
# Clone the repository
|
||||
git clone https://github.com/anomalyco/totalrecall.git
|
||||
cd totalrecall
|
||||
|
||||
# Create .env file
|
||||
cp backend/.env.example .env
|
||||
# Edit .env and set JWT_SECRET
|
||||
|
||||
# Start
|
||||
# Start with Docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Visit http://localhost:5173
|
||||
Access the app at `http://localhost:8080`
|
||||
|
||||
### Manual Development
|
||||
Default credentials: `admin@localhost` / `changeme123`
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available at [https://anomalyco.github.io/totalrecall](https://anomalyco.github.io/totalrecall)
|
||||
|
||||
Topics covered:
|
||||
- [Installation](https://anomalyco.github.io/totalrecall/getting-started/installation/)
|
||||
- [Quick Start](https://anomalyco.github.io/totalrecall/getting-started/quick-start/)
|
||||
- [Configuration](https://anomalyco.github.io/totalrecall/getting-started/configuration/)
|
||||
- [Features](https://anomalyco.github.io/totalrecall/features/events/)
|
||||
- [API Reference](https://anomalyco.github.io/totalrecall/api/authentication/)
|
||||
- [Deployment](https://anomalyco.github.io/totalrecall/deployment/docker/)
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Go to Settings and select your AI provider (Groq, OpenAI, Anthropic, Ollama, LM Studio)
|
||||
2. Enter your API key for the selected provider
|
||||
3. Optionally customize the model and base URL
|
||||
4. Test the connection before saving
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Bun
|
||||
- Node.js 20+
|
||||
- Docker
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
bun install
|
||||
bunx prisma generate
|
||||
bunx prisma db push
|
||||
bun run dev
|
||||
```
|
||||
|
||||
# Frontend (separate terminal)
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `file:./data/deardiary.db` | SQLite, PostgreSQL, or MySQL |
|
||||
| `JWT_SECRET` | (required) | Secret for JWT signing |
|
||||
| `MEDIA_DIR` | `./data/media` | Directory for uploads |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `CORS_ORIGIN` | `*` | Allowed origins |
|
||||
|
||||
### Database Examples
|
||||
### Build Docs Locally
|
||||
|
||||
```bash
|
||||
# SQLite (default)
|
||||
DATABASE_URL="file:./data/deardiary.db"
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL="postgresql://user:pass@host:5432/deardiary"
|
||||
|
||||
# MySQL
|
||||
DATABASE_URL="mysql://user:pass@host:3306/deardiary"
|
||||
cd docs
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## AI Providers
|
||||
### Docker Build
|
||||
|
||||
Configure in Settings after logging in:
|
||||
|
||||
| Provider | Setup |
|
||||
|----------|-------|
|
||||
| **OpenAI** | API key required |
|
||||
| **Anthropic** | API key required |
|
||||
| **Ollama** | Local URL (default: http://localhost:11434) |
|
||||
| **LM Studio** | Local URL (default: http://localhost:1234/v1) |
|
||||
```bash
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
deardiary/
|
||||
├── backend/ # Bun + Hono API server
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API endpoints
|
||||
│ │ ├── services/ # AI providers
|
||||
│ │ └── middleware/
|
||||
│ └── prisma/ # Database schema
|
||||
├── frontend/ # React + Vite web app
|
||||
├── android/ # Native Android app (Kotlin + Compose)
|
||||
├── docker-compose.yml
|
||||
└── PLAN.md # Full specification
|
||||
├── backend/ # Hono + Prisma backend
|
||||
│ ├── prisma/ # Database schema
|
||||
│ └── src/ # API routes and AI services
|
||||
├── frontend/ # React + Vite frontend
|
||||
│ └── src/
|
||||
│ ├── pages/ # Page components
|
||||
│ ├── components/
|
||||
│ └── lib/ # API client, geolocation
|
||||
├── docs/ # Starlight documentation site
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## API
|
||||
## Routes
|
||||
|
||||
All endpoints require `Authorization: Bearer {api_key}` header.
|
||||
|
||||
```
|
||||
POST /api/v1/auth/register
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/api-key
|
||||
|
||||
GET /api/v1/days
|
||||
GET /api/v1/days/:date
|
||||
DELETE /api/v1/days/:date
|
||||
|
||||
POST /api/v1/entries
|
||||
GET /api/v1/entries/:id
|
||||
PUT /api/v1/entries/:id
|
||||
DELETE /api/v1/entries/:id
|
||||
POST /api/v1/entries/:id/photo
|
||||
POST /api/v1/entries/:id/voice
|
||||
|
||||
POST /api/v1/journal/generate/:date
|
||||
GET /api/v1/journal/:date
|
||||
GET /api/v1/journal/:date/tasks
|
||||
|
||||
GET /api/v1/settings
|
||||
PUT /api/v1/settings
|
||||
```
|
||||
- `/` - Dashboard with recent diary pages and excerpts
|
||||
- `/today` - Today's event stream (main capture page)
|
||||
- `/diary` - Paginated diary reader (10/50/100 per page)
|
||||
- `/journal/:date` - View/edit diary page with generation tasks
|
||||
- `/day/:date` - View day's events
|
||||
- `/calendar` - Calendar view
|
||||
- `/settings` - Configuration and Export/Import
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT License
|
||||
|
||||
Copyright 2026 Konrad Lother
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
# Database connection (SQLite, PostgreSQL, or MySQL)
|
||||
# =============================================================================
|
||||
# DearDiary Configuration
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database (SQLite, PostgreSQL, or MySQL)
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL="file:./data/deardiary.db"
|
||||
|
||||
# Media storage directory
|
||||
MEDIA_DIR="./data/media"
|
||||
# Example PostgreSQL:
|
||||
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
|
||||
|
||||
# JWT secret for authentication tokens (REQUIRED in production)
|
||||
JWT_SECRET="change-this-to-a-random-string-in-production"
|
||||
# Example MySQL:
|
||||
# DATABASE_URL="mysql://root:password@localhost:3306/deardiary"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application
|
||||
# -----------------------------------------------------------------------------
|
||||
# App name displayed in UI
|
||||
APP_NAME="DearDiary"
|
||||
|
||||
# App version
|
||||
VERSION="0.1.0"
|
||||
|
||||
# Server port
|
||||
PORT="3000"
|
||||
|
||||
# CORS origin (use specific domain in production)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Security
|
||||
# -----------------------------------------------------------------------------
|
||||
# JWT secret for authentication tokens (REQUIRED in production)
|
||||
JWT_SECRET="change-this-to-a-random-string-in-production"
|
||||
|
||||
# CORS origin (use specific domain in production, e.g., "https://yourapp.com")
|
||||
CORS_ORIGIN="*"
|
||||
|
||||
# Default user (auto-created on startup if doesn't exist)
|
||||
# -----------------------------------------------------------------------------
|
||||
# User Management
|
||||
# -----------------------------------------------------------------------------
|
||||
# Enable/disable user registration ("true" or "false")
|
||||
REGISTRATION_ENABLED="false"
|
||||
|
||||
# Default admin user (auto-created on startup if doesn't exist)
|
||||
DEFAULT_USER_EMAIL="admin@localhost"
|
||||
DEFAULT_USER_PASSWORD="changeme123"
|
||||
|
||||
# Default journal prompt (strict anti-hallucination)
|
||||
# JOURNAL_PROMPT="You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.\n\nCRITICAL RULES:\n1. ONLY use information explicitly stated in the entries below\n2. NEVER invent, assume, or hallucinate any detail not in the entries\n3. NEVER add activities, emotions, weather, or context not directly mentioned\n4. If something is unclear in the entries, simply state what IS clear\n5. Keep the summary grounded and factual - no embellishment\n6. Do not write in an overly creative or story-telling style\n7. Only reference what the user explicitly recorded"
|
||||
|
||||
# Example PostgreSQL connection:
|
||||
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
|
||||
|
||||
# Example MySQL connection:
|
||||
# DATABASE_URL="mysql://root:password@localhost:3306/deardiary"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Storage
|
||||
# -----------------------------------------------------------------------------
|
||||
# Media storage directory
|
||||
MEDIA_DIR="./data/media"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "deardiary-backend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
|
||||
@@ -42,6 +42,9 @@ model Event {
|
||||
content String
|
||||
mediaPath String?
|
||||
metadata String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
placeName String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -55,6 +58,7 @@ model Journal {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
date String
|
||||
title String?
|
||||
content String
|
||||
eventCount Int
|
||||
generatedAt DateTime @default(now())
|
||||
@@ -78,6 +82,7 @@ model Task {
|
||||
request String?
|
||||
response String?
|
||||
error String?
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
@@ -94,7 +99,7 @@ model Settings {
|
||||
aiApiKey String?
|
||||
aiModel String @default("llama-3.3-70b-versatile")
|
||||
aiBaseUrl String?
|
||||
journalPrompt String @default("You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.\n\nCRITICAL RULES:\n1. ONLY use information explicitly stated in the entries below\n2. NEVER invent, assume, or hallucinate any detail not in the entries\n3. NEVER add activities, emotions, weather, or context not directly mentioned\n4. If something is unclear in the entries, simply state what IS clear\n5. Keep the summary grounded and factual - no embellishment\n6. Do not write in an overly creative or story-telling style\n7. Only reference what the user explicitly recorded\n\nStructure:\n- Start with what was recorded (meetings, tasks, activities)\n- Note any explicit feelings or observations mentioned\n- Keep it concise and factual\n- If there are gaps in the day, acknowledge only what was recorded")
|
||||
journalPrompt String?
|
||||
language String @default("en")
|
||||
timezone String @default("UTC")
|
||||
providerSettings String?
|
||||
|
||||
@@ -114,6 +114,17 @@ app.post('/api/v1/auth/login', async (c) => {
|
||||
return c.json({ data: { token, userId: user.id }, error: null });
|
||||
});
|
||||
|
||||
app.get('/api/v1/server-info', async (c) => {
|
||||
return c.json({
|
||||
data: {
|
||||
version: envVars.VERSION || '0.1.0',
|
||||
registrationEnabled: envVars.REGISTRATION_ENABLED !== 'false',
|
||||
appName: envVars.APP_NAME || 'DearDiary',
|
||||
},
|
||||
error: null
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/api-key', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
@@ -156,17 +167,22 @@ app.get('/api/v1/days', async (c) => {
|
||||
|
||||
const journals = await prisma.journal.findMany({
|
||||
where: { userId },
|
||||
select: { date: true, generatedAt: true },
|
||||
select: { date: true, title: true, generatedAt: true, content: true },
|
||||
});
|
||||
|
||||
const journalMap = new Map(journals.map(j => [j.date, j]));
|
||||
|
||||
const result = days.map(day => ({
|
||||
date: day.date,
|
||||
eventCount: day._count.id,
|
||||
hasJournal: journalMap.has(day.date),
|
||||
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
|
||||
}));
|
||||
const result = days.map(day => {
|
||||
const journal = journalMap.get(day.date);
|
||||
return {
|
||||
date: day.date,
|
||||
eventCount: day._count.id,
|
||||
hasJournal: !!journal,
|
||||
journalTitle: journal?.title,
|
||||
journalGeneratedAt: journal?.generatedAt,
|
||||
journalExcerpt: journal?.content?.substring(0, 250),
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ data: result, error: null });
|
||||
});
|
||||
@@ -203,80 +219,78 @@ app.delete('/api/v1/days/:date', async (c) => {
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
|
||||
// Delete journal only (keeps events)
|
||||
app.delete('/api/v1/journal/:date', async (c) => {
|
||||
app.get('/api/v1/search', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { date } = c.req.param();
|
||||
|
||||
const journal = await prisma.journal.findFirst({ where: { userId, date } });
|
||||
if (!journal) {
|
||||
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Journal not found' } }, 404);
|
||||
const query = c.req.query('q') || '';
|
||||
if (query.length < 2) {
|
||||
return c.json({ data: { journals: [], events: [] }, error: null });
|
||||
}
|
||||
|
||||
await prisma.journal.delete({ where: { id: journal.id } });
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
|
||||
// Events routes
|
||||
app.post('/api/v1/events', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { date, type, content, metadata } = await c.req.json();
|
||||
|
||||
if (!date || !type || !content) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
|
||||
try {
|
||||
const safeQuery = query.replace(/['"]/g, '');
|
||||
const searchTerm = safeQuery.split(/\s+/).filter(t => t.length > 0).map(t => `${t}*`).join(' OR ') || `${safeQuery}*`;
|
||||
|
||||
let journalResults: Array<{ userId: string; date: string; title: string; content: string }> = [];
|
||||
let eventResults: Array<{ userId: string; date: string; type: string; content: string }> = [];
|
||||
|
||||
try {
|
||||
journalResults = await prisma.$queryRaw<Array<{ userId: string; date: string; title: string; content: string }>>`
|
||||
SELECT userId, date, title, content
|
||||
FROM journal_fts
|
||||
WHERE userId = ${userId} AND journal_fts MATCH ${searchTerm}
|
||||
ORDER BY rank
|
||||
LIMIT 20
|
||||
`;
|
||||
} catch (e) {
|
||||
console.error('Journal FTS search error:', e);
|
||||
const journals = await prisma.journal.findMany({
|
||||
where: { userId, OR: [
|
||||
{ title: { contains: safeQuery } },
|
||||
{ content: { contains: safeQuery } }
|
||||
]},
|
||||
take: 20,
|
||||
});
|
||||
journalResults = journals.map(j => ({ userId: j.userId, date: j.date, title: j.title || '', content: j.content }));
|
||||
}
|
||||
|
||||
try {
|
||||
eventResults = await prisma.$queryRaw<Array<{ userId: string; date: string; type: string; content: string }>>`
|
||||
SELECT userId, date, type, content
|
||||
FROM event_fts
|
||||
WHERE userId = ${userId} AND event_fts MATCH ${searchTerm}
|
||||
ORDER BY rank
|
||||
LIMIT 20
|
||||
`;
|
||||
} catch (e) {
|
||||
console.error('Event FTS search error:', e);
|
||||
const events = await prisma.event.findMany({
|
||||
where: { userId, content: { contains: safeQuery } },
|
||||
take: 20,
|
||||
});
|
||||
eventResults = events.map(e => ({ userId: e.userId, date: e.date, type: e.type, content: e.content }));
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
journals: journalResults.map(j => ({
|
||||
date: j.date,
|
||||
title: j.title,
|
||||
excerpt: j.content.substring(0, 200),
|
||||
})),
|
||||
events: eventResults.map(e => ({
|
||||
date: e.date,
|
||||
type: e.type,
|
||||
content: e.content,
|
||||
})),
|
||||
},
|
||||
error: null
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Search error:', err);
|
||||
return c.json({ data: { journals: [], events: [] }, error: null });
|
||||
}
|
||||
|
||||
const validTypes = ['event'];
|
||||
if (!validTypes.includes(type)) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400);
|
||||
}
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: { userId, date, type, content, metadata: metadata ? JSON.stringify(metadata) : null },
|
||||
});
|
||||
|
||||
return c.json({ data: event, error: null }, 201);
|
||||
});
|
||||
|
||||
app.get('/api/v1/events/:id', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { id } = c.req.param();
|
||||
const event = await prisma.event.findFirst({ where: { id, userId } });
|
||||
|
||||
if (!event) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
|
||||
return c.json({ data: event, error: null });
|
||||
});
|
||||
|
||||
app.put('/api/v1/events/:id', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { id } = c.req.param();
|
||||
const { content, metadata } = await c.req.json();
|
||||
|
||||
const existing = await prisma.event.findFirst({ where: { id, userId } });
|
||||
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
|
||||
|
||||
const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
|
||||
if (journal) {
|
||||
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot edit event: journal already generated. Delete the journal first.' } }, 400);
|
||||
}
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id },
|
||||
data: {
|
||||
content: content ?? existing.content,
|
||||
metadata: metadata !== undefined ? JSON.stringify(metadata) : existing.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: event, error: null });
|
||||
});
|
||||
|
||||
app.delete('/api/v1/events/:id', async (c) => {
|
||||
@@ -302,6 +316,8 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { date } = c.req.param();
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const additionalInstructions = body.instructions || '';
|
||||
|
||||
const [events, settings] = await Promise.all([
|
||||
prisma.event.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
|
||||
@@ -324,10 +340,14 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
// Build events text
|
||||
const eventsText = events.map(event => {
|
||||
let text = `[EVENT] ${event.createdAt.toISOString()}\n${event.content}`;
|
||||
if (event.placeName) {
|
||||
text += `\nLocation: ${event.placeName}`;
|
||||
} else if (event.latitude && event.longitude) {
|
||||
text += `\nLocation: ${event.latitude}, ${event.longitude}`;
|
||||
}
|
||||
if (event.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(event.metadata);
|
||||
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
|
||||
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
|
||||
} catch {}
|
||||
}
|
||||
@@ -356,22 +376,57 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
|
||||
if (previousJournals.length > 0) {
|
||||
previousJournalsText = `PREVIOUS DIARIES:\n${previousJournals.map(j =>
|
||||
`[${j.date}]\n${j.content}`
|
||||
`[${j.date}]\n${j.title ? `Title: ${j.title}\n` : ''}${j.content}`
|
||||
).join('\n\n')}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build prompts: 1. system prompt, 2. previous journals, 3. today's events
|
||||
const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.';
|
||||
const userPrompt = `${previousJournalsText}EVENTS FROM TODAY (${date}):\n${eventsText}\n\nWrite a thoughtful, reflective journal entry based on the events above.`;
|
||||
const jsonInstruction = `IMPORTANT: Return ONLY valid JSON in this exact format, nothing else:
|
||||
{"title": "A short, descriptive title for this day (max 50 characters)", "content": "Your diary entry text here..."}
|
||||
|
||||
Do not include any text before or after the JSON. Do not use markdown code blocks.`;
|
||||
|
||||
// Build system prompt from user's settings + JSON instruction
|
||||
const defaultPrompt = `You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. ONLY use information explicitly stated in the entries below
|
||||
2. NEVER invent, assume, or hallucinate any detail not in the entries
|
||||
3. NEVER add activities, emotions, weather, or context not directly mentioned
|
||||
4. If something is unclear in the entries, simply state what IS clear
|
||||
5. Keep the summary grounded and factual - no embellishment
|
||||
6. Do not write in an overly creative or story-telling style
|
||||
7. Only reference what the user explicitly recorded
|
||||
8. NEVER write any preamble, meta-commentary, or statements about how you are writing
|
||||
9. NEVER include any closing remarks, sign-offs, or follow-up offers`;
|
||||
|
||||
const userInstructions = settings?.journalPrompt;
|
||||
const systemPromptWithJson = userInstructions
|
||||
? `${defaultPrompt}\n\n${jsonInstruction}\n\nCUSTOM USER INSTRUCTIONS:\n${userInstructions}`
|
||||
: `${defaultPrompt}\n\n${jsonInstruction}`;
|
||||
|
||||
let userPrompt = `${previousJournalsText}EVENTS FROM TODAY (${date}):\n${eventsText}`;
|
||||
|
||||
if (additionalInstructions) {
|
||||
userPrompt = `${userPrompt}\n\nADDITIONAL USER INSTRUCTIONS:\n${additionalInstructions}`;
|
||||
}
|
||||
|
||||
console.log(`[Journal Generate] Date: ${date}, Context days: ${contextDays}, Events: ${events.length}`);
|
||||
|
||||
// Create placeholder journal and task
|
||||
const placeholderJournal = await prisma.journal.create({
|
||||
data: { userId, date, content: 'Generating...', eventCount: events.length },
|
||||
// Check if journal already exists for this date
|
||||
const existingJournal = await prisma.journal.findUnique({
|
||||
where: { userId_date: { userId, date } },
|
||||
});
|
||||
|
||||
// Use upsert to handle both create and regenerate cases
|
||||
const placeholderJournal = await prisma.journal.upsert({
|
||||
where: { userId_date: { userId, date } },
|
||||
create: { userId, date, content: 'Generating...', eventCount: events.length },
|
||||
update: { content: 'Generating...', eventCount: events.length, generatedAt: new Date() },
|
||||
});
|
||||
|
||||
const model = providerConfig.model || settings?.aiModel;
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -379,23 +434,16 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
type: 'journal_generate',
|
||||
status: 'pending',
|
||||
provider,
|
||||
model: settings?.aiModel,
|
||||
model: model,
|
||||
prompt: `${systemPrompt}\n\n---\n\n${userPrompt}`,
|
||||
request: '',
|
||||
response: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Update journal with taskId
|
||||
await prisma.journal.update({
|
||||
where: { id: placeholderJournal.id },
|
||||
data: { id: placeholderJournal.id },
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`[Journal Generate] Using provider: ${provider}`);
|
||||
|
||||
const model = providerConfig.model || settings?.aiModel;
|
||||
console.log(`[Journal Generate] Using model: ${model}`);
|
||||
const baseUrl = providerConfig.baseUrl || settings?.aiBaseUrl;
|
||||
|
||||
const aiProvider = createAIProvider({
|
||||
@@ -407,13 +455,13 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
|
||||
console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`);
|
||||
|
||||
const result = await aiProvider.generate(userPrompt, systemPrompt);
|
||||
const result = await aiProvider.generate(userPrompt, systemPromptWithJson, { jsonMode: true });
|
||||
|
||||
if (!result.content) {
|
||||
throw new Error('No content generated from AI');
|
||||
}
|
||||
|
||||
console.log(`[Journal Generate] Success! Content length: ${result.content.length}`);
|
||||
console.log(`[Journal Generate] Success! Content length: ${result.content.length}, Title: ${result.title || 'N/A'}`);
|
||||
|
||||
// Update task with success - store full request and response JSON
|
||||
await prisma.task.update({
|
||||
@@ -422,14 +470,19 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
status: 'completed',
|
||||
request: JSON.stringify(result.request, null, 2),
|
||||
response: JSON.stringify(result.response, null, 2),
|
||||
title: result.title,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update journal with content
|
||||
// Update journal with content and title
|
||||
const journal = await prisma.journal.update({
|
||||
where: { id: placeholderJournal.id },
|
||||
data: { content: result.content, generatedAt: new Date() },
|
||||
data: {
|
||||
content: result.content,
|
||||
title: result.title,
|
||||
generatedAt: new Date()
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: { journal, task }, error: null });
|
||||
@@ -448,8 +501,16 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Delete placeholder journal
|
||||
await prisma.journal.delete({ where: { id: placeholderJournal.id } });
|
||||
// Only delete journal if it was newly created (not an existing one)
|
||||
if (!existingJournal) {
|
||||
await prisma.journal.delete({ where: { id: placeholderJournal.id } });
|
||||
} else {
|
||||
// Restore the existing content
|
||||
await prisma.journal.update({
|
||||
where: { id: placeholderJournal.id },
|
||||
data: { content: existingJournal.content },
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ data: null, error: { code: 'AI_ERROR', message: `Failed to generate journal: ${errorMessage}` } }, 500);
|
||||
}
|
||||
@@ -469,9 +530,10 @@ app.get('/api/v1/journal/:date/tasks', async (c) => {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { journalId: journal.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { journal: { select: { title: true } } },
|
||||
});
|
||||
|
||||
return c.json({ data: tasks, error: null });
|
||||
return c.json({ data: tasks.map(t => ({ ...t, journalTitle: t.journal?.title || null })), error: null });
|
||||
});
|
||||
|
||||
app.get('/api/v1/tasks/:id', async (c) => {
|
||||
@@ -502,6 +564,30 @@ app.get('/api/v1/journal/:date', async (c) => {
|
||||
return c.json({ data: journal, error: null });
|
||||
});
|
||||
|
||||
app.get('/api/v1/journals', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const page = parseInt(c.req.query('page') || '1');
|
||||
const limit = parseInt(c.req.query('limit') || '10');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [journals, total] = await Promise.all([
|
||||
prisma.journal.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: 'desc' },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.journal.count({ where: { userId } }),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
data: { journals, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
// Settings routes
|
||||
app.get('/api/v1/settings', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
@@ -512,6 +598,11 @@ app.get('/api/v1/settings', async (c) => {
|
||||
settings = await prisma.settings.create({ data: { userId } });
|
||||
}
|
||||
|
||||
const oldDefaultLength = 400;
|
||||
if (settings.journalPrompt && settings.journalPrompt.length > oldDefaultLength) {
|
||||
settings.journalPrompt = null;
|
||||
}
|
||||
|
||||
return c.json({ data: settings, error: null });
|
||||
});
|
||||
|
||||
@@ -527,7 +618,7 @@ app.put('/api/v1/settings', async (c) => {
|
||||
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
|
||||
if (aiModel !== undefined) data.aiModel = aiModel;
|
||||
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
|
||||
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
|
||||
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt === '' ? null : journalPrompt;
|
||||
if (language !== undefined) data.language = language;
|
||||
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
|
||||
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays;
|
||||
@@ -589,12 +680,489 @@ app.post('/api/v1/ai/test', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
const DEARDIARY_VERSION = '0.1.0';
|
||||
const MIN_IMPORT_VERSION = '0.0.3';
|
||||
|
||||
interface ExportData {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
settings: {
|
||||
aiProvider: string;
|
||||
aiApiKey?: string;
|
||||
aiModel?: string;
|
||||
aiBaseUrl?: string;
|
||||
journalPrompt?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
providerSettings?: string;
|
||||
journalContextDays?: number;
|
||||
};
|
||||
events: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
type: string;
|
||||
content: string;
|
||||
mediaPath?: string;
|
||||
metadata?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
placeName?: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
journals: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
eventCount: number;
|
||||
generatedAt: string;
|
||||
}>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
journalId: string;
|
||||
date: string;
|
||||
type: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
request?: string;
|
||||
response?: string;
|
||||
error?: string;
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
app.get('/api/v1/export', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const [settings, events, journals, tasks] = await Promise.all([
|
||||
prisma.settings.findUnique({ where: { userId } }),
|
||||
prisma.event.findMany({ where: { userId }, orderBy: { createdAt: 'asc' } }),
|
||||
prisma.journal.findMany({ where: { userId }, orderBy: { date: 'asc' } }),
|
||||
prisma.task.findMany({ where: { userId }, orderBy: { createdAt: 'asc' } }),
|
||||
]);
|
||||
|
||||
const journalMap = new Map(journals.map(j => [j.id, j]));
|
||||
|
||||
const exportData: ExportData = {
|
||||
version: DEARDIARY_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings: {
|
||||
aiProvider: settings?.aiProvider || 'groq',
|
||||
aiApiKey: settings?.aiApiKey,
|
||||
aiModel: settings?.aiModel,
|
||||
aiBaseUrl: settings?.aiBaseUrl,
|
||||
journalPrompt: settings?.journalPrompt,
|
||||
language: settings?.language,
|
||||
timezone: settings?.timezone,
|
||||
providerSettings: settings?.providerSettings,
|
||||
journalContextDays: settings?.journalContextDays,
|
||||
},
|
||||
events: events.map(e => ({
|
||||
id: e.id,
|
||||
date: e.date,
|
||||
type: e.type,
|
||||
content: e.content,
|
||||
mediaPath: e.mediaPath || undefined,
|
||||
metadata: e.metadata || undefined,
|
||||
latitude: e.latitude ?? undefined,
|
||||
longitude: e.longitude ?? undefined,
|
||||
placeName: e.placeName ?? undefined,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
})),
|
||||
journals: journals.map(j => ({
|
||||
id: j.id,
|
||||
date: j.date,
|
||||
title: j.title || undefined,
|
||||
content: j.content,
|
||||
eventCount: j.eventCount,
|
||||
generatedAt: j.generatedAt.toISOString(),
|
||||
})),
|
||||
tasks: tasks.map(t => {
|
||||
const journal = journalMap.get(t.journalId);
|
||||
return {
|
||||
id: t.id,
|
||||
journalId: t.journalId,
|
||||
date: journal?.date || '',
|
||||
type: t.type,
|
||||
status: t.status,
|
||||
provider: t.provider,
|
||||
model: t.model || undefined,
|
||||
prompt: t.prompt || undefined,
|
||||
request: t.request || undefined,
|
||||
response: t.response || undefined,
|
||||
error: t.error || undefined,
|
||||
title: t.title || undefined,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
completedAt: t.completedAt?.toISOString() || undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return c.json({ data: exportData, error: null });
|
||||
});
|
||||
|
||||
app.post('/api/v1/import', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
let importData: ExportData;
|
||||
try {
|
||||
importData = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ data: null, error: { code: 'INVALID_JSON', message: 'Invalid JSON body' } }, 400);
|
||||
}
|
||||
|
||||
if (!importData.version || !importData.events || !importData.journals) {
|
||||
return c.json({ data: null, error: { code: 'INVALID_FORMAT', message: 'Invalid export format: missing required fields' } }, 400);
|
||||
}
|
||||
|
||||
const versionParts = importData.version.split('.');
|
||||
const minParts = MIN_IMPORT_VERSION.split('.');
|
||||
const versionNum = parseInt(versionParts[0]) * 10000 + parseInt(versionParts[1]) * 100 + parseInt(versionParts[2] || '0');
|
||||
const minNum = parseInt(minParts[0]) * 10000 + parseInt(minParts[1]) * 100 + parseInt(minParts[2]);
|
||||
|
||||
const compatible = versionNum >= minNum;
|
||||
|
||||
if (!compatible) {
|
||||
return c.json({
|
||||
data: {
|
||||
compatible: false,
|
||||
importVersion: importData.version,
|
||||
currentVersion: DEARDIARY_VERSION,
|
||||
warning: `Import version ${importData.version} is older than minimum supported version ${MIN_IMPORT_VERSION}. Import may fail or lose data.`,
|
||||
},
|
||||
error: { code: 'VERSION_INCOMPATIBLE', message: `Import version ${importData.version} is not compatible with current version ${DEARDIARY_VERSION}` }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
let importedEvents = 0;
|
||||
let importedJournals = 0;
|
||||
let importedTasks = 0;
|
||||
let skippedEvents = 0;
|
||||
let skippedJournals = 0;
|
||||
|
||||
const idMapping = {
|
||||
events: new Map<string, string>(),
|
||||
journals: new Map<string, string>(),
|
||||
};
|
||||
|
||||
for (const event of importData.events) {
|
||||
const existing = await prisma.event.findFirst({
|
||||
where: { userId, date: event.date, content: event.content, createdAt: new Date(event.createdAt) }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
skippedEvents++;
|
||||
idMapping.events.set(event.id, existing.id);
|
||||
} else {
|
||||
const created = await prisma.event.create({
|
||||
data: {
|
||||
userId,
|
||||
date: event.date,
|
||||
type: event.type,
|
||||
content: event.content,
|
||||
mediaPath: event.mediaPath,
|
||||
metadata: event.metadata,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
placeName: event.placeName,
|
||||
createdAt: new Date(event.createdAt),
|
||||
}
|
||||
});
|
||||
idMapping.events.set(event.id, created.id);
|
||||
importedEvents++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const journal of importData.journals) {
|
||||
const existing = await prisma.journal.findFirst({
|
||||
where: { userId, date: journal.date }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
skippedJournals++;
|
||||
} else {
|
||||
await prisma.journal.create({
|
||||
data: {
|
||||
userId,
|
||||
date: journal.date,
|
||||
title: journal.title,
|
||||
content: journal.content,
|
||||
eventCount: journal.eventCount,
|
||||
generatedAt: new Date(journal.generatedAt),
|
||||
}
|
||||
});
|
||||
importedJournals++;
|
||||
}
|
||||
}
|
||||
|
||||
const journalDateMap = new Map<string, string>();
|
||||
const journals = await prisma.journal.findMany({ where: { userId }, select: { id: true, date: true } });
|
||||
for (const j of journals) {
|
||||
journalDateMap.set(j.date, j.id);
|
||||
}
|
||||
|
||||
for (const task of importData.tasks) {
|
||||
const newJournalId = journalDateMap.get(task.date);
|
||||
if (!newJournalId) continue;
|
||||
|
||||
const existing = await prisma.task.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
journalId: newJournalId,
|
||||
provider: task.provider,
|
||||
createdAt: new Date(task.createdAt),
|
||||
}
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
userId,
|
||||
journalId: newJournalId,
|
||||
type: task.type,
|
||||
status: task.status,
|
||||
provider: task.provider,
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
request: task.request,
|
||||
response: task.response,
|
||||
error: task.error,
|
||||
title: task.title,
|
||||
createdAt: new Date(task.createdAt),
|
||||
completedAt: task.completedAt ? new Date(task.completedAt) : null,
|
||||
}
|
||||
});
|
||||
importedTasks++;
|
||||
}
|
||||
}
|
||||
|
||||
if (importData.settings) {
|
||||
await prisma.settings.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
aiProvider: importData.settings.aiProvider || 'groq',
|
||||
aiApiKey: importData.settings.aiApiKey,
|
||||
aiModel: importData.settings.aiModel,
|
||||
aiBaseUrl: importData.settings.aiBaseUrl,
|
||||
journalPrompt: importData.settings.journalPrompt,
|
||||
language: importData.settings.language,
|
||||
timezone: importData.settings.timezone,
|
||||
providerSettings: importData.settings.providerSettings,
|
||||
journalContextDays: importData.settings.journalContextDays,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
compatible: true,
|
||||
importedEvents,
|
||||
importedJournals,
|
||||
importedTasks,
|
||||
skippedEvents,
|
||||
skippedJournals,
|
||||
totalEvents: importData.events.length,
|
||||
totalJournals: importData.journals.length,
|
||||
totalTasks: importData.tasks.length,
|
||||
},
|
||||
error: null
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Import failed';
|
||||
return c.json({ data: null, error: { code: 'IMPORT_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/v1/account', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
api.clearApiKey();
|
||||
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Delete failed';
|
||||
return c.json({ data: null, error: { code: 'DELETE_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/account/reset', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
try {
|
||||
await prisma.event.deleteMany({ where: { userId } });
|
||||
await prisma.task.deleteMany({ where: { userId } });
|
||||
await prisma.journal.deleteMany({ where: { userId } });
|
||||
await prisma.settings.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
aiProvider: 'groq',
|
||||
aiApiKey: null,
|
||||
aiModel: 'llama-3.3-70b-versatile',
|
||||
aiBaseUrl: null,
|
||||
journalPrompt: null,
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
providerSettings: null,
|
||||
journalContextDays: 10,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: { reset: true }, error: null });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Reset failed';
|
||||
return c.json({ data: null, error: { code: 'RESET_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/account/password', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { currentPassword, newPassword } = body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'currentPassword and newPassword are required' } }, 400);
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'New password must be at least 6 characters' } }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'User not found' } }, 404);
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ data: null, error: { code: 'INVALID_PASSWORD', message: 'Current password is incorrect' } }, 400);
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, 12);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: newHash },
|
||||
});
|
||||
|
||||
return c.json({ data: { changed: true }, error: null });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Password change failed';
|
||||
return c.json({ data: null, error: { code: 'CHANGE_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/register', async (c) => {
|
||||
const registrationEnabled = envVars.REGISTRATION_ENABLED !== 'false';
|
||||
if (!registrationEnabled) {
|
||||
return c.json({ data: null, error: { code: 'REGISTRATION_DISABLED', message: 'Registration is currently disabled' } }, 403);
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'email and password are required' } }, 400);
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 6 characters' } }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return c.json({ data: null, error: { code: 'EMAIL_EXISTS', message: 'Email already registered' } }, 400);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
settings: { create: {} },
|
||||
},
|
||||
});
|
||||
|
||||
const apiKey = randomBytes(32).toString('hex');
|
||||
const keyHash = createHash('sha256').update(apiKey).digest('hex');
|
||||
await prisma.apiKey.create({
|
||||
data: { userId: user.id, keyHash, name: 'Default' },
|
||||
});
|
||||
|
||||
return c.json({ data: { apiKey, userId: user.id }, error: null }, 201);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Registration failed';
|
||||
return c.json({ data: null, error: { code: 'REGISTRATION_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.notFound((c) => c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Not found' } }, 404));
|
||||
app.onError((err, c) => {
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ data: null, error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500);
|
||||
});
|
||||
|
||||
async function setupFTS() {
|
||||
try {
|
||||
await prisma.$executeRaw`CREATE VIRTUAL TABLE IF NOT EXISTS journal_fts USING fts5(
|
||||
userId UNINDEXED,
|
||||
date UNINDEXED,
|
||||
title,
|
||||
content,
|
||||
tokenize='porter unicode61'
|
||||
)`;
|
||||
|
||||
await prisma.$executeRaw`CREATE VIRTUAL TABLE IF NOT EXISTS event_fts USING fts5(
|
||||
userId UNINDEXED,
|
||||
date UNINDEXED,
|
||||
type UNINDEXED,
|
||||
content,
|
||||
tokenize='porter unicode61'
|
||||
)`;
|
||||
|
||||
console.log('FTS tables ready');
|
||||
} catch (err) {
|
||||
console.error('FTS setup error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildFTS() {
|
||||
try {
|
||||
const journals = await prisma.journal.findMany();
|
||||
await prisma.$executeRaw`DELETE FROM journal_fts`;
|
||||
for (const j of journals) {
|
||||
await prisma.$executeRaw`INSERT INTO journal_fts(userId, date, title, content) VALUES (${j.userId}, ${j.date}, ${j.title || ''}, ${j.content})`;
|
||||
}
|
||||
|
||||
const events = await prisma.event.findMany();
|
||||
await prisma.$executeRaw`DELETE FROM event_fts`;
|
||||
for (const e of events) {
|
||||
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${e.userId}, ${e.date}, ${e.type}, ${e.content})`;
|
||||
}
|
||||
|
||||
console.log('FTS indexes rebuilt');
|
||||
} catch (err) {
|
||||
console.error('FTS rebuild error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDefaultUser() {
|
||||
const defaultEmail = envVars.DEFAULT_USER_EMAIL;
|
||||
const defaultPassword = envVars.DEFAULT_USER_PASSWORD;
|
||||
@@ -647,7 +1215,11 @@ async function createDefaultUser() {
|
||||
const port = parseInt(envVars.PORT || '3000', 10);
|
||||
console.log(`Starting DearDiary API on port ${port}`);
|
||||
|
||||
createDefaultUser().then(() => {
|
||||
setupFTS().then(() => {
|
||||
return rebuildFTS();
|
||||
}).then(() => {
|
||||
return createDefaultUser();
|
||||
}).then(() => {
|
||||
console.log('Server ready');
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ eventsRoutes.post('/', async (c) => {
|
||||
const mediaDir = c.env.MEDIA_DIR || './data/media';
|
||||
|
||||
const body = await c.req.json();
|
||||
const { date, type, content, metadata } = body;
|
||||
const { date, type, content, metadata, latitude, longitude, placeName } = body;
|
||||
|
||||
if (!date || !type || !content) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
|
||||
@@ -27,9 +27,18 @@ eventsRoutes.post('/', async (c) => {
|
||||
type,
|
||||
content,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
latitude: latitude ?? null,
|
||||
longitude: longitude ?? null,
|
||||
placeName: placeName ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${userId}, ${date}, ${type}, ${content})`;
|
||||
} catch (e) {
|
||||
console.error('FTS index error:', e);
|
||||
}
|
||||
|
||||
return c.json({ data: event, error: null }, 201);
|
||||
});
|
||||
|
||||
@@ -78,6 +87,13 @@ eventsRoutes.put('/:id', async (c) => {
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.$executeRaw`DELETE FROM event_fts WHERE rowid = (SELECT rowid FROM event_fts WHERE userId = ${userId} AND date = ${existing.date} AND content = ${existing.content} LIMIT 1)`;
|
||||
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${userId}, ${event.date}, ${event.type}, ${event.content})`;
|
||||
} catch (e) {
|
||||
console.error('FTS index error:', e);
|
||||
}
|
||||
|
||||
return c.json({ data: event, error: null });
|
||||
});
|
||||
|
||||
@@ -129,15 +145,16 @@ eventsRoutes.post('/:id/photo', async (c) => {
|
||||
const fileName = `${id}.${ext}`;
|
||||
const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
|
||||
const filePath = `${userMediaDir}/${fileName}`;
|
||||
const mediaUrl = `/media/${userId}/${event.date}/${fileName}`;
|
||||
|
||||
await Bun.write(filePath, file);
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id },
|
||||
data: { mediaPath: filePath },
|
||||
data: { mediaPath: mediaUrl },
|
||||
});
|
||||
|
||||
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
|
||||
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
|
||||
});
|
||||
|
||||
eventsRoutes.post('/:id/voice', async (c) => {
|
||||
@@ -164,13 +181,14 @@ eventsRoutes.post('/:id/voice', async (c) => {
|
||||
const fileName = `${id}.webm`;
|
||||
const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
|
||||
const filePath = `${userMediaDir}/${fileName}`;
|
||||
const mediaUrl = `/media/${userId}/${event.date}/${fileName}`;
|
||||
|
||||
await Bun.write(filePath, file);
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id },
|
||||
data: { mediaPath: filePath },
|
||||
data: { mediaPath: mediaUrl },
|
||||
});
|
||||
|
||||
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
|
||||
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@ export class AnthropicProvider implements AIProvider {
|
||||
this.baseUrl = config.baseUrl || 'https://api.anthropic.com/v1';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
const requestBody = {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
max_tokens: 2000,
|
||||
system: systemPrompt,
|
||||
@@ -22,6 +22,10 @@ export class AnthropicProvider implements AIProvider {
|
||||
],
|
||||
};
|
||||
|
||||
if (options?.jsonMode) {
|
||||
requestBody.output = { format: { type: 'json_object' } };
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -39,10 +43,22 @@ export class AnthropicProvider implements AIProvider {
|
||||
throw new Error(`Anthropic API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
const content = responseData.content?.[0]?.text || '';
|
||||
let content = responseData.content?.[0]?.text || '';
|
||||
let title: string | undefined;
|
||||
|
||||
if (options?.jsonMode) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
title = parsed.title;
|
||||
content = parsed.content || content;
|
||||
} catch {
|
||||
// If JSON parsing fails, use content as-is
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
title,
|
||||
request: requestBody,
|
||||
response: responseData,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export class GroqProvider implements AIProvider {
|
||||
this.baseUrl = config.baseUrl || 'https://api.groq.com/openai/v1';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
@@ -21,13 +21,17 @@ export class GroqProvider implements AIProvider {
|
||||
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const requestBody = {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
};
|
||||
|
||||
if (options?.jsonMode) {
|
||||
requestBody.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -44,10 +48,22 @@ export class GroqProvider implements AIProvider {
|
||||
}
|
||||
|
||||
const responseData = JSON.parse(responseText);
|
||||
const content = responseData.choices?.[0]?.message?.content || '';
|
||||
let content = responseData.choices?.[0]?.message?.content || '';
|
||||
let title: string | undefined;
|
||||
|
||||
if (options?.jsonMode) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
title = parsed.title;
|
||||
content = parsed.content || content;
|
||||
} catch {
|
||||
// If JSON parsing fails, use content as-is
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
title,
|
||||
request: requestBody,
|
||||
response: responseData,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export class LMStudioProvider implements AIProvider {
|
||||
this.model = config.model || 'local-model';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
@@ -40,10 +40,22 @@ export class LMStudioProvider implements AIProvider {
|
||||
throw new Error(`LM Studio API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
const content = responseData.choices?.[0]?.message?.content || '';
|
||||
let content = responseData.choices?.[0]?.message?.content || '';
|
||||
let title: string | undefined;
|
||||
|
||||
if (options?.jsonMode) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
title = parsed.title;
|
||||
content = parsed.content || content;
|
||||
} catch {
|
||||
// If JSON parsing fails, use content as-is
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
title,
|
||||
request: requestBody,
|
||||
response: responseData,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export class OllamaProvider implements AIProvider {
|
||||
this.model = config.model || 'llama3.2';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const requestBody = {
|
||||
model: this.model,
|
||||
stream: false,
|
||||
@@ -34,10 +34,22 @@ export class OllamaProvider implements AIProvider {
|
||||
throw new Error(`Ollama API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
const content = responseData.message?.content || '';
|
||||
let content = responseData.message?.content || '';
|
||||
let title: string | undefined;
|
||||
|
||||
if (options?.jsonMode) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
title = parsed.title;
|
||||
content = parsed.content || content;
|
||||
} catch {
|
||||
// If JSON parsing fails, use content as-is
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
title,
|
||||
request: requestBody,
|
||||
response: responseData,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export class OpenAIProvider implements AIProvider {
|
||||
this.baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
@@ -21,13 +21,17 @@ export class OpenAIProvider implements AIProvider {
|
||||
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const requestBody = {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
};
|
||||
|
||||
if (options?.jsonMode) {
|
||||
requestBody.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -43,10 +47,22 @@ export class OpenAIProvider implements AIProvider {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
const content = responseData.choices?.[0]?.message?.content || '';
|
||||
let content = responseData.choices?.[0]?.message?.content || '';
|
||||
let title: string | undefined;
|
||||
|
||||
if (options?.jsonMode) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
title = parsed.title;
|
||||
content = parsed.content || content;
|
||||
} catch {
|
||||
// If JSON parsing fails, use content as-is
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
title,
|
||||
request: requestBody,
|
||||
response: responseData,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export interface AIProviderResult {
|
||||
content: string;
|
||||
title?: string;
|
||||
request: Record<string, unknown>;
|
||||
response: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq';
|
||||
generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult>;
|
||||
generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult>;
|
||||
validate?(): Promise<boolean>;
|
||||
}
|
||||
|
||||
|
||||
254
docs/api.md
Normal file
254
docs/api.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# API Reference
|
||||
|
||||
Base URL: `/api/v1`
|
||||
|
||||
Authentication: All requests require an API key in the `Authorization` header:
|
||||
```
|
||||
Authorization: Bearer <api_key>
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### Register User
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "password123"}'
|
||||
```
|
||||
|
||||
### Login
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "password123"}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"token": "jwt_token",
|
||||
"userId": "user_id"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Create API Key
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/auth/api-key \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"name": "my-app"}'
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
### Create Event
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <api_key>" \
|
||||
-d '{
|
||||
"date": "2026-03-27",
|
||||
"type": "event",
|
||||
"content": "Had a great meeting about the new project"
|
||||
}'
|
||||
```
|
||||
|
||||
Valid types: `event`, `text`, `photo`, `voice`, `health`
|
||||
|
||||
### Get Event
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/events/<event_id> \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
### Update Event
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/v1/events/<event_id> \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <api_key>" \
|
||||
-d '{"content": "Updated content"}'
|
||||
```
|
||||
|
||||
Note: Events cannot be updated if a diary page has been generated for that date.
|
||||
|
||||
### Delete Event
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/api/v1/events/<event_id> \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
Note: Events cannot be deleted if a diary page has been generated for that date.
|
||||
|
||||
## Days
|
||||
|
||||
### List Days
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/days \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"date": "2026-03-27",
|
||||
"eventCount": 5,
|
||||
"hasJournal": true
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Get Day Details
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/days/2026-03-27 \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"date": "2026-03-27",
|
||||
"events": [...],
|
||||
"journal": {...}
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Day (and journal)
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/api/v1/days/2026-03-27 \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
## Journal
|
||||
|
||||
### Generate Diary Page
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/journal/generate/2026-03-27 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <api_key>" \
|
||||
-d '{"instructions": "Focus more on the technical aspects"}'
|
||||
```
|
||||
|
||||
The `instructions` field is optional. If provided, it will be appended to the prompt for regeneration.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"journal": {
|
||||
"id": "...",
|
||||
"date": "2026-03-27",
|
||||
"content": "Generated diary content...",
|
||||
"eventCount": 5,
|
||||
"generatedAt": "2026-03-27T10:30:00Z"
|
||||
},
|
||||
"task": {
|
||||
"id": "...",
|
||||
"type": "journal_generate",
|
||||
"status": "completed",
|
||||
"provider": "groq",
|
||||
"model": "llama-3.3-70b-versatile"
|
||||
}
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Get Journal
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/journal/2026-03-27 \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
### Delete Journal
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/api/v1/journal/2026-03-27 \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
Deleting the journal unlocks events for editing.
|
||||
|
||||
### Get Journal Tasks
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/journal/2026-03-27/tasks \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
### Get Task
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/tasks/<task_id> \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
Response includes full request/response JSON for debugging.
|
||||
|
||||
## Settings
|
||||
|
||||
### Get Settings
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/settings \
|
||||
-H "Authorization: Bearer <api_key>"
|
||||
```
|
||||
|
||||
### Update Settings
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/v1/settings \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <api_key>" \
|
||||
-d '{
|
||||
"aiProvider": "groq",
|
||||
"providerSettings": {
|
||||
"groq": {
|
||||
"apiKey": "your-api-key",
|
||||
"model": "llama-3.3-70b-versatile"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## AI Providers
|
||||
|
||||
### Test Connection
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/ai/test \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <api_key>" \
|
||||
-d '{
|
||||
"provider": "groq",
|
||||
"apiKey": "your-api-key",
|
||||
"model": "llama-3.3-70b-versatile"
|
||||
}'
|
||||
```
|
||||
|
||||
Supported providers: `groq`, `openai`, `anthropic`, `ollama`, `lmstudio`
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return errors in this format:
|
||||
```json
|
||||
{
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human readable message"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes:
|
||||
- `UNAUTHORIZED` - Invalid or missing API key
|
||||
- `NOT_FOUND` - Resource not found
|
||||
- `NO_EVENTS` - No events for diary generation
|
||||
- `NO_AI_CONFIG` - AI provider not configured
|
||||
- `EVENT_IMMUTABLE` - Cannot modify event (diary exists)
|
||||
62
docs/astro.config.mjs
Normal file
62
docs/astro.config.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'DearDiary',
|
||||
description: 'AI-Powered Daily Journal - Self-hosted journaling app where users capture events throughout the day and AI generates diary pages.',
|
||||
logo: {
|
||||
light: './src/assets/logo-light.svg',
|
||||
dark: './src/assets/logo-dark.svg',
|
||||
replacesTitle: true,
|
||||
},
|
||||
social: {
|
||||
github: 'https://github.com/anomalyco/totalrecall',
|
||||
},
|
||||
editLink: {
|
||||
baseUrl: 'https://github.com/anomalyco/totalrecall/edit/main/',
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
{ label: 'Introduction', slug: 'index' },
|
||||
{ label: 'Installation', slug: 'getting-started/installation' },
|
||||
{ label: 'Quick Start', slug: 'getting-started/quick-start' },
|
||||
{ label: 'Configuration', slug: 'getting-started/configuration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Features',
|
||||
items: [
|
||||
{ label: 'Events', slug: 'features/events' },
|
||||
{ label: 'Diary Pages', slug: 'features/diary-pages' },
|
||||
{ label: 'AI Providers', slug: 'features/ai-providers' },
|
||||
{ label: 'Search', slug: 'features/search' },
|
||||
{ label: 'Calendar', slug: 'features/calendar' },
|
||||
{ label: 'Export & Import', slug: 'features/export-import' },
|
||||
{ label: 'Media Uploads', slug: 'features/media' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'API Reference',
|
||||
items: [
|
||||
{ label: 'Authentication', slug: 'api/authentication' },
|
||||
{ label: 'Events', slug: 'api/events' },
|
||||
{ label: 'Journals', slug: 'api/journals' },
|
||||
{ label: 'Settings', slug: 'api/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Deployment',
|
||||
items: [
|
||||
{ label: 'Docker', slug: 'deployment/docker' },
|
||||
{ label: 'Environment Variables', slug: 'deployment/environment' },
|
||||
],
|
||||
},
|
||||
],
|
||||
customCss: ['./src/styles/custom.css'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
7311
docs/package-lock.json
generated
Normal file
7311
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
docs/package.json
Normal file
16
docs/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "deardiary-docs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.34.2",
|
||||
"astro": "^5.6.1",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
}
|
||||
15
docs/src/assets/logo-dark.svg
Normal file
15
docs/src/assets/logo-dark.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#818cf8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||
<path d="M25 25 L75 25 L75 80 L25 80 Z" fill="none" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M35 40 L65 40" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M35 50 L65 50" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M35 60 L55 60" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="70" cy="60" r="8" fill="white" opacity="0.3"/>
|
||||
<path d="M70 55 L70 65 M65 60 L75 60" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 897 B |
15
docs/src/assets/logo-light.svg
Normal file
15
docs/src/assets/logo-light.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#6366f1;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||
<path d="M25 25 L75 25 L75 80 L25 80 Z" fill="none" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M35 40 L65 40" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M35 50 L65 50" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M35 60 L55 60" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="70" cy="60" r="8" fill="white" opacity="0.3"/>
|
||||
<path d="M70 55 L70 65 M65 60 L75 60" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 897 B |
47
docs/src/content/docs/api/authentication.mdx
Normal file
47
docs/src/content/docs/api/authentication.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: API authentication methods
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
## API Key Authentication
|
||||
|
||||
All API requests require authentication using a Bearer token:
|
||||
|
||||
```http
|
||||
Authorization: Bearer your-api-key-here
|
||||
```
|
||||
|
||||
## Getting an API Key
|
||||
|
||||
### Via Login
|
||||
|
||||
1. POST to `/api/v1/auth/login` with email/password
|
||||
2. Receive JWT token
|
||||
3. Create API key via POST to `/api/v1/auth/api-key`
|
||||
|
||||
### Via Registration
|
||||
|
||||
1. POST to `/api/v1/auth/register` with email/password
|
||||
2. Receive API key directly
|
||||
|
||||
## Response Format
|
||||
|
||||
All endpoints return:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { ... } | null,
|
||||
"error": { "code": "ERROR_CODE", "message": "Error description" } | null
|
||||
}
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
|------|-------------|-------------|
|
||||
| `UNAUTHORIZED` | 401 | Invalid or missing API key |
|
||||
| `NOT_FOUND` | 404 | Resource not found |
|
||||
| `VALIDATION_ERROR` | 400 | Invalid request data |
|
||||
| `EVENT_IMMUTABLE` | 400 | Cannot modify locked events |
|
||||
70
docs/src/content/docs/api/events.mdx
Normal file
70
docs/src/content/docs/api/events.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Events API
|
||||
description: Events endpoints reference
|
||||
sidebar:
|
||||
order: 2
|
||||
---
|
||||
|
||||
## Create Event
|
||||
|
||||
```http
|
||||
POST /api/v1/events
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"type": "text",
|
||||
"content": "Had coffee with Sarah",
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.0060,
|
||||
"placeName": "New York, NY"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `date` | string | Yes | Date in YYYY-MM-DD format |
|
||||
| `type` | string | Yes | One of: event, text, photo, voice, health |
|
||||
| `content` | string | Yes | Event content |
|
||||
| `latitude` | number | No | GPS latitude |
|
||||
| `longitude` | number | No | GPS longitude |
|
||||
| `placeName` | string | No | Reverse-geocoded place name |
|
||||
| `metadata` | object | No | Additional metadata |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"date": "2024-01-15",
|
||||
"type": "text",
|
||||
"content": "Had coffee with Sarah",
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.0060,
|
||||
"placeName": "New York, NY",
|
||||
"createdAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Get Day
|
||||
|
||||
```http
|
||||
GET /api/v1/days/:date
|
||||
```
|
||||
|
||||
Returns all events and journal for a specific date.
|
||||
|
||||
## Delete Event
|
||||
|
||||
```http
|
||||
DELETE /api/v1/events/:id
|
||||
```
|
||||
|
||||
:::caution
|
||||
Cannot delete events from days with generated journals.
|
||||
:::
|
||||
80
docs/src/content/docs/api/journals.mdx
Normal file
80
docs/src/content/docs/api/journals.mdx
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Journals API
|
||||
description: Journals endpoints reference
|
||||
sidebar:
|
||||
order: 3
|
||||
---
|
||||
|
||||
## Generate Diary
|
||||
|
||||
```http
|
||||
POST /api/v1/journal/generate/:date
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"instructions": "Make it more detailed and poetic"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"journal": {
|
||||
"id": "uuid",
|
||||
"date": "2024-01-15",
|
||||
"title": "A Productive Tuesday",
|
||||
"content": "Full diary content...",
|
||||
"eventCount": 8,
|
||||
"generatedAt": "2024-01-15T22:00:00Z"
|
||||
},
|
||||
"task": {
|
||||
"id": "uuid",
|
||||
"status": "completed",
|
||||
"provider": "groq",
|
||||
"model": "llama-3.3-70b-versatile"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Get Journal
|
||||
|
||||
```http
|
||||
GET /api/v1/journal/:date
|
||||
```
|
||||
|
||||
Returns the diary page for a specific date.
|
||||
|
||||
## List Journals
|
||||
|
||||
```http
|
||||
GET /api/v1/journals?page=1&limit=10
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `page` | 1 | Page number |
|
||||
| `limit` | 10 | Items per page (10, 50, or 100) |
|
||||
|
||||
## Get Generation Tasks
|
||||
|
||||
```http
|
||||
GET /api/v1/journal/:date/tasks
|
||||
```
|
||||
|
||||
Returns all generation attempts for a journal with request/response JSON.
|
||||
|
||||
## Delete Journal
|
||||
|
||||
```http
|
||||
DELETE /api/v1/journal/:date
|
||||
```
|
||||
|
||||
Deleting a journal **unlocks** all events for that day, allowing edits and new entries.
|
||||
80
docs/src/content/docs/api/settings.mdx
Normal file
80
docs/src/content/docs/api/settings.mdx
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Settings API
|
||||
description: Settings endpoints reference
|
||||
sidebar:
|
||||
order: 4
|
||||
---
|
||||
|
||||
## Get Settings
|
||||
|
||||
```http
|
||||
GET /api/v1/settings
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"aiProvider": "groq",
|
||||
"aiApiKey": null,
|
||||
"aiModel": "llama-3.3-70b-versatile",
|
||||
"journalPrompt": "Custom instructions...",
|
||||
"language": "en",
|
||||
"timezone": "UTC",
|
||||
"journalContextDays": 10,
|
||||
"providerSettings": {
|
||||
"groq": { "apiKey": "...", "model": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Update Settings
|
||||
|
||||
```http
|
||||
PUT /api/v1/settings
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"aiProvider": "openai",
|
||||
"aiApiKey": "sk-...",
|
||||
"journalPrompt": "Write in a reflective tone"
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `aiProvider` | string | groq, openai, anthropic, ollama, lmstudio |
|
||||
| `aiApiKey` | string | API key for selected provider |
|
||||
| `aiModel` | string | Model identifier |
|
||||
| `journalPrompt` | string | Custom instructions (null to clear) |
|
||||
| `journalContextDays` | number | Days of previous journals to include (0-30) |
|
||||
|
||||
## Change Password
|
||||
|
||||
```http
|
||||
POST /api/v1/account/password
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"currentPassword": "old-password",
|
||||
"newPassword": "new-secure-password"
|
||||
}
|
||||
```
|
||||
|
||||
## Delete Account
|
||||
|
||||
```http
|
||||
DELETE /api/v1/account
|
||||
```
|
||||
|
||||
:::danger
|
||||
This permanently deletes your account and all data. This cannot be undone.
|
||||
:::
|
||||
93
docs/src/content/docs/deployment/docker.mdx
Normal file
93
docs/src/content/docs/deployment/docker.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Docker Deployment
|
||||
description: Deploy DearDiary with Docker
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The recommended way to run DearDiary:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
env_file:
|
||||
- backend/.env
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
### Start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Stop
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Rebuild
|
||||
|
||||
```bash
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
||||
Data persists in `./data/`:
|
||||
|
||||
| Directory | Contents |
|
||||
|-----------|----------|
|
||||
| `data/db/` | SQLite database |
|
||||
| `data/media/` | Uploaded files |
|
||||
|
||||
## Health Check
|
||||
|
||||
The app exposes a health endpoint:
|
||||
|
||||
```http
|
||||
GET http://localhost:8080/health
|
||||
```
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Production Considerations
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
For production, use a reverse proxy (nginx, Caddy, Traefik) with:
|
||||
|
||||
- HTTPS/TLS termination
|
||||
- Security headers
|
||||
- Rate limiting
|
||||
|
||||
### Database
|
||||
|
||||
Default SQLite is fine for single-user or small deployments.
|
||||
|
||||
For multi-user or high traffic, consider PostgreSQL:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://user:pass@host:5432/deardiary"
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
Regularly backup `./data/` directory.
|
||||
66
docs/src/content/docs/deployment/environment.mdx
Normal file
66
docs/src/content/docs/deployment/environment.mdx
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Environment Variables
|
||||
description: Complete environment variable reference
|
||||
sidebar:
|
||||
order: 2
|
||||
---
|
||||
|
||||
## Configuration File
|
||||
|
||||
Copy `.env.example` to `.env`:
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
## Variables
|
||||
|
||||
### Application
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `APP_NAME` | DearDiary | App name displayed in UI |
|
||||
| `VERSION` | 0.1.0 | App version |
|
||||
| `PORT` | 3000 | Internal API port |
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | file:./data/deardiary.db | SQLite by default |
|
||||
| `DATABASE_URL` | postgresql://... | PostgreSQL connection |
|
||||
| `DATABASE_URL` | mysql://... | MySQL connection |
|
||||
|
||||
### Security
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `JWT_SECRET` | development-secret... | **Required in production!** |
|
||||
| `CORS_ORIGIN` | * | CORS allowed origins |
|
||||
|
||||
### User Management
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `REGISTRATION_ENABLED` | false | Enable/disable registration |
|
||||
| `DEFAULT_USER_EMAIL` | admin@localhost | Default admin email |
|
||||
| `DEFAULT_USER_PASSWORD` | changeme123 | Default admin password |
|
||||
|
||||
### Storage
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MEDIA_DIR` | ./data/media | Media files directory |
|
||||
|
||||
## Production Checklist
|
||||
|
||||
:::caution
|
||||
Before going to production:
|
||||
|
||||
1. Set a strong `JWT_SECRET`
|
||||
2. Set `CORS_ORIGIN` to your domain
|
||||
3. Change default admin credentials
|
||||
4. Set `REGISTRATION_ENABLED=false` if not needed
|
||||
5. Use HTTPS/TLS
|
||||
6. Set up regular backups
|
||||
:::
|
||||
62
docs/src/content/docs/features/ai-providers.mdx
Normal file
62
docs/src/content/docs/features/ai-providers.mdx
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: AI Providers
|
||||
description: Configure AI providers for diary generation
|
||||
sidebar:
|
||||
order: 3
|
||||
---
|
||||
|
||||
## Supported Providers
|
||||
|
||||
DearDiary supports multiple AI providers:
|
||||
|
||||
| Provider | Default Model | Type |
|
||||
|----------|---------------|------|
|
||||
| **Groq** | llama-3.3-70b-versatile | Cloud |
|
||||
| OpenAI | gpt-4o | Cloud |
|
||||
| Anthropic | claude-3.5-sonnet | Cloud |
|
||||
| Ollama | varies | Local |
|
||||
| LM Studio | varies | Local |
|
||||
|
||||
## Groq (Recommended)
|
||||
|
||||
Free tier available. Fast inference.
|
||||
|
||||
1. Get an API key from [console.groq.com](https://console.groq.com)
|
||||
2. Enter the API key in Settings
|
||||
3. Select Groq as your provider
|
||||
|
||||
## OpenAI
|
||||
|
||||
1. Get an API key from [platform.openai.com](https://platform.openai.com)
|
||||
2. Optionally select a specific model:
|
||||
- `gpt-4o` - Most capable
|
||||
- `gpt-4o-mini` - Faster, cheaper
|
||||
|
||||
## Anthropic
|
||||
|
||||
1. Get an API key from [console.anthropic.com](https://console.anthropic.com)
|
||||
2. Select Claude model:
|
||||
- `claude-3-5-sonnet-latest` - Recommended
|
||||
- `claude-3-opus-latest` - Most capable
|
||||
|
||||
## Ollama (Local)
|
||||
|
||||
Run models locally on your machine.
|
||||
|
||||
1. Install [Ollama](https://ollama.ai)
|
||||
2. Pull a model: `ollama pull llama3.2`
|
||||
3. Set base URL: `http://localhost:11434/v1`
|
||||
4. Select model name
|
||||
|
||||
## LM Studio (Local)
|
||||
|
||||
Alternative local option with GUI.
|
||||
|
||||
1. Download [LM Studio](https://lmstudio.ai)
|
||||
2. Download a model
|
||||
3. Start local server (click "Start Server")
|
||||
4. Set base URL: `http://localhost:1234/v1`
|
||||
|
||||
## Testing Connection
|
||||
|
||||
Use the **Test Connection** button in Settings to verify your AI provider is working before generating diaries.
|
||||
44
docs/src/content/docs/features/calendar.mdx
Normal file
44
docs/src/content/docs/features/calendar.mdx
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Calendar
|
||||
description: Visual calendar view of your journals
|
||||
sidebar:
|
||||
order: 5
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Calendar view provides a monthly overview of your journal activity.
|
||||
|
||||
Access it via the navigation menu: **Calendar**
|
||||
|
||||
## Calendar Indicators
|
||||
|
||||
Each day shows:
|
||||
|
||||
| Indicator | Meaning |
|
||||
|-----------|---------|
|
||||
| **Number** | Day of month |
|
||||
| **Filled circle** | Has events |
|
||||
| **Purple border** | Has generated diary page |
|
||||
| **Today** | Highlighted border |
|
||||
|
||||
## Navigation
|
||||
|
||||
- **Previous/Next Month** - Arrow buttons
|
||||
- **Month/Year Picker** - Click month name
|
||||
- **Today Button** - Jump to current date
|
||||
|
||||
## Clicking a Day
|
||||
|
||||
Click any day to view:
|
||||
|
||||
- All events for that day
|
||||
- Diary page if generated
|
||||
- Option to add new events (if unlocked)
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Quickly see activity patterns
|
||||
- Identify gaps in journaling
|
||||
- Navigate to specific past dates
|
||||
- Plan when to catch up on entries
|
||||
60
docs/src/content/docs/features/diary-pages.mdx
Normal file
60
docs/src/content/docs/features/diary-pages.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Diary Pages
|
||||
description: AI-generated diary entries
|
||||
sidebar:
|
||||
order: 2
|
||||
---
|
||||
|
||||
## What is a Diary Page?
|
||||
|
||||
A **Diary Page** is an AI-generated narrative summary of your day's events. Unlike events, diary pages:
|
||||
|
||||
- Can be regenerated (rewritten)
|
||||
- Include a generated title
|
||||
- Are narrative, not just data points
|
||||
|
||||
## Generating a Diary
|
||||
|
||||
1. Navigate to **Today** (`/today`)
|
||||
2. Ensure you have at least one event
|
||||
3. Click **Generate Diary Page**
|
||||
4. Wait for AI processing
|
||||
|
||||
## Viewing Diary Pages
|
||||
|
||||
Access diary pages via:
|
||||
|
||||
- **Dashboard** - Recent diary excerpts
|
||||
- **Diary** - Paginated list of all diaries
|
||||
- **Calendar** - Visual overview with indicators
|
||||
- **Day View** - Click any date to see its diary
|
||||
|
||||
## Rewriting
|
||||
|
||||
Click **Rewrite** to regenerate with additional instructions:
|
||||
|
||||
### Default Rewrite
|
||||
|
||||
Regenerates with the same events and default prompt.
|
||||
|
||||
### Custom Instructions
|
||||
|
||||
Add specific guidance:
|
||||
|
||||
```
|
||||
Make it more poetic and reflective.
|
||||
Focus on the conversations I had.
|
||||
```
|
||||
|
||||
## Title Generation
|
||||
|
||||
Each diary page includes an AI-generated title (max 50 characters) that summarizes the day.
|
||||
|
||||
## Task History
|
||||
|
||||
Every generation attempt is logged as a **Task**, including:
|
||||
|
||||
- Request/response JSON for debugging
|
||||
- Provider and model used
|
||||
- Success/failure status
|
||||
- Processing time
|
||||
60
docs/src/content/docs/features/events.mdx
Normal file
60
docs/src/content/docs/features/events.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Events
|
||||
description: Capturing and managing events
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
## What is an Event?
|
||||
|
||||
An **Event** is a single log entry in your journal. Events can be:
|
||||
|
||||
- **Text** - Simple text notes
|
||||
- **Photo** - Captured moments with images
|
||||
- **Voice** - Voice memos
|
||||
- **Health** - Health-related observations
|
||||
- **Event** - General activity logs
|
||||
|
||||
## Creating Events
|
||||
|
||||
### Via Today Page
|
||||
|
||||
1. Navigate to **Today** (`/today`)
|
||||
2. Type your event in the input field
|
||||
3. Select the event type
|
||||
4. Press Enter or click Add
|
||||
|
||||
### Via Quick Add
|
||||
|
||||
Press `Ctrl + J` from anywhere to open the quick add widget.
|
||||
|
||||
## Location
|
||||
|
||||
Events automatically capture:
|
||||
|
||||
- GPS coordinates (if browser permits)
|
||||
- Reverse-geocoded place names
|
||||
|
||||
This helps you remember where things happened.
|
||||
|
||||
## Immutability
|
||||
|
||||
Once a **Diary Page** is generated for a day, all events for that day become **locked**:
|
||||
|
||||
- Cannot be deleted
|
||||
- Cannot be edited
|
||||
- Cannot have new events added
|
||||
|
||||
To unlock, you must delete the diary page.
|
||||
|
||||
## Media
|
||||
|
||||
### Photos
|
||||
|
||||
Upload photos directly or attach them to existing events.
|
||||
|
||||
### Voice Memos
|
||||
|
||||
Record voice memos using your browser's microphone.
|
||||
|
||||
Media files are stored in `./data/media/{userId}/{date}/`
|
||||
72
docs/src/content/docs/features/export-import.mdx
Normal file
72
docs/src/content/docs/features/export-import.mdx
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: Export & Import
|
||||
description: Backup and restore your data
|
||||
sidebar:
|
||||
order: 6
|
||||
---
|
||||
|
||||
## Why Export?
|
||||
|
||||
Regular exports ensure you never lose your data:
|
||||
|
||||
- Backup to external storage
|
||||
- Migrate to new server
|
||||
- Keep offline archive
|
||||
|
||||
## Export Format
|
||||
|
||||
Exports are JSON files containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.0.6",
|
||||
"exportedAt": "2024-01-15T10:30:00Z",
|
||||
"settings": { ... },
|
||||
"events": [ ... ],
|
||||
"journals": [ ... ],
|
||||
"tasks": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### Included Data
|
||||
|
||||
- **Settings** - AI provider, model, custom prompts
|
||||
- **Events** - All event data including location
|
||||
- **Journals** - All generated diary pages
|
||||
- **Tasks** - Generation history for debugging
|
||||
|
||||
## How to Export
|
||||
|
||||
1. Go to **Settings**
|
||||
2. Scroll to **Data Management**
|
||||
3. Click **Export Data**
|
||||
4. Save the JSON file
|
||||
|
||||
## Import
|
||||
|
||||
### Version Compatibility
|
||||
|
||||
- Minimum supported: 0.0.3
|
||||
- Current version: 0.0.6
|
||||
|
||||
Older exports may lose some data or fail to import.
|
||||
|
||||
### Import Steps
|
||||
|
||||
1. Go to **Settings** → **Data Management**
|
||||
2. Click **Import Data**
|
||||
3. Select your JSON export file
|
||||
4. Review the preview (imported/skipped counts)
|
||||
5. Click **Confirm Import**
|
||||
|
||||
### Duplicate Handling
|
||||
|
||||
- **Events**: Skipped if identical (date + content + timestamp)
|
||||
- **Journals**: Skipped by date
|
||||
- **Tasks**: Linked to existing journals
|
||||
|
||||
## Danger Zone
|
||||
|
||||
:::caution
|
||||
The **Reset Account** option in Danger Zone deletes ALL your data except settings. Use with extreme caution!
|
||||
:::
|
||||
66
docs/src/content/docs/features/media.mdx
Normal file
66
docs/src/content/docs/features/media.mdx
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Media Uploads
|
||||
description: Photos and voice memos
|
||||
sidebar:
|
||||
order: 7
|
||||
---
|
||||
|
||||
## Supported Media Types
|
||||
|
||||
### Photos
|
||||
|
||||
- Upload photos to accompany events
|
||||
- JPEG, PNG, GIF, WebP supported
|
||||
- Displayed inline in event list
|
||||
|
||||
### Voice Memos
|
||||
|
||||
- Record voice notes
|
||||
- WebM audio format
|
||||
- Playback controls in event list
|
||||
|
||||
## How to Add Media
|
||||
|
||||
### Photo Type Events
|
||||
|
||||
1. Select **Photo** type when creating event
|
||||
2. Add description in text field
|
||||
3. Upload photo
|
||||
|
||||
### Voice Type Events
|
||||
|
||||
1. Select **Voice** type
|
||||
2. Click microphone button
|
||||
3. Start recording
|
||||
4. Stop when finished
|
||||
5. Add optional text note
|
||||
|
||||
## Storage
|
||||
|
||||
Media files are stored at:
|
||||
|
||||
```
|
||||
./data/media/{userId}/{date}/
|
||||
```
|
||||
|
||||
For example:
|
||||
```
|
||||
./data/media/user123abc/2024-01-15/photo.jpg
|
||||
./data/media/user123abc/2024-01-15/voice.webm
|
||||
```
|
||||
|
||||
## Serving
|
||||
|
||||
Media is served via nginx at `/media/` route:
|
||||
|
||||
```
|
||||
/media/{userId}/{date}/{filename}
|
||||
```
|
||||
|
||||
## Privacy
|
||||
|
||||
Media files are:
|
||||
- Stored locally only
|
||||
- Associated with your user account
|
||||
- Not shared between users
|
||||
- Deleted when you delete events
|
||||
43
docs/src/content/docs/features/search.mdx
Normal file
43
docs/src/content/docs/features/search.mdx
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Search
|
||||
description: Finding events and diary pages
|
||||
sidebar:
|
||||
order: 4
|
||||
---
|
||||
|
||||
## Using Search
|
||||
|
||||
Press `Ctrl + K` from anywhere to open the search modal.
|
||||
|
||||
## What You Can Search
|
||||
|
||||
### Diary Pages
|
||||
|
||||
Search across all generated diary pages:
|
||||
|
||||
- Titles
|
||||
- Full content
|
||||
- Dates
|
||||
|
||||
### Events
|
||||
|
||||
Search across all events:
|
||||
|
||||
- Event content
|
||||
- Event type
|
||||
- Date
|
||||
|
||||
## How It Works
|
||||
|
||||
DearDiary uses **SQLite FTS5** (Full-Text Search) for fast, indexed searching:
|
||||
|
||||
1. Automatic indexing on content creation
|
||||
2. Porter stemming for better matches
|
||||
3. Fallback to LIKE queries if FTS fails
|
||||
|
||||
## Tips
|
||||
|
||||
- Use specific keywords for better results
|
||||
- Search works across all dates
|
||||
- Results are grouped by type (diaries vs events)
|
||||
- Click any result to navigate directly to that item
|
||||
54
docs/src/content/docs/getting-started/configuration.mdx
Normal file
54
docs/src/content/docs/getting-started/configuration.mdx
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Configuration
|
||||
description: Configure DearDiary for your needs
|
||||
sidebar:
|
||||
order: 3
|
||||
---
|
||||
|
||||
## Settings Page
|
||||
|
||||
Access settings via the navigation menu. Settings are organized into sections:
|
||||
|
||||
### AI Provider
|
||||
|
||||
Choose your AI provider for diary generation:
|
||||
|
||||
| Provider | Model | Notes |
|
||||
|----------|-------|-------|
|
||||
| **Groq** (default) | llama-3.3-70b-versatile | Fast, free tier available |
|
||||
| OpenAI | gpt-4o, gpt-4o-mini | Requires API key |
|
||||
| Anthropic | claude-3.5-sonnet | Requires API key |
|
||||
| Ollama | Various | Local models |
|
||||
| LM Studio | Various | Local models |
|
||||
|
||||
### Custom Instructions
|
||||
|
||||
Add custom instructions that are prepended to the default AI prompt:
|
||||
|
||||
```
|
||||
Write in a poetic style.
|
||||
Focus on health and wellness aspects.
|
||||
```
|
||||
|
||||
### Journal Context
|
||||
|
||||
Set how many previous days of diaries the AI considers when generating new entries (0-30 days).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See [Environment Variables](/deployment/environment) for server-side configuration.
|
||||
|
||||
## Multi-User Setup
|
||||
|
||||
Enable user registration:
|
||||
|
||||
```env
|
||||
REGISTRATION_ENABLED=true
|
||||
```
|
||||
|
||||
Or create a default admin user:
|
||||
|
||||
```env
|
||||
DEFAULT_USER_EMAIL=admin@example.com
|
||||
DEFAULT_USER_PASSWORD=your-secure-password
|
||||
```
|
||||
56
docs/src/content/docs/getting-started/installation.mdx
Normal file
56
docs/src/content/docs/getting-started/installation.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Installation
|
||||
description: How to install and set up DearDiary
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||
- Git
|
||||
|
||||
## Quick Install
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/anomalyco/totalrecall.git
|
||||
cd totalrecall
|
||||
```
|
||||
|
||||
2. Copy the environment file:
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
3. Start with Docker:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. Access the app at `http://localhost:8080`
|
||||
|
||||
## Default Credentials
|
||||
|
||||
On first start, a default user is created if configured:
|
||||
|
||||
- **Email**: `admin@localhost` (configurable via `DEFAULT_USER_EMAIL`)
|
||||
- **Password**: `changeme123` (configurable via `DEFAULT_USER_PASSWORD`)
|
||||
|
||||
:::tip
|
||||
Change these credentials immediately after first login!
|
||||
:::
|
||||
|
||||
## Ports
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| App | 8080 | Main application (nginx) |
|
||||
| API | 3000 | Backend API (internal) |
|
||||
|
||||
## Data Storage
|
||||
|
||||
All data is stored in Docker volumes:
|
||||
|
||||
- `./data/db/` - SQLite database
|
||||
- `./data/media/` - Uploaded photos and voice memos
|
||||
58
docs/src/content/docs/getting-started/quick-start.mdx
Normal file
58
docs/src/content/docs/getting-started/quick-start.mdx
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Get started with DearDiary in 5 minutes
|
||||
sidebar:
|
||||
order: 2
|
||||
---
|
||||
|
||||
## First Steps
|
||||
|
||||
### 1. Log an Event
|
||||
|
||||
Navigate to **Today** and start logging events:
|
||||
|
||||
- Type your event and press Enter
|
||||
- Use the type buttons to change event category (Event, Health, Photo, Voice)
|
||||
- Events are timestamped automatically
|
||||
|
||||
### 2. Add Your Location
|
||||
|
||||
When you log an event, DearDiary automatically captures:
|
||||
|
||||
- **Latitude/Longitude** from your browser
|
||||
- **Place Name** via reverse geocoding (OpenStreetMap)
|
||||
|
||||
:::note
|
||||
Location access requires browser permission. Grant it when prompted for automatic geolocation.
|
||||
:::
|
||||
|
||||
### 3. Generate Your Diary
|
||||
|
||||
When you're ready to lock in your day:
|
||||
|
||||
1. Click **Generate Diary Page**
|
||||
2. AI analyzes all your events
|
||||
3. Creates a narrative diary entry with title
|
||||
|
||||
Once generated, events become **locked** (immutable).
|
||||
|
||||
### 4. Rewrite if Needed
|
||||
|
||||
Click **Rewrite** to regenerate with additional instructions:
|
||||
|
||||
- Add context like "Make it more detailed"
|
||||
- Include specific topics to emphasize
|
||||
- Adjust the tone or style
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl + J` | Quick add event |
|
||||
| `Ctrl + K` | Search |
|
||||
|
||||
## Tips
|
||||
|
||||
- Log events throughout the day for richer diaries
|
||||
- Use the calendar view to browse past days
|
||||
- Export your data regularly for backup
|
||||
34
docs/src/content/docs/index.mdx
Normal file
34
docs/src/content/docs/index.mdx
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: DearDiary
|
||||
description: AI-Powered Daily Journal
|
||||
template: splash
|
||||
hero:
|
||||
title: DearDiary
|
||||
tagline: Your AI-powered daily journal. Capture events throughout the day and let AI transform them into beautiful diary pages.
|
||||
image:
|
||||
file: ../../assets/logo-light.svg
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get Started
|
||||
link: /getting-started/installation/
|
||||
- theme: alt
|
||||
text: View on GitHub
|
||||
link: https://github.com/anomalyco/totalrecall
|
||||
---
|
||||
|
||||
import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
<CardGrid stagger>
|
||||
<Card title="Capture Events">
|
||||
Log events throughout your day - text, photos, voice memos, and health data.
|
||||
</Card>
|
||||
<Card title="AI-Generated Diary">
|
||||
Let AI transform your events into beautiful, narrative diary pages.
|
||||
</Card>
|
||||
<Card title="Privacy First">
|
||||
Self-hosted. Your data stays on your server.
|
||||
</Card>
|
||||
<Card title="Geolocation">
|
||||
Events are automatically tagged with your location.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
21
docs/src/styles/custom.css
Normal file
21
docs/src/styles/custom.css
Normal file
@@ -0,0 +1,21 @@
|
||||
/* Custom DearDiary styling */
|
||||
:root[data-theme='light'] {
|
||||
--sl-color-accent-low: #e9e3ff;
|
||||
--sl-color-accent: #7c3aed;
|
||||
--sl-color-accent-high: #4c1d95;
|
||||
--sl-color-white: #1e1b4b;
|
||||
--sl-color-gray-1: #3730a3;
|
||||
--sl-color-gray-2: #4338ca;
|
||||
--sl-color-gray-3: #4f46e5;
|
||||
--sl-color-gray-4: #6366f1;
|
||||
--sl-color-gray-5: #818cf8;
|
||||
--sl-color-gray-6: #a5b4fc;
|
||||
--sl-color-gray-7: #c7d2fe;
|
||||
--sl-color-black: #e0e7ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--sl-color-accent-low: #4c1d95;
|
||||
--sl-color-accent: #a78bfa;
|
||||
--sl-color-accent-high: #c4b5fd;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deardiary-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2,13 +2,18 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from './lib/api';
|
||||
import Auth from './pages/Auth';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Home from './pages/Home';
|
||||
import History from './pages/History';
|
||||
import Day from './pages/Day';
|
||||
import Journal from './pages/Journal';
|
||||
import Diary from './pages/Diary';
|
||||
import Calendar from './pages/Calendar';
|
||||
import Tasks from './pages/Tasks';
|
||||
import Settings from './pages/Settings';
|
||||
import QuickAddWidget from './components/QuickAddWidget';
|
||||
import SearchModal from './components/SearchModal';
|
||||
import { useTheme } from './lib/ThemeContext';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
@@ -33,18 +38,38 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function Navbar() {
|
||||
function Navbar({ onQuickAdd, onSearch, appName = 'DearDiary' }: { onQuickAdd: () => void; onSearch: () => void; appName?: string }) {
|
||||
return (
|
||||
<nav className="bg-slate-900 border-b border-slate-800 sticky top-0 z-50">
|
||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
DearDiary.io
|
||||
{appName}
|
||||
</span>
|
||||
</a>
|
||||
<div className="flex gap-6">
|
||||
<a href="/" className="text-slate-300 hover:text-white transition">Today</a>
|
||||
<a href="/history" className="text-slate-300 hover:text-white transition">History</a>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onSearch}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition"
|
||||
title="Search (Ctrl+K)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<kbd className="hidden sm:inline px-1.5 py-0.5 text-xs bg-slate-700 rounded">Ctrl+K</kbd>
|
||||
</button>
|
||||
<button
|
||||
onClick={onQuickAdd}
|
||||
className="w-8 h-8 flex items-center justify-center bg-purple-600 hover:bg-purple-700 rounded-lg font-bold transition"
|
||||
title="Quick Add (Ctrl+J)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<a href="/" className="text-slate-300 hover:text-white transition">Dashboard</a>
|
||||
<a href="/today" className="text-slate-300 hover:text-white transition">Today</a>
|
||||
<a href="/calendar" className="text-slate-300 hover:text-white transition">Calendar</a>
|
||||
<a href="/diary" className="text-slate-300 hover:text-white transition">Diary</a>
|
||||
<a href="/settings" className="text-slate-300 hover:text-white transition">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,17 +77,20 @@ function Navbar() {
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
function Footer({ appName = 'DearDiary' }: { appName?: string }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return (
|
||||
<footer className={`py-6 text-center text-sm text-slate-500 ${resolvedTheme === 'dark' ? 'border-t border-slate-800' : 'border-t border-slate-200'}`}>
|
||||
<p>DearDiary.io — Self-hosted AI-powered journaling · <a href="https://github.com/lotherk/deardiary" className="hover:text-purple-400 transition">GitHub</a> · <a href="https://deardiary.io" className="hover:text-purple-400 transition">deardiary.io</a> · MIT License · © 2024 Konrad Lother</p>
|
||||
<p>{appName} v{packageJson.version} — Self-hosted AI-powered journaling · <a href="https://github.com/lotherk/deardiary" className="hover:text-purple-400 transition">GitHub</a> · <a href="https://deardiary.io" className="hover:text-purple-400 transition">deardiary.io</a> · MIT License · © 2026 Konrad Lother</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
const [showQuickAdd, setShowQuickAdd] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [appName, setAppName] = useState('DearDiary');
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,27 +98,61 @@ function App() {
|
||||
setIsAuthenticated(!!key);
|
||||
}, []);
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${resolvedTheme === 'dark' ? 'bg-slate-950' : 'bg-white'}`}>
|
||||
<div className="animate-pulse text-slate-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
const fetchServerInfo = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/server-info');
|
||||
const data = await res.json();
|
||||
if (data.data?.appName) {
|
||||
setAppName(data.data.appName);
|
||||
}
|
||||
} catch {
|
||||
// Use default app name
|
||||
}
|
||||
};
|
||||
fetchServerInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') {
|
||||
e.preventDefault();
|
||||
setShowQuickAdd(true);
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setShowSearch(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className={`min-h-screen ${resolvedTheme === 'dark' ? 'bg-slate-950 text-slate-100' : 'bg-white text-slate-900'}`}>
|
||||
{isAuthenticated ? <Navbar /> : null}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Navbar onQuickAdd={() => setShowQuickAdd(true)} onSearch={() => setShowSearch(true)} appName={appName} />
|
||||
<QuickAddWidget isOpen={showQuickAdd} onClose={() => setShowQuickAdd(false)} />
|
||||
<SearchModal isOpen={showSearch} onClose={() => setShowSearch(false)} />
|
||||
</>
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/login" element={
|
||||
isAuthenticated ? <Navigate to="/" replace /> : <Auth onAuth={() => setIsAuthenticated(true)} />
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<PrivateRoute><Dashboard /></PrivateRoute>
|
||||
} />
|
||||
<Route path="/today" element={
|
||||
<PrivateRoute><Home /></PrivateRoute>
|
||||
} />
|
||||
<Route path="/history" element={
|
||||
<PrivateRoute><History /></PrivateRoute>
|
||||
<Route path="/diary" element={
|
||||
<PrivateRoute><Diary /></PrivateRoute>
|
||||
} />
|
||||
<Route path="/calendar" element={
|
||||
<PrivateRoute><Calendar /></PrivateRoute>
|
||||
} />
|
||||
<Route path="/day/:date" element={
|
||||
<PrivateRoute><Day /></PrivateRoute>
|
||||
@@ -106,7 +168,7 @@ function App() {
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
<Footer appName={appName} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
96
frontend/src/components/DateNavigator.tsx
Normal file
96
frontend/src/components/DateNavigator.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface DateNavigatorProps {
|
||||
currentDate?: string;
|
||||
}
|
||||
|
||||
export default function DateNavigator({ currentDate }: DateNavigatorProps) {
|
||||
const navigate = useNavigate();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const [inputValue, setInputValue] = useState(currentDate || today);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleInputSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (dateRegex.test(inputValue)) {
|
||||
navigate(`/day/${inputValue}`);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPrev = () => {
|
||||
if (!currentDate) return;
|
||||
const prev = new Date(currentDate + 'T12:00:00');
|
||||
prev.setDate(prev.getDate() - 1);
|
||||
const prevStr = prev.toISOString().split('T')[0];
|
||||
navigate(`/day/${prevStr}`);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (!currentDate) return;
|
||||
const next = new Date(currentDate + 'T12:00:00');
|
||||
next.setDate(next.getDate() + 1);
|
||||
const nextStr = next.toISOString().split('T')[0];
|
||||
const todayDate = new Date();
|
||||
todayDate.setHours(23, 59, 59, 999);
|
||||
if (next <= todayDate) {
|
||||
navigate(`/day/${nextStr}`);
|
||||
}
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
navigate(`/day/${today}`);
|
||||
};
|
||||
|
||||
const isToday = currentDate === today;
|
||||
const isFuture = currentDate ? new Date(currentDate) > new Date() : false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={!currentDate}
|
||||
className="p-2 hover:bg-slate-800 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Previous day"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<form onSubmit={handleInputSubmit} className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
max={isFuture ? undefined : today}
|
||||
className="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={!currentDate || isToday || isFuture}
|
||||
className="p-2 hover:bg-slate-800 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Next day"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!isToday && currentDate && (
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-3 py-1.5 text-sm text-purple-400 hover:text-purple-300 transition"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,39 +11,54 @@ export default function EntryList({ events, onDelete, readOnly }: Props) {
|
||||
return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'photo': return '📷';
|
||||
case 'voice': return '🎤';
|
||||
case 'health': return '💚';
|
||||
default: return '📝';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{readOnly && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-sm text-amber-400">
|
||||
🔒 Events are locked because a diary page has been written. Delete the diary page to edit events.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="bg-slate-900 rounded-lg p-4 border border-slate-800 border-l-4 border-l-blue-500"
|
||||
className="bg-slate-900 rounded-lg p-4 border border-slate-800"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">📝</span>
|
||||
<span className="text-lg">{getTypeIcon(event.type)}</span>
|
||||
<span className="text-xs text-slate-500">{formatTime(event.createdAt)}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-800 rounded text-slate-400 capitalize">{event.type}</span>
|
||||
</div>
|
||||
<p className="text-slate-200">{event.content}</p>
|
||||
{event.metadata && (
|
||||
|
||||
{event.mediaPath && (
|
||||
<div className="mt-3">
|
||||
{event.type === 'photo' && (
|
||||
<img
|
||||
src={event.mediaPath}
|
||||
alt="Event photo"
|
||||
className="max-w-md rounded-lg border border-slate-700"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
{event.type === 'voice' && (
|
||||
<audio controls className="w-full max-w-md mt-2">
|
||||
<source src={event.mediaPath} type="audio/webm" />
|
||||
Your browser does not support audio.
|
||||
</audio>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(event.placeName || (event.latitude && event.longitude)) && (
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
{(() => {
|
||||
try {
|
||||
const meta = JSON.parse(event.metadata);
|
||||
return meta.location ? (
|
||||
<span>📍 {meta.location.lat?.toFixed(4)}, {meta.location.lng?.toFixed(4)}</span>
|
||||
) : meta.duration ? (
|
||||
<span>⏱️ {meta.duration}s</span>
|
||||
) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
📍 {event.placeName || `${event.latitude?.toFixed(4)}, ${event.longitude?.toFixed(4)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
120
frontend/src/components/QuickAddWidget.tsx
Normal file
120
frontend/src/components/QuickAddWidget.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { getCurrentLocation } from '../lib/geolocation';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QuickAddWidget({ isOpen, onClose }: Props) {
|
||||
const [type, setType] = useState('event');
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
checkDiaryStatus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const checkDiaryStatus = async () => {
|
||||
const res = await api.getDay(today);
|
||||
if (res.data) {
|
||||
setLocked(!!res.data.journal);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!content.trim() || locked) return;
|
||||
|
||||
setLoading(true);
|
||||
const location = await getCurrentLocation();
|
||||
await api.createEvent(today, type, content, undefined, location ?? undefined);
|
||||
setContent('');
|
||||
setLoading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 text-center">
|
||||
<p className="text-slate-300 mb-2">Today's diary is locked</p>
|
||||
<p className="text-slate-500 text-sm mb-4">Delete the diary to add more events.</p>
|
||||
<a
|
||||
href={`/journal/${today}`}
|
||||
className="inline-block px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
View Diary
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md mx-4">
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('event')}
|
||||
className={`px-3 py-1 rounded text-sm ${type === 'event' ? 'bg-purple-600' : 'bg-slate-700'}`}
|
||||
>
|
||||
Event
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('health')}
|
||||
className={`px-3 py-1 rounded text-sm ${type === 'health' ? 'bg-purple-600' : 'bg-slate-700'}`}
|
||||
>
|
||||
Health
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('photo')}
|
||||
className={`px-3 py-1 rounded text-sm ${type === 'photo' ? 'bg-purple-600' : 'bg-slate-700'}`}
|
||||
>
|
||||
Photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('voice')}
|
||||
className={`px-3 py-1 rounded text-sm ${type === 'voice' ? 'bg-purple-600' : 'bg-slate-700'}`}
|
||||
>
|
||||
Voice
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={type === 'health' ? 'How are you feeling?' : 'Log an event...'}
|
||||
className="flex-1 px-4 py-3 bg-slate-900 rounded-lg border border-slate-700 focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !content.trim()}
|
||||
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
frontend/src/components/SearchModal.tsx
Normal file
159
frontend/src/components/SearchModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<{
|
||||
journals: Array<{ date: string; title: string; excerpt: string }>;
|
||||
events: Array<{ date: string; type: string; content: string }>;
|
||||
}>({ journals: [], events: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query || query.length < 2) {
|
||||
setResults({ journals: [], events: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
const res = await api.search(query);
|
||||
if (res.data) {
|
||||
setResults(res.data);
|
||||
}
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div
|
||||
className="relative w-full max-w-2xl mx-4 bg-slate-900 rounded-xl border border-slate-700 shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search diaries and events..."
|
||||
className="flex-1 bg-transparent text-lg outline-none placeholder-slate-500"
|
||||
/>
|
||||
<kbd className="px-2 py-1 text-xs bg-slate-800 text-slate-400 rounded">ESC</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="p-8 text-center text-slate-400">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && query.length >= 2 && results.journals.length === 0 && results.events.length === 0 && (
|
||||
<div className="p-8 text-center text-slate-400">
|
||||
No results found for "{query}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (results.journals.length > 0 || results.events.length > 0) && (
|
||||
<div className="p-4 space-y-6">
|
||||
{results.journals.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-3">
|
||||
Diary Pages ({results.journals.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{results.journals.map((j, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={`/journal/${j.date}`}
|
||||
onClick={onClose}
|
||||
className="block p-3 bg-slate-800/50 hover:bg-slate-800 rounded-lg transition"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium">{j.title || formatDate(j.date)}</span>
|
||||
<span className="text-xs text-slate-500">{formatDate(j.date)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 line-clamp-2">{j.excerpt}...</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.events.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-3">
|
||||
Events ({results.events.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{results.events.map((e, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={`/day/${e.date}`}
|
||||
onClick={onClose}
|
||||
className="block p-3 bg-slate-800/50 hover:bg-slate-800 rounded-lg transition"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-700 rounded">{e.type}</span>
|
||||
<span className="text-xs text-slate-500">{formatDate(e.date)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300">{e.content}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && query.length < 2 && (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
Type at least 2 characters to search
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,10 +46,6 @@ class ApiClient {
|
||||
return response.json() as Promise<ApiResponse<T>>;
|
||||
}
|
||||
|
||||
async register(email: string, password: string) {
|
||||
return this.request<{ user: { id: string; email: string } }>('POST', '/auth/register', { email, password });
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
return this.request<{ token: string; userId: string }>('POST', '/auth/login', { email, password });
|
||||
}
|
||||
@@ -68,7 +64,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getDays() {
|
||||
return this.request<Array<{ date: string; eventCount: number; hasJournal: boolean }>>('GET', '/days');
|
||||
return this.request<Array<{ date: string; eventCount: number; hasJournal: boolean; journalTitle?: string; journalExcerpt?: string }>>('GET', '/days');
|
||||
}
|
||||
|
||||
async getDay(date: string) {
|
||||
@@ -79,8 +75,8 @@ class ApiClient {
|
||||
return this.request<{ deleted: boolean }>('DELETE', `/days/${date}`);
|
||||
}
|
||||
|
||||
async createEvent(date: string, type: string, content: string, metadata?: object) {
|
||||
return this.request<Event>('POST', '/events', { date, type, content, metadata });
|
||||
async createEvent(date: string, type: string, content: string, metadata?: object, location?: { latitude: number; longitude: number; placeName?: string }) {
|
||||
return this.request<Event>('POST', '/events', { date, type, content, metadata, ...location });
|
||||
}
|
||||
|
||||
async updateEvent(id: string, content: string, metadata?: object) {
|
||||
@@ -129,14 +125,18 @@ class ApiClient {
|
||||
return response.json() as Promise<ApiResponse<{ mediaPath: string }>>;
|
||||
}
|
||||
|
||||
async generateJournal(date: string) {
|
||||
return this.request<{ journal: Journal; task: Task }>('POST', `/journal/generate/${date}`);
|
||||
async generateJournal(date: string, instructions?: string) {
|
||||
return this.request<{ journal: Journal; task: Task }>('POST', `/journal/generate/${date}`, { instructions: instructions || '' });
|
||||
}
|
||||
|
||||
async getJournal(date: string) {
|
||||
return this.request<Journal>('GET', `/journal/${date}`);
|
||||
}
|
||||
|
||||
async getJournals(page: number = 1, limit: number = 10) {
|
||||
return this.request<{ journals: Journal[]; total: number; page: number; limit: number; totalPages: number }>('GET', `/journals?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
async getJournalTasks(date: string) {
|
||||
return this.request<Task[]>('GET', `/journal/${date}/tasks`);
|
||||
}
|
||||
@@ -149,9 +149,37 @@ class ApiClient {
|
||||
return this.request<Settings>('GET', '/settings');
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
return this.request<{ journals: Array<{ date: string; title: string; excerpt: string }>; events: Array<{ date: string; type: string; content: string }> }>('GET', `/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
async updateSettings(settings: Partial<Settings>) {
|
||||
return this.request<Settings>('PUT', '/settings', settings);
|
||||
}
|
||||
|
||||
async exportData() {
|
||||
return this.request<ExportData>('GET', '/export');
|
||||
}
|
||||
|
||||
async importData(data: ExportData) {
|
||||
return this.request<ImportResult>('POST', '/import', data);
|
||||
}
|
||||
|
||||
async deleteAccount() {
|
||||
return this.request<{ deleted: boolean }>('DELETE', '/account');
|
||||
}
|
||||
|
||||
async resetAccount() {
|
||||
return this.request<{ reset: boolean }>('POST', '/account/reset');
|
||||
}
|
||||
|
||||
async changePassword(currentPassword: string, newPassword: string) {
|
||||
return this.request<{ changed: boolean }>('POST', '/account/password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
async register(email: string, password: string) {
|
||||
return this.request<{ apiKey: string; userId: string }>('POST', '/auth/register', { email, password });
|
||||
}
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
@@ -161,12 +189,16 @@ export interface Event {
|
||||
content: string;
|
||||
mediaPath?: string;
|
||||
metadata?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
placeName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Journal {
|
||||
id: string;
|
||||
date: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
eventCount: number;
|
||||
generatedAt: string;
|
||||
@@ -183,6 +215,7 @@ export interface Task {
|
||||
request?: string;
|
||||
response?: string;
|
||||
error?: string;
|
||||
title?: string | null;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
@@ -203,4 +236,69 @@ export interface Settings {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ExportData {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
settings: {
|
||||
aiProvider: string;
|
||||
aiApiKey?: string;
|
||||
aiModel?: string;
|
||||
aiBaseUrl?: string;
|
||||
journalPrompt?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
providerSettings?: string;
|
||||
journalContextDays?: number;
|
||||
};
|
||||
events: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
type: string;
|
||||
content: string;
|
||||
mediaPath?: string;
|
||||
metadata?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
placeName?: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
journals: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
eventCount: number;
|
||||
generatedAt: string;
|
||||
}>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
journalId: string;
|
||||
date: string;
|
||||
type: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
request?: string;
|
||||
response?: string;
|
||||
error?: string;
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
compatible: boolean;
|
||||
importedEvents: number;
|
||||
importedJournals: number;
|
||||
importedTasks: number;
|
||||
skippedEvents: number;
|
||||
skippedJournals: number;
|
||||
totalEvents: number;
|
||||
totalJournals: number;
|
||||
totalTasks: number;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
50
frontend/src/lib/geolocation.ts
Normal file
50
frontend/src/lib/geolocation.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface Geolocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
placeName?: string;
|
||||
}
|
||||
|
||||
export async function getCurrentLocation(): Promise<Geolocation | null> {
|
||||
if (!navigator.geolocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const { latitude, longitude } = position.coords;
|
||||
let placeName: string | undefined;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.address) {
|
||||
const parts = [];
|
||||
if (data.address.building || data.address.house_number) {
|
||||
parts.push(data.address.building || data.address.house_number);
|
||||
}
|
||||
if (data.address.road) {
|
||||
parts.push(data.address.road);
|
||||
}
|
||||
if (data.address.city || data.address.town || data.address.village) {
|
||||
parts.push(data.address.city || data.address.town || data.address.village);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
placeName = parts.slice(0, 2).join(', ');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Geocoding failed, continue without place name
|
||||
}
|
||||
|
||||
resolve({ latitude, longitude, placeName });
|
||||
},
|
||||
() => {
|
||||
resolve(null);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 5000, maximumAge: 60000 }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
export default function Auth({ onAuth }: { onAuth: () => void }) {
|
||||
@@ -7,6 +7,26 @@ export default function Auth({ onAuth }: { onAuth: () => void }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(false);
|
||||
const [appName, setAppName] = useState('DearDiary');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServerInfo = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/server-info');
|
||||
const data = await res.json();
|
||||
if (data.data) {
|
||||
setRegistrationEnabled(data.data.registrationEnabled);
|
||||
if (data.data.appName) {
|
||||
setAppName(data.data.appName);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Server info not available, use defaults
|
||||
}
|
||||
};
|
||||
fetchServerInfo();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -44,7 +64,7 @@ export default function Auth({ onAuth }: { onAuth: () => void }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-3xl font-bold text-center mb-8 text-slate-100">DearDiary</h1>
|
||||
<h1 className="text-3xl font-bold text-center mb-8 text-slate-100">{appName}</h1>
|
||||
|
||||
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
||||
<div className="flex gap-4 mb-6">
|
||||
@@ -56,14 +76,16 @@ export default function Auth({ onAuth }: { onAuth: () => void }) {
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('register')}
|
||||
className={`flex-1 py-2 rounded-lg transition ${
|
||||
mode === 'register' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
{registrationEnabled && (
|
||||
<button
|
||||
onClick={() => setMode('register')}
|
||||
className={`flex-1 py-2 rounded-lg transition ${
|
||||
mode === 'register' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
200
frontend/src/pages/Calendar.tsx
Normal file
200
frontend/src/pages/Calendar.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface DayInfo {
|
||||
date: string;
|
||||
eventCount: number;
|
||||
hasJournal: boolean;
|
||||
}
|
||||
|
||||
export default function Calendar() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [days, setDays] = useState<DayInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMonth();
|
||||
}, [currentDate]);
|
||||
|
||||
const loadMonth = async () => {
|
||||
const res = await api.getDays();
|
||||
if (res.data) {
|
||||
setDays(res.data);
|
||||
}
|
||||
};
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
const firstDayOfMonth = new Date(year, month, 1);
|
||||
const lastDayOfMonth = new Date(year, month + 1, 0);
|
||||
const startingDay = firstDayOfMonth.getDay();
|
||||
const daysInMonth = lastDayOfMonth.getDate();
|
||||
const monthName = currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
|
||||
const prevMonth = () => {
|
||||
setCurrentDate(new Date(year, month - 1, 1));
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setCurrentDate(new Date(year, month + 1, 1));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
const formatDate = (day: number) => {
|
||||
return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getDayInfo = (day: number): DayInfo | undefined => {
|
||||
const dateStr = formatDate(day);
|
||||
return days.find(d => d.date === dateStr);
|
||||
};
|
||||
|
||||
const isToday = (day: number) => {
|
||||
const today = new Date();
|
||||
return today.getDate() === day && today.getMonth() === month && today.getFullYear() === year;
|
||||
};
|
||||
|
||||
const isFuture = (day: number) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dayDate = new Date(year, month, day);
|
||||
return dayDate > today;
|
||||
};
|
||||
|
||||
const weeks = [];
|
||||
let currentWeek = Array(startingDay).fill(null);
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
currentWeek.push(day);
|
||||
if (currentWeek.length === 7) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWeek.length > 0) {
|
||||
while (currentWeek.length < 7) {
|
||||
currentWeek.push(null);
|
||||
}
|
||||
weeks.push(currentWeek);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<a href="/" className="text-sm text-slate-400 hover:text-white transition">← Dashboard</a>
|
||||
<h1 className="text-2xl font-bold mt-2">Calendar</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-2 hover:bg-slate-800 rounded-lg transition"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold">{monthName}</h2>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-2 hover:bg-slate-800 rounded-lg transition"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div className="grid grid-cols-7 border-b border-slate-800">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div key={day} className="p-3 text-center text-sm font-medium text-slate-400">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7">
|
||||
{weeks.flat().map((day, index) => {
|
||||
if (day === null) {
|
||||
return <div key={`empty-${index}`} className="min-h-[80px] p-2 border-r border-b border-slate-800 bg-slate-950/50" />;
|
||||
}
|
||||
|
||||
const dayInfo = getDayInfo(day);
|
||||
const dateStr = formatDate(day);
|
||||
const future = isFuture(day);
|
||||
const today = isToday(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={`min-h-[80px] p-2 border-r border-b border-slate-800 ${
|
||||
future ? 'bg-slate-950/30' : 'bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-sm ${today ? 'w-7 h-7 flex items-center justify-center bg-purple-600 rounded-full text-white font-bold' : ''} ${
|
||||
future ? 'text-slate-600' : 'text-slate-300'
|
||||
}`}>
|
||||
{day}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!future && dayInfo && (
|
||||
<div className="space-y-1">
|
||||
{dayInfo.hasJournal ? (
|
||||
<a
|
||||
href={`/journal/${dateStr}`}
|
||||
className="block text-xs px-2 py-1 bg-purple-600/20 text-purple-400 rounded hover:bg-purple-600/30 transition"
|
||||
title={`Diary: ${dayInfo.eventCount} events`}
|
||||
>
|
||||
📖 {dayInfo.eventCount}
|
||||
</a>
|
||||
) : dayInfo.eventCount > 0 ? (
|
||||
<a
|
||||
href={`/day/${dateStr}`}
|
||||
className="block text-xs px-2 py-1 bg-slate-700/50 text-slate-400 rounded hover:bg-slate-700 transition"
|
||||
title={`${dayInfo.eventCount} events`}
|
||||
>
|
||||
📝 {dayInfo.eventCount}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-6 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 bg-purple-600/20 rounded"></span>
|
||||
<span>Has diary</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 bg-slate-700/50 rounded"></span>
|
||||
<span>Has events</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 bg-slate-950/50 border border-slate-800 rounded"></span>
|
||||
<span>Future</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
frontend/src/pages/Dashboard.tsx
Normal file
165
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Event } from '../lib/api';
|
||||
|
||||
interface DayInfo {
|
||||
date: string;
|
||||
eventCount: number;
|
||||
hasJournal: boolean;
|
||||
journalTitle?: string;
|
||||
journalGeneratedAt?: string;
|
||||
journalExcerpt?: string;
|
||||
}
|
||||
|
||||
interface TodayInfo {
|
||||
eventCount: number;
|
||||
hasJournal: boolean;
|
||||
events: Event[];
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [days, setDays] = useState<DayInfo[]>([]);
|
||||
const [today, setToday] = useState<TodayInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const todayDate = new Date().toISOString().split('T')[0];
|
||||
const [daysRes, todayRes] = await Promise.all([
|
||||
api.getDays(),
|
||||
api.getDay(todayDate)
|
||||
]);
|
||||
if (daysRes.data) {
|
||||
setDays(daysRes.data.slice(0, 7));
|
||||
}
|
||||
if (todayRes.data) {
|
||||
setToday({
|
||||
eventCount: todayRes.data.events.length,
|
||||
hasJournal: !!todayRes.data.journal,
|
||||
events: todayRes.data.events
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (dateStr === today.toISOString().split('T')[0]) return 'Today';
|
||||
if (dateStr === yesterday.toISOString().split('T')[0]) return 'Yesterday';
|
||||
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Dashboard</h1>
|
||||
<p className="text-slate-400">Your recent diary entries</p>
|
||||
</div>
|
||||
|
||||
{today && (
|
||||
<div className="mb-8 bg-gradient-to-br from-purple-900/50 to-pink-900/50 rounded-2xl p-6 border border-purple-500/20">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Today</h2>
|
||||
<p className="text-purple-300 text-sm">
|
||||
{today.eventCount} {today.eventCount === 1 ? 'event' : 'events'} captured
|
||||
{today.hasJournal && <span className="ml-2 text-amber-400">🔒 Locked</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{today.hasJournal ? (
|
||||
<a href={`/journal/${new Date().toISOString().split('T')[0]}`} className="px-4 py-2 bg-amber-600 hover:bg-amber-700 rounded-lg text-sm font-medium transition">
|
||||
View Page
|
||||
</a>
|
||||
) : today.eventCount > 0 ? (
|
||||
<a href="/today" className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition">
|
||||
Generate Diary
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{today.events.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{today.events.slice(-3).reverse().map((event) => (
|
||||
<div key={event.id} className="flex items-center gap-3 text-sm">
|
||||
<span className="text-slate-500 w-12">{formatTime(event.createdAt)}</span>
|
||||
<span className="text-slate-300">{event.content}</span>
|
||||
</div>
|
||||
))}
|
||||
{today.events.length > 3 && (
|
||||
<a href="/today" className="text-purple-400 text-sm hover:text-purple-300">
|
||||
+{today.events.length - 3} more events
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-400 text-sm">No events captured today yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-medium mb-4 text-slate-300">Recent Diary Pages</h3>
|
||||
<div className="grid gap-4">
|
||||
{days.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p>No diary entries yet.</p>
|
||||
<a href="/today" className="text-purple-400 hover:text-purple-300 mt-2 inline-block">
|
||||
Start capturing your day →
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
days.map((day) => (
|
||||
<a
|
||||
key={day.date}
|
||||
href={day.hasJournal ? `/journal/${day.date}` : `/day/${day.date}`}
|
||||
className="block bg-slate-900 rounded-xl p-4 border border-slate-800 hover:border-slate-700 transition"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium">{day.journalTitle || formatDate(day.date)}</h3>
|
||||
{day.hasJournal ? (
|
||||
<span className="text-xs px-2 py-1 bg-purple-500/20 text-purple-400 rounded">Diary</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
{day.journalExcerpt && (
|
||||
<p className="text-sm text-slate-400 mb-2 line-clamp-2">
|
||||
{day.journalExcerpt}...
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm text-slate-500">
|
||||
{day.eventCount} {day.eventCount === 1 ? 'event' : 'events'}
|
||||
{day.hasJournal && day.journalGeneratedAt && (
|
||||
<span className="ml-2">
|
||||
· {formatDate(day.date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { api, Event } from '../lib/api';
|
||||
import { getCurrentLocation } from '../lib/geolocation';
|
||||
import EntryInput from '../components/EntryInput';
|
||||
import EntryList from '../components/EntryList';
|
||||
import DateNavigator from '../components/DateNavigator';
|
||||
|
||||
export default function Day() {
|
||||
const { date } = useParams<{ date: string }>();
|
||||
@@ -28,7 +30,8 @@ export default function Day() {
|
||||
|
||||
const handleAddEvent = async (type: string, content: string, metadata?: object) => {
|
||||
if (!date) return { error: { message: 'No date' } };
|
||||
const res = await api.createEvent(date, type, content, metadata);
|
||||
const location = await getCurrentLocation();
|
||||
const res = await api.createEvent(date, type, content, metadata, location ?? undefined);
|
||||
if (res.data) {
|
||||
setEvents((prev) => [...prev, res.data!]);
|
||||
}
|
||||
@@ -65,22 +68,25 @@ export default function Day() {
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<a href="/history" className="text-slate-400 hover:text-white text-sm mb-1 inline-block">← Back</a>
|
||||
<a href="/" className="text-slate-400 hover:text-white text-sm mb-1 inline-block">← Dashboard</a>
|
||||
<h1 className="text-2xl font-bold">{formatDate(date)}</h1>
|
||||
</div>
|
||||
{hasJournal && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDeleteJournal}
|
||||
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition"
|
||||
>
|
||||
Delete Diary
|
||||
</button>
|
||||
<a href={`/journal/${date}`} className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition">
|
||||
View Diary
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<DateNavigator currentDate={date} />
|
||||
{hasJournal && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDeleteJournal}
|
||||
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition"
|
||||
>
|
||||
Delete Diary
|
||||
</button>
|
||||
<a href={`/journal/${date}`} className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition">
|
||||
View Diary
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteError && (
|
||||
@@ -89,7 +95,13 @@ export default function Day() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntryInput onSubmit={handleAddEvent} />
|
||||
{hasJournal ? (
|
||||
<div className="mb-4 p-4 bg-slate-800/50 border border-slate-700 rounded-lg text-center">
|
||||
<p className="text-slate-400 text-sm">Events are locked. <a href={`/journal/${date}`} className="text-purple-400 hover:underline">View diary</a> or <button onClick={handleDeleteJournal} className="text-red-400 hover:underline">delete diary</button> to add more events.</p>
|
||||
</div>
|
||||
) : (
|
||||
<EntryInput onSubmit={handleAddEvent} />
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
||||
|
||||
154
frontend/src/pages/Diary.tsx
Normal file
154
frontend/src/pages/Diary.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Journal } from '../lib/api';
|
||||
|
||||
const PAGE_SIZES = [10, 50, 100];
|
||||
|
||||
export default function Diary() {
|
||||
const [journals, setJournals] = useState<Journal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadJournals();
|
||||
}, [page, limit]);
|
||||
|
||||
const loadJournals = async () => {
|
||||
setLoading(true);
|
||||
const res = await api.getJournals(page, limit);
|
||||
if (res.data) {
|
||||
setJournals(res.data.journals);
|
||||
setTotal(res.data.total);
|
||||
setTotalPages(res.data.totalPages);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
const formatDateShort = (dateStr: string) => {
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-2">Diary</h1>
|
||||
<p className="text-slate-400 text-sm">Read your diary pages</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-slate-400">Show:</label>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => { setLimit(parseInt(e.target.value)); setPage(1); }}
|
||||
className="bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
{PAGE_SIZES.map(size => (
|
||||
<option key={size} value={size}>{size} per page</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{total} diary pages total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
||||
) : journals.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400 mb-2">No diary pages yet</p>
|
||||
<p className="text-slate-500 text-sm">Generate diary pages from your events to see them here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{journals.map((journal) => (
|
||||
<div key={journal.id} className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<a href={`/journal/${journal.date}`} className="text-lg font-semibold hover:text-purple-400 transition">
|
||||
{journal.title || formatDateShort(journal.date)}
|
||||
</a>
|
||||
<p className="text-sm text-slate-400">{formatDate(journal.date)}</p>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{journal.eventCount} events
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose prose-invert prose-sm prose-slate max-w-none">
|
||||
<div className="text-slate-300 whitespace-pre-wrap leading-relaxed">
|
||||
{journal.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-slate-800 flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">
|
||||
Generated {new Date(journal.generatedAt).toLocaleString()}
|
||||
</span>
|
||||
<a
|
||||
href={`/journal/${journal.date}`}
|
||||
className="text-sm text-purple-400 hover:text-purple-300 transition"
|
||||
>
|
||||
View & Edit →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (page <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (page >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = page - 2 + i;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`w-10 h-10 rounded text-sm transition ${
|
||||
page === pageNum
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-800 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ interface DayInfo {
|
||||
date: string;
|
||||
eventCount: number;
|
||||
hasJournal: boolean;
|
||||
journalTitle?: string;
|
||||
}
|
||||
|
||||
export default function History() {
|
||||
@@ -55,16 +56,14 @@ export default function History() {
|
||||
className="flex items-center justify-between p-4 bg-slate-900 rounded-lg border border-slate-800"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href={`/day/${day.date}`} className="font-medium hover:text-blue-400">
|
||||
{formatDate(day.date)}
|
||||
<a href={day.hasJournal ? `/journal/${day.date}` : `/day/${day.date}`} className="font-medium hover:text-blue-400">
|
||||
{day.journalTitle || formatDate(day.date)}
|
||||
</a>
|
||||
<span className="text-slate-400 text-sm">
|
||||
{day.eventCount} {day.eventCount === 1 ? 'event' : 'events'}
|
||||
</span>
|
||||
{day.hasJournal && (
|
||||
<a href={`/journal/${day.date}`} className="text-purple-400 text-sm hover:text-purple-300">
|
||||
Diary
|
||||
</a>
|
||||
<span className="text-purple-400 text-xs px-2 py-1 bg-purple-500/20 rounded">Diary</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, Event } from '../lib/api';
|
||||
import { getCurrentLocation } from '../lib/geolocation';
|
||||
import EntryInput from '../components/EntryInput';
|
||||
import EntryList from '../components/EntryList';
|
||||
|
||||
@@ -8,7 +9,9 @@ export default function Home() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [hasJournal, setHasJournal] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
@@ -22,12 +25,14 @@ export default function Home() {
|
||||
const res = await api.getDay(today);
|
||||
if (res.data) {
|
||||
setEvents(res.data.events);
|
||||
setHasJournal(!!res.data.journal);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAddEvent = async (type: string, content: string, metadata?: object) => {
|
||||
const res = await api.createEvent(today, type, content, metadata);
|
||||
const location = await getCurrentLocation();
|
||||
const res = await api.createEvent(today, type, content, metadata, location ?? undefined);
|
||||
if (res.data) {
|
||||
setEvents((prev) => [...prev, res.data!]);
|
||||
}
|
||||
@@ -41,14 +46,26 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteJournal = async () => {
|
||||
if (!confirm('Delete diary page? This will unlock events for editing.')) return;
|
||||
const res = await api.deleteJournal(today);
|
||||
if (res.data) {
|
||||
setHasJournal(false);
|
||||
setSuccess(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateJournal = async () => {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
const res = await api.generateJournal(today);
|
||||
setGenerating(false);
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
} else {
|
||||
setHasJournal(true);
|
||||
setSuccess('Diary page generated! Events are now locked.');
|
||||
navigate(`/journal/${today}`);
|
||||
}
|
||||
};
|
||||
@@ -57,16 +74,26 @@ export default function Home() {
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Today</h1>
|
||||
<a href="/" className="text-sm text-slate-400 hover:text-white transition">← Dashboard</a>
|
||||
<h1 className="text-2xl font-bold mt-2">Today</h1>
|
||||
<p className="text-slate-400">{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateJournal}
|
||||
disabled={generating || events.length === 0}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Diary Page'}
|
||||
</button>
|
||||
{hasJournal ? (
|
||||
<a
|
||||
href={`/journal/${today}`}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition"
|
||||
>
|
||||
View Diary Page
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerateJournal}
|
||||
disabled={generating || events.length === 0}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Diary Page'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -75,7 +102,19 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntryInput onSubmit={handleAddEvent} />
|
||||
{success && (
|
||||
<div className="mb-4 p-4 bg-green-500/20 border border-green-500/30 rounded-lg text-green-400">
|
||||
{success} <button onClick={handleDeleteJournal} className="underline hover:no-underline ml-2">Delete diary</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasJournal ? (
|
||||
<div className="mb-4 p-4 bg-slate-800/50 border border-slate-700 rounded-lg text-center">
|
||||
<p className="text-slate-400 text-sm">Events are locked. <a href={`/journal/${today}`} className="text-purple-400 hover:underline">View diary</a> or <button onClick={handleDeleteJournal} className="text-red-400 hover:underline">delete diary</button> to add more events.</p>
|
||||
</div>
|
||||
) : (
|
||||
<EntryInput onSubmit={handleAddEvent} />
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
||||
@@ -85,10 +124,10 @@ export default function Home() {
|
||||
<p className="text-slate-500 text-sm">Start capturing your day above</p>
|
||||
</div>
|
||||
) : (
|
||||
<EntryList events={events} onDelete={handleDeleteEvent} />
|
||||
<EntryList events={events} onDelete={handleDeleteEvent} readOnly={hasJournal} />
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
{events.length > 0 && hasJournal && (
|
||||
<div className="mt-6 text-center">
|
||||
<a href={`/journal/${today}`} className="text-purple-400 hover:text-purple-300 text-sm">
|
||||
View diary page →
|
||||
|
||||
@@ -120,6 +120,54 @@ function GeneratingModal({
|
||||
);
|
||||
}
|
||||
|
||||
function RewriteModal({
|
||||
isOpen,
|
||||
instructions,
|
||||
onChange,
|
||||
onRewrite,
|
||||
onCancel
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
instructions: string;
|
||||
onChange: (v: string) => void;
|
||||
onRewrite: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="relative bg-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full mx-4">
|
||||
<h3 className="text-xl font-semibold mb-4">Rewrite Diary Page</h3>
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
Add any specific instructions or changes you want the AI to consider when rewriting your diary page.
|
||||
</p>
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="e.g., Focus more on the morning meeting, include more details about the lunch break..."
|
||||
className="w-full h-32 px-4 py-3 bg-slate-900 rounded-lg border border-slate-700 focus:border-purple-500 focus:outline-none resize-none text-sm"
|
||||
/>
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onRewrite}
|
||||
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
Rewrite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JournalPage() {
|
||||
const { date } = useParams<{ date: string }>();
|
||||
const [journal, setJournal] = useState<Journal | null>(null);
|
||||
@@ -130,6 +178,8 @@ export default function JournalPage() {
|
||||
const [currentModel, setCurrentModel] = useState('');
|
||||
const [generatingStep, setGeneratingStep] = useState(0);
|
||||
const [currentResponse, setCurrentResponse] = useState<string | undefined>();
|
||||
const [showRewriteModal, setShowRewriteModal] = useState(false);
|
||||
const [rewriteInstructions, setRewriteInstructions] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (date) {
|
||||
@@ -138,6 +188,13 @@ export default function JournalPage() {
|
||||
}
|
||||
}, [date]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!generating) {
|
||||
loadJournal();
|
||||
loadTasks();
|
||||
}
|
||||
}, [generating]);
|
||||
|
||||
const loadJournal = async () => {
|
||||
if (!date) return;
|
||||
setLoading(true);
|
||||
@@ -156,9 +213,8 @@ export default function JournalPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const handleReRunTask = async (_task: Task) => {
|
||||
if (!date) return;
|
||||
|
||||
setGenerating(true);
|
||||
setGeneratingStep(1);
|
||||
setCurrentProvider('ai');
|
||||
@@ -167,6 +223,10 @@ export default function JournalPage() {
|
||||
|
||||
const res = await api.generateJournal(date);
|
||||
setGeneratingStep(3);
|
||||
if (res.error) {
|
||||
setGenerating(false);
|
||||
return;
|
||||
}
|
||||
if (res.data) {
|
||||
setCurrentResponse(res.data.task.response);
|
||||
setJournal(res.data.journal);
|
||||
@@ -177,6 +237,41 @@ export default function JournalPage() {
|
||||
setTimeout(() => setGenerating(false), 500);
|
||||
};
|
||||
|
||||
const handleRewrite = async () => {
|
||||
setShowRewriteModal(false);
|
||||
if (!date) return;
|
||||
|
||||
setGenerating(true);
|
||||
setGeneratingStep(1);
|
||||
setCurrentProvider('ai');
|
||||
setCurrentModel('');
|
||||
setCurrentResponse(undefined);
|
||||
|
||||
const res = await api.generateJournal(date, rewriteInstructions);
|
||||
setGeneratingStep(3);
|
||||
if (res.error) {
|
||||
setGenerating(false);
|
||||
return;
|
||||
}
|
||||
if (res.data) {
|
||||
setCurrentResponse(res.data.task.response);
|
||||
setJournal(res.data.journal);
|
||||
setCurrentProvider(res.data.task.provider);
|
||||
setCurrentModel(res.data.task.model || '');
|
||||
setTasks(prev => [res.data!.task, ...prev]);
|
||||
}
|
||||
setTimeout(() => setGenerating(false), 500);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!date) return;
|
||||
if (!confirm('Delete diary page? This will unlock events for editing.')) return;
|
||||
const res = await api.deleteJournal(date);
|
||||
if (res.data) {
|
||||
window.location.href = `/day/${date}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
||||
@@ -194,6 +289,14 @@ export default function JournalPage() {
|
||||
response={currentResponse}
|
||||
/>
|
||||
|
||||
<RewriteModal
|
||||
isOpen={showRewriteModal}
|
||||
instructions={rewriteInstructions}
|
||||
onChange={setRewriteInstructions}
|
||||
onRewrite={handleRewrite}
|
||||
onCancel={() => setShowRewriteModal(false)}
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block">← Back to day</a>
|
||||
@@ -207,7 +310,7 @@ export default function JournalPage() {
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400 mb-4">No diary page written yet</p>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
onClick={() => setShowRewriteModal(true)}
|
||||
disabled={generating}
|
||||
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
|
||||
>
|
||||
@@ -216,9 +319,12 @@ export default function JournalPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm text-slate-400 mb-4">
|
||||
<div className="text-sm text-slate-400 mb-2">
|
||||
Generated {new Date(journal.generatedAt).toLocaleString()} • {journal.eventCount} events
|
||||
</div>
|
||||
{journal.title && (
|
||||
<h2 className="text-xl font-bold mb-4">{journal.title}</h2>
|
||||
)}
|
||||
<div className="prose prose-invert prose-slate max-w-none">
|
||||
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800 whitespace-pre-wrap leading-relaxed">
|
||||
{journal.content}
|
||||
@@ -226,38 +332,30 @@ export default function JournalPage() {
|
||||
</div>
|
||||
<div className="mt-6 flex gap-4">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50"
|
||||
onClick={() => setShowRewriteModal(true)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition"
|
||||
>
|
||||
Rewrite
|
||||
</button>
|
||||
<a
|
||||
href={`/tasks/${date}`}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition"
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition"
|
||||
>
|
||||
View Tasks
|
||||
</a>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium">Generation Tasks</h2>
|
||||
<a
|
||||
href={`/tasks/${date}`}
|
||||
className="text-sm text-purple-400 hover:text-purple-300"
|
||||
>
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-4">Generation Tasks</h2>
|
||||
<div className="space-y-2">
|
||||
{tasks.slice(0, 3).map(task => (
|
||||
{tasks.map((task, index) => (
|
||||
<details key={task.id} className="bg-slate-900 rounded-lg border border-slate-800">
|
||||
<summary className="p-3 cursor-pointer flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-slate-500 text-xs w-6">#{tasks.length - index}</span>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
task.status === 'completed' ? 'bg-green-500' :
|
||||
task.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500'
|
||||
@@ -265,14 +363,60 @@ export default function JournalPage() {
|
||||
<span className="font-medium">
|
||||
{task.provider.charAt(0).toUpperCase() + task.provider.slice(1)}
|
||||
</span>
|
||||
{task.model && <span className="text-slate-500 text-xs">{task.model}</span>}
|
||||
<span className="text-slate-600 text-xs">
|
||||
{new Date(task.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{task.title && (
|
||||
<span className="text-slate-400 text-xs italic max-w-[200px] truncate" title={task.title}>
|
||||
"{task.title}"
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleReRunTask(task); }}
|
||||
disabled={generating}
|
||||
className="text-slate-400 hover:text-white transition disabled:opacity-50 p-1"
|
||||
title="Re-run this task"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
task.status === 'completed' ? 'bg-green-500/20 text-green-400' :
|
||||
task.status === 'failed' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
task.status === 'completed' ? 'bg-green-500/20 text-green-400' :
|
||||
task.status === 'failed' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="p-3 pt-0 border-t border-slate-800 text-sm space-y-2">
|
||||
{task.error && (
|
||||
<div className="text-red-400 bg-red-500/10 rounded p-2">
|
||||
Error: {task.error}
|
||||
</div>
|
||||
)}
|
||||
{task.prompt && (
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs mb-1">Prompt:</div>
|
||||
<pre className="bg-slate-800 rounded p-2 text-xs overflow-auto max-h-32">{task.prompt}</pre>
|
||||
</div>
|
||||
)}
|
||||
{task.request && (
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs mb-1">Request:</div>
|
||||
<pre className="bg-slate-800 rounded p-2 text-xs overflow-auto max-h-32">{task.request}</pre>
|
||||
</div>
|
||||
)}
|
||||
{task.response && (
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs mb-1">Response:</div>
|
||||
<pre className="bg-slate-800 rounded p-2 text-xs overflow-auto max-h-32">{task.response}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Settings } from '../lib/api';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api, Settings, ExportData, ImportResult } from '../lib/api';
|
||||
import { useTheme } from '../lib/ThemeContext';
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
interface ProviderSettings {
|
||||
apiKey?: string;
|
||||
@@ -39,6 +40,25 @@ export default function SettingsPage() {
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [importWarning, setImportWarning] = useState<string | null>(null);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const [pendingImport, setPendingImport] = useState<ExportData | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [resetConfirmText, setResetConfirmText] = useState('');
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [showPasswordChange, setShowPasswordChange] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSuccess, setPasswordSuccess] = useState('');
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,7 +137,7 @@ export default function SettingsPage() {
|
||||
...settings,
|
||||
aiProvider: provider as FullSettings['aiProvider'],
|
||||
aiApiKey: newProviderSettingsData.apiKey,
|
||||
aiModel: newProviderSettingsData.model || DEFAULT_MODELS[provider] || '',
|
||||
aiModel: DEFAULT_MODELS[provider] || '',
|
||||
aiBaseUrl: newProviderSettingsData.baseUrl,
|
||||
providerSettings: newProviderSettings,
|
||||
});
|
||||
@@ -182,6 +202,178 @@ export default function SettingsPage() {
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await api.exportData();
|
||||
if (res.data) {
|
||||
const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `deardiary-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
alert('Export failed: ' + (res.error?.message || 'Unknown error'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Export failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
||||
}
|
||||
setExporting(false);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string) as ExportData;
|
||||
|
||||
if (!data.version || !data.events || !data.journals) {
|
||||
alert('Invalid export file format');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileVersion = data.version;
|
||||
const currentVersion = packageJson.version;
|
||||
const fileParts = fileVersion.split('.').map(Number);
|
||||
const currentParts = currentVersion.split('.').map(Number);
|
||||
const fileNum = fileParts[0] * 10000 + fileParts[1] * 100 + (fileParts[2] || 0);
|
||||
const currentNum = currentParts[0] * 10000 + currentParts[1] * 100 + (currentParts[2] || 0);
|
||||
|
||||
if (fileNum < currentNum) {
|
||||
setImportWarning(`This export was created with version ${fileVersion}, which is older than the current version ${currentVersion}. Import may fail or lose some data.`);
|
||||
} else if (fileNum > currentNum) {
|
||||
setImportWarning(`This export was created with version ${fileVersion}, which is newer than the current version ${currentVersion}. Some features may not work correctly.`);
|
||||
} else {
|
||||
setImportWarning(null);
|
||||
}
|
||||
|
||||
setPendingImport(data);
|
||||
setShowImportConfirm(true);
|
||||
setImportResult(null);
|
||||
} catch {
|
||||
alert('Failed to parse export file');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!pendingImport) return;
|
||||
|
||||
setImporting(true);
|
||||
setShowImportConfirm(false);
|
||||
|
||||
try {
|
||||
const res = await api.importData(pendingImport);
|
||||
if (res.error) {
|
||||
setImportResult({
|
||||
compatible: false,
|
||||
importedEvents: 0,
|
||||
importedJournals: 0,
|
||||
importedTasks: 0,
|
||||
skippedEvents: 0,
|
||||
skippedJournals: 0,
|
||||
totalEvents: 0,
|
||||
totalJournals: 0,
|
||||
totalTasks: 0,
|
||||
warning: res.error.message,
|
||||
});
|
||||
} else if (res.data) {
|
||||
setImportResult(res.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setImportResult({
|
||||
compatible: false,
|
||||
importedEvents: 0,
|
||||
importedJournals: 0,
|
||||
importedTasks: 0,
|
||||
skippedEvents: 0,
|
||||
skippedJournals: 0,
|
||||
totalEvents: 0,
|
||||
totalJournals: 0,
|
||||
totalTasks: 0,
|
||||
warning: err instanceof Error ? err.message : 'Import failed',
|
||||
});
|
||||
}
|
||||
|
||||
setPendingImport(null);
|
||||
setImporting(false);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteConfirmText !== 'DELETE') return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await api.deleteAccount();
|
||||
if (res.data?.deleted) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
alert('Failed to delete account: ' + (res.error?.message || 'Unknown error'));
|
||||
setDeleting(false);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to delete account: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetAccount = async () => {
|
||||
if (resetConfirmText !== 'RESET') return;
|
||||
|
||||
setResetting(true);
|
||||
try {
|
||||
const res = await api.resetAccount();
|
||||
if (res.data?.reset) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to reset account: ' + (res.error?.message || 'Unknown error'));
|
||||
setResetting(false);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to reset account: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (newPassword.length < 6) {
|
||||
setPasswordError('New password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setChangingPassword(true);
|
||||
setPasswordError('');
|
||||
setPasswordSuccess('');
|
||||
|
||||
try {
|
||||
const res = await api.changePassword(currentPassword, newPassword);
|
||||
if (res.error) {
|
||||
setPasswordError(res.error.message);
|
||||
} else {
|
||||
setPasswordSuccess('Password changed successfully');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setShowPasswordChange(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setPasswordError(err instanceof Error ? err.message : 'Failed to change password');
|
||||
}
|
||||
|
||||
setChangingPassword(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
api.clearApiKey();
|
||||
window.location.href = '/login';
|
||||
@@ -322,13 +514,13 @@ export default function SettingsPage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">System Prompt</label>
|
||||
<label className="block text-sm text-slate-400 mb-1">Prompt</label>
|
||||
<textarea
|
||||
value={settings.journalPrompt || ''}
|
||||
onChange={(e) => setSettings({ ...settings, journalPrompt: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none resize-none"
|
||||
placeholder="Instructions for the AI journal writer..."
|
||||
placeholder="Custom instructions for the diary writer (optional)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -397,6 +589,264 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
||||
<h2 className="text-lg font-medium mb-4">Data Export / Import</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400 mb-3">
|
||||
Export your diary data as a JSON file. This includes all events, diary pages, generation tasks, and settings.
|
||||
Your AI API keys are included so you won't need to reconfigure after import.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exporting...' : 'Export All Data'}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import Data'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importWarning && !showImportConfirm && (
|
||||
<div className="p-3 bg-amber-500/20 border border-amber-500/50 rounded-lg">
|
||||
<p className="text-amber-400 text-sm">{importWarning}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showImportConfirm && pendingImport && (
|
||||
<div className="p-4 bg-slate-800 rounded-lg border border-slate-700">
|
||||
<h3 className="font-medium mb-3">Ready to Import</h3>
|
||||
<div className="text-sm text-slate-400 mb-4 space-y-1">
|
||||
<p>Version: {pendingImport.version}</p>
|
||||
<p>Exported: {new Date(pendingImport.exportedAt).toLocaleString()}</p>
|
||||
<p>Events: {pendingImport.events.length}</p>
|
||||
<p>Journals: {pendingImport.journals.length}</p>
|
||||
<p>Tasks: {pendingImport.tasks.length}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{importing ? 'Importing...' : 'Confirm Import'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowImportConfirm(false); setPendingImport(null); }}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && !showImportConfirm && (
|
||||
<div className={`p-4 rounded-lg border ${importResult.compatible ? 'bg-green-500/10 border-green-500/30' : 'bg-red-500/10 border-red-500/30'}`}>
|
||||
<h3 className={`font-medium mb-2 ${importResult.compatible ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{importResult.compatible ? 'Import Complete' : 'Import Warning'}
|
||||
</h3>
|
||||
{importResult.warning && (
|
||||
<p className="text-amber-400 text-sm mb-2">{importResult.warning}</p>
|
||||
)}
|
||||
<div className="text-sm text-slate-400 space-y-1">
|
||||
<p>Events: {importResult.importedEvents} imported, {importResult.skippedEvents} skipped</p>
|
||||
<p>Journals: {importResult.importedJournals} imported, {importResult.skippedJournals} skipped</p>
|
||||
<p>Tasks: {importResult.importedTasks} imported</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-red-950/30 rounded-xl p-6 border border-red-900/50">
|
||||
<h2 className="text-lg font-medium mb-4 text-red-400">Danger Zone</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400 mb-3">
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="px-4 py-2 bg-red-600/20 hover:bg-red-600/30 border border-red-600/50 text-red-400 rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-red-300">
|
||||
This will permanently delete:
|
||||
</p>
|
||||
<ul className="text-xs text-slate-400 list-disc list-inside space-y-1">
|
||||
<li>All your events and diary pages</li>
|
||||
<li>All generation tasks and AI interactions</li>
|
||||
<li>Your account and settings</li>
|
||||
</ul>
|
||||
<p className="text-sm text-slate-300 pt-2">
|
||||
Type DELETE to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
className="w-full max-w-xs px-4 py-2 bg-slate-800 rounded-lg border border-red-600/50 focus:border-red-500 focus:outline-none text-red-400 font-mono"
|
||||
/>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteConfirmText !== 'DELETE' || deleting}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Permanently Delete Everything'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowDeleteConfirm(false); setDeleteConfirmText(''); }}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-red-900/30 pt-4 mt-4">
|
||||
<h3 className="text-sm font-medium text-slate-300 mb-2">Reset Account</h3>
|
||||
<p className="text-xs text-slate-400 mb-3">
|
||||
Wipe all data and settings but keep your account. You'll have a fresh start.
|
||||
</p>
|
||||
|
||||
{!showResetConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
className="px-4 py-2 bg-amber-600/20 hover:bg-amber-600/30 border border-amber-600/50 text-amber-400 rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
Reset Account
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-amber-300">
|
||||
This will permanently delete:
|
||||
</p>
|
||||
<ul className="text-xs text-slate-400 list-disc list-inside space-y-1">
|
||||
<li>All events and diary pages</li>
|
||||
<li>All generation tasks</li>
|
||||
<li>All settings (AI config, prompts, etc.)</li>
|
||||
</ul>
|
||||
<p className="text-xs text-slate-300 pt-2">
|
||||
Your account (email/password) will be kept.
|
||||
</p>
|
||||
<p className="text-xs text-slate-300 pt-1">
|
||||
Type RESET to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetConfirmText}
|
||||
onChange={(e) => setResetConfirmText(e.target.value)}
|
||||
placeholder="RESET"
|
||||
className="w-full max-w-xs px-4 py-2 bg-slate-800 rounded-lg border border-amber-600/50 focus:border-amber-500 focus:outline-none text-amber-400 font-mono"
|
||||
/>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleResetAccount}
|
||||
disabled={resetConfirmText !== 'RESET' || resetting}
|
||||
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 rounded-lg text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{resetting ? 'Resetting...' : 'Reset Account'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowResetConfirm(false); setResetConfirmText(''); }}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
||||
<h2 className="text-lg font-medium mb-4">Security</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!showPasswordChange ? (
|
||||
<button
|
||||
onClick={() => setShowPasswordChange(true)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3 max-w-md">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Min 6 characters"
|
||||
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{passwordError && (
|
||||
<p className="text-red-400 text-sm">{passwordError}</p>
|
||||
)}
|
||||
{passwordSuccess && (
|
||||
<p className="text-green-400 text-sm">{passwordSuccess}</p>
|
||||
)}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={changingPassword || !currentPassword || newPassword.length < 6}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{changingPassword ? 'Saving...' : 'Save Password'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowPasswordChange(false); setCurrentPassword(''); setNewPassword(''); setPasswordError(''); setPasswordSuccess(''); }}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/EntryInput.tsx","./src/components/EntryList.tsx","./src/lib/ThemeContext.tsx","./src/lib/api.ts","./src/pages/Auth.tsx","./src/pages/Day.tsx","./src/pages/History.tsx","./src/pages/Home.tsx","./src/pages/Journal.tsx","./src/pages/Settings.tsx","./src/pages/Tasks.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/DateNavigator.tsx","./src/components/EntryInput.tsx","./src/components/EntryList.tsx","./src/components/QuickAddWidget.tsx","./src/components/SearchModal.tsx","./src/lib/ThemeContext.tsx","./src/lib/api.ts","./src/lib/geolocation.ts","./src/pages/Auth.tsx","./src/pages/Calendar.tsx","./src/pages/Dashboard.tsx","./src/pages/Day.tsx","./src/pages/Diary.tsx","./src/pages/History.tsx","./src/pages/Home.tsx","./src/pages/Journal.tsx","./src/pages/Settings.tsx","./src/pages/Tasks.tsx"],"version":"5.9.3"}
|
||||
@@ -10,6 +10,11 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /data/media/;
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
2
start.sh
2
start.sh
@@ -3,7 +3,7 @@ set -e
|
||||
|
||||
# Push schema to database on startup
|
||||
echo "Setting up database..."
|
||||
bunx prisma db push --skip-generate
|
||||
bunx prisma db push --skip-generate --accept-data-loss
|
||||
|
||||
echo "Starting server..."
|
||||
nginx -g 'daemon off;' &
|
||||
|
||||
406
todo/ai_style.md
Normal file
406
todo/ai_style.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# AI Diary Style Feature Specification
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a feature allowing users to customize the writing style/voice of AI-generated diary pages. Currently, the journal generation uses a fixed system prompt stored in Settings. This feature would provide a more robust, user-friendly way to define and manage diary writing styles.
|
||||
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
Users can select from predefined style presets or create custom styles that influence how the AI writes their diary pages. Each style defines tone, voice, formatting preferences, and specific writing guidelines that get injected into the generation prompt.
|
||||
|
||||
### Core Capabilities
|
||||
|
||||
1. **Style Presets**: Pre-defined writing styles (formal, casual, poetic, etc.)
|
||||
2. **Custom Styles**: User-defined styles with full control over prompt components
|
||||
3. **Per-Diary Override**: Ability to use a different style for specific dates
|
||||
4. **Style Preview**: Generate a sample diary to preview style before use
|
||||
5. **Multiple Presets**: Create and switch between multiple saved styles
|
||||
|
||||
---
|
||||
|
||||
## Style Preset Definitions
|
||||
|
||||
### 1. Formal
|
||||
|
||||
**Voice**: Professional, polished, articulate
|
||||
**Use Case**: Users who want their diary to read like well-crafted prose
|
||||
|
||||
```
|
||||
System Prompt Component:
|
||||
You write in a formal, articulate style. Use complete sentences and proper grammar.
|
||||
Avoid contractions and colloquialisms. Maintain a reflective, thoughtful tone.
|
||||
Structure your diary with clear paragraphs that flow logically.
|
||||
```
|
||||
|
||||
**Sample Output**:
|
||||
> Today began with considerable promise. The morning meeting concluded successfully, yielding clarity on the project timeline. Subsequent hours were devoted to deep work, punctuated by brief exchanges with colleagues. The afternoon brought an unexpected interruption, though it proved fruitful in unexpected ways. Tomorrow presents new challenges that require careful preparation.
|
||||
|
||||
---
|
||||
|
||||
### 2. Casual
|
||||
|
||||
**Voice**: Relaxed, friendly, conversational
|
||||
**Use Case**: Users who want their diary to feel like chatting with a good friend
|
||||
|
||||
```
|
||||
System Prompt Component:
|
||||
Write in a casual, friendly tone as if talking to a close friend. Use contractions naturally.
|
||||
Keep it relaxed and easygoing. Short sentences are fine. Include natural pauses and "ums."
|
||||
Make it feel like you're debriefing the day with someone who gets you.
|
||||
```
|
||||
|
||||
**Sample Output**:
|
||||
> So here's how today went... The morning was pretty productive, got through most of my todo list before lunch which was nice. Had this weird interaction with a coworker around 2pm that threw me off for a bit, but I think we figured it out. The rest of the afternoon flew by. Anyway, tomorrow's another day!
|
||||
|
||||
---
|
||||
|
||||
### 3. Poetic
|
||||
|
||||
**Voice**: Literary, evocative, lyrical
|
||||
**Use Case**: Users who appreciate beautiful, descriptive language
|
||||
|
||||
```
|
||||
System Prompt Component:
|
||||
Write in a poetic, literary style. Use sensory details, metaphors, and evocative language.
|
||||
Let sentences breathe with varied rhythm. Paint scenes with words. Find beauty in ordinary moments.
|
||||
Use occasional line breaks for emphasis. Let the writing have a lyrical quality.
|
||||
```
|
||||
|
||||
**Sample Output**:
|
||||
> The light crept through the window this morning like a slow, golden tide. Meetings blurred together—a symphony of voices and shifting priorities. There was a moment, somewhere between the third cup of coffee and the afternoon's weight, when the world seemed to hold its breath. Tonight, the stars are sharp and numerous. Tomorrow, another page turns.
|
||||
|
||||
---
|
||||
|
||||
### 4. Minimalist
|
||||
|
||||
**Voice**: Concise, direct, stripped-back
|
||||
**Use Case**: Users who prefer brevity and clarity over elaboration
|
||||
|
||||
```
|
||||
System Prompt Component:
|
||||
Write in a minimalist style. Keep sentences short and direct. Cut unnecessary words.
|
||||
No flourishes or embellishments. Get to the point. Use bullet points if helpful.
|
||||
Focus on facts and essential observations only. Less is more.
|
||||
```
|
||||
|
||||
**Sample Output**:
|
||||
> - Morning: Team sync, focused work block
|
||||
> - Afternoon: Client call, project review
|
||||
> - Evening: Walk, read
|
||||
> Notable: Finally resolved the bug that's been nagging me
|
||||
> Tomorrow: Deadline for presentation
|
||||
|
||||
---
|
||||
|
||||
### 5. Conversational
|
||||
|
||||
**Voice**: Warm, personal, storytelling
|
||||
**Use Case**: Users who want their diary to feel like telling a story
|
||||
|
||||
```
|
||||
System Prompt Component:
|
||||
Write as if telling a story to someone who cares. First person, warm and personal.
|
||||
Use "I" naturally. Describe things the way you'd explain them to a friend.
|
||||
Include small details that bring the day to life. Make it feel present and immediate.
|
||||
```
|
||||
|
||||
**Sample Output**:
|
||||
> So I have to tell you about today—it was one of those days where everything just clicked, you know? First thing this morning I sat down and finally cracked that problem I've been working on for weeks. The feeling was amazing. Then at lunch I ran into this old friend I hadn't seen in forever and we talked for an hour. The rest of the day was chill by comparison. I'm really grateful for days like this.
|
||||
|
||||
---
|
||||
|
||||
### 6. Reflective
|
||||
|
||||
**Voice**: Introspective, thoughtful, philosophical
|
||||
**Use Case**: Users who want to explore the meaning behind their days
|
||||
|
||||
```
|
||||
System Prompt Component:
|
||||
Write with an introspective, reflective lens. Explore what events meant, not just what happened.
|
||||
Ask yourself questions. Examine patterns and connections. Go deeper than surface events.
|
||||
Consider what you learned, what surprised you, what you'll carry forward.
|
||||
```
|
||||
|
||||
**Sample Output**:
|
||||
> What strikes me most about today is how the small moments added up to something meaningful. The conversation with the new team member revealed that sometimes the most valuable insights come from unexpected places. I notice I've been carrying some anxiety about the project, yet today's progress reminded me that I've handled difficult things before. What does it mean to be building something that matters? These questions linger as the day closes.
|
||||
|
||||
---
|
||||
|
||||
## Prompt Engineering for Each Style
|
||||
|
||||
### Structure Comparison
|
||||
|
||||
All styles share this base structure:
|
||||
1. **Style Directive**: How to write (tone, voice)
|
||||
2. **Content Requirements**: What to include
|
||||
3. **Structure Guidance**: How to organize
|
||||
4. **Example Integration**: From user events
|
||||
|
||||
### Style-Specific Additions
|
||||
|
||||
| Style | Sentence Length | Emotion | Formatting | Vocabulary |
|
||||
|-------|-----------------|---------|------------|------------|
|
||||
| Formal | Long, complex | Moderate | Paragraphs | Sophisticated |
|
||||
| Casual | Short-mixed | High | Mixed | Simple |
|
||||
| Poetic | Varied | Very high | Line breaks | Rich |
|
||||
| Minimalist | Very short | Low | Lists/bullets | Plain |
|
||||
| Conversational | Short-medium | High | Paragraphs | Colloquial |
|
||||
| Reflective | Medium-long | High | Open paragraphs | Thoughtful |
|
||||
|
||||
### JSON Response Handling
|
||||
|
||||
The AI returns JSON for title + content. Style affects:
|
||||
- **Title style**: Formal (proper nouns), Casual (lowercase ok), Poetic (evocative)
|
||||
- **Content style**: Applied through system prompt injection
|
||||
|
||||
---
|
||||
|
||||
## UI for Selecting/Creating Styles
|
||||
|
||||
### Settings Page Addition
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ DIARY STYLE │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Current Style: [Casual ▼] [Preview] │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ STYLE PRESETS [+ Create New] │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ ● Formal │ │
|
||||
│ │ ○ Casual │ │
|
||||
│ │ ○ Poetic │ │
|
||||
│ │ ○ Minimalist │ │
|
||||
│ │ ○ Custom: Morning Pages │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
```
|
||||
|
||||
### Style Editor Modal
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ CREATE / EDIT STYLE │
|
||||
│ │
|
||||
│ Name: [ ] │
|
||||
│ │
|
||||
│ Style Directive: │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Write in a... │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Content Requirements: │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ • Summarize key events │ │
|
||||
│ │ • Include emotions and reflections │ │
|
||||
│ │ • End with forward-looking thought │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Example Tone (optional): │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ "Today was a good day. I felt..." │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Cancel] [Save Style] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Per-Diary Style Override
|
||||
|
||||
On the journal generation page (`/journal/:date`):
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ GENERATE DIARY [⚙ Style: Casual]│
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Clicking style button opens dropdown to override for this single generation.
|
||||
|
||||
### Style Preview Feature
|
||||
|
||||
Generate a "test" diary using current events + selected style:
|
||||
- Modal shows generated preview
|
||||
- "Apply this style" button to save as default
|
||||
- Useful for testing custom styles before committing
|
||||
|
||||
---
|
||||
|
||||
## Storage of Custom Styles
|
||||
|
||||
### Database Schema Addition
|
||||
|
||||
```prisma
|
||||
model StylePreset {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
name String
|
||||
isDefault Boolean @default(false)
|
||||
|
||||
// Style components
|
||||
styleDirective String // Core tone/voice instructions
|
||||
contentRequirements String? // What to include
|
||||
structureGuidance String? // How to organize
|
||||
exampleText String? // Sample tone (optional)
|
||||
|
||||
// Metadata
|
||||
isBuiltIn Boolean @default(false) // True for official presets
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Model Update
|
||||
|
||||
```prisma
|
||||
model Settings {
|
||||
// ... existing fields
|
||||
defaultStyleId String? // FK to StylePreset
|
||||
|
||||
defaultStyleId String?
|
||||
defaultStyle StylePreset? @relation(fields: [defaultStyleId], references: [id])
|
||||
}
|
||||
```
|
||||
|
||||
### Journal Model Update (for per-diary override)
|
||||
|
||||
```prisma
|
||||
model Journal {
|
||||
// ... existing fields
|
||||
stylePresetId String? // Override style for this specific journal
|
||||
stylePreset StylePreset? @relation(fields: [stylePresetId], references: [id])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Design
|
||||
|
||||
### New Endpoints
|
||||
|
||||
```
|
||||
GET /api/v1/styles // List user's styles
|
||||
POST /api/v1/styles // Create custom style
|
||||
GET /api/v1/styles/:id // Get specific style
|
||||
PUT /api/v1/styles/:id // Update style
|
||||
DELETE /api/v1/styles/:id // Delete custom style
|
||||
POST /api/v1/styles/:id/preview // Generate preview with this style
|
||||
|
||||
PUT /api/v1/settings/default-style // Set default style
|
||||
```
|
||||
|
||||
### Generation Integration
|
||||
|
||||
In `journal.ts`, modify the generation to:
|
||||
1. Check `journal.stylePresetId` for per-diary override
|
||||
2. Fall back to `settings.defaultStyleId`
|
||||
3. Fall back to legacy `settings.journalPrompt` (for backwards compatibility)
|
||||
4. Build style prompt from StylePreset or use journalPrompt directly
|
||||
|
||||
### Prompt Building Logic
|
||||
|
||||
```typescript
|
||||
function buildStylePrompt(style: StylePreset | null, journalPrompt: string): string {
|
||||
if (!style) return journalPrompt;
|
||||
|
||||
const parts = [
|
||||
style.styleDirective,
|
||||
style.contentRequirements && `\n\nRequirements:\n${style.contentRequirements}`,
|
||||
style.structureGuidance && `\n\nStructure:\n${style.structureGuidance}`,
|
||||
style.exampleText && `\n\nTone example: "${style.exampleText}"`,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join('\n\n') + '\n\n' + journalPrompt;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Complexity
|
||||
|
||||
### Phase 1: Core Infrastructure (Medium)
|
||||
- [ ] Database schema changes (StylePreset model)
|
||||
- [ ] Settings model update
|
||||
- [ ] Journal model update
|
||||
- [ ] New API routes for CRUD operations
|
||||
- [ ] Seed default presets on first run
|
||||
|
||||
### Phase 2: UI Components (Medium)
|
||||
- [ ] Style selector in Settings
|
||||
- [ ] Style editor modal (create/edit)
|
||||
- [ ] Preview modal with generation
|
||||
- [ ] Per-diary override UI
|
||||
|
||||
### Phase 3: Generation Integration (Low)
|
||||
- [ ] Update journal generation to use style
|
||||
- [ ] Handle migration from old journalPrompt
|
||||
- [ ] Add style_id to generation task logging
|
||||
|
||||
### Complexity Breakdown
|
||||
|
||||
| Component | Complexity | Notes |
|
||||
|-----------|------------|-------|
|
||||
| Database Schema | Low | Simple additions, Prisma handles migration |
|
||||
| API Routes | Medium | Standard CRUD, ~100 lines |
|
||||
| Style Editor UI | Medium | Form with preview, ~200 lines |
|
||||
| Preview Feature | Medium | Requires new generation endpoint |
|
||||
| Per-Diary Override | Low | Simple FK field + dropdown |
|
||||
| Migration Path | Low | Handle nulls gracefully |
|
||||
|
||||
### Estimated Time
|
||||
- Backend: 2-3 hours
|
||||
- Frontend: 3-4 hours
|
||||
- Testing: 1 hour
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendation
|
||||
|
||||
### Recommended Priority: **Phase 2 (Medium-High)**
|
||||
|
||||
This feature adds significant user value with moderate implementation effort.
|
||||
|
||||
**Justification**:
|
||||
1. **High Impact**: Users frequently want personalized output
|
||||
2. **Low Risk**: Feature is additive, doesn't break existing flows
|
||||
3. **Builds on Existing**: Leverages already-implemented journal generation
|
||||
4. **Differentiated**: Few journaling apps offer this level of customization
|
||||
|
||||
### Implementation Order
|
||||
|
||||
1. **First**: Add StylePreset model + seed default styles
|
||||
2. **Second**: Update journal generation to use style
|
||||
3. **Third**: Basic style selector in Settings
|
||||
4. **Fourth**: Style editor for custom styles
|
||||
5. **Fifth**: Preview feature (optional, can ship later)
|
||||
|
||||
### Considerations
|
||||
|
||||
- **Migration**: Existing users with custom `journalPrompt` should be converted to a "Custom" style preset on first migration
|
||||
- **Defaults**: Pre-build the 6 style presets so users have immediate options
|
||||
- **Validation**: Ensure style prompts don't exceed model context limits
|
||||
- **Preview**: Consider rate-limiting preview generation to prevent abuse
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Style Transfer**: Copy another user's shared style (community feature)
|
||||
2. **Learning from Edits**: After user edits generated diary, update style
|
||||
3. **Multi-language Styles**: Different writing styles per language
|
||||
4. **Style Analytics**: Show which styles user uses most
|
||||
5. **Export/Import**: Share style configurations
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The AI Style feature transforms DearDiary from a basic AI diarist into a personalized writing assistant. By giving users control over tone, voice, and formatting, we create a more engaging journaling experience that matches individual preferences.
|
||||
|
||||
The implementation is straightforward given the existing architecture, and the feature delivers immediate value without requiring significant refactoring or risk.
|
||||
327
todo/calendar.md
Normal file
327
todo/calendar.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Calendar View Feature Research
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines research and implementation ideas for adding a calendar view to DearDiary, enabling users to visualize their journaling activity across days, weeks, and months at a glance.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Existing Navigation & Data Flow
|
||||
- **Routes**: `/` (Dashboard), `/today`, `/history`, `/diary`, `/day/:date`, `/journal/:date`, `/settings`
|
||||
- **API**: `getDays()` returns `Array<{ date, eventCount, hasJournal, journalTitle?, journalExcerpt? }>`
|
||||
- **History page**: Currently a simple list showing all days chronologically
|
||||
|
||||
### Key Data Points Per Day
|
||||
- `date`: ISO date string (YYYY-MM-DD)
|
||||
- `eventCount`: Number of events captured
|
||||
- `hasJournal`: Whether diary page has been generated
|
||||
- `journalTitle`: Title from AI-generated diary
|
||||
- `journalExcerpt`: Preview text from diary content
|
||||
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
### 1. Monthly Calendar View
|
||||
|
||||
**Primary use case**: Overview of journaling activity at a glance
|
||||
|
||||
- Grid layout (7 columns for weekdays)
|
||||
- Each day cell shows:
|
||||
- Date number
|
||||
- Visual indicator(s) for presence of events/diary
|
||||
- Event count (optional, on hover or expanded)
|
||||
|
||||
**Visual Indicators**
|
||||
| State | Indicator |
|
||||
|-------|-----------|
|
||||
| No events | Empty cell, muted styling |
|
||||
| Events only (draft) | Small blue/gray dot |
|
||||
| Diary generated | Small purple dot |
|
||||
| Both (events + diary) | Two dots or colored dot with indicator |
|
||||
| Today | Highlighted cell (border/background) |
|
||||
| Selected day | Different background/border |
|
||||
|
||||
**Event Density Visualization**
|
||||
- Option to show density heat map (more events = darker shade)
|
||||
- Scale: 1-2 events (light), 3-5 (medium), 6+ (dark)
|
||||
|
||||
### 2. Weekly Calendar View
|
||||
|
||||
**Primary use case**: Detailed look at recent activity
|
||||
|
||||
- Horizontal 7-day strip
|
||||
- Each day shows expanded content:
|
||||
- Event count
|
||||
- Mini event list (first 2-3 events)
|
||||
- Diary status badge
|
||||
|
||||
### 3. Daily Mini Calendar
|
||||
|
||||
- Fixed position in header or sidebar
|
||||
- Shows current month
|
||||
- Click to navigate to specific date
|
||||
- Quick navigation (prev/next month arrows)
|
||||
|
||||
---
|
||||
|
||||
## UI Component Suggestions
|
||||
|
||||
### Recommended Libraries
|
||||
|
||||
| Library | Pros | Cons |
|
||||
|---------|------|------|
|
||||
| **react-big-calendar** | Full-featured, customizable, well-maintained | Requires styling, may be heavy |
|
||||
| **react-calendar** | Lightweight, simple API, good theming | Limited customization |
|
||||
| **react-datepicker** | Easy to integrate, accessible | More input-focused than display |
|
||||
| **fullcalendar-react** | Enterprise-grade, many views | Complex, may be overkill |
|
||||
| **Custom CSS Grid** | Full control, lightweight | More implementation work |
|
||||
|
||||
### Recommendation: Custom CSS Grid + react-calendar hybrid
|
||||
|
||||
For DearDiary's needs, a custom implementation using CSS Grid provides:
|
||||
- Full Tailwind integration (matches existing style)
|
||||
- Lightweight bundle
|
||||
- Complete control over visual indicators
|
||||
- No dependency on heavy calendar libraries
|
||||
|
||||
**Fallback**: If ready-made needed, `react-calendar` is lightweight (~50KB) and sufficient for monthly view.
|
||||
|
||||
### Tailwind CSS Implementation (Custom)
|
||||
|
||||
```tsx
|
||||
// Monthly calendar grid
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map(day => (
|
||||
<button className={cn(
|
||||
"relative p-2 rounded-lg text-center",
|
||||
day.isToday && "ring-2 ring-purple-500",
|
||||
day.hasEvents && "bg-slate-800",
|
||||
day.hasJournal && "bg-purple-900/30"
|
||||
)}>
|
||||
<span>{day.dateNumber}</span>
|
||||
{day.hasEvents && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Existing Pages
|
||||
|
||||
### 1. Standalone Calendar Page (`/calendar`)
|
||||
|
||||
New route added to App.tsx:
|
||||
- Full monthly calendar view
|
||||
- Navigation to previous/next months
|
||||
- Click on day navigates to `/day/:date` or `/journal/:date`
|
||||
- Toggle between month/week views
|
||||
|
||||
### 2. Integration with Dashboard
|
||||
|
||||
Replace or supplement "Recent Diary Pages" with mini calendar:
|
||||
- Show current month
|
||||
- Click date to navigate
|
||||
- Compact version (fewer details per cell)
|
||||
|
||||
### 3. Integration with History Page
|
||||
|
||||
Replace list view with calendar as default:
|
||||
- Toggle between calendar and list views
|
||||
- Calendar shows activity overview
|
||||
- List remains available for detailed browsing
|
||||
|
||||
### 4. Navigation Updates
|
||||
|
||||
Update Navbar to include calendar link:
|
||||
```tsx
|
||||
// App.tsx Navbar
|
||||
<a href="/calendar" className="text-slate-300 hover:text-white transition">Calendar</a>
|
||||
```
|
||||
|
||||
### 5. Quick Date Navigation
|
||||
|
||||
Mini calendar in header (optional):
|
||||
- Always visible month view
|
||||
- Click date = navigate to `/day/:date`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Complexity
|
||||
|
||||
### Phase 1: Basic Monthly Calendar (Priority: High)
|
||||
- [ ] Create `Calendar.tsx` component
|
||||
- [ ] Add `/calendar` route
|
||||
- [ ] Fetch days data for current month
|
||||
- [ ] Render grid with visual indicators
|
||||
- [ ] Click to navigate
|
||||
|
||||
**Complexity**: ~2-3 hours
|
||||
**Files**: `frontend/src/components/Calendar.tsx`, route in App.tsx
|
||||
|
||||
### Phase 2: Dashboard Integration (Priority: Medium)
|
||||
- [ ] Add mini calendar to Dashboard
|
||||
- [ ] Show current month
|
||||
- [ ] Click date navigates to day
|
||||
|
||||
**Complexity**: ~1-2 hours
|
||||
|
||||
### Phase 3: Week View (Priority: Low)
|
||||
- [ ] Add week view toggle
|
||||
- [ ] Horizontal 7-day strip
|
||||
- [ ] Expanded content per day
|
||||
|
||||
**Complexity**: ~2-3 hours
|
||||
|
||||
### Phase 4: Advanced Features (Priority: Low)
|
||||
- [ ] Event density heat map
|
||||
- [ ] Drag to select date range
|
||||
- [ ] Export calendar as image
|
||||
- [ ] iCal integration
|
||||
|
||||
**Complexity**: Varies
|
||||
|
||||
---
|
||||
|
||||
## API Requirements
|
||||
|
||||
### Current API Sufficiency
|
||||
|
||||
`getDays()` returns all days - filtering by month happens on client. For larger datasets:
|
||||
|
||||
**Potential enhancement**:
|
||||
```typescript
|
||||
// Get days for specific month (optional optimization)
|
||||
async getDaysByMonth(year: number, month: number): Promise<DayInfo[]>
|
||||
```
|
||||
|
||||
**Current workaround sufficient**: Fetch all days, filter in React (acceptable for < 365 entries).
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Considerations
|
||||
|
||||
### WCAG 2.1 AA Compliance
|
||||
|
||||
1. **Keyboard Navigation**
|
||||
- Arrow keys to move between days
|
||||
- Enter/Space to select
|
||||
- Escape to close calendar
|
||||
|
||||
2. **Screen Reader Support**
|
||||
- `aria-label` on each day cell: "March 15, 2026 - 3 events, diary generated"
|
||||
- `aria-current` for today
|
||||
- `role="grid"` with proper row/cell structure
|
||||
|
||||
3. **Visual Indicators**
|
||||
- Don't rely solely on color
|
||||
- Use icons/shapes + color
|
||||
- Sufficient contrast ratios (4.5:1 minimum)
|
||||
|
||||
4. **Focus Management**
|
||||
- Focus visible on calendar open
|
||||
- Return focus to trigger element on close
|
||||
|
||||
### Example ARIA Implementation
|
||||
|
||||
```tsx
|
||||
<button
|
||||
aria-label={`${dateString} - ${eventCount} events${hasJournal ? ', diary generated' : ''}`}
|
||||
aria-current={isToday ? 'date' : undefined}
|
||||
className="..."
|
||||
>
|
||||
<span aria-hidden="true">{dayNumber}</span>
|
||||
{hasEvents && <span className="sr-only">Has events</span>}
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile-Friendly Considerations
|
||||
|
||||
### Responsive Design
|
||||
|
||||
1. **Viewport Adaptation**
|
||||
- Desktop: Full monthly grid (35 cells visible)
|
||||
- Tablet: Scrollable or condensed
|
||||
- Mobile: Week view default, swipe between weeks
|
||||
|
||||
2. **Touch Targets**
|
||||
- Minimum 44x44px tap targets
|
||||
- Adequate spacing between dates
|
||||
|
||||
3. **Interaction Patterns**
|
||||
- Swipe left/right for month navigation
|
||||
- Tap to select, long-press for context menu
|
||||
|
||||
### Library Considerations
|
||||
|
||||
If using `react-big-calendar`:
|
||||
```tsx
|
||||
<Calendar
|
||||
views={['month', 'week', 'day']}
|
||||
defaultView="week" // Default to week on mobile
|
||||
popup
|
||||
selectable
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Plan
|
||||
|
||||
### Priority 1: Calendar Page
|
||||
1. Create `frontend/src/components/Calendar.tsx`
|
||||
- Monthly grid using CSS Grid
|
||||
- Visual dots for event/diary status
|
||||
- Click navigates to `/day/:date`
|
||||
2. Add route `/calendar` in App.tsx
|
||||
3. Add nav link in Navbar
|
||||
4. Test with existing data
|
||||
|
||||
### Priority 2: Dashboard Integration
|
||||
1. Add mini calendar above recent entries
|
||||
2. Show current month
|
||||
3. Highlight today, clickable dates
|
||||
|
||||
### Priority 3: History Enhancement
|
||||
1. Add toggle between list/calendar views
|
||||
2. Calendar as default
|
||||
|
||||
### Priority 4: Week View (Optional)
|
||||
1. Add view switcher
|
||||
2. Horizontal week strip with event previews
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Aspect | Recommendation |
|
||||
|--------|----------------|
|
||||
| **Implementation** | Custom CSS Grid (Tailwind) |
|
||||
| **First placement** | New `/calendar` page |
|
||||
| **View types** | Monthly default, week as secondary |
|
||||
| **Indicators** | Dots (blue=events, purple=diary) |
|
||||
| **Navigation** | Click to `/day/:date` or `/journal/:date` |
|
||||
| **Accessibility** | Full ARIA, keyboard nav |
|
||||
| **Mobile** | Default to week view, touch-friendly |
|
||||
|
||||
### Estimated Timeline
|
||||
- Phase 1 (Calendar page): 2-3 hours
|
||||
- Phase 2 (Dashboard): 1-2 hours
|
||||
- Phase 3 (Week view): 2-3 hours
|
||||
- **Total**: ~5-8 hours
|
||||
|
||||
---
|
||||
|
||||
## Questions for Further Research
|
||||
|
||||
1. Should calendar support date range selection for bulk operations?
|
||||
2. Export to iCal/Google Calendar desired?
|
||||
3. Sync with external calendars (Google, Apple)?
|
||||
4. Recurring event support?
|
||||
5. Need to show time-of-day heat map?
|
||||
598
todo/export.md
Normal file
598
todo/export.md
Normal file
@@ -0,0 +1,598 @@
|
||||
# Data Export Feature - DearDiary
|
||||
|
||||
Comprehensive research document for implementing a data export feature.
|
||||
|
||||
---
|
||||
|
||||
## 1. Feature Overview
|
||||
|
||||
Allow users to export their diary data in multiple formats with flexible scope and options. This feature enables users to:
|
||||
- Backup their data locally
|
||||
- Migrate to other journaling platforms
|
||||
- Create offline archives
|
||||
- Share selected entries
|
||||
|
||||
---
|
||||
|
||||
## 2. Export Formats
|
||||
|
||||
### 2.1 Markdown (.md)
|
||||
|
||||
**Description**: Human-readable plain text format with frontmatter metadata.
|
||||
|
||||
**Technical Approach**:
|
||||
- Single file: One `.md` file per day or combined
|
||||
- Use YAML frontmatter for metadata (date, title, word count)
|
||||
- Structure:
|
||||
```markdown
|
||||
---
|
||||
date: 2024-01-15
|
||||
title: A Quiet Morning
|
||||
event_count: 5
|
||||
generated_at: 2024-01-15T20:30:00Z
|
||||
---
|
||||
|
||||
# January 15, 2024
|
||||
|
||||
## Events
|
||||
[08:30] Had coffee and read news
|
||||
[12:00] Team meeting about Q1 goals
|
||||
|
||||
## Diary Page
|
||||
|
||||
The morning started quietly...
|
||||
```
|
||||
|
||||
**Complexity**: Low - straightforward string generation
|
||||
**Priority**: High - most versatile, easy to implement
|
||||
|
||||
---
|
||||
|
||||
### 2.2 JSON (.json)
|
||||
|
||||
**Description**: Machine-readable structured format for programmatic use.
|
||||
|
||||
**Technical Approach**:
|
||||
```json
|
||||
{
|
||||
"exported_at": "2024-01-15T20:30:00Z",
|
||||
"user_id": "user-uuid",
|
||||
"format_version": "1.0",
|
||||
"entries": [
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"journal": {
|
||||
"title": "A Quiet Morning",
|
||||
"content": "The morning started quietly...",
|
||||
"generated_at": "2024-01-15T20:30:00Z"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"id": "event-uuid",
|
||||
"type": "text",
|
||||
"content": "Had coffee and read news",
|
||||
"created_at": "2024-01-15T08:30:00Z",
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Complexity**: Low - native Prisma JSON serialization
|
||||
**Priority**: High - essential for backups/migrations
|
||||
|
||||
---
|
||||
|
||||
### 2.3 PDF (.pdf)
|
||||
|
||||
**Description**: Print-ready formatted document.
|
||||
|
||||
**Technical Approach**:
|
||||
- Use `pdfkit` or `puppeteer` (headless Chrome) for generation
|
||||
- Puppeteer recommended for complex layouts/CSS support
|
||||
- Template options:
|
||||
- Simple: Title + content (minimal styling)
|
||||
- Full: Events listed with diary page formatted
|
||||
- Page breaks handled for multi-day exports
|
||||
|
||||
**Complexity**: Medium - requires additional dependency
|
||||
**Priority**: Medium - high user demand for print/export
|
||||
|
||||
---
|
||||
|
||||
### 2.4 HTML (.html)
|
||||
|
||||
**Description**: Web-viewable static pages.
|
||||
|
||||
**Technical Approach**:
|
||||
- Single HTML file with embedded CSS
|
||||
- Include basic navigation for multi-day exports
|
||||
- Responsive design with print media queries
|
||||
- Structure:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>DearDiary Export</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
.entry { margin-bottom: 2rem; }
|
||||
.meta { color: #666; font-size: 0.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>January 2024</h1>
|
||||
<div class="entry">
|
||||
<h2>January 15, 2024</h2>
|
||||
<div class="meta">5 events</div>
|
||||
<p>Diary content...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Complexity**: Low-Medium - string generation with CSS
|
||||
**Priority**: Medium - good for web publishing
|
||||
|
||||
---
|
||||
|
||||
### 2.5 ePub (.epub)
|
||||
|
||||
**Description**: Ebook format for e-readers.
|
||||
|
||||
**Technical Approach**:
|
||||
- Use `epub-gen` or similar library
|
||||
- Structure: One chapter per day or per month
|
||||
- Include cover image with app branding
|
||||
- Metadata: Title, author, generated date
|
||||
|
||||
**Complexity**: High - requires ebook-specific libraries
|
||||
**Priority**: Low - niche use case, can be deprioritized
|
||||
|
||||
---
|
||||
|
||||
## 3. Export Scope
|
||||
|
||||
### 3.1 Single Diary
|
||||
- Export one day's journal + events
|
||||
- API: `GET /api/v1/export?date=2024-01-15`
|
||||
- Returns single entry with all related data
|
||||
|
||||
### 3.2 Date Range
|
||||
- Export events between start and end dates
|
||||
- API: `GET /api/v1/export?start=2024-01-01&end=2024-01-31`
|
||||
- Batch query: Prisma `where: { date: { gte: start, lte: end } }`
|
||||
|
||||
### 3.3 All Data
|
||||
- Export entire user dataset
|
||||
- Include settings, metadata
|
||||
- Requires pagination for large datasets
|
||||
|
||||
---
|
||||
|
||||
## 4. Include/Exclude Options
|
||||
|
||||
### 4.1 Content Filters
|
||||
| Option | Description | Implementation |
|
||||
|--------|-------------|----------------|
|
||||
| `events_only` | Raw events without AI-generated diaries | Filter journals from response |
|
||||
| `diaries_only` | Only generated diary pages | Filter events from response |
|
||||
| `with_media` | Include media file references | Include `mediaPath` field |
|
||||
| `without_media` | Exclude media references | Omit `mediaPath` field |
|
||||
|
||||
### 4.2 Data Structure Options
|
||||
```typescript
|
||||
interface ExportOptions {
|
||||
format: 'md' | 'json' | 'pdf' | 'html' | 'epub';
|
||||
scope: 'single' | 'range' | 'all';
|
||||
date?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
include: {
|
||||
events: boolean;
|
||||
journals: boolean;
|
||||
media: boolean;
|
||||
settings: boolean;
|
||||
};
|
||||
organization: 'single_file' | 'folder';
|
||||
compress: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. File Organization
|
||||
|
||||
### 5.1 Single File
|
||||
- All content in one file (`.md`, `.json`, `.html`)
|
||||
- Best for: small exports, JSON backups
|
||||
- Simple to implement
|
||||
|
||||
### 5.2 Folder Structure
|
||||
```
|
||||
export-2024-01-15/
|
||||
├── index.html # Main navigation
|
||||
├── 2024-01-15/
|
||||
│ ├── journal.md # Diary page
|
||||
│ ├── events.md # Raw events
|
||||
│ └── media/ # Photos, voice memos
|
||||
├── 2024-01-14/
|
||||
│ └── ...
|
||||
└── manifest.json # Export metadata
|
||||
```
|
||||
|
||||
- Best for: large exports with media
|
||||
- Use ZIP compression for download
|
||||
|
||||
---
|
||||
|
||||
## 6. Compression Options
|
||||
|
||||
### 6.1 ZIP Archive
|
||||
- Default for folder exports > 10MB
|
||||
- Use `Bun.zip()` or `archiver` package
|
||||
- Include manifest with export details
|
||||
|
||||
**Implementation**:
|
||||
```typescript
|
||||
// Example: ZIP export flow
|
||||
async function exportZip(options: ExportOptions) {
|
||||
const tempDir = await createTempDir();
|
||||
await generateFiles(tempDir, options);
|
||||
const zipPath = `${tempDir}.zip`;
|
||||
await zip(tempDir, zipPath);
|
||||
return serveFile(zipPath);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Streaming Large Exports
|
||||
|
||||
### 7.1 Problem
|
||||
- Large exports (years of data) can exceed memory
|
||||
- Need progressive loading and streaming response
|
||||
|
||||
### 7.2 Solution: Server-Sent Events (SSE)
|
||||
|
||||
**API Design**:
|
||||
```
|
||||
POST /api/v1/export
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"format": "json",
|
||||
"startDate": "2020-01-01",
|
||||
"endDate": "2024-01-15"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (chunked):
|
||||
```
|
||||
event: progress
|
||||
data: {"percent": 10, "stage": "loading_events"}
|
||||
|
||||
event: data
|
||||
data: {"date": "2020-01-01", ...}
|
||||
|
||||
event: progress
|
||||
data: {"percent": 20, "stage": "loading_journals"}
|
||||
|
||||
event: data
|
||||
data: {"date": "2020-01-02", ...}
|
||||
|
||||
event: complete
|
||||
data: {"total_entries": 1000, "export_size": "5MB"}
|
||||
```
|
||||
|
||||
### 7.3 Implementation Notes
|
||||
- Use Prisma cursor-based pagination for memory efficiency
|
||||
- Stream directly to response without buffering
|
||||
- Provide progress updates every N records
|
||||
|
||||
---
|
||||
|
||||
## 8. Privacy & Security
|
||||
|
||||
### 8.1 Authentication
|
||||
- Require valid API key for all export endpoints
|
||||
- User can only export their own data
|
||||
|
||||
### 8.2 Sensitive Data Handling
|
||||
- **Option**: Password-protect exports
|
||||
- Use AES-256 encryption for ZIP
|
||||
- Prompt for password in UI
|
||||
- **Option**: redact sensitive entries
|
||||
- Tag certain events as "private"
|
||||
- Exclude from export by default
|
||||
|
||||
### 8.3 Media Files
|
||||
- Generate signed URLs for media export
|
||||
- Set expiration (24h default)
|
||||
- Don't include raw API keys in export
|
||||
|
||||
### 8.4 Audit Logging
|
||||
- Log export requests (who, when, scope)
|
||||
- Store in new `ExportLog` model
|
||||
|
||||
---
|
||||
|
||||
## 9. Database Schema Changes
|
||||
|
||||
### 9.1 New Models
|
||||
|
||||
```prisma
|
||||
model ExportLog {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
format String
|
||||
scope String
|
||||
startDate String?
|
||||
endDate String?
|
||||
recordCount Int
|
||||
sizeBytes Int?
|
||||
status String @default("pending")
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ScheduledExport {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
name String
|
||||
format String
|
||||
scope String @default("all")
|
||||
frequency String @default("weekly")
|
||||
includeJson Json?
|
||||
enabled Boolean @default(true)
|
||||
lastRunAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. API Changes
|
||||
|
||||
### 10.1 New Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/export` | Create export job |
|
||||
| GET | `/api/v1/export/:id` | Get export status |
|
||||
| GET | `/api/v1/export/:id/download` | Download export file |
|
||||
| GET | `/api/v1/exports` | List export history |
|
||||
| DELETE | `/api/v1/export/:id` | Delete export |
|
||||
| GET | `/api/v1/scheduled-exports` | List scheduled exports |
|
||||
| POST | `/api/v1/scheduled-exports` | Create schedule |
|
||||
| PUT | `/api/v1/scheduled-exports/:id` | Update schedule |
|
||||
| DELETE | `/api/v1/scheduled-exports/:id` | Delete schedule |
|
||||
|
||||
### 10.2 Request/Response Examples
|
||||
|
||||
**Create Export**:
|
||||
```typescript
|
||||
// POST /api/v1/export
|
||||
interface CreateExportRequest {
|
||||
format: 'md' | 'json' | 'pdf' | 'html' | 'epub';
|
||||
date?: string; // single day
|
||||
startDate?: string; // range start
|
||||
endDate?: string; // range end
|
||||
include: {
|
||||
events: boolean;
|
||||
journals: boolean;
|
||||
media: boolean;
|
||||
settings: boolean;
|
||||
};
|
||||
organization: 'single_file' | 'folder';
|
||||
compress: boolean;
|
||||
password?: string; // optional ZIP password
|
||||
}
|
||||
|
||||
interface ExportResponse {
|
||||
id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
downloadUrl?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. UI/UX Considerations
|
||||
|
||||
### 11.1 Export Page Location
|
||||
- Add to Settings page as "Export Data" section
|
||||
- Or create dedicated `/export` route
|
||||
|
||||
### 11.2 Export Modal
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Export Your Data │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Format: [Markdown ▼] │
|
||||
│ ○ Markdown │
|
||||
│ ○ JSON │
|
||||
│ ○ PDF │
|
||||
│ ○ HTML │
|
||||
│ ○ ePub │
|
||||
│ │
|
||||
│ Scope: ○ This month │
|
||||
│ ○ This year │
|
||||
│ ○ All time │
|
||||
│ ○ Custom range [____] │
|
||||
│ │
|
||||
│ Include: ☑ Generated diaries │
|
||||
│ ☑ Raw events │
|
||||
│ ☐ Media files │
|
||||
│ ☐ Settings │
|
||||
│ │
|
||||
│ Options: ○ Single file │
|
||||
│ ○ Folder (with ZIP) │
|
||||
│ │
|
||||
│ ☐ Password protect │
|
||||
│ [________] │
|
||||
│ │
|
||||
│ [Cancel] [Export] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 11.3 Progress View
|
||||
- Show progress bar during export
|
||||
- Estimated time remaining
|
||||
- Cancel button for large exports
|
||||
- Email notification option (future)
|
||||
|
||||
### 11.4 Export History
|
||||
- List of past exports with:
|
||||
- Date, format, scope
|
||||
- Size, record count
|
||||
- Download link (with expiration)
|
||||
- Delete button
|
||||
|
||||
---
|
||||
|
||||
## 12. Scheduled Exports
|
||||
|
||||
### 12.1 Configuration Options
|
||||
| Frequency | Description |
|
||||
|-----------|-------------|
|
||||
| `daily` | Every day at configured time |
|
||||
| `weekly` | Every Sunday |
|
||||
| `monthly` | First day of month |
|
||||
| `quarterly` | Every 3 months |
|
||||
|
||||
### 12.2 Implementation
|
||||
- Use cron-style scheduling
|
||||
- Run as background job (Bun.setInterval or dedicated worker)
|
||||
- Store exports in cloud storage (S3-compatible) or local
|
||||
- Send notification when ready
|
||||
|
||||
### 12.3 Use Cases
|
||||
- Automated weekly backups
|
||||
- Monthly archive generation
|
||||
- Quarterly review compilation
|
||||
|
||||
---
|
||||
|
||||
## 13. Implementation Roadmap
|
||||
|
||||
### Phase 1: Core Export (Week 1-2)
|
||||
- [ ] Add `ExportLog` model to schema
|
||||
- [ ] Implement JSON export endpoint
|
||||
- [ ] Implement Markdown export endpoint
|
||||
- [ ] Add single date/range query support
|
||||
- [ ] Basic export UI in Settings
|
||||
|
||||
**Complexity**: 3/5
|
||||
**Priority**: High
|
||||
|
||||
### Phase 2: Advanced Formats (Week 3)
|
||||
- [ ] HTML export
|
||||
- [ ] PDF export (using puppeteer)
|
||||
- [ ] ePub export (optional)
|
||||
|
||||
**Complexity**: 4/5
|
||||
**Priority**: Medium
|
||||
|
||||
### Phase 3: Large Exports (Week 4)
|
||||
- [ ] Streaming with SSE
|
||||
- [ ] ZIP compression
|
||||
- [ ] Progress reporting
|
||||
|
||||
**Complexity**: 5/5
|
||||
**Priority**: Medium
|
||||
|
||||
### Phase 4: Automation (Week 5)
|
||||
- [ ] Scheduled exports model
|
||||
- [ ] Background job scheduler
|
||||
- [ ] Scheduled exports UI
|
||||
|
||||
**Complexity**: 4/5
|
||||
**Priority**: Low
|
||||
|
||||
### Phase 5: Security & Polish (Week 6)
|
||||
- [ ] Password-protected ZIPs
|
||||
- [ ] Export audit logging
|
||||
- [ ] Media file handling
|
||||
- [ ] Edge cases and testing
|
||||
|
||||
**Complexity**: 3/5
|
||||
**Priority**: Medium
|
||||
|
||||
---
|
||||
|
||||
## 14. Dependencies Required
|
||||
|
||||
| Package | Purpose | Version |
|
||||
|---------|---------|---------|
|
||||
| `pdfkit` | PDF generation | ^0.14.0 |
|
||||
| `puppeteer` | HTML to PDF | ^21.0.0 |
|
||||
| `archiver` | ZIP creation | ^6.0.0 |
|
||||
| `epub-gen` | ePub creation | ^0.1.0 |
|
||||
| `jszip` | Client-side ZIP | ^3.10.0 |
|
||||
|
||||
---
|
||||
|
||||
## 15. Testing Considerations
|
||||
|
||||
### 15.1 Unit Tests
|
||||
- Export formatters (MD, JSON, HTML)
|
||||
- Date range filtering
|
||||
- Include/exclude logic
|
||||
|
||||
### 15.2 Integration Tests
|
||||
- Full export workflow
|
||||
- Large dataset performance
|
||||
- Streaming response handling
|
||||
|
||||
### 15.3 Edge Cases
|
||||
- Empty date range
|
||||
- Missing media files
|
||||
- Export during active generation
|
||||
- Concurrent export requests
|
||||
|
||||
---
|
||||
|
||||
## 16. Priority Recommendation
|
||||
|
||||
| Feature | Priority | Rationale |
|
||||
|---------|----------|-----------|
|
||||
| JSON/Markdown export | P0 | Core requirement for backups |
|
||||
| Single/range export | P0 | Essential scope control |
|
||||
| Export UI | P0 | User-facing feature |
|
||||
| PDF export | P1 | High user demand |
|
||||
| HTML export | P1 | Good alternative to PDF |
|
||||
| Streaming exports | P2 | Performance for large data |
|
||||
| ZIP compression | P2 | Usability for folder exports |
|
||||
| ePub export | P3 | Niche, can skip |
|
||||
| Scheduled exports | P3 | Automation, lower urgency |
|
||||
| Password protection | P4 | Advanced, security theater |
|
||||
|
||||
---
|
||||
|
||||
## 17. Open Questions
|
||||
|
||||
1. **Storage**: Should exports be stored temporarily or generated on-demand?
|
||||
2. **Retention**: How long to keep export downloads available?
|
||||
3. **Media handling**: Include actual files or just references?
|
||||
4. **Third-party sync**: Export to Google Drive, Dropbox?
|
||||
5. **Incremental exports**: Only export new data since last export?
|
||||
|
||||
---
|
||||
|
||||
## 18. Summary
|
||||
|
||||
This feature set provides comprehensive data export capabilities while maintaining security and user privacy. Starting with JSON/Markdown exports covers 80% of use cases (backups, migration). PDF and HTML add print/web options. Streaming and compression enable handling of large datasets. Scheduled exports provide automation for power users.
|
||||
|
||||
Recommend implementing Phase 1 first to establish core functionality, then iterate based on user feedback.
|
||||
537
todo/gallery.md
Normal file
537
todo/gallery.md
Normal file
@@ -0,0 +1,537 @@
|
||||
# Media Gallery Feature for DearDiary
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the comprehensive feature specification for adding media gallery capabilities to DearDiary - an AI-powered daily journaling app. The gallery will allow users to view, manage, and organize all their photos and voice recordings in one centralized location.
|
||||
|
||||
## Feature Description
|
||||
|
||||
### Core Functionality
|
||||
|
||||
1. **Media Grid View**
|
||||
- Responsive masonry or grid layout displaying all media items
|
||||
- Support for both photos and voice recordings
|
||||
- Visual distinction between media types (photo thumbnails vs audio waveform icons)
|
||||
- Lazy loading for performance with large collections
|
||||
- Infinite scroll pagination
|
||||
|
||||
2. **Audio Player**
|
||||
- Inline audio player for voice recordings
|
||||
- Play/pause controls
|
||||
- Progress bar with seek functionality
|
||||
- Duration display
|
||||
- Waveform visualization (optional enhancement)
|
||||
|
||||
3. **Filtering & Organization**
|
||||
- Filter by media type (photos, voice recordings, all)
|
||||
- Filter by date range (today, this week, this month, custom range)
|
||||
- Search by date
|
||||
- Sort by date (newest/oldest)
|
||||
|
||||
4. **Thumbnail Generation**
|
||||
- Automatic thumbnail generation for photos
|
||||
- Multiple sizes: small (150px), medium (300px), large (600px)
|
||||
- Server-side processing using sharp or similar library
|
||||
- Cache thumbnails for performance
|
||||
|
||||
5. **Lightbox View**
|
||||
- Full-screen photo viewer
|
||||
- Navigation between photos (previous/next)
|
||||
- Zoom functionality
|
||||
- Photo metadata display (EXIF data)
|
||||
- Close on escape key or click outside
|
||||
|
||||
6. **Storage Management**
|
||||
- Display total storage used
|
||||
- Show individual file sizes
|
||||
- Bulk delete functionality
|
||||
- Delete confirmation dialogs
|
||||
- Storage quota warnings
|
||||
|
||||
7. **Media Metadata**
|
||||
- EXIF data extraction for photos (camera, date taken, location, etc.)
|
||||
- Audio duration and format info
|
||||
- File size and dimensions
|
||||
- Creation date
|
||||
|
||||
8. **Privacy Considerations**
|
||||
- All media stored locally or on user's own object storage
|
||||
- No third-party media processing
|
||||
- Optional encryption at rest
|
||||
- Clear deletion removes all related data
|
||||
|
||||
---
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
### Option 1: Local File Storage (Recommended for Self-Hosted)
|
||||
|
||||
```
|
||||
/data/media/
|
||||
/{userId}/
|
||||
/{date}/
|
||||
/photos/
|
||||
{eventId}.jpg # Original
|
||||
{eventId}_thumb.jpg # Thumbnail
|
||||
{eventId}_medium.jpg # Medium size
|
||||
/voice/
|
||||
{eventId}.webm # Voice recording
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Simple to implement
|
||||
- No additional costs
|
||||
- Full control over data
|
||||
- Easy backup
|
||||
|
||||
**Cons:**
|
||||
- Requires server-side file management
|
||||
- No CDN for fast delivery
|
||||
- Storage limited to server capacity
|
||||
|
||||
**Implementation:**
|
||||
- Use `sharp` library for image processing
|
||||
- Generate thumbnails on upload or on-demand
|
||||
- Serve via static file middleware
|
||||
|
||||
### Option 2: Object Storage (S3-compatible)
|
||||
|
||||
**Pros:**
|
||||
- Scalable storage
|
||||
- Built-in CDN integration
|
||||
- High availability
|
||||
- Offload bandwidth
|
||||
|
||||
**Cons:**
|
||||
- Additional complexity and cost
|
||||
- Requires S3-compatible service
|
||||
- More complex authentication
|
||||
|
||||
**Recommendation:** Start with local storage for v1, design for S3 compatibility in v2.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
### New Media Model
|
||||
|
||||
```prisma
|
||||
model Media {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
eventId String?
|
||||
type String // "photo" | "voice"
|
||||
fileName String
|
||||
originalName String
|
||||
mimeType String
|
||||
fileSize Int // bytes
|
||||
duration Int? // seconds (for audio)
|
||||
width Int? // pixels
|
||||
height Int? // pixels
|
||||
thumbnailPath String?
|
||||
mediumPath String?
|
||||
exifData String? // JSON string
|
||||
storageType String @default("local")
|
||||
storageKey String? // S3 key if using object storage
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, type])
|
||||
@@index([userId, createdAt])
|
||||
@@index([eventId])
|
||||
}
|
||||
```
|
||||
|
||||
### Updated Event Model
|
||||
|
||||
```prisma
|
||||
model Event {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
date String
|
||||
type String // "event" | "text" | "photo" | "voice" | "health"
|
||||
content String
|
||||
metadata String?
|
||||
mediaId String? // Link to Media
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, date])
|
||||
@@index([date])
|
||||
@@index([type])
|
||||
}
|
||||
```
|
||||
|
||||
### Storage Statistics (Optional)
|
||||
|
||||
```prisma
|
||||
model StorageStats {
|
||||
userId String @id
|
||||
totalBytes Int @default(0)
|
||||
photoBytes Int @default(0)
|
||||
voiceBytes Int @default(0)
|
||||
photoCount Int @default(0)
|
||||
voiceCount Int @default(0)
|
||||
lastUpdated DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Needed
|
||||
|
||||
### Media Management
|
||||
|
||||
```
|
||||
// Upload media (photo or voice)
|
||||
POST /api/v1/media/upload
|
||||
Body: multipart/form-data
|
||||
- file: File
|
||||
- type: "photo" | "voice"
|
||||
- date: string (YYYY-MM-DD)
|
||||
- eventId?: string (optional link to existing event)
|
||||
- metadata?: object (optional additional metadata)
|
||||
|
||||
// Get media item by ID
|
||||
GET /api/v1/media/:id
|
||||
|
||||
// Delete media item
|
||||
DELETE /api/v1/media/:id
|
||||
|
||||
// Bulk delete media
|
||||
POST /api/v1/media/bulk-delete
|
||||
Body: { ids: string[] }
|
||||
|
||||
// Get media thumbnail
|
||||
GET /api/v1/media/:id/thumbnail/:size
|
||||
- size: "small" | "medium" | "large"
|
||||
|
||||
// Stream media file
|
||||
GET /api/v1/media/:id/stream
|
||||
|
||||
// Get EXIF data
|
||||
GET /api/v1/media/:id/exif
|
||||
|
||||
// Get storage usage stats
|
||||
GET /api/v1/media/stats
|
||||
|
||||
// List all media with filters
|
||||
GET /api/v1/media
|
||||
Query params:
|
||||
- type?: "photo" | "voice" | "all"
|
||||
- startDate?: string (YYYY-MM-DD)
|
||||
- endDate?: string (YYYY-MM-DD)
|
||||
- page?: number
|
||||
- limit?: number
|
||||
- sort?: "newest" | "oldest"
|
||||
```
|
||||
|
||||
### Integration with Existing Events
|
||||
|
||||
```
|
||||
// Create event with media
|
||||
POST /api/v1/events
|
||||
Body: {
|
||||
date, type, content, metadata,
|
||||
media: { type, file }
|
||||
}
|
||||
|
||||
// Add media to existing event
|
||||
POST /api/v1/events/:id/media
|
||||
Body: multipart/form-data with file
|
||||
|
||||
// Get event with media
|
||||
GET /api/v1/events/:id
|
||||
Response includes linked media
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Design Patterns
|
||||
|
||||
### Gallery Page Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ DearDiary [Settings] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [Dashboard] [Today] [History] [Diary] [Gallery] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 📷 Photos 🎤 Voice | Jan 2026 ▼ │ │
|
||||
│ │ Total: 2.3 GB | 156 photos | 42 voice │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │ │ │ │ │ │ │ │ │ │ │
|
||||
│ │ 📷 │ │ 📷 │ │ 🎤 │ │ 📷 │ │ 📷 │ │
|
||||
│ │ │ │ │ │ │ │ │ │ │ │
|
||||
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │ │ │ │ │ │ │ │ │ │ │
|
||||
│ │ 📷 │ │ 🎤 │ │ 📷 │ │ 📷 │ │ 🎤 │ │
|
||||
│ │ │ │ │ │ │ │ │ │ │ │
|
||||
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||||
│ │
|
||||
│ [Load More...] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Filter Bar Components
|
||||
|
||||
- **Type Toggle**: Segmented button control (All | Photos | Voice)
|
||||
- **Date Picker**: Month/Year dropdown with custom range option
|
||||
- **Search**: Date input or quick filters (Today, This Week, This Month, Year)
|
||||
|
||||
### Photo Card Component
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ │
|
||||
│ [Thumbnail] │
|
||||
│ │
|
||||
├─────────────────┤
|
||||
│ Jan 15, 2026 │
|
||||
│ 2.4 MB • 4032×3024 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Voice Card Component
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ 🎤 Voice Recording │
|
||||
│ ━━━━━━━━━●━━━━━━━━━━━━━ │
|
||||
│ 0:45 / 2:30 │
|
||||
│ Jan 15, 2026 • 1.2 MB │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Lightbox Component
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ← │ 📷 Photo Title │ ⋮ │ → │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [Full Size Image] │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
├─────┴──────────────────────────┴────────────────┤
|
||||
│ 📅 Jan 15, 2026 📷 4032×3024 📏 2.4 MB │
|
||||
│ 📷 iPhone 15 Pro 📍 San Francisco │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Storage Management Panel (Settings or Modal)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Storage Usage │
|
||||
│ │
|
||||
│ ████████████████████░░░░░░░░░ 2.3/10GB│
|
||||
│ │
|
||||
│ 📷 Photos: 1.8 GB (156 files) │
|
||||
│ 🎤 Voice: 500 MB (42 files) │
|
||||
│ │
|
||||
│ [Delete Old Media] │
|
||||
│ [Download All] │
|
||||
│ [Clear All Media] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Design Tokens (Tailwind)
|
||||
|
||||
- **Background**: `bg-slate-900`
|
||||
- **Cards**: `bg-slate-800`
|
||||
- **Borders**: `border-slate-700`
|
||||
- **Primary**: `text-purple-400`
|
||||
- **Muted**: `text-slate-400`
|
||||
- **Hover**: `hover:bg-slate-700`
|
||||
- **Active**: `bg-purple-600`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Complexity
|
||||
|
||||
### Phase 1: Core Gallery (Estimated: 8-12 hours)
|
||||
|
||||
- [ ] Database schema updates (Media model)
|
||||
- [ ] File upload endpoint with storage
|
||||
- [ ] Media listing API with filtering
|
||||
- [ ] Basic grid UI with thumbnails
|
||||
- [ ] Type filtering UI
|
||||
|
||||
### Phase 2: Media Viewing (Estimated: 6-8 hours)
|
||||
|
||||
- [ ] Thumbnail generation service
|
||||
- [ ] Lightbox component
|
||||
- [ ] Audio player component
|
||||
- [ ] Photo navigation (prev/next)
|
||||
- [ ] EXIF extraction service
|
||||
|
||||
### Phase 3: Storage Management (Estimated: 4-6 hours)
|
||||
|
||||
- [ ] Storage stats calculation
|
||||
- [ ] Delete single media
|
||||
- [ ] Bulk delete functionality
|
||||
- [ ] Storage visualization UI
|
||||
- [ ] Delete confirmation dialogs
|
||||
|
||||
### Phase 4: Advanced Features (Estimated: 8-10 hours)
|
||||
|
||||
- [ ] Date range filtering
|
||||
- [ ] Infinite scroll pagination
|
||||
- [ ] Search by date
|
||||
- [ ] Object storage support (S3)
|
||||
- [ ] Media encryption at rest
|
||||
|
||||
### Total Estimated Time: 26-36 hours
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendation
|
||||
|
||||
### High Priority (Phase 1 + Phase 2)
|
||||
- **Target**: MVP Gallery functionality
|
||||
- **Rationale**: Core value proposition - users need to see their media
|
||||
- **Features**:
|
||||
- Grid view of media
|
||||
- Basic type filtering
|
||||
- Photo lightbox
|
||||
- Audio playback
|
||||
- Thumbnail generation
|
||||
|
||||
### Medium Priority (Phase 3)
|
||||
- **Target**: Storage management
|
||||
- **Rationale**: Important for self-hosted users with limited storage
|
||||
- **Features**:
|
||||
- Storage usage display
|
||||
- Delete functionality
|
||||
- Bulk operations
|
||||
|
||||
### Low Priority (Phase 4)
|
||||
- **Target**: Advanced features
|
||||
- **Rationale**: Nice-to-have enhancements
|
||||
- **Features**:
|
||||
- Object storage
|
||||
- Date range filters
|
||||
- Advanced search
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Backward Compatibility
|
||||
- Existing `Event.mediaPath` field can be migrated to new `Media` model
|
||||
- Create migration script to:
|
||||
1. Create Media records from existing mediaPath values
|
||||
2. Generate thumbnails for photos
|
||||
3. Update Event records to reference Media
|
||||
4. Deprecate (not remove) mediaPath field
|
||||
|
||||
### Data Migration Script
|
||||
```typescript
|
||||
// Pseudocode for migration
|
||||
async function migrateMedia() {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { mediaPath: { not: null } }
|
||||
});
|
||||
|
||||
for (const event of events) {
|
||||
// Create Media record
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
userId: event.userId,
|
||||
eventId: event.id,
|
||||
type: event.type === 'voice' ? 'voice' : 'photo',
|
||||
filePath: event.mediaPath,
|
||||
// ... extract metadata
|
||||
}
|
||||
});
|
||||
|
||||
// Update event to link to media
|
||||
await prisma.event.update({
|
||||
where: { id: event.id },
|
||||
data: { mediaId: media.id, mediaPath: null }
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
1. **Unit Tests**
|
||||
- Thumbnail generation
|
||||
- EXIF parsing
|
||||
- Storage size calculation
|
||||
- Date filtering logic
|
||||
|
||||
2. **Integration Tests**
|
||||
- Upload flow
|
||||
- Media listing with filters
|
||||
- Delete operations
|
||||
- Event-media linking
|
||||
|
||||
3. **UI Tests**
|
||||
- Grid responsive layout
|
||||
- Lightbox navigation
|
||||
- Audio player controls
|
||||
- Filter interactions
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Thumbnail Caching**
|
||||
- Generate on upload, store in cache
|
||||
- Serve static thumbnails directly
|
||||
|
||||
2. **Pagination**
|
||||
- Limit initial load to 20-50 items
|
||||
- Infinite scroll for more
|
||||
|
||||
3. **Lazy Loading**
|
||||
- Use intersection observer for images
|
||||
- Load thumbnails first, full-res on demand
|
||||
|
||||
4. **Database Indexing**
|
||||
- Index on userId + type for filtering
|
||||
- Index on userId + createdAt for sorting
|
||||
- Index on eventId for event-media lookups
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **File Validation**
|
||||
- Verify MIME types server-side
|
||||
- Limit file sizes (max 50MB for photos, 20MB for audio)
|
||||
- Sanitize filenames
|
||||
|
||||
2. **Access Control**
|
||||
- Users can only access their own media
|
||||
- API key authentication required for all media endpoints
|
||||
|
||||
3. **Storage Security**
|
||||
- Store outside web root
|
||||
- Optional encryption for sensitive data
|
||||
- Secure file deletion (overwrite before unlink)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Sharp](https://sharp.pixelplumbing.com/) - Image processing library
|
||||
- [EXIF Parser](https://github.com/mifi/exiftool-vendored) - EXIF data extraction
|
||||
- [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API) - Audio playback
|
||||
- [React Player](https://github.com/cookpete/react-player) - Audio/video player React component
|
||||
331
todo/mobile.md
Normal file
331
todo/mobile.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# DearDiary Mobile & PWA Support Research
|
||||
|
||||
Current state: DearDiary already has a basic `manifest.json` (standalone display, icons) but no service worker or offline support.
|
||||
|
||||
---
|
||||
|
||||
## 1. PWA Foundation (Service Worker + Manifest)
|
||||
|
||||
### Feature: Full PWA Implementation with Offline Support
|
||||
|
||||
**Description**: Enable DearDiary to be installable on mobile devices and work offline with full caching strategy.
|
||||
|
||||
**Technical Requirements**:
|
||||
- Add `vite-plugin-pwa` to vite.config.ts
|
||||
- Configure workbox strategies (cache-first for assets, network-first for API)
|
||||
- Service worker with precaching for app shell
|
||||
- Enhanced manifest with `categories`, `orientation`, `launch_handler`
|
||||
|
||||
**Implementation Steps**:
|
||||
1. Install `vite-plugin-pwa` and `workbox-window`
|
||||
2. Update vite.config.ts with PWA plugin configuration
|
||||
3. Extend manifest.json with categories, screenshots, maskable icons
|
||||
4. Create custom service worker for API caching strategies
|
||||
|
||||
**Complexity**: Medium (3-5 hours)
|
||||
**Priority**: HIGH - Foundation for all mobile features
|
||||
|
||||
**PWA Plugin Config Example**:
|
||||
```typescript
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['icon-192.png', 'icon-512.png'],
|
||||
manifest: {
|
||||
categories: ['productivity', 'lifestyle'],
|
||||
orientation: 'portrait',
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,png,svg}'],
|
||||
runtimeCaching: [{
|
||||
urlPattern: /^https:\/\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 86400 }
|
||||
}
|
||||
}]
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Offline-First Architecture
|
||||
|
||||
### Feature: IndexedDB Local Storage with Background Sync
|
||||
|
||||
**Description**: Store events locally when offline, sync to server when connection restored.
|
||||
|
||||
**Technical Requirements**:
|
||||
- IndexedDB via `idb` library or `dexie.js`
|
||||
- Sync queue for pending events
|
||||
- Background Sync API (`sync` event) for automatic retry
|
||||
- Conflict resolution for server/client changes
|
||||
|
||||
**Implementation Steps**:
|
||||
1. Create IndexedDB schema: `events`, `journals`, `syncQueue`
|
||||
2. Build `OfflineStorage` service with add/get/sync methods
|
||||
3. Modify QuickAddWidget to use offline storage first
|
||||
4. Implement BackgroundSync handler in service worker
|
||||
5. Add sync status indicator in UI
|
||||
|
||||
**Complexity**: High (6-8 hours)
|
||||
**Priority**: HIGH - Core offline functionality
|
||||
|
||||
**Dexie Schema Example**:
|
||||
```typescript
|
||||
import Dexie from 'dexie';
|
||||
|
||||
class DearDiaryDB extends Dexie {
|
||||
events!: Table<Event>;
|
||||
journals!: Table<Journal>;
|
||||
syncQueue!: Table<SyncItem>;
|
||||
|
||||
constructor() {
|
||||
super('DearDiaryDB');
|
||||
this.version(1).stores({
|
||||
events: '++id, date, type, createdAt, synced',
|
||||
journals: 'date, syncedAt',
|
||||
syncQueue: '++id, type, data, createdAt'
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Sync Flow**:
|
||||
1. User creates event → save to IndexedDB → add to syncQueue → show "pending sync" badge
|
||||
2. Service worker registers 'sync' event → fetch all queued items → POST to API
|
||||
3. On success → delete from queue → mark event as synced
|
||||
4. On failure → retry on next sync or when online
|
||||
|
||||
---
|
||||
|
||||
## 3. Install Prompt & App-Like Experience
|
||||
|
||||
### Feature: Custom Install Prompt with Native Feel
|
||||
|
||||
**Description**: Detect when PWA is installable, show custom prompt, improve app icon/splash.
|
||||
|
||||
**Technical Requirements**:
|
||||
- `beforeinstallprompt` event listener
|
||||
- Custom install button in UI (not browser prompt)
|
||||
- Splash screen configuration via `theme_color` + `background_color`
|
||||
- Maskable adaptive icons for Android
|
||||
|
||||
**Implementation Steps**:
|
||||
1. Add install prompt handler in App.tsx
|
||||
2. Create custom "Add to Home Screen" UI component
|
||||
3. Generate adaptive icon with SVG maskable version
|
||||
4. Add `<meta name="apple-mobile-web-app-capable">` for iOS
|
||||
5. Add iOS-specific icons (apple-touch-icon)
|
||||
|
||||
**Complexity**: Low (2-3 hours)
|
||||
**Priority**: MEDIUM
|
||||
|
||||
**iOS Support**:
|
||||
```html
|
||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mobile-Responsive UI Improvements
|
||||
|
||||
### Feature: Mobile-Optimized Layout & Touch Targets
|
||||
|
||||
**Description**: Improve mobile usability with larger touch targets, bottom navigation, responsive grid.
|
||||
|
||||
**Technical Requirements**:
|
||||
- Minimum 44px touch targets
|
||||
- Bottom tab bar for mobile (Dashboard, Today, History, Settings)
|
||||
- Responsive padding and font sizes
|
||||
- Swipe gestures for navigation
|
||||
|
||||
**Implementation Steps**:
|
||||
1. Create responsive layout component with mobile nav
|
||||
2. Adjust QuickAddWidget for bottom-sheet on mobile
|
||||
3. Add touch-action CSS for swipe handling
|
||||
4. Media queries for mobile-specific styling
|
||||
5. Test on actual device or responsive mode
|
||||
|
||||
**Complexity**: Medium (4-5 hours)
|
||||
**Priority**: HIGH - User-facing
|
||||
|
||||
**Mobile Navigation Example**:
|
||||
```tsx
|
||||
// BottomNav.tsx
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-slate-900 border-t border-slate-800 flex justify-around py-3">
|
||||
<NavLink to="/" icon={Home} />
|
||||
<NavLink to="/today" icon={PlusCircle} />
|
||||
<NavLink to="/history" icon={Clock} />
|
||||
<NavLink to="/settings" icon={Cog} />
|
||||
</nav>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Push Notifications
|
||||
|
||||
### Feature: Event Reminder Notifications
|
||||
|
||||
**Description**: Send push notifications to remind users to log events throughout the day.
|
||||
|
||||
**Technical Requirements**:
|
||||
- Web Push API (VAPID keys)
|
||||
- Service worker push event handler
|
||||
- Notification permission request flow
|
||||
- Configurable reminder schedule in settings
|
||||
|
||||
**Implementation Steps**:
|
||||
1. Generate VAPID key pair
|
||||
2. Add Web Push subscription endpoint to backend
|
||||
3. Create notification service in frontend
|
||||
4. Add "Enable Reminders" toggle in Settings
|
||||
5. Schedule notifications via service worker
|
||||
|
||||
**Complexity**: High (5-7 hours)
|
||||
**Priority**: MEDIUM
|
||||
|
||||
**Note**: Web Push requires VAPID keys and HTTPS. For iOS Safari, use APNs integration (requires native wrapper or PWABuilder).
|
||||
|
||||
---
|
||||
|
||||
## 6. Touch-Friendly Interactions
|
||||
|
||||
### Feature: Gesture-Based Navigation & Input
|
||||
|
||||
**Description**: Add swipe gestures, long-press actions, haptic feedback for mobile.
|
||||
|
||||
**Technical Requirements**:
|
||||
- Touch event handlers for swipe detection
|
||||
- Haptic feedback API (`navigator.vibrate`)
|
||||
- Pull-to-refresh on event lists
|
||||
- Swipe-to-delete on events
|
||||
|
||||
**Implementation Steps**:
|
||||
1. Add gesture detection hook (useSwipe)
|
||||
2. Implement pull-to-refresh on Today page
|
||||
3. Add haptic feedback on button presses
|
||||
4. Implement swipe actions on event list items
|
||||
|
||||
**Complexity**: Medium (3-4 hours)
|
||||
**Priority**: MEDIUM
|
||||
|
||||
**Implementation Pattern**:
|
||||
```typescript
|
||||
function useSwipe(onSwipeLeft: () => void, onSwipeRight: () => void) {
|
||||
const touchStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
const deltaX = e.changedTouches[0].clientX - touchStart.current.x;
|
||||
if (Math.abs(deltaX) > 50) {
|
||||
deltaX > 0 ? onSwipeRight() : onSwipeLeft();
|
||||
}
|
||||
};
|
||||
|
||||
return { onTouchStart, onTouchEnd };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Offline Event Capture (Sync Later)
|
||||
|
||||
### Feature: Queue Events When Offline, Sync When Online
|
||||
|
||||
**Description**: Full implementation of offline-first event creation.
|
||||
|
||||
**Technical Requirements**:
|
||||
- IndexedDB event storage
|
||||
- Visual queue indicator (X events pending)
|
||||
- Force sync button
|
||||
- Automatic sync on reconnection
|
||||
|
||||
**Implementation Steps**:
|
||||
1. Build offline event queue system (see section 2)
|
||||
2. Add sync status banner to Today page
|
||||
3. Add manual "Sync Now" button
|
||||
4. Show toast on successful sync
|
||||
5. Handle partial sync failures gracefully
|
||||
|
||||
**Complexity**: High (already covered in section 2)
|
||||
**Priority**: HIGH
|
||||
|
||||
---
|
||||
|
||||
## 8. Background Sync for API Calls
|
||||
|
||||
### Feature: Automatic Background Synchronization
|
||||
|
||||
**Description**: Use Background Sync API to automatically sync pending operations when online.
|
||||
|
||||
**Technical Requirements**:
|
||||
- Service worker `sync` event handler
|
||||
- Tag pending requests with sync ID
|
||||
- Retry with exponential backoff
|
||||
- Notify user of sync completion
|
||||
|
||||
**Implementation Steps**:
|
||||
1. Register background sync in service worker
|
||||
2. Trigger sync when events are added offline
|
||||
3. Implement retry logic with backoff
|
||||
4. Send desktop notification on sync complete
|
||||
|
||||
**Complexity**: Medium (3-4 hours)
|
||||
**Priority**: MEDIUM
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Foundation (HIGH)
|
||||
- [ ] PWA manifest & service worker setup
|
||||
- [ ] Basic offline caching for app shell
|
||||
- [ ] IndexedDB local storage
|
||||
|
||||
### Phase 2: Offline Sync (HIGH)
|
||||
- [ ] Offline event capture
|
||||
- [ ] Background sync
|
||||
- [ ] Sync status UI
|
||||
|
||||
### Phase 3: Mobile UX (MEDIUM)
|
||||
- [ ] Responsive layout with bottom nav
|
||||
- [ ] Touch-friendly interactions
|
||||
- [ ] Install prompt
|
||||
|
||||
### Phase 4: Advanced (LOW)
|
||||
- [ ] Push notifications
|
||||
- [ ] Advanced gestures
|
||||
- [ ] iOS PWA optimization
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Feature | Complexity | Priority |
|
||||
|---------|------------|----------|
|
||||
| PWA Setup + Service Worker | Medium | HIGH |
|
||||
| IndexedDB + Offline Storage | High | HIGH |
|
||||
| Mobile UI + Bottom Nav | Medium | HIGH |
|
||||
| Install Prompt | Low | MEDIUM |
|
||||
| Background Sync | Medium | MEDIUM |
|
||||
| Push Notifications | High | MEDIUM |
|
||||
| Touch Gestures | Medium | MEDIUM |
|
||||
|
||||
**Recommended First Steps**:
|
||||
1. Add `vite-plugin-pwa` and configure basic caching
|
||||
2. Implement IndexedDB with Dexie for event storage
|
||||
3. Build offline-first QuickAddWidget
|
||||
4. Add sync queue UI indicator
|
||||
316
todo/search.md
Normal file
316
todo/search.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Full-Text Search Feature Research
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines research and implementation ideas for adding full-text search to DearDiary, an AI-powered daily journaling app.
|
||||
|
||||
---
|
||||
|
||||
## 1. Feature Description
|
||||
|
||||
### Core Functionality
|
||||
- Search across diary content (journal titles and bodies)
|
||||
- Search across raw event content
|
||||
- Filter by date range
|
||||
- Sort by relevance (BM25) or date
|
||||
- Real-time instant search as user types
|
||||
|
||||
### User Stories
|
||||
1. **Quick Recall**: User types "meeting with Sarah" → sees matching diary entries and events
|
||||
2. **Date-based Search**: User searches "vacation" → filters to summer 2024 entries
|
||||
3. **Deep Search**: User searches for specific phrase → finds exact match in event content
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Approach
|
||||
|
||||
### Option A: SQLite FTS5 (Recommended for v1)
|
||||
|
||||
**Pros:**
|
||||
- Zero external dependencies
|
||||
- Built into SQLite (already in use)
|
||||
- BM25 ranking built-in
|
||||
- Real-time indexing (update on insert)
|
||||
- Lowest implementation complexity
|
||||
- No additional infrastructure
|
||||
|
||||
**Cons:**
|
||||
- No typo tolerance (unless using trigram/token helpers)
|
||||
- Limited to SQLite (migration cost if switching DB)
|
||||
- Single-node only (fine for self-hosted)
|
||||
|
||||
**Implementation:**
|
||||
```sql
|
||||
-- FTS5 virtual table for journals
|
||||
CREATE VIRTUAL TABLE journal_fts USING fts5(
|
||||
title,
|
||||
content,
|
||||
content_rowid='rowid',
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
|
||||
-- FTS5 virtual table for events
|
||||
CREATE VIRTUAL TABLE event_fts USING fts5(
|
||||
content,
|
||||
type,
|
||||
content_rowid='rowid',
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
|
||||
-- Triggers to keep FTS in sync
|
||||
CREATE TRIGGER journal_ai AFTER INSERT ON Journal BEGIN
|
||||
INSERT INTO journal_fts(rowid, title, content)
|
||||
VALUES (NEW.rowid, NEW.title, NEW.content);
|
||||
END;
|
||||
```
|
||||
|
||||
**Performance:** FTS5 handles 100k+ rows easily on SQLite. For typical personal journaling (10 years = ~3650 entries, ~10k events), performance will be sub-100ms.
|
||||
|
||||
### Option B: External Search (Typesense/Meilisearch)
|
||||
|
||||
**Pros:**
|
||||
- Typo tolerance (fuzzy search)
|
||||
- Better ranking algorithms
|
||||
- Scalable to millions of records
|
||||
- REST API, language-agnostic
|
||||
|
||||
**Cons:**
|
||||
- Additional infrastructure (Docker service)
|
||||
- Sync complexity (real-time indexing)
|
||||
- More complex setup for self-hosted users
|
||||
- Resource overhead (CPU/RAM for search service)
|
||||
|
||||
**Recommendation:** Defer to v2. External search only becomes necessary when:
|
||||
- User wants fuzzy/typo-tolerant search
|
||||
- Dataset exceeds 500k+ records
|
||||
- Multi-language support needed
|
||||
|
||||
---
|
||||
|
||||
## 3. Indexing Strategy
|
||||
|
||||
### Fields to Index
|
||||
|
||||
| Table | Field | Indexed | Reason |
|
||||
|-------|-------|---------|--------|
|
||||
| Journal | title | Yes | Primary search target |
|
||||
| Journal | content | Yes | Full diary text |
|
||||
| Journal | date | Yes | Filtering |
|
||||
| Event | content | Yes | Raw event text |
|
||||
| Event | type | Yes | Filter by event type |
|
||||
| Event | date | Yes | Date filtering |
|
||||
|
||||
### What NOT to Index
|
||||
- `Event.metadata` - JSON blob, search within JSON handled separately if needed
|
||||
- `Event.mediaPath` - File paths, not searchable content
|
||||
- `User` fields - Not needed for user-facing search
|
||||
|
||||
### Sync Strategy
|
||||
1. **On Insert/Update**: Write to main table, then update FTS via trigger
|
||||
2. **On Delete**: FTS trigger removes from index
|
||||
3. **Reindex**: Manual endpoint for recovery/debugging
|
||||
|
||||
---
|
||||
|
||||
## 4. Database Schema Changes
|
||||
|
||||
### Prisma Schema Addition
|
||||
|
||||
```prisma
|
||||
// Optional: Search history for "recent searches" feature
|
||||
model SearchHistory {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
query String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
```
|
||||
|
||||
Note: FTS5 tables are virtual and managed via raw SQL, not Prisma models. We'll use `prisma.$executeRaw` for FTS operations.
|
||||
|
||||
### Migration Steps
|
||||
1. Create FTS5 virtual tables (raw SQL)
|
||||
2. Create triggers for auto-sync
|
||||
3. Backfill existing data
|
||||
4. Add SearchHistory model (optional)
|
||||
|
||||
---
|
||||
|
||||
## 5. API Changes
|
||||
|
||||
### New Endpoints
|
||||
|
||||
```
|
||||
GET /api/v1/search?q=<query>&type=diary|event|all&from=2024-01-01&to=2024-12-31&sort=relevance|date&page=1&limit=20
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
interface SearchResult {
|
||||
type: 'diary' | 'event';
|
||||
id: string;
|
||||
date: string;
|
||||
title?: string; // For diaries
|
||||
content: string; // Truncated/preview
|
||||
highlight?: string; // Matched text with <mark> tags
|
||||
score: number; // BM25 relevance
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
data: {
|
||||
results: SearchResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
} | null;
|
||||
error: null;
|
||||
}
|
||||
```
|
||||
|
||||
### Optional Endpoints
|
||||
```
|
||||
GET /api/v1/search/history // Recent searches
|
||||
DELETE /api/v1/search/history // Clear history
|
||||
POST /api/v1/search/reindex // Force reindex (admin)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UI/UX Considerations
|
||||
|
||||
### Search Modal
|
||||
- **Trigger**: Cmd/Ctrl+K keyboard shortcut (standard pattern)
|
||||
- **Position**: Centered modal with overlay
|
||||
- **Features**:
|
||||
- Instant search as you type (debounced 150ms)
|
||||
- Filter tabs: All | Diaries | Events
|
||||
- Date range picker (quick presets: Today, This Week, This Month, This Year)
|
||||
- Results show date, type, preview with highlighted matches
|
||||
|
||||
### Sidebar (Alternative)
|
||||
- Persistent search box in navigation
|
||||
- Results in scrollable list below
|
||||
- Less intrusive, always visible
|
||||
|
||||
### Result Cards
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 📅 2024-03-15 [Diary] │
|
||||
│ Meeting with Sarah about project... │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ ...discussed timeline and <mark>budget</mark>... │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### UX Details
|
||||
- **Empty state**: Show recent diaries/events when no query
|
||||
- **No results**: Friendly message + suggestions
|
||||
- **Loading**: Subtle spinner (search should be <100ms)
|
||||
- **Keyboard**: Arrow keys to navigate results, Enter to open
|
||||
|
||||
### Mobile Considerations
|
||||
- Tap search icon in header → full-screen search
|
||||
- Larger touch targets for filters
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance Considerations
|
||||
|
||||
### Query Performance
|
||||
- FTS5 BM25 queries: ~50-100ms for 10k records
|
||||
- Add LIMIT to prevent unbounded results
|
||||
- Use connection pooling if many concurrent searches
|
||||
|
||||
### Write Performance
|
||||
- Triggers add ~5-10ms per insert/update
|
||||
- Batch backfill for existing data (1000 rows/batch)
|
||||
|
||||
### Caching Strategy
|
||||
- Cache recent searches (Redis optional, or in-memory)
|
||||
- Cache FTS index in memory (SQLite mmap)
|
||||
|
||||
### Scaling Thresholds
|
||||
- < 10k entries: No optimization needed
|
||||
- 10k-100k: Consider FTS5 optimization (tokenizer, prefix search)
|
||||
- > 100k: Consider external search
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Complexity
|
||||
|
||||
### Complexity Assessment: **MEDIUM**
|
||||
|
||||
| Component | Complexity | Notes |
|
||||
|-----------|------------|-------|
|
||||
| FTS5 setup | Low | Raw SQL, one-time |
|
||||
| Triggers | Low | Auto-sync, minimal code |
|
||||
| API endpoint | Low | Standard CRUD pattern |
|
||||
| Frontend modal | Medium | Keyboard shortcuts, state |
|
||||
| Filters/Date | Medium | Multiple filter combinations |
|
||||
| Backfill | Low | One-time script |
|
||||
|
||||
### Phased Implementation
|
||||
|
||||
**Phase 1 (MVP - 2-3 days)**
|
||||
- FTS5 tables + triggers
|
||||
- Basic search API
|
||||
- Simple modal UI with text input
|
||||
|
||||
**Phase 2 (Enhancements - 1-2 days)**
|
||||
- Date filtering
|
||||
- Type filtering (diary/event)
|
||||
- Result highlighting
|
||||
|
||||
**Phase 3 (Polish - 1 day)**
|
||||
- Search history
|
||||
- Keyboard navigation
|
||||
- Mobile responsive
|
||||
|
||||
---
|
||||
|
||||
## 9. Priority Recommendation
|
||||
|
||||
### Recommended Priority: **MEDIUM-HIGH**
|
||||
|
||||
**Rationale:**
|
||||
- Search is a core journaling feature (user wants to find past entries)
|
||||
- Competitor apps (Day One, Journey) have robust search
|
||||
- Implementation complexity is manageable (medium)
|
||||
- Zero external dependencies (SQLite FTS5)
|
||||
|
||||
### Factors Supporting High Priority
|
||||
1. **User Value**: High - helps users find meaningful memories
|
||||
2. **Implementation Cost**: Medium - achievable in 1 week
|
||||
3. **Dependency Risk**: Low - no external services needed
|
||||
4. **Future-proofing**: FTS5 is mature, well-supported
|
||||
|
||||
### Factors Against Very High Priority
|
||||
- Current core features (capture, generate) are stable
|
||||
- Small dataset users may not notice missing search
|
||||
- Can be added post-MVP without breaking changes
|
||||
|
||||
---
|
||||
|
||||
## 10. Open Questions / Further Research
|
||||
|
||||
1. **Typo tolerance**: Is exact match sufficient, or do users expect fuzzy search?
|
||||
2. **Search ranking**: Should recent results be boosted higher?
|
||||
3. **Multi-language**: Support languages other than English (tokenizer considerations)
|
||||
4. **Export/Import**: Should search index be rebuilt on data import?
|
||||
5. **Shared access**: Multi-user search (future consideration)
|
||||
|
||||
---
|
||||
|
||||
## 11. Summary
|
||||
|
||||
| Aspect | Recommendation |
|
||||
|--------|----------------|
|
||||
| **Search Engine** | SQLite FTS5 (built-in) |
|
||||
| **UI Pattern** | Cmd/Ctrl+K modal |
|
||||
| **Features** | Instant search, date filter, type filter, relevance sort |
|
||||
| **Complexity** | Medium (3-5 days) |
|
||||
| **Priority** | Medium-High |
|
||||
| **Schema Changes** | FTS5 via raw SQL + optional SearchHistory model |
|
||||
| **API Changes** | New `/search` endpoint with query params |
|
||||
539
todo/stats.md
Normal file
539
todo/stats.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# Statistics Feature Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Add a comprehensive statistics dashboard to track journaling habits and provide insights. All analytics are computed locally from user data - no external services required.
|
||||
|
||||
---
|
||||
|
||||
## 1. Streak Tracking
|
||||
|
||||
### Feature Description
|
||||
Track consecutive days of journaling activity to motivate users to maintain their habit.
|
||||
|
||||
### Metrics to Track
|
||||
- **Current Streak**: Consecutive days with at least one event
|
||||
- **Longest Streak**: All-time record of consecutive days
|
||||
- **Streak Start Date**: When current streak began
|
||||
- **Days Until Milestone**: Days until 7/14/30/60/90 day milestones
|
||||
- **Streak Risk**: Days with no events that would break streak
|
||||
|
||||
### Visualization Approaches
|
||||
- Flame icon with day count (🔥 12 days)
|
||||
- Progress bar to next milestone
|
||||
- Calendar mini-view showing streak days
|
||||
- Warning indicator when streak at risk
|
||||
|
||||
### Dashboard Layout
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🔥 12 Day Streak 🏆 45 days │
|
||||
│ ████████░░░░░░░░ 4 days to 16 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
```typescript
|
||||
// Current streak calculation
|
||||
const events = await prisma.event.findMany({
|
||||
where: { userId },
|
||||
select: { date: true },
|
||||
orderBy: { date: 'desc' }
|
||||
});
|
||||
|
||||
// Group by date, find consecutive days
|
||||
```
|
||||
|
||||
### Implementation Complexity
|
||||
- **Backend**: Medium - requires date range queries with grouping
|
||||
- **Frontend**: Low - simple counter display
|
||||
- **Priority**: HIGH - strong motivation driver
|
||||
|
||||
---
|
||||
|
||||
## 2. Word Count Statistics
|
||||
|
||||
### Feature Description
|
||||
Track total words written in events and journal entries over time.
|
||||
|
||||
### Metrics to Track
|
||||
- **Total Words**: All-time word count
|
||||
- **Daily Average**: Average words per day (active days)
|
||||
- **Today/This Week/This Month**: Rolling word counts
|
||||
- **Longest Entry**: Day with most words
|
||||
- **Word Count Distribution**: Histogram by day
|
||||
- **Writing Sessions**: Days with 100+/500+/1000+ words
|
||||
|
||||
### Visualization Approaches
|
||||
- Line chart showing word count over time (30/90/365 days)
|
||||
- Comparison bar chart: this week vs last week
|
||||
- Mini sparkline showing recent trend
|
||||
- Percentile badges (e.g., "Top 10% writers")
|
||||
|
||||
### Dashboard Layout
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📝 12,450 words total │
|
||||
│ Avg: 156/day • This week: 1,234 │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ ▂▃▅▇▅▃▂▄▅▇█▄▃▂▅▇ │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
```typescript
|
||||
// Word count aggregation
|
||||
await prisma.$queryRaw`
|
||||
SELECT date, SUM(LENGTH(content) - LENGTH(REPLACE(content, ' ', '')) + 1) as word_count
|
||||
FROM Event
|
||||
WHERE userId = ${userId}
|
||||
GROUP BY date
|
||||
`;
|
||||
```
|
||||
|
||||
### Implementation Complexity
|
||||
- **Backend**: Low - simple aggregation queries
|
||||
- **Frontend**: Low - chart library needed
|
||||
- **Priority**: MEDIUM
|
||||
|
||||
---
|
||||
|
||||
## 3. Entry Frequency Heatmap
|
||||
|
||||
### Feature Description
|
||||
GitHub-style contribution heatmap showing activity intensity by day.
|
||||
|
||||
### Metrics to Track
|
||||
- Days with events (boolean per day)
|
||||
- Event count per day (intensity levels)
|
||||
- Active weeks/months count
|
||||
- Most productive day of week
|
||||
- Most productive month
|
||||
|
||||
### Visualization Approaches
|
||||
- GitHub-style grid (52 weeks × 7 days)
|
||||
- Color scale: empty → light → dark (slate-800 to purple-600)
|
||||
- Tooltip on hover showing date + count
|
||||
- Day-of-week labels on left
|
||||
- Month labels on top
|
||||
|
||||
### Dashboard Layout
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Activity Heatmap │
|
||||
│ Jan Feb Mar Apr May Jun │
|
||||
│ Mon ░░░████▓▓░░░░░░░ │
|
||||
│ Tue ░▓▓████▓▓░░░░░░░ │
|
||||
│ Wed ░░░████▓▓░░░░░░░ │
|
||||
│ Thu ░░░░░░▓▓░░░░░░░░ │
|
||||
│ Fri ░░░░░░░░░░░░░░░░░ │
|
||||
│ │
|
||||
│ Less ░▒▓█ More │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
```typescript
|
||||
// Heatmap data
|
||||
await prisma.event.groupBy({
|
||||
by: ['date'],
|
||||
where: { userId },
|
||||
_count: { id }
|
||||
});
|
||||
```
|
||||
|
||||
### Implementation Complexity
|
||||
- **Backend**: Low - groupBy query
|
||||
- **Frontend**: Medium - requires heatmap component (use react-heatmap-grid or custom SVG)
|
||||
- **Priority**: HIGH - visual engagement
|
||||
|
||||
---
|
||||
|
||||
## 4. Event Type Distribution
|
||||
|
||||
### Feature Description
|
||||
Breakdown of events by type (text, photo, voice, health, etc.).
|
||||
|
||||
### Metrics to Track
|
||||
- Count per event type
|
||||
- Percentage distribution
|
||||
- Type trends over time (increasing/decreasing)
|
||||
- Media attachments count
|
||||
|
||||
### Visualization Approaches
|
||||
- Donut/pie chart with legend
|
||||
- Horizontal bar chart (easier to read)
|
||||
- Stacked area chart over time
|
||||
- Type badges with counts
|
||||
|
||||
### Dashboard Layout
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Event Types │
|
||||
│ ┌──────────┐ │
|
||||
│ │ text │ ████████ 45% │
|
||||
│ │ photo │ █████░░░ 28% │
|
||||
│ │ voice │ ███░░░░░ 15% │
|
||||
│ │ health │ █░░░░░░░ 8% │
|
||||
│ │ event │ ██░░░░░░ 4% │
|
||||
│ └──────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
```typescript
|
||||
// Type distribution
|
||||
await prisma.event.groupBy({
|
||||
by: ['type'],
|
||||
where: { userId },
|
||||
_count: { id }
|
||||
});
|
||||
```
|
||||
|
||||
### Implementation Complexity
|
||||
- **Backend**: Low - simple groupBy
|
||||
- **Frontend**: Low - chart library
|
||||
- **Priority**: MEDIUM - nice-to-have insight
|
||||
|
||||
---
|
||||
|
||||
## 5. Time-of-Day Patterns
|
||||
|
||||
### Feature Description
|
||||
Analyze when users typically log events (morning person vs night owl).
|
||||
|
||||
### Metrics to Track
|
||||
- Events by hour of day (0-23)
|
||||
- Events by time block (Morning 5-11, Afternoon 12-17, Evening 18-22, Night 23-4)
|
||||
- Most active hour
|
||||
- Average time of first event
|
||||
- Average time of last event
|
||||
|
||||
### Visualization Approaches
|
||||
- 24-hour radial/bar chart
|
||||
- Time block pie chart
|
||||
- Timeline showing first-last event range
|
||||
- "Your prime time" indicator
|
||||
|
||||
### Dashboard Layout
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Writing Time Patterns │
|
||||
│ ▔▔▔▔▔▔▔ │
|
||||
│ ▔▔ ▔▔▔ │
|
||||
│ ▔▔ ● ▔▔ │
|
||||
│ ▔ ▔▔ ▔ │
|
||||
│ Morning Afternoon Evening │
|
||||
│ (6-12) (12-6) (6-12) │
|
||||
│ │
|
||||
│ ☀️ Peak: 9:00 AM │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
```typescript
|
||||
// Hour extraction from createdAt
|
||||
await prisma.$queryRaw`
|
||||
SELECT strftime('%H', createdAt) as hour, COUNT(*) as count
|
||||
FROM Event
|
||||
WHERE userId = ${userId}
|
||||
GROUP BY hour
|
||||
`;
|
||||
```
|
||||
|
||||
### Implementation Complexity
|
||||
- **Backend**: Low - SQL date functions
|
||||
- **Frontend**: Medium - radial chart
|
||||
- **Priority**: LOW - interesting but not core
|
||||
|
||||
---
|
||||
|
||||
## 6. Monthly/Yearly Summaries
|
||||
|
||||
### Feature Description
|
||||
Aggregate statistics for specific time periods with comparison to previous periods.
|
||||
|
||||
### Metrics to Track
|
||||
- Monthly: events, journals, words, active days
|
||||
- Yearly: same metrics
|
||||
- Month-over-month change (%)
|
||||
- Year-over-year change (%)
|
||||
- Best month ever
|
||||
- Days with 100% journal generation
|
||||
|
||||
### Visualization Approaches
|
||||
- Monthly bar chart comparison
|
||||
- Year-in-review cards
|
||||
- Progress ring showing yearly goals
|
||||
- "This time last year" comparison
|
||||
|
||||
### Dashboard Layout
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2024 Year in Review │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ 342 │ │ 45 │ │ 15,600 │ │
|
||||
│ │Events │ │ Journals│ │ Words │ │
|
||||
│ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ Best month: November (52 events) │
|
||||
│ +12% vs 2023 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
```typescript
|
||||
// Monthly aggregation
|
||||
await prisma.event.groupBy({
|
||||
by: ['date'],
|
||||
where: {
|
||||
userId,
|
||||
date: { gte: '2024-01-01', lte: '2024-12-31' }
|
||||
},
|
||||
_count: { id }
|
||||
});
|
||||
```
|
||||
|
||||
### Implementation Complexity
|
||||
- **Backend**: Low - grouped queries with date filters
|
||||
- **Frontend**: Low-Medium - cards and charts
|
||||
- **Priority**: MEDIUM
|
||||
|
||||
---
|
||||
|
||||
## 7. Progress & Motivation Features
|
||||
|
||||
### Feature Description
|
||||
Gamification elements to encourage consistent journaling.
|
||||
|
||||
### Metrics & Features
|
||||
- **Achievement Badges**:
|
||||
- "First Entry" (1 event)
|
||||
- "Week Warrior" (7 day streak)
|
||||
- "Month Master" (30 day streak)
|
||||
- "Century" (100 events)
|
||||
- "Word Smith" (10,000 words)
|
||||
- "Photojournalist" (50 photos)
|
||||
- "Consistent" (journal every day for a month)
|
||||
- **Goals**: Set daily/weekly targets
|
||||
- **Streak Protection**: 1 "freeze" per month to preserve streak
|
||||
- **Weekly Report**: Auto-generated summary every Sunday
|
||||
- **Milestone Celebrations**: Confetti at 7/14/30/60/90 days
|
||||
|
||||
### Visualization Approaches
|
||||
- Badge grid (earned + locked)
|
||||
- Progress rings for goals
|
||||
- Streak flame animation
|
||||
- Weekly summary card
|
||||
|
||||
### Dashboard Layout
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Achievements │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │🔥7 │ │🌟30│ │📝1K│ │📸10│ │
|
||||
│ └────┘ └────┘ └────┘ └────┘ │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │🔒 │ │🔒 │ │🔒 │ (locked) │
|
||||
│ └────┘ └────┘ └────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────┐
|
||||
│ Daily Goal: 5 events │
|
||||
│ ████████░░░░░░░░░ 3/5 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
```typescript
|
||||
// Achievement checks - multiple queries
|
||||
const eventCount = await prisma.event.count({ where: { userId } });
|
||||
const streak = await calculateStreak(userId);
|
||||
```
|
||||
|
||||
### Implementation Complexity
|
||||
- **Backend**: Medium - multiple queries, achievement logic
|
||||
- **Frontend**: Medium - badge components, progress rings
|
||||
- **Priority**: MEDIUM - strong engagement driver
|
||||
|
||||
---
|
||||
|
||||
## 8. Privacy-Preserving Analytics
|
||||
|
||||
### Feature Description
|
||||
Ensure all statistics computation keeps data on-device.
|
||||
|
||||
### Approach
|
||||
- **All aggregations in SQLite**: Use Prisma raw queries on server
|
||||
- **No external analytics services**: No GA, Mixpanel, etc.
|
||||
- **Opt-in sharing**: Optional anonymous stats (streak length, event count) for community features
|
||||
- **Local-first**: Dashboard computes from local API responses
|
||||
- **Data export**: Users can export all their data
|
||||
|
||||
### Implementation
|
||||
```typescript
|
||||
// All stats computed server-side via API
|
||||
// Frontend receives aggregated numbers, not raw data
|
||||
GET /api/v1/stats/streak
|
||||
GET /api/v1/stats/words
|
||||
GET /api/v1/stats/heatmap
|
||||
```
|
||||
|
||||
### Privacy Considerations
|
||||
- No tracking cookies
|
||||
- No cross-user analytics
|
||||
- API key = user identity (no additional tracking)
|
||||
- Full data deletion on user request
|
||||
|
||||
---
|
||||
|
||||
## API Changes
|
||||
|
||||
### New Endpoints
|
||||
|
||||
```typescript
|
||||
// Statistics aggregation
|
||||
GET /api/v1/stats/streak → { currentStreak, longestStreak, streakStart }
|
||||
GET /api/v1/stats/words → { total, dailyAvg, thisWeek, thisMonth, trend }
|
||||
GET /api/v1/stats/heatmap → [{ date, count }]
|
||||
GET /api/v1/stats/types → [{ type, count, percentage }]
|
||||
GET /api/v1/stats/time-of-day → [{ hour, count }]
|
||||
GET /api/v1/stats/summary → { monthly: [], yearly: [] }
|
||||
GET /api/v1/stats/achievements → { earned: [], available: [], progress: {} }
|
||||
GET /api/v1/stats/month/:year → { events, journals, words, activeDays }
|
||||
```
|
||||
|
||||
### Response Types
|
||||
```typescript
|
||||
interface StreakStats {
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
streakStartDate: string;
|
||||
daysToMilestone: number;
|
||||
}
|
||||
|
||||
interface WordStats {
|
||||
total: number;
|
||||
dailyAverage: number;
|
||||
thisWeek: number;
|
||||
thisMonth: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
longestEntry: { date: string; count: number };
|
||||
}
|
||||
|
||||
interface HeatmapData {
|
||||
date: string;
|
||||
count: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
earned: boolean;
|
||||
earnedAt?: string;
|
||||
progress?: number;
|
||||
target: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Additions
|
||||
|
||||
### Optional: Cached Stats Table
|
||||
For expensive queries, cache results:
|
||||
|
||||
```prisma
|
||||
model UserStats {
|
||||
userId String @id
|
||||
currentStreak Int @default(0)
|
||||
longestStreak Int @default(0)
|
||||
totalWords Int @default(0)
|
||||
totalEvents Int @default(0)
|
||||
lastUpdated DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
**Update Strategy**: Recalculate on event create/delete or daily cron.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### New Page: /stats
|
||||
```tsx
|
||||
// /frontend/src/pages/Stats.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
export default function Stats() {
|
||||
const [streak, setStreak] = useState<StreakStats | null>(null);
|
||||
const [words, setWords] = useState<WordStats | null>(null);
|
||||
const [heatmap, setHeatmap] = useState<HeatmapData[]>([]);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Chart Library Recommendation
|
||||
- **Recharts**: Good for line/bar charts (used with React commonly)
|
||||
- **react-heatmap-grid**: For activity heatmap
|
||||
- **react-circular-progressbar**: For goal progress rings
|
||||
- Build custom SVG for simple visualizations
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Core Stats (Week 1)
|
||||
1. API endpoints for streak, word count, heatmap
|
||||
2. Basic stats page with counters
|
||||
3. Heatmap visualization
|
||||
|
||||
### Phase 2: Visualizations (Week 2)
|
||||
1. Word count trend chart
|
||||
2. Event type distribution chart
|
||||
3. Time-of-day analysis
|
||||
|
||||
### Phase 3: Gamification (Week 3)
|
||||
1. Achievement system
|
||||
2. Daily goals
|
||||
3. Streak UI improvements
|
||||
|
||||
### Phase 4: Summaries (Week 4)
|
||||
1. Monthly/yearly summaries
|
||||
2. "This time last year" comparison
|
||||
3. Weekly report
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
| Feature | Priority | Impact | Effort |
|
||||
|---------|----------|--------|--------|
|
||||
| Streak Tracking | HIGH | High | Low |
|
||||
| Heatmap | HIGH | High | Medium |
|
||||
| Word Count | MEDIUM | Medium | Low |
|
||||
| Event Types | MEDIUM | Low | Low |
|
||||
| Time Patterns | LOW | Low | Medium |
|
||||
| Monthly/Yearly | MEDIUM | Medium | Low |
|
||||
| Achievements | MEDIUM | High | Medium |
|
||||
| Goals | MEDIUM | Medium | Low |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Statistics feature transforms DearDiary from a passive journal into an active self-improvement tool. Key priorities:
|
||||
|
||||
1. **Streak tracking** - strongest motivation driver
|
||||
2. **Heatmap** - visual engagement, GitHub-style
|
||||
3. **Achievements** - gamification layer
|
||||
4. **Word count** - progress visualization
|
||||
|
||||
All data stays local - no privacy concerns. Implementation can be phased with clear value at each step.
|
||||
317
todo/tags.md
Normal file
317
todo/tags.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Tags Feature Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Add a tagging system to DearDiary allowing users to categorize events with customizable tags, enabling powerful filtering and organization of their journaling data.
|
||||
|
||||
---
|
||||
|
||||
## 1. Feature Description
|
||||
|
||||
### Core Functionality
|
||||
- **Tag Creation**: Users can create custom tags with name, color, and optional icon
|
||||
- **Multi-tag Support**: Events can have multiple tags assigned
|
||||
- **Suggested Tags**: Pre-configured tags for common categories (work, health, personal, travel, family, finance, hobbies, social)
|
||||
- **Tag Filtering**: Filter events/days by one or multiple tags on history page
|
||||
- **Tag Search**: Search events by tag name
|
||||
- **AI Auto-tagging**: AI automatically suggests tags when generating journals
|
||||
- **Tag Management**: CRUD operations for tags via settings page
|
||||
- **Tag Cloud**: Visual representation of tag usage frequency on dashboard
|
||||
|
||||
### User Flow
|
||||
1. User creates event → Tag selector appears (autocomplete + suggestions)
|
||||
2. User assigns tags → Tags stored with event
|
||||
3. User visits history → Filter sidebar shows tag filters
|
||||
4. User clicks filter → Only matching days/events shown
|
||||
5. User generates diary → AI suggests tags based on content
|
||||
|
||||
---
|
||||
|
||||
## 2. Database Schema Changes
|
||||
|
||||
```prisma
|
||||
model Tag {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
name String
|
||||
color String @default("#8b5cf6") // Default purple
|
||||
icon String? // emoji or icon name
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
eventTags EventTag[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model EventTag {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
tagId String
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([eventId, tagId])
|
||||
@@index([eventId])
|
||||
@@index([tagId])
|
||||
}
|
||||
```
|
||||
|
||||
**Event Model Update** (add relation):
|
||||
```prisma
|
||||
model Event {
|
||||
// ... existing fields
|
||||
tags EventTag[]
|
||||
}
|
||||
```
|
||||
|
||||
### API Response Updates
|
||||
- `GET /days` → Add `tags: string[]` to each day (aggregated from events)
|
||||
- `GET /days/:date` → Add `tags: string[]` to each event
|
||||
- `GET /events` → New endpoint with tag filtering support
|
||||
- `POST /events` → Accept `tagIds: string[]`
|
||||
- `PUT /events/:id` → Accept `tagIds: string[]` for updating tags
|
||||
|
||||
---
|
||||
|
||||
## 3. UI/UX for Tagging
|
||||
|
||||
### Event Input Component
|
||||
- **Tag Input Field**: Combobox with autocomplete
|
||||
- **Suggested Tags**: Show 4-6 most-used tags as clickable chips
|
||||
- **Create New Tag**: Type new name + choose color in dropdown
|
||||
- **Display**: Show selected tags as colored chips below input
|
||||
|
||||
```tsx
|
||||
// UI Mock - Event Input
|
||||
<div className="tag-input-container">
|
||||
<input
|
||||
placeholder="Add tag..."
|
||||
list="tag-suggestions"
|
||||
onChange={handleTagInput}
|
||||
/>
|
||||
<datalist id="tag-suggestions">
|
||||
{userTags.map(tag => (
|
||||
<option key={tag.id} value={tag.name} />
|
||||
))}
|
||||
</datalist>
|
||||
<div className="selected-tags">
|
||||
{selectedTags.map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="tag-chip"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.name} ×
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Tag Selector Colors
|
||||
Pre-defined palette:
|
||||
- Purple: `#8b5cf6` (default)
|
||||
- Blue: `#3b82f6`
|
||||
- Green: `#22c55e`
|
||||
- Yellow: `#eab308`
|
||||
- Orange: `#f97316`
|
||||
- Red: `#ef4444`
|
||||
- Pink: `#ec4899`
|
||||
- Cyan: `#06b6d4`
|
||||
- Slate: `#64748b`
|
||||
|
||||
### Settings Page - Tag Management
|
||||
- List all tags with event count
|
||||
- Create tag: Name, color picker, icon (optional)
|
||||
- Edit tag: Modify any field
|
||||
- Delete tag: Confirm dialog, option to reassign events
|
||||
- Bulk operations: Merge duplicate tags
|
||||
|
||||
### History Page - Filter Sidebar
|
||||
- Collapsible sidebar on left
|
||||
- Checkbox list of all tags with event counts
|
||||
- "Any match" vs "All match" toggle
|
||||
- Clear filters button
|
||||
- Active filters shown as removable chips
|
||||
|
||||
### Tag Cloud (Dashboard)
|
||||
- Word cloud layout using tag size based on frequency
|
||||
- Click tag → Navigate to filtered history
|
||||
- Top 20 most-used tags shown
|
||||
- Animation on hover
|
||||
|
||||
---
|
||||
|
||||
## 4. Filter/Search Integration
|
||||
|
||||
### History Page Filters
|
||||
```
|
||||
GET /days?tags=work,health&tagMatch=any
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| tags | string | Comma-separated tag names |
|
||||
| tagMatch | 'any' \| 'all' | Match any or all tags |
|
||||
|
||||
### Search Integration
|
||||
- Global search input in header
|
||||
- Search by tag name: `tag:work` or `#work`
|
||||
- Search by multiple tags: `tag:work+health`
|
||||
|
||||
### API Endpoints
|
||||
```
|
||||
GET /tags // List user's tags with counts
|
||||
POST /tags // Create tag
|
||||
PUT /tags/:id // Update tag
|
||||
DELETE /tags/:id // Delete tag
|
||||
GET /days?tags=work // Filter days by tags
|
||||
GET /events?tags=work // Filter events by tags
|
||||
POST /events/:id/tags // Add tags to event
|
||||
DELETE /events/:id/tags/:tagId // Remove tag from event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AI Auto-Tagging Prompt Ideas
|
||||
|
||||
### Option A: Tag on Generation
|
||||
When generating diary, AI suggests relevant tags:
|
||||
|
||||
```system
|
||||
You are a diary analyzer. After generating the diary, suggest 2-5
|
||||
tags that best describe the day's events.
|
||||
|
||||
Available tags (use these if relevant): work, health, personal,
|
||||
travel, family, finance, hobbies, social, exercise, meals, sleep,
|
||||
mood, productivity, creativity, relationships
|
||||
|
||||
Respond with JSON:
|
||||
{"tags": ["work", "health"]}
|
||||
```
|
||||
|
||||
### Option B: Standalone Auto-Tag
|
||||
Separate endpoint for tagging events:
|
||||
|
||||
```system
|
||||
Analyze the following journal entries and suggest appropriate tags.
|
||||
Choose from: work, health, personal, travel, family, finance,
|
||||
hobbies, social, exercise, meals, sleep, mood, productivity,
|
||||
creativity, relationships
|
||||
|
||||
If none match, return empty array. Be conservative - only tag
|
||||
if clearly relevant.
|
||||
|
||||
Entries:
|
||||
{events.map(e => `- ${e.content}`).join('\n')}
|
||||
|
||||
Respond with JSON:
|
||||
{"tags": ["tag1", "tag2"]}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
- Add `autoTag: boolean` to Settings
|
||||
- On journal generation, call AI with events → get suggested tags
|
||||
- Show suggested tags as "AI suggests: [tag]" with one-click accept
|
||||
- Optionally auto-apply (configurable)
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Complexity
|
||||
|
||||
### Backend (Medium)
|
||||
- [ ] Add Tag and EventTag models to Prisma schema
|
||||
- [ ] Create migration
|
||||
- [ ] Add tag CRUD endpoints
|
||||
- [ ] Update event creation/update to accept tagIds
|
||||
- [ ] Add tag filtering to /days and /events endpoints
|
||||
- [ ] Add tag aggregation queries
|
||||
- [ ] AI auto-tagging service (new endpoint)
|
||||
|
||||
### Frontend (Medium-High)
|
||||
- [ ] Tag selector component with autocomplete
|
||||
- [ ] Tag chip display component
|
||||
- [ ] Settings page tag management
|
||||
- [ ] History page filter sidebar
|
||||
- [ ] Tag cloud component for dashboard
|
||||
- [ ] Search integration with tag filtering
|
||||
|
||||
### Estimated Effort
|
||||
| Component | Complexity | Notes |
|
||||
|-----------|------------|-------|
|
||||
| DB Schema | Easy | Simple many-to-many |
|
||||
| Tag CRUD API | Easy | Standard CRUD |
|
||||
| Event tagging | Medium | Update create/update flows |
|
||||
| Tag filtering | Medium | Query construction |
|
||||
| AI auto-tag | Medium | New service + UI |
|
||||
| Frontend components | Medium | Multiple new components |
|
||||
| Filter UI | Medium | Sidebar + state |
|
||||
|
||||
**Total**: ~3-4 days for full implementation
|
||||
|
||||
---
|
||||
|
||||
## 7. Priority Recommendation
|
||||
|
||||
### Recommended Order
|
||||
|
||||
1. **Phase 1: Core Infrastructure** (Priority: HIGH)
|
||||
- Database schema + migration
|
||||
- Tag CRUD API
|
||||
- Event tagging on create/update
|
||||
|
||||
2. **Phase 2: Basic UI** (Priority: HIGH)
|
||||
- Tag selector in event input
|
||||
- Tag display on events
|
||||
- Settings page tag management
|
||||
|
||||
3. **Phase 3: Filtering** (Priority: MEDIUM)
|
||||
- History page filter sidebar
|
||||
- Tag filter API integration
|
||||
- Tag search support
|
||||
|
||||
4. **Phase 4: Advanced Features** (Priority: LOW)
|
||||
- AI auto-tagging
|
||||
- Tag cloud visualization
|
||||
- Bulk tag operations
|
||||
|
||||
### MVP Definition
|
||||
- Users can create/edit/delete tags
|
||||
- Tags can be assigned to events
|
||||
- History page shows tag filter sidebar
|
||||
- Filtering works correctly
|
||||
|
||||
### Out of Scope for V1
|
||||
- AI auto-tagging (defer to future)
|
||||
- Tag cloud (defer to future)
|
||||
- Bulk operations
|
||||
- Tag merging
|
||||
|
||||
---
|
||||
|
||||
## 8. Additional Considerations
|
||||
|
||||
### Tag Inheritance
|
||||
- Should tags be inherited when generating journal?
|
||||
- Decision: Tags stay with individual events only
|
||||
|
||||
### Tag Limits
|
||||
- Max tags per user: 50 (configurable)
|
||||
- Max tags per event: 10
|
||||
- Tag name max length: 30 characters
|
||||
|
||||
### Data Migration
|
||||
- No migration needed for new feature
|
||||
- Future: Migrate from metadata-based tags if any exist
|
||||
|
||||
### Performance
|
||||
- Index on `(userId, name)` for tag lookups
|
||||
- Index on `(eventId, tagId)` for junction table
|
||||
- Cache tag counts, invalidate on changes
|
||||
|
||||
### Privacy
|
||||
- Tags are user-specific (not shared)
|
||||
- No public tag feeds or global tag suggestions
|
||||
Reference in New Issue
Block a user