Initial commit: Quiet Thanks gratitude app

A calm, private gratitude and mood log built with Next.js 16, TypeScript,
Tailwind CSS, and SQLite/Drizzle ORM.

Features:
- Quick check-in with autosave (800ms debounce)
- Optional mood selector (5 levels) with accessibility labels
- Optional tags with tap-to-add from recent
- Timeline with weekly reflection card
- Filters by mood, tag, and rough day
- Export to Markdown and JSON
- Dark mode default
- Delete with undo toast
- Docker deployment ready

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-24 01:57:20 +00:00
commit 5555c1e6b5
43 changed files with 11337 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
import type { UpdateEntryRequest, EntryWithTags } from "@/lib/types";
import { v4 as uuidv4 } from "uuid";
interface RouteParams {
params: Promise<{ id: string }>;
}
// GET /api/entries/[id] - Get single entry
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { id } = await params;
try {
const entry = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.id, id))
.limit(1);
if (entry.length === 0) {
return NextResponse.json({ error: "Entry not found" }, { status: 404 });
}
const entryTagRows = await db
.select({ tag: schema.tags })
.from(schema.entryTags)
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
.where(eq(schema.entryTags.entryId, id));
const entryWithTags: EntryWithTags = {
...entry[0],
tags: entryTagRows.map((row) => row.tag),
};
return NextResponse.json(entryWithTags);
} catch (error) {
console.error("Failed to fetch entry:", error);
return NextResponse.json({ error: "Failed to fetch entry" }, { status: 500 });
}
}
// PATCH /api/entries/[id] - Update entry
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { id } = await params;
try {
const body: UpdateEntryRequest = await request.json();
const { text, mood, roughDay, tagNames } = body;
const existing = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.id, id))
.limit(1);
if (existing.length === 0) {
return NextResponse.json({ error: "Entry not found" }, { status: 404 });
}
const now = Date.now();
// Update entry fields
const updates: Partial<typeof schema.entries.$inferInsert> = {
updatedAt: now,
};
if (text !== undefined) updates.text = text;
if (mood !== undefined) updates.mood = mood;
if (roughDay !== undefined) updates.roughDay = roughDay ? 1 : 0;
await db.update(schema.entries).set(updates).where(eq(schema.entries.id, id));
// Update tags if provided
if (tagNames !== undefined) {
await db.delete(schema.entryTags).where(eq(schema.entryTags.entryId, id));
for (const name of tagNames) {
const normalizedName = name.toLowerCase().trim();
if (!normalizedName) continue;
let tag = await db
.select()
.from(schema.tags)
.where(eq(schema.tags.name, normalizedName))
.limit(1);
let tagId: string;
if (tag.length === 0) {
tagId = uuidv4();
await db.insert(schema.tags).values({
id: tagId,
name: normalizedName,
createdAt: now,
});
} else {
tagId = tag[0].id;
}
await db
.insert(schema.entryTags)
.values({ entryId: id, tagId })
.onConflictDoNothing();
}
}
// Return updated entry
const entry = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.id, id))
.limit(1);
const entryTagRows = await db
.select({ tag: schema.tags })
.from(schema.entryTags)
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
.where(eq(schema.entryTags.entryId, id));
const entryWithTags: EntryWithTags = {
...entry[0],
tags: entryTagRows.map((row) => row.tag),
};
return NextResponse.json(entryWithTags);
} catch (error) {
console.error("Failed to update entry:", error);
return NextResponse.json({ error: "Failed to update entry" }, { status: 500 });
}
}
// DELETE /api/entries/[id] - Delete entry
export async function DELETE(_request: NextRequest, { params }: RouteParams) {
const { id } = await params;
try {
const existing = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.id, id))
.limit(1);
if (existing.length === 0) {
return NextResponse.json({ error: "Entry not found" }, { status: 404 });
}
// Delete entry (cascades to entry_tags)
await db.delete(schema.entries).where(eq(schema.entries.id, id));
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to delete entry:", error);
return NextResponse.json({ error: "Failed to delete entry" }, { status: 500 });
}
}

View File

@@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { eq, desc, and, inArray, gte } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import type { CreateEntryRequest, EntryWithTags } from "@/lib/types";
// GET /api/entries - List entries with optional filters
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const moods = searchParams.get("moods")?.split(",").map(Number).filter(Boolean);
const tagId = searchParams.get("tagId");
const roughDay = searchParams.get("roughDay");
const since = searchParams.get("since"); // YYYY-MM-DD
try {
// Build conditions
const conditions = [];
if (moods && moods.length > 0) {
conditions.push(inArray(schema.entries.mood, moods));
}
if (roughDay === "true") {
conditions.push(eq(schema.entries.roughDay, 1));
} else if (roughDay === "false") {
conditions.push(eq(schema.entries.roughDay, 0));
}
if (since) {
conditions.push(gte(schema.entries.date, since));
}
// Get entries
const entries = await db
.select()
.from(schema.entries)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(schema.entries.date), desc(schema.entries.createdAt));
// Get tags for each entry
const entriesWithTags: EntryWithTags[] = [];
for (const entry of entries) {
const entryTagRows = await db
.select({ tag: schema.tags })
.from(schema.entryTags)
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
.where(eq(schema.entryTags.entryId, entry.id));
const tags = entryTagRows.map((row) => row.tag);
// Filter by tagId if specified
if (tagId && !tags.some((t) => t.id === tagId)) {
continue;
}
entriesWithTags.push({ ...entry, tags });
}
return NextResponse.json(entriesWithTags);
} catch (error) {
console.error("Failed to fetch entries:", error);
return NextResponse.json({ error: "Failed to fetch entries" }, { status: 500 });
}
}
// POST /api/entries - Create or update today's entry
export async function POST(request: NextRequest) {
try {
const body: CreateEntryRequest = await request.json();
const { date, text, mood, roughDay, tagNames } = body;
if (!date || !text) {
return NextResponse.json({ error: "Date and text are required" }, { status: 400 });
}
const now = Date.now();
// Check if entry exists for this date
const existing = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.date, date))
.limit(1);
let entryId: string;
if (existing.length > 0) {
// Update existing entry
entryId = existing[0].id;
await db
.update(schema.entries)
.set({
text,
mood: mood ?? null,
roughDay: roughDay ? 1 : 0,
updatedAt: now,
})
.where(eq(schema.entries.id, entryId));
} else {
// Create new entry
entryId = uuidv4();
await db.insert(schema.entries).values({
id: entryId,
date,
text,
mood: mood ?? null,
roughDay: roughDay ? 1 : 0,
createdAt: now,
updatedAt: now,
});
}
// Update tags
// Remove existing tag associations
await db.delete(schema.entryTags).where(eq(schema.entryTags.entryId, entryId));
// Add new tags
if (tagNames && tagNames.length > 0) {
for (const name of tagNames) {
const normalizedName = name.toLowerCase().trim();
if (!normalizedName) continue;
// Find or create tag
let tag = await db
.select()
.from(schema.tags)
.where(eq(schema.tags.name, normalizedName))
.limit(1);
let tagId: string;
if (tag.length === 0) {
tagId = uuidv4();
await db.insert(schema.tags).values({
id: tagId,
name: normalizedName,
createdAt: now,
});
} else {
tagId = tag[0].id;
}
// Create association
await db
.insert(schema.entryTags)
.values({ entryId, tagId })
.onConflictDoNothing();
}
}
// Fetch and return the updated entry with tags
const entry = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.id, entryId))
.limit(1);
const entryTagRows = await db
.select({ tag: schema.tags })
.from(schema.entryTags)
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
.where(eq(schema.entryTags.entryId, entryId));
const entryWithTags: EntryWithTags = {
...entry[0],
tags: entryTagRows.map((row) => row.tag),
};
return NextResponse.json(entryWithTags);
} catch (error) {
console.error("Failed to create/update entry:", error);
return NextResponse.json({ error: "Failed to save entry" }, { status: 500 });
}
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { eq, desc } from "drizzle-orm";
import type { EntryWithTags, ExportFormat } from "@/lib/types";
import { MOOD_LABELS, APP_NAME } from "@/lib/constants";
import { formatDateLong } from "@/lib/utils/date";
// POST /api/export - Export all entries
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const format: ExportFormat = body.format || "markdown";
// Fetch all entries with tags
const entries = await db
.select()
.from(schema.entries)
.orderBy(desc(schema.entries.date));
const entriesWithTags: EntryWithTags[] = [];
for (const entry of entries) {
const entryTagRows = await db
.select({ tag: schema.tags })
.from(schema.entryTags)
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
.where(eq(schema.entryTags.entryId, entry.id));
entriesWithTags.push({
...entry,
tags: entryTagRows.map((row) => row.tag),
});
}
if (format === "json") {
const jsonContent = JSON.stringify(
{
exportedAt: new Date().toISOString(),
appName: APP_NAME,
entries: entriesWithTags,
},
null,
2
);
return new NextResponse(jsonContent, {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${APP_NAME.toLowerCase().replace(/\s+/g, "-")}-export-${new Date().toISOString().slice(0, 10)}.json"`,
},
});
}
// Markdown export
let markdown = `# ${APP_NAME} Export\n\n`;
markdown += `Exported on ${new Date().toLocaleDateString("en-US", { dateStyle: "long" })}\n\n`;
markdown += `---\n\n`;
// Group by date
let currentDate = "";
for (const entry of entriesWithTags) {
if (entry.date !== currentDate) {
currentDate = entry.date;
markdown += `## ${formatDateLong(entry.date)}\n\n`;
}
markdown += entry.text + "\n\n";
const meta: string[] = [];
if (entry.mood) {
meta.push(`Mood: ${MOOD_LABELS[entry.mood]} (${entry.mood}/5)`);
}
if (entry.roughDay) {
meta.push("Rough day");
}
if (entry.tags.length > 0) {
meta.push(`Tags: ${entry.tags.map((t) => t.name).join(", ")}`);
}
if (meta.length > 0) {
markdown += `*${meta.join(" | ")}*\n\n`;
}
markdown += "---\n\n";
}
return new NextResponse(markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Content-Disposition": `attachment; filename="${APP_NAME.toLowerCase().replace(/\s+/g, "-")}-export-${new Date().toISOString().slice(0, 10)}.md"`,
},
});
} catch (error) {
console.error("Failed to export:", error);
return NextResponse.json({ error: "Failed to export" }, { status: 500 });
}
}

42
src/app/api/tags/route.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { desc, like, sql } from "drizzle-orm";
// GET /api/tags - Get tags (recent or search)
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const search = searchParams.get("search");
const limit = parseInt(searchParams.get("limit") || "10", 10);
try {
if (search) {
// Search tags by name
const tags = await db
.select()
.from(schema.tags)
.where(like(schema.tags.name, `%${search.toLowerCase()}%`))
.limit(limit);
return NextResponse.json(tags);
}
// Get most recently used tags (by entry count)
const tagsWithCount = await db
.select({
id: schema.tags.id,
name: schema.tags.name,
createdAt: schema.tags.createdAt,
useCount: sql<number>`count(${schema.entryTags.entryId})`.as("use_count"),
})
.from(schema.tags)
.leftJoin(schema.entryTags, sql`${schema.tags.id} = ${schema.entryTags.tagId}`)
.groupBy(schema.tags.id)
.orderBy(desc(sql`use_count`), desc(schema.tags.createdAt))
.limit(limit);
return NextResponse.json(tagsWithCount);
} catch (error) {
console.error("Failed to fetch tags:", error);
return NextResponse.json({ error: "Failed to fetch tags" }, { status: 500 });
}
}

138
src/app/entry/[id]/page.tsx Normal file
View File

@@ -0,0 +1,138 @@
"use client";
import { use, useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { EntryForm } from "@/components/EntryForm";
import { ToastContainer } from "@/components/Toast";
import { useToast } from "@/hooks/useToast";
import { ArrowLeft, Trash2 } from "lucide-react";
import Link from "next/link";
import type { EntryWithTags } from "@/lib/types";
import { formatDateLong } from "@/lib/utils/date";
import { APP_NAME } from "@/lib/constants";
interface EntryPageProps {
params: Promise<{ id: string }>;
}
export default function EntryPage({ params }: EntryPageProps) {
const { id } = use(params);
const router = useRouter();
const { toasts, showToast, dismissToast } = useToast();
const [entry, setEntry] = useState<EntryWithTags | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
async function loadEntry() {
try {
const res = await fetch(`/api/entries/${id}`);
if (res.ok) {
setEntry(await res.json());
}
} catch (error) {
console.error("Failed to load entry:", error);
}
}
loadEntry();
}, [id]);
const handleDelete = useCallback(async () => {
if (!entry) return;
setIsDeleting(true);
setShowDeleteConfirm(false);
try {
const res = await fetch(`/api/entries/${id}`, { method: "DELETE" });
if (res.ok) {
// Store for undo
const deletedEntry = entry;
showToast("Entry deleted", {
label: "Undo",
onClick: async () => {
// Re-create the entry
await fetch("/api/entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date: deletedEntry.date,
text: deletedEntry.text,
mood: deletedEntry.mood,
roughDay: Boolean(deletedEntry.roughDay),
tagNames: deletedEntry.tags.map((t) => t.name),
}),
});
router.push("/timeline");
},
});
router.push("/timeline");
}
} catch (error) {
console.error("Failed to delete entry:", error);
} finally {
setIsDeleting(false);
}
}, [entry, id, router, showToast]);
return (
<div>
<header className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Link
href="/timeline"
className="p-2 -ml-2 text-muted hover:text-foreground"
>
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-xl font-light">{APP_NAME}</h1>
{entry && (
<p className="text-sm text-muted">{formatDateLong(entry.date)}</p>
)}
</div>
</div>
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
className="p-2 text-muted hover:text-red-400 transition-colors"
aria-label="Delete entry"
>
<Trash2 size={20} />
</button>
</header>
<EntryForm entryId={id} />
{/* Delete confirmation */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-surface border border-border rounded-xl p-6 max-w-sm w-full">
<h2 className="text-lg font-medium mb-2">Delete entry?</h2>
<p className="text-sm text-muted mb-6">
This action can be undone for a few seconds after deletion.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm text-muted hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Delete
</button>
</div>
</div>
</div>
)}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
</div>
);
}

102
src/app/export/page.tsx Normal file
View File

@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { Download, FileJson, FileText, Loader2 } from "lucide-react";
import { APP_NAME } from "@/lib/constants";
export default function ExportPage() {
const [isExporting, setIsExporting] = useState(false);
const handleExport = async (format: "markdown" | "json") => {
setIsExporting(true);
try {
const res = await fetch("/api/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format }),
});
if (res.ok) {
const blob = await res.blob();
const contentDisposition = res.headers.get("Content-Disposition");
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
const filename =
filenameMatch?.[1] ||
`quietthanks-export.${format === "json" ? "json" : "md"}`;
// Trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (error) {
console.error("Failed to export:", error);
} finally {
setIsExporting(false);
}
};
return (
<div>
<header className="mb-8">
<h1 className="text-2xl font-light">{APP_NAME}</h1>
<p className="text-sm text-muted">Export</p>
</header>
<div className="space-y-4">
<p className="text-muted">
Download all your entries for backup or use elsewhere.
</p>
<div className="grid gap-4">
<button
onClick={() => handleExport("markdown")}
disabled={isExporting}
className="flex items-center gap-4 p-4 bg-surface border border-border rounded-xl hover:border-muted transition-colors text-left"
>
<div className="p-3 bg-background rounded-lg">
<FileText size={24} className="text-accent" />
</div>
<div className="flex-1">
<h3 className="font-medium">Markdown</h3>
<p className="text-sm text-muted">
Readable format, grouped by date
</p>
</div>
{isExporting ? (
<Loader2 className="animate-spin text-muted" size={20} />
) : (
<Download size={20} className="text-muted" />
)}
</button>
<button
onClick={() => handleExport("json")}
disabled={isExporting}
className="flex items-center gap-4 p-4 bg-surface border border-border rounded-xl hover:border-muted transition-colors text-left"
>
<div className="p-3 bg-background rounded-lg">
<FileJson size={24} className="text-accent" />
</div>
<div className="flex-1">
<h3 className="font-medium">JSON</h3>
<p className="text-sm text-muted">
Full data with timestamps
</p>
</div>
{isExporting ? (
<Loader2 className="animate-spin text-muted" size={20} />
) : (
<Download size={20} className="text-muted" />
)}
</button>
</div>
</div>
</div>
);
}

95
src/app/globals.css Normal file
View File

@@ -0,0 +1,95 @@
@import "tailwindcss";
:root {
--background: #0a0a0a;
--foreground: #e5e5e5;
--muted: #737373;
--border: #262626;
--accent: #6366f1;
--surface: #141414;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-border: var(--border);
--color-accent: var(--accent);
--color-surface: var(--surface);
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
}
/* Safe area for mobile */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* Toast animation */
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
/* Custom checkbox */
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border);
border-radius: 0.25rem;
background: var(--surface);
cursor: pointer;
position: relative;
}
input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 5px;
top: 2px;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}

40
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,40 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Navigation } from "@/components/Navigation";
import { APP_NAME } from "@/lib/constants";
export const metadata: Metadata = {
title: APP_NAME,
description: "A calm, private gratitude and mood log",
icons: {
icon: "/icons/icon.svg",
apple: "/icons/icon.svg",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: "#0a0a0a",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased min-h-screen pb-20">
<main className="max-w-lg mx-auto px-4 py-6">{children}</main>
<Navigation />
</body>
</html>
);
}

30
src/app/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { EntryForm } from "@/components/EntryForm";
import { APP_NAME } from "@/lib/constants";
import { formatDate, getLocalDate } from "@/lib/utils/date";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
export default function HomePage() {
const today = getLocalDate();
return (
<div>
<header className="mb-8">
<h1 className="text-2xl font-light mb-1">{APP_NAME}</h1>
<p className="text-sm text-muted">{formatDate(today)}</p>
</header>
<EntryForm />
<div className="mt-8 pt-6 border-t border-border">
<Link
href="/timeline"
className="flex items-center justify-between text-muted hover:text-foreground transition-colors"
>
<span>View timeline</span>
<ChevronRight size={20} />
</Link>
</div>
</div>
);
}

73
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,73 @@
import { APP_NAME } from "@/lib/constants";
export default function SettingsPage() {
return (
<div>
<header className="mb-8">
<h1 className="text-2xl font-light">{APP_NAME}</h1>
<p className="text-sm text-muted">Settings</p>
</header>
<div className="space-y-6">
{/* Notifications - disabled in MVP */}
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Daily reminder</h3>
<p className="text-sm text-muted">
Get a gentle nudge to check in
</p>
</div>
<button
disabled
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
>
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
</button>
</div>
</div>
{/* Sync - disabled in MVP */}
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Cloud sync</h3>
<p className="text-sm text-muted">
Sync across devices (coming soon)
</p>
</div>
<button
disabled
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
>
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
</button>
</div>
</div>
{/* LLM Summaries - disabled in MVP */}
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">AI summaries</h3>
<p className="text-sm text-muted">
Monthly reflections with LLM (coming soon)
</p>
</div>
<button
disabled
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
>
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
</button>
</div>
</div>
<div className="pt-6 border-t border-border text-center text-sm text-muted">
<p>{APP_NAME} v0.1.0</p>
<p className="mt-1">A calm, private gratitude log.</p>
</div>
</div>
</div>
);
}

93
src/app/timeline/page.tsx Normal file
View File

@@ -0,0 +1,93 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { WeeklyReflection } from "@/components/WeeklyReflection";
import { FilterPanel } from "@/components/FilterPanel";
import { EntryRow } from "@/components/EntryRow";
import { Loader2 } from "lucide-react";
import type { EntryWithTags, FilterState } from "@/lib/types";
import { APP_NAME } from "@/lib/constants";
export default function TimelinePage() {
const [entries, setEntries] = useState<EntryWithTags[]>([]);
const [filteredEntries, setFilteredEntries] = useState<EntryWithTags[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filters, setFilters] = useState<FilterState>({
moods: [],
tagId: null,
roughDay: null,
});
useEffect(() => {
async function loadEntries() {
try {
const res = await fetch("/api/entries");
if (res.ok) {
setEntries(await res.json());
}
} catch (error) {
console.error("Failed to load entries:", error);
} finally {
setIsLoading(false);
}
}
loadEntries();
}, []);
// Apply filters client-side
useEffect(() => {
let result = entries;
if (filters.moods.length > 0) {
result = result.filter((e) => e.mood && filters.moods.includes(e.mood));
}
if (filters.tagId) {
result = result.filter((e) => e.tags.some((t) => t.id === filters.tagId));
}
if (filters.roughDay === true) {
result = result.filter((e) => e.roughDay);
} else if (filters.roughDay === false) {
result = result.filter((e) => !e.roughDay);
}
setFilteredEntries(result);
}, [entries, filters]);
return (
<div>
<header className="mb-6">
<h1 className="text-2xl font-light">{APP_NAME}</h1>
<p className="text-sm text-muted">Timeline</p>
</header>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-muted" size={24} />
</div>
) : (
<>
<WeeklyReflection entries={entries} />
<FilterPanel filters={filters} onChange={setFilters} />
{filteredEntries.length === 0 ? (
<div className="text-center py-12 text-muted">
{entries.length === 0 ? (
<p>No entries yet. Start your first check-in!</p>
) : (
<p>No entries match your filters.</p>
)}
</div>
) : (
<div className="space-y-3">
{filteredEntries.map((entry) => (
<EntryRow key={entry.id} entry={entry} />
))}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useEntry } from "@/hooks/useEntry";
import { MoodSelector } from "./MoodSelector";
import { TagInput } from "./TagInput";
import { Check, Loader2 } from "lucide-react";
interface EntryFormProps {
date?: string;
entryId?: string;
}
export function EntryForm({ date, entryId }: EntryFormProps) {
const {
text,
mood,
roughDay,
tagNames,
isLoading,
isSaving,
lastSaved,
updateText,
updateMood,
updateRoughDay,
addTag,
removeTag,
} = useEntry({ date, entryId });
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-muted" size={24} />
</div>
);
}
return (
<div className="space-y-6">
{/* Main prompt */}
<div>
<label htmlFor="gratitude-text" className="block text-lg mb-3 text-muted">
What are you grateful for today?
</label>
<textarea
id="gratitude-text"
value={text}
onChange={(e) => updateText(e.target.value)}
placeholder="Take a moment to reflect..."
rows={5}
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-muted/50 focus:outline-none focus:border-muted resize-none"
/>
</div>
{/* Mood */}
<div>
<label className="block text-sm text-muted mb-2">How are you feeling?</label>
<MoodSelector value={mood} onChange={updateMood} />
</div>
{/* Rough day toggle */}
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={roughDay}
onChange={(e) => updateRoughDay(e.target.checked)}
className="w-5 h-5 rounded border-border bg-surface accent-accent"
/>
<span className="text-sm text-muted">Mark as a rough day</span>
</label>
</div>
{/* Tags */}
<div>
<label className="block text-sm text-muted mb-2">Tags (optional)</label>
<TagInput
selectedTags={tagNames}
onAddTag={addTag}
onRemoveTag={removeTag}
/>
</div>
{/* Save status */}
<div className="flex items-center gap-2 text-sm text-muted">
{isSaving ? (
<>
<Loader2 className="animate-spin" size={14} />
Saving...
</>
) : lastSaved ? (
<>
<Check size={14} className="text-green-500" />
Saved
</>
) : text.trim() ? (
<span className="text-muted/50">Auto-saves as you type</span>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { formatDate, isToday } from "@/lib/utils/date";
import { MoodIcon } from "./MoodSelector";
import { TagChips } from "./TagInput";
import type { EntryWithTags } from "@/lib/types";
interface EntryRowProps {
entry: EntryWithTags;
}
export function EntryRow({ entry }: EntryRowProps) {
// Truncate text to one line preview
const preview = entry.text.length > 80 ? entry.text.slice(0, 80) + "..." : entry.text;
return (
<Link
href={`/entry/${entry.id}`}
className="block p-4 bg-surface border border-border rounded-xl hover:border-muted transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm text-muted">
{isToday(entry.date) ? "Today" : formatDate(entry.date)}
</span>
{entry.roughDay ? (
<span className="text-xs px-2 py-0.5 bg-red-500/20 text-red-400 rounded">
rough day
</span>
) : null}
</div>
<p className="text-foreground truncate">{preview}</p>
{entry.tags.length > 0 && (
<div className="mt-2">
<TagChips tags={entry.tags} />
</div>
)}
</div>
{entry.mood && (
<div className="text-xl">
<MoodIcon mood={entry.mood} />
</div>
)}
</div>
</Link>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useState, useEffect } from "react";
import { Filter, X } from "lucide-react";
import type { Tag } from "@/lib/db/schema";
import type { FilterState } from "@/lib/types";
import { MOOD_LABELS } from "@/lib/constants";
interface FilterPanelProps {
filters: FilterState;
onChange: (filters: FilterState) => void;
}
const MOOD_OPTIONS = [1, 2, 3, 4, 5];
export function FilterPanel({ filters, onChange }: FilterPanelProps) {
const [isOpen, setIsOpen] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
async function loadTags() {
try {
const res = await fetch("/api/tags?limit=20");
if (res.ok) {
setTags(await res.json());
}
} catch {
// Ignore
}
}
loadTags();
}, []);
const hasFilters =
filters.moods.length > 0 || filters.tagId !== null || filters.roughDay !== null;
const clearFilters = () => {
onChange({ moods: [], tagId: null, roughDay: null });
};
const toggleMood = (mood: number) => {
const newMoods = filters.moods.includes(mood)
? filters.moods.filter((m) => m !== mood)
: [...filters.moods, mood];
onChange({ ...filters, moods: newMoods });
};
return (
<div className="mb-4">
<div className="flex items-center gap-2">
<button
onClick={() => setIsOpen(!isOpen)}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
isOpen || hasFilters
? "bg-accent/20 text-accent"
: "bg-surface text-muted hover:text-foreground"
}`}
>
<Filter size={16} />
Filters
{hasFilters && (
<span className="bg-accent text-white text-xs px-1.5 rounded-full">
{(filters.moods.length > 0 ? 1 : 0) +
(filters.tagId ? 1 : 0) +
(filters.roughDay !== null ? 1 : 0)}
</span>
)}
</button>
{hasFilters && (
<button
onClick={clearFilters}
className="text-sm text-muted hover:text-foreground"
>
Clear all
</button>
)}
</div>
{isOpen && (
<div className="mt-3 p-4 bg-surface border border-border rounded-xl space-y-4">
{/* Mood filter */}
<div>
<label className="block text-sm font-medium text-muted mb-2">Mood</label>
<div className="flex gap-2">
{MOOD_OPTIONS.map((mood) => (
<button
key={mood}
onClick={() => toggleMood(mood)}
aria-label={MOOD_LABELS[mood]}
aria-pressed={filters.moods.includes(mood)}
className={`text-xl p-2 rounded-lg transition-colors ${
filters.moods.includes(mood)
? "bg-accent/20"
: "opacity-40 hover:opacity-70"
}`}
>
{["😔", "😕", "😐", "🙂", "😊"][mood - 1]}
</button>
))}
</div>
</div>
{/* Tag filter */}
{tags.length > 0 && (
<div>
<label className="block text-sm font-medium text-muted mb-2">Tag</label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<button
key={tag.id}
onClick={() =>
onChange({
...filters,
tagId: filters.tagId === tag.id ? null : tag.id,
})
}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
filters.tagId === tag.id
? "bg-accent/20 text-accent"
: "bg-background border border-border text-muted hover:text-foreground"
}`}
>
{tag.name}
</button>
))}
</div>
</div>
)}
{/* Rough day filter */}
<div>
<label className="block text-sm font-medium text-muted mb-2">
Rough day
</label>
<div className="flex gap-2">
<button
onClick={() =>
onChange({
...filters,
roughDay: filters.roughDay === true ? null : true,
})
}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
filters.roughDay === true
? "bg-accent/20 text-accent"
: "bg-background border border-border text-muted hover:text-foreground"
}`}
>
Yes
</button>
<button
onClick={() =>
onChange({
...filters,
roughDay: filters.roughDay === false ? null : false,
})
}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
filters.roughDay === false
? "bg-accent/20 text-accent"
: "bg-background border border-border text-muted hover:text-foreground"
}`}
>
No
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { MOOD_LABELS } from "@/lib/constants";
const MOOD_ICONS = ["😔", "😕", "😐", "🙂", "😊"];
interface MoodSelectorProps {
value: number | null;
onChange: (mood: number | null) => void;
}
export function MoodSelector({ value, onChange }: MoodSelectorProps) {
return (
<div className="flex items-center gap-2">
{MOOD_ICONS.map((icon, index) => {
const moodValue = index + 1;
const isSelected = value === moodValue;
return (
<button
key={moodValue}
type="button"
onClick={() => onChange(isSelected ? null : moodValue)}
aria-label={MOOD_LABELS[moodValue]}
aria-pressed={isSelected}
className={`text-2xl p-2 rounded-lg transition-all ${
isSelected
? "bg-accent/20 scale-110"
: "opacity-40 hover:opacity-70 hover:bg-surface"
}`}
>
{icon}
</button>
);
})}
</div>
);
}
export function MoodIcon({ mood }: { mood: number }) {
return (
<span aria-label={MOOD_LABELS[mood]} title={MOOD_LABELS[mood]}>
{MOOD_ICONS[mood - 1]}
</span>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, List, Download, Settings } from "lucide-react";
const NAV_ITEMS = [
{ href: "/", icon: Home, label: "Check-in" },
{ href: "/timeline", icon: List, label: "Timeline" },
{ href: "/export", icon: Download, label: "Export" },
{ href: "/settings", icon: Settings, label: "Settings" },
];
export function Navigation() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 bg-background border-t border-border safe-area-bottom">
<div className="flex justify-around max-w-lg mx-auto">
{NAV_ITEMS.map(({ href, icon: Icon, label }) => {
const isActive = pathname === href;
return (
<Link
key={href}
href={href}
className={`flex flex-col items-center gap-1 py-3 px-4 ${
isActive ? "text-accent" : "text-muted hover:text-foreground"
}`}
>
<Icon size={20} />
<span className="text-xs">{label}</span>
</Link>
);
})}
</div>
</nav>
);
}

146
src/components/TagInput.tsx Normal file
View File

@@ -0,0 +1,146 @@
"use client";
import { useState, useEffect } from "react";
import { X, Plus } from "lucide-react";
import type { Tag } from "@/lib/db/schema";
interface TagInputProps {
selectedTags: string[];
onAddTag: (name: string) => void;
onRemoveTag: (name: string) => void;
}
export function TagInput({ selectedTags, onAddTag, onRemoveTag }: TagInputProps) {
const [recentTags, setRecentTags] = useState<Tag[]>([]);
const [inputValue, setInputValue] = useState("");
const [showInput, setShowInput] = useState(false);
useEffect(() => {
async function loadRecentTags() {
try {
const res = await fetch("/api/tags?limit=10");
if (res.ok) {
const tags = await res.json();
setRecentTags(tags);
}
} catch {
// Ignore
}
}
loadRecentTags();
}, []);
const availableTags = recentTags.filter((t) => !selectedTags.includes(t.name));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = inputValue.trim();
if (trimmed) {
onAddTag(trimmed);
setInputValue("");
setShowInput(false);
}
};
return (
<div className="space-y-3">
{/* Selected tags */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-3 py-1 bg-accent/20 text-accent rounded-full text-sm"
>
{tag}
<button
type="button"
onClick={() => onRemoveTag(tag)}
className="hover:bg-accent/30 rounded-full p-0.5"
aria-label={`Remove ${tag}`}
>
<X size={14} />
</button>
</span>
))}
</div>
)}
{/* Recent tags to tap */}
{availableTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{availableTags.slice(0, 6).map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => onAddTag(tag.name)}
className="px-3 py-1 bg-surface border border-border rounded-full text-sm text-muted hover:text-foreground hover:border-muted transition-colors"
>
+ {tag.name}
</button>
))}
</div>
)}
{/* Add custom tag */}
{showInput ? (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Type a tag..."
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
autoFocus
onBlur={() => {
if (!inputValue.trim()) setShowInput(false);
}}
/>
<button
type="submit"
className="px-3 py-2 bg-accent text-white rounded-lg text-sm"
>
Add
</button>
</form>
) : (
<button
type="button"
onClick={() => setShowInput(true)}
className="inline-flex items-center gap-1 px-3 py-1 text-sm text-muted hover:text-foreground transition-colors"
>
<Plus size={14} />
Add tag
</button>
)}
</div>
);
}
interface TagChipsProps {
tags: { id: string; name: string }[];
maxDisplay?: number;
}
export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) {
if (tags.length === 0) return null;
const displayed = tags.slice(0, maxDisplay);
const remaining = tags.length - maxDisplay;
return (
<div className="flex gap-1">
{displayed.map((tag) => (
<span
key={tag.id}
className="px-2 py-0.5 bg-surface text-muted text-xs rounded"
>
{tag.name}
</span>
))}
{remaining > 0 && (
<span className="px-2 py-0.5 text-muted text-xs">+{remaining}</span>
)}
</div>
);
}

41
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,41 @@
"use client";
import { X } from "lucide-react";
import type { Toast as ToastType } from "@/hooks/useToast";
interface ToastContainerProps {
toasts: ToastType[];
onDismiss: (id: string) => void;
}
export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className="flex items-center gap-3 px-4 py-3 bg-surface border border-border rounded-lg shadow-lg animate-slide-up"
>
<span className="text-sm">{toast.message}</span>
{toast.action && (
<button
onClick={toast.action.onClick}
className="text-sm text-accent font-medium hover:underline"
>
{toast.action.label}
</button>
)}
<button
onClick={() => onDismiss(toast.id)}
className="text-muted hover:text-foreground p-1"
aria-label="Dismiss"
>
<X size={16} />
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import type { EntryWithTags } from "@/lib/types";
import { isWithinWeek } from "@/lib/utils/date";
interface WeeklyReflectionProps {
entries: EntryWithTags[];
}
export function WeeklyReflection({ entries }: WeeklyReflectionProps) {
// Filter to last 7 days
const weekEntries = entries.filter((e) => isWithinWeek(e.date));
if (weekEntries.length === 0) {
return null;
}
// Count tag occurrences
const tagCounts = new Map<string, { name: string; count: number }>();
for (const entry of weekEntries) {
for (const tag of entry.tags) {
const existing = tagCounts.get(tag.id);
if (existing) {
existing.count++;
} else {
tagCounts.set(tag.id, { name: tag.name, count: 1 });
}
}
}
// Get top 3 tags
const topTags = Array.from(tagCounts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 3);
// Generate summary
let summary = "";
if (topTags.length > 0) {
const tagNames = topTags.map((t) => capitalize(t.name));
if (tagNames.length === 1) {
summary = `You mentioned: ${tagNames[0]}.`;
} else if (tagNames.length === 2) {
summary = `You mentioned: ${tagNames[0]} and ${tagNames[1]}.`;
} else {
summary = `You mentioned: ${tagNames.slice(0, -1).join(", ")}, and ${tagNames[tagNames.length - 1]}.`;
}
}
return (
<div className="bg-surface border border-border rounded-xl p-4 mb-6">
<h2 className="text-sm font-medium text-muted mb-2">This week</h2>
<div className="flex items-baseline gap-4 mb-2">
<span className="text-2xl font-light">{weekEntries.length}</span>
<span className="text-sm text-muted">
{weekEntries.length === 1 ? "entry" : "entries"}
</span>
</div>
{summary && <p className="text-sm text-muted italic">{summary}</p>}
</div>
);
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}

32
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,32 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
export function useDebounce<T extends (...args: Parameters<T>) => void>(
callback: T,
delay: number
): T {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFn = useCallback(
(...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay]
) as T;
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedFn;
}

191
src/hooks/useEntry.ts Normal file
View File

@@ -0,0 +1,191 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useDebounce } from "./useDebounce";
import type { EntryWithTags, CreateEntryRequest } from "@/lib/types";
import { getLocalDate } from "@/lib/utils/date";
import { AUTOSAVE_DELAY } from "@/lib/constants";
interface UseEntryOptions {
date?: string;
entryId?: string;
}
export function useEntry({ date, entryId }: UseEntryOptions = {}) {
const targetDate = date || getLocalDate();
const [entry, setEntry] = useState<EntryWithTags | null>(null);
const [text, setText] = useState("");
const [mood, setMood] = useState<number | null>(null);
const [roughDay, setRoughDay] = useState(false);
const [tagNames, setTagNames] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const pendingSaveRef = useRef(false);
// Load entry
useEffect(() => {
async function loadEntry() {
setIsLoading(true);
try {
let data: EntryWithTags | null = null;
if (entryId) {
const res = await fetch(`/api/entries/${entryId}`);
if (res.ok) {
data = await res.json();
}
} else {
// Load today's entry by date
const res = await fetch(`/api/entries?since=${targetDate}`);
if (res.ok) {
const entries: EntryWithTags[] = await res.json();
data = entries.find((e) => e.date === targetDate) || null;
}
}
if (data) {
setEntry(data);
setText(data.text);
setMood(data.mood);
setRoughDay(Boolean(data.roughDay));
setTagNames(data.tags.map((t) => t.name));
}
} catch (error) {
console.error("Failed to load entry:", error);
} finally {
setIsLoading(false);
}
}
loadEntry();
}, [targetDate, entryId]);
// Save function
const save = useCallback(async () => {
if (!text.trim()) return;
setIsSaving(true);
pendingSaveRef.current = false;
try {
const body: CreateEntryRequest = {
date: entry?.date || targetDate,
text: text.trim(),
mood,
roughDay,
tagNames,
};
const res = await fetch("/api/entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
const saved: EntryWithTags = await res.json();
setEntry(saved);
setLastSaved(new Date());
}
} catch (error) {
console.error("Failed to save entry:", error);
} finally {
setIsSaving(false);
}
}, [text, mood, roughDay, tagNames, entry?.date, targetDate]);
// Debounced save
const debouncedSave = useDebounce(() => {
if (pendingSaveRef.current) {
save();
}
}, AUTOSAVE_DELAY);
// Mark pending and trigger debounced save
const markDirty = useCallback(() => {
pendingSaveRef.current = true;
debouncedSave();
}, [debouncedSave]);
// Update handlers that trigger autosave
const updateText = useCallback(
(value: string) => {
setText(value);
markDirty();
},
[markDirty]
);
const updateMood = useCallback(
(value: number | null) => {
setMood(value);
markDirty();
},
[markDirty]
);
const updateRoughDay = useCallback(
(value: boolean) => {
setRoughDay(value);
markDirty();
},
[markDirty]
);
const addTag = useCallback(
(name: string) => {
const normalized = name.toLowerCase().trim();
if (normalized && !tagNames.includes(normalized)) {
setTagNames((prev) => [...prev, normalized]);
markDirty();
}
},
[tagNames, markDirty]
);
const removeTag = useCallback(
(name: string) => {
setTagNames((prev) => prev.filter((t) => t !== name));
markDirty();
},
[markDirty]
);
// Save on unmount if pending
useEffect(() => {
return () => {
if (pendingSaveRef.current && text.trim()) {
// Fire and forget
fetch("/api/entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date: entry?.date || targetDate,
text: text.trim(),
mood,
roughDay,
tagNames,
}),
}).catch(() => {});
}
};
}, [text, mood, roughDay, tagNames, entry?.date, targetDate]);
return {
entry,
text,
mood,
roughDay,
tagNames,
isLoading,
isSaving,
lastSaved,
updateText,
updateMood,
updateRoughDay,
addTag,
removeTag,
save,
};
}

38
src/hooks/useToast.ts Normal file
View File

@@ -0,0 +1,38 @@
"use client";
import { useState, useCallback } from "react";
export interface Toast {
id: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
export function useToast() {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback(
(message: string, action?: Toast["action"], duration = 5000) => {
const id = Math.random().toString(36).slice(2);
const toast: Toast = { id, message, action };
setToasts((prev) => [...prev, toast]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, duration);
return id;
},
[]
);
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return { toasts, showToast, dismissToast };
}

14
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,14 @@
// App name - change this to customize your instance
export const APP_NAME = "Quiet Thanks";
// Mood labels for accessibility
export const MOOD_LABELS: Record<number, string> = {
1: "Very low",
2: "Low",
3: "Neutral",
4: "Good",
5: "Very good",
};
// Autosave debounce delay in ms
export const AUTOSAVE_DELAY = 800;

14
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,14 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema";
import path from "path";
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), "data", "quietthanks.db");
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("busy_timeout = 5000");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export { schema };

27
src/lib/db/migrate.ts Normal file
View File

@@ -0,0 +1,27 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import path from "path";
import fs from "fs";
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), "data", "quietthanks.db");
// Ensure data directory exists
const dataDir = path.dirname(dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("busy_timeout = 5000");
sqlite.pragma("foreign_keys = ON");
const db = drizzle(sqlite);
console.log("Running migrations...");
migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrations complete!");
// Checkpoint WAL to ensure clean state for build
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.close();

40
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,40 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
export const entries = sqliteTable("entries", {
id: text("id").primaryKey(),
date: text("date").notNull(), // YYYY-MM-DD local date
text: text("text").notNull(),
mood: integer("mood"), // 1-5, null if not set
roughDay: integer("rough_day").notNull().default(0),
createdAt: integer("created_at").notNull(), // Unix ms
updatedAt: integer("updated_at").notNull(), // Unix ms
});
export const tags = sqliteTable("tags", {
id: text("id").primaryKey(),
name: text("name").notNull().unique(), // Normalized lowercase
createdAt: integer("created_at").notNull(), // Unix ms
});
export const entryTags = sqliteTable(
"entry_tags",
{
entryId: text("entry_id")
.notNull()
.references(() => entries.id, { onDelete: "cascade" }),
tagId: text("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
},
(table) => [primaryKey({ columns: [table.entryId, table.tagId] })]
);
// Types
export type Entry = typeof entries.$inferSelect;
export type NewEntry = typeof entries.$inferInsert;
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export type EntryTag = typeof entryTags.$inferSelect;
export type NewEntryTag = typeof entryTags.$inferInsert;

39
src/lib/types.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { Entry, Tag } from "./db/schema";
// Entry with tags for API responses
export interface EntryWithTags extends Entry {
tags: Tag[];
}
// API request/response types
export interface CreateEntryRequest {
date: string;
text: string;
mood?: number | null;
roughDay?: boolean;
tagNames?: string[];
}
export interface UpdateEntryRequest {
text?: string;
mood?: number | null;
roughDay?: boolean;
tagNames?: string[];
}
// Filter state
export interface FilterState {
moods: number[];
tagId: string | null;
roughDay: boolean | null;
}
// Weekly reflection data
export interface WeeklyReflection {
entryCount: number;
topTags: Tag[];
summary: string;
}
// Export format
export type ExportFormat = "markdown" | "json";

50
src/lib/utils/date.ts Normal file
View File

@@ -0,0 +1,50 @@
// Get today's date in YYYY-MM-DD format (local time)
export function getLocalDate(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
// Format date for display
export function formatDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
}
// Format date with year
export function formatDateLong(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
// Get date N days ago
export function getDateDaysAgo(days: number): string {
const date = new Date();
date.setDate(date.getDate() - days);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
// Check if date is today
export function isToday(dateStr: string): boolean {
return dateStr === getLocalDate();
}
// Check if date is within last 7 days
export function isWithinWeek(dateStr: string): boolean {
const weekAgo = getDateDaysAgo(7);
return dateStr >= weekAgo;
}