@runnerpro/backend 1.21.12 → 1.21.14

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.
Files changed (50) hide show
  1. package/lib/cjs/achievements/catalog.js +204 -0
  2. package/lib/cjs/achievements/index.js +805 -0
  3. package/lib/cjs/achievements/streak.js +126 -0
  4. package/lib/cjs/chat/api/conversation.js +21 -29
  5. package/lib/cjs/chat/exposed/conversation.js +0 -7
  6. package/lib/cjs/chat/index.js +0 -7
  7. package/lib/cjs/index.js +34 -5
  8. package/lib/cjs/locale/en.js +0 -4
  9. package/lib/cjs/locale/es.js +0 -4
  10. package/lib/cjs/locale/fr.js +0 -4
  11. package/lib/cjs/locale/it.js +0 -4
  12. package/lib/cjs/prompt/azureModel.js +7 -35
  13. package/lib/cjs/prompt/constants.js +12 -67
  14. package/lib/cjs/prompt/googleModel.js +6 -11
  15. package/lib/cjs/prompt/index.js +1 -4
  16. package/lib/cjs/prompt/modelPricing.js +1 -22
  17. package/lib/cjs/sendNotification/index.js +4 -88
  18. package/lib/cjs/types/achievements/catalog.d.ts +104 -0
  19. package/lib/cjs/types/achievements/catalog.d.ts.map +1 -0
  20. package/lib/cjs/types/achievements/index.d.ts +41 -0
  21. package/lib/cjs/types/achievements/index.d.ts.map +1 -0
  22. package/lib/cjs/types/achievements/streak.d.ts +20 -0
  23. package/lib/cjs/types/achievements/streak.d.ts.map +1 -0
  24. package/lib/cjs/types/chat/api/conversation.d.ts.map +1 -1
  25. package/lib/cjs/types/chat/exposed/conversation.d.ts.map +1 -1
  26. package/lib/cjs/types/chat/index.d.ts.map +1 -1
  27. package/lib/cjs/types/index.d.ts +4 -2
  28. package/lib/cjs/types/index.d.ts.map +1 -1
  29. package/lib/cjs/types/locale/en.d.ts +0 -4
  30. package/lib/cjs/types/locale/en.d.ts.map +1 -1
  31. package/lib/cjs/types/locale/es.d.ts +0 -4
  32. package/lib/cjs/types/locale/es.d.ts.map +1 -1
  33. package/lib/cjs/types/locale/fr.d.ts +0 -4
  34. package/lib/cjs/types/locale/fr.d.ts.map +1 -1
  35. package/lib/cjs/types/locale/it.d.ts +0 -4
  36. package/lib/cjs/types/locale/it.d.ts.map +1 -1
  37. package/lib/cjs/types/prompt/azureModel.d.ts.map +1 -1
  38. package/lib/cjs/types/prompt/constants.d.ts +7 -28
  39. package/lib/cjs/types/prompt/constants.d.ts.map +1 -1
  40. package/lib/cjs/types/prompt/googleModel.d.ts.map +1 -1
  41. package/lib/cjs/types/prompt/index.d.ts +2 -2
  42. package/lib/cjs/types/prompt/index.d.ts.map +1 -1
  43. package/lib/cjs/types/prompt/modelPricing.d.ts.map +1 -1
  44. package/lib/cjs/types/sendNotification/index.d.ts +1 -20
  45. package/lib/cjs/types/sendNotification/index.d.ts.map +1 -1
  46. package/lib/cjs/types/workout/planificacionPrueba7dias/index.d.ts.map +1 -1
  47. package/lib/cjs/types/workout/saveWorkoutAplication.d.ts.map +1 -1
  48. package/lib/cjs/workout/planificacionPrueba7dias/index.js +7 -3
  49. package/lib/cjs/workout/saveWorkoutAplication.js +10 -0
  50. package/package.json +1 -1
@@ -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;