/** * Derives currentGrowingPosition, currentWithering, currentLeaves, currentFlowers, currentFruiting, and currentGrowingPhase from lifecycle + time. * Biennial: weeks since plantedAt vs phase offsets; each phase lerps growth and withering like position (first phase withering starts from 0). * Perennial: fractional week in [0, 52) from UTC Jan 1; phases use [startWeek, endWeek) on that ring (wrap allowed). Within each segment, interpolate from the prior phase (t=0 at startWeek) to the current phase’s targets (t=1 at endWeek), snapping to p exactly near endWeek. */ import { Patch } from "../db/models/index.js"; import { getLogger } from "../logger.js"; import { formatClientPatches, sanitizeLifecycleForClient } from "./patches.js"; const logger = getLogger("PatchGrowingState"); const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; const MAX_CAL_WEEK = 52; const DEFAULT_POSITION = 0; /** Baseline withering at timeline start (matches first-phase withering lerp origin). */ const DEFAULT_WITHERING = 0; const MIN_PHASE_GROWING = 0; const MAX_PHASE_GROWING = 100; const MIN_PHASE_LEAVES = 0; const MAX_PHASE_LEAVES = 6; const MIN_PHASE_FLOWERS = 0; const MAX_PHASE_FLOWERS = 3; const DEFAULT_LEAVES = 0; const DEFAULT_FLOWERS = 0; const DEFAULT_FRUITING = 0; function growingPositionFromPhase(ph) { const v = ph?.growingPosition ?? ph?.growing_position; const n = Number(v); if (!Number.isFinite(n)) return 0; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } function witheringFromPhase(ph) { const v = ph?.withering ?? ph?.withering_position; const n = Number(v); if (!Number.isFinite(n)) return DEFAULT_WITHERING; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } function clampPosition(n) { if (!Number.isFinite(n)) return DEFAULT_POSITION; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } function clampWithering(n) { if (!Number.isFinite(n)) return DEFAULT_WITHERING; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } function leavesFromPhase(ph) { const v = ph?.leaves ?? ph?.leaf_count; const n = Number(v); if (!Number.isFinite(n)) return DEFAULT_LEAVES; return Math.min( MAX_PHASE_LEAVES, Math.max(MIN_PHASE_LEAVES, Math.round(n)), ); } function flowersFromPhase(ph) { const v = ph?.flowers ?? ph?.flower_count; const n = Number(v); if (!Number.isFinite(n)) return DEFAULT_FLOWERS; return Math.min( MAX_PHASE_FLOWERS, Math.max(MIN_PHASE_FLOWERS, Math.round(n)), ); } function fruitingFromPhase(ph) { const v = ph?.fruiting; const n = Number(v); if (!Number.isFinite(n)) return DEFAULT_FRUITING; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } function clampLeaves(n) { if (!Number.isFinite(n)) return DEFAULT_LEAVES; return Math.min( MAX_PHASE_LEAVES, Math.max(MIN_PHASE_LEAVES, Math.round(n)), ); } function clampFlowers(n) { if (!Number.isFinite(n)) return DEFAULT_FLOWERS; return Math.min( MAX_PHASE_FLOWERS, Math.max(MIN_PHASE_FLOWERS, Math.round(n)), ); } function clampFruiting(n) { if (!Number.isFinite(n)) return DEFAULT_FRUITING; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } /** * Fractional week index on the annual ring [0, 52): 0 = UTC Jan 1 00:00:00, * 52 ≡ 0 (so late December continues smoothly into cross-year phases like 48→8). */ function perennialFractionalWeekInYearRing(now) { const y = now.getUTCFullYear(); const yearStartUtcMs = Date.UTC(y, 0, 1, 0, 0, 0, 0); let wf = (now.getTime() - yearStartUtcMs) / MS_PER_WEEK; wf %= MAX_CAL_WEEK; if (wf < 0) wf += MAX_CAL_WEEK; return wf; } /** Phase covers [start, end) on the 0..52 ring; end < start means wrap (e.g. 48→8). */ function phaseContainsFractionalWeek(wf, start, end) { const s = Math.floor(Number(start)) || 0; const e = Math.floor(Number(end)) || 0; if (e >= s) return wf >= s && wf < e; return wf >= s || wf < e; } function perennialPhaseSpanWeeks(start, end) { const s = Math.floor(Number(start)) || 0; const e = Math.floor(Number(end)) || 0; if (e >= s) return Math.max(1, e - s); return Math.max(1, MAX_CAL_WEEK - s + e); } /** Weeks (float) elapsed from segment start to `wf` within the phase window. */ function fractionalIntoPerennialPhase(wf, start, end) { const s = Math.floor(Number(start)) || 0; const e = Math.floor(Number(end)) || 0; if (e >= s) return wf - s; if (wf >= s) return wf - s; return MAX_CAL_WEEK - s + wf; } /** Fractional weeks from `wf` to segment end (exclusive); →0 as wf → end⁻. */ function fractionalWeeksRemainingInPhase(wf, start, end) { const s = Math.floor(Number(start)) || 0; const e = Math.floor(Number(end)) || 0; if (e >= s) return e - wf; if (wf >= s) return MAX_CAL_WEEK - wf + e; return e - wf; } /** * @param {object} patchLean — Patch document (lean) * @param {Date} [now] * @returns {{ currentGrowingPosition: number, currentWithering: number, currentLeaves: number, currentFlowers: number, currentFruiting: number, currentGrowingPhase: { name: string, index: number } | null }} */ function patchLogLabel(patchLean) { if (patchLean?.patchId != null && patchLean.patchId !== "") { return String(patchLean.patchId); } if (patchLean?._id != null) return String(patchLean._id); return "(unknown patch)"; } export function computePatchGrowingState(patchLean, now = new Date()) { const lcRaw = patchLean?.lifecycle; const lc = sanitizeLifecycleForClient(lcRaw); const label = patchLogLabel(patchLean); logger.trace( "computePatchGrowingState:", label, "now", now.toISOString(), "lifecycleType", lc?.type ?? "(none)", "phaseCount", lc?.phases?.length ?? 0, ); if (!lc?.phases?.length) { logger.trace("computePatchGrowingState: no phases → default", label); return { currentGrowingPosition: DEFAULT_POSITION, currentWithering: DEFAULT_WITHERING, currentLeaves: DEFAULT_LEAVES, currentFlowers: DEFAULT_FLOWERS, currentFruiting: DEFAULT_FRUITING, currentGrowingPhase: null, }; } const phases = lc.phases; if (lc.type === "biennial") { return computeBiennialGrowingState( phases, lc.durationWeeks, patchLean?.plantedAt ? new Date(patchLean.plantedAt) : null, now, label, ); } if (lc.type === "perennial") { return computePerennialGrowingState(phases, now, label); } logger.trace( "computePatchGrowingState: unknown lifecycle type → default", label, lc.type, ); return { currentGrowingPosition: DEFAULT_POSITION, currentWithering: DEFAULT_WITHERING, currentLeaves: DEFAULT_LEAVES, currentFlowers: DEFAULT_FLOWERS, currentFruiting: DEFAULT_FRUITING, currentGrowingPhase: null, }; } /** * @param {object[]} phases * @param {number} durationWeeks * @param {Date | null} plantedAt * @param {Date} now * @param {string} label */ function computeBiennialGrowingState(phases, durationWeeks, plantedAt, now, label) { if (!plantedAt || Number.isNaN(plantedAt.getTime())) { const p = phases[0]; logger.trace( "biennial: missing/invalid plantedAt → phase 0", label, "plantedAt", plantedAt, ); return { currentGrowingPosition: DEFAULT_POSITION, currentWithering: DEFAULT_WITHERING, currentLeaves: DEFAULT_LEAVES, currentFlowers: DEFAULT_FLOWERS, currentFruiting: DEFAULT_FRUITING, currentGrowingPhase: { name: typeof p?.name === "string" ? p.name : "phase", index: 0, }, }; } const elapsedMs = now.getTime() - plantedAt.getTime(); const elapsedWeeksFloat = elapsedMs >= 0 ? elapsedMs / MS_PER_WEEK : 0; let elapsedWeeks = Math.floor(elapsedWeeksFloat); if (elapsedWeeks < 0) elapsedWeeks = 0; const dur = Math.max(1, Math.floor(Number(durationWeeks)) || 1); logger.trace( "biennial:", label, "plantedAt", plantedAt.toISOString(), "elapsedWeeks", elapsedWeeks, "elapsedWeeksFloat", elapsedWeeksFloat.toFixed(4), "durationWeeks", dur, ); if (elapsedWeeks >= dur) { const last = phases[phases.length - 1]; logger.trace( "biennial: cycle complete → last phase", label, "phaseIndex", phases.length - 1, ); return { currentGrowingPosition: growingPositionFromPhase(last), currentWithering: witheringFromPhase(last), currentLeaves: leavesFromPhase(last), currentFlowers: flowersFromPhase(last), currentFruiting: fruitingFromPhase(last), currentGrowingPhase: { name: typeof last?.name === "string" ? last.name : "phase", index: phases.length - 1, }, }; } for (let i = 0; i < phases.length; i++) { const p = phases[i]; const start = p.startOffsetWeeks ?? 0; const end = p.endOffsetWeeks ?? start; if (elapsedWeeks >= start && elapsedWeeks < end) { const span = Math.max(1, end - start); const u = Math.min(1, Math.max(0, (elapsedWeeksFloat - start) / span)); const fromGp = i === 0 ? 0 : growingPositionFromPhase(phases[i - 1]); const toGp = growingPositionFromPhase(p); const position = clampPosition(fromGp + u * (toGp - fromGp)); const fromW = i === 0 ? DEFAULT_WITHERING : witheringFromPhase(phases[i - 1]); const toW = witheringFromPhase(p); const withering = clampWithering(fromW + u * (toW - fromW)); const fromL = i === 0 ? DEFAULT_LEAVES : leavesFromPhase(phases[i - 1]); const toL = leavesFromPhase(p); const leaves = clampLeaves(fromL + u * (toL - fromL)); const fromF = i === 0 ? DEFAULT_FLOWERS : flowersFromPhase(phases[i - 1]); const toF = flowersFromPhase(p); const flowers = clampFlowers(fromF + u * (toF - fromF)); const fromFr = i === 0 ? DEFAULT_FRUITING : fruitingFromPhase(phases[i - 1]); const toFr = fruitingFromPhase(p); const fruiting = clampFruiting(fromFr + u * (toFr - fromFr)); logger.trace( "biennial: in-phase lerp", label, "phaseIndex", i, "weekRange", `${start}–${end}`, "u", u.toFixed(4), "position", position, "withering", withering, ); return { currentGrowingPosition: position, currentWithering: withering, currentLeaves: leaves, currentFlowers: flowers, currentFruiting: fruiting, currentGrowingPhase: { name: typeof p?.name === "string" ? p.name : "phase", index: i, }, }; } } const firstStart = phases[0]?.startOffsetWeeks ?? 0; if (elapsedWeeks < firstStart) { const p = phases[0]; logger.trace( "biennial: before first phase window → phase 0", label, "elapsedWeeks", elapsedWeeks, "firstStart", firstStart, ); return { currentGrowingPosition: DEFAULT_POSITION, currentWithering: DEFAULT_WITHERING, currentLeaves: DEFAULT_LEAVES, currentFlowers: DEFAULT_FLOWERS, currentFruiting: DEFAULT_FRUITING, currentGrowingPhase: { name: typeof p?.name === "string" ? p.name : "phase", index: 0, }, }; } const last = phases[phases.length - 1]; logger.trace( "biennial: fallback → last phase", label, "elapsedWeeks", elapsedWeeks, ); return { currentGrowingPosition: growingPositionFromPhase(last), currentWithering: witheringFromPhase(last), currentLeaves: leavesFromPhase(last), currentFlowers: flowersFromPhase(last), currentFruiting: fruitingFromPhase(last), currentGrowingPhase: { name: typeof last?.name === "string" ? last.name : "phase", index: phases.length - 1, }, }; } /** * @param {object[]} phases * @param {Date} now * @param {string} label */ function computePerennialGrowingState(phases, now, label) { const wf = perennialFractionalWeekInYearRing(now); logger.trace( "perennial:", label, "yearWeekFloat", wf.toFixed(4), "now", now.toISOString(), ); for (let i = 0; i < phases.length; i++) { const p = phases[i]; const start = p.startWeek ?? 0; const end = p.endWeek ?? 0; if (!phaseContainsFractionalWeek(wf, start, end)) continue; const span = perennialPhaseSpanWeeks(start, end); const into = fractionalIntoPerennialPhase(wf, start, end); const remaining = fractionalWeeksRemainingInPhase(wf, start, end); // [startWeek, endWeek): lerp from prior phase (t=0 at start) → this phase's targets (t=1 at end). // Snap t=1 near endWeek so values match p exactly (open end otherwise never reaches t=1). const EPS_WEEKS = 1e-4; let t = span > 0 ? into / span : 1; if (remaining <= EPS_WEEKS) t = 1; t = Math.min(1, Math.max(0, t)); const prev = phases[(i - 1 + phases.length) % phases.length]; const fromGp = growingPositionFromPhase(prev); const toGp = growingPositionFromPhase(p); const position = clampPosition(fromGp + t * (toGp - fromGp)); const fromW = witheringFromPhase(prev); const toW = witheringFromPhase(p); const withering = clampWithering(fromW + t * (toW - fromW)); const fromL = leavesFromPhase(prev); const toL = leavesFromPhase(p); const leaves = clampLeaves(fromL + t * (toL - fromL)); const fromF = flowersFromPhase(prev); const toF = flowersFromPhase(p); const flowers = clampFlowers(fromF + t * (toF - fromF)); const fromFr = fruitingFromPhase(prev); const toFr = fruitingFromPhase(p); const fruiting = clampFruiting(fromFr + t * (toFr - fromFr)); logger.trace( "perennial: matched phase", label, "phaseIndex", i, "weekRange", `${start}–${end}`, "into", into.toFixed(4), "span", span, "t", t.toFixed(4), "position", position, "withering", withering, ); return { currentGrowingPosition: position, currentWithering: withering, currentLeaves: leaves, currentFlowers: flowers, currentFruiting: fruiting, currentGrowingPhase: { name: typeof p?.name === "string" ? p.name : "phase", index: i, }, }; } const p = phases[0]; logger.trace( "perennial: no week match → phase 0", label, "yearWeekFloat", wf.toFixed(4), ); return { currentGrowingPosition: growingPositionFromPhase(p), currentWithering: witheringFromPhase(p), currentLeaves: leavesFromPhase(p), currentFlowers: flowersFromPhase(p), currentFruiting: fruitingFromPhase(p), currentGrowingPhase: { name: typeof p?.name === "string" ? p.name : "phase", index: 0, }, }; } function growingStateChanged(prev, next) { if ( (prev?.currentGrowingPhase == null) !== (next.currentGrowingPhase == null) ) { return true; } if (prev?.currentGrowingPosition !== next.currentGrowingPosition) return true; if (prev?.currentWithering !== next.currentWithering) return true; if (prev?.currentLeaves !== next.currentLeaves) return true; if (prev?.currentFlowers !== next.currentFlowers) return true; if (prev?.currentFruiting !== next.currentFruiting) return true; if (next.currentGrowingPhase == null) return false; const pp = prev?.currentGrowingPhase; if (!pp) return true; return ( pp.name !== next.currentGrowingPhase.name || pp.index !== next.currentGrowingPhase.index ); } const populateImages = { path: "images", select: "blurHash contentType", }; /** * Persists computed fields on one patch by Mongo _id. * @param {import("mongoose").Types.ObjectId | string} patchMongoId * @param {Date} [now] * @returns {Promise<{ patch: object | null, changed: boolean }>} */ export async function recalculateAndPersistOnePatchByMongoId( patchMongoId, now = new Date(), ) { const idStr = String(patchMongoId); logger.debug("recalculateAndPersistOnePatchByMongoId:", idStr); const doc = await Patch.findById(patchMongoId) .populate(populateImages) .lean(); if (!doc) { logger.debug("recalculateAndPersistOnePatchByMongoId: not found", idStr); return { patch: null, changed: false }; } const label = patchLogLabel(doc); logger.trace( "recalculateAndPersistOnePatch: loaded", label, "mongoId", idStr, ); const next = computePatchGrowingState(doc, now); const prev = { currentGrowingPosition: doc.currentGrowingPosition, currentWithering: doc.currentWithering, currentLeaves: doc.currentLeaves, currentFlowers: doc.currentFlowers, currentFruiting: doc.currentFruiting, currentGrowingPhase: doc.currentGrowingPhase ?? null, }; logger.trace( "recalculateAndPersistOnePatch: compare", label, "prevPosition", prev.currentGrowingPosition, "nextPosition", next.currentGrowingPosition, "prevWithering", prev.currentWithering, "nextWithering", next.currentWithering, "prevLeaves", prev.currentLeaves, "nextLeaves", next.currentLeaves, "prevFlowers", prev.currentFlowers, "nextFlowers", next.currentFlowers, "prevFruiting", prev.currentFruiting, "nextFruiting", next.currentFruiting, "prevPhase", prev.currentGrowingPhase?.name, prev.currentGrowingPhase?.index, "nextPhase", next.currentGrowingPhase?.name, next.currentGrowingPhase?.index, ); if (!growingStateChanged(prev, next)) { logger.trace("recalculateAndPersistOnePatch: unchanged", label); return { patch: formatClientPatches([doc])[0], changed: false }; } const updated = await Patch.findByIdAndUpdate( patchMongoId, { $set: { currentGrowingPosition: next.currentGrowingPosition, currentWithering: next.currentWithering, currentLeaves: next.currentLeaves, currentFlowers: next.currentFlowers, currentFruiting: next.currentFruiting, currentGrowingPhase: next.currentGrowingPhase, }, }, { new: true }, ) .populate(populateImages) .lean(); if (!updated) { logger.warn( "recalculateAndPersistOnePatch: update returned null", idStr, label, ); return { patch: null, changed: false }; } logger.debug( "recalculateAndPersistOnePatch: persisted", label, "position", next.currentGrowingPosition, "withering", next.currentWithering, "leaves", next.currentLeaves, "flowers", next.currentFlowers, "fruiting", next.currentFruiting, "phase", next.currentGrowingPhase?.name, "phaseIndex", next.currentGrowingPhase?.index, ); return { patch: formatClientPatches([updated])[0], changed: true }; } /** * @param {import("mongoose").Types.ObjectId} deviceId * @param {string} patchId * @param {Date} [now] */ export async function recalculateGrowingStateForLogicalPatch( deviceId, patchId, now = new Date(), ) { logger.debug( "recalculateGrowingStateForLogicalPatch:", String(deviceId), patchId, ); const doc = await Patch.findOne({ deviceId, patchId }).select("_id").lean(); if (!doc) { logger.trace( "recalculateGrowingStateForLogicalPatch: no document", String(deviceId), patchId, ); return { patch: null, changed: false }; } logger.trace( "recalculateGrowingStateForLogicalPatch: resolved mongoId", String(doc._id), ); return recalculateAndPersistOnePatchByMongoId(doc._id, now); } /** * Recalculates every patch. Returns formatted client patches that changed (for socket push). * @param {Date} [now] * @returns {Promise} */ export async function recalculateAllPatchesGrowingState(now = new Date()) { const ids = await Patch.find({}).select("_id").lean(); logger.debug( "recalculateAllPatchesGrowingState: starting", ids.length, "patches", "now", now.toISOString(), ); const changed = []; for (const row of ids) { try { const { patch, changed: did } = await recalculateAndPersistOnePatchByMongoId(row._id, now); if (did && patch) changed.push(patch); } catch (e) { logger.error("recalculate patch", row._id, e.message ?? e); } } logger.debug( "recalculateAllPatchesGrowingState: done", changed.length, "updated", ); return changed; }