721 lines
20 KiB
JavaScript
721 lines
20 KiB
JavaScript
/**
|
||
* 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<object[]>}
|
||
*/
|
||
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;
|
||
}
|