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

This commit is contained in:
2026-07-03 16:26:54 +01:00
parent 873c9a5f51
commit 8cff315496
84 changed files with 3063 additions and 603 deletions

View File

@@ -0,0 +1,21 @@
{
"name": "@adventureos/application",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@adventureos/domain": "*"
},
"devDependencies": {
"typescript": "^5",
"vitest": "^3.2.6"
}
}

View File

@@ -0,0 +1,7 @@
export * from "./ports/adventure.repository";
export * from "./ports/xp.repository";
export * from "./ports/action-events.repository";
export * from "./ports/user.repository";
export * from "./ports/scoring.service";
export * from "./use-cases/xp/award-xp";
export * from "./use-cases/adventure/update-adventure-item";

View File

@@ -0,0 +1,30 @@
export type ActionEventRecord = {
id: string;
userId: string;
actionType: string;
entityType: string;
entityId: string;
summary: string;
beforeState: Record<string, unknown>;
afterState: Record<string, unknown>;
metadata: Record<string, unknown> | null;
createdAt: Date;
undoneAt: Date | null;
};
export type RecordActionInput = {
userId: string;
actionType: string;
entityType: string;
entityId: string;
summary: string;
beforeState: Record<string, unknown>;
afterState: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export interface ActionEventsRepository {
recordAction(input: RecordActionInput): Promise<ActionEventRecord>;
getActionEvent(id: string, userId: string): Promise<ActionEventRecord | null>;
markUndone(id: string): Promise<void>;
}

View File

@@ -0,0 +1,86 @@
import type { AdventureItemState } from "@adventureos/domain";
export type DailyAdventureItemRecord = {
id: string;
dailyAdventureId: string;
type: string;
label: string;
config: Record<string, unknown> | null;
value: Record<string, unknown> | null;
state: AdventureItemState;
completedAt: Date | null;
enabled: boolean;
isCustom: boolean;
sortOrder: number;
sourceItemId: string | null;
};
export type DailyAdventureRecord = {
id: string;
userId: string;
date: string;
templateId: string | null;
isRestDay: boolean;
isCustomized: boolean;
workHoursTarget: string | null;
dayMode: string | null;
};
export type DailyTodoRecord = {
id: string;
dailyAdventureId: string;
label: string;
done: boolean;
sortOrder: number;
};
export type AdventureTemplateRecord = {
id: string;
userId: string;
daysOfWeek: number[];
sortPriority: number;
isDefault: boolean;
deletedAt: Date | null;
};
export type AdventureTemplateItemRecord = {
id: string;
templateId: string;
type: string;
label: string;
config: Record<string, unknown> | null;
sortOrder: number;
enabled: boolean;
deletedAt: Date | null;
};
export interface AdventureRepository {
findDailyAdventure(userId: string, date: string): Promise<DailyAdventureRecord | null>;
createDailyAdventure(
userId: string,
date: string,
templateId?: string | null
): Promise<DailyAdventureRecord>;
listDailyAdventureItems(adventureId: string): Promise<DailyAdventureItemRecord[]>;
listDailyTodos(adventureId: string): Promise<DailyTodoRecord[]>;
updateDailyAdventureItem(
itemId: string,
updates: {
value?: Record<string, unknown>;
state?: AdventureItemState;
completedAt?: Date | null;
}
): Promise<void>;
softDeleteDailyAdventureItems(adventureId: string): Promise<void>;
insertDailyAdventureItem(
adventureId: string,
item: Omit<DailyAdventureItemRecord, "id" | "dailyAdventureId">
): Promise<DailyAdventureItemRecord>;
updateDailyAdventure(
adventureId: string,
updates: Record<string, unknown>
): Promise<void>;
listTemplates(userId: string): Promise<AdventureTemplateRecord[]>;
listTemplateItems(templateId: string): Promise<AdventureTemplateItemRecord[]>;
findTemplate(userId: string, templateId: string): Promise<AdventureTemplateRecord | null>;
}

View File

@@ -0,0 +1,6 @@
import type { DaySnapshot } from "@adventureos/domain";
export interface ScoringService {
refreshScores(userId: string): Promise<void>;
buildDaySnapshot(userId: string, date: string): Promise<DaySnapshot>;
}

View File

@@ -0,0 +1,9 @@
export type UserRecord = {
id: string;
displayName: string;
};
export interface UserRepository {
getUser(userId: string): Promise<UserRecord | null>;
getSingleUser(): Promise<UserRecord | null>;
}

View File

@@ -0,0 +1,39 @@
import type { XpSource } from "@adventureos/domain";
export type UserProgressRecord = {
userId: string;
totalXp: number;
level: number;
currentChapter: string;
consistencyScore: number;
disciplineScore: number;
learningScore: number;
spiritualScore: number;
healthScore: number;
readingScore: number;
};
export type XpEventRecord = {
id: string;
userId: string;
date: string;
source: XpSource;
amount: number;
metadata: Record<string, unknown> | null;
};
export interface XpRepository {
getDailyXpEarned(userId: string, date: string, source?: XpSource): Promise<number>;
insertXpEvent(event: Omit<XpEventRecord, "id">): Promise<XpEventRecord>;
getUserProgress(userId: string): Promise<UserProgressRecord | null>;
updateUserProgress(
userId: string,
updates: Partial<UserProgressRecord>
): Promise<void>;
listXpEventsByDateAndSource(
userId: string,
date: string,
source: XpSource
): Promise<XpEventRecord[]>;
listAllXpEvents(userId: string): Promise<XpEventRecord[]>;
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { updateAdventureItem } from "./update-adventure-item";
import type { AdventureRepository, DailyAdventureItemRecord } from "../../ports/adventure.repository";
import type { ActionEventsRepository } from "../../ports/action-events.repository";
import type { ScoringService } from "../../ports/scoring.service";
import type { XpRepository } from "../../ports/xp.repository";
const baseItem: DailyAdventureItemRecord = {
id: "item-1",
dailyAdventureId: "adv-1",
type: "checkbox",
label: "Exercise",
config: { semanticKey: "exercise" },
value: {},
state: "blank",
completedAt: null,
enabled: true,
isCustom: false,
sortOrder: 1,
sourceItemId: null,
};
function createDeps(overrides: {
adventureRepository?: Partial<AdventureRepository>;
} = {}) {
const adventureRepository: AdventureRepository = {
findDailyAdventure: vi.fn().mockResolvedValue({ id: "adv-1", userId: "u1", date: "2026-01-01", templateId: null, isRestDay: false, isCustomized: false, workHoursTarget: null, dayMode: null }),
createDailyAdventure: vi.fn(),
listDailyAdventureItems: vi.fn().mockResolvedValue([baseItem]),
listDailyTodos: vi.fn().mockResolvedValue([]),
updateDailyAdventureItem: vi.fn().mockResolvedValue(undefined),
softDeleteDailyAdventureItems: vi.fn(),
insertDailyAdventureItem: vi.fn(),
updateDailyAdventure: vi.fn(),
listTemplates: vi.fn().mockResolvedValue([]),
listTemplateItems: vi.fn().mockResolvedValue([]),
findTemplate: vi.fn(),
...overrides.adventureRepository,
};
const xpRepository: XpRepository = {
getDailyXpEarned: vi.fn().mockResolvedValue(0),
insertXpEvent: vi.fn().mockImplementation(async (event) => ({
id: `xp-${event.source}`,
...event,
})),
getUserProgress: vi.fn().mockResolvedValue({
userId: "u1",
totalXp: 0,
level: 1,
currentChapter: "Prologue",
consistencyScore: 0,
disciplineScore: 0,
learningScore: 0,
spiritualScore: 0,
healthScore: 0,
readingScore: 0,
}),
updateUserProgress: vi.fn(),
listXpEventsByDateAndSource: vi.fn().mockResolvedValue([]),
listAllXpEvents: vi.fn().mockResolvedValue([]),
};
const actionEventsRepository: ActionEventsRepository = {
recordAction: vi.fn().mockResolvedValue({
id: "action-1",
userId: "u1",
actionType: "adventure_item.update",
entityType: "daily_adventure_item",
entityId: "item-1",
summary: "Updated Exercise",
beforeState: {},
afterState: {},
metadata: null,
createdAt: new Date(),
undoneAt: null,
}),
getActionEvent: vi.fn(),
markUndone: vi.fn(),
};
const scoringService: ScoringService = {
refreshScores: vi.fn(),
buildDaySnapshot: vi.fn(),
};
return { adventureRepository, xpRepository, actionEventsRepository, scoringService };
}
describe("updateAdventureItem use case", () => {
beforeEach(() => vi.clearAllMocks());
it("marks exercise checkbox done and awards exercise XP", async () => {
const deps = createDeps();
const result = await updateAdventureItem(deps, {
userId: "u1",
date: "2026-01-01",
itemId: "item-1",
dayOfWeek: 1,
updates: { value: { done: true } },
});
expect(result.state).toBe("done");
expect(deps.xpRepository.insertXpEvent).toHaveBeenCalledTimes(2);
expect(deps.scoringService.refreshScores).toHaveBeenCalledWith("u1");
expect(result.actionEventId).toBe("action-1");
});
});

View File

@@ -0,0 +1,185 @@
import {
deriveState,
matchesSemanticKey,
pickTemplateForDay,
xpForAdventureState,
XP_AWARDS,
type AdventureItemState,
} from "@adventureos/domain";
import type { AdventureRepository } from "../../ports/adventure.repository";
import type { ActionEventsRepository } from "../../ports/action-events.repository";
import type { ScoringService } from "../../ports/scoring.service";
import { awardXp, type AwardXpDeps } from "../xp/award-xp";
export const ADVENTURE_ITEM_UPDATE_ACTION = "adventure_item.update";
export type UpdateAdventureItemInput = {
userId: string;
date: string;
itemId: string;
dayOfWeek: number;
updates: { value?: Record<string, unknown>; state?: AdventureItemState };
};
export type UpdateAdventureItemDeps = {
adventureRepository: AdventureRepository;
actionEventsRepository: ActionEventsRepository;
scoringService: ScoringService;
} & AwardXpDeps;
export async function updateAdventureItem(
deps: UpdateAdventureItemDeps,
input: UpdateAdventureItemInput
) {
const { adventureRepository, actionEventsRepository, scoringService } = deps;
const { userId, date, itemId, dayOfWeek, updates } = input;
const { items } = await getDailyAdventure(deps, userId, date, dayOfWeek);
const item = items.find((i) => i.id === itemId);
if (!item) throw new Error("Item not found");
const newValue = { ...(item.value ?? {}), ...updates.value };
const newState =
updates.state ?? deriveState(item.type, newValue, item.config ?? {});
const oldState = item.state;
const beforeState = {
value: item.value,
state: item.state,
completedAt: item.completedAt,
};
await adventureRepository.updateDailyAdventureItem(itemId, {
value: newValue,
state: newState,
completedAt: newState === "done" ? new Date() : null,
});
const xpEventIds: string[] = [];
if (newState !== oldState && newState !== "blank") {
const xp = xpForAdventureState(newState);
if (xp > 0) {
const result = await awardXp(deps, {
userId,
date,
source: "adventure_item",
amount: xp,
metadata: { itemId, state: newState },
});
if (result.xpEventId) xpEventIds.push(result.xpEventId);
}
if (
matchesSemanticKey(item, "exercise") &&
newState === "done"
) {
const ex = await awardXp(deps, {
userId,
date,
source: "exercise",
amount: XP_AWARDS.exercise,
metadata: { itemId },
});
if (ex.xpEventId) xpEventIds.push(ex.xpEventId);
}
if (item.type === "checklist") {
const oldChecks = ((item.value ?? {}).checks as boolean[]) ?? [];
const newChecks = (newValue.checks as boolean[]) ?? [];
const added = newChecks.filter((c, i) => c && !oldChecks[i]).length;
if (added > 0) {
const sp = await awardXp(deps, {
userId,
date,
source: "spiritual",
amount: added * XP_AWARDS.spiritual_per_check,
metadata: { itemId },
});
if (sp.xpEventId) xpEventIds.push(sp.xpEventId);
}
}
}
const actionEvent = await actionEventsRepository.recordAction({
userId,
actionType: ADVENTURE_ITEM_UPDATE_ACTION,
entityType: "daily_adventure_item",
entityId: itemId,
summary: `Updated ${item.label}`,
beforeState,
afterState: {
value: newValue,
state: newState,
completedAt: newState === "done" ? new Date() : null,
},
metadata: { date, xpEventIds, itemLabel: item.label },
});
await scoringService.refreshScores(userId);
return { state: newState, value: newValue, actionEventId: actionEvent.id };
}
function initialChecklistValue(config: Record<string, unknown>): Record<string, unknown> {
const size = (config.checklistSize as number) ?? 5;
return { checks: Array(size).fill(false) };
}
export async function materializeDay(
deps: Pick<UpdateAdventureItemDeps, "adventureRepository">,
userId: string,
date: string,
dayOfWeek: number
) {
const { adventureRepository } = deps;
const existing = await adventureRepository.findDailyAdventure(userId, date);
if (existing) return existing;
const templates = await adventureRepository.listTemplates(userId);
const template = pickTemplateForDay(templates, dayOfWeek);
if (!template) {
return adventureRepository.createDailyAdventure(userId, date);
}
const items = await adventureRepository.listTemplateItems(template.id);
const adventure = await adventureRepository.createDailyAdventure(
userId,
date,
template.id
);
for (const item of items) {
const config = item.config ?? {};
const value = item.type === "checklist" ? initialChecklistValue(config) : {};
await adventureRepository.insertDailyAdventureItem(adventure.id, {
sourceItemId: item.id,
type: item.type,
label: item.label,
config,
value,
state: "blank",
completedAt: null,
sortOrder: item.sortOrder,
enabled: item.enabled,
isCustom: false,
});
}
return adventure;
}
export async function getDailyAdventure(
deps: Pick<UpdateAdventureItemDeps, "adventureRepository">,
userId: string,
date: string,
dayOfWeek: number
) {
const adventure =
(await deps.adventureRepository.findDailyAdventure(userId, date)) ??
(await materializeDay(deps, userId, date, dayOfWeek));
const [items, todos] = await Promise.all([
deps.adventureRepository.listDailyAdventureItems(adventure.id),
deps.adventureRepository.listDailyTodos(adventure.id),
]);
return { adventure, items, todos };
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { awardXp } from "./award-xp";
import type { XpRepository } from "../../ports/xp.repository";
function createMockXpRepository(overrides: Partial<XpRepository> = {}): XpRepository {
return {
getDailyXpEarned: vi.fn().mockResolvedValue(0),
insertXpEvent: vi.fn().mockResolvedValue({
id: "xp-1",
userId: "user-1",
date: "2026-01-01",
source: "adventure_item",
amount: 40,
metadata: null,
}),
getUserProgress: vi.fn().mockResolvedValue({
userId: "user-1",
totalXp: 100,
level: 1,
currentChapter: "Prologue",
consistencyScore: 0,
disciplineScore: 0,
learningScore: 0,
spiritualScore: 0,
healthScore: 0,
readingScore: 0,
}),
updateUserProgress: vi.fn().mockResolvedValue(undefined),
listXpEventsByDateAndSource: vi.fn().mockResolvedValue([]),
listAllXpEvents: vi.fn().mockResolvedValue([]),
...overrides,
};
}
describe("awardXp use case", () => {
beforeEach(() => vi.clearAllMocks());
it("returns zero for non-positive amounts", async () => {
const xpRepository = createMockXpRepository();
const result = await awardXp(
{ xpRepository },
{ userId: "user-1", date: "2026-01-01", source: "exercise", amount: 0 }
);
expect(result.awarded).toBe(0);
expect(xpRepository.insertXpEvent).not.toHaveBeenCalled();
});
it("awards XP and updates progress", async () => {
const xpRepository = createMockXpRepository();
const result = await awardXp(
{ xpRepository },
{ userId: "user-1", date: "2026-01-01", source: "exercise", amount: 50 }
);
expect(result.awarded).toBe(50);
expect(xpRepository.insertXpEvent).toHaveBeenCalled();
expect(xpRepository.updateUserProgress).toHaveBeenCalled();
});
it("applies soft cap diminishing returns", async () => {
const xpRepository = createMockXpRepository({
getDailyXpEarned: vi.fn().mockResolvedValue(200),
});
const result = await awardXp(
{ xpRepository },
{ userId: "user-1", date: "2026-01-01", source: "adventure_item", amount: 40 }
);
expect(result.awarded).toBe(10);
});
});

View File

@@ -0,0 +1,81 @@
import {
applyDiminishingReturns,
chapterForLevel,
DAILY_SOFT_CAPS,
levelFromXp,
type XpSource,
} from "@adventureos/domain";
import type { XpRepository } from "../../ports/xp.repository";
export type AwardXpInput = {
userId: string;
date: string;
source: XpSource;
amount: number;
metadata?: Record<string, unknown>;
};
export type AwardXpResult = {
awarded: number;
levelUp: boolean;
newLevel: number;
xpEventId?: string;
};
export type AwardXpDeps = {
xpRepository: XpRepository;
};
export async function awardXp(
deps: AwardXpDeps,
input: AwardXpInput
): Promise<AwardXpResult> {
const { xpRepository } = deps;
const { userId, date, source, amount, metadata } = input;
const progress = await xpRepository.getUserProgress(userId);
const currentLevel = progress?.level ?? 1;
if (amount <= 0) {
return { awarded: 0, levelUp: false, newLevel: currentLevel };
}
const softCap = DAILY_SOFT_CAPS[source];
let finalAmount = amount;
if (softCap) {
const earned = await xpRepository.getDailyXpEarned(userId, date, source);
finalAmount = applyDiminishingReturns(amount, earned, softCap);
}
if (finalAmount <= 0) {
return { awarded: 0, levelUp: false, newLevel: currentLevel };
}
const inserted = await xpRepository.insertXpEvent({
userId,
date,
source,
amount: finalAmount,
metadata: metadata ?? null,
});
const updatedProgress = await xpRepository.getUserProgress(userId);
const oldLevel = updatedProgress?.level ?? currentLevel;
const oldTotal = updatedProgress?.totalXp ?? 0;
const newTotal = oldTotal + finalAmount;
const newLevel = levelFromXp(newTotal);
const chapter = chapterForLevel(newLevel);
await xpRepository.updateUserProgress(userId, {
totalXp: newTotal,
level: newLevel,
currentChapter: chapter.name,
});
return {
awarded: finalAmount,
levelUp: newLevel > oldLevel,
newLevel,
xpEventId: inserted.id,
};
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});