feat: terminology fix (Entry→Event), diary page generation, settings refactor

- Rename Entry→Event throughout frontend and backend
- Change 'journal' terminology to 'diary page'
- Add professional footer with links
- Redirect to diary page after generation
- Error handling for generation failures
- Fix settings to store per-provider configs in providerSettings
- Backend reads API key from providerSettings
- Use prisma db push instead of migrate for schema sync
- Clean up duplicate entries.ts file
This commit is contained in:
lotherk
2026-03-26 23:10:33 +00:00
parent 754fea73c6
commit deaf496a7d
18 changed files with 289 additions and 256 deletions

View File

@@ -19,10 +19,10 @@ CREATE TABLE IF NOT EXISTS "ApiKey" (
CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
); );
CREATE INDEX IF NOT EXISTS "ApiKey_userId_key" ON "ApiKey"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "ApiKey_keyHash_key" ON "ApiKey"("keyHash"); CREATE UNIQUE INDEX IF NOT EXISTS "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
CREATE INDEX IF NOT EXISTS "ApiKey_userId_key" ON "ApiKey"("userId");
CREATE TABLE IF NOT EXISTS "Entry" ( CREATE TABLE IF NOT EXISTS "Event" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"date" TEXT NOT NULL, "date" TEXT NOT NULL,
@@ -32,18 +32,18 @@ CREATE TABLE IF NOT EXISTS "Entry" (
"metadata" TEXT, "metadata" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" DATETIME NOT NULL,
CONSTRAINT "Entry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT "Event_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
); );
CREATE INDEX IF NOT EXISTS "Entry_userId_date_key" ON "Entry"("userId", "date"); CREATE INDEX IF NOT EXISTS "Event_userId_date_key" ON "Event"("userId", "date");
CREATE INDEX IF NOT EXISTS "Entry_date_key" ON "Entry"("date"); CREATE INDEX IF NOT EXISTS "Event_date_key" ON "Event"("date");
CREATE TABLE IF NOT EXISTS "Journal" ( CREATE TABLE IF NOT EXISTS "Journal" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"date" TEXT NOT NULL, "date" TEXT NOT NULL,
"content" TEXT NOT NULL, "content" TEXT NOT NULL,
"entryCount" INTEGER NOT NULL, "eventCount" INTEGER NOT NULL,
"generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Journal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT "Journal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
); );
@@ -86,7 +86,6 @@ CREATE TABLE IF NOT EXISTS "Settings" (
CONSTRAINT "Settings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT "Settings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
); );
-- Create _prisma_migrations table for Prisma migrate tracking
CREATE TABLE IF NOT EXISTS "_prisma_migrations" ( CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
"checksum" TEXT NOT NULL, "checksum" TEXT NOT NULL,
@@ -98,6 +97,5 @@ CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
"applied_steps_count" INTEGER NOT NULL DEFAULT 0 "applied_steps_count" INTEGER NOT NULL DEFAULT 0
); );
-- Record this migration
INSERT INTO "_prisma_migrations" ("id", "checksum", "finished_at", "migration_name", "applied_steps_count") INSERT INTO "_prisma_migrations" ("id", "checksum", "finished_at", "migration_name", "applied_steps_count")
VALUES (lower(hex(randomblob(16))), 'init', datetime('now'), '00000000000000_init', 1); VALUES (lower(hex(randomblob(16))), 'init', datetime('now'), '00000000000000_init', 1);

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -15,7 +15,7 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
apiKeys ApiKey[] apiKeys ApiKey[]
entries Entry[] events Event[]
journals Journal[] journals Journal[]
tasks Task[] tasks Task[]
settings Settings? settings Settings?
@@ -34,7 +34,7 @@ model ApiKey {
@@index([userId]) @@index([userId])
} }
model Entry { model Event {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
date String date String
@@ -56,7 +56,7 @@ model Journal {
userId String userId String
date String date String
content String content String
entryCount Int eventCount Int
generatedAt DateTime @default(now()) generatedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -147,7 +147,7 @@ app.get('/api/v1/days', async (c) => {
const userId = await getUserId(c); const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const days = await prisma.entry.groupBy({ const days = await prisma.event.groupBy({
by: ['date'], by: ['date'],
where: { userId }, where: { userId },
_count: { id: true }, _count: { id: true },
@@ -163,7 +163,7 @@ app.get('/api/v1/days', async (c) => {
const result = days.map(day => ({ const result = days.map(day => ({
date: day.date, date: day.date,
entryCount: day._count.id, eventCount: day._count.id,
hasJournal: journalMap.has(day.date), hasJournal: journalMap.has(day.date),
journalGeneratedAt: journalMap.get(day.date)?.generatedAt, journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
})); }));
@@ -181,12 +181,12 @@ app.get('/api/v1/days/:date', async (c) => {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400); return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400);
} }
const [entries, journal] = await Promise.all([ const [events, journal] = await Promise.all([
prisma.entry.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }), prisma.event.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
prisma.journal.findFirst({ where: { userId, date } }), prisma.journal.findFirst({ where: { userId, date } }),
]); ]);
return c.json({ data: { date, entries, journal }, error: null }); return c.json({ data: { date, events, journal }, error: null });
}); });
app.delete('/api/v1/days/:date', async (c) => { app.delete('/api/v1/days/:date', async (c) => {
@@ -196,15 +196,31 @@ app.delete('/api/v1/days/:date', async (c) => {
const { date } = c.req.param(); const { date } = c.req.param();
await prisma.$transaction([ await prisma.$transaction([
prisma.entry.deleteMany({ where: { userId, date } }), prisma.event.deleteMany({ where: { userId, date } }),
prisma.journal.deleteMany({ where: { userId, date } }), prisma.journal.deleteMany({ where: { userId, date } }),
]); ]);
return c.json({ data: { deleted: true }, error: null }); return c.json({ data: { deleted: true }, error: null });
}); });
// Entries routes // Delete journal only (keeps events)
app.post('/api/v1/entries', async (c) => { app.delete('/api/v1/journal/:date', 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);
}
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); const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
@@ -214,45 +230,45 @@ app.post('/api/v1/entries', async (c) => {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400); return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
} }
const validTypes = ['text', 'voice', 'photo', 'health', 'location']; const validTypes = ['event'];
if (!validTypes.includes(type)) { if (!validTypes.includes(type)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400); return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400);
} }
const entry = await prisma.entry.create({ const event = await prisma.event.create({
data: { userId, date, type, content, metadata: metadata ? JSON.stringify(metadata) : null }, data: { userId, date, type, content, metadata: metadata ? JSON.stringify(metadata) : null },
}); });
return c.json({ data: entry, error: null }, 201); return c.json({ data: event, error: null }, 201);
}); });
app.get('/api/v1/entries/:id', async (c) => { app.get('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c); const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param(); const { id } = c.req.param();
const entry = await prisma.entry.findFirst({ where: { id, userId } }); const event = await prisma.event.findFirst({ where: { id, userId } });
if (!entry) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); if (!event) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
return c.json({ data: entry, error: null }); return c.json({ data: event, error: null });
}); });
app.put('/api/v1/entries/:id', async (c) => { app.put('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c); const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param(); const { id } = c.req.param();
const { content, metadata } = await c.req.json(); const { content, metadata } = await c.req.json();
const existing = await prisma.entry.findFirst({ where: { id, userId } }); const existing = await prisma.event.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); 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 } }); const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) { if (journal) {
return c.json({ data: null, error: { code: 'ENTRY_IMMUTABLE', message: 'Cannot edit entry: journal already generated. Delete the journal first.' } }, 400); return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot edit event: journal already generated. Delete the journal first.' } }, 400);
} }
const entry = await prisma.entry.update({ const event = await prisma.event.update({
where: { id }, where: { id },
data: { data: {
content: content ?? existing.content, content: content ?? existing.content,
@@ -260,23 +276,23 @@ app.put('/api/v1/entries/:id', async (c) => {
}, },
}); });
return c.json({ data: entry, error: null }); return c.json({ data: event, error: null });
}); });
app.delete('/api/v1/entries/:id', async (c) => { app.delete('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c); const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param(); const { id } = c.req.param();
const existing = await prisma.entry.findFirst({ where: { id, userId } }); const existing = await prisma.event.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); 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 } }); const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) { if (journal) {
return c.json({ data: null, error: { code: 'ENTRY_IMMUTABLE', message: 'Cannot delete entry: journal already generated. Delete the journal first.' } }, 400); return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot delete event: journal already generated. Delete the journal first.' } }, 400);
} }
await prisma.entry.delete({ where: { id } }); await prisma.event.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null }); return c.json({ data: { deleted: true }, error: null });
}); });
@@ -287,27 +303,30 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
const { date } = c.req.param(); const { date } = c.req.param();
const [entries, settings] = await Promise.all([ const [events, settings] = await Promise.all([
prisma.entry.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }), prisma.event.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
prisma.settings.findUnique({ where: { userId } }), prisma.settings.findUnique({ where: { userId } }),
]); ]);
if (entries.length === 0) { if (events.length === 0) {
return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400); return c.json({ data: null, error: { code: 'NO_EVENTS', message: 'No events found for this date' } }, 400);
} }
const provider = settings?.aiProvider || 'groq'; const provider = settings?.aiProvider || 'groq';
const providerSettings = settings?.providerSettings ? JSON.parse(settings.providerSettings) : {};
const providerConfig = providerSettings[provider] || {};
const apiKey = providerConfig.apiKey || settings?.aiApiKey;
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq') && !settings?.aiApiKey) { if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq') && !apiKey) {
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400); return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
} }
// Build entries text // Build events text
const entriesText = entries.map(entry => { const eventsText = events.map(event => {
let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`; let text = `[EVENT] ${event.createdAt.toISOString()}\n${event.content}`;
if (entry.metadata) { if (event.metadata) {
try { try {
const meta = JSON.parse(entry.metadata); const meta = JSON.parse(event.metadata);
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`; if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
if (meta.duration) text += `\nDuration: ${meta.duration}s`; if (meta.duration) text += `\nDuration: ${meta.duration}s`;
} catch {} } catch {}
@@ -342,15 +361,15 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
} }
} }
// Build prompts: 1. system prompt, 2. previous journals, 3. today's entries // Build prompts: 1. system prompt, 2. previous journals, 3. today's events
const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.'; const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.';
const userPrompt = `${previousJournalsText}ENTRIES FROM TODAY (${date}):\n${entriesText}\n\nWrite a thoughtful, reflective journal entry based on the entries above.`; const userPrompt = `${previousJournalsText}EVENTS FROM TODAY (${date}):\n${eventsText}\n\nWrite a thoughtful, reflective journal entry based on the events above.`;
console.log(`[Journal Generate] Date: ${date}, Context days: ${contextDays}, Entries: ${entries.length}`); console.log(`[Journal Generate] Date: ${date}, Context days: ${contextDays}, Events: ${events.length}`);
// Create placeholder journal and task // Create placeholder journal and task
const placeholderJournal = await prisma.journal.create({ const placeholderJournal = await prisma.journal.create({
data: { userId, date, content: 'Generating...', entryCount: entries.length }, data: { userId, date, content: 'Generating...', eventCount: events.length },
}); });
const task = await prisma.task.create({ const task = await prisma.task.create({
@@ -376,11 +395,14 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
try { try {
console.log(`[Journal Generate] Using provider: ${provider}`); console.log(`[Journal Generate] Using provider: ${provider}`);
const model = providerConfig.model || settings?.aiModel;
const baseUrl = providerConfig.baseUrl || settings?.aiBaseUrl;
const aiProvider = createAIProvider({ const aiProvider = createAIProvider({
provider: provider as 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq', provider: provider as 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq',
apiKey: settings?.aiApiKey || '', apiKey: apiKey,
model: settings?.aiModel || undefined, model: model || undefined,
baseUrl: (provider === 'ollama' || provider === 'lmstudio') ? settings?.aiBaseUrl || undefined : undefined, baseUrl: (provider === 'ollama' || provider === 'lmstudio') ? baseUrl : undefined,
}); });
console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`); console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`);

View File

@@ -7,7 +7,7 @@ daysRoutes.get('/', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const prisma = c.get('prisma'); const prisma = c.get('prisma');
const days = await prisma.entry.groupBy({ const days = await prisma.event.groupBy({
by: ['date'], by: ['date'],
where: { userId }, where: { userId },
_count: { id: true }, _count: { id: true },
@@ -16,14 +16,14 @@ daysRoutes.get('/', async (c) => {
const journals = await prisma.journal.findMany({ const journals = await prisma.journal.findMany({
where: { userId }, where: { userId },
select: { date: true, generatedAt: true, entryCount: true }, select: { date: true, generatedAt: true, eventCount: true },
}); });
const journalMap = new Map(journals.map(j => [j.date, j])); const journalMap = new Map(journals.map(j => [j.date, j]));
const result = days.map(day => ({ const result = days.map(day => ({
date: day.date, date: day.date,
entryCount: day._count.id, eventCount: day._count.id,
hasJournal: journalMap.has(day.date), hasJournal: journalMap.has(day.date),
journalGeneratedAt: journalMap.get(day.date)?.generatedAt, journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
})); }));
@@ -41,8 +41,8 @@ daysRoutes.get('/:date', async (c) => {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400); return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400);
} }
const [entries, journal] = await Promise.all([ const [events, journal] = await Promise.all([
prisma.entry.findMany({ prisma.event.findMany({
where: { userId, date }, where: { userId, date },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}), }),
@@ -51,7 +51,7 @@ daysRoutes.get('/:date', async (c) => {
}), }),
]); ]);
return c.json({ data: { date, entries, journal }, error: null }); return c.json({ data: { date, events, journal }, error: null });
}); });
daysRoutes.delete('/:date', async (c) => { daysRoutes.delete('/:date', async (c) => {
@@ -60,9 +60,29 @@ daysRoutes.delete('/:date', async (c) => {
const prisma = c.get('prisma'); const prisma = c.get('prisma');
await prisma.$transaction([ await prisma.$transaction([
prisma.entry.deleteMany({ where: { userId, date } }), prisma.event.deleteMany({ where: { userId, date } }),
prisma.journal.deleteMany({ where: { userId, date } }), prisma.journal.deleteMany({ where: { userId, date } }),
]); ]);
return c.json({ data: { deleted: true }, error: null }); return c.json({ data: { deleted: true }, error: null });
}); });
daysRoutes.delete('/:date/journal', async (c) => {
const userId = c.get('userId');
const { date } = c.req.param();
const prisma = c.get('prisma');
const journal = await prisma.journal.findUnique({
where: { userId_date: { userId, date } },
});
if (!journal) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Journal not found' } }, 404);
}
await prisma.journal.delete({
where: { id: journal.id },
});
return c.json({ data: { deleted: true }, error: null });
});

View File

@@ -1,9 +1,9 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { HonoEnv } from '../lib/types'; import { HonoEnv } from '../lib/types';
export const entriesRoutes = new Hono<HonoEnv>(); export const eventsRoutes = new Hono<HonoEnv>();
entriesRoutes.post('/', async (c) => { eventsRoutes.post('/', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const prisma = c.get('prisma'); const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media'; const mediaDir = c.env.MEDIA_DIR || './data/media';
@@ -15,12 +15,12 @@ entriesRoutes.post('/', async (c) => {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400); return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
} }
const validTypes = ['text', 'voice', 'photo', 'health', 'location']; const validTypes = ['event', 'text', 'photo', 'voice', 'health'];
if (!validTypes.includes(type)) { if (!validTypes.includes(type)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400); return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400);
} }
const entry = await prisma.entry.create({ const event = await prisma.event.create({
data: { data: {
userId, userId,
date, date,
@@ -30,26 +30,26 @@ entriesRoutes.post('/', async (c) => {
}, },
}); });
return c.json({ data: entry, error: null }, 201); return c.json({ data: event, error: null }, 201);
}); });
entriesRoutes.get('/:id', async (c) => { eventsRoutes.get('/:id', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
const entry = await prisma.entry.findFirst({ const event = await prisma.event.findFirst({
where: { id, userId }, where: { id, userId },
}); });
if (!entry) { if (!event) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
} }
return c.json({ data: entry, error: null }); return c.json({ data: event, error: null });
}); });
entriesRoutes.put('/:id', async (c) => { eventsRoutes.put('/:id', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
@@ -57,20 +57,20 @@ entriesRoutes.put('/:id', async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const { content, metadata } = body; const { content, metadata } = body;
const existing = await prisma.entry.findFirst({ const existing = await prisma.event.findFirst({
where: { id, userId }, where: { id, userId },
}); });
if (!existing) { if (!existing) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); 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 } }); const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) { if (journal) {
return c.json({ data: null, error: { code: 'ENTRY_IMMUTABLE', message: 'Cannot edit entry: journal already generated. Delete the journal first.' } }, 400); return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot edit event: diary already generated. Delete the diary first.' } }, 400);
} }
const entry = await prisma.entry.update({ const event = await prisma.event.update({
where: { id }, where: { id },
data: { data: {
content: content ?? existing.content, content: content ?? existing.content,
@@ -78,44 +78,44 @@ entriesRoutes.put('/:id', async (c) => {
}, },
}); });
return c.json({ data: entry, error: null }); return c.json({ data: event, error: null });
}); });
entriesRoutes.delete('/:id', async (c) => { eventsRoutes.delete('/:id', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
const existing = await prisma.entry.findFirst({ const existing = await prisma.event.findFirst({
where: { id, userId }, where: { id, userId },
}); });
if (!existing) { if (!existing) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); 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 } }); const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) { if (journal) {
return c.json({ data: null, error: { code: 'ENTRY_IMMUTABLE', message: 'Cannot delete entry: journal already generated. Delete the journal first.' } }, 400); return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot delete event: diary already generated. Delete the diary first.' } }, 400);
} }
await prisma.entry.delete({ where: { id } }); await prisma.event.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null }); return c.json({ data: { deleted: true }, error: null });
}); });
entriesRoutes.post('/:id/photo', async (c) => { eventsRoutes.post('/:id/photo', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media'; const mediaDir = c.env.MEDIA_DIR || './data/media';
const entry = await prisma.entry.findFirst({ const event = await prisma.event.findFirst({
where: { id, userId }, where: { id, userId },
}); });
if (!entry) { if (!event) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 400);
} }
const body = await c.req.parseBody(); const body = await c.req.parseBody();
@@ -127,12 +127,12 @@ entriesRoutes.post('/:id/photo', async (c) => {
const ext = file.name.split('.').pop() || 'jpg'; const ext = file.name.split('.').pop() || 'jpg';
const fileName = `${id}.${ext}`; const fileName = `${id}.${ext}`;
const userMediaDir = `${mediaDir}/${userId}/${entry.date}`; const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
const filePath = `${userMediaDir}/${fileName}`; const filePath = `${userMediaDir}/${fileName}`;
await Bun.write(filePath, file); await Bun.write(filePath, file);
await prisma.entry.update({ await prisma.event.update({
where: { id }, where: { id },
data: { mediaPath: filePath }, data: { mediaPath: filePath },
}); });
@@ -140,18 +140,18 @@ entriesRoutes.post('/:id/photo', async (c) => {
return c.json({ data: { mediaPath: filePath }, error: null }, 201); return c.json({ data: { mediaPath: filePath }, error: null }, 201);
}); });
entriesRoutes.post('/:id/voice', async (c) => { eventsRoutes.post('/:id/voice', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media'; const mediaDir = c.env.MEDIA_DIR || './data/media';
const entry = await prisma.entry.findFirst({ const event = await prisma.event.findFirst({
where: { id, userId }, where: { id, userId },
}); });
if (!entry) { if (!event) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 400);
} }
const body = await c.req.parseBody(); const body = await c.req.parseBody();
@@ -162,12 +162,12 @@ entriesRoutes.post('/:id/voice', async (c) => {
} }
const fileName = `${id}.webm`; const fileName = `${id}.webm`;
const userMediaDir = `${mediaDir}/${userId}/${entry.date}`; const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
const filePath = `${userMediaDir}/${fileName}`; const filePath = `${userMediaDir}/${fileName}`;
await Bun.write(filePath, file); await Bun.write(filePath, file);
await prisma.entry.update({ await prisma.event.update({
where: { id }, where: { id },
data: { mediaPath: filePath }, data: { mediaPath: filePath },
}); });

View File

@@ -9,8 +9,8 @@ journalRoutes.post('/generate/:date', async (c) => {
const { date } = c.req.param(); const { date } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
const [entries, settings] = await Promise.all([ const [events, settings] = await Promise.all([
prisma.entry.findMany({ prisma.event.findMany({
where: { userId, date }, where: { userId, date },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}), }),
@@ -19,8 +19,8 @@ journalRoutes.post('/generate/:date', async (c) => {
}), }),
]); ]);
if (entries.length === 0) { if (events.length === 0) {
return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400); return c.json({ data: null, error: { code: 'NO_EVENTS', message: 'No events found for this date' } }, 400);
} }
if (!settings?.aiApiKey) { if (!settings?.aiApiKey) {
@@ -34,11 +34,11 @@ journalRoutes.post('/generate/:date', async (c) => {
baseUrl: settings.aiBaseUrl, baseUrl: settings.aiBaseUrl,
}); });
const entriesText = entries.map(entry => { const eventsText = events.map(event => {
let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`; let text = `[${event.type.toUpperCase()}] ${event.createdAt.toISOString()}\n${event.content}`;
if (entry.metadata) { if (event.metadata) {
try { try {
const meta = JSON.parse(entry.metadata); const meta = JSON.parse(event.metadata);
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`; if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
if (meta.duration) text += `\nDuration: ${meta.duration}s`; if (meta.duration) text += `\nDuration: ${meta.duration}s`;
} catch {} } catch {}
@@ -46,17 +46,17 @@ journalRoutes.post('/generate/:date', async (c) => {
return text; return text;
}).join('\n\n'); }).join('\n\n');
const prompt = `The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry that: const prompt = `The following events were captured throughout the day (${date}). Write a thoughtful, reflective diary page that:
1. Summarizes the key moments and activities 1. Summarizes the key moments and activities
2. Reflects on any patterns, feelings, or insights 2. Reflects on any patterns, feelings, or insights
3. Ends with a forward-looking thought 3. Ends with a forward-looking thought
Use a warm, personal tone. The journal should flow naturally as prose. Use a warm, personal tone. The journal should flow naturally as prose.
ENTRIES: EVENTS:
${entriesText} ${eventsText}
JOURNAL:`; DIARY PAGE:`;
try { try {
const content = await provider.generate(prompt, settings.journalPrompt); const content = await provider.generate(prompt, settings.journalPrompt);
@@ -67,11 +67,11 @@ JOURNAL:`;
userId, userId,
date, date,
content, content,
entryCount: entries.length, eventCount: events.length,
}, },
update: { update: {
content, content,
entryCount: entries.length, eventCount: events.length,
generatedAt: new Date(), generatedAt: new Date(),
}, },
}); });

View File

@@ -52,6 +52,15 @@ function Navbar() {
); );
} }
function Footer() {
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>
</footer>
);
}
function App() { function App() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
@@ -97,6 +106,7 @@ function App() {
} /> } />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
<Footer />
</div> </div>
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -5,7 +5,7 @@ interface Props {
} }
export default function EntryInput({ onSubmit }: Props) { export default function EntryInput({ onSubmit }: Props) {
const [type, setType] = useState('text'); const [type, setType] = useState('event');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -76,10 +76,10 @@ export default function EntryInput({ onSubmit }: Props) {
<div className="flex gap-2 mb-2"> <div className="flex gap-2 mb-2">
<button <button
type="button" type="button"
onClick={() => setType('text')} onClick={() => setType('event')}
className={`px-3 py-1 rounded text-sm ${type === 'text' ? 'bg-slate-700' : 'bg-slate-800'}`} className={`px-3 py-1 rounded text-sm ${type === 'event' ? 'bg-slate-700' : 'bg-slate-800'}`}
> >
Text Event
</button> </button>
<button <button
type="button" type="button"
@@ -104,13 +104,13 @@ export default function EntryInput({ onSubmit }: Props) {
</button> </button>
</div> </div>
{type === 'text' && ( {(type === 'text' || type === 'event') && (
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder="What's on your mind?" placeholder="Log an event..."
className="flex-1 px-4 py-3 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" className="flex-1 px-4 py-3 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
autoFocus autoFocus
/> />

View File

@@ -1,62 +1,40 @@
import type { Entry } from '../lib/api'; import type { Event } from '../lib/api';
interface Props { interface Props {
entries: Entry[]; events: Event[];
onDelete: (id: string) => void; onDelete: (id: string) => void;
readOnly?: boolean; readOnly?: boolean;
} }
export default function EntryList({ entries, onDelete, readOnly }: Props) { export default function EntryList({ events, onDelete, readOnly }: Props) {
const formatTime = (dateStr: string) => { const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}; };
const getTypeIcon = (type: string) => {
switch (type) {
case 'text': return '📝';
case 'photo': return '📷';
case 'voice': return '🎤';
case 'health': return '❤️';
case 'location': return '📍';
default: return '📌';
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'text': return 'border-l-blue-500';
case 'photo': return 'border-l-yellow-500';
case 'voice': return 'border-l-purple-500';
case 'health': return 'border-l-red-500';
case 'location': return 'border-l-green-500';
default: return 'border-l-slate-500';
}
};
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{readOnly && ( {readOnly && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-sm text-amber-400"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-sm text-amber-400">
🔒 Entries are locked because a journal has been generated. Delete the journal to edit entries. 🔒 Events are locked because a diary page has been written. Delete the diary page to edit events.
</div> </div>
)} )}
{entries.map((entry) => ( {events.map((event) => (
<div <div
key={entry.id} key={event.id}
className={`bg-slate-900 rounded-lg p-4 border border-slate-800 border-l-4 ${getTypeColor(entry.type)}`} className="bg-slate-900 rounded-lg p-4 border border-slate-800 border-l-4 border-l-blue-500"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-lg">{getTypeIcon(entry.type)}</span> <span className="text-lg">📝</span>
<span className="text-xs text-slate-500">{formatTime(entry.createdAt)}</span> <span className="text-xs text-slate-500">{formatTime(event.createdAt)}</span>
</div> </div>
<p className="text-slate-200">{entry.content}</p> <p className="text-slate-200">{event.content}</p>
{entry.metadata && ( {event.metadata && (
<div className="mt-2 text-xs text-slate-500"> <div className="mt-2 text-xs text-slate-500">
{(() => { {(() => {
try { try {
const meta = JSON.parse(entry.metadata); const meta = JSON.parse(event.metadata);
return meta.location ? ( return meta.location ? (
<span>📍 {meta.location.lat?.toFixed(4)}, {meta.location.lng?.toFixed(4)}</span> <span>📍 {meta.location.lat?.toFixed(4)}, {meta.location.lng?.toFixed(4)}</span>
) : meta.duration ? ( ) : meta.duration ? (
@@ -71,7 +49,7 @@ export default function EntryList({ entries, onDelete, readOnly }: Props) {
</div> </div>
{!readOnly && ( {!readOnly && (
<button <button
onClick={() => onDelete(entry.id)} onClick={() => onDelete(event.id)}
className="text-slate-500 hover:text-red-400 text-sm transition" className="text-slate-500 hover:text-red-400 text-sm transition"
> >
× ×

View File

@@ -68,30 +68,34 @@ class ApiClient {
} }
async getDays() { async getDays() {
return this.request<Array<{ date: string; entryCount: number; hasJournal: boolean }>>('GET', '/days'); return this.request<Array<{ date: string; eventCount: number; hasJournal: boolean }>>('GET', '/days');
} }
async getDay(date: string) { async getDay(date: string) {
return this.request<{ date: string; entries: Entry[]; journal: Journal | null }>('GET', `/days/${date}`); return this.request<{ date: string; events: Event[]; journal: Journal | null }>('GET', `/days/${date}`);
} }
async deleteDay(date: string) { async deleteDay(date: string) {
return this.request<{ deleted: boolean }>('DELETE', `/days/${date}`); return this.request<{ deleted: boolean }>('DELETE', `/days/${date}`);
} }
async createEntry(date: string, type: string, content: string, metadata?: object) { async createEvent(date: string, type: string, content: string, metadata?: object) {
return this.request<Entry>('POST', '/entries', { date, type, content, metadata }); return this.request<Event>('POST', '/events', { date, type, content, metadata });
} }
async updateEntry(id: string, content: string, metadata?: object) { async updateEvent(id: string, content: string, metadata?: object) {
return this.request<Entry>('PUT', `/entries/${id}`, { content, metadata }); return this.request<Event>('PUT', `/events/${id}`, { content, metadata });
} }
async deleteEntry(id: string) { async deleteEvent(id: string) {
return this.request<{ deleted: boolean }>('DELETE', `/entries/${id}`); return this.request<{ deleted: boolean }>('DELETE', `/events/${id}`);
} }
async uploadPhoto(entryId: string, file: File) { async deleteJournal(date: string) {
return this.request<{ deleted: boolean }>('DELETE', `/journal/${date}`);
}
async uploadPhoto(eventId: string, file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@@ -100,7 +104,7 @@ class ApiClient {
headers['Authorization'] = `Bearer ${this.getApiKey()}`; headers['Authorization'] = `Bearer ${this.getApiKey()}`;
} }
const response = await fetch(`${API_BASE}/entries/${entryId}/photo`, { const response = await fetch(`${API_BASE}/events/${eventId}/photo`, {
method: 'POST', method: 'POST',
headers, headers,
body: formData, body: formData,
@@ -108,7 +112,7 @@ class ApiClient {
return response.json() as Promise<ApiResponse<{ mediaPath: string }>>; return response.json() as Promise<ApiResponse<{ mediaPath: string }>>;
} }
async uploadVoice(entryId: string, file: File) { async uploadVoice(eventId: string, file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@@ -117,7 +121,7 @@ class ApiClient {
headers['Authorization'] = `Bearer ${this.getApiKey()}`; headers['Authorization'] = `Bearer ${this.getApiKey()}`;
} }
const response = await fetch(`${API_BASE}/entries/${entryId}/voice`, { const response = await fetch(`${API_BASE}/events/${eventId}/voice`, {
method: 'POST', method: 'POST',
headers, headers,
body: formData, body: formData,
@@ -150,10 +154,10 @@ class ApiClient {
} }
} }
export interface Entry { export interface Event {
id: string; id: string;
date: string; date: string;
type: 'text' | 'voice' | 'photo' | 'health' | 'location'; type: string;
content: string; content: string;
mediaPath?: string; mediaPath?: string;
metadata?: string; metadata?: string;
@@ -164,7 +168,7 @@ export interface Journal {
id: string; id: string;
date: string; date: string;
content: string; content: string;
entryCount: number; eventCount: number;
generatedAt: string; generatedAt: string;
} }

View File

@@ -1,54 +1,54 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { api, Entry } from '../lib/api'; import { api, Event } from '../lib/api';
import EntryInput from '../components/EntryInput'; import EntryInput from '../components/EntryInput';
import EntryList from '../components/EntryList'; import EntryList from '../components/EntryList';
export default function Day() { export default function Day() {
const { date } = useParams<{ date: string }>(); const { date } = useParams<{ date: string }>();
const [entries, setEntries] = useState<Entry[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasJournal, setHasJournal] = useState(false); const [hasJournal, setHasJournal] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (date) loadEntries(); if (date) loadEvents();
}, [date]); }, [date]);
const loadEntries = async () => { const loadEvents = async () => {
if (!date) return; if (!date) return;
setLoading(true); setLoading(true);
const res = await api.getDay(date); const res = await api.getDay(date);
if (res.data) { if (res.data) {
setEntries(res.data.entries); setEvents(res.data.events);
setHasJournal(!!res.data.journal); setHasJournal(!!res.data.journal);
} }
setLoading(false); setLoading(false);
}; };
const handleAddEntry = async (type: string, content: string, metadata?: object) => { const handleAddEvent = async (type: string, content: string, metadata?: object) => {
if (!date) return { error: { message: 'No date' } }; if (!date) return { error: { message: 'No date' } };
const res = await api.createEntry(date, type, content, metadata); const res = await api.createEvent(date, type, content, metadata);
if (res.data) { if (res.data) {
setEntries((prev) => [...prev, res.data!]); setEvents((prev) => [...prev, res.data!]);
} }
return res; return res;
}; };
const handleDeleteEntry = async (id: string) => { const handleDeleteEvent = async (id: string) => {
setDeleteError(null); setDeleteError(null);
const res = await api.deleteEntry(id); const res = await api.deleteEvent(id);
if (res.data) { if (res.data) {
setEntries((prev) => prev.filter((e) => e.id !== id)); setEvents((prev) => prev.filter((e) => e.id !== id));
} else if (res.error?.code === 'ENTRY_IMMUTABLE') { } else if (res.error?.code === 'EVENT_IMMUTABLE') {
setDeleteError(res.error.message); setDeleteError(res.error.message);
} }
}; };
const handleDeleteJournal = async () => { const handleDeleteJournal = async () => {
if (!date) return; if (!date) return;
if (!confirm('Delete journal? This will unlock entries for editing.')) return; if (!confirm('Delete diary page? This will unlock events for editing.')) return;
const res = await api.deleteDay(date); const res = await api.deleteJournal(date);
if (res.data) { if (res.data) {
setHasJournal(false); setHasJournal(false);
} }
@@ -74,10 +74,10 @@ export default function Day() {
onClick={handleDeleteJournal} onClick={handleDeleteJournal}
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition" className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition"
> >
Delete Journal Delete Diary
</button> </button>
<a href={`/journal/${date}`} className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition"> <a href={`/journal/${date}`} className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition">
View Journal View Diary
</a> </a>
</div> </div>
)} )}
@@ -89,16 +89,16 @@ export default function Day() {
</div> </div>
)} )}
<EntryInput onSubmit={handleAddEntry} /> <EntryInput onSubmit={handleAddEvent} />
{loading ? ( {loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div> <div className="text-center py-12 text-slate-400">Loading...</div>
) : entries.length === 0 ? ( ) : events.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-slate-400">No entries for this day</p> <p className="text-slate-400">No events for this day</p>
</div> </div>
) : ( ) : (
<EntryList entries={entries} onDelete={handleDeleteEntry} readOnly={hasJournal} /> <EntryList events={events} onDelete={handleDeleteEvent} readOnly={hasJournal} />
)} )}
</div> </div>
); );

View File

@@ -3,7 +3,7 @@ import { api } from '../lib/api';
interface DayInfo { interface DayInfo {
date: string; date: string;
entryCount: number; eventCount: number;
hasJournal: boolean; hasJournal: boolean;
} }
@@ -30,7 +30,7 @@ export default function History() {
}; };
const handleDelete = async (date: string) => { const handleDelete = async (date: string) => {
if (confirm('Delete all entries for this day?')) { if (confirm('Delete all events for this day?')) {
await api.deleteDay(date); await api.deleteDay(date);
loadDays(); loadDays();
} }
@@ -59,11 +59,11 @@ export default function History() {
{formatDate(day.date)} {formatDate(day.date)}
</a> </a>
<span className="text-slate-400 text-sm"> <span className="text-slate-400 text-sm">
{day.entryCount} {day.entryCount === 1 ? 'entry' : 'entries'} {day.eventCount} {day.eventCount === 1 ? 'event' : 'events'}
</span> </span>
{day.hasJournal && ( {day.hasJournal && (
<a href={`/journal/${day.date}`} className="text-purple-400 text-sm hover:text-purple-300"> <a href={`/journal/${day.date}`} className="text-purple-400 text-sm hover:text-purple-300">
Journal Diary
</a> </a>
)} )}
</div> </div>

View File

@@ -1,47 +1,56 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api, Entry } from '../lib/api'; import { useNavigate } from 'react-router-dom';
import { api, Event } from '../lib/api';
import EntryInput from '../components/EntryInput'; import EntryInput from '../components/EntryInput';
import EntryList from '../components/EntryList'; import EntryList from '../components/EntryList';
export default function Home() { export default function Home() {
const [entries, setEntries] = useState<Entry[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
useEffect(() => { useEffect(() => {
loadEntries(); loadEvents();
}, []); }, []);
const loadEntries = async () => { const loadEvents = async () => {
setLoading(true); setLoading(true);
const res = await api.getDay(today); const res = await api.getDay(today);
if (res.data) { if (res.data) {
setEntries(res.data.entries); setEvents(res.data.events);
} }
setLoading(false); setLoading(false);
}; };
const handleAddEntry = async (type: string, content: string, metadata?: object) => { const handleAddEvent = async (type: string, content: string, metadata?: object) => {
const res = await api.createEntry(today, type, content, metadata); const res = await api.createEvent(today, type, content, metadata);
if (res.data) { if (res.data) {
setEntries((prev) => [...prev, res.data!]); setEvents((prev) => [...prev, res.data!]);
} }
return res; return res;
}; };
const handleDeleteEntry = async (id: string) => { const handleDeleteEvent = async (id: string) => {
const res = await api.deleteEntry(id); const res = await api.deleteEvent(id);
if (res.data) { if (res.data) {
setEntries((prev) => prev.filter((e) => e.id !== id)); setEvents((prev) => prev.filter((e) => e.id !== id));
} }
}; };
const handleGenerateJournal = async () => { const handleGenerateJournal = async () => {
setGenerating(true); setGenerating(true);
await api.generateJournal(today); setError(null);
const res = await api.generateJournal(today);
setGenerating(false); setGenerating(false);
if (res.error) {
setError(res.error.message);
} else {
navigate(`/journal/${today}`);
}
}; };
return ( return (
@@ -53,30 +62,36 @@ export default function Home() {
</div> </div>
<button <button
onClick={handleGenerateJournal} onClick={handleGenerateJournal}
disabled={generating || entries.length === 0} 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" className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
> >
{generating ? 'Generating...' : 'Generate Journal'} {generating ? 'Generating...' : 'Generate Diary Page'}
</button> </button>
</div> </div>
<EntryInput onSubmit={handleAddEntry} /> {error && (
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400">
{error}
</div>
)}
<EntryInput onSubmit={handleAddEvent} />
{loading ? ( {loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div> <div className="text-center py-12 text-slate-400">Loading...</div>
) : entries.length === 0 ? ( ) : events.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-slate-400 mb-2">No entries yet today</p> <p className="text-slate-400 mb-2">No events yet today</p>
<p className="text-slate-500 text-sm">Start capturing your day above</p> <p className="text-slate-500 text-sm">Start capturing your day above</p>
</div> </div>
) : ( ) : (
<EntryList entries={entries} onDelete={handleDeleteEntry} /> <EntryList events={events} onDelete={handleDeleteEvent} />
)} )}
{entries.length > 0 && ( {events.length > 0 && (
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<a href={`/journal/${today}`} className="text-purple-400 hover:text-purple-300 text-sm"> <a href={`/journal/${today}`} className="text-purple-400 hover:text-purple-300 text-sm">
View journal View diary page
</a> </a>
</div> </div>
)} )}

View File

@@ -197,7 +197,7 @@ export default function JournalPage() {
<div className="max-w-4xl mx-auto p-4"> <div className="max-w-4xl mx-auto p-4">
<div className="mb-6"> <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> <a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Back to day</a>
<h1 className="text-2xl font-bold">Journal</h1> <h1 className="text-2xl font-bold">Diary Page</h1>
<p className="text-slate-400">{formatDate(date)}</p> <p className="text-slate-400">{formatDate(date)}</p>
</div> </div>
@@ -205,19 +205,19 @@ export default function JournalPage() {
<div className="text-center py-12 text-slate-400">Loading...</div> <div className="text-center py-12 text-slate-400">Loading...</div>
) : !journal || journal.content === 'Generating...' ? ( ) : !journal || journal.content === 'Generating...' ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-slate-400 mb-4">No journal generated yet</p> <p className="text-slate-400 mb-4">No diary page written yet</p>
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={generating} disabled={generating}
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50" className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
> >
Generate Journal Generate Diary Page
</button> </button>
</div> </div>
) : ( ) : (
<div> <div>
<div className="text-sm text-slate-400 mb-4"> <div className="text-sm text-slate-400 mb-4">
Generated {new Date(journal.generatedAt).toLocaleString()} {journal.entryCount} entries Generated {new Date(journal.generatedAt).toLocaleString()} {journal.eventCount} events
</div> </div>
<div className="prose prose-invert prose-slate max-w-none"> <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"> <div className="bg-slate-900 rounded-xl p-6 border border-slate-800 whitespace-pre-wrap leading-relaxed">
@@ -230,7 +230,7 @@ export default function JournalPage() {
disabled={generating} disabled={generating}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50" className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50"
> >
Regenerate Rewrite
</button> </button>
<a <a
href={`/tasks/${date}`} href={`/tasks/${date}`}

View File

@@ -28,7 +28,7 @@ const DEFAULT_BASE_URLS: Record<string, string> = {
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState<FullSettings>({ const [settings, setSettings] = useState<FullSettings>({
aiProvider: 'groq', aiProvider: 'groq',
aiModel: 'llama-3.3-70b-versatile', aiModel: '',
journalPrompt: '', journalPrompt: '',
language: 'en', language: 'en',
timezone: 'UTC', timezone: 'UTC',
@@ -60,6 +60,11 @@ export default function SettingsPage() {
if (!data.providerSettings) { if (!data.providerSettings) {
data.providerSettings = {}; data.providerSettings = {};
} }
const provider = data.aiProvider || 'groq';
const providerData = data.providerSettings[provider] || {};
data.aiApiKey = providerData.apiKey;
data.aiModel = providerData.model || '';
data.aiBaseUrl = providerData.baseUrl;
setSettings(data); setSettings(data);
} }
setLoading(false); setLoading(false);
@@ -67,22 +72,31 @@ export default function SettingsPage() {
const getCurrentProviderSettings = (): ProviderSettings => { const getCurrentProviderSettings = (): ProviderSettings => {
const provider = settings.aiProvider || 'groq'; const provider = settings.aiProvider || 'groq';
return settings.providerSettings?.[provider] || { const providerSettings = settings.providerSettings?.[provider];
apiKey: settings.aiApiKey, return {
model: settings.aiModel, apiKey: providerSettings?.apiKey || settings.aiApiKey,
baseUrl: settings.aiBaseUrl, model: providerSettings?.model || settings.aiModel,
baseUrl: providerSettings?.baseUrl || settings.aiBaseUrl,
}; };
}; };
const updateProviderSettings = (updates: Partial<ProviderSettings>) => { const updateProviderSettings = (updates: Partial<ProviderSettings>) => {
const provider = settings.aiProvider || 'groq'; const provider = settings.aiProvider || 'groq';
const current = getCurrentProviderSettings(); const current = getCurrentProviderSettings();
const merged = { ...current, ...updates };
const newProviderSettings = { const newProviderSettings = {
...settings.providerSettings, ...settings.providerSettings,
[provider]: { ...current, ...updates }, [provider]: {
apiKey: merged.apiKey,
model: merged.model || undefined,
baseUrl: merged.baseUrl || undefined,
},
}; };
setSettings({ setSettings({
...settings, ...settings,
aiApiKey: merged.apiKey || settings.aiApiKey,
aiModel: merged.model || settings.aiModel,
aiBaseUrl: merged.baseUrl || settings.aiBaseUrl,
providerSettings: newProviderSettings, providerSettings: newProviderSettings,
aiProvider: provider, aiProvider: provider,
}); });
@@ -122,7 +136,7 @@ export default function SettingsPage() {
}; };
await api.updateSettings({ await api.updateSettings({
...settings, aiProvider: settings.aiProvider,
providerSettings: newProviderSettings, providerSettings: newProviderSettings,
}); });
setSaving(false); setSaving(false);

View File

@@ -56,7 +56,7 @@ export default function TasksPage() {
onClick={() => navigate(`/journal/${date}`)} onClick={() => navigate(`/journal/${date}`)}
className="text-slate-400 hover:text-white text-sm mb-1" className="text-slate-400 hover:text-white text-sm mb-1"
> >
Back to journal Back to diary
</button> </button>
<h1 className="text-2xl font-bold">Generation Tasks</h1> <h1 className="text-2xl font-bold">Generation Tasks</h1>
<p className="text-slate-400">{formatDate(date)}</p> <p className="text-slate-400">{formatDate(date)}</p>
@@ -71,7 +71,7 @@ export default function TasksPage() {
onClick={() => navigate(`/journal/${date}`)} onClick={() => navigate(`/journal/${date}`)}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm" className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm"
> >
Generate Journal Generate Diary Page
</button> </button>
</div> </div>
) : ( ) : (

View File

@@ -1,40 +1,9 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Check if database exists and has data # Push schema to database on startup
if [ -f /data/deardiary.db ]; then echo "Setting up database..."
# Database exists - ensure migration tracking exists bunx prisma db push --skip-generate
bun -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({ datasourceUrl: 'file:/data/deardiary.db' });
async function main() {
try {
const tables = await prisma.\$queryRaw\`SELECT name FROM sqlite_master WHERE type='table' AND name='_prisma_migrations'\`;
if (tables.length === 0) {
await prisma.\$executeRaw\`
CREATE TABLE _prisma_migrations (
id TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
finished_at DATETIME,
migration_name TEXT NOT NULL,
logs TEXT,
rolled_back_at DATETIME,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
applied_steps_count INTEGER NOT NULL DEFAULT 0
)
\`;
await prisma.\$executeRaw\`INSERT INTO _prisma_migrations (id, checksum, finished_at, migration_name, applied_steps_count) VALUES (lower(hex(randomblob(16))), 'baseline', datetime('now'), '00000000000000_init', 1)\`;
console.log('Migration table created');
}
} catch (e) {
console.log('Migration check done');
}
await prisma.\$disconnect();
}
main();
"
fi
echo "Starting server..." echo "Starting server..."
nginx -g 'daemon off;' & nginx -g 'daemon off;' &