diff --git a/.env.example b/.env.example index 4f856db..63eac32 100755 --- a/.env.example +++ b/.env.example @@ -20,3 +20,6 @@ CALIBRE_READ_ONLY=true # OPENAI_API_KEY=sk-... # LLAMACPP_API_KEY=... + +# Optional Obsidian/resource backlog export path +# RESOURCE_BACKLOG_PATH=/path/to/vault/resources.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa38916..961fbbe 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,4 +16,6 @@ jobs: cache: npm - run: npm ci - run: npm run test + - run: npm run lint + - run: npm run typecheck - run: npm run build diff --git a/README.md b/README.md index eb06c4e..40d76d1 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ npm run db:seed npm run dev ``` -Open http://localhost:3060 — default password: `adventure` (set `AUTH_PASSWORD` in `.env`). +Open http://localhost:3000 — default password: `adventure` (set `AUTH_PASSWORD` in `.env`). + +Docker production serves the app at http://localhost:3060. ### Production (Docker) diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 05e726d..b30fbe3 100755 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -12,6 +12,7 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "public/sw.js", ]), ]); diff --git a/apps/web/package.json b/apps/web/package.json index 32b6e44..e5f8d1f 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,10 +7,13 @@ "build": "next build --webpack", "start": "next start", "lint": "eslint", + "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { + "@adventureos/application": "*", "@adventureos/db": "*", + "@adventureos/domain": "*", "@adventureos/shared": "*", "@serwist/next": "^9.0.12", "@tanstack/react-query": "^5.67.2", diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js old mode 100755 new mode 100644 index 562baa8..d9dcb87 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,s,r,n={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"u">typeof registration?registration.scope:""},i=e=>[n.prefix,e,n.suffix].filter(e=>e&&e.length>0).join("-"),c=e=>e||i(n.precache),o=e=>e||i(n.runtime);var l=class extends Error{details;constructor(e,t){super(((e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a})(e,t)),this.name=e,this.details=t}};function h(e){return new Promise(t=>setTimeout(t,e))}let u=new Set;function d(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function m(e,t,a,s){let r=d(t.url,a);if(t.url===r)return e.match(t,s);let n={...s,ignoreSearch:!0};for(let i of(await e.keys(t,n)))if(r===d(i.url,a))return e.match(i,s)}var f=class{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}};let g=async()=>{for(let e of u)await e()},w="-precache-",p=async(e,t=w)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},y=(e,t)=>{let a=t();return e.waitUntil(a),a},_=(e,t)=>t.some(t=>e instanceof t),x=new WeakMap,b=new WeakMap,v=new WeakMap,E={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return x.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return R(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function R(e){if(e instanceof IDBRequest){let t;return t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("success",r),e.removeEventListener("error",n)},r=()=>{t(R(e.result)),s()},n=()=>{a(e.error),s()};e.addEventListener("success",r),e.addEventListener("error",n)}),v.set(t,e),t}if(b.has(e))return b.get(e);let t=function(e){if("function"==typeof e)return(r||(r=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(q(this),t),R(this.request)}:function(...t){return R(e.apply(q(this),t))};return(e instanceof IDBTransaction&&function(e){if(x.has(e))return;let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("complete",r),e.removeEventListener("error",n),e.removeEventListener("abort",n)},r=()=>{t(),s()},n=()=>{a(e.error||new DOMException("AbortError","AbortError")),s()};e.addEventListener("complete",r),e.addEventListener("error",n),e.addEventListener("abort",n)});x.set(e,t)}(e),_(e,s||(s=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,E):e}(e);return t!==e&&(b.set(e,t),v.set(t,e)),t}let q=e=>v.get(e);function S(e,t,{blocked:a,upgrade:s,blocking:r,terminated:n}={}){let i=indexedDB.open(e,t),c=R(i);return s&&i.addEventListener("upgradeneeded",e=>{s(R(i.result),e.oldVersion,e.newVersion,R(i.transaction),e)}),a&&i.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{n&&e.addEventListener("close",()=>n()),r&&e.addEventListener("versionchange",e=>r(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}let D=["get","getKey","getAll","getAllKeys","count"],N=["put","add","delete","clear"],C=new Map;function T(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(C.get(t))return C.get(t);let a=t.replace(/FromIndex$/,""),s=t!==a,r=N.includes(a);if(!(a in(s?IDBIndex:IDBObjectStore).prototype)||!(r||D.includes(a)))return;let n=async function(e,...t){let n=this.transaction(e,r?"readwrite":"readonly"),i=n.store;return s&&(i=i.index(t.shift())),(await Promise.all([i[a](...t),r&&n.done]))[0]};return C.set(t,n),n}E={...e=E,get:(t,a,s)=>T(t,a)||e.get(t,a,s),has:(t,a)=>!!T(t,a)||e.has(t,a)};let P=["continue","continuePrimaryKey","advance"],k={},A=new WeakMap,I=new WeakMap,U={get(e,t){if(!P.includes(t))return e[t];let a=k[t];return a||(a=k[t]=function(...e){A.set(this,I.get(this)[t](...e))}),a}};async function*L(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,U);for(I.set(a,t),v.set(a,q(t));t;)yield a,t=await (A.get(a)||t.continue()),A.delete(a)}function F(e,t){return t===Symbol.asyncIterator&&_(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&_(e,[IDBIndex,IDBObjectStore])}E={...t=E,get:(e,a,s)=>F(e,a)?L:t.get(e,a,s),has:(e,a)=>F(e,a)||t.has(e,a)};let M=async(e,t)=>{let s=null;if(e.url&&(s=new URL(e.url).origin),s!==self.location.origin)throw new l("cross-origin-copy-response",{origin:s});let r=e.clone(),n={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},i=t?t(n):n,c=!function(){if(void 0===a){let e=new Response("");if("body"in e)try{new Response(e.body),a=!0}catch{a=!1}a=!1}return a}()?await r.blob():r.body;return new Response(c,i)},O="requests",B="queueName";var K=class{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(O,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){return(await (await this.getDb()).transaction(O).store.openCursor())?.value.id}async getAllEntriesByQueueName(e){return await (await this.getDb()).getAllFromIndex(O,B,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(O,B,IDBKeyRange.only(e))}async deleteEntry(e){await (await this.getDb()).delete(O,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){return(await (await this.getDb()).transaction(O).store.index(B).openCursor(e,t))?.value}async getDb(){return this._db||(this._db=await S("serwist-background-sync",3,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(O)&&e.deleteObjectStore(O),e.createObjectStore(O,{autoIncrement:!0,keyPath:"id"}).createIndex(B,B,{unique:!1})}},W=class{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new K}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}};let j=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];var $=class e{_requestData;static async fromRequest(t){let a={url:t.url,headers:{}};for(let e of("GET"!==t.method&&(a.body=await t.clone().arrayBuffer()),t.headers.forEach((e,t)=>{a.headers[t]=e}),j))void 0!==t[e]&&(a[e]=t[e]);return new e(a)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new e(this.toObject())}};let H="serwist-background-sync",G=new Set,Q=e=>{let t={request:new $(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};var V=class{_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:s}={}){if(G.has(e))throw new l("duplicate-queue-name",{name:e});G.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=s||10080,this._forceSyncFallback=!!t,this._queueStore=new W(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let s of e){let e=60*this._maxRetentionTime*1e3;t-s.timestamp>e?await this._queueStore.deleteEntry(s.id):a.push(Q(s))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},s){let r={requestData:(await $.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(r.metadata=t),s){case"push":await this._queueStore.pushEntry(r);break;case"unshift":await this._queueStore.unshiftEntry(r)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let s=60*this._maxRetentionTime*1e3;return a-t.timestamp>s?this._removeRequest(e):Q(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new l("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${H}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${H}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return G}},z=class{_queue;constructor(e,t){this._queue=new V(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}};let J={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function X(e){return"string"==typeof e?new Request(e):e}var Y=class{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(const a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new f,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=X(e),s=await this.getPreloadResponse();if(s)return s;let r=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let n=a.clone();try{let e;for(let s of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await s({event:t,request:n,response:e});return e}catch(e){throw r&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:r.clone(),request:n.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=X(e),{cacheName:s,matchOptions:r}=this._strategy,n=await this.getCacheKey(a,"read"),i={...r,cacheName:s};for(let e of(t=await caches.match(n,i),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:s,matchOptions:r,cachedResponse:t,request:n,event:this.event})||void 0;return t}async cachePut(e,t){let a=X(e);await h(0);let s=await this.getCacheKey(a,"write");if(!t)throw new l("cache-put-with-no-response",{url:new URL(String(s.url),location.href).href.replace(RegExp(`^${location.origin}`),"")});let r=await this._ensureResponseSafeToCache(t);if(!r)return!1;let{cacheName:n,matchOptions:i}=this._strategy,c=await self.caches.open(n),o=this.hasCallback("cacheDidUpdate"),u=o?await m(c,s.clone(),["__WB_REVISION__"],i):null;try{await c.put(s,o?r.clone():r)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await g(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:n,oldResponse:u,newResponse:r.clone(),request:s,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let s=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))s=X(await e({mode:t,request:s,event:this.event,params:this.params}));this._cacheKeys[a]=s}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),s=s=>{let r={...s,state:a};return t[e](r)};yield s}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){return}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}},Z=class{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=o(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,s=new Y(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),r=this._getResponse(s,a,t);return[r,this._awaitComplete(r,s,a,t)]}async _getResponse(e,t,a){let s;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(s=await this._handle(t,e),void 0===s||"error"===s.type)throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(let n of e.iterateCallbacks("handlerDidError"))if(void 0!==(s=await n({error:r,event:a,request:t})))break}if(!s)throw r}for(let r of e.iterateCallbacks("handlerWillRespond"))s=await r({event:a,request:t,response:s});return s}async _awaitComplete(e,t,a,s){let r,n;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:s,request:a,response:r}),await t.doneWaiting()}catch(e){e instanceof Error&&(n=e)}if(await t.runCallbacks("handlerDidComplete",{event:s,request:a,response:r,error:n}),t.destroy(),n)throw n}},ee=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(J),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s=[],r=[];if(this._networkTimeoutSeconds){let{id:n,promise:i}=this._getTimeoutPromise({request:e,logs:s,handler:t});a=n,r.push(i)}let n=this._getNetworkPromise({timeoutId:a,request:e,logs:s,handler:t});r.push(n);let i=await t.waitUntil((async()=>await t.waitUntil(Promise.race(r))||await n)());if(!i)throw new l("no-response",{url:e.url});return i}_getTimeoutPromise({request:e,logs:t,handler:a}){let s;return{promise:new Promise(t=>{s=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:s}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:s}){let r,n;try{n=await s.fetchAndCachePut(t)}catch(e){e instanceof Error&&(r=e)}return e&&clearTimeout(e),(r||!n)&&(n=await s.cacheMatch(t)),n}},et=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=h(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(s=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}};let ea=e=>e&&"object"==typeof e?e:{handle:e};var es=class{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=ea(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=ea(e)}},er=class e extends Z{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await M(e):e};constructor(t={}){t.cacheName=c(t.cacheName),super(t),this._fallbackToNetwork=!1!==t.fallbackToNetwork,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let s=await t.cacheMatch(e);return s||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,s=t.params||{};if(this._fallbackToNetwork){let r=s.integrity,n=e.integrity,i=!n||n===r;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?n||r:void 0})),r&&i&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new l("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let t=null,a=0;for(let[s,r]of this.plugins.entries())r!==e.copyRedirectedCacheableResponsesPlugin&&(r===e.defaultPrecacheCacheabilityPlugin&&(t=s),r.cacheWillUpdate&&a++);0===a?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):a>1&&null!==t&&this.plugins.splice(t,1)}},en=class extends es{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}},ei=class extends es{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}};let ec=e=>{if(!e)throw new l("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let s=new URL(a,location.href),r=new URL(a,location.href);return s.searchParams.set("__WB_REVISION__",t),{cacheKey:s.href,url:r.href}};var eo=class{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}};let el=async(e,t,a)=>{let s=t.map((e,t)=>({index:t,item:e})),r=async e=>{let t=[];for(;;){let r=s.pop();if(!r)return e(t);let n=await a(r.item);t.push({result:n,index:r.index})}},n=Array.from({length:e},()=>new Promise(r));return(await Promise.all(n)).flat().sort((e,t)=>e.indexe.result)};"u">typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent);let eh="cache-entries",eu=e=>{let t=new URL(e,location.href);return t.hash="",t.href};var ed=class{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eu(e)}`}_upgradeDb(e){let t=e.createObjectStore(eh,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&function(e,{blocked:t}={}){let a=indexedDB.deleteDatabase(e);t&&a.addEventListener("blocked",e=>t(e.oldVersion,e)),R(a).then(()=>void 0)}(this._cacheName)}async setTimestamp(e,t){e=eu(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},s=(await this.getDb()).transaction(eh,"readwrite",{durability:"relaxed"});await s.store.put(a),await s.done}async getTimestamp(e){return(await (await this.getDb()).get(eh,this._getId(e)))?.timestamp}async expireEntries(e,t){let a=await (await this.getDb()).transaction(eh,"readwrite").store.index("timestamp").openCursor(null,"prev"),s=[],r=0;for(;a;){let n=a.value;n.cacheName===this._cacheName&&(e&&n.timestamp=t?(a.delete(),s.push(n.url)):r++),a=await a.continue()}return s}async getDb(){return this._db||(this._db=await S("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}},em=class{_isRunning=!1;_rerunRequested=!1;_maxEntries;_maxAgeSeconds;_matchOptions;_cacheName;_timestampModel;constructor(e,t={}){this._maxEntries=t.maxEntries,this._maxAgeSeconds=t.maxAgeSeconds,this._matchOptions=t.matchOptions,this._cacheName=e,this._timestampModel=new ed(e)}async expireEntries(){if(this._isRunning){this._rerunRequested=!0;return}this._isRunning=!0;let e=this._maxAgeSeconds?Date.now()-1e3*this._maxAgeSeconds:0,t=await this._timestampModel.expireEntries(e,this._maxEntries),a=await self.caches.open(this._cacheName);for(let e of t)await a.delete(e,this._matchOptions);this._isRunning=!1,this._rerunRequested&&(this._rerunRequested=!1,this.expireEntries())}async updateTimestamp(e){await this._timestampModel.setTimestamp(e,Date.now())}async isURLExpired(e){if(!this._maxAgeSeconds)return!1;let t=await this._timestampModel.getTimestamp(e),a=Date.now()-1e3*this._maxAgeSeconds;return void 0===t||t{u.add(e)})(()=>this.deleteCacheAndMetadata())}_getCacheExpiration(e){if(e===o())throw new l("expire-custom-caches-only");let t=this._cacheExpirations.get(e);return t||(t=new em(e,this._config),this._cacheExpirations.set(e,t)),t}cachedResponseWillBeUsed({event:e,cacheName:t,request:a,cachedResponse:s}){if(!s)return null;let r=this._isResponseDateFresh(s),n=this._getCacheExpiration(t),i="last-used"===this._config.maxAgeFrom,c=(async()=>{i&&await n.updateTimestamp(a.url),await n.expireEntries()})();try{e.waitUntil(c)}catch{}return r?s:null}_isResponseDateFresh(e){if("last-used"===this._config.maxAgeFrom)return!0;let t=Date.now();if(!this._config.maxAgeSeconds)return!0;let a=this._getDateHeaderTimestamp(e);return null===a||a>=t-1e3*this._config.maxAgeSeconds}_getDateHeaderTimestamp(e){if(!e.headers.has("date"))return null;let t=new Date(e.headers.get("date")).getTime();return Number.isNaN(t)?null:t}async cacheDidUpdate({cacheName:e,request:t}){let a=this._getCacheExpiration(e);await a.updateTimestamp(t.url),await a.expireEntries()}async deleteCacheAndMetadata(){for(let[e,t]of this._cacheExpirations)await self.caches.delete(e),await t.delete();this._cacheExpirations=new Map}};let eg=/^\/(\w+\/)?collect/,ew=({serwist:e,cacheName:t,...a})=>{let s,r,c=t||i(n.googleAnalytics),o=new z("serwist-google-analytics",{maxRetentionTime:2880,onSync:async({queue:e})=>{let t;for(;t=await e.shiftRequest();){let{request:s,timestamp:r}=t,n=new URL(s.url);try{let e="POST"===s.method?new URLSearchParams(await s.clone().text()):n.searchParams,t=r-(Number(e.get("qt"))||0),i=Date.now()-t;if(e.set("qt",String(i)),a.parameterOverrides)for(let t of Object.keys(a.parameterOverrides)){let s=a.parameterOverrides[t];e.set(t,s)}"function"==typeof a.hitFilter&&a.hitFilter.call(null,e),await fetch(new Request(n.origin+n.pathname,{body:e.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(a){throw await e.unshiftRequest(t),a}}}});for(let t of[new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,new ee({cacheName:c}),"GET"),new es(s=({url:e})=>"www.google-analytics.com"===e.hostname&&eg.test(e.pathname),r=new et({plugins:[o]}),"GET"),new es(s,r,"POST")])e.registerRoute(t)};var ep=class{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}};let ey=async(e,t)=>{try{if(206===t.status)return t;let a=e.headers.get("range");if(!a)throw new l("no-range-header");let s=(e=>{let t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new l("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new l("single-range-only",{normalizedRangeHeader:t});let a=/(\d*)-(\d*)/.exec(t);if(!a||!(a[1]||a[2]))throw new l("invalid-range-values",{normalizedRangeHeader:t});return{start:""===a[1]?void 0:Number(a[1]),end:""===a[2]?void 0:Number(a[2])}})(a),r=await t.blob(),n=((e,t,a)=>{let s,r,n=e.size;if(a&&a>n||t&&t<0)throw new l("range-not-satisfiable",{size:n,end:a,start:t});return void 0!==t&&void 0!==a?(s=t,r=a+1):void 0!==t&&void 0===a?(s=t,r=n):void 0!==a&&void 0===t&&(s=n-a,r=n),{start:s,end:r}})(r,s.start,s.end),i=r.slice(n.start,n.end),c=i.size,o=new Response(i,{status:206,statusText:"Partial Content",headers:t.headers});return o.headers.set("Content-Length",String(c)),o.headers.set("Content-Range",`bytes ${n.start}-${n.end-1}/${r.size}`),o}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}};var e_=class{cachedResponseWillBeUsed=async({request:e,cachedResponse:t})=>t&&e.headers.has("range")?await ey(e,t):t},ex=class extends Z{async _handle(e,t){let a,s=await t.cacheMatch(e);if(s);else try{s=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}},eb=class extends Z{constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(J)}async _handle(e,t){let a,s=t.fetchAndCachePut(e).catch(()=>{});t.waitUntil(s);let r=await t.cacheMatch(e);if(r);else try{r=await s}catch(e){e instanceof Error&&(a=e)}if(!r)throw new l("no-response",{url:e.url,error:a});return r}},ev=class extends es{constructor(e,t){super(({request:a})=>{let s=e.getUrlsToPrecacheKeys();for(let r of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:s=!0,urlManipulation:r}={}){let n=new URL(e,location.href);n.hash="",yield n.href;let i=((e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e})(n,a);if(yield i.href,t&&i.pathname.endsWith("/")){let e=new URL(i.href);e.pathname+=t,yield e.href}if(s){let e=new URL(i.href);e.pathname+=".html",yield e.href}if(r)for(let e of r({url:n}))yield e.href}(a.url,t)){let t=s.get(r);if(t)return{cacheKey:t,integrity:e.getIntegrityForPrecacheKey(t)}}},e.precacheStrategy)}},eE=class{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}},eR=class{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:s,navigationPreload:r=!1,cacheId:i,clientsClaim:o=!1,runtimeCaching:l,offlineAnalyticsConfig:h,disableDevLogs:u=!1,fallbacks:d,requestRules:m}={}){const{precacheStrategyOptions:f,precacheRouteOptions:g,precacheMiscOptions:w}=((e,t={})=>{let{cacheName:a,plugins:s=[],fetchOptions:r,matchOptions:n,fallbackToNetwork:i,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:m=10,navigateFallback:f,navigateFallbackAllowlist:g,navigateFallbackDenylist:w}=t??{};return{precacheStrategyOptions:{cacheName:c(a),plugins:[...s,new eE({precacheController:e})],fetchOptions:r,matchOptions:n,fallbackToNetwork:i},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:m,navigateFallback:f,navigateFallbackAllowlist:g,navigateFallbackDenylist:w}}})(this,t);if(this._concurrentPrecaching=w.concurrency,this._precacheStrategy=new er(f),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=m,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),s&&s.length>0&&self.importScripts(...s),r&&self.registration?.navigationPreload&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{}))}),void 0!==i&&(e=>{var t=e;for(let e of Object.keys(n))(e=>{let a=t[e];"string"==typeof a&&(n[e]=a)})(e)})({prefix:i}),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),o&&self.addEventListener("activate",()=>self.clients.claim()),e&&e.length>0&&this.addToPrecacheList(e),w.cleanupOutdatedCaches&&(e=>{self.addEventListener("activate",t=>{t.waitUntil(p(c(e)).then(e=>{}))})})(f.cacheName),this.registerRoute(new ev(this,g)),w.navigateFallback&&this.registerRoute(new en(this.createHandlerBoundToUrl(w.navigateFallback),{allowlist:w.navigateFallbackAllowlist,denylist:w.navigateFallbackDenylist})),void 0!==h&&("boolean"==typeof h?h&&ew({serwist:this}):ew({...h,serwist:this})),void 0!==l){if(void 0!==d){const e=new ep({fallbackUrls:d.entries,serwist:this});l.forEach(t=>{t.handler instanceof Z&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(const e of l)this.registerCapture(e.matcher,e.handler,e.method)}u&&(self.__WB_DISABLE_DEV_LOGS=!0)}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:s}=ec(a),r="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(s)&&this._urlsToCacheKeys.get(s)!==e)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(s),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:s});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(s,e),this._urlsToCacheModes.set(s,r)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new ei(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}};let eq=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new ex({cacheName:"google-fonts-webfonts",plugins:[new ef({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new ex({cacheName:"next-static-js-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new ex({cacheName:"static-audio-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:mp4|webm)$/i,handler:new ex({cacheName:"static-video-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new ef({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new ee({cacheName:"next-data",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new ee({cacheName:"static-data-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new et({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new ee({cacheName:"apis",plugins:[new ef({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc-prefetch",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new ee({cacheName:"others",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new ee({cacheName:"cross-origin",plugins:[new ef({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new et}];new eR({precacheEntries:[{'revision':null,'url':'/_next/static/chunks/3199-4a3557850e4b6905.js'},{'revision':null,'url':'/_next/static/chunks/3759-5d3f98d0fa0b9f96.js'},{'revision':null,'url':'/_next/static/chunks/5239-9df6f85b3ce4fcfc.js'},{'revision':null,'url':'/_next/static/chunks/8479-7692f8880c4c2990.js'},{'revision':null,'url':'/_next/static/chunks/87c73c54-f46fec743414da25.js'},{'revision':null,'url':'/_next/static/chunks/8916-8fae66d930cf17bb.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-4ba38dd60d12680e.js'},{'revision':null,'url':'/_next/static/chunks/app/achievements/page-400c045a202d4e32.js'},{'revision':null,'url':'/_next/static/chunks/app/api/achievements/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/actions/%5Bid%5D/undo/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/actions/recent/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/apply-template/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/items/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/items/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/quick-log/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/rest-day/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/todos/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/todos/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/catch-up/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/chat/knows/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/chat/sessions/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/chat/sessions/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/config/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/context/preview/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/health/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/learning/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/reset/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/suggestions/%5Bid%5D/accept/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/suggestions/%5Bid%5D/ignore/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/suggestions/%5Bid%5D/reject/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/suggestions/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/summary/rebuild/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/summary/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/models/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/suggestions/%5Bid%5D/dismiss/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/suggestions/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/templates/%5Bkey%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/templates/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/auth/login/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/%5Bid%5D/log-pages/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/cartographer/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/cron/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/dashboard/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/explorations/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/explorations/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/export/json/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/books/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/context/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/cover/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/progress/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/status/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/reflections/%5Bdate%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/reviews/%5BweekStart%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/settings/day-boundary/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/settings/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/%5Bdomain%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/teacher/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/teacher/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/templates/%5Bid%5D/items/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/templates/%5Bid%5D/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/templates/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/api/themes/route-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/app/cartographer/page-e4b8654b3c21d853.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-ba1ea38fbfcd8c88.js'},{'revision':null,'url':'/_next/static/chunks/app/library/page-1cac926f1104fe58.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-5f636e39fc0fa1fb.js'},{'revision':null,'url':'/_next/static/chunks/app/mentor/page-f34d5a926ba84c5f.js'},{'revision':null,'url':'/_next/static/chunks/app/page-5b6e9862a852a9e1.js'},{'revision':null,'url':'/_next/static/chunks/app/review/page-c79e740f3c0d7f0c.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/page-934e9d4dcda30737.js'},{'revision':null,'url':'/_next/static/chunks/app/statistics/page-27722c50b7ca4629.js'},{'revision':null,'url':'/_next/static/chunks/app/teacher/page-ccb6ea419b8acacc.js'},{'revision':null,'url':'/_next/static/chunks/app/yearly/page-bd1926b7a8dd7e73.js'},{'revision':null,'url':'/_next/static/chunks/framework-af3c63e39f557570.js'},{'revision':null,'url':'/_next/static/chunks/main-4a90bc78b0d3fc62.js'},{'revision':null,'url':'/_next/static/chunks/main-app-567e4578199091e8.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-555d6023af128706.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/not-found-1006750767d18832.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-1006750767d18832.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-99360225e07f4440.js'},{'revision':null,'url':'/_next/static/css/8d14fbb5e0c187b9.css'},{'revision':'fb2db2f80495800c6e7f12c4471d9562','url':'/_next/static/uAVFOFW83j-Znj8sxuLaW/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/uAVFOFW83j-Znj8sxuLaW/_ssgManifest.js'},{'revision':'d09f95206c3fa0bb9bd9fefabfd0ea71','url':'/file.svg'},{'revision':'2aaafa6a49b6563925fe440891e32717','url':'/globe.svg'},{'revision':'0927dc4135144aca169f3c770914dcbe','url':'/icons/icon.svg'},{'revision':'e8f86e44407fcbcd8f9748e1b3f67e95','url':'/manifest.json'},{'revision':'8e061864f388b47f33a1c3780831193e','url':'/next.svg'},{'revision':'c0af2f507b369b085b35ef4bbe3bcf1e','url':'/vercel.svg'},{'revision':'a2760511c65806022ad20adf74370ff3','url':'/window.svg'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners()})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new ei(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}};let eq=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new ex({cacheName:"google-fonts-webfonts",plugins:[new ef({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new ex({cacheName:"next-static-js-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new ex({cacheName:"static-audio-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:mp4|webm)$/i,handler:new ex({cacheName:"static-video-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new ef({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new ee({cacheName:"next-data",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new ee({cacheName:"static-data-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new et({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new ee({cacheName:"apis",plugins:[new ef({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc-prefetch",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new ee({cacheName:"others",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new ee({cacheName:"cross-origin",plugins:[new ef({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new et}];new eR({precacheEntries:[{'revision':'de28497eb1c246bf4a01759b4564c286','url':'/_next/static/ZF0tiWPdI0noqgxb556hz/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/ZF0tiWPdI0noqgxb556hz/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/2141-bb474ca3b44354c1.js'},{'revision':null,'url':'/_next/static/chunks/3199-4a3557850e4b6905.js'},{'revision':null,'url':'/_next/static/chunks/3759-a25f292284a5790e.js'},{'revision':null,'url':'/_next/static/chunks/5239-9df6f85b3ce4fcfc.js'},{'revision':null,'url':'/_next/static/chunks/5628-cf90ba8b1c4c4fb3.js'},{'revision':null,'url':'/_next/static/chunks/8479-b4ab0574e7976b71.js'},{'revision':null,'url':'/_next/static/chunks/87c73c54-f46fec743414da25.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-4ba38dd60d12680e.js'},{'revision':null,'url':'/_next/static/chunks/app/achievements/page-400c045a202d4e32.js'},{'revision':null,'url':'/_next/static/chunks/app/api/achievements/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/actions/%5Bid%5D/undo/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/actions/recent/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/apply-template/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/items/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/items/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/quick-log/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/rest-day/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/todos/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/%5Bdate%5D/todos/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/adventures/catch-up/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/chat/knows/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/chat/sessions/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/chat/sessions/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/config/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/context/preview/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/health/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/learning/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/reset/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/suggestions/%5Bid%5D/accept/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/suggestions/%5Bid%5D/ignore/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/suggestions/%5Bid%5D/reject/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/suggestions/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/summary/rebuild/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/memory/summary/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/models/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/suggestions/%5Bid%5D/dismiss/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/suggestions/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/templates/%5Bkey%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ai/templates/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/auth/login/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/%5Bid%5D/log-pages/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/cartographer/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/cron/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/dashboard/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/explorations/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/explorations/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/export/json/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/books/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/context/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/cover/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/progress/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/library/status/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/reflections/%5Bdate%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/resource-links/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/resource-links/export/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/resource-links/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/reviews/%5BweekStart%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/settings/day-boundary/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/settings/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/%5Bdomain%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/teacher/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/teacher/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/templates/%5Bid%5D/items/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/templates/%5Bid%5D/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/templates/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/api/themes/route-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/app/cartographer/page-e4b8654b3c21d853.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-ba1ea38fbfcd8c88.js'},{'revision':null,'url':'/_next/static/chunks/app/library/page-1cac926f1104fe58.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-5f636e39fc0fa1fb.js'},{'revision':null,'url':'/_next/static/chunks/app/mentor/page-65412e2c0dd56661.js'},{'revision':null,'url':'/_next/static/chunks/app/page-352053835d19ae27.js'},{'revision':null,'url':'/_next/static/chunks/app/review/page-91ae0a3dac707994.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/page-b2b01f0d72622aa4.js'},{'revision':null,'url':'/_next/static/chunks/app/statistics/page-27722c50b7ca4629.js'},{'revision':null,'url':'/_next/static/chunks/app/teacher/page-ccb6ea419b8acacc.js'},{'revision':null,'url':'/_next/static/chunks/app/yearly/page-bd1926b7a8dd7e73.js'},{'revision':null,'url':'/_next/static/chunks/framework-af3c63e39f557570.js'},{'revision':null,'url':'/_next/static/chunks/main-4a90bc78b0d3fc62.js'},{'revision':null,'url':'/_next/static/chunks/main-app-567e4578199091e8.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-555d6023af128706.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/not-found-42fda1d5c30bf2ee.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-42fda1d5c30bf2ee.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-99360225e07f4440.js'},{'revision':null,'url':'/_next/static/css/a1f2fe4261378fbb.css'},{'revision':'d09f95206c3fa0bb9bd9fefabfd0ea71','url':'/file.svg'},{'revision':'2aaafa6a49b6563925fe440891e32717','url':'/globe.svg'},{'revision':'0927dc4135144aca169f3c770914dcbe','url':'/icons/icon.svg'},{'revision':'e8f86e44407fcbcd8f9748e1b3f67e95','url':'/manifest.json'},{'revision':'8e061864f388b47f33a1c3780831193e','url':'/next.svg'},{'revision':'c0af2f507b369b085b35ef4bbe3bcf1e','url':'/vercel.svg'},{'revision':'a2760511c65806022ad20adf74370ff3','url':'/window.svg'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners()})(); \ No newline at end of file diff --git a/apps/web/src/adapters/persistence/drizzle-action-events.repository.ts b/apps/web/src/adapters/persistence/drizzle-action-events.repository.ts new file mode 100644 index 0000000..6df505e --- /dev/null +++ b/apps/web/src/adapters/persistence/drizzle-action-events.repository.ts @@ -0,0 +1,54 @@ +import { and, eq } from "drizzle-orm"; +import type { + ActionEventsRepository, + ActionEventRecord, + RecordActionInput, +} from "@adventureos/application"; +import { db, actionEvents } from "@/lib/db"; + +function mapActionEvent(row: typeof actionEvents.$inferSelect): ActionEventRecord { + return { + id: row.id, + userId: row.userId, + actionType: row.actionType, + entityType: row.entityType, + entityId: row.entityId, + summary: row.summary, + beforeState: row.beforeState as Record, + afterState: row.afterState as Record, + metadata: (row.metadata as Record) ?? null, + createdAt: row.createdAt, + undoneAt: row.undoneAt, + }; +} + +export const drizzleActionEventsRepository: ActionEventsRepository = { + async recordAction(input: RecordActionInput) { + const [row] = await db + .insert(actionEvents) + .values({ + userId: input.userId, + actionType: input.actionType, + entityType: input.entityType, + entityId: input.entityId, + summary: input.summary, + beforeState: input.beforeState, + afterState: input.afterState, + metadata: input.metadata ?? null, + }) + .returning(); + return mapActionEvent(row); + }, + + async getActionEvent(id, userId) { + const [row] = await db + .select() + .from(actionEvents) + .where(and(eq(actionEvents.id, id), eq(actionEvents.userId, userId))); + return row ? mapActionEvent(row) : null; + }, + + async markUndone(id) { + await db.update(actionEvents).set({ undoneAt: new Date() }).where(eq(actionEvents.id, id)); + }, +}; diff --git a/apps/web/src/adapters/persistence/drizzle-adventure.repository.ts b/apps/web/src/adapters/persistence/drizzle-adventure.repository.ts new file mode 100644 index 0000000..b50207d --- /dev/null +++ b/apps/web/src/adapters/persistence/drizzle-adventure.repository.ts @@ -0,0 +1,183 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { + AdventureRepository, + AdventureTemplateItemRecord, + AdventureTemplateRecord, + DailyAdventureItemRecord, + DailyAdventureRecord, + DailyTodoRecord, +} from "@adventureos/application"; +import type { AdventureItemState } from "@adventureos/domain"; +import { + db, + adventureItems, + adventureTemplates, + dailyAdventureItems, + dailyAdventures, + dailyTodos, +} from "@/lib/db"; + +function mapAdventure(row: typeof dailyAdventures.$inferSelect): DailyAdventureRecord { + return { + id: row.id, + userId: row.userId, + date: row.date, + templateId: row.templateId, + isRestDay: row.isRestDay, + isCustomized: row.isCustomized, + workHoursTarget: row.workHoursTarget, + dayMode: row.dayMode, + }; +} + +function mapItem(row: typeof dailyAdventureItems.$inferSelect): DailyAdventureItemRecord { + return { + id: row.id, + dailyAdventureId: row.dailyAdventureId, + type: row.type, + label: row.label, + config: (row.config as Record) ?? null, + value: (row.value as Record) ?? null, + state: row.state as AdventureItemState, + completedAt: row.completedAt, + enabled: row.enabled, + isCustom: row.isCustom, + sortOrder: row.sortOrder, + sourceItemId: row.sourceItemId, + }; +} + +function mapTodo(row: typeof dailyTodos.$inferSelect): DailyTodoRecord { + return { + id: row.id, + dailyAdventureId: row.dailyAdventureId, + label: row.label, + done: row.done, + sortOrder: row.sortOrder, + }; +} + +function mapTemplate(row: typeof adventureTemplates.$inferSelect): AdventureTemplateRecord { + return { + id: row.id, + userId: row.userId, + daysOfWeek: row.daysOfWeek, + sortPriority: row.sortPriority, + isDefault: row.isDefault, + deletedAt: row.deletedAt, + }; +} + +function mapTemplateItem(row: typeof adventureItems.$inferSelect): AdventureTemplateItemRecord { + return { + id: row.id, + templateId: row.templateId, + type: row.type, + label: row.label, + config: (row.config as Record) ?? null, + sortOrder: row.sortOrder, + enabled: row.enabled, + deletedAt: row.deletedAt, + }; +} + +export const drizzleAdventureRepository: AdventureRepository = { + async findDailyAdventure(userId, date) { + const [row] = await db + .select() + .from(dailyAdventures) + .where(and(eq(dailyAdventures.userId, userId), eq(dailyAdventures.date, date))); + return row ? mapAdventure(row) : null; + }, + + async createDailyAdventure(userId, date, templateId = null) { + const [row] = await db + .insert(dailyAdventures) + .values({ userId, date, templateId }) + .returning(); + return mapAdventure(row); + }, + + async listDailyAdventureItems(adventureId) { + const rows = await db + .select() + .from(dailyAdventureItems) + .where( + and( + eq(dailyAdventureItems.dailyAdventureId, adventureId), + isNull(dailyAdventureItems.deletedAt) + ) + ) + .orderBy(dailyAdventureItems.sortOrder); + return rows.map(mapItem); + }, + + async listDailyTodos(adventureId) { + const rows = await db + .select() + .from(dailyTodos) + .where(and(eq(dailyTodos.dailyAdventureId, adventureId), isNull(dailyTodos.deletedAt))) + .orderBy(dailyTodos.sortOrder); + return rows.map(mapTodo); + }, + + async updateDailyAdventureItem(itemId, updates) { + await db.update(dailyAdventureItems).set(updates).where(eq(dailyAdventureItems.id, itemId)); + }, + + async softDeleteDailyAdventureItems(adventureId) { + await db + .update(dailyAdventureItems) + .set({ deletedAt: new Date() }) + .where(eq(dailyAdventureItems.dailyAdventureId, adventureId)); + }, + + async insertDailyAdventureItem(adventureId, item) { + const [row] = await db + .insert(dailyAdventureItems) + .values({ + dailyAdventureId: adventureId, + sourceItemId: item.sourceItemId, + type: item.type, + label: item.label, + config: item.config ?? {}, + value: item.value ?? {}, + state: item.state, + completedAt: item.completedAt, + sortOrder: item.sortOrder, + enabled: item.enabled, + isCustom: item.isCustom, + }) + .returning(); + return mapItem(row); + }, + + async updateDailyAdventure(adventureId, updates) { + await db.update(dailyAdventures).set(updates).where(eq(dailyAdventures.id, adventureId)); + }, + + async listTemplates(userId) { + const rows = await db + .select() + .from(adventureTemplates) + .where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt))); + return rows.map(mapTemplate); + }, + + async listTemplateItems(templateId) { + const rows = await db + .select() + .from(adventureItems) + .where(and(eq(adventureItems.templateId, templateId), isNull(adventureItems.deletedAt))) + .orderBy(adventureItems.sortOrder); + return rows.map(mapTemplateItem); + }, + + async findTemplate(userId, templateId) { + const [row] = await db + .select() + .from(adventureTemplates) + .where(and(eq(adventureTemplates.id, templateId), eq(adventureTemplates.userId, userId))); + return row ? mapTemplate(row) : null; + }, +}; diff --git a/apps/web/src/adapters/persistence/drizzle-scoring.service.ts b/apps/web/src/adapters/persistence/drizzle-scoring.service.ts new file mode 100644 index 0000000..5567b69 --- /dev/null +++ b/apps/web/src/adapters/persistence/drizzle-scoring.service.ts @@ -0,0 +1,7 @@ +import type { ScoringService } from "@adventureos/application"; +import { buildDaySnapshot, refreshScores } from "@/lib/services/adventure/scoring"; + +export const drizzleScoringService: ScoringService = { + buildDaySnapshot, + refreshScores, +}; diff --git a/apps/web/src/adapters/persistence/drizzle-xp.repository.ts b/apps/web/src/adapters/persistence/drizzle-xp.repository.ts new file mode 100644 index 0000000..0a4369c --- /dev/null +++ b/apps/web/src/adapters/persistence/drizzle-xp.repository.ts @@ -0,0 +1,71 @@ +import { and, eq, gte, lte, sum } from "drizzle-orm"; +import type { XpRepository, XpEventRecord, UserProgressRecord } from "@adventureos/application"; +import type { XpSource } from "@adventureos/domain"; +import { db, userProgress, xpEvents } from "@/lib/db"; + +function mapProgress(row: typeof userProgress.$inferSelect): UserProgressRecord { + return { + userId: row.userId, + totalXp: row.totalXp, + level: row.level, + currentChapter: row.currentChapter, + consistencyScore: row.consistencyScore, + disciplineScore: row.disciplineScore, + learningScore: row.learningScore, + spiritualScore: row.spiritualScore, + healthScore: row.healthScore, + readingScore: row.readingScore, + }; +} + +function mapXpEvent(row: typeof xpEvents.$inferSelect): XpEventRecord { + return { + id: row.id, + userId: row.userId, + date: row.date, + source: row.source as XpSource, + amount: row.amount, + metadata: (row.metadata as Record) ?? null, + }; +} + +export const drizzleXpRepository: XpRepository = { + async getDailyXpEarned(userId, date, source) { + const conditions = [eq(xpEvents.userId, userId), eq(xpEvents.date, date)]; + if (source) conditions.push(eq(xpEvents.source, source)); + const [row] = await db + .select({ total: sum(xpEvents.amount) }) + .from(xpEvents) + .where(and(...conditions)); + return Number(row?.total ?? 0); + }, + + async insertXpEvent(event) { + const [row] = await db.insert(xpEvents).values(event).returning(); + return mapXpEvent(row); + }, + + async getUserProgress(userId) { + const [row] = await db.select().from(userProgress).where(eq(userProgress.userId, userId)); + return row ? mapProgress(row) : null; + }, + + async updateUserProgress(userId, updates) { + await db.update(userProgress).set(updates).where(eq(userProgress.userId, userId)); + }, + + async listXpEventsByDateAndSource(userId, date, source) { + const rows = await db + .select() + .from(xpEvents) + .where( + and(eq(xpEvents.userId, userId), eq(xpEvents.date, date), eq(xpEvents.source, source)) + ); + return rows.map(mapXpEvent); + }, + + async listAllXpEvents(userId) { + const rows = await db.select().from(xpEvents).where(eq(xpEvents.userId, userId)); + return rows.map(mapXpEvent); + }, +}; diff --git a/apps/web/src/app/api/dashboard/route.ts b/apps/web/src/app/api/dashboard/route.ts index 9ff9828..5e647be 100755 --- a/apps/web/src/app/api/dashboard/route.ts +++ b/apps/web/src/app/api/dashboard/route.ts @@ -1,8 +1,12 @@ import { handleApi } from "@/lib/api"; -import { getDashboard } from "@/lib/services/dashboard"; +import { getDashboardForUser } from "@/lib/services/dashboard"; +import { requireUser } from "@/lib/services/user"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const date = searchParams.get("date") ?? undefined; - return handleApi(() => getDashboard(date)); + return handleApi(async () => { + const { user } = await requireUser(); + return getDashboardForUser(user.id, date); + }); } diff --git a/apps/web/src/app/api/export/json/route.ts b/apps/web/src/app/api/export/json/route.ts index 2f52c60..f024ea5 100755 --- a/apps/web/src/app/api/export/json/route.ts +++ b/apps/web/src/app/api/export/json/route.ts @@ -11,11 +11,11 @@ export async function GET() { const aiData = await exportMemories(user.id); const tables = { user: await db.select().from(schema.users).where(eq(schema.users.id, user.id)), - progress: await db.select().from(schema.userProgress), - templates: await db.select().from(schema.adventureTemplates), - books: await db.select().from(schema.books), - achievements: await db.select().from(schema.achievements), - reflections: await db.select().from(schema.reflections), + progress: await db.select().from(schema.userProgress).where(eq(schema.userProgress.userId, user.id)), + templates: await db.select().from(schema.adventureTemplates).where(eq(schema.adventureTemplates.userId, user.id)), + books: await db.select().from(schema.books).where(eq(schema.books.userId, user.id)), + achievements: await db.select().from(schema.achievements).where(eq(schema.achievements.userId, user.id)), + reflections: await db.select().from(schema.reflections).where(eq(schema.reflections.userId, user.id)), aiMemories: aiData.memories, aiProfileSummary: aiData.summary, aiMemoryLearning: aiData.learning, diff --git a/apps/web/src/app/mentor/page.tsx b/apps/web/src/app/mentor/page.tsx index 78f6690..05b267d 100755 --- a/apps/web/src/app/mentor/page.tsx +++ b/apps/web/src/app/mentor/page.tsx @@ -8,14 +8,9 @@ import { createChatSession, fetchChatSessions, useMentorChat } from "@/hooks/use import Link from "next/link"; import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared"; -const SUGGESTED = [ - "What should I focus on today?", - "Why do I keep falling off my reading?", - "Give me a gentle plan for tomorrow.", - "What have I been worried about recently?", - "What do you know about me?", - "What goals am I working towards?", -]; +import { MENTOR_SUGGESTED_PROMPTS } from "@/lib/constants/mentor-prompts"; + +const SUGGESTED = MENTOR_SUGGESTED_PROMPTS; type MemorySuggestion = { id: string; diff --git a/apps/web/src/components/features/mentor-panel.tsx b/apps/web/src/components/features/mentor-panel.tsx index a85589d..f8c8b4b 100755 --- a/apps/web/src/components/features/mentor-panel.tsx +++ b/apps/web/src/components/features/mentor-panel.tsx @@ -5,13 +5,9 @@ import Link from "next/link"; import { MentorChatMessages } from "@/components/features/mentor-chat-messages"; import { createChatSession, useMentorChat } from "@/hooks/useMentorChat"; -const SUGGESTED = [ - "What should I focus on today?", - "Why do I keep falling off my reading?", - "Give me a gentle plan for tomorrow.", - "What do you know about me?", - "What goals am I working towards?", -]; +import { MENTOR_SUGGESTED_PROMPTS } from "@/lib/constants/mentor-prompts"; + +const SUGGESTED = MENTOR_SUGGESTED_PROMPTS.slice(0, 5); export function MentorPanel({ onClose }: { onClose?: () => void }) { const [sessionId, setSessionId] = useState(null); diff --git a/apps/web/src/infrastructure/composition.test.ts b/apps/web/src/infrastructure/composition.test.ts new file mode 100644 index 0000000..975da6f --- /dev/null +++ b/apps/web/src/infrastructure/composition.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import { updateAdventureItemViaUseCase } from "@/infrastructure/composition"; + +describe("composition root", () => { + it("exports updateAdventureItemViaUseCase", () => { + expect(typeof updateAdventureItemViaUseCase).toBe("function"); + }); +}); diff --git a/apps/web/src/infrastructure/composition.ts b/apps/web/src/infrastructure/composition.ts new file mode 100644 index 0000000..224fa09 --- /dev/null +++ b/apps/web/src/infrastructure/composition.ts @@ -0,0 +1,33 @@ +import { + updateAdventureItem as updateAdventureItemUseCase, + type UpdateAdventureItemDeps, +} from "@adventureos/application"; +import { drizzleActionEventsRepository } from "@/adapters/persistence/drizzle-action-events.repository"; +import { drizzleAdventureRepository } from "@/adapters/persistence/drizzle-adventure.repository"; +import { drizzleScoringService } from "@/adapters/persistence/drizzle-scoring.service"; +import { drizzleXpRepository } from "@/adapters/persistence/drizzle-xp.repository"; +import { dayOfWeek as getDayOfWeek } from "@/lib/dates"; + +const adventureDeps: UpdateAdventureItemDeps = { + adventureRepository: drizzleAdventureRepository, + xpRepository: drizzleXpRepository, + actionEventsRepository: drizzleActionEventsRepository, + scoringService: drizzleScoringService, +}; + +export async function updateAdventureItemViaUseCase( + userId: string, + date: string, + itemId: string, + updates: Parameters[1]["updates"] +) { + return updateAdventureItemUseCase(adventureDeps, { + userId, + date, + itemId, + dayOfWeek: getDayOfWeek(date), + updates, + }); +} + +export { adventureDeps }; diff --git a/apps/web/src/infrastructure/env.ts b/apps/web/src/infrastructure/env.ts new file mode 100644 index 0000000..5aca000 --- /dev/null +++ b/apps/web/src/infrastructure/env.ts @@ -0,0 +1 @@ +export { env } from "@/lib/config/env"; diff --git a/apps/web/src/lib/constants/mentor-prompts.ts b/apps/web/src/lib/constants/mentor-prompts.ts new file mode 100644 index 0000000..06b0bec --- /dev/null +++ b/apps/web/src/lib/constants/mentor-prompts.ts @@ -0,0 +1,8 @@ +export const MENTOR_SUGGESTED_PROMPTS = [ + "What should I focus on today?", + "Why do I keep falling off my reading?", + "Give me a gentle plan for tomorrow.", + "What have I been worried about recently?", + "What do you know about me?", + "What goals am I working towards?", +] as const; diff --git a/apps/web/src/lib/dates-client.ts b/apps/web/src/lib/dates-client.ts index 6dd4725..e8d6abb 100755 --- a/apps/web/src/lib/dates-client.ts +++ b/apps/web/src/lib/dates-client.ts @@ -3,8 +3,9 @@ import { getLogicalToday, getLogicalYesterday, isWithinGraceWindow, + weekStartForDate, DEFAULT_DAY_BOUNDARY_HOUR, -} from "@adventureos/shared"; +} from "@adventureos/domain"; export function todayString(): string { return format(new Date(), "yyyy-MM-dd"); @@ -17,9 +18,5 @@ export function formatDisplayDate(dateStr: string): string { } export function weekStartString(date = new Date()): string { - const d = new Date(date); - const day = d.getDay(); - const diff = d.getDate() - day + (day === 0 ? -6 : 1); - d.setDate(diff); - return format(d, "yyyy-MM-dd"); + return weekStartForDate(date); } diff --git a/apps/web/src/lib/dates.ts b/apps/web/src/lib/dates.ts index 5bfb10a..efb5437 100755 --- a/apps/web/src/lib/dates.ts +++ b/apps/web/src/lib/dates.ts @@ -1,10 +1,11 @@ -import { format, subDays, startOfWeek, parseISO } from "date-fns"; +import { format, subDays, parseISO } from "date-fns"; import { getLogicalToday as sharedLogicalToday, getLogicalYesterday as sharedLogicalYesterday, isWithinGraceWindow as sharedGraceWindow, + weekStartForDate, DEFAULT_DAY_BOUNDARY_HOUR, -} from "@adventureos/shared"; +} from "@adventureos/domain"; export function todayString(): string { return format(new Date(), "yyyy-MM-dd"); @@ -31,7 +32,7 @@ export function formatDisplayDate(dateStr: string): string { } export function weekStartString(date = new Date()): string { - return format(startOfWeek(date, { weekStartsOn: 1 }), "yyyy-MM-dd"); + return weekStartForDate(date); } export function lastNDays(n: number): string[] { diff --git a/apps/web/src/lib/reflection-utils.ts b/apps/web/src/lib/reflection-utils.ts index f323308..2d948c4 100755 --- a/apps/web/src/lib/reflection-utils.ts +++ b/apps/web/src/lib/reflection-utils.ts @@ -1,20 +1,5 @@ -export type ReflectionData = { - wentWell: string; - learned: string; - improveTomorrow: string; -}; - -export function hasMeaningfulReflectionContent(data: ReflectionData): boolean { - return [data.wentWell, data.learned, data.improveTomorrow].some( - (field) => field.trim().length > 0 - ); -} - -export function shouldAwardReflectionXp( - existing: ReflectionData | null | undefined, - data: ReflectionData -): boolean { - if (!hasMeaningfulReflectionContent(data)) return false; - if (!existing) return true; - return !hasMeaningfulReflectionContent(existing); -} +export { + hasMeaningfulReflectionContent, + shouldAwardReflectionXp, + type ReflectionData, +} from "@adventureos/domain/reflection"; diff --git a/apps/web/src/lib/repositories/adventure-templates.repository.ts b/apps/web/src/lib/repositories/adventure-templates.repository.ts deleted file mode 100755 index 4d8faf6..0000000 --- a/apps/web/src/lib/repositories/adventure-templates.repository.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { eq, and, isNull, desc } from "drizzle-orm"; -import { db, adventureTemplates, adventureItems } from "@/lib/db"; - -export async function listTemplates(userId: string) { - return db - .select() - .from(adventureTemplates) - .where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt))) - .orderBy(desc(adventureTemplates.sortPriority)); -} - -export async function findTemplateById(templateId: string) { - const [row] = await db - .select() - .from(adventureTemplates) - .where(eq(adventureTemplates.id, templateId)); - return row ?? null; -} - -export async function listTemplateItems(templateId: string) { - return db - .select() - .from(adventureItems) - .where(and(eq(adventureItems.templateId, templateId), isNull(adventureItems.deletedAt))) - .orderBy(adventureItems.sortOrder); -} diff --git a/apps/web/src/lib/repositories/index.ts b/apps/web/src/lib/repositories/index.ts index e9ba3eb..11f2d34 100755 --- a/apps/web/src/lib/repositories/index.ts +++ b/apps/web/src/lib/repositories/index.ts @@ -1,3 +1,2 @@ export * from "./settings.repository"; export * from "./teacher.repository"; -export * from "./adventure-templates.repository"; diff --git a/apps/web/src/lib/services/adventure/daily.ts b/apps/web/src/lib/services/adventure/daily.ts index a2936c6..cf0a1f1 100755 --- a/apps/web/src/lib/services/adventure/daily.ts +++ b/apps/web/src/lib/services/adventure/daily.ts @@ -1,28 +1,19 @@ -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { - xpForAdventureState, type AdventureItemState, - type DaySnapshot, XP_AWARDS, - computeAllScores, } from "@adventureos/shared"; import { db, dailyAdventureItems, dailyAdventures, dailyTodos, - userProgress, - readingLogs, - books, - xpEvents, } from "@/lib/db"; -import { lastNDays } from "@/lib/dates"; import { awardXp } from "@/lib/services/xp"; import { recordAction } from "@/lib/services/action-events"; import { ACTION_TYPES } from "@/lib/config"; -import { deriveState } from "./derive-state"; import { getDailyAdventure, materializeDay } from "./materialization"; -import { refreshScores } from "./scoring"; +import { updateAdventureItemViaUseCase } from "@/infrastructure/composition"; export async function updateAdventureItem( userId: string, @@ -30,71 +21,7 @@ export async function updateAdventureItem( itemId: string, updates: { value?: Record; state?: AdventureItemState } ) { - const { items } = await getDailyAdventure(userId, date); - const item = items.find((i) => i.id === itemId); - if (!item) throw new Error("Item not found"); - - const newValue = { ...(item.value as Record), ...updates.value }; - const newState = - updates.state ?? deriveState(item.type, newValue, item.config as Record); - - const oldState = item.state as AdventureItemState; - const beforeState = { - value: item.value, - state: item.state, - completedAt: item.completedAt, - }; - - await db - .update(dailyAdventureItems) - .set({ - value: newValue, - state: newState, - completedAt: newState === "done" ? new Date() : null, - }) - .where(eq(dailyAdventureItems.id, itemId)); - - const xpEventIds: string[] = []; - - if (newState !== oldState && newState !== "blank") { - const xp = xpForAdventureState(newState); - if (xp > 0) { - const result = await awardXp(userId, date, "adventure_item", xp, { - itemId, - state: newState, - }); - if (result.xpEventId) xpEventIds.push(result.xpEventId); - } - if (item.type === "checkbox" && item.label.toLowerCase().includes("exercise") && newState === "done") { - const ex = await awardXp(userId, date, "exercise", XP_AWARDS.exercise, { itemId }); - if (ex.xpEventId) xpEventIds.push(ex.xpEventId); - } - if (item.type === "checklist") { - const oldChecks = ((item.value as Record).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(userId, date, "spiritual", added * XP_AWARDS.spiritual_per_check, { - itemId, - }); - if (sp.xpEventId) xpEventIds.push(sp.xpEventId); - } - } - } - - const actionEvent = await recordAction({ - userId, - actionType: ACTION_TYPES.adventureItemUpdate, - 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 refreshScores(userId); - return { state: newState, value: newValue, actionEventId: actionEvent.id }; + return updateAdventureItemViaUseCase(userId, date, itemId, updates); } export async function setRestDay(userId: string, date: string) { diff --git a/apps/web/src/lib/services/adventure/derive-state.ts b/apps/web/src/lib/services/adventure/derive-state.ts index d0fb2d2..42afe7b 100755 --- a/apps/web/src/lib/services/adventure/derive-state.ts +++ b/apps/web/src/lib/services/adventure/derive-state.ts @@ -1,32 +1 @@ -import type { AdventureItemState } from "@adventureos/shared"; - -export function deriveState( - type: string, - value: Record, - config: Record -): AdventureItemState { - if (type === "checkbox" || type === "timeblock") { - return value.done ? "done" : "blank"; - } - if (type === "duration") { - const hours = (value.hours as number) ?? 0; - const target = (config.targetHours as number) ?? 0; - if (hours <= 0) return "blank"; - if (target > 0 && hours >= target) return "done"; - if (hours > 0) return hours >= target * 0.5 ? "partial" : "started"; - return "started"; - } - if (type === "checklist") { - const checks = (value.checks as boolean[]) ?? []; - const done = checks.filter(Boolean).length; - if (done === 0) return "blank"; - if (done === checks.length) return "done"; - return "partial"; - } - if (type === "reading") { - const pages = (value.pages as number) ?? 0; - if (pages <= 0) return "blank"; - return pages >= 10 ? "done" : "partial"; - } - return "blank"; -} +export { deriveState } from "@adventureos/domain/adventure/derive-state"; diff --git a/apps/web/src/lib/services/adventure/index.ts b/apps/web/src/lib/services/adventure/index.ts deleted file mode 100755 index d68ed34..0000000 --- a/apps/web/src/lib/services/adventure/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./materialization"; -export * from "./daily"; -export * from "./scoring"; -export * from "./templates"; diff --git a/apps/web/src/lib/services/adventure/scoring.test.ts b/apps/web/src/lib/services/adventure/scoring.test.ts new file mode 100644 index 0000000..0c36bc6 --- /dev/null +++ b/apps/web/src/lib/services/adventure/scoring.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from "vitest"; +import { matchesSemanticKey } from "@adventureos/domain"; + +describe("scoring label heuristics characterization", () => { + it("maps seed template labels to semantic keys", () => { + expect(matchesSemanticKey({ type: "checkbox", label: "Exercise" }, "exercise")).toBe(true); + expect(matchesSemanticKey({ type: "checklist", label: "Prayer" }, "prayer")).toBe(true); + expect(matchesSemanticKey({ type: "checklist", label: "Litanies" }, "litanies")).toBe(true); + expect(matchesSemanticKey({ type: "duration", label: "Work" }, "work")).toBe(true); + }); +}); diff --git a/apps/web/src/lib/services/adventure/scoring.ts b/apps/web/src/lib/services/adventure/scoring.ts index ef12390..7dcccb7 100755 --- a/apps/web/src/lib/services/adventure/scoring.ts +++ b/apps/web/src/lib/services/adventure/scoring.ts @@ -1,5 +1,5 @@ import { and, eq } from "drizzle-orm"; -import { computeAllScores, type DaySnapshot } from "@adventureos/shared"; +import { computeAllScores, matchesSemanticKey, type DaySnapshot } from "@adventureos/domain"; import { db, userProgress, readingLogs, books, xpEvents } from "@/lib/db"; import { lastNDays } from "@/lib/dates"; import { getDailyAdventure } from "./materialization"; @@ -10,19 +10,13 @@ export async function buildDaySnapshot(userId: string, date: string): Promise i.type !== "note" && i.enabled); const touched = scorable.filter((i) => i.state !== "blank").length; - const exerciseItem = items.find( - (i) => i.type === "checkbox" && i.label.toLowerCase().includes("exercise") - ); - const workItem = items.find((i) => i.type === "duration"); - const teachingItem = items.find( - (i) => i.type === "checkbox" && i.label.toLowerCase().includes("teaching") - ); - const classItems = items.filter( - (i) => i.type === "timeblock" || i.label.toLowerCase().includes("class") - ); + const exerciseItem = items.find((i) => matchesSemanticKey(i, "exercise")); + const workItem = items.find((i) => matchesSemanticKey(i, "work")); + const teachingItem = items.find((i) => matchesSemanticKey(i, "teaching")); + const classItems = items.filter((i) => matchesSemanticKey(i, "class")); - const prayerItem = items.find((i) => i.label === "Prayer"); - const litanyItem = items.find((i) => i.label === "Litanies"); + const prayerItem = items.find((i) => matchesSemanticKey(i, "prayer")); + const litanyItem = items.find((i) => matchesSemanticKey(i, "litanies")); const prayerChecks = ((prayerItem?.value as Record)?.checks as boolean[])?.filter(Boolean) diff --git a/apps/web/src/lib/services/adventure/template-matching.ts b/apps/web/src/lib/services/adventure/template-matching.ts index a3d45a6..c903d6a 100755 --- a/apps/web/src/lib/services/adventure/template-matching.ts +++ b/apps/web/src/lib/services/adventure/template-matching.ts @@ -1,23 +1,4 @@ -export type TemplateCandidate = { - id: string; - daysOfWeek: number[]; - sortPriority: number; - isDefault: boolean; - deletedAt: Date | null; -}; - -export function pickTemplateForDay( - templates: TemplateCandidate[], - dayOfWeek: number -): TemplateCandidate | null { - const active = templates.filter((t) => t.deletedAt === null); - const matching = active - .filter((t) => t.daysOfWeek.includes(dayOfWeek)) - .sort( - (a, b) => - b.sortPriority - a.sortPriority || a.daysOfWeek.length - b.daysOfWeek.length - ); - - if (matching.length > 0) return matching[0]; - return active.find((t) => t.isDefault) ?? active[0] ?? null; -} +export { + pickTemplateForDay, + type TemplateCandidate, +} from "@adventureos/domain/adventure/template-matching"; diff --git a/apps/web/src/lib/services/ai-teacher.test.ts b/apps/web/src/lib/services/ai-teacher.test.ts index f5a2bbd..0148196 100755 --- a/apps/web/src/lib/services/ai-teacher.test.ts +++ b/apps/web/src/lib/services/ai-teacher.test.ts @@ -14,13 +14,22 @@ vi.mock("./ai-templates", () => ({ getTemplateBody: vi.fn(async () => "topic: {{topic}} difficulty: {{difficulty}}"), })); +vi.mock("../db", () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ displayName: "Traveler" }]), + limit: vi.fn().mockResolvedValue([{ id: "user-1" }]), + }), + }), + }, + users: {}, +})); + vi.mock("../ai/provider-registry", () => ({ getProvider: vi.fn(), })); -vi.mock("./user", () => ({ - requireUser: vi.fn(async () => ({ user: { id: "user-1", displayName: "Traveler" } })), -})); import { getAiBehaviorConfig, getAiProviderConfig, getAiProviderSettings, generateTextWithFallback } from "./ai-config"; import { getProvider } from "../ai/provider-registry"; @@ -28,9 +37,10 @@ import { getProvider } from "../ai/provider-registry"; const onlineAvailability = { enabled: true, creativity: 0.7, - personality: "supportive_mentor", - verbosity: "balanced", + personality: "supportive_mentor" as const, + verbosity: "balanced" as const, strictMode: false, + frequency: "daily" as const, }; const onlineProviderConfig = { @@ -130,7 +140,19 @@ describe("generateTeacherContent", () => { }); vi.mocked(getProvider).mockReturnValue({ type: "ollama", - healthCheck: vi.fn(async () => ({ status: "online" as const })), + healthCheck: vi.fn(async () => ({ + status: "online" as const, + provider: "ollama" as const, + model: "test", + latencyMs: 1, + lastSuccessAt: null, + lastFailureAt: null, + lastError: null, + baseUrlSafe: null, + contextLength: null, + memoryUsageMb: null, + modelsAvailable: [], + })), listModels: vi.fn(async () => []), generateText: vi.fn(), }); @@ -170,7 +192,19 @@ describe("generateTeacherContent", () => { }); vi.mocked(getProvider).mockReturnValue({ type: "ollama", - healthCheck: vi.fn(async () => ({ status: "online" as const })), + healthCheck: vi.fn(async () => ({ + status: "online" as const, + provider: "ollama" as const, + model: "test", + latencyMs: 1, + lastSuccessAt: null, + lastFailureAt: null, + lastError: null, + baseUrlSafe: null, + contextLength: null, + memoryUsageMb: null, + modelsAvailable: [], + })), listModels: vi.fn(async () => []), generateText: vi.fn(), }); diff --git a/apps/web/src/lib/services/ai.ts b/apps/web/src/lib/services/ai.ts index c64ee26..1a9cd4f 100755 --- a/apps/web/src/lib/services/ai.ts +++ b/apps/web/src/lib/services/ai.ts @@ -1,10 +1,8 @@ -import { z } from "zod"; import { pickRandomExplorations, pickRandomQuests, } from "@adventureos/shared"; import { getProvider } from "../ai/provider-registry"; -import type { TeacherDifficulty, TeacherLength } from "@adventureos/shared"; import { debugAiLog, isAiTimeoutError, @@ -24,123 +22,51 @@ import { } from "./ai-config"; import { getTemplateBody } from "./ai-templates"; import { renderTemplate } from "../ai/prompts/render"; -import { requireUser } from "./user"; +import { eq } from "drizzle-orm"; +import { db, users } from "../db"; +import { + explorationSchema, + mentorSchema, + questSchema, + teacherSchema, + TeacherAiUnavailableError, + type AiFallbackReason, + type ExplorationGenerationResult, + type TeacherContent, + type TeacherGenerationOptions, + type TeacherGenerationResult, +} from "./ai/schemas"; -const questSchema = z.object({ - quests: z - .array( - z.object({ - title: z.string(), - reason: z.string(), - xp_hint: z.string(), - category: z.string(), - }) - ) - .max(3), -}); +export { + explorationSchema, + mentorSchema, + questSchema, + teacherSchema, + TeacherAiUnavailableError, + type AiContentSource, + type AiFallbackReason, + type ExplorationGenerationResult, + type TeacherContent, + type TeacherGenerationOptions, + type TeacherGenerationResult, +} from "./ai/schemas"; -const explorationSchema = z.object({ - explorations: z - .array( - z.object({ - title: z.string(), - hook: z.string(), - category: z.string(), - minutes: z.coerce.number(), - }) - ) - .max(5), -}); - -const mentorSchema = z.object({ - patterns: z.array(z.string()), - encouragement: z.string(), - focus_suggestion: z.string(), - letter: z.string(), -}); - -const teacherResearchAssignmentSchema = z.object({ - steps: z.array(z.string()), - expectedOutcome: z.string().optional(), - estimatedTimeMinutes: z.coerce.number().optional(), -}); - -const teacherHomeworkSchema = z.object({ - task: z.string(), - instructions: z.array(z.string()), -}); - -const teacherCalibreSuggestionSchema = z.object({ - title: z.string(), - authors: z.array(z.string()), - reason: z.string(), -}); - -export const teacherSchema = z.object({ - title: z.string().optional(), - introduction: z.string().optional(), - overview: z.string().optional(), - whyItMatters: z.string().optional(), - objectives: z.array(z.string()).optional(), - learningObjectives: z.array(z.string()).optional(), - explanation: z.string().optional(), - researchAssignment: teacherResearchAssignmentSchema.optional(), - readingSteps: z.array(z.string()).optional(), - calibreSuggestions: z.array(teacherCalibreSuggestionSchema).optional(), - externalReading: z.array(z.string()).optional(), - homework: teacherHomeworkSchema.optional(), - reflectionPrompt: z.string().optional(), - flashcards: z.array(z.object({ front: z.string(), back: z.string() })), - quiz: z.array( - z.object({ - question: z.string(), - type: z.enum(["multiple_choice", "short_answer"]).optional(), - options: z.array(z.string()).optional(), - answer: z.coerce.number(), - answerText: z.string().optional(), - explanation: z.string().optional(), - }) - ), - answerKey: z - .array( - z.object({ - questionIndex: z.coerce.number(), - answer: z.string(), - explanation: z.string().optional(), - }) - ) - .optional(), - nextLessonSuggestion: z.string().optional(), - assignment: z.string().optional(), -}); - -export type TeacherContent = z.infer; -export type AiContentSource = "ai" | "fallback"; -export type AiFallbackReason = "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout"; - -export type TeacherGenerationResult = { - content: TeacherContent; - source: AiContentSource; - fallbackReason?: AiFallbackReason; -}; - -export type ExplorationGenerationResult = { - explorations: z.infer["explorations"]; - source: AiContentSource; - fallbackReason?: AiFallbackReason; -}; +async function getDisplayName(userId: string): Promise { + const [user] = await db + .select({ displayName: users.displayName }) + .from(users) + .where(eq(users.id, userId)); + return user?.displayName ?? "Traveler"; +} async function getUserId(): Promise { - const { user } = await requireUser(); + const [user] = await db.select({ id: users.id }).from(users).limit(1); + if (!user) throw new Error("No user found. Run db:seed first."); return user.id; } let lastAiFallbackReason: AiFallbackReason | null = null; -export function getLastAiFallbackReason(): AiFallbackReason | null { - return lastAiFallbackReason; -} - export async function getAiAvailability(userId: string) { const behavior = await getAiBehaviorConfig(userId); if (!behavior.enabled) return { canUse: false, online: false }; @@ -187,10 +113,10 @@ async function aiGenerate( const coreSystem = await getTemplateBody(userId, systemKey); const roleSystem = roleKey ? await getTemplateBody(userId, roleKey) : undefined; const system = buildSystemPrompt(behavior, coreSystem, roleSystem); - const { user } = await requireUser(); + const displayName = await getDisplayName(userId); const prompt = renderTemplate(templateBody, { ...data, - user_name: user.displayName, + user_name: displayName, context: data.context ?? data, }); @@ -312,20 +238,6 @@ function teacherFallback(topic: string): TeacherContent { }; } -export type TeacherGenerationOptions = { - personalContext?: string; - libraryContext?: string; - difficulty?: TeacherDifficulty; - length?: TeacherLength; -}; - -export class TeacherAiUnavailableError extends Error { - constructor(message = "AI is unavailable. Check Settings or your local model.") { - super(message); - this.name = "TeacherAiUnavailableError"; - } -} - export async function generateQuests(context: Record, userId?: string) { const uid = userId ?? (await getUserId()); const raw = await aiGenerate( @@ -487,20 +399,16 @@ export async function generateTeacherContent( }; } -export async function isOllamaAvailable(): Promise { +export async function isOllamaAvailable(userId?: string): Promise { try { - const userId = await getUserId(); - const availability = await getAiAvailability(userId); + const uid = userId ?? (await getUserId()); + const availability = await getAiAvailability(uid); return availability.online; } catch { return false; } } -export async function isAiAvailable(): Promise { - return isOllamaAvailable(); -} - export async function generateChatReply( userId: string, input: { userMessage: string; contextBlock: string; history?: string; memoryAction?: string } diff --git a/apps/web/src/lib/services/ai/schemas.ts b/apps/web/src/lib/services/ai/schemas.ts new file mode 100644 index 0000000..f22abbb --- /dev/null +++ b/apps/web/src/lib/services/ai/schemas.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; + +export const questSchema = z.object({ + quests: z + .array( + z.object({ + title: z.string(), + reason: z.string(), + xp_hint: z.string(), + category: z.string(), + }) + ) + .max(3), +}); + +export const explorationSchema = z.object({ + explorations: z + .array( + z.object({ + title: z.string(), + hook: z.string(), + category: z.string(), + minutes: z.coerce.number(), + }) + ) + .max(5), +}); + +export const mentorSchema = z.object({ + patterns: z.array(z.string()), + encouragement: z.string(), + focus_suggestion: z.string(), + letter: z.string(), +}); + +const teacherResearchAssignmentSchema = z.object({ + steps: z.array(z.string()), + expectedOutcome: z.string().optional(), + estimatedTimeMinutes: z.coerce.number().optional(), +}); + +const teacherHomeworkSchema = z.object({ + task: z.string(), + instructions: z.array(z.string()), +}); + +const teacherCalibreSuggestionSchema = z.object({ + title: z.string(), + authors: z.array(z.string()), + reason: z.string(), +}); + +export const teacherSchema = z.object({ + title: z.string().optional(), + introduction: z.string().optional(), + overview: z.string().optional(), + whyItMatters: z.string().optional(), + objectives: z.array(z.string()).optional(), + learningObjectives: z.array(z.string()).optional(), + explanation: z.string().optional(), + researchAssignment: teacherResearchAssignmentSchema.optional(), + readingSteps: z.array(z.string()).optional(), + calibreSuggestions: z.array(teacherCalibreSuggestionSchema).optional(), + externalReading: z.array(z.string()).optional(), + homework: teacherHomeworkSchema.optional(), + reflectionPrompt: z.string().optional(), + flashcards: z.array(z.object({ front: z.string(), back: z.string() })), + quiz: z.array( + z.object({ + question: z.string(), + type: z.enum(["multiple_choice", "short_answer"]).optional(), + options: z.array(z.string()).optional(), + answer: z.coerce.number(), + answerText: z.string().optional(), + explanation: z.string().optional(), + }) + ), + answerKey: z + .array( + z.object({ + questionIndex: z.coerce.number(), + answer: z.string(), + explanation: z.string().optional(), + }) + ) + .optional(), + nextLessonSuggestion: z.string().optional(), + assignment: z.string().optional(), +}); + +export type TeacherContent = z.infer; +export type AiContentSource = "ai" | "fallback"; +export type AiFallbackReason = "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout"; + +export type TeacherGenerationResult = { + content: TeacherContent; + source: AiContentSource; + fallbackReason?: AiFallbackReason; +}; + +export type ExplorationGenerationResult = { + explorations: z.infer["explorations"]; + source: AiContentSource; + fallbackReason?: AiFallbackReason; +}; + +export type TeacherGenerationOptions = { + personalContext?: string; + libraryContext?: string; + difficulty?: import("@adventureos/shared").TeacherDifficulty; + length?: import("@adventureos/shared").TeacherLength; +}; + +export class TeacherAiUnavailableError extends Error { + constructor(message = "AI is unavailable. Check Settings or your local model.") { + super(message); + this.name = "TeacherAiUnavailableError"; + } +} diff --git a/apps/web/src/lib/services/dashboard.ts b/apps/web/src/lib/services/dashboard.ts index 42435dc..500ad35 100755 --- a/apps/web/src/lib/services/dashboard.ts +++ b/apps/web/src/lib/services/dashboard.ts @@ -8,7 +8,7 @@ import { getSuggestions } from "./explorations"; import { awardDailyVisit } from "./xp"; import { getAchievements } from "./achievements"; import { todayString, getLogicalToday, getLogicalYesterday, isWithinGraceWindow } from "../dates"; -import { db, settings } from "../db"; +import { db, settings, users, userProgress } from "../db"; import { and, eq } from "drizzle-orm"; import { SETTINGS_KEYS } from "@/lib/config"; import { getXpBySource, getXpInRange } from "./xp"; @@ -16,8 +16,20 @@ import { getDayBoundaryHour } from "./day-boundary"; import { detectCatchUpGaps } from "./catch-up"; export async function getDashboard(date?: string) { - const { user, progress } = await requireUser(); - const boundaryHour = await getDayBoundaryHour(user.id); + const { user } = await requireUser(); + return getDashboardForUser(user.id, date); +} + +export async function getDashboardForUser(userId: string, date?: string) { + const [user] = await db.select().from(users).where(eq(users.id, userId)); + if (!user) throw new Error("User not found"); + + const [progress] = await db + .select() + .from(userProgress) + .where(eq(userProgress.userId, userId)); + + const boundaryHour = await getDayBoundaryHour(userId); const logicalToday = getLogicalToday(boundaryHour); const activeDate = date ?? logicalToday; const weekStart = format(subDays(new Date(), 6), "yyyy-MM-dd"); @@ -32,22 +44,22 @@ export async function getDashboard(date?: string) { catchUpGaps, goalRows, ] = await Promise.all([ - getDailyAdventure(user.id, activeDate), - getReflection(user.id, activeDate), - getSuggestions(user.id, "quest_giver"), - getBooks(user.id), - getReadingStreak(user.id), - getWeeklyPages(user.id, weekStart, activeDate), - detectCatchUpGaps(user.id), + getDailyAdventure(userId, activeDate), + getReflection(userId, activeDate), + getSuggestions(userId, "quest_giver"), + getBooks(userId), + getReadingStreak(userId), + getWeeklyPages(userId, weekStart, activeDate), + detectCatchUpGaps(userId), db .select() .from(settings) .where( - and(eq(settings.userId, user.id), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal)) + and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal)) ), ]); - void recordDashboardVisit(user.id, activeDate); + void recordDashboardVisit(userId, activeDate); const [goalRow] = goalRows; diff --git a/apps/web/src/lib/services/explorations.ts b/apps/web/src/lib/services/explorations.ts index e41785e..c0404b2 100755 --- a/apps/web/src/lib/services/explorations.ts +++ b/apps/web/src/lib/services/explorations.ts @@ -1,13 +1,12 @@ import { and, eq, desc } from "drizzle-orm"; -import { subDays, format } from "date-fns"; +import { format } from "date-fns"; import { XP_AWARDS } from "@adventureos/shared"; import { db, explorations, aiSuggestions, weeklyReviews } from "../db"; import { weekStartString } from "../dates"; import { generateExplorations, generateMentorReview, generateQuests } from "./ai"; import { awardXp, getXpInRange } from "./xp"; -import { buildDaySnapshot, refreshScores } from "./adventure"; +import { refreshScores } from "./adventure"; import { buildAiContext } from "./ai-context"; -import { requireUser } from "./user"; import { recordAction } from "./action-events"; import { ACTION_TYPES } from "@/lib/config"; @@ -71,14 +70,6 @@ export async function generateWeeklyExplorations(userId: string) { .where(and(eq(explorations.userId, userId), eq(explorations.weekOf, weekOf))); const suggested = existing.filter((e) => e.status === "suggested"); - const statusCounts = existing.reduce>((acc, e) => { - acc[e.status] = (acc[e.status] ?? 0) + 1; - return acc; - }, {}); - - // #region agent log - fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'explorations.ts:generateWeeklyExplorations',message:'generate guard check',data:{weekOf,statusCounts,suggestedCount:suggested.length},timestamp:Date.now(),hypothesisId:'C1'})}).catch(()=>{}); - // #endregion if (suggested.length > 0) { const visible = existing.filter((e) => e.status !== "dismissed"); @@ -111,10 +102,6 @@ export async function generateWeeklyExplorations(userId: string) { created.push(row); } - // #region agent log - fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'explorations.ts:generateWeeklyExplorations',message:'generate complete',data:{source,generatedCount:generated.length,createdCount:created.length,filteredCount:items.length},timestamp:Date.now(),hypothesisId:'C3'})}).catch(()=>{}); - // #endregion - const visible = [ ...existing.filter((e) => e.status !== "dismissed"), ...created, diff --git a/apps/web/src/lib/services/resource-links.ts b/apps/web/src/lib/services/resource-links.ts index 492d722..0ccfc50 100755 --- a/apps/web/src/lib/services/resource-links.ts +++ b/apps/web/src/lib/services/resource-links.ts @@ -4,9 +4,7 @@ import { and, desc, eq, inArray } from "drizzle-orm"; import { db, resourceLinks } from "../db"; import { weekStartString } from "../dates"; -const RESOURCE_BACKLOG_PATH = - process.env.RESOURCE_BACKLOG_PATH ?? - "/home/zaine/master-folder/vault-master/Non Technical/z resources.md"; +const RESOURCE_BACKLOG_PATH = process.env.RESOURCE_BACKLOG_PATH; export const DEFAULT_RESOURCE_CATEGORY = "Interesting things to read up:"; @@ -143,7 +141,7 @@ export async function exportResourceLinksToBacklog(input: { markdown = appendLinksToSection(markdown, category, weekLabel, lines); } - await fs.writeFile(RESOURCE_BACKLOG_PATH, markdown, "utf8"); + await fs.writeFile(requireBacklogPath(), markdown, "utf8"); await db .update(resourceLinks) .set({ status: "exported", exportedAt: new Date() }) @@ -156,14 +154,22 @@ export async function exportResourceLinksToBacklog(input: { return { exported: links.length, - path: RESOURCE_BACKLOG_PATH, + path: requireBacklogPath(), weekLabel, }; } +function requireBacklogPath(): string { + if (!RESOURCE_BACKLOG_PATH) { + throw new Error("RESOURCE_BACKLOG_PATH is not configured"); + } + return RESOURCE_BACKLOG_PATH; +} + async function readBacklog() { + const path = requireBacklogPath(); try { - return await fs.readFile(RESOURCE_BACKLOG_PATH, "utf8"); + return await fs.readFile(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; throw error; diff --git a/apps/web/src/lib/services/undo.ts b/apps/web/src/lib/services/undo.ts index 3e16769..bbf0c11 100755 --- a/apps/web/src/lib/services/undo.ts +++ b/apps/web/src/lib/services/undo.ts @@ -12,11 +12,7 @@ import { userProgress, xpEvents, } from "../db"; -import { - getActionEvent, - markUndone, - type RecordActionInput, -} from "./action-events"; +import { getActionEvent, markUndone } from "./action-events"; import { refreshScores } from "./adventure"; import { levelFromXp, chapterForLevel } from "@adventureos/shared"; @@ -26,19 +22,23 @@ export type UndoResult = { error?: string; }; -async function recalculateProgress(userId: string) { - const events = await db - .select() - .from(xpEvents) - .where(eq(xpEvents.userId, userId)); +type UndoHandlerContext = { + userId: string; + event: NonNullable>>; + before: Record; + meta: Record; + xpEventIds: string[]; + revokeXpEvents: (userId: string, xpEventIds: string[]) => Promise; +}; - const active = events.filter( - (e) => !(e.metadata as Record)?.revoked - ); +type UndoHandler = (ctx: UndoHandlerContext) => Promise; + +async function recalculateProgress(userId: string) { + const events = await db.select().from(xpEvents).where(eq(xpEvents.userId, userId)); + const active = events.filter((e) => !(e.metadata as Record)?.revoked); const totalXp = active.reduce((s, e) => s + e.amount, 0); const newLevel = levelFromXp(totalXp); const chapter = chapterForLevel(newLevel); - await db .update(userProgress) .set({ totalXp, level: newLevel, currentChapter: chapter.name }) @@ -50,9 +50,7 @@ async function revokeXpEvents(userId: string, xpEventIds: string[]) { const events = await db .select() .from(xpEvents) - .where( - and(eq(xpEvents.userId, userId), inArray(xpEvents.id, xpEventIds)) - ); + .where(and(eq(xpEvents.userId, userId), inArray(xpEvents.id, xpEventIds))); for (const event of events) { await db.insert(xpEvents).values({ @@ -60,170 +58,156 @@ async function revokeXpEvents(userId: string, xpEventIds: string[]) { date: event.date, source: event.source, amount: -event.amount, - metadata: { - revokedEventId: event.id, - actionUndo: true, - }, + metadata: { revokedEventId: event.id, actionUndo: true }, }); await db .update(xpEvents) .set({ - metadata: { - ...(event.metadata as Record), - revoked: true, - }, + metadata: { ...(event.metadata as Record), revoked: true }, }) .where(eq(xpEvents.id, event.id)); } await recalculateProgress(userId); } -export async function undoAction( - userId: string, - actionEventId: string -): Promise { +const undoHandlers: Record = { + "adventure_item.update": async ({ event, before, userId, xpEventIds, revokeXpEvents: revoke }) => { + const updates: Record = {}; + if ("value" in before) updates.value = before.value as Record; + if ("state" in before) updates.state = before.state as string; + if ("completedAt" in before) updates.completedAt = (before.completedAt as Date | null) ?? null; + if ("label" in before) updates.label = before.label as string; + if ("enabled" in before) updates.enabled = before.enabled as boolean; + if ("config" in before) updates.config = before.config as Record; + if (Object.keys(updates).length > 0) { + await db.update(dailyAdventureItems).set(updates).where(eq(dailyAdventureItems.id, event.entityId)); + } + await revoke(userId, xpEventIds); + await refreshScores(userId); + }, + "adventure_item.create": async ({ event }) => { + await db + .update(dailyAdventureItems) + .set({ deletedAt: new Date() }) + .where(eq(dailyAdventureItems.id, event.entityId)); + }, + "adventure_item.delete": async ({ event }) => { + await db + .update(dailyAdventureItems) + .set({ deletedAt: null }) + .where(eq(dailyAdventureItems.id, event.entityId)); + }, + "adventure.work_hours.update": async ({ event, before }) => { + await db + .update(dailyAdventures) + .set({ workHoursTarget: (before.workHoursTarget as string | null) ?? null }) + .where(eq(dailyAdventures.id, event.entityId)); + }, + "daily_todo.create": async ({ event }) => { + await db.update(dailyTodos).set({ deletedAt: new Date() }).where(eq(dailyTodos.id, event.entityId)); + }, + "daily_todo.update": async ({ event, before }) => { + await db + .update(dailyTodos) + .set({ label: before.label as string, done: before.done as boolean }) + .where(eq(dailyTodos.id, event.entityId)); + }, + "daily_todo.delete": async ({ event }) => { + await db.update(dailyTodos).set({ deletedAt: null }).where(eq(dailyTodos.id, event.entityId)); + }, + "reading.log_pages": async ({ event, before, userId, meta, xpEventIds, revokeXpEvents: revoke }) => { + const bookBefore = before.book as Record | undefined; + const logId = meta.readingLogId as string; + if (logId) { + await db.delete(readingLogs).where(eq(readingLogs.id, logId)); + } + if (bookBefore) { + await db + .update(books) + .set({ + currentPage: bookBefore.currentPage as number, + status: bookBefore.status as string, + finishedAt: (bookBefore.finishedAt as Date | null) ?? null, + }) + .where(eq(books.id, event.entityId)); + } else if ("currentPage" in before) { + await db + .update(books) + .set({ + currentPage: before.currentPage as number, + status: before.status as string, + finishedAt: (before.finishedAt as Date | null) ?? null, + }) + .where(eq(books.id, event.entityId)); + } + await revoke(userId, xpEventIds); + }, + "adventure.rest_day": async ({ meta, userId, xpEventIds, revokeXpEvents: revoke }) => { + const adventureId = meta.adventureId as string; + if (adventureId) { + await db.update(dailyAdventures).set({ isRestDay: false }).where(eq(dailyAdventures.id, adventureId)); + } + await revoke(userId, xpEventIds); + await refreshScores(userId); + }, + "reflection.save": async ({ event, before }) => { + if (before.existed) { + await db + .update(reflections) + .set({ + wentWell: before.wentWell as string, + learned: before.learned as string, + improveTomorrow: before.improveTomorrow as string, + }) + .where(eq(reflections.id, event.entityId)); + } else { + await db.delete(reflections).where(eq(reflections.id, event.entityId)); + } + }, + "exploration.update": async ({ event, before, userId, xpEventIds, revokeXpEvents: revoke }) => { + await db + .update(explorations) + .set({ + status: before.status as string, + acceptedAt: (before.acceptedAt as Date | null) ?? null, + completedNote: (before.completedNote as string | null) ?? null, + }) + .where(eq(explorations.id, event.entityId)); + await revoke(userId, xpEventIds); + await refreshScores(userId); + }, + "template_item.delete": async ({ event }) => { + await db + .update(adventureItems) + .set({ deletedAt: null }) + .where(eq(adventureItems.id, event.entityId)); + }, +}; + +export async function undoAction(userId: string, actionEventId: string): Promise { const event = await getActionEvent(userId, actionEventId); if (!event) return { ok: false, error: "Action not found" }; if (event.undoneAt) return { ok: false, error: "Already undone" }; if (!event.undoable) return { ok: false, error: "Action cannot be undone" }; + const handler = undoHandlers[event.actionType]; + if (!handler) { + return { ok: false, error: `Unknown action type: ${event.actionType}` }; + } + const before = event.beforeState as Record; const meta = (event.metadata ?? {}) as Record; const xpEventIds = (meta.xpEventIds as string[]) ?? []; try { - switch (event.actionType) { - case "adventure_item.update": { - const updates: Record = {}; - if ("value" in before) updates.value = before.value as Record; - if ("state" in before) updates.state = before.state as string; - if ("completedAt" in before) updates.completedAt = (before.completedAt as Date | null) ?? null; - if ("label" in before) updates.label = before.label as string; - if ("enabled" in before) updates.enabled = before.enabled as boolean; - if ("config" in before) updates.config = before.config as Record; - if (Object.keys(updates).length > 0) { - await db - .update(dailyAdventureItems) - .set(updates) - .where(eq(dailyAdventureItems.id, event.entityId)); - } - await revokeXpEvents(userId, xpEventIds); - await refreshScores(userId); - break; - } - case "adventure_item.create": { - await db - .update(dailyAdventureItems) - .set({ deletedAt: new Date() }) - .where(eq(dailyAdventureItems.id, event.entityId)); - break; - } - case "adventure_item.delete": { - await db - .update(dailyAdventureItems) - .set({ deletedAt: null }) - .where(eq(dailyAdventureItems.id, event.entityId)); - break; - } - case "adventure.work_hours.update": { - await db - .update(dailyAdventures) - .set({ workHoursTarget: (before.workHoursTarget as string | null) ?? null }) - .where(eq(dailyAdventures.id, event.entityId)); - break; - } - case "daily_todo.create": { - await db - .update(dailyTodos) - .set({ deletedAt: new Date() }) - .where(eq(dailyTodos.id, event.entityId)); - break; - } - case "daily_todo.update": { - await db - .update(dailyTodos) - .set({ - label: before.label as string, - done: before.done as boolean, - }) - .where(eq(dailyTodos.id, event.entityId)); - break; - } - case "daily_todo.delete": { - await db - .update(dailyTodos) - .set({ deletedAt: null }) - .where(eq(dailyTodos.id, event.entityId)); - break; - } - case "reading.log_pages": { - const bookBefore = before.book as Record; - const logId = meta.readingLogId as string; - if (logId) { - await db.delete(readingLogs).where(eq(readingLogs.id, logId)); - } - await db - .update(books) - .set({ - currentPage: bookBefore.currentPage as number, - status: bookBefore.status as string, - finishedAt: (bookBefore.finishedAt as Date | null) ?? null, - }) - .where(eq(books.id, event.entityId)); - await revokeXpEvents(userId, xpEventIds); - break; - } - case "adventure.rest_day": { - const adventureId = meta.adventureId as string; - if (adventureId) { - await db - .update(dailyAdventures) - .set({ isRestDay: false }) - .where(eq(dailyAdventures.id, adventureId)); - } - await revokeXpEvents(userId, xpEventIds); - await refreshScores(userId); - break; - } - case "reflection.save": { - if (before.existed) { - await db - .update(reflections) - .set({ - wentWell: before.wentWell as string, - learned: before.learned as string, - improveTomorrow: before.improveTomorrow as string, - }) - .where(eq(reflections.id, event.entityId)); - } else { - await db.delete(reflections).where(eq(reflections.id, event.entityId)); - } - break; - } - case "exploration.update": { - await db - .update(explorations) - .set({ - status: before.status as string, - acceptedAt: (before.acceptedAt as Date | null) ?? null, - completedNote: (before.completedNote as string | null) ?? null, - }) - .where(eq(explorations.id, event.entityId)); - await revokeXpEvents(userId, xpEventIds); - await refreshScores(userId); - break; - } - case "template_item.delete": { - await db - .update(adventureItems) - .set({ deletedAt: null }) - .where(eq(adventureItems.id, event.entityId)); - break; - } - default: - return { ok: false, error: `Unknown action type: ${event.actionType}` }; - } - + await handler({ + userId, + event, + before, + meta, + xpEventIds, + revokeXpEvents, + }); await markUndone(actionEventId); return { ok: true, summary: event.summary }; } catch (e) { @@ -232,4 +216,4 @@ export async function undoAction( } } -export { type RecordActionInput }; +export type { RecordActionInput } from "./action-events"; diff --git a/apps/web/src/lib/services/xp.test.ts b/apps/web/src/lib/services/xp.test.ts new file mode 100644 index 0000000..e402369 --- /dev/null +++ b/apps/web/src/lib/services/xp.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { awardXp as awardXpService } from "./xp"; + +vi.mock("@/lib/db", () => { + const progress = { userId: "user-1", totalXp: 100, level: 1, currentChapter: "Prologue" }; + return { + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([progress]), + }), + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: "xp-event-1" }]), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }, + userProgress: {}, + xpEvents: {}, + }; +}); + +describe("xp service characterization", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns zero award for non-positive amount", async () => { + const result = await awardXpService("user-1", "2026-01-01", "exercise", 0); + expect(result.awarded).toBe(0); + expect(result.levelUp).toBe(false); + }); +}); diff --git a/apps/web/src/lib/teacher/teacher-content.test.ts b/apps/web/src/lib/teacher/teacher-content.test.ts index 21facb5..c886a89 100755 --- a/apps/web/src/lib/teacher/teacher-content.test.ts +++ b/apps/web/src/lib/teacher/teacher-content.test.ts @@ -11,7 +11,7 @@ describe("teacher-content envelope", () => { }, { source: "fallback", fallbackReason: "parse_failed" } ); - const unwrapped = unwrapTeacherContent(wrapped as Record); + const unwrapped = unwrapTeacherContent(wrapped as unknown as Record); expect(unwrapped.source).toBe("fallback"); expect(unwrapped.fallbackReason).toBe("parse_failed"); expect(unwrapped.content.assignment).toBe("Read"); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 650ece0..f7011eb 100755 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ environment: "node", include: [ "src/**/*.test.ts", + "../../packages/domain/src/**/*.test.ts", + "../../packages/application/src/**/*.test.ts", "../../packages/shared/src/**/*.test.ts", ], }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..567eec9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,86 @@ +# AdventureOS Architecture + +This document describes the system architecture and behavioural invariants that must be preserved during refactoring. + +## Package Structure + +``` +packages/ + domain/ Pure domain rules, entities, value objects — no external deps + application/ Use cases and port interfaces — depends on domain only + shared/ Deprecated re-export shim → @adventureos/domain + db/ Drizzle schema, migrations, connection + +apps/web/ + src/ + adapters/ Persistence, AI, Calibre implementations of ports + infrastructure/ Composition root, env wiring + app/api/ HTTP adapters (Next.js route handlers) + components/ UI adapters + lib/services/ Legacy service layer (migrating to application use cases) +``` + +### Dependency Rule + +- `domain` → nothing external +- `application` → `domain` +- `adapters` / `infrastructure` / `app/api` → `application`, `domain`, `db` +- Domain must not import controllers, ORM, HTTP, or config + +## Behavioural Invariants + +Derived from [PRODUCT.md](./PRODUCT.md). These must not change without explicit approval. + +### XP and Progression + +- **No XP loss** under normal operation. Undo revokes XP via compensating negative events, not deletion. +- **Soft caps** apply diminishing returns per source (`adventure_item`, `spiritual`, `reading`) — see `DAILY_SOFT_CAPS` in domain. +- **Level curve** is defined by `levelFromXp` / `xpForLevel` — cap at level 150. +- **Rest days** award `XP_AWARDS.rest_day` without requiring item completion. +- **Daily visit** XP is awarded once per logical day. + +### Adventure Items + +- Item states: `blank` → `started` → `partial` → `done` (derived by type-specific rules). +- Partial credit is always awarded for non-blank state transitions. +- **Semantic keys** (`exercise`, `prayer`, `litanies`, etc.) identify items for scoring/XP. Label-string fallback remains for backward compatibility until all templates use `config.semanticKey`. + +### Day Boundary + +- Logical "today" respects configurable day-boundary hour and grace window (`getLogicalToday`, `isWithinGraceWindow`). +- Week starts on Monday (`startOfWeek` with `weekStartsOn: 1`). + +### Undo + +- Actions recorded via `recordAction` with `beforeState` / `afterState`. +- Undo creates compensating XP events and marks originals as revoked. +- Each `actionType` has a dedicated undo handler — handlers must stay synchronized with record sites. + +### Anti-Burnout + +- No streak destruction, no failure screens. +- Grace days and welcome-back flows preserve progress. +- Forgiving day modes do not penalize the user. + +### Auth + +- Single-tenant: one user per installation (`requireUser` uses `limit(1)`). +- Use cases receive explicit `userId` — auth resolution happens in HTTP/cron adapters only. + +## Migration Status + +| Slice | Status | +|-------|--------| +| UpdateAdventureItem use case | Migrated via composition root | +| Remaining adventure operations | Legacy services | +| Reading, dashboard, AI | Legacy services | +| Admin CMS (Drizzle schema) | Persistence adapter | + +## Running Checks + +```bash +npm run test # domain + application + web + shared +npm run lint # ESLint (web) +npm run typecheck # tsc --noEmit +npm run build # Next.js production build +``` diff --git a/package-lock.json b/package-lock.json index 3613ef9..06ba6a1 100755 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "name": "@adventureos/web", "version": "0.1.0", "dependencies": { + "@adventureos/application": "*", "@adventureos/db": "*", + "@adventureos/domain": "*", "@adventureos/shared": "*", "@serwist/next": "^9.0.12", "@tanstack/react-query": "^5.67.2", @@ -3941,18 +3943,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "apps/web/node_modules/typescript": { - "version": "5.9.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "apps/web/node_modules/typescript-eslint": { "version": "8.62.0", "dev": true, @@ -4180,10 +4170,18 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/@adventureos/application": { + "resolved": "packages/application", + "link": true + }, "node_modules/@adventureos/db": { "resolved": "packages/db", "link": true }, + "node_modules/@adventureos/domain": { + "resolved": "packages/domain", + "link": true + }, "node_modules/@adventureos/shared": { "resolved": "packages/shared", "link": true @@ -8622,6 +8620,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uncrypto": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", @@ -9398,6 +9410,17 @@ } } }, + "packages/application": { + "name": "@adventureos/application", + "version": "1.0.0", + "dependencies": { + "@adventureos/domain": "*" + }, + "devDependencies": { + "typescript": "^5", + "vitest": "^3.2.6" + } + }, "packages/db": { "name": "@adventureos/db", "version": "1.0.0", @@ -9411,9 +9434,23 @@ "tsx": "^4.19.3" } }, + "packages/domain": { + "name": "@adventureos/domain", + "version": "1.0.0", + "dependencies": { + "date-fns": "^4.1.0" + }, + "devDependencies": { + "typescript": "^5", + "vitest": "^3.2.6" + } + }, "packages/shared": { "name": "@adventureos/shared", "version": "1.0.0", + "dependencies": { + "@adventureos/domain": "*" + }, "devDependencies": { "vitest": "^3.2.6" } diff --git a/package.json b/package.json index 33680b6..05aea0e 100755 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "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", + "typecheck": "npm run typecheck -w @adventureos/web && npm run typecheck -w @adventureos/application && npm run typecheck -w @adventureos/domain", + "test": "npm run test -w @adventureos/domain && npm run test -w @adventureos/application && npm run test -w @adventureos/web && npm run test -w @adventureos/shared", + "test:web": "npm run test -w @adventureos/web", "test:shared": "npm run test -w @adventureos/shared" } } diff --git a/packages/application/package.json b/packages/application/package.json new file mode 100644 index 0000000..c02cfab --- /dev/null +++ b/packages/application/package.json @@ -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" + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts new file mode 100644 index 0000000..53be69c --- /dev/null +++ b/packages/application/src/index.ts @@ -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"; diff --git a/packages/application/src/ports/action-events.repository.ts b/packages/application/src/ports/action-events.repository.ts new file mode 100644 index 0000000..28067ee --- /dev/null +++ b/packages/application/src/ports/action-events.repository.ts @@ -0,0 +1,30 @@ +export type ActionEventRecord = { + id: string; + userId: string; + actionType: string; + entityType: string; + entityId: string; + summary: string; + beforeState: Record; + afterState: Record; + metadata: Record | null; + createdAt: Date; + undoneAt: Date | null; +}; + +export type RecordActionInput = { + userId: string; + actionType: string; + entityType: string; + entityId: string; + summary: string; + beforeState: Record; + afterState: Record; + metadata?: Record; +}; + +export interface ActionEventsRepository { + recordAction(input: RecordActionInput): Promise; + getActionEvent(id: string, userId: string): Promise; + markUndone(id: string): Promise; +} diff --git a/packages/application/src/ports/adventure.repository.ts b/packages/application/src/ports/adventure.repository.ts new file mode 100644 index 0000000..66e13b4 --- /dev/null +++ b/packages/application/src/ports/adventure.repository.ts @@ -0,0 +1,86 @@ +import type { AdventureItemState } from "@adventureos/domain"; + +export type DailyAdventureItemRecord = { + id: string; + dailyAdventureId: string; + type: string; + label: string; + config: Record | null; + value: Record | 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 | null; + sortOrder: number; + enabled: boolean; + deletedAt: Date | null; +}; + +export interface AdventureRepository { + findDailyAdventure(userId: string, date: string): Promise; + createDailyAdventure( + userId: string, + date: string, + templateId?: string | null + ): Promise; + listDailyAdventureItems(adventureId: string): Promise; + listDailyTodos(adventureId: string): Promise; + updateDailyAdventureItem( + itemId: string, + updates: { + value?: Record; + state?: AdventureItemState; + completedAt?: Date | null; + } + ): Promise; + softDeleteDailyAdventureItems(adventureId: string): Promise; + insertDailyAdventureItem( + adventureId: string, + item: Omit + ): Promise; + updateDailyAdventure( + adventureId: string, + updates: Record + ): Promise; + listTemplates(userId: string): Promise; + listTemplateItems(templateId: string): Promise; + findTemplate(userId: string, templateId: string): Promise; +} diff --git a/packages/application/src/ports/scoring.service.ts b/packages/application/src/ports/scoring.service.ts new file mode 100644 index 0000000..bf7aa0b --- /dev/null +++ b/packages/application/src/ports/scoring.service.ts @@ -0,0 +1,6 @@ +import type { DaySnapshot } from "@adventureos/domain"; + +export interface ScoringService { + refreshScores(userId: string): Promise; + buildDaySnapshot(userId: string, date: string): Promise; +} diff --git a/packages/application/src/ports/user.repository.ts b/packages/application/src/ports/user.repository.ts new file mode 100644 index 0000000..25a800a --- /dev/null +++ b/packages/application/src/ports/user.repository.ts @@ -0,0 +1,9 @@ +export type UserRecord = { + id: string; + displayName: string; +}; + +export interface UserRepository { + getUser(userId: string): Promise; + getSingleUser(): Promise; +} diff --git a/packages/application/src/ports/xp.repository.ts b/packages/application/src/ports/xp.repository.ts new file mode 100644 index 0000000..341c36f --- /dev/null +++ b/packages/application/src/ports/xp.repository.ts @@ -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 | null; +}; + +export interface XpRepository { + getDailyXpEarned(userId: string, date: string, source?: XpSource): Promise; + insertXpEvent(event: Omit): Promise; + getUserProgress(userId: string): Promise; + updateUserProgress( + userId: string, + updates: Partial + ): Promise; + listXpEventsByDateAndSource( + userId: string, + date: string, + source: XpSource + ): Promise; + listAllXpEvents(userId: string): Promise; +} diff --git a/packages/application/src/use-cases/adventure/update-adventure-item.test.ts b/packages/application/src/use-cases/adventure/update-adventure-item.test.ts new file mode 100644 index 0000000..c409e5e --- /dev/null +++ b/packages/application/src/use-cases/adventure/update-adventure-item.test.ts @@ -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; +} = {}) { + 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"); + }); +}); diff --git a/packages/application/src/use-cases/adventure/update-adventure-item.ts b/packages/application/src/use-cases/adventure/update-adventure-item.ts new file mode 100644 index 0000000..2f2caa0 --- /dev/null +++ b/packages/application/src/use-cases/adventure/update-adventure-item.ts @@ -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; 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): Record { + const size = (config.checklistSize as number) ?? 5; + return { checks: Array(size).fill(false) }; +} + +export async function materializeDay( + deps: Pick, + 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, + 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 }; +} diff --git a/packages/application/src/use-cases/xp/award-xp.test.ts b/packages/application/src/use-cases/xp/award-xp.test.ts new file mode 100644 index 0000000..5f3186a --- /dev/null +++ b/packages/application/src/use-cases/xp/award-xp.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/application/src/use-cases/xp/award-xp.ts b/packages/application/src/use-cases/xp/award-xp.ts new file mode 100644 index 0000000..f48d031 --- /dev/null +++ b/packages/application/src/use-cases/xp/award-xp.ts @@ -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; +}; + +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 { + 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, + }; +} diff --git a/packages/application/tsconfig.json b/packages/application/tsconfig.json new file mode 100644 index 0000000..c351ece --- /dev/null +++ b/packages/application/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/application/vitest.config.ts b/packages/application/vitest.config.ts new file mode 100644 index 0000000..c1433e6 --- /dev/null +++ b/packages/application/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 2462429..02d2d67 100755 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -25,6 +25,23 @@ const DEFAULT_LITANIES = [ "Litany 6", ]; +function itemConfig( + label: string, + config: Record = {} +): Record { + const semanticByLabel: Record = { + Work: "work", + Exercise: "exercise", + Prayer: "prayer", + Litanies: "litanies", + Teaching: "teaching", + "Class 1": "class", + "Class 2": "class", + }; + const semanticKey = semanticByLabel[label]; + return semanticKey ? { ...config, semanticKey } : config; +} + async function seed() { const existing = await db.select().from(users).limit(1); if (existing.length > 0) { @@ -58,13 +75,13 @@ async function seed() { isSystem: true, sortPriority: 0, items: [ - { type: "duration", label: "Work", config: { targetHours: 7.5 }, sortOrder: 0 }, - { type: "checkbox", label: "Exercise", config: {}, sortOrder: 1 }, + { type: "duration", label: "Work", config: itemConfig("Work", { targetHours: 7.5 }), sortOrder: 0 }, + { type: "checkbox", label: "Exercise", config: itemConfig("Exercise"), 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: "timeblock", label: "Class 1", config: itemConfig("Class 1", { scheduledTime: "18:00" }), sortOrder: 3 }, + { type: "timeblock", label: "Class 2", config: itemConfig("Class 2", { scheduledTime: "20:00" }), sortOrder: 4 }, + { type: "checklist", label: "Prayer", config: itemConfig("Prayer", { checklistSize: 5 }), sortOrder: 5 }, + { type: "checklist", label: "Litanies", config: itemConfig("Litanies", { checklistSize: 6 }), sortOrder: 6 }, { type: "note", label: "Notes", config: {}, sortOrder: 7 }, ], }, @@ -75,10 +92,10 @@ async function seed() { isSystem: true, sortPriority: 0, items: [ - { type: "checkbox", label: "Exercise", config: {}, sortOrder: 0 }, + { type: "checkbox", label: "Exercise", config: itemConfig("Exercise"), 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: "checklist", label: "Prayer", config: itemConfig("Prayer", { checklistSize: 5 }), sortOrder: 2 }, + { type: "checklist", label: "Litanies", config: itemConfig("Litanies", { checklistSize: 6 }), sortOrder: 3 }, { type: "note", label: "Notes", config: {}, sortOrder: 4 }, ], }, @@ -90,9 +107,9 @@ async function seed() { 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: "checkbox", label: "Exercise", config: itemConfig("Exercise"), sortOrder: 1 }, + { type: "checklist", label: "Prayer", config: itemConfig("Prayer", { checklistSize: 5 }), sortOrder: 2 }, + { type: "checklist", label: "Litanies", config: itemConfig("Litanies", { checklistSize: 6 }), sortOrder: 3 }, { type: "note", label: "Notes", config: {}, sortOrder: 4 }, ], }, @@ -104,11 +121,11 @@ async function seed() { 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: "checkbox", label: "Teaching", config: itemConfig("Teaching"), sortOrder: 1 }, + { type: "checkbox", label: "Exercise", config: itemConfig("Exercise"), 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 }, + { type: "checklist", label: "Prayer", config: itemConfig("Prayer", { checklistSize: 5 }), sortOrder: 4 }, + { type: "checklist", label: "Litanies", config: itemConfig("Litanies", { checklistSize: 6 }), sortOrder: 5 }, ], }, ]; diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100755 index 0000000..2be79b7 --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,25 @@ +{ + "name": "@adventureos/domain", + "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", + "./adventure/derive-state": "./src/adventure/derive-state.ts", + "./adventure/template-matching": "./src/adventure/template-matching.ts", + "./adventure/semantic-key": "./src/adventure/semantic-key.ts", + "./reflection": "./src/reflection/index.ts" + }, + "dependencies": { + "date-fns": "^4.1.0" + }, + "devDependencies": { + "typescript": "^5", + "vitest": "^3.2.6" + } +} diff --git a/packages/domain/src/achievements.ts b/packages/domain/src/achievements.ts new file mode 100755 index 0000000..d44546d --- /dev/null +++ b/packages/domain/src/achievements.ts @@ -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); +} diff --git a/packages/domain/src/adventure/derive-state.test.ts b/packages/domain/src/adventure/derive-state.test.ts new file mode 100755 index 0000000..48e4cbb --- /dev/null +++ b/packages/domain/src/adventure/derive-state.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { deriveState } from "./derive-state"; + +describe("deriveState", () => { + it("checkbox: blank when not done", () => { + expect(deriveState("checkbox", {}, {})).toBe("blank"); + }); + + it("checkbox: done when checked", () => { + expect(deriveState("checkbox", { done: true }, {})).toBe("done"); + }); + + it("duration: done at target", () => { + expect(deriveState("duration", { hours: 8 }, { targetHours: 8 })).toBe("done"); + }); + + it("duration: partial at half target", () => { + expect(deriveState("duration", { hours: 4 }, { targetHours: 8 })).toBe("partial"); + }); + + it("duration: started below half", () => { + expect(deriveState("duration", { hours: 2 }, { targetHours: 8 })).toBe("started"); + }); + + it("checklist: partial when some checked", () => { + expect(deriveState("checklist", { checks: [true, false, false] }, {})).toBe("partial"); + }); + + it("checklist: done when all checked", () => { + expect(deriveState("checklist", { checks: [true, true] }, {})).toBe("done"); + }); + + it("reading: done at 10+ pages", () => { + expect(deriveState("reading", { pages: 10 }, {})).toBe("done"); + expect(deriveState("reading", { pages: 5 }, {})).toBe("partial"); + }); +}); diff --git a/packages/domain/src/adventure/derive-state.ts b/packages/domain/src/adventure/derive-state.ts new file mode 100644 index 0000000..0a8d348 --- /dev/null +++ b/packages/domain/src/adventure/derive-state.ts @@ -0,0 +1,32 @@ +import type { AdventureItemState } from "../types"; + +export function deriveState( + type: string, + value: Record, + config: Record +): AdventureItemState { + if (type === "checkbox" || type === "timeblock") { + return value.done ? "done" : "blank"; + } + if (type === "duration") { + const hours = (value.hours as number) ?? 0; + const target = (config.targetHours as number) ?? 0; + if (hours <= 0) return "blank"; + if (target > 0 && hours >= target) return "done"; + if (hours > 0) return hours >= target * 0.5 ? "partial" : "started"; + return "started"; + } + if (type === "checklist") { + const checks = (value.checks as boolean[]) ?? []; + const done = checks.filter(Boolean).length; + if (done === 0) return "blank"; + if (done === checks.length) return "done"; + return "partial"; + } + if (type === "reading") { + const pages = (value.pages as number) ?? 0; + if (pages <= 0) return "blank"; + return pages >= 10 ? "done" : "partial"; + } + return "blank"; +} diff --git a/packages/domain/src/adventure/semantic-key.test.ts b/packages/domain/src/adventure/semantic-key.test.ts new file mode 100644 index 0000000..08322af --- /dev/null +++ b/packages/domain/src/adventure/semantic-key.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { matchesSemanticKey, resolveSemanticKey } from "./semantic-key"; + +describe("semantic-key", () => { + it("resolves exercise from label fallback", () => { + expect(resolveSemanticKey({ type: "checkbox", label: "Exercise" })).toBe("exercise"); + }); + + it("prefers config semanticKey over label", () => { + expect( + resolveSemanticKey({ + type: "checkbox", + label: "Morning Run", + config: { semanticKey: "exercise" }, + }) + ).toBe("exercise"); + }); + + it("matches prayer and litanies by exact label fallback", () => { + expect(matchesSemanticKey({ type: "checklist", label: "Prayer" }, "prayer")).toBe(true); + expect(matchesSemanticKey({ type: "checklist", label: "Litanies" }, "litanies")).toBe(true); + }); + + it("resolves work from duration type", () => { + expect(matchesSemanticKey({ type: "duration", label: "Work" }, "work")).toBe(true); + }); +}); diff --git a/packages/domain/src/adventure/semantic-key.ts b/packages/domain/src/adventure/semantic-key.ts new file mode 100644 index 0000000..da91b57 --- /dev/null +++ b/packages/domain/src/adventure/semantic-key.ts @@ -0,0 +1,36 @@ +export type AdventureSemanticKey = + | "exercise" + | "prayer" + | "litanies" + | "teaching" + | "work" + | "class"; + +export type SemanticItem = { + type: string; + label: string; + config?: Record | null; +}; + +export function resolveSemanticKey(item: SemanticItem): AdventureSemanticKey | null { + const fromConfig = item.config?.semanticKey; + if (typeof fromConfig === "string") { + return fromConfig as AdventureSemanticKey; + } + + const label = item.label.toLowerCase(); + if (item.type === "checkbox" && label.includes("exercise")) return "exercise"; + if (item.type === "checkbox" && label.includes("teaching")) return "teaching"; + if (item.label === "Prayer") return "prayer"; + if (item.label === "Litanies") return "litanies"; + if (item.type === "duration") return "work"; + if (item.type === "timeblock" || label.includes("class")) return "class"; + return null; +} + +export function matchesSemanticKey( + item: SemanticItem, + key: AdventureSemanticKey +): boolean { + return resolveSemanticKey(item) === key; +} diff --git a/packages/domain/src/adventure/template-matching.test.ts b/packages/domain/src/adventure/template-matching.test.ts new file mode 100755 index 0000000..5f2841b --- /dev/null +++ b/packages/domain/src/adventure/template-matching.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { pickTemplateForDay } from "./template-matching"; + +describe("pickTemplateForDay", () => { + const templates = [ + { + id: "weekday", + daysOfWeek: [1, 2, 3, 4, 5], + sortPriority: 0, + isDefault: true, + deletedAt: null, + }, + { + id: "tuesday-thursday", + daysOfWeek: [2, 4], + sortPriority: 10, + isDefault: false, + deletedAt: null, + }, + { + id: "deleted", + daysOfWeek: [2], + sortPriority: 100, + isDefault: false, + deletedAt: new Date(), + }, + ]; + + it("prefers higher sortPriority on overlapping days", () => { + expect(pickTemplateForDay(templates, 2)?.id).toBe("tuesday-thursday"); + }); + + it("breaks ties by fewer daysOfWeek entries", () => { + const tied = [ + { id: "broad", daysOfWeek: [1, 2, 3, 4, 5], sortPriority: 5, isDefault: false, deletedAt: null }, + { id: "narrow", daysOfWeek: [2], sortPriority: 5, isDefault: false, deletedAt: null }, + ]; + expect(pickTemplateForDay(tied, 2)?.id).toBe("narrow"); + }); + + it("falls back to default when no day match", () => { + expect(pickTemplateForDay(templates, 0)?.id).toBe("weekday"); + }); + + it("ignores deleted templates", () => { + const onlyDeleted = templates.filter((t) => t.id === "deleted"); + expect(pickTemplateForDay(onlyDeleted, 2)).toBeNull(); + }); +}); diff --git a/packages/domain/src/adventure/template-matching.ts b/packages/domain/src/adventure/template-matching.ts new file mode 100644 index 0000000..a3d45a6 --- /dev/null +++ b/packages/domain/src/adventure/template-matching.ts @@ -0,0 +1,23 @@ +export type TemplateCandidate = { + id: string; + daysOfWeek: number[]; + sortPriority: number; + isDefault: boolean; + deletedAt: Date | null; +}; + +export function pickTemplateForDay( + templates: TemplateCandidate[], + dayOfWeek: number +): TemplateCandidate | null { + const active = templates.filter((t) => t.deletedAt === null); + const matching = active + .filter((t) => t.daysOfWeek.includes(dayOfWeek)) + .sort( + (a, b) => + b.sortPriority - a.sortPriority || a.daysOfWeek.length - b.daysOfWeek.length + ); + + if (matching.length > 0) return matching[0]; + return active.find((t) => t.isDefault) ?? active[0] ?? null; +} diff --git a/packages/domain/src/chapters.test.ts b/packages/domain/src/chapters.test.ts new file mode 100755 index 0000000..ac7be54 --- /dev/null +++ b/packages/domain/src/chapters.test.ts @@ -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); + }); +}); diff --git a/packages/domain/src/chapters.ts b/packages/domain/src/chapters.ts new file mode 100755 index 0000000..63e1583 --- /dev/null +++ b/packages/domain/src/chapters.ts @@ -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; +} diff --git a/packages/domain/src/day-boundary.test.ts b/packages/domain/src/day-boundary.test.ts new file mode 100755 index 0000000..67dd60f --- /dev/null +++ b/packages/domain/src/day-boundary.test.ts @@ -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); + }); +}); diff --git a/packages/domain/src/day-boundary.ts b/packages/domain/src/day-boundary.ts new file mode 100755 index 0000000..de1b469 --- /dev/null +++ b/packages/domain/src/day-boundary.ts @@ -0,0 +1,42 @@ +import { format, subDays, parseISO, startOfWeek } from "date-fns"; + +/** Calendar date string for the current moment given a day-boundary hour (0–5). */ +export function getLogicalDate(now: Date, boundaryHour: number): string { + const adjusted = new Date(now); + if (adjusted.getHours() < boundaryHour) { + adjusted.setDate(adjusted.getDate() - 1); + } + return format(adjusted, "yyyy-MM-dd"); +} + +export function getLogicalToday(boundaryHour: number, now = new Date()): string { + return getLogicalDate(now, boundaryHour); +} + +export function getLogicalYesterday(boundaryHour: number, now = new Date()): string { + const logical = parseISO(getLogicalToday(boundaryHour, now)); + return format(subDays(logical, 1), "yyyy-MM-dd"); +} + +/** True during the first few hours after the day boundary (e.g. midnight–4am). */ +export function isWithinGraceWindow( + boundaryHour: number, + graceHours = 4, + now = new Date() +): boolean { + const hour = now.getHours(); + if (hour < boundaryHour) return true; + if (hour >= boundaryHour && hour < boundaryHour + graceHours) return true; + return false; +} + +export function isCalendarYesterday(logicalDate: string, boundaryHour: number, now = new Date()): boolean { + const calendarToday = format(now, "yyyy-MM-dd"); + const logicalToday = getLogicalToday(boundaryHour, now); + return logicalDate !== calendarToday && logicalDate === logicalToday; +} + +/** Monday-start week containing the given date (yyyy-MM-dd). */ +export function weekStartForDate(date: Date = new Date()): string { + return format(startOfWeek(date, { weekStartsOn: 1 }), "yyyy-MM-dd"); +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100755 index 0000000..319a915 --- /dev/null +++ b/packages/domain/src/index.ts @@ -0,0 +1,15 @@ +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"; +export * from "./adventure/derive-state"; +export * from "./adventure/template-matching"; +export * from "./adventure/semantic-key"; +export * from "./reflection"; +export { weekStartForDate } from "./day-boundary"; diff --git a/packages/domain/src/levels.test.ts b/packages/domain/src/levels.test.ts new file mode 100755 index 0000000..fdaefe5 --- /dev/null +++ b/packages/domain/src/levels.test.ts @@ -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); + }); +}); diff --git a/packages/domain/src/levels.ts b/packages/domain/src/levels.ts new file mode 100755 index 0000000..2516466 --- /dev/null +++ b/packages/domain/src/levels.ts @@ -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 }; +} diff --git a/packages/domain/src/memory.ts b/packages/domain/src/memory.ts new file mode 100755 index 0000000..72b8788 --- /dev/null +++ b/packages/domain/src/memory.ts @@ -0,0 +1,124 @@ +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[] = [ + "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: true, + maxPendingSuggestions: 10, + minDaysBetweenNudges: 3, + allowSensitiveCategories: false, +}; + +export const DEFAULT_DAY_BOUNDARY_HOUR = 0; diff --git a/packages/domain/src/quest-pool.ts b/packages/domain/src/quest-pool.ts new file mode 100755 index 0000000..9b09aba --- /dev/null +++ b/packages/domain/src/quest-pool.ts @@ -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); +} diff --git a/packages/domain/src/reflection/index.ts b/packages/domain/src/reflection/index.ts new file mode 100644 index 0000000..72f8116 --- /dev/null +++ b/packages/domain/src/reflection/index.ts @@ -0,0 +1,18 @@ +import type { ReflectionData } from "../types"; + +export type { ReflectionData }; + +export function hasMeaningfulReflectionContent(data: ReflectionData): boolean { + return [data.wentWell, data.learned, data.improveTomorrow].some( + (field) => field.trim().length > 0 + ); +} + +export function shouldAwardReflectionXp( + existing: ReflectionData | null | undefined, + data: ReflectionData +): boolean { + if (!hasMeaningfulReflectionContent(data)) return false; + if (!existing) return true; + return !hasMeaningfulReflectionContent(existing); +} diff --git a/packages/domain/src/scores.test.ts b/packages/domain/src/scores.test.ts new file mode 100755 index 0000000..f8e766a --- /dev/null +++ b/packages/domain/src/scores.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/domain/src/scores.ts b/packages/domain/src/scores.ts new file mode 100755 index 0000000..5cb6362 --- /dev/null +++ b/packages/domain/src/scores.ts @@ -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), + }; +} diff --git a/packages/domain/src/titles.ts b/packages/domain/src/titles.ts new file mode 100755 index 0000000..73905c2 --- /dev/null +++ b/packages/domain/src/titles.ts @@ -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"; diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts new file mode 100755 index 0000000..c73da66 --- /dev/null +++ b/packages/domain/src/types.ts @@ -0,0 +1,283 @@ +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; + 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; + 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; +} + +export interface StatsOverview { + totalXp: number; + xpThisMonth: number; + booksCompleted: number; + pagesThisMonth: number; + exerciseSessions: number; + consistencyTrend: { date: string; value: number }[]; + xpByCategory: { category: string; amount: number }[]; +} + +export type TeacherDifficulty = "beginner" | "intermediate" | "advanced"; +export type TeacherLength = "short" | "standard" | "deep"; + +export interface TeacherResearchAssignment { + steps: string[]; + expectedOutcome?: string; + estimatedTimeMinutes?: number; +} + +export interface TeacherHomework { + task: string; + instructions: string[]; +} + +export interface TeacherCalibreSuggestion { + title: string; + authors: string[]; + reason: string; +} + +export interface TeacherQuizQuestion { + question: string; + type?: "multiple_choice" | "short_answer"; + options?: string[]; + /** 0-based index for multiple choice (legacy + normalized MC) */ + answer: number; + answerText?: string; + explanation?: string; +} + +export interface TeacherAnswerKeyEntry { + questionIndex: number; + answer: string; + explanation?: string; +} + +export interface TeacherLessonContent { + title?: string; + /** Alias kept for legacy stored lessons */ + introduction?: string; + overview?: string; + whyItMatters?: string; + /** Alias kept for legacy stored lessons */ + objectives?: string[]; + learningObjectives?: string[]; + explanation?: string; + researchAssignment?: TeacherResearchAssignment; + readingSteps?: string[]; + calibreSuggestions?: TeacherCalibreSuggestion[]; + externalReading?: string[]; + homework?: TeacherHomework; + flashcards: { front: string; back: string }[]; + quiz: TeacherQuizQuestion[]; + assignment?: string; + reflectionPrompt?: string; + answerKey?: TeacherAnswerKeyEntry[]; + nextLessonSuggestion?: string; +} + +export interface TeacherLessonData { + id: string; + topic: string; + content: TeacherLessonContent; + status: string; + explorationId?: string | null; + completedNote?: string | null; + createdAt: string; + source?: "ai" | "fallback"; + fallbackReason?: + | "offline" + | "model_unavailable" + | "parse_failed" + | "generation_failed" + | "timeout"; +} diff --git a/packages/domain/src/xp.test.ts b/packages/domain/src/xp.test.ts new file mode 100755 index 0000000..358c9dc --- /dev/null +++ b/packages/domain/src/xp.test.ts @@ -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); + }); +}); diff --git a/packages/domain/src/xp.ts b/packages/domain/src/xp.ts new file mode 100755 index 0000000..2070983 --- /dev/null +++ b/packages/domain/src/xp.ts @@ -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> = { + 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; +} diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json new file mode 100644 index 0000000..c351ece --- /dev/null +++ b/packages/domain/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/domain/vitest.config.ts b/packages/domain/vitest.config.ts new file mode 100755 index 0000000..c1433e6 --- /dev/null +++ b/packages/domain/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index 3beaded..741e7be 100755 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,6 +10,9 @@ "exports": { ".": "./src/index.ts" }, + "dependencies": { + "@adventureos/domain": "*" + }, "devDependencies": { "vitest": "^3.2.6" } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4ce6d13..238222c 100755 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,10 +1 @@ -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"; +export * from "@adventureos/domain";