initial 2
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-26 09:26:50 +01:00
parent 194330fb47
commit 3b37368e7d
213 changed files with 14688 additions and 1 deletions

Submodule apps/web deleted from 1916d348b0

41
apps/web/.gitignore vendored Executable file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
apps/web/AGENTS.md Executable file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
apps/web/CLAUDE.md Executable file
View File

@@ -0,0 +1 @@
@AGENTS.md

39
apps/web/Dockerfile Executable file
View File

@@ -0,0 +1,39 @@
FROM node:22-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/db/package.json ./packages/db/
COPY packages/shared/package.json ./packages/shared/
RUN npm ci && \
for pkg in \
lightningcss-linux-x64-gnu \
lightningcss-linux-x64-musl \
@tailwindcss/oxide-linux-x64-gnu \
@tailwindcss/oxide-linux-x64-musl; do \
if [ -d "node_modules/$pkg" ]; then \
mkdir -p "apps/web/node_modules/$(dirname "$pkg")"; \
cp -a "node_modules/$pkg" "apps/web/node_modules/$pkg"; \
fi; \
done
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY . .
RUN npm run build -w @adventureos/web
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/web/server.js"]

36
apps/web/README.md Executable file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

66
apps/web/docs/ARCHITECTURE.md Executable file
View File

@@ -0,0 +1,66 @@
# AdventureOS Architecture
AdventureOS is a Next.js 16 full-stack monolith with npm workspaces.
## Layers
```
Browser (React + TanStack Query + Zustand)
↓ fetch /api/*
Next.js Route Handlers (app/api/**)
↓ validation (lib/validation) + handleApi (lib/api)
Domain Services (lib/services/**)
Repositories (lib/repositories/**) — Drizzle queries
Packages: @adventureos/db, @adventureos/shared
```
## Frontend boundaries
| Path | Responsibility |
|------|----------------|
| `app/*/page.tsx` | Route pages, data fetching via React Query |
| `components/features/` | Domain UI components |
| `components/ui/` | Reusable retro UI primitives |
| `features/*/api.ts` | Typed client fetch wrappers |
| `hooks/` | Shared React hooks (mutations, toasts) |
| `stores/ui.ts` | Ephemeral UI state (toasts, modals) |
| `themes/` | CSS token registry and runtime theme switching |
## Backend boundaries
| Path | Responsibility |
|------|----------------|
| `app/api/**/route.ts` | HTTP entry; parse/validate; call services |
| `lib/services/adventure/` | Daily adventures, materialization, scoring, templates |
| `lib/services/reading/` | Shared reading log + action recording |
| `lib/services/ai*.ts` | AI generation, config, memory, chat, context |
| `lib/services/ai-memory.ts` | User-controlled personal AI memories |
| `lib/services/ai-context.ts` | Layered context builder for local models |
| `lib/services/ai-chat.ts` | Mentor chat sessions and messages |
| `lib/services/day-boundary.ts` | Configurable day rollover (after-midnight logging) |
| `lib/repositories/` | Drizzle data access only |
| `lib/config/` | Environment variables and domain constants |
| `lib/errors/` | Typed errors and response mapping |
| `lib/validation/` | Zod request schemas |
## Data stores
- **PostgreSQL** — primary app data via Drizzle (`packages/db`)
- **Calibre metadata.db** — read-only SQLite via `better-sqlite3` when configured
## Service boundaries (rules)
1. Route handlers must not import Drizzle directly (use services/repositories).
2. Repositories contain no business rules.
3. Services orchestrate repositories, shared math, XP, and action events.
4. Client components use `features/*/api.ts`, not raw fetch scattered in UI.
5. `@adventureos/shared` holds pure domain types and formulas — no I/O.
## Known gaps
See [KNOWN-ISSUES.md](./KNOWN-ISSUES.md).
## Refactor history
See [REFACTOR-LOG.md](./REFACTOR-LOG.md).

41
apps/web/docs/KNOWN-ISSUES.md Executable file
View File

@@ -0,0 +1,41 @@
# Known Issues
Issues discovered during refactor audit. Not fixed unless explicitly approved (behaviour preservation).
## Calibre reading undo is broken
Both manual (`reading.ts`) and Calibre (`calibre-reading.ts`) use `actionType: "reading.log_pages"`.
- Manual: `beforeState: { book: { currentPage, status, finishedAt } }`, `entityType: "book"`
- Calibre: `beforeState: { currentPage, status }`, `entityType: "reading_progress"`
`undo.ts` case `"reading.log_pages"` only restores the manual `books` table. Undoing Calibre page logs will fail or corrupt state.
## Cron middleware requires session
`middleware.ts` does not exempt `/api/cron`. External cron with only `x-cron-secret` (per README) receives 401 before the route handler runs.
## Partial undo coverage
- `applyTemplateToDate` records no action event
- Template item create/update not recorded (only delete)
**Fixed:** Reflection saves now record `reflection.save` action events for undo.
## HTTP status codes
`handleApi` maps thrown `"Item not found"` to 500, not 404. Normalising would change API contract.
## Dashboard may ignore Calibre progress
Stats and dashboard reading metrics query manual `books` / `reading_logs`; Calibre `reading_progress` may not feed scores.
## Dead / unused code
- `packages/shared/src/titles.ts` — exported, never imported
- `action_events.inversePatch` — written, never read in undo
- `getCalibreBook()` in calibre.ts — no-op implementation
## Undo UX inconsistency
Toast undo (`overlays.tsx`) reloads the page; settings undo invalidates React Query cache.

74
apps/web/docs/REFACTOR-LOG.md Executable file
View File

@@ -0,0 +1,74 @@
# Refactor Log
Internal refactor of AdventureOS. Behaviour, API contracts, and schema preserved unless explicitly noted.
## Phase 1: Safety baseline
- Removed debug telemetry from `apps/web/src/app/api/library/books/route.ts`
- Extracted pure adventure helpers: `derive-state.ts`, `template-matching.ts`, `checklist-value.ts`
- Added unit tests for `@adventureos/shared` (xp, levels, scores, chapters)
- Added characterization tests for deriveState, template matching, undo Calibre mismatch
- Replaced misnamed `feature-repair.test.ts` with real tests
### Manual smoke checklist
- [ ] Login / logout
- [ ] Command Centre: adventure items, todos, rest day, customize
- [ ] Settings: theme, AI config, templates, action history undo
- [ ] Library: manual books and Calibre (if configured)
- [ ] Cartographer: generate, activate, complete, dismiss
- [ ] Teacher: generate, complete, deep links
- [ ] Undo toast and settings undo
### Commands run each phase
```bash
npm run test
npm run lint
npm run build
npm run db:migrate # when DB available
```
## Phase 2: Configuration and constants
- Centralised env reads in `lib/config/`
- Typed constants for settings keys, action types, theme IDs
## Phase 3: API and data access
- Zod input validation on teacher, templates, settings routes
- Repository layer for settings, teacher, adventure templates
- Typed errors in `lib/errors/`; `handleApi` uses `mapErrorToResponse`
- Teacher route uses `createTeacherLesson` service
## Phase 4: Domain/service cleanup
- Split `adventure.ts` into `lib/services/adventure/{materialization,daily,scoring,templates}.ts`
- Unified Calibre reading log via `lib/services/reading/log-pages.ts`
## Phase 5: Frontend cleanup
- Client API: `features/adventure/api.ts`, `features/undo/api.ts`, `lib/api-client.ts`
- Hooks: `useAdventureMutations`, `useUndoMutation`, `useActionToastHandlers`
- Split `todays-adventure.tsx`; extracted `adventure-item-row`, `checklist-item`
- Consolidated AI prompt editors into `AiPromptEditor`
- Shared UI: `RetroWindow`, `TabToggle`, `LoadingState`, `ErrorState`
- Undo toast uses query invalidation (no full page reload)
## Phase 6: Type safety
- JSONB interfaces in `lib/types/jsonb.ts`
- Teacher types in `@adventureos/shared`
- Removed unused `@radix-ui/*` dependencies
## Phase 7: Tests and CI
- Validation/error unit tests
- `@adventureos/shared` test script and vitest config
- Root `npm run test` runs web + shared
- GitHub Actions CI workflow
## Phase 8: Documentation
- [ARCHITECTURE.md](./ARCHITECTURE.md) — layers and service boundaries
- [KNOWN-ISSUES.md](./KNOWN-ISSUES.md) — documented gaps

18
apps/web/eslint.config.mjs Executable file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

25
apps/web/next.config.ts Executable file
View File

@@ -0,0 +1,25 @@
import type { NextConfig } from "next";
import path from "path";
import withSerwistInit from "@serwist/next";
const withSerwist = withSerwistInit({
swSrc: "src/sw.ts",
swDest: "public/sw.js",
disable: process.env.NODE_ENV === "development",
});
const nextConfig: NextConfig = {
output: "standalone",
outputFileTracingRoot: path.join(__dirname, "../../"),
transpilePackages: ["@adventureos/shared", "@adventureos/db"],
serverExternalPackages: ["postgres"],
webpack: (config) => {
config.resolve.alias = {
...(config.resolve.alias ?? {}),
"@": path.join(__dirname, "src"),
};
return config;
},
};
export default withSerwist(nextConfig);

47
apps/web/package.json Executable file
View File

@@ -0,0 +1,47 @@
{
"name": "@adventureos/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
"test": "vitest run"
},
"dependencies": {
"@adventureos/db": "*",
"@adventureos/shared": "*",
"@serwist/next": "^9.0.12",
"@tanstack/react-query": "^5.67.2",
"better-sqlite3": "^11.9.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.39.3",
"iron-session": "^8.0.4",
"next": "16.2.9",
"postgres": "^3.4.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"serwist": "^9.5.11",
"zod": "^3.25.76",
"zustand": "^5.0.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.6"
},
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.3.1",
"@tailwindcss/oxide-linux-x64-musl": "^4.3.1",
"lightningcss-linux-x64-gnu": "^1.32.0",
"lightningcss-linux-x64-musl": "^1.32.0"
}
}

7
apps/web/postcss.config.mjs Executable file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
apps/web/public/file.svg Executable file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
apps/web/public/globe.svg Executable file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

5
apps/web/public/icons/icon.svg Executable file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" fill="#3A6EA5"/>
<rect x="64" y="64" width="384" height="384" fill="#F0E6D2" stroke="#fff" stroke-width="8"/>
<text x="256" y="280" text-anchor="middle" font-family="Tahoma,sans-serif" font-size="120" font-weight="bold" fill="#2C2416">A</text>
</svg>

After

Width:  |  Height:  |  Size: 376 B

16
apps/web/public/manifest.json Executable file
View File

@@ -0,0 +1,16 @@
{
"name": "AdventureOS",
"short_name": "AdventureOS",
"description": "Your personal command centre for consistency and growth",
"start_url": "/",
"display": "standalone",
"background_color": "#5a8f3e",
"theme_color": "#3A6EA5",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}

1
apps/web/public/next.svg Executable file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

2
apps/web/public/sw.js Executable file

File diff suppressed because one or more lines are too long

1
apps/web/public/vercel.svg Executable file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
apps/web/public/window.svg Executable file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,58 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
export default function AchievementsPage() {
const { data, isLoading } = useQuery({
queryKey: ["achievements"],
queryFn: async () => {
const res = await fetch("/api/achievements");
return res.json();
},
});
const unlockedKeys = new Set(
(data?.unlocked ?? []).map((a: { key: string }) => a.key)
);
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
<h1 className="font-bold text-lg mb-4">Achievement Gallery</h1>
<p className="text-sm text-[var(--warm-grey)] mb-6">
Milestones on your long adventure earned through showing up, not perfection.
</p>
{isLoading ? (
<p>Loading trophies...</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{(data?.all ?? []).map(
(a: { key: string; name: string; description: string; category: string }) => {
const unlocked = unlockedKeys.has(a.key);
return (
<div
key={a.key}
className={`retro-window p-4 ${
unlocked ? "" : "opacity-50 grayscale"
}`}
>
<div className="flex items-start gap-3">
<span className="text-2xl">{unlocked ? "🏆" : "🔒"}</span>
<div>
<h3 className="font-bold">{a.name}</h3>
<p className="text-xs text-[var(--warm-grey)]">{a.description}</p>
<span className="text-xs text-[var(--gold-trim)]">{a.category}</span>
</div>
</div>
</div>
);
}
)}
</div>
)}
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,23 @@
import { handleApi } from "@/lib/api";
import {
getAchievements,
getAllAchievementDefinitions,
checkAchievements,
} from "@/lib/services/achievements";
import { requireUser } from "@/lib/services/user";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const unlocked = await getAchievements(user.id);
const all = getAllAchievementDefinitions();
return { unlocked, all };
});
}
export async function POST() {
return handleApi(async () => {
const { user } = await requireUser();
return checkAchievements(user.id);
});
}

View File

@@ -0,0 +1,16 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { undoAction } from "@/lib/services/undo";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const result = await undoAction(user.id, id);
if (!result.ok) throw new Error(result.error ?? "Undo failed");
return result;
});
}

View File

@@ -0,0 +1,11 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getRecentActions } from "@/lib/services/action-events";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const actions = await getRecentActions(user.id, 30);
return { actions };
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { applyTemplateToDate } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return applyTemplateToDate(user.id, date, body.templateId, body.force === true);
});
}

View File

@@ -0,0 +1,29 @@
import { handleApi } from "@/lib/api";
import { updateAdventureItem, updateDailyItemMeta, deleteCustomDailyItem } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ date: string; id: string }> }
) {
const { date, id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.label !== undefined || body.enabled !== undefined || body.config !== undefined) {
return updateDailyItemMeta(user.id, date, id, body);
}
return updateAdventureItem(user.id, date, id, body);
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ date: string; id: string }> }
) {
const { date, id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return deleteCustomDailyItem(user.id, date, id);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { addCustomDailyItem } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return addCustomDailyItem(user.id, date, body);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { quickLog } from "@/lib/services/quick-log";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return quickLog(user.id, date, body);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { setRestDay } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const result = await setRestDay(user.id, date);
return { ok: true, actionEventId: result.actionEventId };
});
}

View File

@@ -0,0 +1,32 @@
import { handleApi } from "@/lib/api";
import { getDailyAdventure, updateDaySettings } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return getDailyAdventure(user.id, date);
});
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.workHoursTarget !== undefined || body.dayMode !== undefined) {
return updateDaySettings(user.id, date, {
workHoursTarget: body.workHoursTarget,
dayMode: body.dayMode,
});
}
throw new Error("No valid updates");
});
}

View File

@@ -0,0 +1,26 @@
import { handleApi } from "@/lib/api";
import { updateDailyTodo, deleteDailyTodo } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ date: string; id: string }> }
) {
const { date, id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return updateDailyTodo(user.id, date, id, body);
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ date: string; id: string }> }
) {
const { date, id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return deleteDailyTodo(user.id, date, id);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { createDailyTodo } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return createDailyTodo(user.id, date, body.label);
});
}

View File

@@ -0,0 +1,11 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { detectCatchUpGaps } from "@/lib/services/catch-up";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const gaps = await detectCatchUpGaps(user.id);
return { gaps };
});
}

View File

@@ -0,0 +1,10 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getWhatAiKnows } from "@/lib/services/ai-chat";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getWhatAiKnows(user.id);
});
}

View File

@@ -0,0 +1,40 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getChatSession, sendChatMessage, archiveChatSession } from "@/lib/services/ai-chat";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const data = await getChatSession(user.id, id);
if (!data) throw new Error("Session not found");
return data;
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await archiveChatSession(user.id, id);
return { ok: true };
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return sendChatMessage(user.id, id, body.content ?? "");
});
}

View File

@@ -0,0 +1,19 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { listChatSessions, createChatSession } from "@/lib/services/ai-chat";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const sessions = await listChatSessions(user.id);
return { sessions };
});
}
export async function POST(request: Request) {
const body = await request.json().catch(() => ({}));
return handleApi(async () => {
const { user } = await requireUser();
return createChatSession(user.id, body.title, body.featureContext);
});
}

View File

@@ -0,0 +1,33 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
getAiBehaviorConfig,
getAiProviderConfig,
saveAiBehaviorConfig,
saveAiProviderConfig,
} from "@/lib/services/ai-config";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const behavior = await getAiBehaviorConfig(user.id);
const provider = await getAiProviderConfig(user.id);
return { behavior, provider };
});
}
export async function PATCH(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
let behavior = await getAiBehaviorConfig(user.id);
let provider = await getAiProviderConfig(user.id);
if (body.behavior) {
behavior = await saveAiBehaviorConfig(user.id, body.behavior);
}
if (body.provider) {
provider = await saveAiProviderConfig(user.id, body.provider);
}
return { behavior, provider };
});
}

View File

@@ -0,0 +1,19 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { buildMentorContext, formatContextForPrompt } from "@/lib/services/ai-context";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const message = searchParams.get("message") ?? "";
const feature = searchParams.get("feature") ?? "mentor";
return handleApi(async () => {
const { user } = await requireUser();
const ctx = await buildMentorContext(user.id, { userMessage: message, feature, logContext: false });
return {
layers: ctx.layers,
tokenEstimate: ctx.tokenEstimate,
memoryIds: ctx.memoryIds,
formatted: formatContextForPrompt(ctx),
};
});
}

View File

@@ -0,0 +1,21 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getCachedHealth, runHealthCheck } from "@/lib/services/ai-config";
import { getLastHealthLogs } from "@/lib/services/ai-config";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const cached = await getCachedHealth(user.id);
const logs = await getLastHealthLogs(user.id, 5);
return { health: cached, logs };
});
}
export async function POST() {
return handleApi(async () => {
const { user } = await requireUser();
const health = await runHealthCheck(user.id);
return { health };
});
}

View File

@@ -0,0 +1,40 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getMemory, updateMemory, archiveMemory } from "@/lib/services/ai-memory";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const memory = await getMemory(user.id, id);
if (!memory) throw new Error("Memory not found");
return memory;
});
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return updateMemory(user.id, id, body);
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await archiveMemory(user.id, id);
return { ok: true };
});
}

View File

@@ -0,0 +1,21 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
getMemoryLearningSettings,
saveMemoryLearningSettings,
} from "@/lib/services/ai-memory";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getMemoryLearningSettings(user.id);
});
}
export async function PATCH(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return saveMemoryLearningSettings(user.id, body);
});
}

View File

@@ -0,0 +1,13 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { resetAllMemories } from "@/lib/services/ai-memory";
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (!body.confirm) throw new Error("Confirmation required");
await resetAllMemories(user.id);
return { ok: true };
});
}

View File

@@ -0,0 +1,32 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
listMemories,
createMemory,
getProfileSummary,
listSuggestions,
exportMemories,
} from "@/lib/services/ai-memory";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
return handleApi(async () => {
const { user } = await requireUser();
const category = searchParams.get("category") ?? undefined;
const q = searchParams.get("q") ?? undefined;
const enabledParam = searchParams.get("enabled");
const enabled = enabledParam === null ? undefined : enabledParam === "true";
const memories = await listMemories(user.id, { category, q, enabled });
const summary = await getProfileSummary(user.id);
const pending = await listSuggestions(user.id, "pending");
return { memories, summary, pendingCount: pending.length };
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return createMemory(user.id, body);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { acceptSuggestion } from "@/lib/services/ai-memory";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json().catch(() => ({}));
return handleApi(async () => {
const { user } = await requireUser();
return acceptSuggestion(user.id, id, body);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { ignoreSuggestion } from "@/lib/services/ai-memory";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await ignoreSuggestion(user.id, id);
return { ok: true };
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { rejectSuggestion } from "@/lib/services/ai-memory";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await rejectSuggestion(user.id, id);
return { ok: true };
});
}

View File

@@ -0,0 +1,13 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { listSuggestions } from "@/lib/services/ai-memory";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") ?? "pending";
return handleApi(async () => {
const { user } = await requireUser();
const suggestions = await listSuggestions(user.id, status);
return { suggestions };
});
}

View File

@@ -0,0 +1,54 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { listMemories, saveProfileSummary } from "@/lib/services/ai-memory";
import { getProvider } from "@/lib/ai/provider-registry";
import { getAiBehaviorConfig, getAiProviderConfig, buildSystemPrompt } from "@/lib/services/ai-config";
import { getTemplateBody } from "@/lib/services/ai-templates";
import { renderTemplate } from "@/lib/ai/prompts/render";
export async function POST() {
return handleApi(async () => {
const { user } = await requireUser();
const memories = await listMemories(user.id, { enabled: true });
const verified = memories.filter((m) => m.userVerified);
const bulletList = verified
.map((m) => `- [${m.category}] ${m.title}: ${m.content.slice(0, 120)}`)
.join("\n");
const behavior = await getAiBehaviorConfig(user.id);
const providerConfig = await getAiProviderConfig(user.id);
let summaryText = verified.length === 0
? "No verified memories yet. Add memories manually to build your profile."
: bulletList.slice(0, 1500);
if (behavior.enabled && providerConfig.enabled && verified.length > 0) {
try {
const coreSystem = await getTemplateBody(user.id, "system_core");
const templateBody = await getTemplateBody(user.id, "mentor_summary");
const system = buildSystemPrompt(behavior, coreSystem);
const prompt = renderTemplate(templateBody, {
user_name: user.displayName,
context: bulletList,
});
const provider = getProvider(providerConfig.type);
const res = await provider.generateText(providerConfig, {
model: providerConfig.model,
prompt,
system,
format: "text",
temperature: 0.5,
maxTokens: 400,
timeoutMs: providerConfig.timeoutMs,
});
if (res.text.trim()) summaryText = res.text.trim();
} catch {
/* use bullet list fallback */
}
}
const summary = await saveProfileSummary(user.id, summaryText);
return { ...summary, sourceMemoryCount: verified.length };
});
}

View File

@@ -0,0 +1,34 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
getProfileSummary,
saveProfileSummary,
exportMemories,
importMemories,
} from "@/lib/services/ai-memory";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getProfileSummary(user.id);
});
}
export async function PATCH(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return saveProfileSummary(user.id, body.summary ?? "");
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "import") {
return importMemories(user.id, body.data, body.overwrite);
}
return exportMemories(user.id);
});
}

View File

@@ -0,0 +1,23 @@
import { handleApi } from "@/lib/api";
import { listProviderTypes } from "@/lib/ai/provider-registry";
import { requireUser } from "@/lib/services/user";
import { getAiProviderConfig } from "@/lib/services/ai-config";
import { getProvider } from "@/lib/ai/provider-registry";
export async function GET() {
return handleApi(async () => {
const providers = listProviderTypes();
return { providers };
});
}
export async function POST() {
return handleApi(async () => {
const { user } = await requireUser();
const config = await getAiProviderConfig(user.id);
const provider = getProvider(config.type);
if (!provider.listModels) return { models: [config.model] };
const models = await provider.listModels(config);
return { models };
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { dismissSuggestion } from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
await dismissSuggestion(id, user.id);
return { ok: true };
});
}

View File

@@ -0,0 +1,26 @@
import { handleApi, jsonError } from "@/lib/api";
import { getSuggestions, dismissSuggestion, generateDailyQuests } from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
import { isOllamaAvailable } from "@/lib/services/ai";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const role = searchParams.get("role") ?? undefined;
return handleApi(async () => {
const { user } = await requireUser();
const suggestions = await getSuggestions(user.id, role);
const ollamaAvailable = await isOllamaAvailable();
return { suggestions, ollamaAvailable };
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "generate_quests") {
return generateDailyQuests(user.id);
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,64 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import {
getPromptTemplate,
updatePromptTemplate,
resetPromptTemplate,
previewTemplate,
getTemplateVersions,
restoreTemplateVersion,
} from "@/lib/services/ai-templates";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const template = await getPromptTemplate(user.id, key);
const versions = await getTemplateVersions(user.id, key);
return { template, versions };
});
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
const template = await updatePromptTemplate(user.id, key, body);
return { template };
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
const { searchParams } = new URL(request.url);
const action = searchParams.get("action");
const body = await request.json().catch(() => ({}));
return handleApi(async () => {
const { user } = await requireUser();
if (action === "reset") {
const template = await resetPromptTemplate(user.id, key);
return { template };
}
if (action === "preview") {
const rendered = await previewTemplate(user.id, key, body.body);
return { rendered };
}
if (action === "restore" && body.version) {
const template = await restoreTemplateVersion(user.id, key, body.version);
return { template };
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,12 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getPromptTemplates } from "@/lib/services/ai-templates";
import { PLACEHOLDER_DOCS } from "@/lib/ai/prompts/defaults";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const templates = await getPromptTemplates(user.id);
return { templates, placeholders: PLACEHOLDER_DOCS };
});
}

View File

@@ -0,0 +1,28 @@
import { handleApi, jsonOk } from "@/lib/api";
import { getSession, verifyPassword } from "@/lib/auth";
export async function POST(request: Request) {
return handleApi(async () => {
const { password } = await request.json();
if (!verifyPassword(password)) {
throw new Error("Invalid password");
}
const session = await getSession();
session.isLoggedIn = true;
await session.save();
return { ok: true };
});
}
export async function DELETE() {
return handleApi(async () => {
const session = await getSession();
session.destroy();
return { ok: true };
});
}
export async function GET() {
const session = await getSession();
return jsonOk({ isLoggedIn: !!session.isLoggedIn });
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { logPages } from "@/lib/services/reading";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return logPages(user.id, id, body.pages, body.date, body.note);
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { updateBook } from "@/lib/services/reading";
import { requireUser } from "@/lib/services/user";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return updateBook(id, user.id, body);
});
}

View File

@@ -0,0 +1,18 @@
import { handleApi } from "@/lib/api";
import { getBooks, createBook } from "@/lib/services/reading";
import { requireUser } from "@/lib/services/user";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getBooks(user.id);
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return createBook(user.id, body);
});
}

View File

@@ -0,0 +1,10 @@
import { handleApi } from "@/lib/api";
import { getCartographerDesk } from "@/lib/services/cartographer";
import { requireUser } from "@/lib/services/user";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getCartographerDesk(user.id);
});
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { verifyCronSecret } from "@/lib/auth";
import { requireUser } from "@/lib/services/user";
import { materializeDay } from "@/lib/services/adventure";
import { todayString } from "@/lib/dates";
import { generateDailyQuests, generateWeeklyExplorations } from "@/lib/services/explorations";
import { weekStartString } from "@/lib/dates";
import { generateWeeklyReview } from "@/lib/services/explorations";
import { pruneOldActions } from "@/lib/services/action-events";
export async function POST(request: Request) {
if (!verifyCronSecret(request)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { user } = await requireUser();
const { searchParams } = new URL(request.url);
const action = searchParams.get("action") ?? "materialize";
if (action === "materialize") {
await materializeDay(user.id, todayString());
return NextResponse.json({ ok: true });
}
if (action === "generate-quests") {
await generateDailyQuests(user.id);
return NextResponse.json({ ok: true });
}
if (action === "generate-explorations") {
await generateWeeklyExplorations(user.id);
return NextResponse.json({ ok: true });
}
if (action === "generate-weekly-review") {
await generateWeeklyReview(user.id, weekStartString());
return NextResponse.json({ ok: true });
}
if (action === "prune-actions") {
await pruneOldActions(user.id, 90);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}

View File

@@ -0,0 +1,8 @@
import { handleApi } from "@/lib/api";
import { getDashboard } from "@/lib/services/dashboard";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const date = searchParams.get("date") ?? undefined;
return handleApi(() => getDashboard(date));
}

View File

@@ -0,0 +1,29 @@
import { handleApi } from "@/lib/api";
import {
acceptExploration,
completeExploration,
dismissExploration,
} from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
import { todayString } from "@/lib/dates";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "accept") {
return acceptExploration(id, user.id);
}
if (body.action === "complete") {
return completeExploration(id, user.id, body.note ?? "", body.date ?? todayString());
}
if (body.action === "dismiss" || body.action === "deny") {
return dismissExploration(id, user.id);
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,33 @@
import { handleApi } from "@/lib/api";
import {
getExplorations,
generateWeeklyExplorations,
acceptExploration,
completeExploration,
dismissExploration,
getExplorationHistory,
} from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
import { todayString } from "@/lib/dates";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const weekOf = searchParams.get("week_of") ?? undefined;
const history = searchParams.get("history") === "true";
return handleApi(async () => {
const { user } = await requireUser();
if (history) return getExplorationHistory(user.id);
return getExplorations(user.id, weekOf);
});
}
export async function POST(request: Request) {
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "generate") {
return generateWeeklyExplorations(user.id);
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,25 @@
import { eq } from "drizzle-orm";
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { db } from "@/lib/db";
import * as schema from "@adventureos/db/schema";
import { exportMemories } from "@/lib/services/ai-memory";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const aiData = await exportMemories(user.id);
const tables = {
user: await db.select().from(schema.users).where(eq(schema.users.id, user.id)),
progress: await db.select().from(schema.userProgress),
templates: await db.select().from(schema.adventureTemplates),
books: await db.select().from(schema.books),
achievements: await db.select().from(schema.achievements),
reflections: await db.select().from(schema.reflections),
aiMemories: aiData.memories,
aiProfileSummary: aiData.summary,
aiMemoryLearning: aiData.learning,
};
return tables;
});
}

View File

@@ -0,0 +1,31 @@
import { handleApi } from "@/lib/api";
import { listCalibreBooks } from "@/lib/services/calibre";
import { getReadingProgress } from "@/lib/services/calibre-reading";
import { requireUser } from "@/lib/services/user";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const search = searchParams.get("search") ?? undefined;
const limit = Number(searchParams.get("limit") ?? "200");
const offset = Number(searchParams.get("offset") ?? "0");
return handleApi(async () => {
const { user } = await requireUser();
const books = await listCalibreBooks({ search, limit, offset });
const progress = await getReadingProgress(user.id);
const progressMap = new Map(progress.map((p) => [p.calibreBookId, p]));
return books.map((b) => {
const p = progressMap.get(b.id);
return {
...b,
currentPage: p?.currentPage ?? 0,
totalPages: p?.totalPages ?? 300,
status: p?.status ?? "unread",
progressPercent: p
? Math.round((p.currentPage / Math.max(p.totalPages, 1)) * 100)
: 0,
};
});
});
}

View File

@@ -0,0 +1,30 @@
import fs from "fs";
import { resolveCoverPath } from "@/lib/services/calibre";
import { requireUser } from "@/lib/services/user";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const bookId = Number(id);
if (!Number.isFinite(bookId)) {
return new Response("Invalid id", { status: 400 });
}
await requireUser();
const coverPath = resolveCoverPath(bookId);
if (!coverPath) {
return new Response("Cover not found", { status: 404 });
}
const buffer = fs.readFileSync(coverPath);
return new Response(buffer, {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=3600" },
});
} catch (e) {
const msg = e instanceof Error ? e.message : "Error";
if (msg === "Unauthorized") return new Response(msg, { status: 401 });
return new Response(msg, { status: 500 });
}
}

View File

@@ -0,0 +1,23 @@
import { handleApi } from "@/lib/api";
import { logCalibrePages } from "@/lib/services/calibre-reading";
import { requireUser } from "@/lib/services/user";
import { todayString } from "@/lib/dates";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const calibreBookId = Number(id);
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return logCalibrePages(
user.id,
calibreBookId,
body.pages ?? 10,
body.date ?? todayString()
);
});
}

View File

@@ -0,0 +1,10 @@
import { handleApi } from "@/lib/api";
import { getCalibreStatus } from "@/lib/services/calibre";
import { requireUser } from "@/lib/services/user";
export async function GET() {
return handleApi(async () => {
await requireUser();
return getCalibreStatus();
});
}

View File

@@ -0,0 +1,26 @@
import { handleApi } from "@/lib/api";
import { saveReflection, getReflection } from "@/lib/services/reflection";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
return saveReflection(user.id, date, body);
});
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
const { date } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return getReflection(user.id, date);
});
}

View File

@@ -0,0 +1,37 @@
import { handleApi } from "@/lib/api";
import {
getWeeklyReview,
generateWeeklyReview,
setReviewIntention,
} from "@/lib/services/explorations";
import { requireUser } from "@/lib/services/user";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ weekStart: string }> }
) {
const { weekStart } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return getWeeklyReview(user.id, weekStart);
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ weekStart: string }> }
) {
const { weekStart } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "generate") {
return generateWeeklyReview(user.id, weekStart);
}
if (body.intention) {
await setReviewIntention(user.id, weekStart, body.intention);
return { ok: true };
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,20 @@
import { handleApi } from "@/lib/api";
import { requireUser } from "@/lib/services/user";
import { getDayBoundaryHour, setDayBoundaryHour } from "@/lib/services/day-boundary";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const hour = await getDayBoundaryHour(user.id);
return { hour };
});
}
export async function PATCH(request: Request) {
return handleApi(async () => {
const { user } = await requireUser();
const body = await request.json();
const hour = await setDayBoundaryHour(user.id, body.hour ?? 0);
return { hour };
});
}

View File

@@ -0,0 +1,58 @@
import { handleApi } from "@/lib/api";
import { requireUser, updateUserProfile } from "@/lib/services/user";
import { db, spiritualConfig } from "@/lib/db";
import { eq } from "drizzle-orm";
import { normalizeSettingsTheme } from "@/lib/services/theme-migration";
import { migrateUserThemeInDb } from "@/lib/services/theme-migration";
import { seedPromptTemplatesForUser } from "@/lib/services/ai-templates";
import { getSettingsForUser, upsertSetting } from "@/lib/repositories/settings.repository";
import { parseJsonBody, settingsPatchSchema } from "@/lib/validation";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
const allSettings = await getSettingsForUser(user.id);
const settingsMap = Object.fromEntries(allSettings.map((s) => [s.key, s.value]));
const migratedTheme = await migrateUserThemeInDb(
user.id,
settingsMap.theme as string | undefined
);
settingsMap.theme = migratedTheme;
const [spiritual] = await db
.select()
.from(spiritualConfig)
.where(eq(spiritualConfig.userId, user.id));
await seedPromptTemplatesForUser(user.id);
return {
user,
settings: normalizeSettingsTheme(settingsMap),
spiritual,
};
});
}
export async function PATCH(request: Request) {
return handleApi(async () => {
const body = await parseJsonBody(request, settingsPatchSchema);
const { user } = await requireUser();
if (body.profile) {
await updateUserProfile(user.id, body.profile);
}
if (body.settings) {
for (const [key, value] of Object.entries(body.settings)) {
await upsertSetting(user.id, key, value);
}
}
if (body.spiritual) {
await db
.update(spiritualConfig)
.set({
prayerLabels: body.spiritual.prayerLabels,
litanyLabels: body.spiritual.litanyLabels,
})
.where(eq(spiritualConfig.userId, user.id));
}
return { ok: true };
});
}

View File

@@ -0,0 +1,15 @@
import { handleApi } from "@/lib/api";
import { getStatsOverview, getStatsDomain } from "@/lib/services/dashboard";
export async function GET(
request: Request,
{ params }: { params: Promise<{ domain: string }> }
) {
const { domain } = await params;
const { searchParams } = new URL(request.url);
const range = Number(searchParams.get("range") ?? 30);
return handleApi(async () => {
if (domain === "overview") return getStatsOverview();
return getStatsDomain(domain, range);
});
}

View File

@@ -0,0 +1,31 @@
import { handleApi } from "@/lib/api";
import { getTeacherLesson, completeTeacherLesson } from "@/lib/services/teacher";
import { requireUser } from "@/lib/services/user";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
const lesson = await getTeacherLesson(user.id, id);
if (!lesson) throw new Error("Lesson not found");
return lesson;
});
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "complete") {
return completeTeacherLesson(user.id, id, body.completedNote ?? "");
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,34 @@
import { handleApi } from "@/lib/api";
import { createTeacherLesson, getTeacherHistory } from "@/lib/services/teacher";
import { requireUser } from "@/lib/services/user";
import { parseJsonBody } from "@/lib/validation";
import {
teacherCreateSchema,
normalizeTeacherCreateBody,
} from "@/lib/validation/schemas";
export async function POST(request: Request) {
return handleApi(async () => {
// #region agent log
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher POST start',data:{},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
// #endregion
const parsed = await parseJsonBody(request, teacherCreateSchema);
const body = normalizeTeacherCreateBody(parsed);
// #region agent log
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher body validated',data:{topicLen:body.topic.length,hasExplorationId:!!body.explorationId},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
// #endregion
const { user } = await requireUser();
const result = await createTeacherLesson(user.id, body);
// #region agent log
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher lesson created',data:{source:result.source,lessonId:result.id},timestamp:Date.now(),hypothesisId:'H2'})}).catch(()=>{});
// #endregion
return result;
});
}
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getTeacherHistory(user.id);
});
}

View File

@@ -0,0 +1,32 @@
import { handleApi } from "@/lib/api";
import { upsertTemplateItem, deleteTemplateItem, verifyTemplateOwnership } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
await verifyTemplateOwnership(user.id, id);
const itemId = await upsertTemplateItem(id, body);
return { id: itemId };
});
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const itemId = searchParams.get("itemId");
if (!itemId) throw new Error("itemId required");
return handleApi(async () => {
const { user } = await requireUser();
const result = await deleteTemplateItem(itemId, user.id);
return { ok: true, ...result };
});
}

View File

@@ -0,0 +1,47 @@
import { handleApi } from "@/lib/api";
import {
updateTemplate,
deleteTemplate,
duplicateTemplate,
verifyTemplateOwnership,
} from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
await verifyTemplateOwnership(user.id, id);
return updateTemplate(user.id, id, body);
});
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return handleApi(async () => {
const { user } = await requireUser();
return deleteTemplate(user.id, id);
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
return handleApi(async () => {
const { user } = await requireUser();
if (body.action === "duplicate") {
return duplicateTemplate(user.id, id);
}
throw new Error("Unknown action");
});
}

View File

@@ -0,0 +1,19 @@
import { handleApi } from "@/lib/api";
import { getTemplates, createTemplate } from "@/lib/services/adventure";
import { requireUser } from "@/lib/services/user";
import { parseJsonBody, templateCreateSchema } from "@/lib/validation";
export async function GET() {
return handleApi(async () => {
const { user } = await requireUser();
return getTemplates(user.id);
});
}
export async function POST(request: Request) {
return handleApi(async () => {
const body = await parseJsonBody(request, templateCreateSchema);
const { user } = await requireUser();
return createTemplate(user.id, body);
});
}

View File

@@ -0,0 +1,6 @@
import { jsonOk } from "@/lib/api";
import { THEMES } from "@/themes/registry";
export async function GET() {
return jsonOk({ themes: THEMES });
}

View File

@@ -0,0 +1,311 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import Link from "next/link";
import { useState } from "react";
import { useUiStore } from "@/stores/ui";
interface Exploration {
id: string;
title: string;
description: string;
category: string;
status: string;
completedNote?: string | null;
minutes?: number | null;
}
export default function CartographerPage() {
const qc = useQueryClient();
const showXpToast = useUiStore((s) => s.showXpToast);
const showActionToast = useUiStore((s) => s.showActionToast);
const [tab, setTab] = useState<"active" | "history">("active");
const [completeId, setCompleteId] = useState<string | null>(null);
const [note, setNote] = useState("");
const { data: desk, isLoading: deskLoading, error: deskError } = useQuery({
queryKey: ["cartographer-desk"],
queryFn: async () => {
const res = await fetch("/api/cartographer");
if (!res.ok) throw new Error("Failed to load map desk");
return res.json();
},
});
const { data: explorations = [], isLoading, error } = useQuery({
queryKey: ["explorations"],
queryFn: async () => {
const res = await fetch("/api/explorations");
if (!res.ok) throw new Error("Failed to load explorations");
const data = await res.json();
if (data.error) throw new Error(data.error);
return Array.isArray(data) ? data : [];
},
});
const { data: history = [] } = useQuery({
queryKey: ["explorations-history"],
queryFn: async () => {
const res = await fetch("/api/explorations?history=true");
if (!res.ok) throw new Error("Failed");
const data = await res.json();
return Array.isArray(data) ? data : [];
},
enabled: tab === "history",
});
const [generateSource, setGenerateSource] = useState<"ai" | "fallback" | "existing" | null>(
null
);
const generate = useMutation({
mutationFn: async () => {
const res = await fetch("/api/explorations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "generate" }),
});
if (!res.ok) throw new Error("Generate failed");
const data = await res.json();
if (data.error) throw new Error(data.error);
return data as {
explorations: Exploration[];
source?: "ai" | "fallback" | "existing";
};
},
onSuccess: (data) => {
setGenerateSource(data.source ?? null);
qc.invalidateQueries({ queryKey: ["explorations"] });
},
});
const generateNotice =
generateSource === "existing"
? "You still have suggested quests to review — finish or deny them before generating more."
: generateSource === "fallback"
? "AI unavailable — showing curated offline quest suggestions."
: null;
const action = useMutation({
mutationFn: async ({ id, action, note }: { id: string; action: string; note?: string }) => {
const res = await fetch(`/api/explorations/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, note }),
});
if (!res.ok) throw new Error("Action failed");
return res.json();
},
onSuccess: (data, vars) => {
qc.invalidateQueries({ queryKey: ["explorations"] });
qc.invalidateQueries({ queryKey: ["explorations-history"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["cartographer-desk"] });
if (vars.action === "complete") showXpToast(100, "Exploration complete");
if (data.actionEventId) {
showActionToast(`Exploration ${vars.action}`, data.actionEventId);
}
setCompleteId(null);
setNote("");
},
});
const suggested = explorations.filter((e: Exploration) => e.status === "suggested");
const active = explorations.filter((e: Exploration) => e.status === "active");
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 parchment-bg min-h-full">
<div className="flex justify-between items-center mb-4">
<h1 className="font-bold text-lg">The Cartographer&apos;s Desk</h1>
<button
className="retro-btn"
onClick={() => generate.mutate()}
disabled={generate.isPending}
>
{generate.isPending ? "Generating..." : "Generate Quests"}
</button>
</div>
{generate.isError && (
<p className="text-sm text-[var(--muted-rose)] mb-3">
Could not generate quests. Check AI Health in Settings.
</p>
)}
{generateNotice && (
<p className="text-sm text-[var(--warm-grey)] mb-3 italic">{generateNotice}</p>
)}
{generateSource === "fallback" && !generateNotice && (
<p className="text-sm text-[var(--warm-grey)] mb-3 italic">
AI is offline showing curated offline quest suggestions.
</p>
)}
{!deskLoading && desk && (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
{desk.domains?.map((d: { key: string; label: string; score: number }) => (
<div key={d.key} className="retro-window-inset p-2 text-center">
<p className="text-xs text-[var(--warm-grey)]">{d.label}</p>
<p className="font-bold text-lg">{d.score}</p>
<div className="skill-bar-track h-1.5 mt-1">
<div className="skill-bar-fill h-full" style={{ width: `${d.score}%` }} />
</div>
</div>
))}
</div>
{desk.horizon?.intention && (
<p className="text-sm italic mb-4 text-[var(--warm-grey)]">
Horizon: {desk.horizon.intention}
</p>
)}
</>
)}
{deskError && (
<p className="text-sm text-[var(--muted-rose)] mb-3">Could not load life map scores.</p>
)}
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
Optional curiosity quests pick what interests you this week.
</p>
<div className="flex gap-2 mb-4">
<button
className={`retro-btn ${tab === "active" ? "retro-btn-primary" : ""}`}
onClick={() => setTab("active")}
>
This Week
</button>
<button
className={`retro-btn ${tab === "history" ? "retro-btn-primary" : ""}`}
onClick={() => setTab("history")}
>
History
</button>
</div>
{tab === "active" && (
<>
{isLoading ? (
<p>Loading map...</p>
) : error ? (
<p className="text-sm text-[var(--muted-rose)]">Failed to load explorations.</p>
) : (
<div className="space-y-4">
{suggested.map((e: Exploration) => (
<QuestCard
key={e.id}
exploration={e}
onAccept={() => action.mutate({ id: e.id, action: "accept" })}
onDismiss={() => action.mutate({ id: e.id, action: "dismiss" })}
/>
))}
{active.map((e: Exploration) => (
<QuestCard
key={e.id}
exploration={e}
active
onComplete={() => setCompleteId(e.id)}
/>
))}
{suggested.length === 0 && active.length === 0 && (
<p className="text-sm">
No explorations this week. Generate some quests dismissed quests won&apos;t block new ones.
</p>
)}
</div>
)}
</>
)}
{tab === "history" && (
<div className="flex flex-wrap gap-2">
{history.map((e: Exploration) => (
<span
key={e.id}
className="retro-window-inset px-3 py-1 text-xs"
title={e.completedNote ?? ""}
>
{e.title}
</span>
))}
</div>
)}
{completeId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="retro-window p-4 w-full max-w-md">
<p className="font-bold mb-2">What did you learn?</p>
<input
className="retro-window-inset w-full p-2 mb-4"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="One thing you discovered..."
/>
<button
className="retro-btn retro-btn-primary w-full"
onClick={() => action.mutate({ id: completeId, action: "complete", note })}
>
Complete Exploration
</button>
</div>
</div>
)}
</div>
</AppShell>
);
}
function QuestCard({
exploration,
active,
onAccept,
onDismiss,
onComplete,
}: {
exploration: Exploration;
active?: boolean;
onAccept?: () => void;
onDismiss?: () => void;
onComplete?: () => void;
}) {
return (
<div className="retro-window p-4 relative">
<span className="absolute top-2 right-2 text-xs bg-[var(--gold-trim)] px-2 py-0.5 rounded">
{exploration.category}
</span>
<h3 className="font-bold mb-1 pr-20">{exploration.title}</h3>
<p className="text-sm mb-3">{exploration.description}</p>
{exploration.minutes && (
<p className="text-xs text-[var(--warm-grey)] mb-2">~{exploration.minutes} min</p>
)}
<div className="flex gap-2 flex-wrap">
{active ? (
<>
<button className="retro-btn retro-btn-primary" onClick={onComplete}>
Complete
</button>
<Link
href={`/teacher?explorationId=${exploration.id}&topic=${encodeURIComponent(exploration.title)}`}
className="retro-btn text-xs"
>
Study with Teacher
</Link>
</>
) : (
<>
<button className="retro-btn retro-btn-primary" onClick={onAccept}>
Accept
</button>
<button className="retro-btn" onClick={onDismiss}>
Deny quest
</button>
</>
)}
</div>
</div>
);
}

BIN
apps/web/src/app/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

215
apps/web/src/app/globals.css Executable file
View File

@@ -0,0 +1,215 @@
@import "tailwindcss";
@import "../themes/index.css";
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: calc(14px * var(--density));
color: var(--color-text);
background-color: var(--color-body-bg);
background-image: var(--color-body-bg-image);
background-size: cover;
background-attachment: fixed;
min-height: 100vh;
}
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: var(--effect-scanlines);
z-index: 9999;
}
@theme inline {
--color-xp-blue: var(--color-accent);
--color-bliss-green: var(--color-positive);
--color-parchment: var(--color-surface);
--color-gold: var(--color-gold);
--color-ink: var(--color-text);
--font-sans: var(--font-sans);
--font-serif: var(--font-serif);
}
.retro-window {
background: var(--color-surface);
border: 2px solid;
border-color: var(--color-border-light) var(--color-border-dark) var(--color-border-dark)
var(--color-border-light);
box-shadow: var(--window-shadow);
}
.retro-window-inset {
border: 2px solid;
border-color: var(--color-border-dark) var(--color-border-light) var(--color-border-light)
var(--color-border-dark);
background: var(--color-surface-inset);
color: var(--color-text);
}
.retro-titlebar {
background: linear-gradient(
180deg,
var(--color-titlebar-start) 0%,
var(--color-titlebar-mid) 8%,
var(--color-titlebar-end) 100%
);
color: var(--color-accent-text);
padding: 4px 8px;
font-weight: bold;
font-size: 13px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.retro-btn {
background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-surface) 100%);
border: 2px solid;
border-color: var(--color-border-light) var(--color-border-dark) var(--color-border-dark)
var(--color-border-light);
padding: 4px 12px;
font-family: var(--font-sans);
font-size: 13px;
color: var(--color-text);
cursor: pointer;
}
.retro-btn:hover {
background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-btn-hover) 100%);
}
.retro-btn:active {
border-color: var(--color-border-dark) var(--color-border-light) var(--color-border-light)
var(--color-border-dark);
padding: 5px 11px 3px 13px;
}
.retro-btn-primary {
background: linear-gradient(180deg, var(--color-btn-primary-start) 0%, var(--color-accent) 100%);
color: var(--color-accent-text);
border-color: var(--color-btn-primary-border-light) var(--color-btn-primary-border-dark)
var(--color-btn-primary-border-dark) var(--color-btn-primary-border-light);
}
.skill-bar-track {
height: 14px;
background: var(--color-skill-track);
border: 1px solid var(--color-border-dark);
box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.3);
position: relative;
overflow: hidden;
}
.skill-bar-fill {
height: 100%;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-skill-fill) 80%, white) 0%,
var(--color-skill-fill) 50%,
color-mix(in srgb, var(--color-skill-fill) 70%, black) 100%
);
border-right: 1px solid var(--color-gold);
transition: width 600ms ease-out;
}
.skill-bar-fill-gold {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-skill-fill-gold) 80%, white) 0%,
var(--color-skill-fill-gold) 50%,
color-mix(in srgb, var(--color-skill-fill-gold) 60%, black) 100%
);
}
@media (prefers-reduced-motion: reduce) {
.skill-bar-fill {
transition: none;
}
}
.serif {
font-family: var(--font-serif);
}
.xp-toast {
animation: slideUp 200ms ease-out, fadeOut 3s ease-in 2s forwards;
}
.action-toast {
animation: slideUp 200ms ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fadeOut {
to {
opacity: 0;
}
}
.book-spine {
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
min-height: 120px;
padding: 8px 4px;
border-radius: 2px 4px 4px 2px;
cursor: pointer;
transition: transform 150ms;
}
.book-spine:hover {
transform: rotate(180deg) translateY(-4px);
}
.parchment-bg {
background: var(--color-surface);
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
}
.theme-preview-card {
cursor: pointer;
transition: transform 150ms, box-shadow 150ms;
}
.theme-preview-card:hover {
transform: translateY(-2px);
}
.theme-preview-card.selected {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.theme-preview-bar {
height: 20px;
border-radius: 2px 2px 0 0;
}
.theme-preview-body {
padding: 8px;
display: flex;
gap: 4px;
}
.theme-preview-swatch {
width: 16px;
height: 16px;
border-radius: 2px;
}

44
apps/web/src/app/layout.tsx Executable file
View File

@@ -0,0 +1,44 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Providers } from "@/components/providers";
import { XpToast, ActionToast, BookCelebration, WelcomeBack } from "@/components/retro/overlays";
export const metadata: Metadata = {
title: "AdventureOS",
description: "Your personal command centre for consistency and growth",
manifest: "/manifest.json",
appleWebApp: { capable: true, title: "AdventureOS" },
};
export const viewport: Viewport = {
themeColor: "#3A6EA5",
width: "device-width",
initialScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" data-theme="minimal-dark" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var L={xp:"windows-xp-light",win98:"windows-98",mac:"gnome-2",terminal:"retro-terminal"};var V=["minimal-dark","retro-terminal","windows-xp-dark","windows-xp-light","windows-98","classic-kde","gnome-2","runescape","game-boy","early-web-forum","crt-hacker","library"];var c=localStorage.getItem("adventureos-theme");var t="minimal-dark";if(c){t=L[c]||(V.indexOf(c)>=0?c:"minimal-dark");}document.documentElement.setAttribute("data-theme",t);}catch(e){}})();`,
}}
/>
</head>
<body>
<Providers>
{children}
<XpToast />
<ActionToast />
<BookCelebration />
<WelcomeBack />
</Providers>
</body>
</html>
);
}

425
apps/web/src/app/library/page.tsx Executable file
View File

@@ -0,0 +1,425 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import { useState } from "react";
import { useUiStore } from "@/stores/ui";
type CalibreBookRow = {
id: number;
title: string;
authors: string[];
currentPage: number;
totalPages: number;
status: string;
progressPercent: number;
hasCover: boolean;
};
export default function LibraryPage() {
const qc = useQueryClient();
const showBookCelebration = useUiStore((s) => s.showBookCelebration);
const showActionToast = useUiStore((s) => s.showActionToast);
const [tab, setTab] = useState<"reading" | "finished">("reading");
const [showAdd, setShowAdd] = useState(false);
const [newBook, setNewBook] = useState({ title: "", author: "", totalPages: 300 });
const [selectedCalibre, setSelectedCalibre] = useState<number | null>(null);
const [selectedManual, setSelectedManual] = useState<string | null>(null);
const [logPages, setLogPages] = useState(10);
const [search, setSearch] = useState("");
const { data: calibreStatus } = useQuery({
queryKey: ["library-status"],
queryFn: async () => {
const res = await fetch("/api/library/status");
return res.json();
},
});
const useCalibre = calibreStatus?.configured && calibreStatus?.online;
const { data: calibreBooks = [], isLoading: calibreLoading } = useQuery<CalibreBookRow[]>({
queryKey: ["library-books", search],
queryFn: async () => {
const params = search ? `?search=${encodeURIComponent(search)}` : "";
const res = await fetch(`/api/library/books${params}`);
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!useCalibre,
});
const { data: manualBooks = [], isLoading: manualLoading } = useQuery({
queryKey: ["books"],
queryFn: async () => {
const res = await fetch("/api/books");
return res.json();
},
enabled: !useCalibre,
});
const addBook = useMutation({
mutationFn: async () => {
const res = await fetch("/api/books", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newBook),
});
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["books"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
setShowAdd(false);
},
});
const logManual = useMutation({
mutationFn: async ({ id, pages }: { id: string; pages: number }) => {
const res = await fetch(`/api/books/${id}/log-pages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pages }),
});
return res.json();
},
onSuccess: (data, vars) => {
qc.invalidateQueries({ queryKey: ["books"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
const book = manualBooks.find((b: { id: string; title: string }) => b.id === vars.id);
if (data.status === "finished" && book) showBookCelebration(book.title);
if (data.actionEventId && book) {
showActionToast(`Logged ${vars.pages} pages in ${book.title}`, data.actionEventId);
}
},
});
const logCalibre = useMutation({
mutationFn: async ({ id, pages }: { id: number; pages: number }) => {
const res = await fetch(`/api/library/progress/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pages }),
});
return res.json();
},
onSuccess: (data, vars) => {
qc.invalidateQueries({ queryKey: ["library-books"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
const book = calibreBooks.find((b) => b.id === vars.id);
if (data.status === "finished" && book) showBookCelebration(book.title);
if (data.actionEventId && book) {
showActionToast(`Logged ${vars.pages} pages in ${book.title}`, data.actionEventId);
}
},
});
const books = useCalibre ? calibreBooks : manualBooks;
const isLoading = useCalibre ? calibreLoading : manualLoading;
const filtered = books.filter((b: { status: string }) =>
tab === "reading" ? b.status !== "finished" : b.status === "finished"
);
const spineColors = ["#8B4513", "#2F4F4F", "#800020", "#4A3728", "#1B3A5C"];
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
<div className="flex justify-between items-center mb-4">
<h1 className="font-bold text-lg">The Library Wing</h1>
{!useCalibre && (
<button className="retro-btn retro-btn-primary" onClick={() => setShowAdd(true)}>
Add Book
</button>
)}
</div>
{calibreStatus?.configured === false && (
<div className="retro-window-inset p-3 mb-4 text-sm">
<p className="font-bold mb-1">Calibre not configured</p>
<p className="text-[var(--warm-grey)]">
Set <code>CALIBRE_LIBRARY_PATH</code> in your environment to connect your Calibre library.
Manual book entry is available below.
</p>
<button className="retro-btn text-xs mt-2" onClick={() => setShowAdd(true)}>
Add manual book
</button>
</div>
)}
{calibreStatus?.configured && !calibreStatus.online && (
<div className="retro-window-inset p-3 mb-4 text-sm text-[var(--muted-rose)]">
{calibreStatus.error ?? "Calibre library unavailable"}
</div>
)}
{useCalibre && (
<input
className="retro-window-inset w-full p-2 mb-4 text-sm"
placeholder="Search titles or authors..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)}
<div className="flex gap-2 mb-4">
<button
className={`retro-btn ${tab === "reading" ? "retro-btn-primary" : ""}`}
onClick={() => setTab("reading")}
>
Reading
</button>
<button
className={`retro-btn ${tab === "finished" ? "retro-btn-primary" : ""}`}
onClick={() => setTab("finished")}
>
Completed Shelf
</button>
</div>
{isLoading ? (
<p>Loading shelf...</p>
) : (
<div
className="parchment-bg retro-window-inset p-6 min-h-[200px] flex flex-wrap gap-3 items-end"
style={{ background: "linear-gradient(180deg, #8B6914 0%, #6B4F10 100%)" }}
>
{filtered.length === 0 && (
<p className="text-white/80 text-sm w-full text-center py-8">
{tab === "reading" ? "No books yet." : "Completed books appear here."}
</p>
)}
{useCalibre
? filtered.map((book: CalibreBookRow, i: number) => (
<button
key={book.id}
onClick={() => setSelectedCalibre(book.id)}
className="book-spine text-white text-xs font-bold shadow-md relative overflow-hidden"
style={{
backgroundColor: spineColors[i % spineColors.length],
width: 36,
minHeight: 120,
}}
title={book.title}
>
{book.hasCover && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/library/cover/${book.id}`}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-40"
/>
)}
<span className="relative">{book.title.slice(0, 20)}</span>
</button>
))
: filtered.map(
(book: { id: string; title: string }, i: number) => (
<button
key={book.id}
onClick={() => setSelectedManual(book.id)}
className="book-spine text-white text-xs font-bold shadow-md"
style={{
backgroundColor: spineColors[i % spineColors.length],
width: 36,
}}
title={book.title}
>
{book.title.slice(0, 20)}
</button>
)
)}
</div>
)}
{useCalibre && calibreStatus?.bookCount != null && (
<p className="text-xs text-[var(--warm-grey)] mt-2">
{calibreStatus.bookCount} books in Calibre catalog
</p>
)}
{showAdd && (
<Modal onClose={() => setShowAdd(false)} title="Add Book">
<input
className="retro-window-inset w-full p-2 mb-2"
placeholder="Title"
value={newBook.title}
onChange={(e) => setNewBook({ ...newBook, title: e.target.value })}
/>
<input
className="retro-window-inset w-full p-2 mb-2"
placeholder="Author"
value={newBook.author}
onChange={(e) => setNewBook({ ...newBook, author: e.target.value })}
/>
<input
type="number"
className="retro-window-inset w-full p-2 mb-4"
placeholder="Total pages"
value={newBook.totalPages}
onChange={(e) => setNewBook({ ...newBook, totalPages: Number(e.target.value) })}
/>
<button className="retro-btn retro-btn-primary w-full" onClick={() => addBook.mutate()}>
Add to Shelf
</button>
</Modal>
)}
{selectedCalibre != null && (
<CalibreBookDetail
book={calibreBooks.find((b) => b.id === selectedCalibre)}
logPages={logPages}
setLogPages={setLogPages}
onLog={() => logCalibre.mutate({ id: selectedCalibre, pages: logPages })}
onClose={() => setSelectedCalibre(null)}
/>
)}
{selectedManual && (
<ManualBookDetail
book={manualBooks.find((b: { id: string }) => b.id === selectedManual)}
logPages={logPages}
setLogPages={setLogPages}
onLog={() => logManual.mutate({ id: selectedManual, pages: logPages })}
onClose={() => setSelectedManual(null)}
/>
)}
</div>
</AppShell>
);
}
function CalibreBookDetail({
book,
logPages,
setLogPages,
onLog,
onClose,
}: {
book?: CalibreBookRow;
logPages: number;
setLogPages: (n: number) => void;
onLog: () => void;
onClose: () => void;
}) {
if (!book) return null;
const pct = book.progressPercent;
return (
<Modal onClose={onClose} title={book.title}>
{book.authors.length > 0 && (
<p className="text-sm text-[var(--warm-grey)] mb-2">{book.authors.join(", ")}</p>
)}
<p className="mb-2">
Page {book.currentPage} of {book.totalPages} ({pct}%)
</p>
<div className="skill-bar-track mb-4">
<div className="skill-bar-fill" style={{ width: `${pct}%` }} />
</div>
{book.status !== "finished" && (
<div className="flex gap-2 items-center">
<input
type="number"
className="retro-window-inset w-20 p-2"
value={logPages}
onChange={(e) => setLogPages(Number(e.target.value))}
/>
<button className="retro-btn retro-btn-primary" onClick={onLog}>
Log pages
</button>
<button
className="retro-btn"
onClick={() => {
setLogPages(10);
onLog();
}}
>
+10
</button>
</div>
)}
</Modal>
);
}
function ManualBookDetail({
book,
logPages,
setLogPages,
onLog,
onClose,
}: {
book?: {
title: string;
author: string | null;
currentPage: number;
totalPages: number;
status: string;
};
logPages: number;
setLogPages: (n: number) => void;
onLog: () => void;
onClose: () => void;
}) {
if (!book) return null;
const pct = Math.round((book.currentPage / book.totalPages) * 100);
return (
<Modal onClose={onClose} title={book.title}>
{book.author && <p className="text-sm text-[var(--warm-grey)] mb-2">{book.author}</p>}
<p className="mb-2">
Page {book.currentPage} of {book.totalPages} ({pct}%)
</p>
<div className="skill-bar-track mb-4">
<div className="skill-bar-fill" style={{ width: `${pct}%` }} />
</div>
{book.status !== "finished" && (
<div className="flex gap-2 items-center">
<input
type="number"
className="retro-window-inset w-20 p-2"
value={logPages}
onChange={(e) => setLogPages(Number(e.target.value))}
/>
<button className="retro-btn retro-btn-primary" onClick={onLog}>
Log pages
</button>
<button
className="retro-btn"
onClick={() => {
setLogPages(10);
onLog();
}}
>
+10
</button>
</div>
)}
</Modal>
);
}
function Modal({
children,
title,
onClose,
}: {
children: React.ReactNode;
title: string;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="retro-window w-full max-w-md p-4">
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3 flex justify-between">
<span>{title}</span>
<button onClick={onClose} className="text-white hover:opacity-80">
</button>
</div>
{children}
</div>
</div>
);
}

48
apps/web/src/app/login/page.tsx Executable file
View File

@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.push("/");
router.refresh();
} else {
setError("Invalid password");
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<form onSubmit={handleSubmit} className="retro-window w-full max-w-sm p-6">
<div className="retro-titlebar -mx-6 -mt-6 mb-6 px-4">AdventureOS</div>
<p className="serif text-center mb-4 text-sm">
Welcome, traveler. Enter to continue your adventure.
</p>
<input
type="password"
className="retro-window-inset w-full p-2 mb-4"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-[var(--muted-rose)] text-sm mb-2">{error}</p>}
<button type="submit" className="retro-btn retro-btn-primary w-full">
Enter Command Centre
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,236 @@
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/layout/app-shell";
import Link from "next/link";
const SUGGESTED = [
"What should I focus on today?",
"Why do I keep falling off my reading?",
"Give me a gentle plan for tomorrow.",
"What have I been worried about recently?",
"What do you know about me?",
"What goals am I working towards?",
];
export default function MentorPage() {
const [sessionId, setSessionId] = useState<string | null>(null);
const [input, setInput] = useState("");
const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat");
const bottomRef = useRef<HTMLDivElement>(null);
const { data: sessionsData } = useQuery({
queryKey: ["chat-sessions"],
queryFn: async () => {
const res = await fetch("/api/ai/chat/sessions");
return res.json();
},
});
useEffect(() => {
if (!sessionId && sessionsData?.sessions?.[0]) {
setSessionId(sessionsData.sessions[0].id);
} else if (!sessionId) {
fetch("/api/ai/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
})
.then((r) => r.json())
.then((d) => setSessionId(d.id));
}
}, [sessionId, sessionsData]);
const { data: chatData, refetch } = useQuery({
queryKey: ["chat-session", sessionId],
queryFn: async () => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
return res.json();
},
enabled: !!sessionId,
});
const { data: knowsData } = useQuery({
queryKey: ["ai-knows"],
queryFn: async () => {
const res = await fetch("/api/ai/chat/knows");
return res.json();
},
enabled: tab === "knows",
});
const { data: previewData, refetch: refetchPreview } = useQuery({
queryKey: ["context-preview", input],
queryFn: async () => {
const params = new URLSearchParams({ message: input || "hello", feature: "mentor" });
const res = await fetch(`/api/ai/context/preview?${params}`);
return res.json();
},
enabled: tab === "preview",
});
const send = useMutation({
mutationFn: async (content: string) => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Send failed");
return res.json();
},
onSuccess: () => {
refetch();
setInput("");
},
});
const newSession = useMutation({
mutationFn: async () => {
const res = await fetch("/api/ai/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
return res.json();
},
onSuccess: (d) => setSessionId(d.id),
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatData?.messages?.length]);
const messages = chatData?.messages ?? [];
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 max-w-4xl mx-auto">
<div className="flex flex-wrap gap-2 mb-4 items-center">
<h1 className="font-bold text-lg">Mentor</h1>
<Link href="/settings?section=ai-memory" className="text-xs underline text-[var(--warm-grey)]">
Edit AI Memory
</Link>
</div>
<div className="flex gap-1 mb-4">
{(["chat", "knows", "preview"] as const).map((t) => (
<button
key={t}
type="button"
className={`retro-btn text-xs ${tab === t ? "retro-btn-primary" : ""}`}
onClick={() => setTab(t)}
>
{t === "knows" ? "What I know" : t === "preview" ? "Context preview" : "Chat"}
</button>
))}
</div>
{tab === "chat" && (
<div className="grid md:grid-cols-4 gap-4">
<div className="retro-window p-2 md:col-span-1 max-h-64 overflow-y-auto">
<button type="button" className="retro-btn text-xs w-full mb-2" onClick={() => newSession.mutate()}>
New conversation
</button>
{(sessionsData?.sessions ?? []).map((s: { id: string; title: string }) => (
<button
key={s.id}
type="button"
className={`block w-full text-left text-xs p-1 truncate ${sessionId === s.id ? "font-bold" : ""}`}
onClick={() => setSessionId(s.id)}
>
{s.title}
</button>
))}
</div>
<div className="retro-window md:col-span-3 flex flex-col min-h-[400px]">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((m: { id: string; role: string; content: string; metadata?: { offline?: boolean } }) => (
<div key={m.id} className={`p-3 rounded text-sm ${m.role === "user" ? "bg-white/50 ml-8" : "bg-[var(--xp-blue)]/10 mr-8"}`}>
<span className="text-xs font-bold">{m.role === "user" ? "You" : "Mentor"}</span>
{m.metadata?.offline && (
<span className="text-[10px] ml-2 opacity-60">(offline/local)</span>
)}
<p className="mt-1 whitespace-pre-wrap">{m.content}</p>
</div>
))}
<div ref={bottomRef} />
</div>
<div className="p-2 flex flex-wrap gap-1 border-t">
{SUGGESTED.map((q) => (
<button
key={q}
type="button"
className="retro-btn text-[10px]"
onClick={() => send.mutate(q)}
disabled={send.isPending}
>
{q}
</button>
))}
</div>
<div className="p-3 flex gap-2 border-t">
<input
className="retro-window-inset flex-1 p-2 text-sm"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())}
placeholder="Ask the mentor..."
/>
<button
type="button"
className="retro-btn retro-btn-primary"
disabled={!input.trim() || send.isPending}
onClick={() => send.mutate(input.trim())}
>
Send
</button>
</div>
</div>
</div>
)}
{tab === "knows" && knowsData && (
<div className="retro-window p-4 space-y-4">
{knowsData.summary && (
<div>
<h2 className="font-bold text-sm mb-1">Profile summary</h2>
<p className="text-sm whitespace-pre-wrap">{knowsData.summary}</p>
</div>
)}
{knowsData.grouped?.map((g: { category: string; label: string; items: { title: string; content: string }[] }) => (
<div key={g.category}>
<h3 className="font-bold text-sm">{g.label}</h3>
<ul className="text-sm list-disc pl-4">
{g.items.map((m) => (
<li key={m.title}>{m.title}: {m.content}</li>
))}
</ul>
</div>
))}
{!knowsData.summary && !knowsData.grouped?.length && (
<p className="text-sm italic">No memories saved yet. Add some in Settings AI Memory.</p>
)}
</div>
)}
{tab === "preview" && (
<div className="retro-window p-4 space-y-3">
<input
className="retro-window-inset w-full p-2 text-sm"
placeholder="Sample message to preview context..."
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button type="button" className="retro-btn text-xs" onClick={() => refetchPreview()}>
Refresh preview
</button>
<pre className="text-xs whitespace-pre-wrap bg-black/5 p-3 rounded max-h-96 overflow-y-auto">
{previewData?.formatted ?? "Loading..."}
</pre>
<p className="text-[10px] text-[var(--warm-grey)]">
~{previewData?.tokenEstimate ?? 0} tokens estimated · {previewData?.memoryIds?.length ?? 0} memories selected
</p>
</div>
)}
</div>
</AppShell>
);
}

122
apps/web/src/app/page.tsx Executable file
View File

@@ -0,0 +1,122 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { AppShell } from "@/components/layout/app-shell";
import { CharacterCard } from "@/components/features/character-card";
import { TodaysAdventure } from "@/components/features/todays-adventure";
import { DailyReflection } from "@/components/features/daily-reflection";
import { QuestGiverSidebar, ReadingWidget } from "@/components/features/sidebar-widgets";
import { DaySwitcher } from "@/components/features/day-switcher";
import { CatchUpCard } from "@/components/features/catch-up-card";
import { QuickLogPanel } from "@/components/features/quick-log-panel";
import { formatDisplayDate, todayString } from "@/lib/dates-client";
async function fetchDashboard(date?: string) {
const url = date ? `/api/dashboard?date=${date}` : "/api/dashboard";
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to load dashboard");
return res.json();
}
export default function HomePage() {
const [activeDate, setActiveDate] = useState<string | undefined>(undefined);
const { data, isLoading, error } = useQuery({
queryKey: ["dashboard", activeDate],
queryFn: () => fetchDashboard(activeDate),
});
if (isLoading) {
return (
<AppShell>
<div className="p-8 text-center">Loading your adventure...</div>
</AppShell>
);
}
if (error || !data) {
return (
<AppShell>
<div className="p-8 text-center text-[var(--muted-rose)]">
Could not load dashboard. Is the database running?
</div>
</AppShell>
);
}
const date = data.today?.date ?? data.dateContext?.logicalToday ?? todayString();
const ctx = data.dateContext;
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
{ctx && (
<DaySwitcher
activeDate={date}
logicalToday={ctx.logicalToday}
logicalYesterday={ctx.logicalYesterday}
isGraceWindow={ctx.isGraceWindow}
onSelectDate={setActiveDate}
/>
)}
<p className="text-sm text-[var(--warm-grey)] mb-4">
{formatDisplayDate(date)}
</p>
{data.catchUpGaps?.length > 0 && (
<CatchUpCard gaps={data.catchUpGaps} onSelectDate={setActiveDate} />
)}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
<div className="lg:col-span-4">
<CharacterCard
displayName={data.user.displayName}
currentTitle={data.user.currentTitle}
level={data.progress.level}
totalXp={data.progress.totalXp}
portraitConfig={data.user.portraitConfig}
scores={{
consistencyScore: data.progress.consistencyScore,
disciplineScore: data.progress.disciplineScore,
learningScore: data.progress.learningScore,
spiritualScore: data.progress.spiritualScore,
healthScore: data.progress.healthScore,
readingScore: data.progress.readingScore,
}}
/>
</div>
<div className="lg:col-span-5 space-y-4">
{data.today && (
<TodaysAdventure
date={date}
chapter={data.progress.currentChapter}
journeyDay={data.progress.journeyDay}
isRestDay={data.today.isRestDay}
isCustomized={data.today.isCustomized}
workHoursTarget={data.today.workHoursTarget}
dayMode={data.today.dayMode ?? "normal"}
isBackfilled={data.today.isBackfilled ?? false}
items={data.today.items}
todos={data.today.todos}
/>
)}
<QuickLogPanel date={date} />
<DailyReflection
date={date}
initial={data.reflection}
dateLabel={formatDisplayDate(date)}
/>
</div>
<div className="lg:col-span-3 space-y-4">
<QuestGiverSidebar suggestions={data.suggestions} />
<ReadingWidget
activeBooks={data.reading.activeBooks}
streak={data.reading.streak}
weeklyPages={data.reading.weeklyPages}
weeklyGoal={data.reading.weeklyGoal}
/>
</div>
</div>
</div>
</AppShell>
);
}

168
apps/web/src/app/review/page.tsx Executable file
View File

@@ -0,0 +1,168 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import { useState } from "react";
import { weekStartString } from "@/lib/dates-client";
import { useUiStore } from "@/stores/ui";
export default function ReviewPage() {
const weekStart = weekStartString();
const qc = useQueryClient();
const showXpToast = useUiStore((s) => s.showXpToast);
const [intention, setIntention] = useState("");
const [page, setPage] = useState(0);
const { data: review, isLoading } = useQuery({
queryKey: ["review", weekStart],
queryFn: async () => {
const res = await fetch(`/api/reviews/${weekStart}`);
const data = await res.json();
if (!data || data.error) return null;
return data;
},
});
const generate = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/reviews/${weekStart}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "generate" }),
});
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["review", weekStart] });
showXpToast(150, "Weekly review opened");
},
});
const saveIntention = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/reviews/${weekStart}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intention }),
});
return res.json();
},
});
if (isLoading) {
return (
<AppShell>
<div className="p-8 text-center">Turning the page...</div>
</AppShell>
);
}
if (!review) {
return (
<AppShell>
<div className="p-8 text-center max-w-md mx-auto">
<h1 className="font-bold text-lg mb-4">Weekly Review</h1>
<p className="serif mb-4">
Your weekly chapter is ready to be written. Generate your review to see patterns,
progress, and a letter from the Guide.
</p>
<button className="retro-btn retro-btn-primary" onClick={() => generate.mutate()}>
Generate This Week&apos;s Review
</button>
</div>
</AppShell>
);
}
const content = review.content as Record<string, unknown>;
const pages = [
{
title: "XP Earned",
body: (
<div>
<p className="text-3xl font-bold text-[var(--gold-trim)] mb-2">
+{review.xpEarned} XP
</p>
<ul className="text-sm space-y-1">
{((content.patterns as string[]) ?? []).map((p, i) => (
<li key={i}>· {p}</li>
))}
</ul>
</div>
),
},
{
title: "Encouragement",
body: <p className="serif">{content.encouragement as string}</p>,
},
{
title: "Mentor's Letter",
body: (
<div className="serif text-base leading-relaxed whitespace-pre-wrap">
{review.mentorLetter}
</div>
),
},
{
title: "Next Week",
body: (
<div>
<p className="mb-2 text-sm">{content.focus_suggestion as string}</p>
<input
className="retro-window-inset w-full p-2 mb-2"
placeholder="Your one intention for next week..."
value={intention || review.userIntention || ""}
onChange={(e) => setIntention(e.target.value)}
onBlur={() => saveIntention.mutate()}
/>
</div>
),
},
];
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 min-h-full parchment-bg">
<div className="max-w-4xl mx-auto retro-window p-6 md:p-10">
<div className="text-center mb-8 border-b border-[var(--warm-grey)] pb-4">
<p className="text-xs text-[var(--warm-grey)]">Week of {weekStart}</p>
<h1 className="serif text-2xl font-bold">Turning the Page</h1>
</div>
<div className="min-h-[300px]">
<h2 className="font-bold mb-4">{pages[page].title}</h2>
{pages[page].body}
</div>
<div className="flex justify-between items-center mt-8">
<button
className="retro-btn"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
Previous
</button>
<div className="flex gap-1">
{pages.map((_, i) => (
<button
key={i}
className={`w-2 h-2 rounded-full ${
i === page ? "bg-[var(--xp-blue)]" : "bg-gray-300"
}`}
onClick={() => setPage(i)}
/>
))}
</div>
<button
className="retro-btn"
disabled={page === pages.length - 1}
onClick={() => setPage(page + 1)}
>
Next
</button>
</div>
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,310 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { ThemeGallery } from "@/components/theme/theme-gallery";
import { TemplateEditor } from "@/components/features/template-editor";
import { AiHealthPanel } from "@/components/settings/ai-health-panel";
import { AiConfigPanel } from "@/components/settings/ai-config-panel";
import {
PromptTemplateEditor,
SystemPromptsPanel,
} from "@/components/settings/prompt-template-editor";
import { ActionHistoryPanel } from "@/components/settings/action-history-panel";
import { AiMemoryPanel } from "@/components/settings/ai-memory-panel";
const SECTIONS = [
{ id: "profile", label: "Profile" },
{ id: "spiritual", label: "Spiritual Labels" },
{ id: "templates", label: "Templates" },
{ id: "appearance", label: "Appearance" },
{ id: "ai-config", label: "AI Configuration" },
{ id: "ai-memory", label: "AI Memory" },
{ id: "ai-templates", label: "AI Templates" },
{ id: "system-prompts", label: "System Prompts" },
{ id: "ai-health", label: "AI Health" },
{ id: "action-history", label: "Action History" },
{ id: "notifications", label: "Notifications" },
{ id: "data", label: "Data Export" },
];
export default function SettingsPage() {
return (
<Suspense fallback={<AppShell><div className="p-8">Loading settings...</div></AppShell>}>
<SettingsPageContent />
</Suspense>
);
}
function SettingsPageContent() {
const searchParams = useSearchParams();
const [section, setSection] = useState("profile");
const qc = useQueryClient();
useEffect(() => {
const s = searchParams.get("section");
if (s && SECTIONS.some((sec) => sec.id === s)) setSection(s);
}, [searchParams]);
const { data } = useQuery({
queryKey: ["settings"],
queryFn: async () => {
const res = await fetch("/api/settings");
return res.json();
},
});
const [profile, setProfile] = useState({ displayName: "", currentTitle: "" });
const [spiritual, setSpiritual] = useState({
prayerLabels: [] as string[],
litanyLabels: [] as string[],
});
const [readingGoal, setReadingGoal] = useState(50);
const [soundsEnabled, setSoundsEnabled] = useState(false);
useEffect(() => {
if (data?.user) {
setProfile({
displayName: data.user.displayName,
currentTitle: data.user.currentTitle ?? "",
});
}
if (data?.spiritual) {
setSpiritual({
prayerLabels: data.spiritual.prayerLabels,
litanyLabels: data.spiritual.litanyLabels,
});
}
if (data?.settings) {
setReadingGoal((data.settings.weekly_reading_goal as number) ?? 50);
setSoundsEnabled((data.settings.sounds_enabled as boolean) ?? false);
}
}, [data]);
const save = useMutation({
mutationFn: async (body: Record<string, unknown>) => {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings"] }),
});
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 flex flex-col md:flex-row gap-4 min-h-full">
<div className="retro-window w-full md:w-48 flex-shrink-0">
<div className="retro-titlebar">Control Panel</div>
<nav className="p-2 max-h-[70vh] overflow-y-auto">
{SECTIONS.map((s) => (
<button
key={s.id}
className={`block w-full text-left px-2 py-1.5 text-sm hover:bg-white/40 ${
section === s.id ? "bg-white/60 font-bold" : ""
}`}
onClick={() => setSection(s.id)}
>
{s.label}
</button>
))}
</nav>
</div>
<div className="retro-window flex-1 p-4">
{section === "profile" && (
<div className="space-y-3">
<h2 className="font-bold">Profile</h2>
<Field label="Display Name" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} />
<Field label="Title" value={profile.currentTitle} onChange={(v) => setProfile({ ...profile, currentTitle: v })} />
<button className="retro-btn retro-btn-primary" onClick={() => save.mutate({ profile })}>
Save
</button>
</div>
)}
{section === "spiritual" && (
<div className="space-y-3">
<h2 className="font-bold">Spiritual Labels</h2>
<p className="text-xs text-[var(--color-text-muted)]">Customize your prayer and litany checkboxes.</p>
{spiritual.prayerLabels.map((l, i) => (
<Field
key={`p-${i}`}
label={`Prayer ${i + 1}`}
value={l}
onChange={(v) => {
const next = [...spiritual.prayerLabels];
next[i] = v;
setSpiritual({ ...spiritual, prayerLabels: next });
}}
/>
))}
{spiritual.litanyLabels.map((l, i) => (
<Field
key={`l-${i}`}
label={`Litany ${i + 1}`}
value={l}
onChange={(v) => {
const next = [...spiritual.litanyLabels];
next[i] = v;
setSpiritual({ ...spiritual, litanyLabels: next });
}}
/>
))}
<button className="retro-btn retro-btn-primary" onClick={() => save.mutate({ spiritual })}>
Save
</button>
</div>
)}
{section === "templates" && <TemplateEditor />}
{section === "appearance" && <ThemeGallery />}
{section === "ai-config" && <AiConfigPanel />}
{section === "ai-memory" && (
<div>
<h2 className="font-bold mb-3">AI Memory</h2>
<AiMemoryPanel />
</div>
)}
{section === "ai-templates" && <PromptTemplateEditor />}
{section === "system-prompts" && <SystemPromptsPanel />}
{section === "ai-health" && <AiHealthPanel />}
{section === "action-history" && <ActionHistoryPanel />}
{section === "notifications" && (
<div className="space-y-6">
<DayBoundarySettings />
<div className="space-y-3">
<h2 className="font-bold">Notifications</h2>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={soundsEnabled}
onChange={(e) => setSoundsEnabled(e.target.checked)}
/>
UI sounds (off by default)
</label>
<Field
label="Weekly reading goal (pages)"
value={String(readingGoal)}
onChange={(v) => setReadingGoal(Number(v))}
/>
<button
className="retro-btn retro-btn-primary"
onClick={() =>
save.mutate({
settings: {
sounds_enabled: soundsEnabled,
weekly_reading_goal: readingGoal,
},
})
}
>
Save
</button>
</div>
</div>
)}
{section === "data" && (
<div className="space-y-3">
<h2 className="font-bold">Data Export</h2>
<a href="/api/export/json" className="retro-btn retro-btn-primary inline-block" download>
Download JSON Backup
</a>
<p className="text-xs text-[var(--color-text-muted)]">
Nightly pg_dump backups can be configured via scripts/backup.sh on your server.
</p>
</div>
)}
<div className="mt-8 pt-4 border-t">
<button
className="retro-btn text-sm"
onClick={async () => {
await fetch("/api/auth/login", { method: "DELETE" });
window.location.href = "/login";
}}
>
Sign Out
</button>
</div>
</div>
</div>
</AppShell>
);
}
function DayBoundarySettings() {
const [hour, setHour] = useState(0);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
fetch("/api/settings/day-boundary")
.then((r) => r.json())
.then((d) => {
setHour(d.hour ?? 0);
setLoaded(true);
});
}, []);
const save = async () => {
await fetch("/api/settings/day-boundary", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hour }),
});
};
if (!loaded) return null;
return (
<div className="space-y-3">
<h2 className="font-bold">Day boundary</h2>
<p className="text-xs text-[var(--warm-grey)]">
When your day rolls over. Useful if you often log after midnight.
</p>
<label className="flex items-center gap-2 text-sm">
Day starts at
<select
className="retro-window-inset p-1"
value={hour}
onChange={(e) => setHour(Number(e.target.value))}
>
<option value={0}>Midnight (12am)</option>
<option value={1}>1:00am</option>
<option value={2}>2:00am</option>
<option value={3}>3:00am</option>
</select>
</label>
<button type="button" className="retro-btn retro-btn-primary text-sm" onClick={save}>
Save day boundary
</button>
</div>
);
}
function Field({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div>
<label className="text-xs font-bold block mb-1">{label}</label>
<input
className="retro-window-inset w-full p-2"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
import { useState } from "react";
import { SimpleBarChart, SimpleLineChart, HeatmapGrid } from "@/components/charts/simple-charts";
const TABS = ["overview", "reading", "work", "exercise", "spiritual", "learning"] as const;
export default function StatisticsPage() {
const [tab, setTab] = useState<(typeof TABS)[number]>("overview");
const { data: overview } = useQuery({
queryKey: ["stats", "overview"],
queryFn: async () => {
const res = await fetch("/api/stats/overview");
return res.json();
},
enabled: tab === "overview",
});
const { data: domain } = useQuery({
queryKey: ["stats", tab],
queryFn: async () => {
const res = await fetch(`/api/stats/${tab}?range=30`);
return res.json();
},
enabled: tab !== "overview",
});
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
<h1 className="font-bold text-lg mb-4">Statistics Hall</h1>
<div className="flex flex-wrap gap-1 mb-4">
{TABS.map((t) => (
<button
key={t}
className={`retro-btn text-xs capitalize ${tab === t ? "retro-btn-primary" : ""}`}
onClick={() => setTab(t)}
>
{t}
</button>
))}
<a href="/yearly" className="retro-btn text-xs">
Year in Adventure
</a>
</div>
<div className="retro-window p-4">
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3">Performance Monitor</div>
{tab === "overview" && overview && (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<StatBox label="Total XP" value={overview.totalXp} />
<StatBox label="XP This Month" value={overview.xpThisMonth} />
<StatBox label="Books Done" value={overview.booksCompleted} />
<StatBox label="Pages This Month" value={overview.pagesThisMonth} />
</div>
<ChartFrame title="Consistency (30 days)">
<SimpleLineChart data={overview.consistencyTrend} />
</ChartFrame>
<ChartFrame title="XP by Category">
<SimpleBarChart
data={overview.xpByCategory.map((c: { category: string; amount: number }) => ({
label: c.category,
amount: c.amount,
}))}
dataKey="amount"
color="#3A6EA5"
/>
</ChartFrame>
</div>
)}
{tab === "reading" && domain && (
<ChartFrame title="Pages per day">
<SimpleBarChart data={domain.days} dataKey="pages" color="#C8A951" />
</ChartFrame>
)}
{tab === "work" && domain && (
<ChartFrame title="Work hours">
<SimpleBarChart data={domain.days} dataKey="hours" color="#3A6EA5" />
</ChartFrame>
)}
{tab === "exercise" && domain && (
<ChartFrame title="Exercise sessions">
<HeatmapGrid data={domain.days?.slice(-28) ?? []} />
</ChartFrame>
)}
{tab === "spiritual" && domain && (
<ChartFrame title="Prayer checks per day">
<SimpleBarChart data={domain.days} dataKey="prayer" color="#3A6EA5" />
</ChartFrame>
)}
{tab === "learning" && domain && (
<ChartFrame title="Classes attended">
<SimpleBarChart data={domain.days} dataKey="classes" color="#74B749" />
</ChartFrame>
)}
</div>
</div>
</AppShell>
);
}
function StatBox({ label, value }: { label: string; value: number }) {
return (
<div className="retro-window-inset p-3">
<p className="text-xs text-[var(--warm-grey)]">{label}</p>
<p className="text-xl font-bold">{value.toLocaleString()}</p>
</div>
);
}
function ChartFrame({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border-4 border-gray-500 rounded-lg p-2 bg-gray-800">
<p className="text-white text-xs mb-2 font-mono">{title}</p>
<div className="bg-[#1a1a2e] p-2 rounded">{children}</div>
</div>
);
}

328
apps/web/src/app/teacher/page.tsx Executable file
View File

@@ -0,0 +1,328 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { AppShell } from "@/components/layout/app-shell";
type LessonContent = {
title?: string;
introduction?: string;
objectives?: string[];
readingSteps?: string[];
reflectionPrompt?: string;
flashcards: { front: string; back: string }[];
quiz: { question: string; options: string[]; answer: number }[];
assignment: string;
};
type Lesson = {
id: string;
topic: string;
content: LessonContent;
status: string;
completedNote?: string | null;
createdAt: string;
source?: "ai" | "fallback";
fallbackReason?: "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout";
};
export default function TeacherPage() {
return (
<Suspense fallback={<AppShell><div className="p-8">Loading...</div></AppShell>}>
<TeacherPageContent />
</Suspense>
);
}
function TeacherPageContent() {
const searchParams = useSearchParams();
const qc = useQueryClient();
const [topic, setTopic] = useState("");
const [explorationId, setExplorationId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [content, setContent] = useState<LessonContent | null>(null);
const [contentSource, setContentSource] = useState<"ai" | "fallback" | null>(null);
const [fallbackReason, setFallbackReason] = useState<string | null>(null);
const [revealedQuiz, setRevealedQuiz] = useState<Record<number, number | null>>({});
const [completionNote, setCompletionNote] = useState("");
useEffect(() => {
const t = searchParams.get("topic");
const e = searchParams.get("explorationId");
if (t) setTopic(t);
if (e) setExplorationId(e);
}, [searchParams]);
const { data: history = [], isLoading } = useQuery<Lesson[]>({
queryKey: ["teacher"],
queryFn: async () => {
const res = await fetch("/api/teacher");
if (!res.ok) throw new Error("Failed to load lessons");
return res.json();
},
});
const loadLesson = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/teacher/${id}`);
if (!res.ok) throw new Error("Failed to load lesson");
return res.json();
},
onSuccess: (data: Lesson) => {
setSelectedId(data.id);
setContent(data.content);
setContentSource(data.source ?? null);
setFallbackReason(data.fallbackReason ?? null);
setTopic(data.topic);
setRevealedQuiz({});
setCompletionNote(data.completedNote ?? "");
},
});
const generate = useMutation({
mutationFn: async () => {
const res = await fetch("/api/teacher", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
topic,
...(explorationId ? { explorationId } : {}),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? "Generation failed");
}
return res.json();
},
onSuccess: (data: Lesson) => {
setContent(data.content);
setContentSource(data.source ?? null);
setFallbackReason(data.fallbackReason ?? null);
setSelectedId(data.id);
setRevealedQuiz({});
qc.invalidateQueries({ queryKey: ["teacher"] });
},
});
const complete = useMutation({
mutationFn: async () => {
if (!selectedId) throw new Error("No lesson selected");
const res = await fetch(`/api/teacher/${selectedId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "complete", completedNote: completionNote }),
});
if (!res.ok) throw new Error("Failed");
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["teacher"] }),
});
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4">
<h1 className="font-bold text-lg mb-2">The Teacher</h1>
<p className="text-sm text-[var(--warm-grey)] mb-4">
Ask the Guide to create flashcards, quizzes, and research assignments on any topic.
</p>
<div className="flex gap-2 mb-6">
<input
className="retro-window-inset flex-1 p-2"
placeholder="e.g. How Roman roads were built"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
<button
className="retro-btn retro-btn-primary"
onClick={() => generate.mutate()}
disabled={!topic || generate.isPending}
>
{generate.isPending ? "Teaching..." : "Teach Me"}
</button>
</div>
{generate.isError && (
<p className="text-sm text-[var(--muted-rose)] mb-4">
{generate.error instanceof Error
? generate.error.message
: "Could not generate lesson. Check AI Health."}
</p>
)}
{contentSource === "fallback" && (
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
{fallbackReason === "timeout"
? "AI timed out — your model may be too large for this machine. Try a smaller model (e.g. llama3.2:1b) in Settings or .env."
: fallbackReason === "generation_failed"
? "AI could not generate a lesson (check that your Ollama model is installed) — showing a basic offline template."
: "AI is offline or unavailable — showing a basic offline lesson template."}
</p>
)}
{content && (
<div className="space-y-4">
{content.title && (
<h2 className="font-bold text-base">{content.title}</h2>
)}
{content.introduction && (
<Section title="Introduction">
<p className="serif text-sm">{content.introduction}</p>
</Section>
)}
{content.objectives && content.objectives.length > 0 && (
<Section title="Learning Objectives">
<ul className="text-sm list-disc pl-5 space-y-1">
{content.objectives.map((o, i) => (
<li key={i}>{o}</li>
))}
</ul>
</Section>
)}
{content.readingSteps && content.readingSteps.length > 0 && (
<Section title="Reading & Research">
<ol className="text-sm list-decimal pl-5 space-y-1">
{content.readingSteps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</Section>
)}
<Section title="Flashcards">
<div className="grid gap-2">
{content.flashcards.map((c, i) => (
<Flashcard key={i} front={c.front} back={c.back} />
))}
</div>
</Section>
<Section title="Quiz">
{content.quiz.map((q, i) => (
<QuizQuestion
key={i}
question={q.question}
options={q.options}
answer={q.answer}
selected={revealedQuiz[i] ?? null}
onSelect={(idx) => setRevealedQuiz({ ...revealedQuiz, [i]: idx })}
/>
))}
</Section>
<Section title="Research Assignment">
<p className="serif">{content.assignment}</p>
{content.reflectionPrompt && (
<p className="text-sm italic mt-2 text-[var(--warm-grey)]">
Reflection: {content.reflectionPrompt}
</p>
)}
{selectedId && (
<div className="mt-4">
<textarea
className="retro-window-inset w-full p-2 text-sm min-h-[60px]"
placeholder="What did you learn from this assignment?"
value={completionNote}
onChange={(e) => setCompletionNote(e.target.value)}
/>
<button
className="retro-btn retro-btn-primary mt-2 text-sm"
onClick={() => complete.mutate()}
disabled={complete.isPending}
>
Mark assignment complete
</button>
</div>
)}
</Section>
</div>
)}
{history.length > 0 && (
<div className="mt-8">
<h2 className="font-bold mb-2">Past Lessons</h2>
{isLoading ? (
<p className="text-sm">Loading...</p>
) : (
<ul className="text-sm space-y-1">
{history.map((h) => (
<li key={h.id}>
<button
className={`underline text-left ${selectedId === h.id ? "font-bold" : ""}`}
onClick={() => loadLesson.mutate(h.id)}
>
{h.status === "completed" ? "✓ " : ""}
{h.topic}
</button>
<span className="text-xs text-[var(--warm-grey)] ml-2">
{new Date(h.createdAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
</AppShell>
);
}
function Flashcard({ front, back }: { front: string; back: string }) {
const [flipped, setFlipped] = useState(false);
return (
<button
className="retro-window-inset p-3 text-left w-full"
onClick={() => setFlipped(!flipped)}
>
<p className="font-bold text-sm">{flipped ? back : front}</p>
<p className="text-xs text-[var(--warm-grey)] mt-1">{flipped ? "Back" : "Tap to flip"}</p>
</button>
);
}
function QuizQuestion({
question,
options,
answer,
selected,
onSelect,
}: {
question: string;
options: string[];
answer: number;
selected: number | null;
onSelect: (idx: number) => void;
}) {
return (
<div className="retro-window-inset p-3 mb-2">
<p className="font-bold text-sm mb-2">{question}</p>
<ul className="text-sm space-y-1">
{options.map((o, j) => {
const picked = selected === j;
const showResult = selected !== null;
const isCorrect = j === answer;
let cls = "";
if (showResult && picked && isCorrect) cls = "text-[var(--bliss-green)] font-bold";
if (showResult && picked && !isCorrect) cls = "text-[var(--muted-rose)]";
if (showResult && !picked && isCorrect) cls = "text-[var(--bliss-green)]";
return (
<li key={j}>
<button className={`text-left ${cls}`} onClick={() => onSelect(j)}>
{String.fromCharCode(65 + j)}. {o}
</button>
</li>
);
})}
</ul>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="retro-window p-4">
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3">{title}</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AppShell } from "@/components/layout/app-shell";
export default function YearlyPage() {
const year = new Date().getFullYear();
const { data: overview } = useQuery({
queryKey: ["stats", "overview"],
queryFn: async () => {
const res = await fetch("/api/stats/overview");
return res.json();
},
});
const { data: achievements } = useQuery({
queryKey: ["achievements"],
queryFn: async () => {
const res = await fetch("/api/achievements");
return res.json();
},
});
return (
<AppShell>
<div className="p-4 pb-20 md:pb-4 parchment-bg min-h-full">
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-sm text-[var(--warm-grey)] mb-2">{year}</p>
<h1 className="serif text-4xl font-bold mb-6">Your Year in Adventure</h1>
<div className="retro-window p-8 text-left space-y-6">
<ScrollSection title="Total XP" value={overview?.totalXp?.toLocaleString() ?? "—"} />
<ScrollSection title="Books Completed" value={overview?.booksCompleted ?? "—"} />
<ScrollSection title="Pages Read" value={overview?.totalPagesRead?.toLocaleString() ?? "—"} />
<ScrollSection
title="Achievements Unlocked"
value={achievements?.unlocked?.length ?? 0}
/>
<p className="serif text-center pt-6 border-t italic">
Another year on the long road. The adventure continues.
</p>
</div>
</div>
</div>
</AppShell>
);
}
function ScrollSection({ title, value }: { title: string; value: string | number }) {
return (
<div className="flex justify-between items-baseline border-b border-[var(--parchment-dark)] pb-3">
<span className="font-bold">{title}</span>
<span className="text-2xl text-[var(--gold-trim)]">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
interface BarChartProps {
data: { label?: string; date?: string; value?: number; amount?: number; hours?: number; pages?: number }[];
dataKey: string;
max?: number;
color?: string;
}
export function SimpleBarChart({ data, dataKey, max, color = "#74B749" }: BarChartProps) {
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0));
const peak = max ?? Math.max(...values, 1);
return (
<div className="flex items-end gap-0.5 h-[200px] pt-4">
{values.map((v, i) => (
<div
key={i}
className="flex-1 min-w-[4px] rounded-t-sm transition-all"
style={{
height: `${(v / peak) * 100}%`,
backgroundColor: color,
minHeight: v > 0 ? 2 : 0,
}}
title={`${data[i].date ?? data[i].label ?? i}: ${v}`}
/>
))}
</div>
);
}
export function SimpleLineChart({
data,
dataKey = "value",
color = "#3A6EA5",
}: {
data: { date: string; value: number }[];
dataKey?: string;
color?: string;
}) {
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0));
const max = Math.max(...values, 100);
const width = 100;
const height = 100;
const points = values
.map((v, i) => {
const x = (i / Math.max(values.length - 1, 1)) * width;
const y = height - (v / max) * height;
return `${x},${y}`;
})
.join(" ");
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[200px] bg-[#1a1a2e] rounded p-2">
<polyline
fill="none"
stroke={color}
strokeWidth="1.5"
points={points}
/>
</svg>
);
}
export function HeatmapGrid({ data }: { data: { date: string; done: boolean }[] }) {
return (
<div className="grid grid-cols-7 gap-1">
{data.map((d) => (
<div
key={d.date}
className={`aspect-square rounded-sm ${d.done ? "bg-[var(--bliss-green)]" : "bg-gray-300"}`}
title={d.date}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,147 @@
import type { DailyAdventureItemData } from "@adventureos/shared";
export function AdventureItemRow({
item,
disabled,
customize,
workTarget,
onUpdate,
onMetaUpdate,
onRemove,
}: {
item: DailyAdventureItemData;
disabled: boolean;
customize: boolean;
workTarget: number;
onUpdate: (value?: Record<string, unknown>, state?: string) => void;
onMetaUpdate: (updates: { label?: string; enabled?: boolean }) => void;
onRemove: () => void;
}) {
if (item.type === "duration") {
const hours = (item.value?.hours as number) ?? 0;
const target = workTarget;
return (
<div className="flex items-center gap-3 py-1">
{customize ? (
<input
className="retro-window-inset w-24 p-1 text-sm"
value={item.label}
onChange={(e) => onMetaUpdate({ label: e.target.value })}
/>
) : (
<span className="w-24 text-sm font-medium shrink-0">{item.label}</span>
)}
<div className="flex-1 skill-bar-track h-3">
<div
className="skill-bar-fill h-full"
style={{ width: `${Math.min(100, (hours / target) * 100)}%` }}
/>
</div>
<span className="text-xs w-16 text-right">
{hours.toFixed(1)} / {target}h
</span>
<button
className="retro-btn text-xs px-2"
disabled={disabled}
onClick={() => {
const next = Math.min(target, hours + 0.5);
onUpdate({ hours: next });
}}
>
+30m
</button>
{customize && item.isCustom && (
<button className="text-xs text-[var(--muted-rose)]" onClick={onRemove}>
</button>
)}
{customize && !item.isCustom && (
<label className="text-xs flex items-center gap-1">
<input
type="checkbox"
checked={item.enabled}
onChange={() => onMetaUpdate({ enabled: !item.enabled })}
/>
On
</label>
)}
</div>
);
}
if (item.type === "checkbox" || item.type === "timeblock") {
const done = item.state === "done";
return (
<div className="flex items-center gap-2 py-1">
<label className="flex items-center gap-2 cursor-pointer flex-1">
<input
type="checkbox"
checked={done}
disabled={disabled}
onChange={() => onUpdate({ done: !done }, done ? "blank" : "done")}
className="w-4 h-4"
/>
{customize ? (
<input
className="retro-window-inset flex-1 p-1 text-sm"
value={item.label}
onChange={(e) => onMetaUpdate({ label: e.target.value })}
/>
) : (
<span className={done ? "line-through opacity-70" : ""}>{item.label}</span>
)}
{item.config?.scheduledTime && (
<span className="text-xs text-[var(--warm-grey)]">
{item.config.scheduledTime as string}
</span>
)}
</label>
{customize && item.isCustom && (
<button className="text-xs text-[var(--muted-rose)]" onClick={onRemove}>
</button>
)}
</div>
);
}
if (item.type === "reading") {
return (
<div className="flex items-center gap-2 py-1">
<input
type="checkbox"
checked={item.state === "done" || item.state === "partial"}
disabled={disabled}
onChange={() =>
onUpdate(
{ pages: item.state === "blank" ? 10 : 0 },
item.state === "blank" ? "partial" : "blank"
)
}
className="w-4 h-4"
/>
<span>{item.label}</span>
<span className="text-xs text-[var(--warm-grey)]">
{(item.value?.pages as number) ? `${item.value.pages} pages today` : "log in Library"}
</span>
</div>
);
}
if (item.type === "note") {
return (
<div className="py-1">
<p className="text-xs font-bold text-[var(--warm-grey)] mb-1">{item.label}</p>
<textarea
className="retro-window-inset w-full p-2 text-sm min-h-[60px] resize-y"
defaultValue={(item.value?.note as string) ?? ""}
disabled={disabled}
onBlur={(e) => onUpdate({ note: e.target.value })}
placeholder="Freeform notes for today..."
/>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,42 @@
"use client";
import { formatDisplayDate } from "@/lib/dates-client";
type Gap = { date: string; reason: string };
interface CatchUpCardProps {
gaps: Gap[];
onSelectDate: (date: string) => void;
}
const REASON_LABEL: Record<string, string> = {
empty: "No adventure logged",
incomplete: "Adventure started but empty",
no_reflection: "Reflection missing",
};
export function CatchUpCard({ gaps, onSelectDate }: CatchUpCardProps) {
if (gaps.length === 0) return null;
return (
<div className="retro-window mb-4">
<div className="retro-titlebar">Catch up gently</div>
<div className="p-3 space-y-2">
<p className="text-xs text-[var(--warm-grey)]">
A few recent days could use a note no pressure, just pick one if you like.
</p>
{gaps.map((g) => (
<button
key={g.date}
type="button"
className="retro-btn text-xs w-full text-left flex justify-between"
onClick={() => onSelectDate(g.date)}
>
<span>{formatDisplayDate(g.date)}</span>
<span className="opacity-70">{REASON_LABEL[g.reason] ?? g.reason}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { xpProgressInLevel } from "@adventureos/shared";
import { SkillBar } from "@/components/retro/skill-bar";
interface PortraitConfig {
skinTone: string;
hairColor: string;
clothingColor: string;
}
interface CharacterCardProps {
displayName: string;
currentTitle: string | null;
level: number;
totalXp: number;
portraitConfig: PortraitConfig;
scores: {
consistencyScore: number;
disciplineScore: number;
learningScore: number;
spiritualScore: number;
healthScore: number;
readingScore: number;
};
}
export function CharacterCard({
displayName,
currentTitle,
level,
totalXp,
portraitConfig,
scores,
}: CharacterCardProps) {
const xp = xpProgressInLevel(totalXp);
return (
<div className="retro-window h-full">
<div className="retro-titlebar">Character Overview</div>
<div className="p-4 flex gap-4">
<div className="flex-shrink-0">
<svg width="80" height="100" viewBox="0 0 80 100" className="drop-shadow">
<ellipse cx="40" cy="28" rx="22" ry="26" fill={portraitConfig.skinTone} />
<path
d="M18 30 Q40 8 62 30 Q58 50 40 48 Q22 50 18 30"
fill={portraitConfig.hairColor}
/>
<rect x="20" y="52" width="40" height="45" rx="4" fill={portraitConfig.clothingColor} />
<polygon
points="40,12 48,22 32,22"
fill="var(--gold-trim)"
stroke="#a08030"
strokeWidth="1"
/>
<text x="40" y="20" textAnchor="middle" fontSize="8" fill="#2c2416" fontWeight="bold">
{level}
</text>
</svg>
</div>
<div className="flex-1 min-w-0">
<h2 className="font-bold text-base truncate">{displayName}</h2>
<p className="text-xs text-[var(--gold-trim)] mb-2">
{currentTitle ?? "New Adventurer"}
</p>
<div className="text-xs mb-2">
Level {level} · {xp.current}/{xp.needed} XP
</div>
<div className="skill-bar-track mb-3">
<div className="skill-bar-fill skill-bar-fill-gold" style={{ width: `${xp.percent}%` }} />
</div>
<SkillBar label="Consistency" value={scores.consistencyScore} />
<SkillBar label="Discipline" value={scores.disciplineScore} />
<SkillBar label="Learning" value={scores.learningScore} />
<SkillBar label="Spiritual" value={scores.spiritualScore} />
<SkillBar label="Health" value={scores.healthScore} />
<SkillBar label="Reading" value={scores.readingScore} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
export function ChecklistItem({
label,
checks,
disabled,
onToggle,
}: {
label: string;
checks: boolean[];
disabled: boolean;
onToggle: (checks: boolean[]) => void;
}) {
return (
<div>
<p className="text-xs mb-1">{label}</p>
<div className="flex gap-1">
{checks.map((c, i) => (
<button
key={i}
disabled={disabled}
onClick={() => {
const next = [...checks];
next[i] = !next[i];
onToggle(next);
}}
className={`w-7 h-7 retro-window-inset text-xs flex items-center justify-center ${
c ? "bg-[var(--bliss-green)] text-white" : ""
}`}
>
{c ? "✓" : ""}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { useUiStore } from "@/stores/ui";
import { hasMeaningfulReflectionContent } from "@/lib/reflection-utils";
interface ReflectionProps {
date: string;
dateLabel?: string;
initial?: {
wentWell: string;
learned: string;
improveTomorrow: string;
} | null;
}
type ReflectionForm = {
wentWell: string;
learned: string;
improveTomorrow: string;
};
function formSnapshot(form: ReflectionForm) {
return JSON.stringify(form);
}
export function DailyReflection({ date, dateLabel, initial }: ReflectionProps) {
const [open, setOpen] = useState(true);
const [form, setForm] = useState<ReflectionForm>({
wentWell: initial?.wentWell ?? "",
learned: initial?.learned ?? "",
improveTomorrow: initial?.improveTomorrow ?? "",
});
const lastSaved = useRef(formSnapshot(form));
const qc = useQueryClient();
const showXpToast = useUiStore((s) => s.showXpToast);
const save = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/reflections/${date}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error("Failed");
return res.json() as Promise<ReflectionForm & { xpAwarded?: number }>;
},
onSuccess: (data) => {
lastSaved.current = formSnapshot(form);
qc.invalidateQueries({ queryKey: ["dashboard"] });
if (data.xpAwarded && data.xpAwarded > 0) {
showXpToast(data.xpAwarded, "Reflection saved");
}
},
});
const handleBlur = () => {
if (formSnapshot(form) === lastSaved.current) return;
if (!hasMeaningfulReflectionContent(form)) return;
save.mutate();
};
return (
<div className="retro-window">
<button
className="retro-titlebar w-full text-left"
onClick={() => setOpen(!open)}
>
Daily Reflection {dateLabel ? `· ${dateLabel}` : ""} {open ? "▼" : "▶"}
<span className="text-xs font-normal opacity-80">~3 minutes</span>
</button>
{open && (
<div className="p-4 space-y-3">
<Field
label="What went well?"
value={form.wentWell}
onChange={(v) => setForm({ ...form, wentWell: v })}
onBlur={handleBlur}
/>
<Field
label="What did I learn?"
value={form.learned}
onChange={(v) => setForm({ ...form, learned: v })}
onBlur={handleBlur}
/>
<Field
label="What should I improve tomorrow?"
value={form.improveTomorrow}
onChange={(v) => setForm({ ...form, improveTomorrow: v })}
onBlur={handleBlur}
/>
</div>
)}
</div>
);
}
function Field({
label,
value,
onChange,
onBlur,
}: {
label: string;
value: string;
onChange: (v: string) => void;
onBlur: () => void;
}) {
return (
<div>
<label className="text-xs font-bold block mb-1">{label}</label>
<input
className="retro-window-inset w-full p-2 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
/>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { formatDisplayDate } from "@/lib/dates-client";
interface DaySwitcherProps {
activeDate: string;
logicalToday: string;
logicalYesterday: string;
isGraceWindow: boolean;
onSelectDate: (date: string) => void;
}
export function DaySwitcher({
activeDate,
logicalToday,
logicalYesterday,
isGraceWindow,
onSelectDate,
}: DaySwitcherProps) {
const isYesterday = activeDate === logicalYesterday;
return (
<div className="flex flex-wrap items-center gap-2 mb-4">
<div className="flex gap-1 retro-window p-1">
<button
type="button"
className={`retro-btn text-xs ${activeDate === logicalToday ? "retro-btn-primary" : ""}`}
onClick={() => onSelectDate(logicalToday)}
>
Today
</button>
<button
type="button"
className={`retro-btn text-xs ${isYesterday ? "retro-btn-primary" : ""}`}
onClick={() => onSelectDate(logicalYesterday)}
>
Yesterday
</button>
</div>
{isGraceWindow && activeDate === logicalYesterday && (
<span className="text-xs italic text-[var(--xp-blue)]">
Still logging yesterday?
</span>
)}
{activeDate !== logicalToday && (
<span className="text-xs text-[var(--warm-grey)]">
Viewing {formatDisplayDate(activeDate)}
</span>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More