7
.dockerignore
Executable file
7
.dockerignore
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
apps/*/node_modules
|
||||||
|
packages/*/node_modules
|
||||||
|
apps/*/.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
npm-debug.log*
|
||||||
17
.env.example
Executable file
17
.env.example
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
DATABASE_URL=postgresql://adventureos:adventureos@localhost:5432/adventureos
|
||||||
|
DOCKER_DATABASE_URL=postgresql://adventureos:adventureos@host.docker.internal:5432/adventureos
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
CRON_SECRET=change-me-cron-secret
|
||||||
|
AUTH_PASSWORD=adventure
|
||||||
|
OLLAMA_URL=http://localhost:11434
|
||||||
|
# For low-RAM machines (e.g. 8GB), use llama3.2:1b for both:
|
||||||
|
OLLAMA_MODEL_FAST=llama3.2:1b
|
||||||
|
OLLAMA_MODEL_PROSE=llama3.2:1b
|
||||||
|
|
||||||
|
# Calibre library (live read-only metadata)
|
||||||
|
CALIBRE_LIBRARY_PATH=
|
||||||
|
CALIBRE_METADATA_DB_PATH=
|
||||||
|
CALIBRE_READ_ONLY=true
|
||||||
|
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# LLAMACPP_API_KEY=...
|
||||||
19
.github/workflows/ci.yml
vendored
Executable file
19
.github/workflows/ci.yml
vendored
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test
|
||||||
|
- run: npm run build
|
||||||
9
.gitignore
vendored
Executable file
9
.gitignore
vendored
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
backups/
|
||||||
|
packages/db/drizzle/meta/
|
||||||
122
README.md
Executable file
122
README.md
Executable file
@@ -0,0 +1,122 @@
|
|||||||
|
# AdventureOS
|
||||||
|
|
||||||
|
A self-hosted personal companion for building consistency, learning, discipline, and growth — styled as a nostalgic personal operating system and adventure game.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- PostgreSQL running on `localhost:5432`
|
||||||
|
- Docker & Docker Compose (for the app and Ollama)
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start Ollama
|
||||||
|
docker compose up ollama -d
|
||||||
|
|
||||||
|
# Copy environment
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Create the PostgreSQL role/database if needed
|
||||||
|
sudo -u postgres psql -c "CREATE ROLE adventureos WITH LOGIN PASSWORD 'adventureos';"
|
||||||
|
sudo -u postgres createdb -O adventureos adventureos
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run migrations and seed
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
npm run db:seed
|
||||||
|
|
||||||
|
# Start dev server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3060 — default password: `adventure` (set `AUTH_PASSWORD` in `.env`).
|
||||||
|
|
||||||
|
### Production (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Jobs
|
||||||
|
|
||||||
|
Schedule these on your server (daily/weekly):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Materialize today's adventure (midnight)
|
||||||
|
curl -X POST -H "x-cron-secret: YOUR_CRON_SECRET" "http://localhost:3060/api/cron?action=materialize"
|
||||||
|
|
||||||
|
# Generate daily quests (6am)
|
||||||
|
curl -X POST -H "x-cron-secret: YOUR_CRON_SECRET" "http://localhost:3060/api/cron?action=generate-quests"
|
||||||
|
|
||||||
|
# Generate weekly explorations (Monday 6am)
|
||||||
|
curl -X POST -H "x-cron-secret: YOUR_CRON_SECRET" "http://localhost:3060/api/cron?action=generate-explorations"
|
||||||
|
|
||||||
|
# Generate weekly review (Sunday 6pm)
|
||||||
|
curl -X POST -H "x-cron-secret: YOUR_CRON_SECRET" "http://localhost:3060/api/cron?action=generate-weekly-review"
|
||||||
|
|
||||||
|
# Prune action history older than 90 days (weekly)
|
||||||
|
curl -X POST -H "x-cron-secret: YOUR_CRON_SECRET" "http://localhost:3060/api/cron?action=prune-actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ollama Models
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec ollama ollama pull llama3.2:3b
|
||||||
|
docker compose exec ollama ollama pull llama3.1:8b
|
||||||
|
```
|
||||||
|
|
||||||
|
### Themes, Undo & AI Settings
|
||||||
|
|
||||||
|
- **Themes:** Settings → Appearance — 11 nostalgia themes with live preview. Default for new installs: `minimal-dark`. Legacy `xp` maps to `windows-xp-light`.
|
||||||
|
- **Undo:** After adventure/reading actions, use the toast Undo button or Settings → Action History.
|
||||||
|
- **AI:** Settings → AI Configuration, AI Templates, System Prompts, AI Health. Provider keys stay in `.env` only (`OPENAI_API_KEY`, `LLAMACPP_API_KEY`).
|
||||||
|
|
||||||
|
Run tests: `npm run test`
|
||||||
|
|
||||||
|
After upgrading, run migrations: `npm run db:migrate`
|
||||||
|
|
||||||
|
### Calibre Library (optional)
|
||||||
|
|
||||||
|
Connect a live Calibre library for the Library Wing — metadata is read read-only from `metadata.db`; reading progress is stored in Postgres.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In .env
|
||||||
|
CALIBRE_LIBRARY_PATH=/path/to/Calibre Library
|
||||||
|
CALIBRE_READ_ONLY=true
|
||||||
|
```
|
||||||
|
|
||||||
|
For Docker, mount your library read-only (see `docker-compose.yml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /path/to/Calibre Library:/calibre-library:ro
|
||||||
|
environment:
|
||||||
|
CALIBRE_LIBRARY_PATH: /calibre-library
|
||||||
|
```
|
||||||
|
|
||||||
|
If Calibre is open and locks the database, the Library page shows a friendly retry message. Without Calibre configured, manual book entry still works.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nostalgia/
|
||||||
|
├── apps/web/ # Next.js 15 application
|
||||||
|
├── packages/db/ # Drizzle schema + migrations
|
||||||
|
├── packages/shared/ # XP formulas, scores, types
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── scripts/backup.sh
|
||||||
|
└── docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
- Consistency over productivity
|
||||||
|
- No guilt, no streak destruction
|
||||||
|
- The user *is* the character
|
||||||
|
- Local AI on your server — data never leaves your infrastructure
|
||||||
1
apps/web
Submodule
1
apps/web
Submodule
Submodule apps/web added at 1916d348b0
36
docker-compose.yml
Executable file
36
docker-compose.yml
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ollama:/root/.ollama
|
||||||
|
ports:
|
||||||
|
- "11434:11434"
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
ollama:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DOCKER_DATABASE_URL:-postgresql://adventureos:adventureos@host.docker.internal:5432/adventureos}
|
||||||
|
OLLAMA_URL: http://ollama:11434
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
||||||
|
CRON_SECRET: ${CRON_SECRET:-change-me-in-production}
|
||||||
|
AUTH_PASSWORD: ${AUTH_PASSWORD:-adventure}
|
||||||
|
CALIBRE_LIBRARY_PATH: /calibre-library
|
||||||
|
CALIBRE_READ_ONLY: "true"
|
||||||
|
volumes:
|
||||||
|
- /home/zaine/master-folder/projects/calibre/library:/calibre-library:ro
|
||||||
|
ports:
|
||||||
|
- "3060:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ollama:
|
||||||
26
docs/DESIGN.md
Executable file
26
docs/DESIGN.md
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
# AdventureOS Design Tokens
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| XP Blue | `#3A6EA5` | Title bars, primary actions |
|
||||||
|
| Bliss Green | `#74B749` | Progress fills, positive accents |
|
||||||
|
| Parchment | `#F0E6D2` | Content backgrounds |
|
||||||
|
| Warm Grey | `#8B8678` | Borders, secondary text |
|
||||||
|
| Gold Trim | `#C8A951` | Level badges, milestones |
|
||||||
|
| Ink | `#2C2416` | Body text |
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- UI: Tahoma, Segoe UI, system-ui (14–16px)
|
||||||
|
- Mentor/Review: Georgia, serif (15px, line-height 1.6)
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `retro-window` — raised panel with XP-style double border
|
||||||
|
- `retro-titlebar` — Luna blue gradient header
|
||||||
|
- `retro-btn` — beveled button with press state
|
||||||
|
- `skill-bar-track` / `skill-bar-fill` — RuneScape-style stat bars
|
||||||
|
|
||||||
|
See `apps/web/src/app/globals.css` for CSS variables.
|
||||||
22
docs/PRODUCT.md
Executable file
22
docs/PRODUCT.md
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
# AdventureOS — Product Overview
|
||||||
|
|
||||||
|
AdventureOS is a self-hosted personal companion for building long-term consistency through a nostalgic personal-OS adventure experience.
|
||||||
|
|
||||||
|
See the full product specification in your Cursor plan file. Key modules:
|
||||||
|
|
||||||
|
- **Command Centre** — Character overview, Today's Adventure, Daily Reflection
|
||||||
|
- **Library Wing** — Books, page logging, soft streaks, completion celebrations
|
||||||
|
- **Cartographer's Desk** — Weekly Explorations (optional curiosity quests)
|
||||||
|
- **Statistics Hall** — Historical views and trends
|
||||||
|
- **Weekly Review** — Mentor's letter and week summary
|
||||||
|
- **The Guide** — Local LLM quest giver (Ollama)
|
||||||
|
- **The Teacher** — Flashcards, quizzes, research assignments
|
||||||
|
- **Achievement Gallery** — 20+ milestones
|
||||||
|
- **Control Panel** — Settings, templates, export, themes
|
||||||
|
|
||||||
|
## Anti-Burnout Rules
|
||||||
|
|
||||||
|
- No XP loss, no streak destruction, no failure screens
|
||||||
|
- Grace days and rest days
|
||||||
|
- Welcome back flow after absence
|
||||||
|
- Partial credit on all adventure items
|
||||||
9422
package-lock.json
generated
Executable file
9422
package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
20
package.json
Executable file
20
package.json
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "adventureos",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev -w @adventureos/web",
|
||||||
|
"build": "npm run build -w @adventureos/web",
|
||||||
|
"start": "npm run start -w @adventureos/web",
|
||||||
|
"db:generate": "npm run generate -w @adventureos/db",
|
||||||
|
"db:migrate": "npm run migrate -w @adventureos/db",
|
||||||
|
"db:seed": "npm run seed -w @adventureos/db",
|
||||||
|
"lint": "npm run lint -w @adventureos/web",
|
||||||
|
"test": "npm run test -w @adventureos/web && npm run test -w @adventureos/shared",
|
||||||
|
"test:shared": "npm run test -w @adventureos/shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/db/drizzle.config.ts
Executable file
12
packages/db/drizzle.config.ts
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url:
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgresql://adventureos:adventureos@localhost:5432/adventureos",
|
||||||
|
},
|
||||||
|
});
|
||||||
184
packages/db/drizzle/0000_wealthy_patch.sql
Executable file
184
packages/db/drizzle/0000_wealthy_patch.sql
Executable file
@@ -0,0 +1,184 @@
|
|||||||
|
CREATE TABLE "achievements" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"key" varchar(100) NOT NULL,
|
||||||
|
"unlocked_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "adventure_items" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"template_id" uuid NOT NULL,
|
||||||
|
"type" varchar(20) NOT NULL,
|
||||||
|
"label" varchar(200) NOT NULL,
|
||||||
|
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"schedule" jsonb
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "adventure_templates" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"days_of_week" integer[] NOT NULL,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ai_suggestions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"role" varchar(20) NOT NULL,
|
||||||
|
"content" jsonb NOT NULL,
|
||||||
|
"generated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"dismissed" boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "books" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"title" varchar(300) NOT NULL,
|
||||||
|
"author" varchar(200),
|
||||||
|
"total_pages" integer DEFAULT 300 NOT NULL,
|
||||||
|
"current_page" integer DEFAULT 0 NOT NULL,
|
||||||
|
"status" varchar(20) DEFAULT 'reading' NOT NULL,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"finished_at" timestamp with time zone,
|
||||||
|
"notes" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "daily_adventure_items" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"daily_adventure_id" uuid NOT NULL,
|
||||||
|
"source_item_id" uuid,
|
||||||
|
"type" varchar(20) NOT NULL,
|
||||||
|
"label" varchar(200) NOT NULL,
|
||||||
|
"state" varchar(20) DEFAULT 'blank' NOT NULL,
|
||||||
|
"value" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "daily_adventures" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"template_id" uuid,
|
||||||
|
"is_rest_day" boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "explorations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"title" varchar(300) NOT NULL,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"category" varchar(50) NOT NULL,
|
||||||
|
"status" varchar(20) DEFAULT 'suggested' NOT NULL,
|
||||||
|
"week_of" date NOT NULL,
|
||||||
|
"completed_note" text,
|
||||||
|
"accepted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "reading_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"book_id" uuid NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"pages_read" integer DEFAULT 0 NOT NULL,
|
||||||
|
"note" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "reflections" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"went_well" text DEFAULT '' NOT NULL,
|
||||||
|
"learned" text DEFAULT '' NOT NULL,
|
||||||
|
"improve_tomorrow" text DEFAULT '' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "settings" (
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"key" varchar(100) NOT NULL,
|
||||||
|
"value" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "spiritual_config" (
|
||||||
|
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"prayer_labels" text[] NOT NULL,
|
||||||
|
"litany_labels" text[] NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "teacher_content" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"exploration_id" uuid,
|
||||||
|
"topic" varchar(300) NOT NULL,
|
||||||
|
"content" jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_progress" (
|
||||||
|
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"total_xp" integer DEFAULT 0 NOT NULL,
|
||||||
|
"level" integer DEFAULT 1 NOT NULL,
|
||||||
|
"current_chapter" varchar(100) DEFAULT 'Prologue: Awakening' NOT NULL,
|
||||||
|
"grace_days_remaining" integer DEFAULT 2 NOT NULL,
|
||||||
|
"consistency_score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"discipline_score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"learning_score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"spiritual_score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"health_score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"reading_score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"scores_updated_at" timestamp with time zone,
|
||||||
|
"last_visit_date" date
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"display_name" varchar(100) DEFAULT 'Traveler' NOT NULL,
|
||||||
|
"portrait_config" jsonb DEFAULT '{"skinTone":"#D4A574","hairColor":"#3D2314","clothingColor":"#3A6EA5"}'::jsonb NOT NULL,
|
||||||
|
"current_title" varchar(100),
|
||||||
|
"rest_days_used_week" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "weekly_reviews" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"week_start" date NOT NULL,
|
||||||
|
"content" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"mentor_letter" text,
|
||||||
|
"xp_earned" integer DEFAULT 0 NOT NULL,
|
||||||
|
"user_intention" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "xp_events" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"source" varchar(50) NOT NULL,
|
||||||
|
"amount" integer NOT NULL,
|
||||||
|
"metadata" jsonb
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "achievements" ADD CONSTRAINT "achievements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "adventure_items" ADD CONSTRAINT "adventure_items_template_id_adventure_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."adventure_templates"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "adventure_templates" ADD CONSTRAINT "adventure_templates_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_suggestions" ADD CONSTRAINT "ai_suggestions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "books" ADD CONSTRAINT "books_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventure_items" ADD CONSTRAINT "daily_adventure_items_daily_adventure_id_daily_adventures_id_fk" FOREIGN KEY ("daily_adventure_id") REFERENCES "public"."daily_adventures"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventures" ADD CONSTRAINT "daily_adventures_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventures" ADD CONSTRAINT "daily_adventures_template_id_adventure_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."adventure_templates"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "explorations" ADD CONSTRAINT "explorations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reading_logs" ADD CONSTRAINT "reading_logs_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reflections" ADD CONSTRAINT "reflections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "settings" ADD CONSTRAINT "settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "spiritual_config" ADD CONSTRAINT "spiritual_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "teacher_content" ADD CONSTRAINT "teacher_content_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "teacher_content" ADD CONSTRAINT "teacher_content_exploration_id_explorations_id_fk" FOREIGN KEY ("exploration_id") REFERENCES "public"."explorations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_progress" ADD CONSTRAINT "user_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "weekly_reviews" ADD CONSTRAINT "weekly_reviews_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "xp_events" ADD CONSTRAINT "xp_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "daily_adventures_user_date_idx" ON "daily_adventures" USING btree ("user_id","date");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "reflections_user_date_idx" ON "reflections" USING btree ("user_id","date");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "settings_user_key_idx" ON "settings" USING btree ("user_id","key");
|
||||||
71
packages/db/drizzle/0001_extension.sql
Executable file
71
packages/db/drizzle/0001_extension.sql
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "action_events" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"action_type" varchar(50) NOT NULL,
|
||||||
|
"entity_type" varchar(50) NOT NULL,
|
||||||
|
"entity_id" uuid NOT NULL,
|
||||||
|
"summary" varchar(200) NOT NULL,
|
||||||
|
"before_state" jsonb NOT NULL,
|
||||||
|
"after_state" jsonb NOT NULL,
|
||||||
|
"inverse_patch" jsonb,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"undoable" boolean DEFAULT true NOT NULL,
|
||||||
|
"undone_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "action_events" ADD CONSTRAINT "action_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "action_events_user_created_idx" ON "action_events" ("user_id", "created_at" DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "adventure_items" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "books" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "explorations" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "ai_prompt_templates" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"key" varchar(50) NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"description" text DEFAULT '' NOT NULL,
|
||||||
|
"category" varchar(30) DEFAULT 'template' NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"is_default_override" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_prompt_templates" ADD CONSTRAINT "ai_prompt_templates_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ai_prompt_templates_user_key_idx" ON "ai_prompt_templates" ("user_id", "key");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "ai_prompt_versions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"template_id" uuid NOT NULL,
|
||||||
|
"version" integer NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_by" varchar(20) DEFAULT 'user' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_prompt_versions" ADD CONSTRAINT "ai_prompt_versions_template_id_ai_prompt_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."ai_prompt_templates"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "ai_health_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"provider_type" varchar(30) NOT NULL,
|
||||||
|
"status" varchar(20) NOT NULL,
|
||||||
|
"latency_ms" integer,
|
||||||
|
"model" varchar(100),
|
||||||
|
"context_length" integer,
|
||||||
|
"memory_usage_mb" integer,
|
||||||
|
"base_url_safe" varchar(200),
|
||||||
|
"error_message" text,
|
||||||
|
"checked_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_health_log" ADD CONSTRAINT "ai_health_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
62
packages/db/drizzle/0002_feature_repair.sql
Executable file
62
packages/db/drizzle/0002_feature_repair.sql
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "daily_todos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"daily_adventure_id" uuid NOT NULL,
|
||||||
|
"label" varchar(300) NOT NULL,
|
||||||
|
"done" boolean DEFAULT false NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_todos" ADD CONSTRAINT "daily_todos_daily_adventure_id_daily_adventures_id_fk" FOREIGN KEY ("daily_adventure_id") REFERENCES "public"."daily_adventures"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventures" ADD COLUMN IF NOT EXISTS "work_hours_target" numeric(4, 1);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventures" ADD COLUMN IF NOT EXISTS "is_customized" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventures" ADD COLUMN IF NOT EXISTS "customized_at" timestamp with time zone;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventure_items" ADD COLUMN IF NOT EXISTS "is_custom" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventure_items" ADD COLUMN IF NOT EXISTS "enabled" boolean DEFAULT true NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventure_items" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "adventure_templates" ADD COLUMN IF NOT EXISTS "sort_priority" integer DEFAULT 0 NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "adventure_templates" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "adventure_templates" ADD COLUMN IF NOT EXISTS "is_system" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "adventure_items" ADD COLUMN IF NOT EXISTS "enabled" boolean DEFAULT true NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "adventure_items" ADD COLUMN IF NOT EXISTS "item_kind" varchar(20) DEFAULT 'recurring' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "reading_progress" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"calibre_book_id" integer NOT NULL,
|
||||||
|
"calibre_uuid" varchar(36),
|
||||||
|
"current_page" integer DEFAULT 0 NOT NULL,
|
||||||
|
"total_pages" integer DEFAULT 300 NOT NULL,
|
||||||
|
"status" varchar(20) DEFAULT 'reading' NOT NULL,
|
||||||
|
"last_read_date" date,
|
||||||
|
"notes" text,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "reading_progress_user_calibre_idx" ON "reading_progress" ("user_id", "calibre_book_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "reading_logs" ADD COLUMN IF NOT EXISTS "calibre_book_id" integer;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "reading_logs" ALTER COLUMN "book_id" DROP NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "teacher_content" ADD COLUMN IF NOT EXISTS "status" varchar(20) DEFAULT 'active' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "teacher_content" ADD COLUMN IF NOT EXISTS "completed_note" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "teacher_content" ADD COLUMN IF NOT EXISTS "completed_at" timestamp with time zone;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "explorations" ADD COLUMN IF NOT EXISTS "minutes" integer;
|
||||||
83
packages/db/drizzle/0003_ai_memory_and_day_modes.sql
Normal file
83
packages/db/drizzle/0003_ai_memory_and_day_modes.sql
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "ai_memories" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"category" varchar(50) NOT NULL,
|
||||||
|
"title" varchar(200) NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"source_type" varchar(30) NOT NULL DEFAULT 'manual',
|
||||||
|
"source_ref" jsonb,
|
||||||
|
"confidence" numeric(3, 2) DEFAULT 1 NOT NULL,
|
||||||
|
"user_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"sensitivity" varchar(20) DEFAULT 'normal' NOT NULL,
|
||||||
|
"tags" text[] DEFAULT '{}',
|
||||||
|
"last_used_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"archived_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_memories" ADD CONSTRAINT "ai_memories_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "ai_memories_user_category_idx" ON "ai_memories" ("user_id", "category");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "ai_memories_user_enabled_idx" ON "ai_memories" ("user_id", "enabled");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "ai_memory_suggestions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"category" varchar(50) NOT NULL,
|
||||||
|
"title" varchar(200) NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"source_type" varchar(30) NOT NULL,
|
||||||
|
"source_ref" jsonb,
|
||||||
|
"confidence" numeric(3, 2) DEFAULT 0.7 NOT NULL,
|
||||||
|
"status" varchar(20) DEFAULT 'pending' NOT NULL,
|
||||||
|
"reviewed_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_memory_suggestions" ADD CONSTRAINT "ai_memory_suggestions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "ai_memory_suggestions_user_status_idx" ON "ai_memory_suggestions" ("user_id", "status");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "ai_chat_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"title" varchar(200) DEFAULT 'New conversation' NOT NULL,
|
||||||
|
"feature_context" jsonb DEFAULT '{}',
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"archived_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_chat_sessions" ADD CONSTRAINT "ai_chat_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "ai_chat_messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"session_id" uuid NOT NULL,
|
||||||
|
"role" varchar(20) NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT '{}',
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_chat_messages" ADD CONSTRAINT "ai_chat_messages_session_id_ai_chat_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."ai_chat_sessions"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "ai_context_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"feature" varchar(50) NOT NULL,
|
||||||
|
"memory_ids" uuid[] DEFAULT '{}',
|
||||||
|
"token_estimate" integer,
|
||||||
|
"layers" jsonb DEFAULT '{}',
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_context_logs" ADD CONSTRAINT "ai_context_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventures" ADD COLUMN IF NOT EXISTS "day_mode" varchar(30) DEFAULT 'normal' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventures" ADD COLUMN IF NOT EXISTS "is_backfilled" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "daily_adventures" ADD COLUMN IF NOT EXISTS "logged_at" timestamp with time zone;
|
||||||
25
packages/db/package.json
Executable file
25
packages/db/package.json
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@adventureos/db",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./schema": "./src/schema.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "drizzle-kit generate",
|
||||||
|
"migrate": "tsx src/migrate.ts",
|
||||||
|
"seed": "tsx src/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@adventureos/shared": "*",
|
||||||
|
"drizzle-orm": "^0.39.3",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"drizzle-kit": "^0.30.4",
|
||||||
|
"tsx": "^4.19.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/db/src/index.ts
Executable file
12
packages/db/src/index.ts
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
const connectionString =
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgresql://adventureos:adventureos@localhost:5432/adventureos";
|
||||||
|
|
||||||
|
const client = postgres(connectionString, { max: 10 });
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
export * from "./schema";
|
||||||
24
packages/db/src/migrate.ts
Executable file
24
packages/db/src/migrate.ts
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const connectionString =
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgresql://adventureos:adventureos@localhost:5432/adventureos";
|
||||||
|
|
||||||
|
const client = postgres(connectionString, { max: 1 });
|
||||||
|
const db = drizzle(client);
|
||||||
|
const migrationsFolder = path.join(__dirname, "..", "drizzle");
|
||||||
|
|
||||||
|
console.log("Running migrations...");
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
console.log("Migrations complete.");
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
481
packages/db/src/schema.ts
Executable file
481
packages/db/src/schema.ts
Executable file
@@ -0,0 +1,481 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
date,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
numeric,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
index,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const users = pgTable("users", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
displayName: varchar("display_name", { length: 100 }).notNull().default("Traveler"),
|
||||||
|
portraitConfig: jsonb("portrait_config")
|
||||||
|
.$type<{ skinTone: string; hairColor: string; clothingColor: string }>()
|
||||||
|
.notNull()
|
||||||
|
.default({ skinTone: "#D4A574", hairColor: "#3D2314", clothingColor: "#3A6EA5" }),
|
||||||
|
currentTitle: varchar("current_title", { length: 100 }),
|
||||||
|
restDaysUsedWeek: integer("rest_days_used_week").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userProgress = pgTable("user_progress", {
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
totalXp: integer("total_xp").notNull().default(0),
|
||||||
|
level: integer("level").notNull().default(1),
|
||||||
|
currentChapter: varchar("current_chapter", { length: 100 }).notNull().default("Prologue: Awakening"),
|
||||||
|
graceDaysRemaining: integer("grace_days_remaining").notNull().default(2),
|
||||||
|
consistencyScore: integer("consistency_score").notNull().default(0),
|
||||||
|
disciplineScore: integer("discipline_score").notNull().default(0),
|
||||||
|
learningScore: integer("learning_score").notNull().default(0),
|
||||||
|
spiritualScore: integer("spiritual_score").notNull().default(0),
|
||||||
|
healthScore: integer("health_score").notNull().default(0),
|
||||||
|
readingScore: integer("reading_score").notNull().default(0),
|
||||||
|
scoresUpdatedAt: timestamp("scores_updated_at", { withTimezone: true }),
|
||||||
|
lastVisitDate: date("last_visit_date"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adventureTemplates = pgTable("adventure_templates", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
daysOfWeek: integer("days_of_week").array().notNull(),
|
||||||
|
isDefault: boolean("is_default").notNull().default(false),
|
||||||
|
sortPriority: integer("sort_priority").notNull().default(0),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
isSystem: boolean("is_system").notNull().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adventureItems = pgTable("adventure_items", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
templateId: uuid("template_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => adventureTemplates.id, { onDelete: "cascade" }),
|
||||||
|
type: varchar("type", { length: 20 }).notNull(),
|
||||||
|
label: varchar("label", { length: 200 }).notNull(),
|
||||||
|
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
schedule: jsonb("schedule").$type<Record<string, unknown>>(),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
itemKind: varchar("item_kind", { length: 20 }).notNull().default("recurring"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dailyAdventures = pgTable(
|
||||||
|
"daily_adventures",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
date: date("date").notNull(),
|
||||||
|
templateId: uuid("template_id").references(() => adventureTemplates.id),
|
||||||
|
isRestDay: boolean("is_rest_day").notNull().default(false),
|
||||||
|
workHoursTarget: numeric("work_hours_target", { precision: 4, scale: 1 }),
|
||||||
|
isCustomized: boolean("is_customized").notNull().default(false),
|
||||||
|
customizedAt: timestamp("customized_at", { withTimezone: true }),
|
||||||
|
dayMode: varchar("day_mode", { length: 30 }).notNull().default("normal"),
|
||||||
|
isBackfilled: boolean("is_backfilled").notNull().default(false),
|
||||||
|
loggedAt: timestamp("logged_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("daily_adventures_user_date_idx").on(t.userId, t.date)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dailyAdventureItems = pgTable("daily_adventure_items", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
dailyAdventureId: uuid("daily_adventure_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => dailyAdventures.id, { onDelete: "cascade" }),
|
||||||
|
sourceItemId: uuid("source_item_id"),
|
||||||
|
type: varchar("type", { length: 20 }).notNull(),
|
||||||
|
label: varchar("label", { length: 200 }).notNull(),
|
||||||
|
state: varchar("state", { length: 20 }).notNull().default("blank"),
|
||||||
|
value: jsonb("value").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
isCustom: boolean("is_custom").notNull().default(false),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dailyTodos = pgTable("daily_todos", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
dailyAdventureId: uuid("daily_adventure_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => dailyAdventures.id, { onDelete: "cascade" }),
|
||||||
|
label: varchar("label", { length: 300 }).notNull(),
|
||||||
|
done: boolean("done").notNull().default(false),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const books = pgTable("books", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
title: varchar("title", { length: 300 }).notNull(),
|
||||||
|
author: varchar("author", { length: 200 }),
|
||||||
|
totalPages: integer("total_pages").notNull().default(300),
|
||||||
|
currentPage: integer("current_page").notNull().default(0),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("reading"),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }).defaultNow(),
|
||||||
|
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||||
|
notes: text("notes"),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const readingLogs = pgTable("reading_logs", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
bookId: uuid("book_id").references(() => books.id, { onDelete: "cascade" }),
|
||||||
|
date: date("date").notNull(),
|
||||||
|
pagesRead: integer("pages_read").notNull().default(0),
|
||||||
|
note: text("note"),
|
||||||
|
calibreBookId: integer("calibre_book_id"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const readingProgress = pgTable(
|
||||||
|
"reading_progress",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
calibreBookId: integer("calibre_book_id").notNull(),
|
||||||
|
calibreUuid: varchar("calibre_uuid", { length: 36 }),
|
||||||
|
currentPage: integer("current_page").notNull().default(0),
|
||||||
|
totalPages: integer("total_pages").notNull().default(300),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("reading"),
|
||||||
|
lastReadDate: date("last_read_date"),
|
||||||
|
notes: text("notes"),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("reading_progress_user_calibre_idx").on(t.userId, t.calibreBookId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const reflections = pgTable(
|
||||||
|
"reflections",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
date: date("date").notNull(),
|
||||||
|
wentWell: text("went_well").notNull().default(""),
|
||||||
|
learned: text("learned").notNull().default(""),
|
||||||
|
improveTomorrow: text("improve_tomorrow").notNull().default(""),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("reflections_user_date_idx").on(t.userId, t.date)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const spiritualConfig = pgTable("spiritual_config", {
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
prayerLabels: text("prayer_labels").array().notNull(),
|
||||||
|
litanyLabels: text("litany_labels").array().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const explorations = pgTable("explorations", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
title: varchar("title", { length: 300 }).notNull(),
|
||||||
|
description: text("description").notNull(),
|
||||||
|
category: varchar("category", { length: 50 }).notNull(),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("suggested"),
|
||||||
|
weekOf: date("week_of").notNull(),
|
||||||
|
completedNote: text("completed_note"),
|
||||||
|
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
minutes: integer("minutes"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const achievements = pgTable("achievements", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
key: varchar("key", { length: 100 }).notNull(),
|
||||||
|
unlockedAt: timestamp("unlocked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiSuggestions = pgTable("ai_suggestions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
role: varchar("role", { length: 20 }).notNull(),
|
||||||
|
content: jsonb("content").$type<Record<string, unknown>>().notNull(),
|
||||||
|
generatedAt: timestamp("generated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
dismissed: boolean("dismissed").notNull().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const weeklyReviews = pgTable("weekly_reviews", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
weekStart: date("week_start").notNull(),
|
||||||
|
content: jsonb("content").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
mentorLetter: text("mentor_letter"),
|
||||||
|
xpEarned: integer("xp_earned").notNull().default(0),
|
||||||
|
userIntention: text("user_intention"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const xpEvents = pgTable("xp_events", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
date: date("date").notNull(),
|
||||||
|
source: varchar("source", { length: 50 }).notNull(),
|
||||||
|
amount: integer("amount").notNull(),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settings = pgTable(
|
||||||
|
"settings",
|
||||||
|
{
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
key: varchar("key", { length: 100 }).notNull(),
|
||||||
|
value: jsonb("value").$type<unknown>().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("settings_user_key_idx").on(t.userId, t.key)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const teacherContent = pgTable("teacher_content", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
explorationId: uuid("exploration_id").references(() => explorations.id),
|
||||||
|
topic: varchar("topic", { length: 300 }).notNull(),
|
||||||
|
content: jsonb("content").$type<Record<string, unknown>>().notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("active"),
|
||||||
|
completedNote: text("completed_note"),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionEvents = pgTable("action_events", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
actionType: varchar("action_type", { length: 50 }).notNull(),
|
||||||
|
entityType: varchar("entity_type", { length: 50 }).notNull(),
|
||||||
|
entityId: uuid("entity_id").notNull(),
|
||||||
|
summary: varchar("summary", { length: 200 }).notNull(),
|
||||||
|
beforeState: jsonb("before_state").$type<Record<string, unknown>>().notNull(),
|
||||||
|
afterState: jsonb("after_state").$type<Record<string, unknown>>().notNull(),
|
||||||
|
inversePatch: jsonb("inverse_patch").$type<Record<string, unknown>>(),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
||||||
|
undoable: boolean("undoable").notNull().default(true),
|
||||||
|
undoneAt: timestamp("undone_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiPromptTemplates = pgTable(
|
||||||
|
"ai_prompt_templates",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
key: varchar("key", { length: 50 }).notNull(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
description: text("description").notNull().default(""),
|
||||||
|
category: varchar("category", { length: 30 }).notNull().default("template"),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
version: integer("version").notNull().default(1),
|
||||||
|
isDefaultOverride: boolean("is_default_override").notNull().default(false),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("ai_prompt_templates_user_key_idx").on(t.userId, t.key)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiPromptVersions = pgTable("ai_prompt_versions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
templateId: uuid("template_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiPromptTemplates.id, { onDelete: "cascade" }),
|
||||||
|
version: integer("version").notNull(),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
createdBy: varchar("created_by", { length: 20 }).notNull().default("user"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiMemories = pgTable(
|
||||||
|
"ai_memories",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
category: varchar("category", { length: 50 }).notNull(),
|
||||||
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
sourceType: varchar("source_type", { length: 30 }).notNull().default("manual"),
|
||||||
|
sourceRef: jsonb("source_ref").$type<{ type: string; id?: string; date?: string }>(),
|
||||||
|
confidence: numeric("confidence", { precision: 3, scale: 2 }).notNull().default("1"),
|
||||||
|
userVerified: boolean("user_verified").notNull().default(false),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
sensitivity: varchar("sensitivity", { length: 20 }).notNull().default("normal"),
|
||||||
|
tags: text("tags").array().notNull().default([]),
|
||||||
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("ai_memories_user_category_idx").on(t.userId, t.category),
|
||||||
|
index("ai_memories_user_enabled_idx").on(t.userId, t.enabled),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiMemorySuggestions = pgTable("ai_memory_suggestions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
category: varchar("category", { length: 50 }).notNull(),
|
||||||
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
sourceType: varchar("source_type", { length: 30 }).notNull(),
|
||||||
|
sourceRef: jsonb("source_ref").$type<{ type: string; id?: string; date?: string }>(),
|
||||||
|
confidence: numeric("confidence", { precision: 3, scale: 2 }).notNull().default("0.7"),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||||
|
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiChatSessions = pgTable("ai_chat_sessions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
title: varchar("title", { length: 200 }).notNull().default("New conversation"),
|
||||||
|
featureContext: jsonb("feature_context").$type<Record<string, unknown>>().default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiChatMessages = pgTable("ai_chat_messages", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sessionId: uuid("session_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiChatSessions.id, { onDelete: "cascade" }),
|
||||||
|
role: varchar("role", { length: 20 }).notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiContextLogs = pgTable("ai_context_logs", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
feature: varchar("feature", { length: 50 }).notNull(),
|
||||||
|
memoryIds: uuid("memory_ids").array().notNull().default([]),
|
||||||
|
tokenEstimate: integer("token_estimate"),
|
||||||
|
layers: jsonb("layers").$type<Record<string, unknown>>().default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiHealthLog = pgTable("ai_health_log", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
providerType: varchar("provider_type", { length: 30 }).notNull(),
|
||||||
|
status: varchar("status", { length: 20 }).notNull(),
|
||||||
|
latencyMs: integer("latency_ms"),
|
||||||
|
model: varchar("model", { length: 100 }),
|
||||||
|
contextLength: integer("context_length"),
|
||||||
|
memoryUsageMb: integer("memory_usage_mb"),
|
||||||
|
baseUrlSafe: varchar("base_url_safe", { length: 200 }),
|
||||||
|
errorMessage: text("error_message"),
|
||||||
|
checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ one, many }) => ({
|
||||||
|
progress: one(userProgress),
|
||||||
|
templates: many(adventureTemplates),
|
||||||
|
dailyAdventures: many(dailyAdventures),
|
||||||
|
books: many(books),
|
||||||
|
reflections: many(reflections),
|
||||||
|
spiritualConfig: one(spiritualConfig),
|
||||||
|
explorations: many(explorations),
|
||||||
|
achievements: many(achievements),
|
||||||
|
aiSuggestions: many(aiSuggestions),
|
||||||
|
weeklyReviews: many(weeklyReviews),
|
||||||
|
xpEvents: many(xpEvents),
|
||||||
|
settings: many(settings),
|
||||||
|
actionEvents: many(actionEvents),
|
||||||
|
aiPromptTemplates: many(aiPromptTemplates),
|
||||||
|
aiHealthLogs: many(aiHealthLog),
|
||||||
|
aiMemories: many(aiMemories),
|
||||||
|
aiMemorySuggestions: many(aiMemorySuggestions),
|
||||||
|
aiChatSessions: many(aiChatSessions),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const adventureTemplatesRelations = relations(
|
||||||
|
adventureTemplates,
|
||||||
|
({ one, many }) => ({
|
||||||
|
user: one(users, { fields: [adventureTemplates.userId], references: [users.id] }),
|
||||||
|
items: many(adventureItems),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const adventureItemsRelations = relations(adventureItems, ({ one }) => ({
|
||||||
|
template: one(adventureTemplates, {
|
||||||
|
fields: [adventureItems.templateId],
|
||||||
|
references: [adventureTemplates.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dailyAdventuresRelations = relations(
|
||||||
|
dailyAdventures,
|
||||||
|
({ one, many }) => ({
|
||||||
|
user: one(users, { fields: [dailyAdventures.userId], references: [users.id] }),
|
||||||
|
items: many(dailyAdventureItems),
|
||||||
|
todos: many(dailyTodos),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dailyAdventureItemsRelations = relations(
|
||||||
|
dailyAdventureItems,
|
||||||
|
({ one }) => ({
|
||||||
|
dailyAdventure: one(dailyAdventures, {
|
||||||
|
fields: [dailyAdventureItems.dailyAdventureId],
|
||||||
|
references: [dailyAdventures.id],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const booksRelations = relations(books, ({ one, many }) => ({
|
||||||
|
user: one(users, { fields: [books.userId], references: [users.id] }),
|
||||||
|
readingLogs: many(readingLogs),
|
||||||
|
}));
|
||||||
148
packages/db/src/seed.ts
Executable file
148
packages/db/src/seed.ts
Executable file
@@ -0,0 +1,148 @@
|
|||||||
|
import { db } from "./index";
|
||||||
|
import {
|
||||||
|
adventureItems,
|
||||||
|
adventureTemplates,
|
||||||
|
spiritualConfig,
|
||||||
|
userProgress,
|
||||||
|
users,
|
||||||
|
settings,
|
||||||
|
} from "./schema";
|
||||||
|
|
||||||
|
const DEFAULT_PRAYER = [
|
||||||
|
"Morning",
|
||||||
|
"Midday",
|
||||||
|
"Evening",
|
||||||
|
"Rosary",
|
||||||
|
"Examen",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_LITANIES = [
|
||||||
|
"Litany 1",
|
||||||
|
"Litany 2",
|
||||||
|
"Litany 3",
|
||||||
|
"Litany 4",
|
||||||
|
"Litany 5",
|
||||||
|
"Litany 6",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
const existing = await db.select().from(users).limit(1);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
console.log("User already exists, skipping seed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({ displayName: "Traveler" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(userProgress).values({ userId: user.id });
|
||||||
|
await db.insert(spiritualConfig).values({
|
||||||
|
userId: user.id,
|
||||||
|
prayerLabels: DEFAULT_PRAYER,
|
||||||
|
litanyLabels: DEFAULT_LITANIES,
|
||||||
|
});
|
||||||
|
await db.insert(settings).values([
|
||||||
|
{ userId: user.id, key: "weekly_reading_goal", value: 50 },
|
||||||
|
{ userId: user.id, key: "notifications_enabled", value: false },
|
||||||
|
{ userId: user.id, key: "sounds_enabled", value: false },
|
||||||
|
{ userId: user.id, key: "theme", value: "minimal-dark" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const templates = [
|
||||||
|
{
|
||||||
|
name: "Weekday",
|
||||||
|
daysOfWeek: [1, 2, 3, 4, 5],
|
||||||
|
isDefault: true,
|
||||||
|
isSystem: true,
|
||||||
|
sortPriority: 0,
|
||||||
|
items: [
|
||||||
|
{ type: "duration", label: "Work", config: { targetHours: 7.5 }, sortOrder: 0 },
|
||||||
|
{ type: "checkbox", label: "Exercise", config: {}, sortOrder: 1 },
|
||||||
|
{ type: "reading", label: "Read", config: {}, sortOrder: 2 },
|
||||||
|
{ type: "timeblock", label: "Class 1", config: { scheduledTime: "18:00" }, sortOrder: 3 },
|
||||||
|
{ type: "timeblock", label: "Class 2", config: { scheduledTime: "20:00" }, sortOrder: 4 },
|
||||||
|
{ type: "checklist", label: "Prayer", config: { checklistSize: 5 }, sortOrder: 5 },
|
||||||
|
{ type: "checklist", label: "Litanies", config: { checklistSize: 6 }, sortOrder: 6 },
|
||||||
|
{ type: "note", label: "Notes", config: {}, sortOrder: 7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Saturday",
|
||||||
|
daysOfWeek: [6],
|
||||||
|
isDefault: false,
|
||||||
|
isSystem: true,
|
||||||
|
sortPriority: 0,
|
||||||
|
items: [
|
||||||
|
{ type: "checkbox", label: "Exercise", config: {}, sortOrder: 0 },
|
||||||
|
{ type: "reading", label: "Read", config: {}, sortOrder: 1 },
|
||||||
|
{ type: "checklist", label: "Prayer", config: { checklistSize: 5 }, sortOrder: 2 },
|
||||||
|
{ type: "checklist", label: "Litanies", config: { checklistSize: 6 }, sortOrder: 3 },
|
||||||
|
{ type: "note", label: "Notes", config: {}, sortOrder: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sunday",
|
||||||
|
daysOfWeek: [0],
|
||||||
|
isDefault: false,
|
||||||
|
isSystem: true,
|
||||||
|
sortPriority: 0,
|
||||||
|
items: [
|
||||||
|
{ type: "reading", label: "Read", config: {}, sortOrder: 0 },
|
||||||
|
{ type: "checkbox", label: "Exercise", config: {}, sortOrder: 1 },
|
||||||
|
{ type: "checklist", label: "Prayer", config: { checklistSize: 5 }, sortOrder: 2 },
|
||||||
|
{ type: "checklist", label: "Litanies", config: { checklistSize: 6 }, sortOrder: 3 },
|
||||||
|
{ type: "note", label: "Notes", config: {}, sortOrder: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Teaching Day",
|
||||||
|
daysOfWeek: [2, 4],
|
||||||
|
isDefault: false,
|
||||||
|
isSystem: true,
|
||||||
|
sortPriority: 10,
|
||||||
|
items: [
|
||||||
|
{ type: "duration", label: "Work", config: { targetHours: 7.5 }, sortOrder: 0 },
|
||||||
|
{ type: "checkbox", label: "Teaching", config: {}, sortOrder: 1 },
|
||||||
|
{ type: "checkbox", label: "Exercise", config: {}, sortOrder: 2 },
|
||||||
|
{ type: "reading", label: "Read", config: {}, sortOrder: 3 },
|
||||||
|
{ type: "checklist", label: "Prayer", config: { checklistSize: 5 }, sortOrder: 4 },
|
||||||
|
{ type: "checklist", label: "Litanies", config: { checklistSize: 6 }, sortOrder: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tpl of templates) {
|
||||||
|
const [template] = await db
|
||||||
|
.insert(adventureTemplates)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
name: tpl.name,
|
||||||
|
daysOfWeek: tpl.daysOfWeek,
|
||||||
|
isDefault: tpl.isDefault,
|
||||||
|
isSystem: tpl.isSystem,
|
||||||
|
sortPriority: tpl.sortPriority,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
for (const item of tpl.items) {
|
||||||
|
await db.insert(adventureItems).values({
|
||||||
|
templateId: template.id,
|
||||||
|
type: item.type,
|
||||||
|
label: item.label,
|
||||||
|
config: item.config,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seed complete. User ID:", user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
16
packages/shared/package.json
Executable file
16
packages/shared/package.json
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@adventureos/shared",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/shared/src/achievements.ts
Executable file
33
packages/shared/src/achievements.ts
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface AchievementDefinition {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACHIEVEMENTS: AchievementDefinition[] = [
|
||||||
|
{ key: "first_visit", name: "First Steps", description: "Opened AdventureOS for the first time", category: "journey" },
|
||||||
|
{ key: "first_reflection", name: "Quiet Moment", description: "Completed your first daily reflection", category: "journey" },
|
||||||
|
{ key: "first_book", name: "Apprentice Reader", description: "Finished your first book", category: "reading" },
|
||||||
|
{ key: "books_5", name: "Shelf Filler", description: "Completed 5 books", category: "reading" },
|
||||||
|
{ key: "books_10", name: "Library Keeper", description: "Completed 10 books", category: "reading" },
|
||||||
|
{ key: "pages_1000", name: "Thousand Pages", description: "Read 1,000 pages total", category: "reading" },
|
||||||
|
{ key: "reading_streak_7", name: "Weekly Reader", description: "7-day reading streak", category: "reading" },
|
||||||
|
{ key: "reading_streak_30", name: "Monthly Reader", description: "30-day reading streak", category: "reading" },
|
||||||
|
{ key: "level_10", name: "Foundations", description: "Reached level 10", category: "progression" },
|
||||||
|
{ key: "level_25", name: "Consistent Builder", description: "Reached level 25", category: "progression" },
|
||||||
|
{ key: "level_50", name: "Reliable Craftsman", description: "Reached level 50", category: "progression" },
|
||||||
|
{ key: "level_100", name: "Master of Discipline", description: "Reached level 100", category: "progression" },
|
||||||
|
{ key: "exploration_1", name: "Curious Mind", description: "Completed first exploration", category: "learning" },
|
||||||
|
{ key: "exploration_10", name: "Curious Wanderer", description: "Completed 10 explorations", category: "learning" },
|
||||||
|
{ key: "exercise_10", name: "Moving Forward", description: "10 exercise sessions", category: "health" },
|
||||||
|
{ key: "exercise_50", name: "Steady Stride", description: "50 exercise sessions", category: "health" },
|
||||||
|
{ key: "spiritual_30", name: "Steady Pilgrim", description: "30 days of spiritual presence", category: "spiritual" },
|
||||||
|
{ key: "weekly_review_4", name: "Page Turner", description: "Completed 4 weekly reviews", category: "journey" },
|
||||||
|
{ key: "consistency_60", name: "Rhythm Found", description: "30-day consistency at 60+", category: "progression" },
|
||||||
|
{ key: "year_one", name: "One Year Adventurer", description: "One year on your journey", category: "journey" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getAchievement(key: string): AchievementDefinition | undefined {
|
||||||
|
return ACHIEVEMENTS.find((a) => a.key === key);
|
||||||
|
}
|
||||||
37
packages/shared/src/chapters.test.ts
Executable file
37
packages/shared/src/chapters.test.ts
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { chapterForLevel, journeyDay, JOURNEY_CHAPTERS } from "./chapters";
|
||||||
|
|
||||||
|
describe("chapterForLevel", () => {
|
||||||
|
it("returns prologue for level 1", () => {
|
||||||
|
expect(chapterForLevel(1).key).toBe("prologue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns chapter II for level 30", () => {
|
||||||
|
expect(chapterForLevel(30).key).toBe("chapter_2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns epilogue for level 100+", () => {
|
||||||
|
expect(chapterForLevel(100).key).toBe("epilogue");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("journeyDay", () => {
|
||||||
|
it("returns 1 on creation day", () => {
|
||||||
|
const created = new Date("2026-06-01T12:00:00");
|
||||||
|
const now = new Date("2026-06-01T18:00:00");
|
||||||
|
expect(journeyDay(created, now)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts calendar days", () => {
|
||||||
|
const created = new Date("2026-06-01");
|
||||||
|
const now = new Date("2026-06-03");
|
||||||
|
expect(journeyDay(created, now)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JOURNEY_CHAPTERS", () => {
|
||||||
|
it("covers all levels without gaps", () => {
|
||||||
|
expect(JOURNEY_CHAPTERS[0].minLevel).toBe(1);
|
||||||
|
expect(JOURNEY_CHAPTERS[JOURNEY_CHAPTERS.length - 1].maxLevel).toBe(Infinity);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
packages/shared/src/chapters.ts
Executable file
68
packages/shared/src/chapters.ts
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
export interface JourneyChapter {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
minLevel: number;
|
||||||
|
maxLevel: number;
|
||||||
|
theme: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JOURNEY_CHAPTERS: JourneyChapter[] = [
|
||||||
|
{
|
||||||
|
key: "prologue",
|
||||||
|
name: "Prologue: Awakening",
|
||||||
|
minLevel: 1,
|
||||||
|
maxLevel: 9,
|
||||||
|
theme: "Learning the system",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chapter_1",
|
||||||
|
name: "Chapter I: Foundations",
|
||||||
|
minLevel: 10,
|
||||||
|
maxLevel: 24,
|
||||||
|
theme: "Building rhythms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chapter_2",
|
||||||
|
name: "Chapter II: The Long Road",
|
||||||
|
minLevel: 25,
|
||||||
|
maxLevel: 49,
|
||||||
|
theme: "Consistency through difficulty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chapter_3",
|
||||||
|
name: "Chapter III: Deep Craft",
|
||||||
|
minLevel: 50,
|
||||||
|
maxLevel: 74,
|
||||||
|
theme: "Mastery of habits",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chapter_4",
|
||||||
|
name: "Chapter IV: Stewardship",
|
||||||
|
minLevel: 75,
|
||||||
|
maxLevel: 99,
|
||||||
|
theme: "Teaching others, giving back",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "epilogue",
|
||||||
|
name: "Epilogue: Legacy",
|
||||||
|
minLevel: 100,
|
||||||
|
maxLevel: Infinity,
|
||||||
|
theme: "Lifelong maintainer",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function chapterForLevel(level: number): JourneyChapter {
|
||||||
|
return (
|
||||||
|
JOURNEY_CHAPTERS.find(
|
||||||
|
(c) => level >= c.minLevel && level <= c.maxLevel
|
||||||
|
) ?? JOURNEY_CHAPTERS[JOURNEY_CHAPTERS.length - 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function journeyDay(createdAt: Date, now = new Date()): number {
|
||||||
|
const start = new Date(createdAt);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const today = new Date(now);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return Math.floor((today.getTime() - start.getTime()) / 86400000) + 1;
|
||||||
|
}
|
||||||
38
packages/shared/src/day-boundary.test.ts
Normal file
38
packages/shared/src/day-boundary.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
getLogicalToday,
|
||||||
|
getLogicalYesterday,
|
||||||
|
isWithinGraceWindow,
|
||||||
|
} from "./day-boundary";
|
||||||
|
|
||||||
|
describe("day-boundary", () => {
|
||||||
|
it("returns calendar date when boundary is midnight and after noon", () => {
|
||||||
|
const now = new Date(2026, 5, 26, 14, 0, 0);
|
||||||
|
expect(getLogicalToday(0, now)).toBe("2026-06-26");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns previous calendar date when before boundary hour", () => {
|
||||||
|
const now = new Date(2026, 5, 26, 1, 30, 0);
|
||||||
|
expect(getLogicalToday(2, now)).toBe("2026-06-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns current date when at or after boundary hour", () => {
|
||||||
|
const now = new Date(2026, 5, 26, 2, 30, 0);
|
||||||
|
expect(getLogicalToday(2, now)).toBe("2026-06-26");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes logical yesterday", () => {
|
||||||
|
const now = new Date(2026, 5, 26, 14, 0, 0);
|
||||||
|
expect(getLogicalYesterday(0, now)).toBe("2026-06-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects grace window after midnight", () => {
|
||||||
|
const now = new Date(2026, 5, 26, 1, 0, 0);
|
||||||
|
expect(isWithinGraceWindow(0, 4, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is outside grace window mid-day", () => {
|
||||||
|
const now = new Date(2026, 5, 26, 14, 0, 0);
|
||||||
|
expect(isWithinGraceWindow(0, 4, now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
packages/shared/src/day-boundary.ts
Normal file
37
packages/shared/src/day-boundary.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { format, subDays, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
/** Calendar date string for the current moment given a day-boundary hour (0–5). */
|
||||||
|
export function getLogicalDate(now: Date, boundaryHour: number): string {
|
||||||
|
const adjusted = new Date(now);
|
||||||
|
if (adjusted.getHours() < boundaryHour) {
|
||||||
|
adjusted.setDate(adjusted.getDate() - 1);
|
||||||
|
}
|
||||||
|
return format(adjusted, "yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogicalToday(boundaryHour: number, now = new Date()): string {
|
||||||
|
return getLogicalDate(now, boundaryHour);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogicalYesterday(boundaryHour: number, now = new Date()): string {
|
||||||
|
const logical = parseISO(getLogicalToday(boundaryHour, now));
|
||||||
|
return format(subDays(logical, 1), "yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True during the first few hours after the day boundary (e.g. midnight–4am). */
|
||||||
|
export function isWithinGraceWindow(
|
||||||
|
boundaryHour: number,
|
||||||
|
graceHours = 4,
|
||||||
|
now = new Date()
|
||||||
|
): boolean {
|
||||||
|
const hour = now.getHours();
|
||||||
|
if (hour < boundaryHour) return true;
|
||||||
|
if (hour >= boundaryHour && hour < boundaryHour + graceHours) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCalendarYesterday(logicalDate: string, boundaryHour: number, now = new Date()): boolean {
|
||||||
|
const calendarToday = format(now, "yyyy-MM-dd");
|
||||||
|
const logicalToday = getLogicalToday(boundaryHour, now);
|
||||||
|
return logicalDate !== calendarToday && logicalDate === logicalToday;
|
||||||
|
}
|
||||||
10
packages/shared/src/index.ts
Executable file
10
packages/shared/src/index.ts
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./memory";
|
||||||
|
export * from "./day-boundary";
|
||||||
|
export * from "./xp";
|
||||||
|
export * from "./levels";
|
||||||
|
export * from "./scores";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./titles";
|
||||||
|
export * from "./achievements";
|
||||||
|
export * from "./quest-pool";
|
||||||
36
packages/shared/src/levels.test.ts
Executable file
36
packages/shared/src/levels.test.ts
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { xpForLevel, levelFromXp, xpProgressInLevel } from "./levels";
|
||||||
|
|
||||||
|
describe("xpForLevel", () => {
|
||||||
|
it("returns 0 for level 1", () => {
|
||||||
|
expect(xpForLevel(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increases with level", () => {
|
||||||
|
expect(xpForLevel(2)).toBeGreaterThan(0);
|
||||||
|
expect(xpForLevel(10)).toBeGreaterThan(xpForLevel(5));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("levelFromXp", () => {
|
||||||
|
it("returns level 1 at zero XP", () => {
|
||||||
|
expect(levelFromXp(0)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("levels up at threshold", () => {
|
||||||
|
const level2Xp = xpForLevel(2);
|
||||||
|
expect(levelFromXp(level2Xp)).toBe(2);
|
||||||
|
expect(levelFromXp(level2Xp - 1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("xpProgressInLevel", () => {
|
||||||
|
it("computes progress within current level", () => {
|
||||||
|
const totalXp = xpForLevel(3) + 10;
|
||||||
|
const progress = xpProgressInLevel(totalXp);
|
||||||
|
expect(progress.level).toBe(3);
|
||||||
|
expect(progress.current).toBe(10);
|
||||||
|
expect(progress.percent).toBeGreaterThan(0);
|
||||||
|
expect(progress.percent).toBeLessThanOrEqual(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
27
packages/shared/src/levels.ts
Executable file
27
packages/shared/src/levels.ts
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
export function xpForLevel(level: number): number {
|
||||||
|
if (level <= 1) return 0;
|
||||||
|
return Math.floor(100 * Math.pow(level - 1, 1.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelFromXp(totalXp: number): number {
|
||||||
|
let level = 1;
|
||||||
|
while (xpForLevel(level + 1) <= totalXp && level < 150) {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xpProgressInLevel(totalXp: number): {
|
||||||
|
level: number;
|
||||||
|
current: number;
|
||||||
|
needed: number;
|
||||||
|
percent: number;
|
||||||
|
} {
|
||||||
|
const level = levelFromXp(totalXp);
|
||||||
|
const currentLevelXp = xpForLevel(level);
|
||||||
|
const nextLevelXp = xpForLevel(level + 1);
|
||||||
|
const current = totalXp - currentLevelXp;
|
||||||
|
const needed = nextLevelXp - currentLevelXp;
|
||||||
|
const percent = needed > 0 ? Math.min(100, (current / needed) * 100) : 100;
|
||||||
|
return { level, current, needed, percent };
|
||||||
|
}
|
||||||
125
packages/shared/src/memory.ts
Normal file
125
packages/shared/src/memory.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
export const MEMORY_CATEGORIES = {
|
||||||
|
identity: "Identity & background",
|
||||||
|
long_term_goals: "Long-term goals",
|
||||||
|
current_goals: "Current goals",
|
||||||
|
likes: "Likes",
|
||||||
|
dislikes: "Dislikes",
|
||||||
|
motivators: "Motivators",
|
||||||
|
discouragers: "Things that discourage",
|
||||||
|
daily_routines: "Daily routines",
|
||||||
|
weekly_routines: "Weekly routines",
|
||||||
|
spiritual_practices: "Spiritual practices",
|
||||||
|
reading_preferences: "Reading preferences",
|
||||||
|
learning_interests: "Learning interests",
|
||||||
|
exercise_preferences: "Exercise preferences",
|
||||||
|
work_study: "Work/study commitments",
|
||||||
|
worries: "Worries & concerns",
|
||||||
|
ai_tone: "Preferred AI tone",
|
||||||
|
boundaries: "Boundaries & avoid",
|
||||||
|
personal_context: "Important personal context",
|
||||||
|
life_season: "Current life season",
|
||||||
|
open_questions: "Open questions about user",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MemoryCategory = keyof typeof MEMORY_CATEGORIES;
|
||||||
|
|
||||||
|
export const MEMORY_SOURCE_TYPES = [
|
||||||
|
"manual",
|
||||||
|
"chat",
|
||||||
|
"reflection",
|
||||||
|
"adventure",
|
||||||
|
"reading",
|
||||||
|
"quest",
|
||||||
|
"teacher",
|
||||||
|
"cartographer",
|
||||||
|
"weekly_review",
|
||||||
|
"correction",
|
||||||
|
"import",
|
||||||
|
"summary_rebuild",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MemorySourceType = (typeof MEMORY_SOURCE_TYPES)[number];
|
||||||
|
|
||||||
|
export const MEMORY_SENSITIVITY_LEVELS = ["normal", "private", "sensitive"] as const;
|
||||||
|
export type MemorySensitivity = (typeof MEMORY_SENSITIVITY_LEVELS)[number];
|
||||||
|
|
||||||
|
export const SENSITIVE_MEMORY_CATEGORIES: MemoryCategory[] = [
|
||||||
|
"worries",
|
||||||
|
"personal_context",
|
||||||
|
"boundaries",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MEMORY_SUGGESTION_STATUSES = [
|
||||||
|
"pending",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"ignored",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MemorySuggestionStatus = (typeof MEMORY_SUGGESTION_STATUSES)[number];
|
||||||
|
|
||||||
|
export const DAY_MODES = {
|
||||||
|
normal: "Normal day",
|
||||||
|
low_energy: "Low energy",
|
||||||
|
travel: "Travel",
|
||||||
|
illness: "Illness",
|
||||||
|
family: "Family event",
|
||||||
|
exam: "Exam day",
|
||||||
|
work_emergency: "Work emergency",
|
||||||
|
rest: "Rest day",
|
||||||
|
maintenance: "Maintenance day",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type DayMode = keyof typeof DAY_MODES;
|
||||||
|
|
||||||
|
export const FORGIVING_DAY_MODES: DayMode[] = [
|
||||||
|
"low_energy",
|
||||||
|
"travel",
|
||||||
|
"illness",
|
||||||
|
"family",
|
||||||
|
"exam",
|
||||||
|
"work_emergency",
|
||||||
|
"rest",
|
||||||
|
"maintenance",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface MemorySourceRef {
|
||||||
|
type: string;
|
||||||
|
id?: string;
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiProfileSummary {
|
||||||
|
summary: string;
|
||||||
|
generatedAt: string;
|
||||||
|
sourceMemoryCount: number;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiMemoryLearningSettings {
|
||||||
|
learningEnabled: boolean;
|
||||||
|
autoSuggestEnabled: boolean;
|
||||||
|
requireApproval: boolean;
|
||||||
|
allowedCategories: MemoryCategory[];
|
||||||
|
suggestAfterReflection: boolean;
|
||||||
|
suggestAfterChat: boolean;
|
||||||
|
maxPendingSuggestions: number;
|
||||||
|
minDaysBetweenNudges: number;
|
||||||
|
allowSensitiveCategories: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_AI_MEMORY_LEARNING: AiMemoryLearningSettings = {
|
||||||
|
learningEnabled: false,
|
||||||
|
autoSuggestEnabled: true,
|
||||||
|
requireApproval: true,
|
||||||
|
allowedCategories: (Object.keys(MEMORY_CATEGORIES) as MemoryCategory[]).filter(
|
||||||
|
(c) => !SENSITIVE_MEMORY_CATEGORIES.includes(c)
|
||||||
|
),
|
||||||
|
suggestAfterReflection: true,
|
||||||
|
suggestAfterChat: false,
|
||||||
|
maxPendingSuggestions: 10,
|
||||||
|
minDaysBetweenNudges: 3,
|
||||||
|
allowSensitiveCategories: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DAY_BOUNDARY_HOUR = 0;
|
||||||
43
packages/shared/src/quest-pool.ts
Executable file
43
packages/shared/src/quest-pool.ts
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
export interface StaticQuest {
|
||||||
|
title: string;
|
||||||
|
reason: string;
|
||||||
|
xp_hint: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticExploration {
|
||||||
|
title: string;
|
||||||
|
hook: string;
|
||||||
|
category: string;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATIC_QUESTS: StaticQuest[] = [
|
||||||
|
{ title: "Read 10 pages tonight", reason: "A small chapter keeps the story alive", xp_hint: "+15 XP", category: "reading" },
|
||||||
|
{ title: "Walk for 15 minutes", reason: "Movement clears the mind for tomorrow", xp_hint: "+50 XP", category: "health" },
|
||||||
|
{ title: "Learn one new fact today", reason: "Curiosity compounds quietly", xp_hint: "+25 XP", category: "learning" },
|
||||||
|
{ title: "Review yesterday's reflection", reason: "Patterns emerge when you look back gently", xp_hint: "+10 XP", category: "journey" },
|
||||||
|
{ title: "Spend 5 minutes in prayer", reason: "Stillness anchors the day", xp_hint: "+5 XP", category: "spiritual" },
|
||||||
|
{ title: "Log your work hours", reason: "Visibility builds honest rhythm", xp_hint: "+25 XP", category: "discipline" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATIC_EXPLORATIONS: StaticExploration[] = [
|
||||||
|
{ title: "How traceroute works", hook: "Follow a packet's journey across the internet in fifteen minutes.", category: "technology", minutes: 20 },
|
||||||
|
{ title: "Roman road engineering", hook: "Discover how ancient roads outlasted empires.", category: "history", minutes: 25 },
|
||||||
|
{ title: "The periodic table's shape", hook: "Learn why elements sit where they do.", category: "science", minutes: 20 },
|
||||||
|
{ title: "Constellations this season", hook: "Find three stars visible from your window tonight.", category: "nature", minutes: 15 },
|
||||||
|
{ title: "A moment in 1066", hook: "One battle that reshaped a continent — what happened after?", category: "history", minutes: 30 },
|
||||||
|
{ title: "How DNS resolves a name", hook: "The hidden directory that makes the web work.", category: "technology", minutes: 20 },
|
||||||
|
{ title: "Stoic morning practice", hook: "One ancient exercise for starting the day with clarity.", category: "philosophy", minutes: 15 },
|
||||||
|
{ title: "Birdsong at dawn", hook: "Learn which birds you might hear tomorrow morning.", category: "nature", minutes: 15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function pickRandomQuests(count: number): StaticQuest[] {
|
||||||
|
const shuffled = [...STATIC_QUESTS].sort(() => Math.random() - 0.5);
|
||||||
|
return shuffled.slice(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickRandomExplorations(count: number): StaticExploration[] {
|
||||||
|
const shuffled = [...STATIC_EXPLORATIONS].sort(() => Math.random() - 0.5);
|
||||||
|
return shuffled.slice(0, count);
|
||||||
|
}
|
||||||
70
packages/shared/src/scores.test.ts
Executable file
70
packages/shared/src/scores.test.ts
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
computeConsistencyScore,
|
||||||
|
computeDisciplineScore,
|
||||||
|
computeLearningScore,
|
||||||
|
computeSpiritualScore,
|
||||||
|
computeHealthScore,
|
||||||
|
computeReadingScore,
|
||||||
|
computeAllScores,
|
||||||
|
type DaySnapshot,
|
||||||
|
} from "./scores";
|
||||||
|
|
||||||
|
function makeDay(overrides: Partial<DaySnapshot> = {}): DaySnapshot {
|
||||||
|
return {
|
||||||
|
date: "2026-01-01",
|
||||||
|
adventureSlots: 5,
|
||||||
|
adventureTouched: 3,
|
||||||
|
exerciseDone: true,
|
||||||
|
workHours: 6,
|
||||||
|
workTarget: 8,
|
||||||
|
sleepLogged: true,
|
||||||
|
classesDone: 1,
|
||||||
|
teachingDone: false,
|
||||||
|
explorationsDone: 0,
|
||||||
|
prayerChecks: 2,
|
||||||
|
prayerTotal: 3,
|
||||||
|
litanyChecks: 1,
|
||||||
|
litanyTotal: 2,
|
||||||
|
pagesRead: 20,
|
||||||
|
readingGoalWeekly: 100,
|
||||||
|
hadSpiritualEver: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeConsistencyScore", () => {
|
||||||
|
it("returns 0 for empty days", () => {
|
||||||
|
expect(computeConsistencyScore([])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores touched adventure ratio", () => {
|
||||||
|
const score = computeConsistencyScore([makeDay({ adventureTouched: 5, adventureSlots: 5 })]);
|
||||||
|
expect(score).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 50 when no adventure slots", () => {
|
||||||
|
expect(computeConsistencyScore([makeDay({ adventureSlots: 0 })])).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeReadingScore", () => {
|
||||||
|
it("includes streak bonus", () => {
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) =>
|
||||||
|
makeDay({ date: `2026-01-0${i + 1}`, pagesRead: 10 })
|
||||||
|
);
|
||||||
|
expect(computeReadingScore(days, 100)).toBeGreaterThan(computeReadingScore([makeDay()], 100));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeAllScores", () => {
|
||||||
|
it("returns all score domains", () => {
|
||||||
|
const scores = computeAllScores([makeDay()], 100);
|
||||||
|
expect(scores).toHaveProperty("consistencyScore");
|
||||||
|
expect(scores).toHaveProperty("disciplineScore");
|
||||||
|
expect(scores).toHaveProperty("learningScore");
|
||||||
|
expect(scores).toHaveProperty("spiritualScore");
|
||||||
|
expect(scores).toHaveProperty("healthScore");
|
||||||
|
expect(scores).toHaveProperty("readingScore");
|
||||||
|
});
|
||||||
|
});
|
||||||
137
packages/shared/src/scores.ts
Executable file
137
packages/shared/src/scores.ts
Executable file
@@ -0,0 +1,137 @@
|
|||||||
|
export interface DaySnapshot {
|
||||||
|
date: string;
|
||||||
|
adventureSlots: number;
|
||||||
|
adventureTouched: number;
|
||||||
|
exerciseDone: boolean;
|
||||||
|
workHours: number;
|
||||||
|
workTarget: number;
|
||||||
|
sleepLogged: boolean;
|
||||||
|
classesDone: number;
|
||||||
|
teachingDone: boolean;
|
||||||
|
explorationsDone: number;
|
||||||
|
prayerChecks: number;
|
||||||
|
prayerTotal: number;
|
||||||
|
litanyChecks: number;
|
||||||
|
litanyTotal: number;
|
||||||
|
pagesRead: number;
|
||||||
|
readingGoalWeekly: number;
|
||||||
|
hadSpiritualEver: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decayWeight(daysAgo: number): number {
|
||||||
|
return Math.exp(-daysAgo / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeConsistencyScore(days: DaySnapshot[]): number {
|
||||||
|
if (days.length === 0) return 0;
|
||||||
|
let weighted = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
days.forEach((d, i) => {
|
||||||
|
const w = decayWeight(i);
|
||||||
|
totalWeight += w;
|
||||||
|
if (d.adventureSlots === 0) {
|
||||||
|
weighted += w * 50;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
weighted += w * (d.adventureTouched / d.adventureSlots) * 100;
|
||||||
|
});
|
||||||
|
return Math.round(weighted / totalWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDisciplineScore(days: DaySnapshot[]): number {
|
||||||
|
if (days.length === 0) return 0;
|
||||||
|
let weighted = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
days.forEach((d, i) => {
|
||||||
|
const w = decayWeight(i);
|
||||||
|
totalWeight += w;
|
||||||
|
const exercise = d.exerciseDone ? 100 : 0;
|
||||||
|
const sleep = d.sleepLogged ? 100 : 50;
|
||||||
|
const work =
|
||||||
|
d.workTarget > 0
|
||||||
|
? Math.min(100, (d.workHours / d.workTarget) * 100)
|
||||||
|
: 50;
|
||||||
|
weighted += w * (exercise * 0.4 + sleep * 0.3 + work * 0.3);
|
||||||
|
});
|
||||||
|
return Math.round(weighted / totalWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeLearningScore(days: DaySnapshot[]): number {
|
||||||
|
if (days.length === 0) return 0;
|
||||||
|
let weighted = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
days.forEach((d, i) => {
|
||||||
|
const w = decayWeight(i);
|
||||||
|
totalWeight += w;
|
||||||
|
let dayScore = 0;
|
||||||
|
if (d.classesDone > 0) dayScore += 40;
|
||||||
|
if (d.teachingDone) dayScore += 30;
|
||||||
|
if (d.explorationsDone > 0) dayScore += 30;
|
||||||
|
weighted += w * Math.min(100, dayScore);
|
||||||
|
});
|
||||||
|
return Math.round(weighted / totalWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSpiritualScore(days: DaySnapshot[]): number {
|
||||||
|
const hadEver = days.some((d) => d.hadSpiritualEver);
|
||||||
|
if (days.length === 0) return hadEver ? 20 : 0;
|
||||||
|
let weighted = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
days.forEach((d, i) => {
|
||||||
|
const w = decayWeight(i);
|
||||||
|
totalWeight += w;
|
||||||
|
const total = d.prayerTotal + d.litanyTotal;
|
||||||
|
const checked = d.prayerChecks + d.litanyChecks;
|
||||||
|
const rate = total > 0 ? (checked / total) * 100 : 0;
|
||||||
|
weighted += w * rate;
|
||||||
|
});
|
||||||
|
const score = Math.round(weighted / totalWeight);
|
||||||
|
return hadEver ? Math.max(20, score) : score;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeHealthScore(days: DaySnapshot[]): number {
|
||||||
|
if (days.length === 0) return 0;
|
||||||
|
let weighted = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
days.forEach((d, i) => {
|
||||||
|
const w = decayWeight(i);
|
||||||
|
totalWeight += w;
|
||||||
|
const exercise = d.exerciseDone ? 100 : 0;
|
||||||
|
const sleep = d.sleepLogged ? 100 : 40;
|
||||||
|
weighted += w * (exercise * 0.6 + sleep * 0.4);
|
||||||
|
});
|
||||||
|
return Math.round(weighted / totalWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeReadingScore(
|
||||||
|
days: DaySnapshot[],
|
||||||
|
weeklyGoal: number
|
||||||
|
): number {
|
||||||
|
if (days.length === 0) return 0;
|
||||||
|
const recentWeek = days.slice(0, 7);
|
||||||
|
const pages = recentWeek.reduce((s, d) => s + d.pagesRead, 0);
|
||||||
|
const goalScore = weeklyGoal > 0 ? Math.min(100, (pages / weeklyGoal) * 100) : 50;
|
||||||
|
let streakBonus = 0;
|
||||||
|
let streak = 0;
|
||||||
|
for (const d of days) {
|
||||||
|
if (d.pagesRead > 0) streak++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (streak >= 7) streakBonus = 20;
|
||||||
|
else if (streak >= 3) streakBonus = 10;
|
||||||
|
return Math.min(100, Math.round(goalScore * 0.8 + streakBonus));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeAllScores(
|
||||||
|
days: DaySnapshot[],
|
||||||
|
weeklyReadingGoal: number
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
consistencyScore: computeConsistencyScore(days),
|
||||||
|
disciplineScore: computeDisciplineScore(days),
|
||||||
|
learningScore: computeLearningScore(days),
|
||||||
|
spiritualScore: computeSpiritualScore(days),
|
||||||
|
healthScore: computeHealthScore(days),
|
||||||
|
readingScore: computeReadingScore(days, weeklyReadingGoal),
|
||||||
|
};
|
||||||
|
}
|
||||||
53
packages/shared/src/titles.ts
Executable file
53
packages/shared/src/titles.ts
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
export interface TitleDefinition {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
minLevel: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TITLES: TitleDefinition[] = [
|
||||||
|
{
|
||||||
|
key: "apprentice_reader",
|
||||||
|
name: "Apprentice Reader",
|
||||||
|
minLevel: 5,
|
||||||
|
description: "Finished your first book",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "early_riser",
|
||||||
|
name: "Early Riser",
|
||||||
|
minLevel: 10,
|
||||||
|
description: "Logged sleep for 14 days",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "consistent_builder",
|
||||||
|
name: "Consistent Builder",
|
||||||
|
minLevel: 25,
|
||||||
|
description: "30-day consistency at 60+",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "curious_wanderer",
|
||||||
|
name: "Curious Wanderer",
|
||||||
|
minLevel: 40,
|
||||||
|
description: "Completed 10 explorations",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reliable_craftsman",
|
||||||
|
name: "Reliable Craftsman",
|
||||||
|
minLevel: 50,
|
||||||
|
description: "90 days of adventure with steady consistency",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "steady_pilgrim",
|
||||||
|
name: "Steady Pilgrim",
|
||||||
|
minLevel: 75,
|
||||||
|
description: "Spiritual presence over 30 days",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "master_of_discipline",
|
||||||
|
name: "Master of Discipline",
|
||||||
|
minLevel: 100,
|
||||||
|
description: "One year of steady growth",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_TITLE = "New Adventurer";
|
||||||
229
packages/shared/src/types.ts
Executable file
229
packages/shared/src/types.ts
Executable file
@@ -0,0 +1,229 @@
|
|||||||
|
export type AdventureItemType =
|
||||||
|
| "duration"
|
||||||
|
| "checkbox"
|
||||||
|
| "checklist"
|
||||||
|
| "timeblock"
|
||||||
|
| "reading"
|
||||||
|
| "note";
|
||||||
|
|
||||||
|
export type AdventureItemState =
|
||||||
|
| "blank"
|
||||||
|
| "started"
|
||||||
|
| "partial"
|
||||||
|
| "done";
|
||||||
|
|
||||||
|
export type BookStatus = "reading" | "paused" | "finished";
|
||||||
|
|
||||||
|
export type ExplorationStatus =
|
||||||
|
| "suggested"
|
||||||
|
| "active"
|
||||||
|
| "completed"
|
||||||
|
| "dismissed";
|
||||||
|
|
||||||
|
export type AiRole = "quest_giver" | "mentor" | "teacher";
|
||||||
|
|
||||||
|
export type XpSource =
|
||||||
|
| "daily_visit"
|
||||||
|
| "adventure_item"
|
||||||
|
| "reflection"
|
||||||
|
| "spiritual"
|
||||||
|
| "reading"
|
||||||
|
| "exercise"
|
||||||
|
| "exploration"
|
||||||
|
| "weekly_review"
|
||||||
|
| "rest_day"
|
||||||
|
| "book_complete"
|
||||||
|
| "achievement";
|
||||||
|
|
||||||
|
export interface PortraitConfig {
|
||||||
|
skinTone: string;
|
||||||
|
hairColor: string;
|
||||||
|
clothingColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdventureItemConfig {
|
||||||
|
targetHours?: number;
|
||||||
|
checklistSize?: number;
|
||||||
|
bookId?: string;
|
||||||
|
scheduledTime?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdventureItemValue {
|
||||||
|
hours?: number;
|
||||||
|
note?: string;
|
||||||
|
checks?: boolean[];
|
||||||
|
pages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
portraitConfig: PortraitConfig;
|
||||||
|
currentTitle: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
progress: {
|
||||||
|
totalXp: number;
|
||||||
|
level: number;
|
||||||
|
currentChapter: string;
|
||||||
|
graceDaysRemaining: number;
|
||||||
|
consistencyScore: number;
|
||||||
|
disciplineScore: number;
|
||||||
|
learningScore: number;
|
||||||
|
spiritualScore: number;
|
||||||
|
healthScore: number;
|
||||||
|
readingScore: number;
|
||||||
|
journeyDay: number;
|
||||||
|
};
|
||||||
|
today: DailyAdventureData | null;
|
||||||
|
reflection: ReflectionData | null;
|
||||||
|
suggestions: AiSuggestionData[];
|
||||||
|
reading: {
|
||||||
|
activeBooks: BookSummary[];
|
||||||
|
streak: ReadingStreak;
|
||||||
|
weeklyPages: number;
|
||||||
|
weeklyGoal: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { DayMode } from "./memory";
|
||||||
|
|
||||||
|
export interface DailyAdventureData {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
isRestDay: boolean;
|
||||||
|
isCustomized: boolean;
|
||||||
|
workHoursTarget: number | null;
|
||||||
|
dayMode: DayMode;
|
||||||
|
isBackfilled: boolean;
|
||||||
|
loggedAt: string | null;
|
||||||
|
items: DailyAdventureItemData[];
|
||||||
|
todos: DailyTodoData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyTodoData {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
done: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyAdventureItemData {
|
||||||
|
id: string;
|
||||||
|
type: AdventureItemType;
|
||||||
|
label: string;
|
||||||
|
state: AdventureItemState;
|
||||||
|
value: AdventureItemValue;
|
||||||
|
config: AdventureItemConfig;
|
||||||
|
sortOrder: number;
|
||||||
|
enabled: boolean;
|
||||||
|
isCustom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReflectionData {
|
||||||
|
wentWell: string;
|
||||||
|
learned: string;
|
||||||
|
improveTomorrow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSuggestionData {
|
||||||
|
id: string;
|
||||||
|
role: AiRole;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string | null;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
status: BookStatus;
|
||||||
|
progressPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadingStreak {
|
||||||
|
current: number;
|
||||||
|
best: number;
|
||||||
|
isPaused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
daysOfWeek: number[];
|
||||||
|
isDefault: boolean;
|
||||||
|
isSystem: boolean;
|
||||||
|
sortPriority: number;
|
||||||
|
items: TemplateItemData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateItemData {
|
||||||
|
id: string;
|
||||||
|
type: AdventureItemType;
|
||||||
|
label: string;
|
||||||
|
config: AdventureItemConfig;
|
||||||
|
sortOrder: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyReviewData {
|
||||||
|
id: string;
|
||||||
|
weekStart: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
mentorLetter: string | null;
|
||||||
|
xpEarned: number;
|
||||||
|
userIntention: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExplorationData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
status: ExplorationStatus;
|
||||||
|
weekOf: string;
|
||||||
|
completedNote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementData {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
unlockedAt: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsOverview {
|
||||||
|
totalXp: number;
|
||||||
|
xpThisMonth: number;
|
||||||
|
booksCompleted: number;
|
||||||
|
pagesThisMonth: number;
|
||||||
|
exerciseSessions: number;
|
||||||
|
consistencyTrend: { date: string; value: number }[];
|
||||||
|
xpByCategory: { category: string; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherQuizQuestion {
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
answer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherLessonContent {
|
||||||
|
flashcards: { front: string; back: string }[];
|
||||||
|
quiz: TeacherQuizQuestion[];
|
||||||
|
assignment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherLessonData {
|
||||||
|
id: string;
|
||||||
|
topic: string;
|
||||||
|
content: TeacherLessonContent;
|
||||||
|
status: string;
|
||||||
|
explorationId?: string | null;
|
||||||
|
completedNote?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
39
packages/shared/src/xp.test.ts
Executable file
39
packages/shared/src/xp.test.ts
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
XP_AWARDS,
|
||||||
|
xpForAdventureState,
|
||||||
|
applyDiminishingReturns,
|
||||||
|
xpForReadingPages,
|
||||||
|
} from "./xp";
|
||||||
|
|
||||||
|
describe("xpForAdventureState", () => {
|
||||||
|
it("maps states to XP awards", () => {
|
||||||
|
expect(xpForAdventureState("started")).toBe(XP_AWARDS.adventure_started);
|
||||||
|
expect(xpForAdventureState("partial")).toBe(XP_AWARDS.adventure_partial);
|
||||||
|
expect(xpForAdventureState("done")).toBe(XP_AWARDS.adventure_done);
|
||||||
|
expect(xpForAdventureState("blank")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyDiminishingReturns", () => {
|
||||||
|
it("returns full amount when under soft cap", () => {
|
||||||
|
expect(applyDiminishingReturns(20, 0, 60)).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies 25% when already at cap", () => {
|
||||||
|
expect(applyDiminishingReturns(20, 60, 60)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits amount at cap boundary", () => {
|
||||||
|
expect(applyDiminishingReturns(30, 50, 60)).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("xpForReadingPages", () => {
|
||||||
|
it("awards per 10 pages", () => {
|
||||||
|
expect(xpForReadingPages(0)).toBe(0);
|
||||||
|
expect(xpForReadingPages(9)).toBe(0);
|
||||||
|
expect(xpForReadingPages(10)).toBe(XP_AWARDS.reading_per_10_pages);
|
||||||
|
expect(xpForReadingPages(25)).toBe(XP_AWARDS.reading_per_10_pages * 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
packages/shared/src/xp.ts
Executable file
57
packages/shared/src/xp.ts
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { AdventureItemState, XpSource } from "./types";
|
||||||
|
|
||||||
|
export const XP_AWARDS = {
|
||||||
|
daily_visit: 5,
|
||||||
|
adventure_started: 10,
|
||||||
|
adventure_partial: 25,
|
||||||
|
adventure_done: 40,
|
||||||
|
reflection: 30,
|
||||||
|
spiritual_per_check: 5,
|
||||||
|
spiritual_daily_cap: 25,
|
||||||
|
reading_per_10_pages: 15,
|
||||||
|
reading_daily_cap: 60,
|
||||||
|
exercise: 50,
|
||||||
|
exploration: 100,
|
||||||
|
weekly_review: 150,
|
||||||
|
rest_day: 15,
|
||||||
|
book_complete: 200,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DAILY_SOFT_CAPS: Partial<Record<XpSource, number>> = {
|
||||||
|
adventure_item: 200,
|
||||||
|
spiritual: 25,
|
||||||
|
reading: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function xpForAdventureState(state: AdventureItemState): number {
|
||||||
|
switch (state) {
|
||||||
|
case "started":
|
||||||
|
return XP_AWARDS.adventure_started;
|
||||||
|
case "partial":
|
||||||
|
return XP_AWARDS.adventure_partial;
|
||||||
|
case "done":
|
||||||
|
return XP_AWARDS.adventure_done;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDiminishingReturns(
|
||||||
|
amount: number,
|
||||||
|
earnedToday: number,
|
||||||
|
softCap: number
|
||||||
|
): number {
|
||||||
|
if (earnedToday >= softCap) {
|
||||||
|
return Math.floor(amount * 0.25);
|
||||||
|
}
|
||||||
|
if (earnedToday + amount > softCap) {
|
||||||
|
const atFull = softCap - earnedToday;
|
||||||
|
const over = amount - atFull;
|
||||||
|
return atFull + Math.floor(over * 0.25);
|
||||||
|
}
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xpForReadingPages(pages: number): number {
|
||||||
|
return Math.floor(pages / 10) * XP_AWARDS.reading_per_10_pages;
|
||||||
|
}
|
||||||
8
packages/shared/vitest.config.ts
Executable file
8
packages/shared/vitest.config.ts
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
7
scripts/backup.sh
Executable file
7
scripts/backup.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
pg_dump "$DATABASE_URL" > "$BACKUP_DIR/adventureos_$TIMESTAMP.sql"
|
||||||
|
echo "Backup saved to $BACKUP_DIR/adventureos_$TIMESTAMP.sql"
|
||||||
Reference in New Issue
Block a user