@runnerpro/backend 1.21.12 → 1.21.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cjs/achievements/catalog.js +204 -0
- package/lib/cjs/achievements/index.js +805 -0
- package/lib/cjs/achievements/streak.js +126 -0
- package/lib/cjs/chat/api/conversation.js +21 -29
- package/lib/cjs/chat/exposed/conversation.js +0 -7
- package/lib/cjs/chat/index.js +0 -7
- package/lib/cjs/index.js +34 -5
- package/lib/cjs/locale/en.js +0 -4
- package/lib/cjs/locale/es.js +0 -4
- package/lib/cjs/locale/fr.js +0 -4
- package/lib/cjs/locale/it.js +0 -4
- package/lib/cjs/prompt/azureModel.js +7 -35
- package/lib/cjs/prompt/constants.js +12 -67
- package/lib/cjs/prompt/googleModel.js +6 -11
- package/lib/cjs/prompt/index.js +1 -4
- package/lib/cjs/prompt/modelPricing.js +1 -22
- package/lib/cjs/sendMail/index.js +1 -1
- package/lib/cjs/sendNotification/index.js +4 -88
- package/lib/cjs/types/achievements/catalog.d.ts +104 -0
- package/lib/cjs/types/achievements/catalog.d.ts.map +1 -0
- package/lib/cjs/types/achievements/index.d.ts +41 -0
- package/lib/cjs/types/achievements/index.d.ts.map +1 -0
- package/lib/cjs/types/achievements/streak.d.ts +20 -0
- package/lib/cjs/types/achievements/streak.d.ts.map +1 -0
- package/lib/cjs/types/chat/api/conversation.d.ts.map +1 -1
- package/lib/cjs/types/chat/exposed/conversation.d.ts.map +1 -1
- package/lib/cjs/types/chat/index.d.ts.map +1 -1
- package/lib/cjs/types/index.d.ts +4 -2
- package/lib/cjs/types/index.d.ts.map +1 -1
- package/lib/cjs/types/locale/en.d.ts +0 -4
- package/lib/cjs/types/locale/en.d.ts.map +1 -1
- package/lib/cjs/types/locale/es.d.ts +0 -4
- package/lib/cjs/types/locale/es.d.ts.map +1 -1
- package/lib/cjs/types/locale/fr.d.ts +0 -4
- package/lib/cjs/types/locale/fr.d.ts.map +1 -1
- package/lib/cjs/types/locale/it.d.ts +0 -4
- package/lib/cjs/types/locale/it.d.ts.map +1 -1
- package/lib/cjs/types/prompt/azureModel.d.ts.map +1 -1
- package/lib/cjs/types/prompt/constants.d.ts +7 -28
- package/lib/cjs/types/prompt/constants.d.ts.map +1 -1
- package/lib/cjs/types/prompt/googleModel.d.ts.map +1 -1
- package/lib/cjs/types/prompt/index.d.ts +2 -2
- package/lib/cjs/types/prompt/index.d.ts.map +1 -1
- package/lib/cjs/types/prompt/modelPricing.d.ts.map +1 -1
- package/lib/cjs/types/sendNotification/index.d.ts +1 -20
- package/lib/cjs/types/sendNotification/index.d.ts.map +1 -1
- package/lib/cjs/types/workout/planificacionPrueba7dias/index.d.ts.map +1 -1
- package/lib/cjs/types/workout/saveWorkoutAplication.d.ts.map +1 -1
- package/lib/cjs/workout/planificacionPrueba7dias/index.js +7 -3
- package/lib/cjs/workout/saveWorkoutAplication.js +10 -0
- package/package.json +2 -2
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.getUnlockedAchievements = exports.evaluateAchievementsBounded = exports.evaluateAchievements = exports.computeWorkoutEfforts = exports.computeStreakStats = void 0;
|
|
16
|
+
const db_1 = require("../db");
|
|
17
|
+
const moment_1 = __importDefault(require("moment"));
|
|
18
|
+
const streak_1 = require("./streak");
|
|
19
|
+
// Re-export para que los consumidores del paquete (p. ej. Cliente-backend
|
|
20
|
+
// progress) usen el MISMO motor de racha sin duplicar la lógica.
|
|
21
|
+
var streak_2 = require("./streak");
|
|
22
|
+
Object.defineProperty(exports, "computeStreakStats", { enumerable: true, get: function () { return streak_2.computeStreakStats; } });
|
|
23
|
+
const catalog_1 = require("./catalog");
|
|
24
|
+
// Mapa inverso badge → metros (para leer los PRs guardados en PARAMS).
|
|
25
|
+
const BADGE_PR_DISTANCE = {};
|
|
26
|
+
for (const meters of Object.keys(catalog_1.PR_DISTANCE_BADGE)) {
|
|
27
|
+
BADGE_PR_DISTANCE[catalog_1.PR_DISTANCE_BADGE[Number(meters)]] = Number(meters);
|
|
28
|
+
}
|
|
29
|
+
const typesList = (types) => types.map((t) => `'${t}'`).join(',');
|
|
30
|
+
const countScalar = (sql, params) => __awaiter(void 0, void 0, void 0, function* () {
|
|
31
|
+
const [row] = yield (0, db_1.query)(sql, params);
|
|
32
|
+
return Number(row === null || row === void 0 ? void 0 : row.c) || 0;
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* Ancla "empezar de cero": solo cuentan los workouts con DATE >= fecha de alta
|
|
36
|
+
* (CLIENTE."DATE INITIAL FORM"). Evita que el import histórico de Strava/Garmin
|
|
37
|
+
* desbloquee logros retroactivos.
|
|
38
|
+
*/
|
|
39
|
+
const getAnchorDate = (userId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
40
|
+
const [row] = yield (0, db_1.query)('SELECT "DATE INITIAL FORM"::date AS "ANCHOR" FROM "CLIENTE" WHERE "ID" = ?', [userId]);
|
|
41
|
+
return (row === null || row === void 0 ? void 0 : row.anchor) || null;
|
|
42
|
+
});
|
|
43
|
+
/** Todos los agregados por tipo en una sola pasada (DISTANCE/DESNIVEL en metros). */
|
|
44
|
+
const getAllAggregates = (userId, sinceDate) => __awaiter(void 0, void 0, void 0, function* () {
|
|
45
|
+
const params = [userId];
|
|
46
|
+
let sinceFilter = '';
|
|
47
|
+
if (sinceDate) {
|
|
48
|
+
sinceFilter = ' AND "DATE"::date >= ?::date';
|
|
49
|
+
params.push(sinceDate);
|
|
50
|
+
}
|
|
51
|
+
const runList = typesList(catalog_1.RUNNING_TYPES);
|
|
52
|
+
const [row] = yield (0, db_1.query)(`SELECT
|
|
53
|
+
COALESCE(MAX("DISTANCE") FILTER (WHERE "TYPE" IN (${runList})), 0) AS "RUN MAX",
|
|
54
|
+
COALESCE(SUM("DISTANCE") FILTER (WHERE "TYPE" IN (${runList})), 0) AS "RUN TOTAL",
|
|
55
|
+
COUNT(*) FILTER (WHERE "TYPE" = 'FUERZA') AS "STR COUNT",
|
|
56
|
+
COALESCE(MAX("DISTANCE") FILTER (WHERE "TYPE" = 'BIKE'), 0) AS "BIKE MAX",
|
|
57
|
+
COALESCE(SUM("DISTANCE") FILTER (WHERE "TYPE" = 'BIKE'), 0) AS "BIKE TOTAL",
|
|
58
|
+
COALESCE(MAX("DISTANCE") FILTER (WHERE "TYPE" = 'SWIM'), 0) AS "SWIM MAX",
|
|
59
|
+
COUNT(*) FILTER (WHERE "TYPE" = 'SWIM') AS "SWIM COUNT",
|
|
60
|
+
COALESCE(MAX("DISTANCE") FILTER (WHERE "TYPE" = 'TRAIL'), 0) AS "TRAIL MAX",
|
|
61
|
+
COALESCE(SUM("DESNIVEL") FILTER (WHERE "TYPE" = 'TRAIL'), 0) AS "TRAIL DESNIVEL",
|
|
62
|
+
COUNT(*) FILTER (WHERE "TYPE" = 'COMPETICION') AS "RACE COUNT",
|
|
63
|
+
COUNT(*) FILTER (WHERE "FC MEDIA" IS NOT NULL) AS "HR COUNT"
|
|
64
|
+
FROM "WORKOUT"
|
|
65
|
+
WHERE "ID CLIENTE" = ? AND "DONE" = true${sinceFilter}`, params);
|
|
66
|
+
return {
|
|
67
|
+
runMax: Number(row === null || row === void 0 ? void 0 : row.runMax) || 0,
|
|
68
|
+
runTotal: Number(row === null || row === void 0 ? void 0 : row.runTotal) || 0,
|
|
69
|
+
strCount: Number(row === null || row === void 0 ? void 0 : row.strCount) || 0,
|
|
70
|
+
bikeMax: Number(row === null || row === void 0 ? void 0 : row.bikeMax) || 0,
|
|
71
|
+
bikeTotal: Number(row === null || row === void 0 ? void 0 : row.bikeTotal) || 0,
|
|
72
|
+
swimMax: Number(row === null || row === void 0 ? void 0 : row.swimMax) || 0,
|
|
73
|
+
swimCount: Number(row === null || row === void 0 ? void 0 : row.swimCount) || 0,
|
|
74
|
+
trailMax: Number(row === null || row === void 0 ? void 0 : row.trailMax) || 0,
|
|
75
|
+
trailDesnivel: Number(row === null || row === void 0 ? void 0 : row.trailDesnivel) || 0,
|
|
76
|
+
raceCount: Number(row === null || row === void 0 ? void 0 : row.raceCount) || 0,
|
|
77
|
+
hrCount: Number(row === null || row === void 0 ? void 0 : row.hrCount) || 0,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
const getRaceDates = (userId, sinceDate) => __awaiter(void 0, void 0, void 0, function* () {
|
|
81
|
+
const params = [userId];
|
|
82
|
+
let sinceFilter = '';
|
|
83
|
+
if (sinceDate) {
|
|
84
|
+
sinceFilter = ' AND "DATE"::date >= ?::date';
|
|
85
|
+
params.push(sinceDate);
|
|
86
|
+
}
|
|
87
|
+
const rows = yield (0, db_1.query)(`SELECT "DATE"::date AS "DATE"
|
|
88
|
+
FROM "WORKOUT"
|
|
89
|
+
WHERE "ID CLIENTE" = ? AND "DONE" = true AND "TYPE" = 'COMPETICION'${sinceFilter}
|
|
90
|
+
ORDER BY "DATE" ASC`, params);
|
|
91
|
+
return rows.map((r) => r.date);
|
|
92
|
+
});
|
|
93
|
+
// ── Best-effort sobre streams ───────────────────────────────────────────────
|
|
94
|
+
const buildSeries = (distRaw, timeRaw) => {
|
|
95
|
+
const dist = [];
|
|
96
|
+
const time = [];
|
|
97
|
+
const n = Math.min((distRaw === null || distRaw === void 0 ? void 0 : distRaw.length) || 0, (timeRaw === null || timeRaw === void 0 ? void 0 : timeRaw.length) || 0);
|
|
98
|
+
let lastD = -Infinity;
|
|
99
|
+
for (let i = 0; i < n; i++) {
|
|
100
|
+
const d = Number(distRaw[i]);
|
|
101
|
+
const t = Number(timeRaw[i]);
|
|
102
|
+
if (!Number.isFinite(d) || !Number.isFinite(t))
|
|
103
|
+
continue;
|
|
104
|
+
if (d < lastD)
|
|
105
|
+
continue;
|
|
106
|
+
dist.push(d);
|
|
107
|
+
time.push(t);
|
|
108
|
+
lastD = d;
|
|
109
|
+
}
|
|
110
|
+
return { dist, time };
|
|
111
|
+
};
|
|
112
|
+
/** Segmento continuo más rápido que cubre al menos `target` metros. */
|
|
113
|
+
const bestEffortTime = (dist, time, target) => {
|
|
114
|
+
const n = dist.length;
|
|
115
|
+
if (n < 2)
|
|
116
|
+
return null;
|
|
117
|
+
if (dist[n - 1] - dist[0] < target)
|
|
118
|
+
return null;
|
|
119
|
+
let best = Infinity;
|
|
120
|
+
let j = 0;
|
|
121
|
+
for (let i = 0; i < n; i++) {
|
|
122
|
+
if (j < i)
|
|
123
|
+
j = i;
|
|
124
|
+
while (j < n && dist[j] - dist[i] < target)
|
|
125
|
+
j++;
|
|
126
|
+
if (j >= n)
|
|
127
|
+
break;
|
|
128
|
+
const t = time[j] - time[i];
|
|
129
|
+
if (t > 0 && t < best)
|
|
130
|
+
best = t;
|
|
131
|
+
}
|
|
132
|
+
return best === Infinity ? null : Math.round(best);
|
|
133
|
+
};
|
|
134
|
+
/** Tiempos candidatos por distancia para un entreno (stream → best-effort; sin stream → híbrido). */
|
|
135
|
+
// Exportado para tests unitarios (no forma parte de la API pública del paquete).
|
|
136
|
+
const computeWorkoutEfforts = (workout, series) => {
|
|
137
|
+
var _a;
|
|
138
|
+
const result = {};
|
|
139
|
+
const hasStream = series && series.dist.length >= 2 && series.time.length >= 2;
|
|
140
|
+
if (hasStream) {
|
|
141
|
+
for (const target of catalog_1.PR_DISTANCES) {
|
|
142
|
+
const t = bestEffortTime(series.dist, series.time, target);
|
|
143
|
+
if (t !== null)
|
|
144
|
+
result[target] = { time: t, esBestEffort: true };
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
const dist = Number(workout.distance) || 0;
|
|
149
|
+
const dur = Number(workout.duration) || 0;
|
|
150
|
+
if (dur <= 0)
|
|
151
|
+
return result;
|
|
152
|
+
for (const target of catalog_1.PR_DISTANCES) {
|
|
153
|
+
// Entrada manual: media/maratón se guardan redondeadas (21000/42000), justo
|
|
154
|
+
// por debajo del oficial. PR_MANUAL_FLOOR baja el suelo a la distancia
|
|
155
|
+
// redondeada para esos casos; el resto mantiene suelo = distancia oficial.
|
|
156
|
+
const floor = (_a = catalog_1.PR_MANUAL_FLOOR[target]) !== null && _a !== void 0 ? _a : target;
|
|
157
|
+
if (dist >= floor && dist <= target * catalog_1.PR_NO_STREAM_MARGIN) {
|
|
158
|
+
result[target] = { time: dur, esBestEffort: false };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
};
|
|
163
|
+
exports.computeWorkoutEfforts = computeWorkoutEfforts;
|
|
164
|
+
const fetchStreams = (ids) => __awaiter(void 0, void 0, void 0, function* () {
|
|
165
|
+
const byWorkout = {};
|
|
166
|
+
if (!ids.length)
|
|
167
|
+
return byWorkout;
|
|
168
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
169
|
+
const streamRows = yield (0, db_1.query)(`SELECT "ID WORKOUT" AS "ID WORKOUT", "METRIC TYPE", "DATA"
|
|
170
|
+
FROM "WORKOUT STREAM"
|
|
171
|
+
WHERE "ID WORKOUT" IN (${placeholders}) AND "METRIC TYPE" IN ('distance','time')`, ids);
|
|
172
|
+
for (const r of streamRows) {
|
|
173
|
+
if (!byWorkout[r.idWorkout])
|
|
174
|
+
byWorkout[r.idWorkout] = {};
|
|
175
|
+
byWorkout[r.idWorkout][r.metricType] = r.data;
|
|
176
|
+
}
|
|
177
|
+
return byWorkout;
|
|
178
|
+
});
|
|
179
|
+
/** Mejor tiempo por distancia entre un conjunto de entrenos. */
|
|
180
|
+
const computeBest = (workouts, streamsByWorkout) => {
|
|
181
|
+
const best = {};
|
|
182
|
+
for (const w of workouts) {
|
|
183
|
+
const raw = streamsByWorkout[w.id];
|
|
184
|
+
const series = raw ? buildSeries(raw.distance, raw.time) : null;
|
|
185
|
+
const efforts = (0, exports.computeWorkoutEfforts)(w, series);
|
|
186
|
+
for (const distStr of Object.keys(efforts)) {
|
|
187
|
+
const d = Number(distStr);
|
|
188
|
+
const eff = efforts[distStr];
|
|
189
|
+
if (!best[d] || eff.time < best[d].time) {
|
|
190
|
+
best[d] = { time: eff.time, workoutId: w.id, date: w.date, esBestEffort: eff.esBestEffort };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return best;
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Guarda los PRs en los PARAMS del logro de distancia correspondiente, con
|
|
198
|
+
* "solo mejora" ATÓMICO (un solo UPDATE/INSERT por distancia, sin leer-modificar
|
|
199
|
+
* -escribir en JS → race-safe). Si la fila no existe, la inserta (cubrir esa
|
|
200
|
+
* distancia desbloquea el logro, así que es correcto unlockearlo aquí).
|
|
201
|
+
*/
|
|
202
|
+
const applyPRs = (userId, best, markSeen = false) => __awaiter(void 0, void 0, void 0, function* () {
|
|
203
|
+
const nuevos = [];
|
|
204
|
+
// markSeen: igual que en persistLogros — backfill inserta "FECHA VISTO"=now()
|
|
205
|
+
// (sin celebración retroactiva), evento en vivo NULL (lo recoge pendingCongrats).
|
|
206
|
+
// En el DO UPDATE (mejora de PR sobre un dist_* YA desbloqueado) NO se toca
|
|
207
|
+
// "FECHA VISTO": no se re-celebra un récord mejorado. Literal seguro.
|
|
208
|
+
const seenSql = markSeen ? 'now()' : 'NULL';
|
|
209
|
+
for (const distStr of Object.keys(best)) {
|
|
210
|
+
const badgeId = catalog_1.PR_DISTANCE_BADGE[Number(distStr)];
|
|
211
|
+
if (!badgeId)
|
|
212
|
+
continue;
|
|
213
|
+
const b = best[distStr];
|
|
214
|
+
// prDate en formato ISO (YYYY-MM-DD), no el toString de Date con locale/timezone.
|
|
215
|
+
const prDate = b.date ? (0, moment_1.default)(b.date).format('YYYY-MM-DD') : null;
|
|
216
|
+
// RETURNING (xmax = 0) AS inserted: distingue una fila recién CREADA (desbloqueo
|
|
217
|
+
// nuevo de este dist_*) de una ACTUALIZADA (solo mejora de PR sobre un badge ya
|
|
218
|
+
// desbloqueado). Solo las creadas se notifican como recién desbloqueadas.
|
|
219
|
+
const rows = yield (0, db_1.query)(`INSERT INTO "CLIENTE LOGRO" ("ID CLIENTE", "LOGRO ID", "PARAMS", "FECHA VISTO")
|
|
220
|
+
VALUES (?, ?, jsonb_build_object('prTime', ?::numeric, 'prWorkout', ?::int, 'prDate', ?::text, 'prBestEffort', ?::boolean), ${seenSql})
|
|
221
|
+
ON CONFLICT ("ID CLIENTE", "LOGRO ID") DO UPDATE
|
|
222
|
+
SET "PARAMS" = COALESCE("CLIENTE LOGRO"."PARAMS", '{}'::jsonb) || EXCLUDED."PARAMS"
|
|
223
|
+
WHERE "CLIENTE LOGRO"."PARAMS" IS NULL
|
|
224
|
+
OR "CLIENTE LOGRO"."PARAMS"->>'prTime' IS NULL
|
|
225
|
+
OR ("CLIENTE LOGRO"."PARAMS"->>'prTime')::numeric > (EXCLUDED."PARAMS"->>'prTime')::numeric
|
|
226
|
+
RETURNING "LOGRO ID" AS "ID", "FECHA DESBLOQUEO", (xmax = 0) AS "INSERTED"`, [userId, badgeId, b.time, b.workoutId, prDate, !!b.esBestEffort]);
|
|
227
|
+
for (const r of rows) {
|
|
228
|
+
// El wrapper query() puede devolver el boolean como true/'t'; normalizamos.
|
|
229
|
+
const inserted = r.inserted === true || r.inserted === 't';
|
|
230
|
+
if (!inserted)
|
|
231
|
+
continue;
|
|
232
|
+
nuevos.push({
|
|
233
|
+
id: r.id,
|
|
234
|
+
fechaDesbloqueo: r.fechaDesbloqueo,
|
|
235
|
+
params: {
|
|
236
|
+
prTime: Number(b.time),
|
|
237
|
+
prWorkout: b.workoutId,
|
|
238
|
+
prDate,
|
|
239
|
+
prBestEffort: !!b.esBestEffort,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return nuevos;
|
|
245
|
+
});
|
|
246
|
+
/** Lee el mapa de PRs {metros: segundos} desde los PARAMS de los logros dist_*. */
|
|
247
|
+
const getPrMap = (userId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
248
|
+
const badgeIds = Object.values(catalog_1.PR_DISTANCE_BADGE);
|
|
249
|
+
const placeholders = badgeIds.map(() => '?').join(',');
|
|
250
|
+
const rows = yield (0, db_1.query)(`SELECT "LOGRO ID" AS "ID", ("PARAMS"->>'prTime')::numeric AS "PR TIME"
|
|
251
|
+
FROM "CLIENTE LOGRO"
|
|
252
|
+
WHERE "ID CLIENTE" = ? AND "LOGRO ID" IN (${placeholders}) AND "PARAMS"->>'prTime' IS NOT NULL`, [userId, ...badgeIds]);
|
|
253
|
+
const prMap = {};
|
|
254
|
+
for (const r of rows) {
|
|
255
|
+
const dist = BADGE_PR_DISTANCE[r.id];
|
|
256
|
+
if (dist)
|
|
257
|
+
prMap[dist] = Number(r.prTime);
|
|
258
|
+
}
|
|
259
|
+
return prMap;
|
|
260
|
+
});
|
|
261
|
+
const RUN_SELECT = 'SELECT "ID" AS "ID", "DISTANCE", "DURATION", "DATE"::date AS "DATE" FROM "WORKOUT"';
|
|
262
|
+
/** PR de UN entreno (evento): procesa solo ese entreno y actualiza sus PRs.
|
|
263
|
+
* Devuelve los dist_* recién desbloqueados por este entreno (con su PR en params). */
|
|
264
|
+
const processWorkoutPR = (userId, anchor, workoutId, markSeen = false) => __awaiter(void 0, void 0, void 0, function* () {
|
|
265
|
+
const params = [userId, workoutId];
|
|
266
|
+
let sinceFilter = '';
|
|
267
|
+
if (anchor) {
|
|
268
|
+
sinceFilter = ' AND "DATE"::date >= ?::date';
|
|
269
|
+
params.push(anchor);
|
|
270
|
+
}
|
|
271
|
+
const workouts = yield (0, db_1.query)(`${RUN_SELECT}
|
|
272
|
+
WHERE "ID CLIENTE" = ? AND "ID" = ? AND "DONE" = true
|
|
273
|
+
AND "TYPE" IN (${typesList(catalog_1.RUNNING_TYPES)}) AND "DISTANCE" >= 1000${sinceFilter}`, params);
|
|
274
|
+
if (!workouts.length)
|
|
275
|
+
return [];
|
|
276
|
+
const streams = yield fetchStreams(workouts.map((w) => w.id));
|
|
277
|
+
return applyPRs(userId, computeBest(workouts, streams), markSeen);
|
|
278
|
+
});
|
|
279
|
+
/** PR desde cero (backfill): procesa todos los entrenos de carrera desde el ancla.
|
|
280
|
+
* Devuelve los dist_* recién desbloqueados (con su PR en params). */
|
|
281
|
+
const recomputeAllPRs = (userId, anchor, markSeen = false) => __awaiter(void 0, void 0, void 0, function* () {
|
|
282
|
+
const params = [userId];
|
|
283
|
+
let sinceFilter = '';
|
|
284
|
+
if (anchor) {
|
|
285
|
+
sinceFilter = ' AND "DATE"::date >= ?::date';
|
|
286
|
+
params.push(anchor);
|
|
287
|
+
}
|
|
288
|
+
const workouts = yield (0, db_1.query)(`${RUN_SELECT}
|
|
289
|
+
WHERE "ID CLIENTE" = ? AND "DONE" = true
|
|
290
|
+
AND "TYPE" IN (${typesList(catalog_1.RUNNING_TYPES)}) AND "DISTANCE" >= 1000${sinceFilter}`, params);
|
|
291
|
+
if (!workouts.length)
|
|
292
|
+
return [];
|
|
293
|
+
const streams = yield fetchStreams(workouts.map((w) => w.id));
|
|
294
|
+
return applyPRs(userId, computeBest(workouts, streams), markSeen);
|
|
295
|
+
});
|
|
296
|
+
/**
|
|
297
|
+
* Persiste logros (filas) en una sola query, idempotente (no revoca).
|
|
298
|
+
* Devuelve SOLO los recién insertados (RETURNING + ON CONFLICT DO NOTHING),
|
|
299
|
+
* para que el caller pueda notificar al FE qué se acaba de desbloquear.
|
|
300
|
+
*/
|
|
301
|
+
const persistLogros = (userId, ids, markSeen = false) => __awaiter(void 0, void 0, void 0, function* () {
|
|
302
|
+
const uniq = Array.from(new Set(ids));
|
|
303
|
+
if (!uniq.length)
|
|
304
|
+
return [];
|
|
305
|
+
// markSeen: en backfill (event='all') se inserta "FECHA VISTO"=now() para NO
|
|
306
|
+
// celebrar logros retroactivos; en eventos en vivo se deja NULL ("no visto")
|
|
307
|
+
// para que el FE lo recoja en pendingCongrats y muestre la celebración (A2).
|
|
308
|
+
// Literal seguro (no entra input de usuario).
|
|
309
|
+
const seenSql = markSeen ? 'now()' : 'NULL';
|
|
310
|
+
const valuesSql = uniq.map(() => `(?, ?, ${seenSql})`).join(', ');
|
|
311
|
+
const params = [];
|
|
312
|
+
for (const id of uniq)
|
|
313
|
+
params.push(userId, id);
|
|
314
|
+
const rows = yield (0, db_1.query)(`INSERT INTO "CLIENTE LOGRO" ("ID CLIENTE", "LOGRO ID", "FECHA VISTO")
|
|
315
|
+
VALUES ${valuesSql}
|
|
316
|
+
ON CONFLICT ("ID CLIENTE", "LOGRO ID") DO NOTHING
|
|
317
|
+
RETURNING "LOGRO ID" AS "ID", "FECHA DESBLOQUEO"`, params);
|
|
318
|
+
return rows.map((r) => ({ id: r.id, fechaDesbloqueo: r.fechaDesbloqueo }));
|
|
319
|
+
});
|
|
320
|
+
// ── Reglas por categoría ────────────────────────────────────────────────────
|
|
321
|
+
const evaluateConsistency = (longestWeeks) => catalog_1.CONSISTENCY.filter((c) => longestWeeks >= c.weeks).map((c) => c.id);
|
|
322
|
+
const evaluateDistance = (maxMeters) => catalog_1.DISTANCE.filter((d) => maxMeters >= d.meters).map((d) => d.id);
|
|
323
|
+
const evaluateVolume = (totalMeters) => catalog_1.VOLUME.filter((v) => totalMeters >= v.km * 1000).map((v) => v.id);
|
|
324
|
+
const evaluateStrength = (count) => catalog_1.STRENGTH_COUNT.filter((s) => count >= s.count).map((s) => s.id);
|
|
325
|
+
const evaluateCycling = (agg) => [
|
|
326
|
+
...catalog_1.CYCLING_DISTANCE.filter((d) => agg.bikeMax >= d.meters).map((d) => d.id),
|
|
327
|
+
...catalog_1.CYCLING_VOLUME.filter((v) => agg.bikeTotal >= v.km * 1000).map((v) => v.id),
|
|
328
|
+
];
|
|
329
|
+
const evaluateSwim = (agg) => catalog_1.SWIM_DISTANCE.filter((d) => agg.swimMax >= d.meters).map((d) => d.id);
|
|
330
|
+
const evaluateTriSwim = (agg) => [
|
|
331
|
+
...(agg.swimCount >= 1 ? ['tri_first_swim'] : []),
|
|
332
|
+
...catalog_1.TRI_SWIM_DISTANCE.filter((d) => agg.swimMax >= d.meters).map((d) => d.id),
|
|
333
|
+
];
|
|
334
|
+
const evaluateTrail = (agg) => [
|
|
335
|
+
...catalog_1.TRAIL_DISTANCE.filter((d) => agg.trailMax >= d.meters).map((d) => d.id),
|
|
336
|
+
...catalog_1.TRAIL_ELEVATION.filter((e) => agg.trailDesnivel >= e.meters).map((e) => e.id),
|
|
337
|
+
];
|
|
338
|
+
const has3RacesIn365 = (raceDates) => {
|
|
339
|
+
if (raceDates.length < 3)
|
|
340
|
+
return false;
|
|
341
|
+
const sorted = raceDates.map((d) => new Date(d).getTime()).sort((a, b) => a - b);
|
|
342
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
343
|
+
for (let i = 0; i + 2 < sorted.length; i++) {
|
|
344
|
+
if (sorted[i + 2] - sorted[i] <= 365 * DAY)
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
};
|
|
349
|
+
const evaluateRaces = (count, raceDates) => {
|
|
350
|
+
const ids = catalog_1.RACE_COUNT.filter((r) => count >= r.count).map((r) => r.id);
|
|
351
|
+
if (has3RacesIn365(raceDates))
|
|
352
|
+
ids.push('pc_3carreras_anio');
|
|
353
|
+
return ids;
|
|
354
|
+
};
|
|
355
|
+
const evaluatePace = (prMap) => {
|
|
356
|
+
const best1k = prMap[1000];
|
|
357
|
+
if (typeof best1k !== 'number')
|
|
358
|
+
return [];
|
|
359
|
+
return catalog_1.PACE.filter((p) => best1k <= p.maxSecPerKm).map((p) => p.id);
|
|
360
|
+
};
|
|
361
|
+
const evaluateTimePR = (prMap) => catalog_1.TIME_PR.filter((t) => typeof prMap[t.distance] === 'number' && prMap[t.distance] <= t.maxSeconds).map((t) => t.id);
|
|
362
|
+
const evalCount = (rules, count) => rules.filter((r) => count >= r.count).map((r) => r.id);
|
|
363
|
+
// ── Plan + Hitos del Plan ─────────────────────────────────────────────────
|
|
364
|
+
/**
|
|
365
|
+
* Plan activo del cliente: id, ventana (inicio/objetivo) y rangos de fecha de
|
|
366
|
+
* los 4 bloques (base/desarrollo/pico/taper). Devuelve null si no hay plan.
|
|
367
|
+
*/
|
|
368
|
+
const getPlanContext = (userId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
369
|
+
const [row] = yield (0, db_1.query)(`SELECT
|
|
370
|
+
"ID PLAN CARRERA" AS "PLAN ID",
|
|
371
|
+
"START DATE PLAN"::date AS "PLAN START",
|
|
372
|
+
"GOAL DATE"::date AS "PLAN GOAL",
|
|
373
|
+
"GOAL DISTANCE" AS "GOAL DIST",
|
|
374
|
+
"GOAL DURATION" AS "GOAL DUR",
|
|
375
|
+
"ALGORITMO BLOQUE 1 INICIO"::date AS "BASE INI",
|
|
376
|
+
"ALGORITMO BLOQUE 1 FIN"::date AS "BASE FIN",
|
|
377
|
+
"ALGORITMO BLOQUE 2 INICIO"::date AS "DESA INI",
|
|
378
|
+
"ALGORITMO BLOQUE 2 FIN"::date AS "DESA FIN",
|
|
379
|
+
"ALGORITMO BLOQUE 3 INICIO"::date AS "PICO INI",
|
|
380
|
+
"ALGORITMO BLOQUE 3 FIN"::date AS "PICO FIN",
|
|
381
|
+
"ALGORITMO BLOQUE 4 INICIO"::date AS "TAPER INI",
|
|
382
|
+
"ALGORITMO BLOQUE 4 FIN"::date AS "TAPER FIN"
|
|
383
|
+
FROM "CLIENTE" WHERE "ID" = ?`, [userId]);
|
|
384
|
+
return row || null;
|
|
385
|
+
});
|
|
386
|
+
/**
|
|
387
|
+
* Logros de Plan + Hitos del Plan (TIER 1, todos derivables ya):
|
|
388
|
+
* - plan_baseline: el cliente tiene un plan activo.
|
|
389
|
+
* - plan_82: adherencia ≥ 82%.
|
|
390
|
+
* - plan_completado: alcanzó GOAL DATE con adherencia ≥ 80%.
|
|
391
|
+
* - hito_base/desarrollo/pico/taper: bloque ya finalizado con adherencia ≥ 80%.
|
|
392
|
+
* - hito_semana_race: competición completada en la semana de GOAL DATE.
|
|
393
|
+
* - str_plan_full: plan terminado con el 100% de las sesiones de FUERZA hechas.
|
|
394
|
+
* - plan_tirada_larga / plan_ritmo_carrera.
|
|
395
|
+
* Adherencia = planificadas ("SHOW CLIENT"=true, tipo entrenable) completadas /
|
|
396
|
+
* DEBIDAS (DATE <= hoy), para no penalizar las sesiones futuras aún no vencidas.
|
|
397
|
+
*/
|
|
398
|
+
const evaluatePlan = (userId, anchor) => __awaiter(void 0, void 0, void 0, function* () {
|
|
399
|
+
const plan = yield getPlanContext(userId);
|
|
400
|
+
if (!plan || !plan.planId || !plan.planStart)
|
|
401
|
+
return [];
|
|
402
|
+
const fmt = (d) => (0, moment_1.default)(d).format('YYYY-MM-DD');
|
|
403
|
+
const today = (0, moment_1.default)().format('YYYY-MM-DD');
|
|
404
|
+
const ids = ['plan_baseline'];
|
|
405
|
+
const rows = yield (0, db_1.query)(`SELECT "DATE"::date AS "DATE", "DONE", "TYPE",
|
|
406
|
+
"DISTANCE", "DURATION",
|
|
407
|
+
"DISTANCE PLANNED" AS "DIST PLANNED", "DURATION PLANNED" AS "DUR PLANNED"
|
|
408
|
+
FROM "WORKOUT"
|
|
409
|
+
WHERE "ID CLIENTE" = ?
|
|
410
|
+
AND "SHOW CLIENT" = true
|
|
411
|
+
AND "TYPE" IN (${typesList(catalog_1.PLAN_SESSION_TYPES)})
|
|
412
|
+
AND "DATE"::date >= ?::date
|
|
413
|
+
AND "DATE"::date <= ?::date`, [userId, plan.planStart, today]);
|
|
414
|
+
const total = rows.length;
|
|
415
|
+
const adherence = total > 0 ? rows.filter((r) => r.done).length / total : 0;
|
|
416
|
+
if (total > 0 && adherence >= catalog_1.PLAN_ADHERENCE_82)
|
|
417
|
+
ids.push('plan_82');
|
|
418
|
+
const goal = plan.planGoal ? fmt(plan.planGoal) : null;
|
|
419
|
+
if (goal && today >= goal && total > 0 && adherence >= catalog_1.PLAN_COMPLETE_ADHERENCE) {
|
|
420
|
+
ids.push('plan_completado');
|
|
421
|
+
}
|
|
422
|
+
// str_plan_full: plan terminado con el 100% de las sesiones de FUERZA hechas.
|
|
423
|
+
if (goal && today >= goal) {
|
|
424
|
+
const strengthSessions = rows.filter((r) => r.type === 'FUERZA');
|
|
425
|
+
if (strengthSessions.length > 0 && strengthSessions.every((r) => r.done)) {
|
|
426
|
+
ids.push('str_plan_full');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Hitos de bloque: cada bloque YA finalizado con adherencia ≥80%.
|
|
430
|
+
const blocks = [
|
|
431
|
+
{ id: 'hito_base', ini: plan.baseIni, fin: plan.baseFin },
|
|
432
|
+
{ id: 'hito_desarrollo', ini: plan.desaIni, fin: plan.desaFin },
|
|
433
|
+
{ id: 'hito_pico', ini: plan.picoIni, fin: plan.picoFin },
|
|
434
|
+
{ id: 'hito_taper', ini: plan.taperIni, fin: plan.taperFin },
|
|
435
|
+
];
|
|
436
|
+
for (const b of blocks) {
|
|
437
|
+
if (!b.ini || !b.fin)
|
|
438
|
+
continue;
|
|
439
|
+
const finStr = fmt(b.fin);
|
|
440
|
+
if (today <= finStr)
|
|
441
|
+
continue; // bloque aún no terminado
|
|
442
|
+
const iniStr = fmt(b.ini);
|
|
443
|
+
const inBlock = rows.filter((r) => {
|
|
444
|
+
const d = fmt(r.date);
|
|
445
|
+
return d >= iniStr && d <= finStr;
|
|
446
|
+
});
|
|
447
|
+
if (inBlock.length > 0 && inBlock.filter((r) => r.done).length / inBlock.length >= catalog_1.PLAN_BLOCK_ADHERENCE) {
|
|
448
|
+
ids.push(b.id);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Semana de carrera: competición completada alrededor de GOAL DATE.
|
|
452
|
+
if (goal) {
|
|
453
|
+
const raceStart = (0, moment_1.default)(plan.planGoal).subtract(catalog_1.PLAN_RACE_WEEK_DAYS, 'days').format('YYYY-MM-DD');
|
|
454
|
+
const raceEnd = (0, moment_1.default)(plan.planGoal).add(1, 'days').format('YYYY-MM-DD');
|
|
455
|
+
const raceDone = yield countScalar(`SELECT COUNT(*) AS "C" FROM "WORKOUT"
|
|
456
|
+
WHERE "ID CLIENTE" = ? AND "TYPE" = 'COMPETICION' AND "DONE" = true
|
|
457
|
+
AND "DATE"::date BETWEEN ?::date AND ?::date`, [userId, raceStart, raceEnd]);
|
|
458
|
+
if (raceDone >= 1)
|
|
459
|
+
ids.push('hito_semana_race');
|
|
460
|
+
}
|
|
461
|
+
// plan_tirada_larga: la tirada larga (carrera prevista más larga de su semana)
|
|
462
|
+
// completada, si supera el mínimo de volumen.
|
|
463
|
+
const runRows = rows.filter((r) => catalog_1.PLAN_RUN_TYPES.includes(r.type));
|
|
464
|
+
const longestByWeek = {};
|
|
465
|
+
for (const r of runRows) {
|
|
466
|
+
const wk = (0, moment_1.default)(r.date).format('GGGG-WW'); // semana ISO + año
|
|
467
|
+
const planned = Number(r.distPlanned) || 0;
|
|
468
|
+
if (!longestByWeek[wk] || planned > (Number(longestByWeek[wk].distPlanned) || 0)) {
|
|
469
|
+
longestByWeek[wk] = r;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const longRunDone = Object.values(longestByWeek).some((r) => {
|
|
473
|
+
if (!r.done)
|
|
474
|
+
return false;
|
|
475
|
+
const dist = Number(r.distPlanned) || Number(r.distance) || 0;
|
|
476
|
+
const dur = Number(r.durPlanned) || Number(r.duration) || 0;
|
|
477
|
+
return dist >= catalog_1.PLAN_LONG_RUN_MIN_DISTANCE || dur >= catalog_1.PLAN_LONG_RUN_MIN_DURATION;
|
|
478
|
+
});
|
|
479
|
+
if (longRunDone)
|
|
480
|
+
ids.push('plan_tirada_larga');
|
|
481
|
+
// plan_ritmo_carrera: carrera planificada hecha cuyo ritmo medio (s/m) ≈ ritmo
|
|
482
|
+
// objetivo (GOAL DURATION/GOAL DISTANCE). Solo si el cliente fijó tiempo objetivo.
|
|
483
|
+
const goalDist = Number(plan.goalDist) || 0;
|
|
484
|
+
const goalDur = Number(plan.goalDur) || 0;
|
|
485
|
+
if (goalDist > 0 && goalDur > 0) {
|
|
486
|
+
const targetPace = goalDur / goalDist; // segundos por metro
|
|
487
|
+
const hitRacePace = runRows.some((r) => {
|
|
488
|
+
if (!r.done)
|
|
489
|
+
return false;
|
|
490
|
+
const dist = Number(r.distance) || 0;
|
|
491
|
+
const dur = Number(r.duration) || 0;
|
|
492
|
+
if (dist < catalog_1.PLAN_RACE_PACE_MIN_DISTANCE || dur <= 0)
|
|
493
|
+
return false;
|
|
494
|
+
return Math.abs(dur / dist - targetPace) / targetPace <= catalog_1.PLAN_RACE_PACE_TOLERANCE;
|
|
495
|
+
});
|
|
496
|
+
if (hitRacePace)
|
|
497
|
+
ids.push('plan_ritmo_carrera');
|
|
498
|
+
}
|
|
499
|
+
// pc_nuevo_capitulo: empezaste un nuevo objetivo tras cerrar una etapa = existe
|
|
500
|
+
// una COMPETICION completada (post-ancla) ANTERIOR al inicio del plan actual.
|
|
501
|
+
const prevRaceParams = [userId, plan.planStart];
|
|
502
|
+
let prevRaceAnchor = '';
|
|
503
|
+
if (anchor) {
|
|
504
|
+
prevRaceAnchor = ' AND "DATE"::date >= ?::date';
|
|
505
|
+
prevRaceParams.push(anchor);
|
|
506
|
+
}
|
|
507
|
+
const prevRaces = yield countScalar(`SELECT COUNT(*) AS "C" FROM "WORKOUT"
|
|
508
|
+
WHERE "ID CLIENTE" = ? AND "TYPE" = 'COMPETICION' AND "DONE" = true
|
|
509
|
+
AND "DATE"::date < ?::date${prevRaceAnchor}`, prevRaceParams);
|
|
510
|
+
if (prevRaces >= 1)
|
|
511
|
+
ids.push('pc_nuevo_capitulo');
|
|
512
|
+
return ids;
|
|
513
|
+
});
|
|
514
|
+
// ── Fuerza: frecuencia / hábito ──────────────────────────────────────────────
|
|
515
|
+
/** Fechas (moment) de las sesiones de FUERZA completadas desde el ancla, asc. */
|
|
516
|
+
const getStrengthDates = (userId, sinceDate) => __awaiter(void 0, void 0, void 0, function* () {
|
|
517
|
+
const params = [userId];
|
|
518
|
+
let sinceFilter = '';
|
|
519
|
+
if (sinceDate) {
|
|
520
|
+
sinceFilter = ' AND "DATE"::date >= ?::date';
|
|
521
|
+
params.push(sinceDate);
|
|
522
|
+
}
|
|
523
|
+
const rows = yield (0, db_1.query)(`SELECT "DATE"::date AS "DATE" FROM "WORKOUT"
|
|
524
|
+
WHERE "ID CLIENTE" = ? AND "TYPE" = 'FUERZA' AND "DONE" = true${sinceFilter}
|
|
525
|
+
ORDER BY "DATE" ASC`, params);
|
|
526
|
+
return rows.map((r) => (0, moment_1.default)(r.date));
|
|
527
|
+
});
|
|
528
|
+
/** ¿Hay ≥sessions sesiones dentro de una ventana de `days` días? (momentos asc) */
|
|
529
|
+
const hasSessionsInWindow = (moments, sessions, days) => {
|
|
530
|
+
for (let i = 0; i + sessions - 1 < moments.length; i++) {
|
|
531
|
+
if (moments[i + sessions - 1].diff(moments[i], 'days') <= days - 1)
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
};
|
|
536
|
+
/** ¿Hay ≥n semanas ISO consecutivas con al menos una sesión? (tolerante a DST) */
|
|
537
|
+
const hasConsecutiveWeeks = (moments, n) => {
|
|
538
|
+
const starts = [...new Map(moments.map((m) => {
|
|
539
|
+
const s = m.clone().startOf('isoWeek');
|
|
540
|
+
return [s.format('GGGG-[W]WW'), s];
|
|
541
|
+
})).values()].sort((a, b) => a.valueOf() - b.valueOf());
|
|
542
|
+
let run = 1;
|
|
543
|
+
for (let i = 1; i < starts.length; i++) {
|
|
544
|
+
const d = starts[i].diff(starts[i - 1], 'days');
|
|
545
|
+
run = d >= 6 && d <= 8 ? run + 1 : 1;
|
|
546
|
+
if (run >= n)
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
return false;
|
|
550
|
+
};
|
|
551
|
+
const evaluateStrengthFrequency = (moments) => {
|
|
552
|
+
const ids = catalog_1.STRENGTH_WINDOWS
|
|
553
|
+
.filter((w) => hasSessionsInWindow(moments, w.sessions, w.days))
|
|
554
|
+
.map((w) => w.id);
|
|
555
|
+
if (hasConsecutiveWeeks(moments, catalog_1.STRENGTH_HABIT_WEEKS))
|
|
556
|
+
ids.push('str_habito');
|
|
557
|
+
return ids;
|
|
558
|
+
};
|
|
559
|
+
// ── Triatlón: brick (bici + carrera el mismo día) ─────────────────────────────
|
|
560
|
+
const evaluateBrick = (userId, sinceDate) => __awaiter(void 0, void 0, void 0, function* () {
|
|
561
|
+
const params = [userId];
|
|
562
|
+
let sinceFilter = '';
|
|
563
|
+
if (sinceDate) {
|
|
564
|
+
sinceFilter = ' AND "DATE"::date >= ?::date';
|
|
565
|
+
params.push(sinceDate);
|
|
566
|
+
}
|
|
567
|
+
const c = yield countScalar(`SELECT COUNT(*) AS "C" FROM (
|
|
568
|
+
SELECT "DATE"::date AS d
|
|
569
|
+
FROM "WORKOUT"
|
|
570
|
+
WHERE "ID CLIENTE" = ? AND "DONE" = true AND "TYPE" IN (${typesList(catalog_1.BRICK_TYPES)})${sinceFilter}
|
|
571
|
+
GROUP BY "DATE"::date
|
|
572
|
+
HAVING COUNT(DISTINCT "TYPE") = 2
|
|
573
|
+
) t`, params);
|
|
574
|
+
return c >= 1 ? ['tri_brick'] : [];
|
|
575
|
+
});
|
|
576
|
+
// ── Post-carrera: recovery run tras competición ───────────────────────────────
|
|
577
|
+
const evaluateRecovery = (userId, sinceDate) => __awaiter(void 0, void 0, void 0, function* () {
|
|
578
|
+
const params = [userId];
|
|
579
|
+
let sinceR = '';
|
|
580
|
+
let sinceC = '';
|
|
581
|
+
if (sinceDate) {
|
|
582
|
+
sinceR = ' AND r."DATE"::date >= ?::date';
|
|
583
|
+
sinceC = ' AND c."DATE"::date >= ?::date';
|
|
584
|
+
params.push(sinceDate, sinceDate);
|
|
585
|
+
}
|
|
586
|
+
const c = yield countScalar(`SELECT COUNT(*) AS "C"
|
|
587
|
+
FROM "WORKOUT" r
|
|
588
|
+
WHERE r."ID CLIENTE" = ?
|
|
589
|
+
AND r."TYPE" = 'CORRER' AND r."DONE" = true AND r."ZONE PLANNED" = ${catalog_1.RECOVERY_RUN_ZONE}${sinceR}
|
|
590
|
+
AND EXISTS (
|
|
591
|
+
SELECT 1 FROM "WORKOUT" c
|
|
592
|
+
WHERE c."ID CLIENTE" = r."ID CLIENTE"
|
|
593
|
+
AND c."TYPE" = 'COMPETICION' AND c."DONE" = true${sinceC}
|
|
594
|
+
AND r."DATE"::date > c."DATE"::date
|
|
595
|
+
AND r."DATE"::date <= c."DATE"::date + (${catalog_1.RECOVERY_RUN_DAYS} * INTERVAL '1 day')
|
|
596
|
+
)`, params);
|
|
597
|
+
return c >= 1 ? ['pc_recovery'] : [];
|
|
598
|
+
});
|
|
599
|
+
/**
|
|
600
|
+
* coach_llamada: el cliente tiene una llamada programada cuya fecha ya llegó
|
|
601
|
+
* (CLIENTE."LLAMADA SCHEDULE"::date <= hoy). Es por TIEMPO (no hay acción de
|
|
602
|
+
* usuario que la dispare), así que se re-evalúa de forma oportunista en el
|
|
603
|
+
* evento 'workout' y en el backfill ('all').
|
|
604
|
+
*/
|
|
605
|
+
const evaluateCoachCall = (userId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
606
|
+
const c = yield countScalar('SELECT COUNT(*) AS "C" FROM "CLIENTE" WHERE "ID" = ? AND "LLAMADA SCHEDULE" IS NOT NULL AND "LLAMADA SCHEDULE"::date <= CURRENT_DATE', [userId]);
|
|
607
|
+
return c >= 1 ? ['coach_llamada'] : [];
|
|
608
|
+
});
|
|
609
|
+
/** Categorías no-workout (cada una su query de conteo). `event`='all' las corre todas. */
|
|
610
|
+
const evaluateNonWorkout = (userId, event) => __awaiter(void 0, void 0, void 0, function* () {
|
|
611
|
+
const want = (e) => event === 'all' || event === e;
|
|
612
|
+
const ids = [];
|
|
613
|
+
if (want('chat')) {
|
|
614
|
+
const c = yield countScalar('SELECT COUNT(*) AS "C" FROM "CHAT MESSAGE" WHERE "ID CLIENTE" = ? AND "ID SENDER" = ?', [userId, userId]);
|
|
615
|
+
ids.push(...evalCount(catalog_1.CHAT_MSG, c));
|
|
616
|
+
}
|
|
617
|
+
if (want('survey')) {
|
|
618
|
+
const s = yield countScalar('SELECT COUNT(*) AS "C" FROM "CLIENTE ENCUESTA" WHERE "ID CLIENTE" = ?', [userId]);
|
|
619
|
+
if (s >= 1)
|
|
620
|
+
ids.push('coach_encuesta');
|
|
621
|
+
}
|
|
622
|
+
if (want('availability')) {
|
|
623
|
+
// coach_ajuste_plan repurposado = "Editar Disponibilidad": filas en WORKOUT DISPONIBILIDAD EXCEPCION.
|
|
624
|
+
const c = yield countScalar('SELECT COUNT(*) AS "C" FROM "WORKOUT DISPONIBILIDAD EXCEPCION" WHERE "ID CLIENTE" = ?', [userId]);
|
|
625
|
+
if (c >= 1)
|
|
626
|
+
ids.push('coach_ajuste_plan');
|
|
627
|
+
}
|
|
628
|
+
if (want('nutrition')) {
|
|
629
|
+
const meals = yield countScalar('SELECT COUNT(*) AS "C" FROM "CLIENTE NUTRICION" WHERE "ID CLIENTE" = ?', [userId]);
|
|
630
|
+
const days = yield countScalar('SELECT COUNT(DISTINCT "DATE") AS "C" FROM "CLIENTE NUTRICION" WHERE "ID CLIENTE" = ?', [userId]);
|
|
631
|
+
ids.push(...evalCount(catalog_1.NUTRITION_MEALS, meals));
|
|
632
|
+
if (days >= catalog_1.NUTRITION_DAYS_THRESHOLD)
|
|
633
|
+
ids.push('nut_7d_plan');
|
|
634
|
+
}
|
|
635
|
+
// 'food_photo' (primera foto a la comida): se desbloquea AL ANALIZAR una foto
|
|
636
|
+
// de comida con éxito (POST /nutricion/analizar-foto). No persiste ninguna
|
|
637
|
+
// tabla que contar, así que se otorga por el EVENTO explícito — NO en backfill
|
|
638
|
+
// ('all'), que no puede saber retroactivamente si el usuario hizo una foto.
|
|
639
|
+
if (event === 'food_photo') {
|
|
640
|
+
ids.push('nut_foto_comida');
|
|
641
|
+
}
|
|
642
|
+
if (want('wearable')) {
|
|
643
|
+
const rows = yield (0, db_1.query)('SELECT DISTINCT "TIPO APLICATION" AS "TIPO" FROM "CLIENTE APLICACIONES" WHERE "ID CLIENTE" = ?', [userId]);
|
|
644
|
+
const tipoSet = new Set(rows.map((r) => Number(r.tipo)).filter((n) => Number.isFinite(n)));
|
|
645
|
+
ids.push(...catalog_1.WEARABLE_APPS.filter((w) => tipoSet.has(w.tipo)).map((w) => w.id));
|
|
646
|
+
}
|
|
647
|
+
return ids;
|
|
648
|
+
});
|
|
649
|
+
/**
|
|
650
|
+
* Evalúa los logros del usuario PARA UN EVENTO y persiste los desbloqueados
|
|
651
|
+
* (idempotente, no revoca). Se llama justo donde ocurre la condición:
|
|
652
|
+
* - 'workout' → al completar un entreno. opts.workoutId procesa SOLO ese
|
|
653
|
+
* entreno para los PRs (incremental); sin él, recalcula todos.
|
|
654
|
+
* - 'chat' | 'survey' | 'availability' | 'nutrition' | 'food_photo' | 'wearable'.
|
|
655
|
+
* - 'goal' → al añadir un objetivo (re-evalúa Plan, incl. pc_nuevo_capitulo).
|
|
656
|
+
* - 'all' → todo (backfill; recalcula los PRs desde cero).
|
|
657
|
+
* Nunca lanza por categorías aisladas (no debe romper el flujo que la invoca).
|
|
658
|
+
*
|
|
659
|
+
* Devuelve los logros RECIÉN desbloqueados en esta llamada: [{id, fechaDesbloqueo,
|
|
660
|
+
* params?}] (params solo en los dist_* con PR). Sirve para que el FE parchee su caché
|
|
661
|
+
* y suba el chip sin refetch. RETRO-COMPATIBLE: los callers que ignoran el retorno
|
|
662
|
+
* (backfill, postHooks de saveWorkoutAplication) siguen funcionando igual.
|
|
663
|
+
*/
|
|
664
|
+
const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(void 0, void 0, void 0, function* () {
|
|
665
|
+
if (!userId)
|
|
666
|
+
return [];
|
|
667
|
+
const anchor = yield getAnchorDate(userId);
|
|
668
|
+
const want = (e) => event === 'all' || event === e;
|
|
669
|
+
// Backfill ('all') marca como vistos al insertar (sin celebración retroactiva);
|
|
670
|
+
// los eventos en vivo dejan "FECHA VISTO"=NULL para que el FE los celebre (A2).
|
|
671
|
+
const markSeen = event === 'all';
|
|
672
|
+
const eligible = [];
|
|
673
|
+
// Recién desbloqueados que se devuelven al caller (para que el FE parchee su
|
|
674
|
+
// caché y suba el chip sin refetch). Los dist_* vienen de applyPRs CON params (PR);
|
|
675
|
+
// el resto de persistLogros SIN params. Se deduplica por id al final.
|
|
676
|
+
const nuevos = [];
|
|
677
|
+
if (want('workout')) {
|
|
678
|
+
// PRs (guardados en PARAMS de los dist_*). Aislado: streams no deben tumbar el resto.
|
|
679
|
+
try {
|
|
680
|
+
if (opts && opts.workoutId)
|
|
681
|
+
nuevos.push(...(yield processWorkoutPR(userId, anchor, opts.workoutId, markSeen)));
|
|
682
|
+
else
|
|
683
|
+
nuevos.push(...(yield recomputeAllPRs(userId, anchor, markSeen)));
|
|
684
|
+
}
|
|
685
|
+
catch (_) {
|
|
686
|
+
// ignore
|
|
687
|
+
}
|
|
688
|
+
const [streak, agg] = yield Promise.all([
|
|
689
|
+
(0, streak_1.computeStreakStats)(userId, anchor),
|
|
690
|
+
getAllAggregates(userId, anchor),
|
|
691
|
+
]);
|
|
692
|
+
// Racha MÁXIMA persistida (high-water mark, con ancla): se guarda aquí —en el
|
|
693
|
+
// evento de workout, donde ya tenemos `streak.longest` calculado— para que la
|
|
694
|
+
// lectura (chip/StreakSheet) sea un simple SELECT y se pueda eliminar
|
|
695
|
+
// /progress/overview. GREATEST = solo sube, nunca baja (no se revoca el récord).
|
|
696
|
+
// Aislado: si la columna aún no existe (migración 0004 sin aplicar) no rompe.
|
|
697
|
+
try {
|
|
698
|
+
yield (0, db_1.query)('UPDATE "CLIENTE" SET "RACHA MAXIMA" = GREATEST(COALESCE("RACHA MAXIMA", 0), ?) WHERE "ID" = ?', [streak.longest, userId]);
|
|
699
|
+
}
|
|
700
|
+
catch (_) {
|
|
701
|
+
// ignore
|
|
702
|
+
}
|
|
703
|
+
eligible.push(...evaluateConsistency(streak.longest), ...evaluateDistance(agg.runMax), ...evaluateVolume(agg.runTotal), ...evaluateStrength(agg.strCount), ...evaluateCycling(agg), ...evaluateSwim(agg), ...evaluateTriSwim(agg), ...evaluateTrail(agg));
|
|
704
|
+
const raceDates = agg.raceCount >= 3 ? yield getRaceDates(userId, anchor) : [];
|
|
705
|
+
eligible.push(...evaluateRaces(agg.raceCount, raceDates));
|
|
706
|
+
if (agg.hrCount >= 1)
|
|
707
|
+
eligible.push('wear_fc');
|
|
708
|
+
// Plan + Hitos, frecuencia de fuerza, brick, recovery y coach_llamada. Todas
|
|
709
|
+
// dependen de workouts (o se re-evalúan al completar uno). Cada grupo aislado:
|
|
710
|
+
// un fallo (p. ej. una columna de plan ausente) no tumba el resto.
|
|
711
|
+
try {
|
|
712
|
+
eligible.push(...(yield evaluatePlan(userId, anchor)));
|
|
713
|
+
}
|
|
714
|
+
catch (_) { /* ignore */ }
|
|
715
|
+
try {
|
|
716
|
+
const strengthDates = yield getStrengthDates(userId, anchor);
|
|
717
|
+
eligible.push(...evaluateStrengthFrequency(strengthDates));
|
|
718
|
+
}
|
|
719
|
+
catch (_) { /* ignore */ }
|
|
720
|
+
try {
|
|
721
|
+
eligible.push(...(yield evaluateBrick(userId, anchor)));
|
|
722
|
+
}
|
|
723
|
+
catch (_) { /* ignore */ }
|
|
724
|
+
try {
|
|
725
|
+
eligible.push(...(yield evaluateRecovery(userId, anchor)));
|
|
726
|
+
}
|
|
727
|
+
catch (_) { /* ignore */ }
|
|
728
|
+
try {
|
|
729
|
+
eligible.push(...(yield evaluateCoachCall(userId)));
|
|
730
|
+
}
|
|
731
|
+
catch (_) { /* ignore */ }
|
|
732
|
+
}
|
|
733
|
+
if (want('chat') || want('survey') || want('availability') || want('nutrition') || want('food_photo') || want('wearable')) {
|
|
734
|
+
try {
|
|
735
|
+
eligible.push(...(yield evaluateNonWorkout(userId, event)));
|
|
736
|
+
}
|
|
737
|
+
catch (_) {
|
|
738
|
+
// ignore
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// 'goal' (añadir objetivo) re-evalúa los logros de Plan (incl. pc_nuevo_capitulo).
|
|
742
|
+
// En 'all'/'workout' ya se cubre en el branch de workout; por eso aquí solo 'goal'.
|
|
743
|
+
if (event === 'goal') {
|
|
744
|
+
try {
|
|
745
|
+
eligible.push(...(yield evaluatePlan(userId, anchor)));
|
|
746
|
+
}
|
|
747
|
+
catch (_) { /* ignore */ }
|
|
748
|
+
}
|
|
749
|
+
nuevos.push(...(yield persistLogros(userId, eligible, markSeen)));
|
|
750
|
+
// pace_*/pr_* se derivan del mapa de PRs ya persistido en los PARAMS de dist_*.
|
|
751
|
+
if (want('workout')) {
|
|
752
|
+
try {
|
|
753
|
+
const prMap = yield getPrMap(userId);
|
|
754
|
+
nuevos.push(...(yield persistLogros(userId, [...evaluatePace(prMap), ...evaluateTimePR(prMap)])));
|
|
755
|
+
}
|
|
756
|
+
catch (_) {
|
|
757
|
+
// ignore
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Dedup por id, preservando el PRIMERO de cada id. applyPRs se acumula antes que
|
|
761
|
+
// persistLogros(eligible), así que para un dist_* que salta por las dos vías nos
|
|
762
|
+
// quedamos con el objeto que trae `params` (el PR). El INSERT ON CONFLICT DO NOTHING
|
|
763
|
+
// de persistLogros ya evita el doble alta, pero deduplicamos por seguridad.
|
|
764
|
+
const seen = new Set();
|
|
765
|
+
const result = [];
|
|
766
|
+
for (const n of nuevos) {
|
|
767
|
+
if (!n || !n.id || seen.has(n.id))
|
|
768
|
+
continue;
|
|
769
|
+
seen.add(n.id);
|
|
770
|
+
result.push(n);
|
|
771
|
+
}
|
|
772
|
+
return result;
|
|
773
|
+
});
|
|
774
|
+
exports.evaluateAchievements = evaluateAchievements;
|
|
775
|
+
/**
|
|
776
|
+
* Variante acotada con timeout para el CAMINO CALIENTE (hooks de evento que
|
|
777
|
+
* responden HTTP). Devuelve los recién desbloqueados si el motor termina dentro
|
|
778
|
+
* de `timeoutMs`; si vence (o falla), devuelve [] sin romper el flujo que la invoca.
|
|
779
|
+
*
|
|
780
|
+
* La promesa del motor lleva su propio `.catch` ANTES del race, así que si vence el
|
|
781
|
+
* timeout sigue corriendo en background SIN unhandled rejection y persiste igual
|
|
782
|
+
* (el INSERT ocurre dentro de evaluateAchievements) → el FE lo recupera en el
|
|
783
|
+
* siguiente refetch. Nunca lanza.
|
|
784
|
+
*/
|
|
785
|
+
const evaluateAchievementsBounded = (userId, event = 'all', opts = {}, timeoutMs = 1500) => __awaiter(void 0, void 0, void 0, function* () {
|
|
786
|
+
const motor = (0, exports.evaluateAchievements)(userId, event, opts).catch(() => []);
|
|
787
|
+
let timer;
|
|
788
|
+
const timeout = new Promise((resolve) => {
|
|
789
|
+
timer = setTimeout(() => resolve([]), timeoutMs);
|
|
790
|
+
});
|
|
791
|
+
const result = yield Promise.race([motor, timeout]);
|
|
792
|
+
// El motor podría seguir vivo tras el timeout; ya tiene su .catch, así que no hay
|
|
793
|
+
// unhandled rejection. Limpiamos el timer si el motor ganó la carrera.
|
|
794
|
+
motor.finally(() => clearTimeout(timer));
|
|
795
|
+
return Array.isArray(result) ? result : [];
|
|
796
|
+
});
|
|
797
|
+
exports.evaluateAchievementsBounded = evaluateAchievementsBounded;
|
|
798
|
+
/** Set completo de logros desbloqueados del usuario (para lecturas / verificación). */
|
|
799
|
+
const getUnlockedAchievements = (userId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
800
|
+
if (!userId)
|
|
801
|
+
return [];
|
|
802
|
+
const rows = yield (0, db_1.query)('SELECT "LOGRO ID" AS "ID" FROM "CLIENTE LOGRO" WHERE "ID CLIENTE" = ?', [userId]);
|
|
803
|
+
return rows.map((r) => r.id);
|
|
804
|
+
});
|
|
805
|
+
exports.getUnlockedAchievements = getUnlockedAchievements;
|