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,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 });
}
}