diff --git a/scripts/test-shared-db.mjs b/scripts/test-shared-db.mjs index 0523ddd..c18e90b 100644 --- a/scripts/test-shared-db.mjs +++ b/scripts/test-shared-db.mjs @@ -7,7 +7,15 @@ import { fileURLToPath, pathToFileURL } from 'node:url' const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') -const testPath = path.join(shadowMateRoot, 'supabase', 'tests', 'learning_rls_test.sql') +const testsDir = path.join(shadowMateRoot, 'supabase', 'tests') + +function collectTestFiles() { + return fs + .readdirSync(testsDir) + .filter((entry) => entry.endsWith('.sql')) + .sort() + .map((entry) => path.join(testsDir, entry)) +} function validateLocalDatabaseUrl(databaseUrl, source) { let parsedUrl @@ -75,8 +83,13 @@ export { buildTestDatabaseUrl } if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { const databaseUrl = getDatabaseUrl() const testDatabaseUrl = buildTestDatabaseUrl(databaseUrl) + const testFiles = collectTestFiles() + + if (testFiles.length === 0) { + throw new Error(`在 ${testsDir} 中没有找到任何 SQL 测试文件。`) + } - console.log('🧪 运行 Shadow Mate learning RLS 测试(目标:loopback 本地数据库)') + console.log(`🧪 运行 ${testFiles.length} 个 pgTAP 测试文件(目标:loopback 本地数据库)`) execFileSync( 'npx', [ @@ -85,7 +98,7 @@ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) 'db', '--db-url', testDatabaseUrl, - testPath, + ...testFiles, ], { cwd: shadowMateRoot, diff --git a/src/app.css b/src/app.css index 94f831e..56371f3 100644 --- a/src/app.css +++ b/src/app.css @@ -271,6 +271,26 @@ .pts-card.sub .pts-toggle{background:#e08a8a;} .pts-card.sub.done .pts-toggle{background:#d9534f;} + .growth-form{display:grid;grid-template-columns:minmax(0,1fr) 120px auto;gap:8px;align-items:end;margin-top:10px;} + .growth-form label{display:flex;flex-direction:column;gap:5px;color:var(--green-deep);font-size:12px;font-weight:800;} + .growth-form input{width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:12px;background:var(--card);color:var(--ink);padding:10px 11px;font:inherit;font-size:14px;} + .growth-form .checkin{width:auto;white-space:nowrap;padding:10px 14px;font-size:13px;} + .growth-custom-card{background:linear-gradient(135deg,var(--card),var(--green-soft));} + .growth-reward-card{background:linear-gradient(135deg,var(--card),#fff7dc);} + .reward-list{margin-top:14px;display:grid;gap:9px;} + .reward-card{display:flex;align-items:center;gap:9px;border:1px solid var(--line);border-radius:16px;background:var(--card);padding:9px 10px;} + .reward-icon{width:34px;height:34px;border-radius:12px;background:#fff2bc;color:#a26c00;display:flex;align-items:center;justify-content:center;flex:0 0 auto;} + .reward-info{display:flex;flex:1;min-width:0;flex-direction:column;gap:2px;} + .reward-info strong{font-size:13px;color:var(--ink);} + .reward-info span{font-size:11px;color:var(--ink-soft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .reward-card .pts-badge{margin-left:0;} + .reward-redeem{width:auto;padding:8px 10px;font-size:12px;} + .reward-redeem:disabled{opacity:.55;cursor:not-allowed;} + @media (max-width:360px){ + .growth-form{grid-template-columns:1fr;} + .growth-form .checkin{width:100%;} + } + .cal{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;} .cal-chip{width:100%;height:auto;aspect-ratio:1;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800;background:#f1f7f2;border:2px solid transparent;cursor:pointer;color:var(--ink-soft);transition:transform .15s ease,background-color .15s ease,border-color .15s ease,box-shadow .15s ease;font-family:inherit;appearance:none;} .cal-chip:active{transform:scale(.96);} diff --git a/src/app.js b/src/app.js index 831c2a3..905f70d 100644 --- a/src/app.js +++ b/src/app.js @@ -16,9 +16,14 @@ import { CHECKIN_GROUPS, createLearningState, hasCheckin, - isPointMarked, transitionLearningState, } from "./learning-state.js"; +import { getLearningStateStorageKey } from "./learning-state-envelope.js"; +import { loadLearningStateEnvelope, adoptPendingLearningState } from "./learning-state-storage.js"; +import { createIndexedDbLearningDb } from "./learning-local-db.js"; +import { createGrowthLoopController } from "./learning-growth-loop-controller.js"; +import { ACTIVITY_EVENT_TYPES, activityEventIdFor } from "./learning-analytics.js"; +import { getActivePointAction, getBalance, getPointDayTotal, getPointPeriodTotal } from "./learning-growth-loop.js"; const CHECKIN_MODULES = Object.keys(CHECKIN_GROUPS); @@ -130,31 +135,90 @@ const PEANUT_BOOKS = [ ========================================================= */ const STORE_KEY = "shadow_mate_workbench_v1"; +const growthLoopDb = createIndexedDbLearningDb(); +const growthLoopController = createGrowthLoopController({ db: growthLoopDb }); +let growthLoopSnapshot = growthLoopController.getSnapshot(); +let CURRENT_MOD = "home"; +window.growthLoop = growthLoopController; + +function clientRequestId(prefix = "growth") { + if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID(); + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function queueGrowthActivity(event_type, payload = {}, bucket = "once") { + const scope = growthLoopController.getScope(); + if (!scope.household_id || !scope.profile_id) return Promise.resolve(null); + return growthLoopController.queueActivity({ + event_type, + event_id: activityEventIdFor({ ...scope, event_type, bucket }), + payload, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + client_version: document.documentElement.dataset.version || null, + }).catch((error) => { + console.warn("Growth Loop activity event queued locally but could not be written:", error); + return null; + }); +} + +growthLoopController.subscribe((nextSnapshot) => { + growthLoopSnapshot = nextSnapshot; + if (CURRENT_MOD === "points" || CURRENT_MOD === "grow") switchMod(CURRENT_MOD); +}); + +function learningStateFromEnvelope(envelope) { + return envelope?.schema_version === 2 && envelope.learning ? envelope.learning : envelope; +} + function readStoredState(){ - const raw = localStorage.getItem(STORE_KEY); - if(raw === null) return {}; - try{ - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : {}; - }catch(error){ - console.warn("忽略无法解析的本地存档", error); - } - return {}; + return learningStateFromEnvelope(loadLearningStateEnvelope(localStorage, {})); } let store = createLearningState(readStoredState()); if(!store.checkins) store.checkins = {}; // {date: {module:true}} if(!store.extra) store.extra = {}; // 扩展记录(如数学题数) -if(!store.points) store.points = {}; // {ym: {itemIdx: {day:1}}} 积分打卡记录 +if(!store.points) store.points = {}; // 仅保留旧积分历史;新 Growth Loop 不再写入此字段 if(!store.bookShelf) store.bookShelf = {}; // {bookIdx:1} 绘本已读标记 if(!store.peanutLog) store.peanutLog = []; // [{title,date,rating}] 小花生阅读记录 if(!store.peanutRead) store.peanutRead = {}; // {bookIdx:1} 小花生书单已读标记 +let learningEnvelope = loadLearningStateEnvelope(localStorage, {}); + +function persistLearningState(){ + learningEnvelope = { + ...learningEnvelope, + schema_version: 2, + product_id: "shadow-mate", + learning: structuredClone(store), + }; + const scope = learningEnvelope.scope || {}; + localStorage.setItem(getLearningStateStorageKey(scope), JSON.stringify(learningEnvelope)); + // 兼容旧版本的低风险学习状态读取和既有冲突测试;新 Growth Loop + // 账本永远不写入旧 state.points。 + localStorage.setItem(STORE_KEY, JSON.stringify({ ...store, points: {} })); +} + function save(){ - localStorage.setItem(STORE_KEY, JSON.stringify(store)); + persistLearningState(); window.cloudSync?.schedule(); } +async function setLearningScope(scope, { adoptPending = false } = {}) { + learningEnvelope = adoptPending + ? adoptPendingLearningState(localStorage, scope) + : loadLearningStateEnvelope(localStorage, scope); + store = createLearningState(learningStateFromEnvelope(learningEnvelope)); + if(!store.checkins) store.checkins = {}; + if(!store.extra) store.extra = {}; + if(!store.points) store.points = {}; + if(!store.bookShelf) store.bookShelf = {}; + if(!store.peanutLog) store.peanutLog = []; + if(!store.peanutRead) store.peanutRead = {}; + persistLearningState(); + switchMod(CURRENT_MOD); + return structuredClone(learningEnvelope); +} + function todayKey(){ const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; @@ -181,6 +245,12 @@ function toggleCheckin(mod){ key: mod, }); save(); + void queueGrowthActivity( + ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED, + { source: "checkin", entry_type: "manual" }, + `${todayKey()}:${mod}`, + ); + window.cloudSync?.scheduleGrowthLoop?.(); } function streak(mod){ let s = 0; @@ -199,37 +269,69 @@ function totalChecked(mod){ /* 积分打卡辅助 */ function ymKey(){ const d=new Date(); return d.getFullYear()+"-"+(d.getMonth()+1); } -function pointOn(itemIdx, day){ - const ym = ymKey(); - return isPointMarked(store, ym, itemIdx, day); +function dateKeyForDay(day){ + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(Number(day)); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; } -function togglePoint(itemIdx, day){ - store = transitionLearningState(store, { - type: "POINT_TOGGLED", - month: ymKey(), - itemIndex: itemIdx, - day, +function visiblePointItems(){ + const items = window.growthLoop?.getPointItems?.() || []; + if(items.length) return items; + return POINT_ITEMS.map((item, index) => ({ + id: `recommended:${index}`, + name: item.name, + description: item.desc, + default_points: item.pts, + icon_key: PTS_ICON[item.name] || "star", + item_kind: "recommended", + })); +} +function pointItemAt(itemId){ + return visiblePointItems().find((item) => item.id === itemId) || null; +} +function pointOn(itemId, day){ + const item = pointItemAt(itemId); + if(!item?.id || item.id.startsWith("recommended:")) return false; + return getActivePointAction(growthLoopSnapshot, item.id, dateKeyForDay(day)); +} +function togglePoint(itemId, day){ + const item = pointItemAt(itemId); + if(!item) return; + const requestId = clientRequestId("point"); + void window.growthLoop.recordPoint({ item, occurred_on: dateKeyForDay(day), request_id: requestId }).then(() => { + void queueGrowthActivity( + ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED, + { source: "point_item", entry_type: Number(item.default_points ?? item.pts) < 0 ? "adjustment" : "manual" }, + requestId, + ); + void queueGrowthActivity(ACTIVITY_EVENT_TYPES.CORE_ACTIVATION, { source: "point_item" }, "once"); + window.cloudSync?.scheduleGrowthLoop?.(); + renderPoints(); + }).catch((error) => { + console.error("Growth Loop local point write failed:", error); + alert("本机记录没有保存成功,请稍后重试。"); }); - save(); } -function itemMonthTotal(itemIdx){ - const ym = ymKey(); - const rec = store.points[ym] && store.points[ym][itemIdx]; - if(!rec) return 0; - return POINT_ITEMS[itemIdx].pts * Object.keys(rec).length; +function itemMonthTotal(itemId){ + const item = pointItemAt(itemId); + if(!item?.id || item.id.startsWith("recommended:")) return 0; + return getPointPeriodTotal(growthLoopSnapshot, item.id, currentPeriodKey()); } function monthTotal(){ - let s = 0; - for(let i=0;i total + itemMonthTotal(item.id), 0); +} +function currentPeriodKey(){ + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } function pointDayState(day){ let total = 0; let positive = false; let negative = false; - for(let i=0;i 0) positive = true; if(points < 0) negative = true; @@ -337,6 +439,12 @@ async function speak(t, button){ button.innerHTML = buttonContent("alert", message); button.title = message.startsWith("未检测到系统语音") ? voiceHelp : message; button.dataset.speechFailure = "true"; + const errorCode = message.includes("超时") ? "timeout" : message.includes("下载") ? "download_failed" : "synthesis_failed"; + void queueGrowthActivity(ACTIVITY_EVENT_TYPES.TTS_FAILED, { + source: "offline_tts", + error_code: errorCode, + retryable: true, + }, `${errorCode}:${Date.now()}`); showSpeechGuide(); window.setTimeout(() => { if (button.dataset.speechFailure === "true") restore(); @@ -856,6 +964,74 @@ function renderGrow(){ main.appendChild(card); card.querySelector(".progressbar i").style.width = Math.min(100,total/30*100)+"%"; + const balance = getBalance(growthLoopSnapshot); + const rewards = window.growthLoop?.getRewards?.() || []; + const pendingRedemptions = growthLoopSnapshot.redemptions.filter((item) => item.status === "pending").length; + const rewardCards = rewards.map((reward) => { + const cost = Number(reward.cost_points || 0); + const latest = growthLoopSnapshot.redemptions + .filter((item) => item.reward_id === reward.id) + .sort((left, right) => String(right.created_at || "").localeCompare(String(left.created_at || "")))[0]; + const status = latest?.status === "pending" ? "待联网确认" : latest?.status === "fulfilled" ? "已兑现" : ""; + return `
+
${icon(reward.icon_key || "gift")}
+
${escapeHtml(reward.name)}${escapeHtml(reward.description || "家长和孩子一起约定")}
+ ${cost}分 + +
`; + }).join(""); + const rewardCard = $(`
+

${icon("gift")} 奖励兑换

+
+
${balance}
当前可用积分
+
${pendingRedemptions}
待联网确认
+
+
离线兑换会先记为“待联网确认”,联网并完成服务端确认前不代表最终成功。
+
+ + + +
+
${rewardCards || '
还没有奖励,先添加一个约定吧。
'}
+
`); + main.appendChild(rewardCard); + rewardCard.querySelector("#rewardForm").onsubmit = async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const name = String(form.get("name") || "").trim(); + const cost = Number(form.get("cost")); + if (!name || !Number.isInteger(cost) || cost < 1 || cost > 100000) { + alert("请填写奖励名称,并输入 1 到 100000 的整数积分。"); + return; + } + try { + await window.growthLoop.createReward({ + request_id: clientRequestId("reward"), + reward: { name, description: "家庭约定奖励", cost_points: cost, category: "family", icon_key: "gift" }, + }); + window.cloudSync?.scheduleGrowthLoop?.(); + renderGrow(); + } catch (error) { + console.error("Growth Loop reward creation failed:", error); + alert("奖励没有保存成功,请稍后重试。"); + } + }; + rewardCard.querySelectorAll("[data-reward-id]").forEach((button) => { + button.onclick = async () => { + const requestId = clientRequestId("redemption"); + button.disabled = true; + const result = await window.growthLoop.redeemReward({ reward_id: button.dataset.rewardId, request_id: requestId }); + if (result.error) { + button.disabled = false; + alert(result.error === "insufficient_points" ? "积分还不够,继续积累后再兑换吧。" : "这个奖励暂时不能兑换,请刷新后重试。"); + return; + } + void queueGrowthActivity(ACTIVITY_EVENT_TYPES.REWARD_REDEEMED, { source: "reward" }, requestId); + window.cloudSync?.scheduleGrowthLoop?.(); + renderGrow(); + }; + }); + // 日历式最近记录 let cells=""; for(let i=29;i>=0;i--){ @@ -892,18 +1068,20 @@ function renderGrow(){ /* 积分卡片辅助 */ const PTS_ICON = {"一起做家务":"house","认真完成学习":"bookCheck","帮带带弟弟":"learner","古诗词跟读":"bookMarked","撒谎":"circleX","白天摸当众摸鸡鸡":"alert","不收玩具":"eraser"}; let PT_DAY = null; -function ptsCardHTML(it, i, day){ - const done = pointOn(i, day); - const sub = it.pts < 0; - const ptsIcon = PTS_ICON[it.name] || (sub ? "alert" : "star"); +function ptsCardHTML(it, day){ + const done = pointOn(it.id, day); + const points = Number(it.default_points ?? it.pts ?? 0); + const description = it.description ?? it.desc ?? ""; + const sub = points < 0; + const ptsIcon = it.icon_key || PTS_ICON[it.name] || (sub ? "alert" : "star"); return `
${icon(ptsIcon)}
-
${it.name}
- ${it.desc?`
${it.desc}
`:""} +
${escapeHtml(it.name)}
+ ${description?`
${escapeHtml(description)}
`:""}
-
${it.pts>0?'+':'-'}${Math.abs(it.pts)}分
- +
${points>0?'+':'-'}${Math.abs(points)}分
+
`; } @@ -919,7 +1097,8 @@ function renderPoints(){ if(PT_DAY===null || PT_DAY > daysInMonth) PT_DAY = Math.min(today, daysInMonth); const activeDay = PT_DAY; const mt = monthTotal(); - const dt = dayTotal(activeDay); + const dt = getPointDayTotal(growthLoopSnapshot, dateKeyForDay(activeDay)); + const pointItems = visiblePointItems(); // 挖掘机主题横幅 main.appendChild($(`
@@ -967,20 +1146,70 @@ function renderPoints(){
${icon("checkCircle")} 为 ${new Date().getMonth()+1}月${activeDay}日打卡 ${isToday?'今天':''}
${isToday?"":``}
${icon("plus")} 加分项
- ${POINT_ITEMS.filter(it=>it.group==="加分项").map((it)=>ptsCardHTML(it,POINT_ITEMS.indexOf(it),activeDay)).join("")} + ${pointItems.filter((it) => Number(it.default_points ?? it.pts) > 0).map((it)=>ptsCardHTML(it,activeDay)).join("")}
${icon("minus")} 减分项目
- ${POINT_ITEMS.filter(it=>it.group==="减分项目").map((it)=>ptsCardHTML(it,POINT_ITEMS.indexOf(it),activeDay)).join("")} + ${pointItems.filter((it) => Number(it.default_points ?? it.pts) < 0).map((it)=>ptsCardHTML(it,activeDay)).join("")}
`); main.appendChild(head); - head.querySelectorAll(".pts-toggle").forEach(b=>b.onclick=()=>{ togglePoint(+b.dataset.i, activeDay); renderPoints(); }); + head.querySelectorAll(".pts-toggle").forEach((button)=>{ + button.onclick=()=>{ + togglePoint(button.dataset.itemId, activeDay); + button.disabled = true; + }; + }); const bt = el("backtoday"); if(bt) bt.onclick=()=>{PT_DAY=today; renderPoints();}; - // 清空 - main.appendChild($(`
`)); - el("ptclear").onclick=()=>{ - if(confirm("确定清空本月所有积分打卡记录?")){ - store = transitionLearningState(store, { type: "POINTS_CLEARED", month: ymKey() }); - save(); PT_DAY=today; renderPoints(); + const customCard = $(`
+

${icon("pencil")} 自定义积分项

+
把成长任务纳入积分管理:正数是加分,负数是扣分;每个孩子可以有自己的分值。
+
+ + + +
+
`); + main.appendChild(customCard); + customCard.querySelector("#pointItemForm").onsubmit = async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const name = String(form.get("name") || "").trim(); + const points = Number(form.get("points")); + if (!name || !Number.isInteger(points) || points === 0 || Math.abs(points) > 1000) { + alert("请填写名称,并输入 1 到 1000 的整数分值(可填负数)。"); + return; + } + try { + await window.growthLoop.createPointItem({ + request_id: clientRequestId("point-item"), + item: { + name, + description: "自定义成长任务", + default_points: points, + category: "growth", + icon_key: points > 0 ? "star" : "alert", + item_kind: "custom", + }, + }); + window.cloudSync?.scheduleGrowthLoop?.(); + renderPoints(); + } catch (error) { + console.error("Growth Loop custom point item creation failed:", error); + alert("积分项没有保存成功,请稍后重试。"); + } + }; + + // 结束当前积分周期:保留历史,通过不可变的反向调整归零当前月。 + main.appendChild($(`
不会删除历史记录,会追加反向调整,让本月重新开始。
`)); + el("ptclear").onclick=async()=>{ + if(confirm("确定结束本月积分周期?历史记录会保留,但本月积分会归零。")){ + try { + await window.growthLoop.closePeriod({ period_key: currentPeriodKey(), request_id: clientRequestId("period-close") }); + window.cloudSync?.scheduleGrowthLoop?.(); + PT_DAY=today; renderPoints(); + } catch (error) { + console.error("Growth Loop point period close failed:", error); + alert("积分周期没有结束成功,请稍后重试。"); + } } }; main.appendChild($(``)); @@ -1195,7 +1424,6 @@ function checkinBtn(mod,label){ /* ========================================================= 导航切换 ========================================================= */ -let CURRENT_MOD = "home"; function switchMod(mod){ CURRENT_MOD = mod; document.querySelectorAll(".navbtn").forEach(b=>{ @@ -1261,19 +1489,41 @@ window.learningDesk = { getState(){ return JSON.parse(JSON.stringify(store)); }, + getEnvelope(){ + return structuredClone({ + ...learningEnvelope, + schema_version: 2, + product_id: "shadow-mate", + learning: store, + }); + }, + async setScope(scope, options = {}){ + return setLearningScope(scope, options); + }, + getPendingState(){ + const pending = loadLearningStateEnvelope(localStorage, {}); + return structuredClone(pending); + }, replaceState(next, options = {}){ - store = transitionLearningState(store, { type: "STATE_REPLACED", state: next }); - if(options.persist) localStorage.setItem(STORE_KEY, JSON.stringify(store)); + store = transitionLearningState(store, { type: "STATE_REPLACED", state: learningStateFromEnvelope(next) }); + if(options.persist) persistLearningState(); switchMod(CURRENT_MOD); }, clearLocalData(){ const { reload = true } = arguments[0] || {}; localStorage.removeItem(STORE_KEY); + localStorage.removeItem(getLearningStateStorageKey({})); + localStorage.removeItem(getLearningStateStorageKey(learningEnvelope.scope || {})); if (reload) { window.location.reload(); return; } + learningEnvelope = loadLearningStateEnvelope(localStorage, {}); store = createLearningState(); switchMod(CURRENT_MOD); } }; + +void growthLoopController.hydrate().catch((error) => { + console.warn("Growth Loop 本地数据库初始化失败,已保持只读推荐项:", error); +}); diff --git a/src/cloud.js b/src/cloud.js index 1af1b97..ad499b0 100644 --- a/src/cloud.js +++ b/src/cloud.js @@ -3,6 +3,11 @@ import { CLOUD_CONFIG } from "./config.js"; import { escapeHtml, formatAuthError, formatCloudError, passwordStrength, stateHasData, mergeObjects, mergeState, latestUpdatedAt, GRADE_OPTIONS, gradeLabel, gradeOptionsSelected } from "./lib.js"; import { runLockedAction } from "./action-lock.js"; import { icon } from "./icons.js"; +import { buildCloudSavePayload, normalizeCloudLearningState } from "./learning-cloud-state.js"; +import { createGrowthLoopTransport, fetchGrowthLoopSnapshot } from "./learning-growth-cloud.js"; +import { buildHouseholdExport } from "./learning-export.js"; +import { ACTIVITY_EVENT_TYPES, activityEventIdFor } from "./learning-analytics.js"; +import { mergeGrowthLoopSnapshot } from "./learning-growth-loop.js"; const PRODUCT_ID = CLOUD_CONFIG.productId; const AUTH_PRODUCT_NAME = "影伴 Shadow Mate"; @@ -29,6 +34,7 @@ const supabase = cloudEnabled }, }) : null; +const growthLoopTransport = cloudEnabled ? createGrowthLoopTransport({ client: supabase }) : null; let session = null; let memberships = []; @@ -51,6 +57,7 @@ let localResetInProgress = false; let lastAuthSessionKey = null; let passwordRecoveryActive = false; let passwordStatusCheckedForSession = null; +let growthLoopSyncTimer = null; const accountButton = document.querySelector("#accountButton"); const dialog = document.querySelector("#cloudDialog"); @@ -88,6 +95,67 @@ function showToast(message, duration = 2800) { toastTimer = setTimeout(hideToast, duration); } +async function loadGrowthLoopProfile(profile, { adoptPending = false } = {}) { + if (!profile || !window.growthLoop || !window.learningDesk) return; + const scope = { household_id: profile.household_id, profile_id: profile.id }; + await window.growthLoop.loadScope(scope, { adoptPending }); + await queueGrowthCloudActivity(profile, ACTIVITY_EVENT_TYPES.HOUSEHOLD_ACTIVATED, {}, "once"); + if (!growthLoopTransport) return; + const remote = await fetchGrowthLoopSnapshot(supabase, { + householdId: profile.household_id, + profileId: profile.id, + }); + if (window.growthLoop.getScope().profile_id !== profile.id) return; + // A not-yet-migrated environment must not overwrite local records with an + // empty fallback snapshot. Release-time migrations enable this path. + if (remote.errors.length) return; + await window.growthLoop.mergeRemote(remote.snapshot); + if (window.growthLoop.getScope().profile_id !== profile.id) return; + const report = await window.growthLoop.sync({ transport: growthLoopTransport }); + if (report.retryable || report.conflict || report.rejected) { + await queueGrowthCloudActivity(profile, ACTIVITY_EVENT_TYPES.SYNC_FAILED, { + source: "growth_loop_sync", + error_code: report.conflict ? "conflict" : report.rejected ? "rejected" : "retryable", + retryable: Boolean(report.retryable), + }, `sync:${new Date().toISOString().slice(0, 13)}`); + scheduleGrowthLoopSync(); + } +} + +async function queueGrowthCloudActivity(profile, event_type, payload = {}, bucket = "once", { ensureScope = false } = {}) { + if (!profile || !window.growthLoop) return null; + const scope = { household_id: profile.household_id, profile_id: profile.id }; + if (!scope.household_id || !scope.profile_id) return null; + try { + const currentScope = window.growthLoop.getScope(); + if (currentScope.household_id !== scope.household_id || currentScope.profile_id !== scope.profile_id) { + if (!ensureScope) return null; + await window.growthLoop.loadScope(scope, { adoptPending: false }); + } + if (window.growthLoop.getScope().profile_id !== scope.profile_id) return null; + const event = await window.growthLoop.queueActivity({ + event_type, + event_id: activityEventIdFor({ ...scope, event_type, bucket }), + payload, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + client_version: document.documentElement.dataset.version || null, + }); + return event; + } catch (error) { + console.warn("Growth Loop cloud activity event deferred:", error); + return null; + } +} + +function scheduleGrowthLoopSync() { + clearTimeout(growthLoopSyncTimer); + growthLoopSyncTimer = setTimeout(() => { + if (activeProfile) void loadGrowthLoopProfile(activeProfile).catch((error) => { + console.warn("Growth Loop cloud sync deferred:", error); + }); + }, 500); +} + function guardianConsentField() { return `