1
apps/web
41
apps/web/.gitignore
vendored
Executable 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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
39
apps/web/Dockerfile
Executable 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
apps/web/public/file.svg
Executable 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
@@ -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
@@ -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
@@ -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
@@ -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
1
apps/web/public/vercel.svg
Executable 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
@@ -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 |
58
apps/web/src/app/achievements/page.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/web/src/app/api/achievements/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
16
apps/web/src/app/api/actions/[id]/undo/route.ts
Executable 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
11
apps/web/src/app/api/actions/recent/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/adventures/[date]/apply-template/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
29
apps/web/src/app/api/adventures/[date]/items/[id]/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/adventures/[date]/items/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/adventures/[date]/quick-log/route.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/adventures/[date]/rest-day/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
32
apps/web/src/app/api/adventures/[date]/route.ts
Executable 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
26
apps/web/src/app/api/adventures/[date]/todos/[id]/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/adventures/[date]/todos/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
11
apps/web/src/app/api/adventures/catch-up/route.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/web/src/app/api/ai/chat/knows/route.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
40
apps/web/src/app/api/ai/chat/sessions/[id]/route.ts
Normal 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 ?? "");
|
||||||
|
});
|
||||||
|
}
|
||||||
19
apps/web/src/app/api/ai/chat/sessions/route.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
33
apps/web/src/app/api/ai/config/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
19
apps/web/src/app/api/ai/context/preview/route.ts
Normal 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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
21
apps/web/src/app/api/ai/health/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
40
apps/web/src/app/api/ai/memory/[id]/route.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
21
apps/web/src/app/api/ai/memory/learning/route.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
13
apps/web/src/app/api/ai/memory/reset/route.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
32
apps/web/src/app/api/ai/memory/route.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
13
apps/web/src/app/api/ai/memory/suggestions/route.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
54
apps/web/src/app/api/ai/memory/summary/rebuild/route.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
34
apps/web/src/app/api/ai/memory/summary/route.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
23
apps/web/src/app/api/ai/models/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/ai/suggestions/[id]/dismiss/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
26
apps/web/src/app/api/ai/suggestions/route.ts
Executable 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
64
apps/web/src/app/api/ai/templates/[key]/route.ts
Executable 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
12
apps/web/src/app/api/ai/templates/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
28
apps/web/src/app/api/auth/login/route.ts
Executable 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 });
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/books/[id]/log-pages/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/books/[id]/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
18
apps/web/src/app/api/books/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/web/src/app/api/cartographer/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
42
apps/web/src/app/api/cron/route.ts
Executable 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 });
|
||||||
|
}
|
||||||
8
apps/web/src/app/api/dashboard/route.ts
Executable 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));
|
||||||
|
}
|
||||||
29
apps/web/src/app/api/explorations/[id]/route.ts
Executable 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
33
apps/web/src/app/api/explorations/route.ts
Executable 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
25
apps/web/src/app/api/export/json/route.ts
Executable 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
31
apps/web/src/app/api/library/books/route.ts
Executable 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
30
apps/web/src/app/api/library/cover/[id]/route.ts
Executable 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/src/app/api/library/progress/[id]/route.ts
Executable 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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/web/src/app/api/library/status/route.ts
Executable 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
26
apps/web/src/app/api/reflections/[date]/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
37
apps/web/src/app/api/reviews/[weekStart]/route.ts
Executable 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
20
apps/web/src/app/api/settings/day-boundary/route.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
58
apps/web/src/app/api/settings/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/app/api/stats/[domain]/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
31
apps/web/src/app/api/teacher/[id]/route.ts
Executable 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
34
apps/web/src/app/api/teacher/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
32
apps/web/src/app/api/templates/[id]/items/route.ts
Executable 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
47
apps/web/src/app/api/templates/[id]/route.ts
Executable 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
19
apps/web/src/app/api/templates/route.ts
Executable 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
6
apps/web/src/app/api/themes/route.ts
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
import { jsonOk } from "@/lib/api";
|
||||||
|
import { THEMES } from "@/themes/registry";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return jsonOk({ themes: THEMES });
|
||||||
|
}
|
||||||
311
apps/web/src/app/cartographer/page.tsx
Executable 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'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'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
|
After Width: | Height: | Size: 25 KiB |
215
apps/web/src/app/globals.css
Executable 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
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
apps/web/src/app/mentor/page.tsx
Normal 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
@@ -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
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
apps/web/src/app/settings/page.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/web/src/app/statistics/page.tsx
Executable 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/web/src/app/yearly/page.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/web/src/components/charts/simple-charts.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
apps/web/src/components/features/adventure-item-row.tsx
Executable 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;
|
||||||
|
}
|
||||||
42
apps/web/src/components/features/catch-up-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/web/src/components/features/character-card.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/web/src/components/features/checklist-item.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/web/src/components/features/daily-reflection.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/web/src/components/features/day-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||