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

This commit is contained in:
2026-06-26 09:21:14 +01:00
commit 194330fb47
40 changed files with 11873 additions and 0 deletions

7
.dockerignore Executable file
View 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
View 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
View 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
View 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
View 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

Submodule apps/web added at 1916d348b0

36
docker-compose.yml Executable file
View 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
View 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 (1416px)
- 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
View 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

File diff suppressed because it is too large Load Diff

20
package.json Executable file
View 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
View 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",
},
});

View 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");

View 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;

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

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

View 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
View 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;
}

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

View File

@@ -0,0 +1,37 @@
import { format, subDays, parseISO } from "date-fns";
/** Calendar date string for the current moment given a day-boundary hour (05). */
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. midnight4am). */
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
View 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";

View 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
View 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 };
}

View 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;

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

View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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"