@brika/plugin-weather 0.3.0

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.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Tests for weather plugin utility functions.
3
+ *
4
+ * 100% coverage for: getWeatherMeta, getGradient, getConditionColor,
5
+ * formatTemp, tempUnit, windDirectionLabel, dayName.
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { createMockTranslation } from '@brika/sdk/testing';
10
+ import type { WeatherCondition } from '../utils';
11
+ import {
12
+ dayName,
13
+ formatTemp,
14
+ formatTempWithUnit,
15
+ getConditionColor,
16
+ getGradient,
17
+ getWeatherMeta,
18
+ getWeatherVisuals,
19
+ tempUnit,
20
+ windDirectionLabel,
21
+ } from '../utils';
22
+
23
+ // ─── getWeatherMeta ─────────────────────────────────────────────────────────
24
+
25
+ describe('getWeatherMeta', () => {
26
+ test('code 0 → clear sky', () => {
27
+ const meta = getWeatherMeta(0);
28
+ expect(meta.condition).toBe('clear');
29
+ expect(meta.labelKey).toBe('conditions.clearSky');
30
+ expect(meta.icon).toBe('sun');
31
+ });
32
+
33
+ test('code 1 → mainly clear', () => {
34
+ const meta = getWeatherMeta(1);
35
+ expect(meta.condition).toBe('partly-cloudy');
36
+ expect(meta.labelKey).toBe('conditions.mainlyClear');
37
+ });
38
+
39
+ test('code 2 → partly cloudy', () => {
40
+ expect(getWeatherMeta(2).condition).toBe('partly-cloudy');
41
+ });
42
+
43
+ test('code 3 → overcast', () => {
44
+ expect(getWeatherMeta(3).condition).toBe('cloudy');
45
+ expect(getWeatherMeta(3).icon).toBe('cloud');
46
+ });
47
+
48
+ test('code 45 → fog', () => {
49
+ expect(getWeatherMeta(45).condition).toBe('fog');
50
+ });
51
+
52
+ test('code 48 → rime fog', () => {
53
+ expect(getWeatherMeta(48).condition).toBe('fog');
54
+ expect(getWeatherMeta(48).labelKey).toBe('conditions.rimeFog');
55
+ });
56
+
57
+ test('codes 51-57 → drizzle variants', () => {
58
+ for (const code of [51, 53, 55, 56, 57]) {
59
+ expect(getWeatherMeta(code).condition).toBe('drizzle');
60
+ expect(getWeatherMeta(code).icon).toBe('cloud-drizzle');
61
+ }
62
+ });
63
+
64
+ test('codes 61-67 → rain variants', () => {
65
+ for (const code of [61, 63, 65, 66, 67]) {
66
+ expect(getWeatherMeta(code).condition).toBe('rain');
67
+ expect(getWeatherMeta(code).icon).toBe('cloud-rain');
68
+ }
69
+ });
70
+
71
+ test('codes 71-77 → snow variants', () => {
72
+ for (const code of [71, 73, 75, 77]) {
73
+ expect(getWeatherMeta(code).condition).toBe('snow');
74
+ expect(getWeatherMeta(code).icon).toBe('snowflake');
75
+ }
76
+ });
77
+
78
+ test('codes 80-82 → shower variants', () => {
79
+ for (const code of [80, 81, 82]) {
80
+ expect(getWeatherMeta(code).condition).toBe('showers');
81
+ expect(getWeatherMeta(code).icon).toBe('cloud-rain-wind');
82
+ }
83
+ });
84
+
85
+ test('codes 85-86 → snow showers', () => {
86
+ for (const code of [85, 86]) {
87
+ expect(getWeatherMeta(code).condition).toBe('snow');
88
+ }
89
+ });
90
+
91
+ test('codes 95-99 → thunderstorm variants', () => {
92
+ for (const code of [95, 96, 99]) {
93
+ expect(getWeatherMeta(code).condition).toBe('thunderstorm');
94
+ expect(getWeatherMeta(code).icon).toBe('cloud-lightning');
95
+ }
96
+ });
97
+
98
+ test('unknown code returns fallback', () => {
99
+ const meta = getWeatherMeta(999);
100
+ expect(meta.condition).toBe('cloudy');
101
+ expect(meta.labelKey).toBe('conditions.unknown');
102
+ expect(meta.icon).toBe('cloud');
103
+ });
104
+ });
105
+
106
+ // ─── getGradient ────────────────────────────────────────────────────────────
107
+
108
+ describe('getGradient', () => {
109
+ test('returns a gradient string for known codes', () => {
110
+ const gradient = getGradient(0);
111
+ expect(gradient).toContain('linear-gradient');
112
+ });
113
+
114
+ test('each condition maps to a distinct gradient', () => {
115
+ const codes: Record<WeatherCondition, number> = {
116
+ clear: 0,
117
+ 'partly-cloudy': 1,
118
+ cloudy: 3,
119
+ fog: 45,
120
+ drizzle: 51,
121
+ rain: 61,
122
+ snow: 71,
123
+ showers: 80,
124
+ thunderstorm: 95,
125
+ };
126
+ const gradients = new Set<string>();
127
+ for (const code of Object.values(codes)) {
128
+ gradients.add(getGradient(code));
129
+ }
130
+ expect(gradients.size).toBe(9);
131
+ });
132
+
133
+ test('unknown code uses fallback (cloudy) gradient', () => {
134
+ const unknownGradient = getGradient(999);
135
+ const cloudyGradient = getGradient(3);
136
+ expect(unknownGradient).toBe(cloudyGradient);
137
+ });
138
+ });
139
+
140
+ // ─── getConditionColor ──────────────────────────────────────────────────────
141
+
142
+ describe('getConditionColor', () => {
143
+ test('clear sky returns yellow accent', () => {
144
+ expect(getConditionColor(0)).toBe('#fbbf24');
145
+ });
146
+
147
+ test('each condition maps to a color', () => {
148
+ const codes: Record<WeatherCondition, number> = {
149
+ clear: 0,
150
+ 'partly-cloudy': 1,
151
+ cloudy: 3,
152
+ fog: 45,
153
+ drizzle: 51,
154
+ rain: 61,
155
+ snow: 71,
156
+ showers: 80,
157
+ thunderstorm: 95,
158
+ };
159
+ for (const code of Object.values(codes)) {
160
+ const color = getConditionColor(code);
161
+ expect(color).toMatch(/^#[0-9a-f]{6}$/);
162
+ }
163
+ });
164
+
165
+ test('unknown code uses fallback (cloudy) color', () => {
166
+ expect(getConditionColor(999)).toBe(getConditionColor(3));
167
+ });
168
+ });
169
+
170
+ // ─── formatTemp ─────────────────────────────────────────────────────────────
171
+
172
+ describe('formatTemp', () => {
173
+ test('celsius returns rounded value', () => {
174
+ expect(formatTemp(22.7, 'celsius')).toBe('23');
175
+ });
176
+
177
+ test('celsius rounds down correctly', () => {
178
+ expect(formatTemp(22.3, 'celsius')).toBe('22');
179
+ });
180
+
181
+ test('celsius handles negative values', () => {
182
+ expect(formatTemp(-5.8, 'celsius')).toBe('-6');
183
+ });
184
+
185
+ test('celsius handles zero', () => {
186
+ expect(formatTemp(0, 'celsius')).toBe('0');
187
+ });
188
+
189
+ test('fahrenheit converts and rounds', () => {
190
+ // 0°C = 32°F
191
+ expect(formatTemp(0, 'fahrenheit')).toBe('32');
192
+ });
193
+
194
+ test('fahrenheit converts 100°C', () => {
195
+ // 100°C = 212°F
196
+ expect(formatTemp(100, 'fahrenheit')).toBe('212');
197
+ });
198
+
199
+ test('fahrenheit converts negative', () => {
200
+ // -40°C = -40°F
201
+ expect(formatTemp(-40, 'fahrenheit')).toBe('-40');
202
+ });
203
+
204
+ test('fahrenheit rounds correctly', () => {
205
+ // 22°C = 71.6°F → 72
206
+ expect(formatTemp(22, 'fahrenheit')).toBe('72');
207
+ });
208
+ });
209
+
210
+ // ─── tempUnit ───────────────────────────────────────────────────────────────
211
+
212
+ describe('tempUnit', () => {
213
+ test('celsius returns °C', () => {
214
+ expect(tempUnit('celsius')).toBe('\u00b0C');
215
+ });
216
+
217
+ test('fahrenheit returns °F', () => {
218
+ expect(tempUnit('fahrenheit')).toBe('\u00b0F');
219
+ });
220
+
221
+ test('any other string defaults to celsius', () => {
222
+ expect(tempUnit('kelvin')).toBe('\u00b0C');
223
+ });
224
+ });
225
+
226
+ // ─── windDirectionLabel ─────────────────────────────────────────────────────
227
+
228
+ describe('windDirectionLabel', () => {
229
+ test('0° → N', () => {
230
+ expect(windDirectionLabel(0)).toBe('N');
231
+ });
232
+
233
+ test('45° → NE', () => {
234
+ expect(windDirectionLabel(45)).toBe('NE');
235
+ });
236
+
237
+ test('90° → E', () => {
238
+ expect(windDirectionLabel(90)).toBe('E');
239
+ });
240
+
241
+ test('135° → SE', () => {
242
+ expect(windDirectionLabel(135)).toBe('SE');
243
+ });
244
+
245
+ test('180° → S', () => {
246
+ expect(windDirectionLabel(180)).toBe('S');
247
+ });
248
+
249
+ test('225° → SW', () => {
250
+ expect(windDirectionLabel(225)).toBe('SW');
251
+ });
252
+
253
+ test('270° → W', () => {
254
+ expect(windDirectionLabel(270)).toBe('W');
255
+ });
256
+
257
+ test('315° → NW', () => {
258
+ expect(windDirectionLabel(315)).toBe('NW');
259
+ });
260
+
261
+ test('360° wraps to N', () => {
262
+ expect(windDirectionLabel(360)).toBe('N');
263
+ });
264
+
265
+ test('22° rounds to NE', () => {
266
+ expect(windDirectionLabel(22)).toBe('N');
267
+ });
268
+
269
+ test('23° rounds to NE', () => {
270
+ expect(windDirectionLabel(23)).toBe('NE');
271
+ });
272
+ });
273
+
274
+ // ─── formatTempWithUnit ─────────────────────────────────────────────────────
275
+
276
+ describe('formatTempWithUnit', () => {
277
+ test('celsius combines value and unit', () => {
278
+ expect(formatTempWithUnit(22.7, 'celsius')).toBe('23\u00b0C');
279
+ });
280
+
281
+ test('fahrenheit combines converted value and unit', () => {
282
+ expect(formatTempWithUnit(0, 'fahrenheit')).toBe('32\u00b0F');
283
+ });
284
+
285
+ test('negative celsius', () => {
286
+ expect(formatTempWithUnit(-5.8, 'celsius')).toBe('-6\u00b0C');
287
+ });
288
+ });
289
+
290
+ // ─── getWeatherVisuals ──────────────────────────────────────────────────────
291
+
292
+ describe('getWeatherVisuals', () => {
293
+ test('returns meta, color, and gradient for known code', () => {
294
+ const v = getWeatherVisuals(0);
295
+ expect(v.meta.condition).toBe('clear');
296
+ expect(v.color).toBe('#fbbf24');
297
+ expect(v.gradient).toContain('linear-gradient');
298
+ });
299
+
300
+ test('matches individual helper results', () => {
301
+ const v = getWeatherVisuals(61);
302
+ expect(v.meta).toEqual(getWeatherMeta(61));
303
+ expect(v.color).toBe(getConditionColor(61));
304
+ expect(v.gradient).toBe(getGradient(61));
305
+ });
306
+
307
+ test('unknown code uses fallback', () => {
308
+ const v = getWeatherVisuals(999);
309
+ expect(v.meta.condition).toBe('cloudy');
310
+ expect(v.color).toBe(getConditionColor(3));
311
+ expect(v.gradient).toBe(getGradient(3));
312
+ });
313
+ });
314
+
315
+ // ─── dayName ────────────────────────────────────────────────────────────────
316
+
317
+ describe('dayName', () => {
318
+ const { t } = createMockTranslation('plugin:weather');
319
+
320
+ test('today returns I18nRef for days.today', () => {
321
+ const today = new Date();
322
+ const dateStr = today.toISOString().slice(0, 10);
323
+ const result = dayName(dateStr, t);
324
+ expect(result).toEqual(t('days.today'));
325
+ });
326
+
327
+ test('tomorrow returns I18nRef for days.tomorrow', () => {
328
+ const tomorrow = new Date();
329
+ tomorrow.setDate(tomorrow.getDate() + 1);
330
+ const dateStr = tomorrow.toISOString().slice(0, 10);
331
+ const result = dayName(dateStr, t);
332
+ expect(result).toEqual(t('days.tomorrow'));
333
+ });
334
+
335
+ test('other dates return I18nRef for weekday key', () => {
336
+ const nextWeek = new Date();
337
+ nextWeek.setDate(nextWeek.getDate() + 7);
338
+ const dateStr = nextWeek.toISOString().slice(0, 10);
339
+ const result = dayName(dateStr, t);
340
+ // Should be an I18nRef with a days.xxx key
341
+ expect(result).toHaveProperty('__i18n', true);
342
+ expect(result).toHaveProperty('ns', 'plugin:weather');
343
+ expect((result as { key: string }).key).toMatch(/^days\.(mon|tue|wed|thu|fri|sat|sun)$/);
344
+ });
345
+
346
+ test('all weekdays map to correct keys', () => {
347
+ const expected = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
348
+ // Use a date far enough in the future to avoid today/tomorrow
349
+ const base = new Date();
350
+ base.setDate(base.getDate() + 10);
351
+ for (let i = 0; i < 7; i++) {
352
+ const d = new Date(base);
353
+ d.setDate(d.getDate() + i);
354
+ const dateStr = d.toISOString().slice(0, 10);
355
+ const result = dayName(dateStr, t);
356
+ const dayIdx = d.getDay();
357
+ expect((result as { key: string }).key).toBe(`days.${expected[dayIdx]}`);
358
+ }
359
+ });
360
+ });
package/src/api.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { CurrentWeather, DailyForecast, GeoLocation, HourlyForecast } from './types';
2
+
3
+ const GEO_BASE = 'https://geocoding-api.open-meteo.com/v1';
4
+ const WEATHER_BASE = 'https://api.open-meteo.com/v1';
5
+
6
+ // ─── Geocoding ─────────────────────────────────────────────────────────────
7
+
8
+ interface GeoResult {
9
+ name: string;
10
+ latitude: number;
11
+ longitude: number;
12
+ country: string;
13
+ timezone: string;
14
+ }
15
+
16
+ export async function geocodeCity(name: string): Promise<GeoLocation | null> {
17
+ const url = `${GEO_BASE}/search?name=${encodeURIComponent(name)}&count=1&language=en`;
18
+ const res = await fetch(url);
19
+ if (!res.ok) return null;
20
+
21
+ const data = (await res.json()) as { results?: GeoResult[] };
22
+ const first = data.results?.[0];
23
+ if (!first) return null;
24
+
25
+ return {
26
+ name: first.name,
27
+ latitude: first.latitude,
28
+ longitude: first.longitude,
29
+ country: first.country,
30
+ timezone: first.timezone,
31
+ };
32
+ }
33
+
34
+ // ─── Weather Data (single request for current + hourly + daily) ────────────
35
+
36
+ interface ForecastResponse {
37
+ current: {
38
+ temperature_2m: number;
39
+ relative_humidity_2m: number;
40
+ apparent_temperature: number;
41
+ weather_code: number;
42
+ wind_speed_10m: number;
43
+ wind_direction_10m: number;
44
+ pressure_msl: number;
45
+ };
46
+ hourly: {
47
+ time: string[];
48
+ temperature_2m: number[];
49
+ weather_code: number[];
50
+ precipitation_probability: number[];
51
+ };
52
+ daily: {
53
+ time: string[];
54
+ weather_code: number[];
55
+ temperature_2m_max: number[];
56
+ temperature_2m_min: number[];
57
+ precipitation_sum: number[];
58
+ wind_speed_10m_max: number[];
59
+ };
60
+ }
61
+
62
+ export interface WeatherData {
63
+ current: CurrentWeather;
64
+ hourly: HourlyForecast[];
65
+ daily: DailyForecast[];
66
+ }
67
+
68
+ export async function fetchWeather(
69
+ latitude: number,
70
+ longitude: number,
71
+ ): Promise<WeatherData | null> {
72
+ const params = new URLSearchParams({
73
+ latitude: String(latitude),
74
+ longitude: String(longitude),
75
+ current:
76
+ 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m,pressure_msl',
77
+ hourly: 'temperature_2m,weather_code,precipitation_probability',
78
+ daily:
79
+ 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
80
+ timezone: 'auto',
81
+ forecast_days: '7',
82
+ });
83
+
84
+ const res = await fetch(`${WEATHER_BASE}/forecast?${params}`);
85
+ if (!res.ok) return null;
86
+
87
+ const data = (await res.json()) as ForecastResponse;
88
+
89
+ const current: CurrentWeather = {
90
+ temperature: data.current.temperature_2m,
91
+ apparentTemperature: data.current.apparent_temperature,
92
+ humidity: data.current.relative_humidity_2m,
93
+ weatherCode: data.current.weather_code,
94
+ windSpeed: data.current.wind_speed_10m,
95
+ windDirection: data.current.wind_direction_10m,
96
+ pressure: data.current.pressure_msl,
97
+ };
98
+
99
+ const hourly: HourlyForecast[] = data.hourly.time.map((time, i) => ({
100
+ time,
101
+ temperature: data.hourly.temperature_2m[i],
102
+ weatherCode: data.hourly.weather_code[i],
103
+ precipitationProbability: data.hourly.precipitation_probability[i],
104
+ }));
105
+
106
+ const daily: DailyForecast[] = data.daily.time.map((date, i) => ({
107
+ date,
108
+ weatherCode: data.daily.weather_code[i],
109
+ tempMax: data.daily.temperature_2m_max[i],
110
+ tempMin: data.daily.temperature_2m_min[i],
111
+ precipitationSum: data.daily.precipitation_sum[i],
112
+ windSpeedMax: data.daily.wind_speed_10m_max[i],
113
+ }));
114
+
115
+ return { current, hourly, daily };
116
+ }
@@ -0,0 +1,60 @@
1
+ import { Avatar, Box, Column, defineBrick, Icon, Row, Text, useLocale } from '@brika/sdk/bricks';
2
+ import { useWeather } from '../use-weather';
3
+ import { formatTempWithUnit, getWeatherVisuals } from '../utils';
4
+ import { CITY_UNIT_CONFIG, WeatherError, WeatherLoading } from './shared';
5
+
6
+ // ─── Brick Definition ──────────────────────────────────────────────────────
7
+
8
+ export const compactBrick = defineBrick(
9
+ {
10
+ id: 'compact',
11
+ families: ['sm'],
12
+ minSize: { w: 1, h: 1 },
13
+ maxSize: { w: 3, h: 3 },
14
+ config: CITY_UNIT_CONFIG,
15
+ },
16
+ () => {
17
+ const { t } = useLocale();
18
+ const { weather, unit } = useWeather();
19
+
20
+ if (weather.loading && !weather.current) return <WeatherLoading variant="compact" />;
21
+ if (weather.error && !weather.current) return <WeatherError message={t('ui.noData')} />;
22
+ if (!weather.current || !weather.location) return <WeatherLoading variant="compact" />;
23
+
24
+ const { meta, color, gradient } = getWeatherVisuals(weather.current.weatherCode);
25
+ const temp = formatTempWithUnit(weather.current.temperature, unit);
26
+
27
+ return (
28
+ <Box background={gradient} rounded="sm" padding="md" grow>
29
+ <Column gap="sm" justify="center" grow>
30
+ <Row gap="sm" align="center">
31
+ <Avatar icon={meta.icon} color={color} size="md" />
32
+ <Column gap="sm" grow>
33
+ <Text content={temp} variant="heading" weight="bold" color="#ffffff" maxLines={1} />
34
+ <Text content={t(meta.labelKey)} variant="caption" color="rgba(255,255,255,0.65)" maxLines={1} />
35
+ </Column>
36
+ </Row>
37
+ <Row gap="sm" align="center">
38
+ <Icon name="map-pin" size="sm" color="rgba(255,255,255,0.5)" />
39
+ <Text
40
+ content={weather.location.name}
41
+ variant="caption"
42
+ weight="semibold"
43
+ color="rgba(255,255,255,0.85)"
44
+ maxLines={1}
45
+ />
46
+ </Row>
47
+ <Row gap="sm" align="center">
48
+ <Icon name="thermometer" size="sm" color="rgba(255,255,255,0.5)" />
49
+ <Text
50
+ content={t('stats.feelsLikeTemp', { temp: formatTempWithUnit(weather.current.apparentTemperature, unit) })}
51
+ variant="caption"
52
+ color="rgba(255,255,255,0.6)"
53
+ maxLines={1}
54
+ />
55
+ </Row>
56
+ </Column>
57
+ </Box>
58
+ );
59
+ },
60
+ );
@@ -0,0 +1,133 @@
1
+ import { Avatar, Box, Column, Divider, defineBrick, Grid, type I18nRef, Icon, Row, Spacer, Text, useLocale } from '@brika/sdk/bricks';
2
+ import { useWeather } from '../use-weather';
3
+ import {
4
+ formatTemp,
5
+ formatTempWithUnit,
6
+ getWeatherVisuals,
7
+ tempUnit,
8
+ windDirectionLabel,
9
+ } from '../utils';
10
+ import { CITY_UNIT_CONFIG, WeatherError, WeatherLoading } from './shared';
11
+
12
+ // ─── Inline stat (icon + label + value + suffix) ────────────────────────────
13
+
14
+ function WeatherStat({
15
+ icon,
16
+ label,
17
+ value,
18
+ suffix,
19
+ }: Readonly<{
20
+ icon: string;
21
+ label: string | I18nRef;
22
+ value: string;
23
+ suffix?: string;
24
+ }>) {
25
+ return (
26
+ <Column gap="sm" grow>
27
+ <Row gap="sm" align="center">
28
+ <Icon name={icon} size="sm" color="rgba(255,255,255,0.5)" />
29
+ <Text content={label} variant="caption" size="xs" color="rgba(255,255,255,0.6)" maxLines={1} />
30
+ </Row>
31
+ <Row gap="sm" align="end">
32
+ <Text content={value} variant="heading" weight="bold" color="#ffffff" maxLines={1} />
33
+ {suffix ? (
34
+ <Text content={suffix} variant="caption" size="xs" color="rgba(255,255,255,0.5)" maxLines={1} />
35
+ ) : null}
36
+ </Row>
37
+ </Column>
38
+ );
39
+ }
40
+
41
+ // ─── Brick Definition ──────────────────────────────────────────────────────
42
+
43
+ export const currentBrick = defineBrick(
44
+ {
45
+ id: 'current',
46
+ families: ['sm', 'md', 'lg'],
47
+ minSize: { w: 1, h: 1 },
48
+ maxSize: { w: 12, h: 8 },
49
+ config: CITY_UNIT_CONFIG,
50
+ },
51
+ () => {
52
+ const { t } = useLocale();
53
+ const { weather, unit } = useWeather();
54
+
55
+ if (weather.loading && !weather.current) return <WeatherLoading />;
56
+ if (weather.error && !weather.current) return <WeatherError message={weather.error} />;
57
+ if (!weather.current || !weather.location) return <WeatherLoading />;
58
+
59
+ const { meta, color, gradient } = getWeatherVisuals(weather.current.weatherCode);
60
+
61
+ return (
62
+ <Box background={gradient} rounded="sm" padding="lg" grow>
63
+ <Column gap="md" grow justify="between">
64
+ {/* Header: location + condition label */}
65
+ <Row gap="sm" align="center">
66
+ <Icon name="map-pin" size="sm" color="rgba(255,255,255,0.5)" />
67
+ <Text content={weather.location.name} variant="body" weight="bold" color="#ffffff" maxLines={1} />
68
+ <Spacer />
69
+ <Text content={t(meta.labelKey)} variant="caption" color="rgba(255,255,255,0.6)" maxLines={1} />
70
+ </Row>
71
+
72
+ {/* Main: avatar + temp + feels like */}
73
+ <Row gap="md" align="center">
74
+ <Avatar icon={meta.icon} color={color} size="lg" />
75
+ <Column gap="sm" grow>
76
+ <Text
77
+ content={formatTempWithUnit(weather.current.temperature, unit)}
78
+ variant="heading"
79
+ size="xl"
80
+ weight="bold"
81
+ color="#ffffff"
82
+ maxLines={1}
83
+ />
84
+ <Text
85
+ content={t('stats.feelsLikeTemp', { temp: formatTempWithUnit(weather.current.apparentTemperature, unit) })}
86
+ variant="caption"
87
+ color="rgba(255,255,255,0.6)"
88
+ maxLines={1}
89
+ />
90
+ </Column>
91
+ </Row>
92
+
93
+ {/* Stats: auto-fit grid wraps to available width */}
94
+ <Divider color="rgba(255,255,255,0.12)" />
95
+ <Grid autoFit minColumnWidth={90} gap="md">
96
+ <WeatherStat
97
+ icon="thermometer"
98
+ label={t('stats.feelsLike')}
99
+ value={formatTemp(weather.current.apparentTemperature, unit)}
100
+ suffix={tempUnit(unit)}
101
+ />
102
+ <WeatherStat
103
+ icon="droplets"
104
+ label={t('stats.humidity')}
105
+ value={`${weather.current.humidity}`}
106
+ suffix="%"
107
+ />
108
+ <WeatherStat
109
+ icon="wind"
110
+ label={t('stats.wind')}
111
+ value={`${Math.round(weather.current.windSpeed)}`}
112
+ suffix={`km/h ${windDirectionLabel(weather.current.windDirection)}`}
113
+ />
114
+ <WeatherStat
115
+ icon="gauge"
116
+ label={t('stats.pressure')}
117
+ value={`${Math.round(weather.current.pressure)}`}
118
+ suffix="hPa"
119
+ />
120
+ </Grid>
121
+
122
+ {/* Updated timestamp */}
123
+ <Text
124
+ content={t('ui.updated', { time: weather.lastUpdated === null ? '' : new Date(weather.lastUpdated).toLocaleTimeString() })}
125
+ variant="caption"
126
+ color="rgba(255,255,255,0.35)"
127
+ maxLines={1}
128
+ />
129
+ </Column>
130
+ </Box>
131
+ );
132
+ },
133
+ );