diff --git a/src/app/__tests__/unit/weatherService.test.ts b/src/app/__tests__/unit/weatherService.test.ts index b692f539..74ea1a60 100644 --- a/src/app/__tests__/unit/weatherService.test.ts +++ b/src/app/__tests__/unit/weatherService.test.ts @@ -76,3 +76,88 @@ describe('weatherService negative cache helpers', () => { }, now)).toBe(false); }); }); + +describe('weatherService legacy cache normalization', () => { + const legacyDay = (date: string) => ({ + id: `weather-${date}`, + date, + coordinates: [47.5, 19] as [number, number], + temperature: { min: 10, max: 20, average: 15 }, + precipitation: { total: 0, probability: null }, + wind: { speed: null, direction: null }, + conditions: { description: 'Clear', icon: 'sun', code: 0, cloudCover: null, humidity: null }, + fetchedAt: '2026-06-01T00:00:00.000Z' + }); + + it('marks all-future legacy cache entries as historical averages', () => { + const normalized = weatherServiceTestUtils.normalizeCachedWeatherSummary({ + ...emptySummary, + startDate: '2027-06-01', + endDate: '2027-06-02', + dailyWeather: [ + legacyDay('2027-06-01'), + legacyDay('2027-06-02') + ] as WeatherSummary['dailyWeather'] + }, new Date('2026-06-01T12:00:00.000Z')); + + expect(normalized.dailyWeather).toEqual(expect.arrayContaining([ + expect.objectContaining({ + date: '2027-06-01', + dataSource: 'historical-average', + isHistorical: true, + isForecast: false + }), + expect.objectContaining({ + date: '2027-06-02', + dataSource: 'historical-average', + isHistorical: true, + isForecast: false + }) + ])); + }); + + it('infers recorded open-meteo data for legacy cache entries that include past days', () => { + const normalized = weatherServiceTestUtils.normalizeCachedWeatherSummary({ + ...emptySummary, + startDate: '2026-05-30', + endDate: '2026-06-01', + dailyWeather: [ + legacyDay('2026-05-30'), + legacyDay('2026-06-01') + ] as WeatherSummary['dailyWeather'] + }, new Date('2026-06-01T12:00:00.000Z')); + + expect(normalized.dailyWeather).toEqual(expect.arrayContaining([ + expect.objectContaining({ + date: '2026-05-30', + dataSource: 'open-meteo', + isHistorical: true, + isForecast: false + }), + expect.objectContaining({ + date: '2026-06-01', + dataSource: 'open-meteo', + isHistorical: true, + isForecast: false + }) + ])); + }); + + it('fills missing forecast and historical flags when cached data has a source', () => { + const normalized = weatherServiceTestUtils.normalizeCachedWeatherSummary({ + ...emptySummary, + startDate: '2026-06-01', + endDate: '2026-06-02', + dailyWeather: [ + { ...legacyDay('2026-06-02'), dataSource: 'open-meteo' } + ] as WeatherSummary['dailyWeather'] + }, new Date('2026-06-01T12:00:00.000Z')); + + expect(normalized.dailyWeather[0]).toEqual(expect.objectContaining({ + date: '2026-06-02', + dataSource: 'open-meteo', + isHistorical: false, + isForecast: true + })); + }); +}); diff --git a/src/app/components/Weather/WeatherSummary.tsx b/src/app/components/Weather/WeatherSummary.tsx index c776a1b5..1f964f1b 100644 --- a/src/app/components/Weather/WeatherSummary.tsx +++ b/src/app/components/Weather/WeatherSummary.tsx @@ -26,6 +26,13 @@ export default function WeatherSummary({ summary }: Props) { const hasHistAvg = sourceDays.some(d => d.dataSource === 'historical-average'); const hasForecast = sourceDays.some(d => d.isForecast); const hasRecorded = sourceDays.some(d => d.isHistorical && d.dataSource !== 'historical-average'); + const hasExplicitSourceMetadata = sourceDays.some(d => + d.dataSource === 'historical-average' || + d.dataSource === 'open-meteo' || + d.dataSource === 'cache' || + typeof d.isForecast === 'boolean' || + typeof d.isHistorical === 'boolean' + ); if (hasHistAvg && hasForecast && hasRecorded) return 'Recorded + Forecast + Hist. avg.'; if (hasHistAvg && hasForecast) return 'Forecast + Hist. avg.'; @@ -34,6 +41,9 @@ export default function WeatherSummary({ summary }: Props) { if (hasHistAvg) return 'Hist. avg.'; if (hasForecast) return 'Forecast'; if (hasRecorded) return 'Recorded'; + if (!hasExplicitSourceMetadata) { + return sourceDays.every(d => d.date > todayISO) ? 'Hist. avg.' : 'Recorded'; + } return ''; })(); return ( diff --git a/src/app/components/Weather/__tests__/WeatherSummary.test.tsx b/src/app/components/Weather/__tests__/WeatherSummary.test.tsx new file mode 100644 index 00000000..4f87e8be --- /dev/null +++ b/src/app/components/Weather/__tests__/WeatherSummary.test.tsx @@ -0,0 +1,54 @@ +/** + * @jest-environment jsdom + */ + +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import WeatherSummary from '@/app/components/Weather/WeatherSummary'; +import type { WeatherSummary as WeatherSummaryType } from '@/app/types/weather'; + +const buildLegacySummary = (dates: string[]): WeatherSummaryType => ({ + locationId: 'location-1', + startDate: dates[0], + endDate: dates[dates.length - 1], + dailyWeather: dates.map(date => ({ + id: `weather-${date}`, + date, + coordinates: [47.5, 19] as [number, number], + temperature: { min: 10, max: 20, average: 15 }, + precipitation: { total: 0, probability: null }, + wind: { speed: null, direction: null }, + conditions: { description: 'Clear', icon: 'sun', code: 0, cloudCover: null, humidity: null }, + fetchedAt: '2026-06-01T00:00:00.000Z', + })) as WeatherSummaryType['dailyWeather'], + summary: { + averageTemp: 15, + totalPrecipitation: 0, + predominantCondition: 'Clear', + }, +}); + +describe('WeatherSummary', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('labels all-future legacy cached weather as historical averages', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-01T12:00:00.000Z')); + + render(); + + expect(screen.getByText('Weather · Hist. avg.')).toBeInTheDocument(); + }); + + it('labels past legacy cached weather as recorded instead of rendering a blank source label', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-01T12:00:00.000Z')); + + render(); + + expect(screen.getByText('Weather · Recorded')).toBeInTheDocument(); + }); +}); diff --git a/src/app/services/weatherService.ts b/src/app/services/weatherService.ts index eff6e802..262f98ee 100644 --- a/src/app/services/weatherService.ts +++ b/src/app/services/weatherService.ts @@ -282,7 +282,7 @@ async function readCache(key: string): Promise { const exp = parseISO(d.expiresAt); return isValid(exp) && isAfter(exp, today); }); - const summary: WeatherSummary = { ...parsed.summary, dailyWeather: filtered }; + const summary = normalizeCachedWeatherSummary({ ...parsed.summary, dailyWeather: filtered }); const fetchedAt = typeof parsed.fetchedAt === 'string' ? parsed.fetchedAt : LEGACY_FETCHED_AT; const entry: CacheEntry = { key: parsed.key || key, @@ -346,6 +346,48 @@ function computeSources(daily: WeatherData[]): CacheSources { return result; } +function isKnownWeatherDataSource(value: unknown): value is WeatherData['dataSource'] { + return value === 'open-meteo' || value === 'cache' || value === 'historical-average'; +} + +function normalizeCachedWeatherSummary(summary: WeatherSummary, today: Date = new Date()): WeatherSummary { + const todayISO = toISODate(today); + const hasExplicitSourceMetadata = summary.dailyWeather.some(day => { + const rawDay = day as Partial; + return ( + isKnownWeatherDataSource(rawDay.dataSource) || + typeof rawDay.isForecast === 'boolean' || + typeof rawDay.isHistorical === 'boolean' + ); + }); + const legacyAllFuture = !hasExplicitSourceMetadata && summary.dailyWeather.every(day => day.date > todayISO); + + return { + ...summary, + dailyWeather: summary.dailyWeather.map(day => { + const rawDay = day as Partial; + const dataSource = isKnownWeatherDataSource(rawDay.dataSource) + ? rawDay.dataSource + : legacyAllFuture + ? 'historical-average' + : 'open-meteo'; + const isHistorical = typeof rawDay.isHistorical === 'boolean' + ? rawDay.isHistorical + : dataSource === 'historical-average' || day.date <= todayISO; + const isForecast = typeof rawDay.isForecast === 'boolean' + ? rawDay.isForecast + : dataSource === 'open-meteo' && !isHistorical; + + return { + ...day, + dataSource, + isHistorical, + isForecast, + }; + }), + }; +} + function enumerateDateStrings(startISO: string, endISO: string): string[] { const start = parseISO(startISO); const end = parseISO(endISO); @@ -844,6 +886,7 @@ export const weatherServiceTestUtils = { getRateLimitHeaderDelayMs, needsForecastRefresh, isFreshEmptyCacheEntry, + normalizeCachedWeatherSummary, resetRateLimitStateForTests };