Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/app/__tests__/unit/weatherService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}));
});
});
10 changes: 10 additions & 0 deletions src/app/components/Weather/WeatherSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand All @@ -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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of todayISO (defined as UTC at line 14) for comparison with d.date (which follows local-date semantics from the service) can lead to off-by-one errors in labeling depending on the user's timezone and time of day. Additionally, this fallback logic labels today as "Recorded" (since today > todayISO is false), which is inconsistent with the "Forecast" label usually applied to the current day. Consider using a consistent date representation and matching the service's heuristic for today's status.

References
  1. Maintain consistent local-calendar-day semantics for both reading and writing to avoid bugs caused by mixed UTC and local time behavior.

}
return '';
})();
return (
Expand Down
54 changes: 54 additions & 0 deletions src/app/components/Weather/__tests__/WeatherSummary.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WeatherSummary summary={buildLegacySummary(['2027-06-01', '2027-06-02'])} />);

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(<WeatherSummary summary={buildLegacySummary(['2026-05-31', '2026-06-01'])} />);

expect(screen.getByText('Weather · Recorded')).toBeInTheDocument();
});
});
Comment on lines +1 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing accessibility tests

AGENTS.md requires all components to be tested with jest-axe for WCAG AA compliance. This new test file exercises WeatherSummary rendering but does not call axe(container) / expect(results).toHaveNoViolations(). Adding at least one jest-axe pass-through check would keep the file consistent with the project standard.

45 changes: 44 additions & 1 deletion src/app/services/weatherService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ async function readCache(key: string): Promise<CacheEntry | null> {
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,
Expand Down Expand Up @@ -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<WeatherData>;
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<WeatherData>;
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comparison day.date <= todayISO incorrectly marks the current day as historical. In the main fetch logic (fetchOpenMeteoDaily, line 545), today is treated as a forecast. This inconsistency will cause legacy cache entries containing today's date to be labeled as "Recorded" instead of "Forecast" when read and normalized.

Suggested change
: dataSource === 'historical-average' || day.date <= todayISO;
: dataSource === 'historical-average' || day.date < todayISO;

Comment on lines +363 to +376

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mixed past/future legacy entries misclassify far-future days as Forecast

legacyAllFuture is an all-or-nothing flag: if any day in the summary has a past date, every day without explicit source metadata gets dataSource: 'open-meteo'. For the future dates in that batch, the subsequent isForecast inference then resolves to true (open-meteo + not historical → forecast). Legacy entries for a trip that was originally cached entirely in the future but whose start date has since passed will therefore show "Forecast" in the UI for the remaining future days, even though that data came from the historical-average API. Per-day inference — day.date > todayISO ? 'historical-average' : 'open-meteo' — would correctly handle these mixed-range entries.

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);
Expand Down Expand Up @@ -844,6 +886,7 @@ export const weatherServiceTestUtils = {
getRateLimitHeaderDelayMs,
needsForecastRefresh,
isFreshEmptyCacheEntry,
normalizeCachedWeatherSummary,
resetRateLimitStateForTests
};

Expand Down
Loading