Skip to content

Commit 6c6366c

Browse files
committed
fix(window): rain falling now vetoes the next hour of the day window
The window scored only the FORECAST slots: when the hourly model said "dry" while the aggregated nowcast said it was raining — the exact disagreement the nowcast exists for — the best-time verdict recommended going out into the rain, right next to a "lloviendo ahora" badge. buildDayWindow now takes the nowcast: with status 'raining', every slot starting within the next 60 minutes is treated as wet (calidad 0). A model already disproved by the sky cannot claim the immediate hour; one hour is the minimum persistence an active event deserves, and the short cache TTLs slide the veto forward while it keeps raining. The stretch motive then tells the truth on its own ('sin_lluvia'). Both callers already hold the nowcast — featured fan-out and details assembler (on the OpenWeather-slots fallback too, where the status can be valid while Open-Meteo's slots are not) — so this costs zero extra provider calls. Tests: three new cases (veto over a dry-claiming forecast, no recommendation with under an hour of window left while raining, dry status changes nothing). Claude-Session: https://claude.ai/code/session_01C4ffvC8qXXWYF9mGemXTvi
1 parent 5719956 commit 6c6366c

4 files changed

Lines changed: 64 additions & 8 deletions

File tree

backend/src/__tests__/BeachWindowScorer.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,36 @@ describe('buildDayWindow — la mejor franja del día', () => {
130130
expect(señal?.horasConsideradas).toBe(5);
131131
});
132132

133+
it('lluvia AHORA veta la próxima hora aunque el modelo diga seco', () => {
134+
// 11:30 Madrid, lloviendo según el nowcast; la previsión (BUENA) dice
135+
// seco todo el día — que es exactamente el caso en que el modelo ya ha
136+
// quedado desmentido y no puede recomendar salir ya.
137+
const lloviendo = new Date('2026-07-15T09:30:00Z');
138+
const señal = buildDayWindow(slots([9, 10, 11, 12, 13], BUENA), lloviendo, {
139+
status: 'raining',
140+
});
141+
142+
// Los slots de las 09:00 y 10:00 UTC (en curso y siguiente) caen dentro
143+
// del veto de una hora: el tramo recomendable empieza a las 13:00 Madrid.
144+
expect(señal?.mejor).toEqual({ inicio: utc(11), fin: utc(14) });
145+
// Y el motivo cuenta la verdad: es el tramo sin lluvia.
146+
expect(señal?.motivo).toBe('sin_lluvia');
147+
});
148+
149+
it('lloviendo y con menos de una hora de franja por delante, no hay recomendación', () => {
150+
const tarde = new Date('2026-07-15T17:30:00Z'); // 19:30 Madrid
151+
expect(
152+
buildDayWindow(slots([17, 18], BUENA), tarde, { status: 'raining' }),
153+
).toBeNull();
154+
});
155+
156+
it('con el nowcast seco (o ausente) el veto no existe y nada cambia', () => {
157+
const señal = buildDayWindow(slots([10, 11, 12, 13, 14], BUENA), MEDIA_MANANA, {
158+
status: 'dry',
159+
});
160+
expect(señal?.mejor).toEqual({ inicio: utc(10), fin: utc(15) });
161+
});
162+
133163
it('un tramo que cubre toda la franja restante no tiene motivo: no venció a nadie', () => {
134164
const señal = buildDayWindow(slots([10, 11, 12, 13, 14], BUENA), MEDIA_MANANA);
135165

backend/src/application/services/LegacyDetailsAssembler.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,22 @@ export class LegacyDetailsAssembler {
100100
private async resolverVentanaDia(
101101
lat: number,
102102
lon: number,
103-
delNowcast: readonly HourlyOutlookSlot[] | null | undefined,
103+
nowcast: RainNowcast | null,
104104
): Promise<{ ventana: DayWindowSignal; fuente: string } | null> {
105+
// The nowcast rides into the verdict on BOTH branches: rain falling now
106+
// must veto the next hour even when the slots come from the OpenWeather
107+
// fallback — its status can be valid while the Open-Meteo slots are not.
108+
const delNowcast = nowcast?.outlook;
105109
if ((delNowcast?.length ?? 0) > 0) {
106-
const ventana = buildDayWindow(delNowcast);
110+
const ventana = buildDayWindow(delNowcast, new Date(), nowcast);
107111
return ventana ? { ventana, fuente: OPEN_METEO_NOMBRE } : null;
108112
}
109113
try {
110-
const ventana = buildDayWindow(await this.openWeather.getOutlookSlots(lat, lon));
114+
const ventana = buildDayWindow(
115+
await this.openWeather.getOutlookSlots(lat, lon),
116+
new Date(),
117+
nowcast,
118+
);
111119
return ventana ? { ventana, fuente: 'OpenWeather' } : null;
112120
} catch {
113121
return null;
@@ -404,7 +412,7 @@ export class LegacyDetailsAssembler {
404412
const ventana = await this.resolverVentanaDia(
405413
details.beach.latitude,
406414
details.beach.longitude,
407-
rainSignal?.outlook,
415+
rainSignal,
408416
);
409417
base.tiempoActual = {
410418
...base.tiempoActual,

backend/src/domain/use-cases/BeachWindowScorer.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { HourlyOutlookSlot } from '../entities/RainNowcast';
1+
import type { HourlyOutlookSlot, RainNowcast } from '../entities/RainNowcast';
22
import {
33
computeTemperatureScore,
44
computeWindScore,
@@ -138,6 +138,7 @@ function media(valores: number[]): number {
138138
export function buildDayWindow(
139139
slots: readonly HourlyOutlookSlot[] | null | undefined,
140140
ahora: Date = new Date(),
141+
lluviaAhora?: Pick<RainNowcast, 'status'> | null,
141142
): DayWindowSignal | null {
142143
if (!slots || slots.length === 0) return null;
143144

@@ -151,9 +152,24 @@ export function buildDayWindow(
151152
// window, not only when it starts inside it: dropping the in-progress slot
152153
// meant the window could never start "now" even when now was the best hour.
153154
const enFranja = ordenados.filter((s) => s.timestamp + paso > desde && s.timestamp <= hasta);
155+
156+
// Rain detected NOW (the aggregated multi-source nowcast) overrides the
157+
// model for the next hour: these slots said "dry" while it was actually
158+
// raining, so their claim is already disproved — the window must never
159+
// recommend going out into rain the forecast cannot see. One hour is the
160+
// minimum persistence an active event deserves; while it keeps raining the
161+
// short cache TTLs slide this veto forward on every refresh. Same
162+
// philosophy as the score's rain cap, and it costs zero extra calls: both
163+
// callers already hold the nowcast.
164+
const vetoHasta =
165+
lluviaAhora?.status === 'raining' ? ahora.getTime() + 60 * 60_000 : null;
166+
154167
const evaluados = enFranja
155168
.map(evaluarSlot)
156-
.filter((s): s is SlotEvaluado => s !== null);
169+
.filter((s): s is SlotEvaluado => s !== null)
170+
.map((s) =>
171+
vetoHasta != null && s.timestamp < vetoHasta ? { ...s, mojado: true, calidad: 0 } : s,
172+
);
157173
if (evaluados.length < 2) return null;
158174

159175
// The bar every recommended hour must clear: near the day's own best hour,

backend/src/domain/use-cases/GetFeaturedBeaches.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,10 @@ export class GetFeaturedBeaches {
141141

142142
// WHEN to go: best stretch of the remaining beach window, from the same
143143
// slots. Open-Meteo only, symmetric with the outlook above: when it is
144-
// down the field is null and the interface shows nothing.
145-
const ventanaDia = buildDayWindow(rain?.outlook);
144+
// down the field is null and the interface shows nothing. The nowcast
145+
// rides along so rain falling NOW vetoes the next hour, whatever the
146+
// forecast claims.
147+
const ventanaDia = buildDayWindow(rain?.outlook, new Date(), rain);
146148

147149
const { score, subScores, tope } = computeBeachScore(
148150
weather,

0 commit comments

Comments
 (0)