@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.
- package/README.md +62 -0
- package/icon.svg +32 -0
- package/locales/en/plugin.json +137 -0
- package/locales/fr/plugin.json +137 -0
- package/package.json +181 -0
- package/src/__tests__/utils.test.ts +360 -0
- package/src/api.ts +116 -0
- package/src/bricks/compact.tsx +60 -0
- package/src/bricks/current.tsx +133 -0
- package/src/bricks/forecast.tsx +136 -0
- package/src/bricks/shared.tsx +49 -0
- package/src/index.tsx +13 -0
- package/src/types.ts +53 -0
- package/src/use-weather.ts +75 -0
- package/src/utils.ts +160 -0
- package/src/weather-store.ts +128 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Avatar, Box, Column, Divider, defineBrick, Grid, type I18nRef, Icon, Row, Spacer, Text, useBrickSize, useLocale, usePreference } from '@brika/sdk/bricks';
|
|
2
|
+
import { useWeather } from '../use-weather';
|
|
3
|
+
import { dayName, formatTempWithUnit, getWeatherVisuals } from '../utils';
|
|
4
|
+
import { CITY_UNIT_CONFIG, WeatherError, WeatherLoading } from './shared';
|
|
5
|
+
|
|
6
|
+
// ─── Day props shared by both layouts ───────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface DayProps {
|
|
9
|
+
dayLabel: I18nRef;
|
|
10
|
+
code: number;
|
|
11
|
+
high: number;
|
|
12
|
+
low: number;
|
|
13
|
+
unit: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── List row (narrow) — single-line per day, maximally scannable ───────────
|
|
17
|
+
|
|
18
|
+
function DayRow({ dayLabel, code, high, low, unit }: Readonly<DayProps>) {
|
|
19
|
+
const { meta, color } = getWeatherVisuals(code);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Row gap="sm" align="center">
|
|
23
|
+
<Avatar icon={meta.icon} color={color} size="sm" />
|
|
24
|
+
<Column grow>
|
|
25
|
+
<Text content={dayLabel} variant="body" weight="medium" color="#ffffff" maxLines={1} />
|
|
26
|
+
</Column>
|
|
27
|
+
<Text content={formatTempWithUnit(high, unit)} variant="body" weight="bold" color="#ffffff" />
|
|
28
|
+
<Text content={formatTempWithUnit(low, unit)} variant="body" color="rgba(255,255,255,0.35)" />
|
|
29
|
+
</Row>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Grid cell (wide) — compact vertical card ──────────────────────────────
|
|
34
|
+
|
|
35
|
+
function DayCell({ dayLabel, code, high, low, unit }: Readonly<DayProps>) {
|
|
36
|
+
const { meta, color } = getWeatherVisuals(code);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Column gap="sm" align="center">
|
|
40
|
+
<Text content={dayLabel} variant="caption" weight="semibold" color="rgba(255,255,255,0.7)" maxLines={1} />
|
|
41
|
+
<Avatar icon={meta.icon} color={color} size="sm" />
|
|
42
|
+
<Row gap="sm" align="end">
|
|
43
|
+
<Text content={formatTempWithUnit(high, unit)} variant="body" weight="bold" color="#ffffff" />
|
|
44
|
+
<Text content={formatTempWithUnit(low, unit)} variant="caption" color="rgba(255,255,255,0.35)" />
|
|
45
|
+
</Row>
|
|
46
|
+
</Column>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Brick Definition ────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export const forecastBrick = defineBrick(
|
|
53
|
+
{
|
|
54
|
+
id: 'forecast',
|
|
55
|
+
families: ['md', 'lg'],
|
|
56
|
+
minSize: { w: 2, h: 1 },
|
|
57
|
+
maxSize: { w: 12, h: 6 },
|
|
58
|
+
config: [
|
|
59
|
+
...CITY_UNIT_CONFIG,
|
|
60
|
+
{ type: 'number', name: 'days', default: 7, min: 1, max: 7, step: 1 },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
() => {
|
|
64
|
+
const { t } = useLocale();
|
|
65
|
+
const { weather, unit } = useWeather();
|
|
66
|
+
const [days] = usePreference<number>('days', 7);
|
|
67
|
+
const { width, height } = useBrickSize();
|
|
68
|
+
|
|
69
|
+
if (weather.loading && weather.daily.length === 0) return <WeatherLoading variant="forecast" />;
|
|
70
|
+
if (weather.error && weather.daily.length === 0) return <WeatherError message={weather.error} />;
|
|
71
|
+
if (weather.daily.length === 0) return <WeatherLoading variant="forecast" />;
|
|
72
|
+
|
|
73
|
+
const code = weather.current?.weatherCode ?? 3;
|
|
74
|
+
const { gradient } = getWeatherVisuals(code);
|
|
75
|
+
const locationName = weather.location?.name ?? '';
|
|
76
|
+
|
|
77
|
+
const useGrid = width >= 4;
|
|
78
|
+
|
|
79
|
+
// Grid: one row only — cap days by width. List: cap by height.
|
|
80
|
+
let maxVisible = width;
|
|
81
|
+
if (!useGrid) {
|
|
82
|
+
if (height >= 3) maxVisible = 7;
|
|
83
|
+
else if (height >= 2) maxVisible = 5;
|
|
84
|
+
else maxVisible = 3;
|
|
85
|
+
}
|
|
86
|
+
const visibleDays = weather.daily.slice(0, Math.min(days, maxVisible));
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Box background={gradient} rounded="sm" padding="lg" grow>
|
|
90
|
+
<Column gap="sm" grow>
|
|
91
|
+
{/* Header — location left, day count right */}
|
|
92
|
+
<Row gap="sm" align="center">
|
|
93
|
+
<Icon name="map-pin" size="sm" color="rgba(255,255,255,0.5)" />
|
|
94
|
+
<Text content={locationName} variant="body" weight="semibold" color="#ffffff" maxLines={1} />
|
|
95
|
+
<Spacer />
|
|
96
|
+
<Text
|
|
97
|
+
content={t('ui.dayForecast', { count: visibleDays.length })}
|
|
98
|
+
variant="caption"
|
|
99
|
+
color="rgba(255,255,255,0.45)"
|
|
100
|
+
/>
|
|
101
|
+
</Row>
|
|
102
|
+
<Divider color="rgba(255,255,255,0.1)" />
|
|
103
|
+
|
|
104
|
+
{/* Forecast — grid when wide, list when narrow */}
|
|
105
|
+
{useGrid
|
|
106
|
+
? (
|
|
107
|
+
<Grid columns={visibleDays.length} gap="md">
|
|
108
|
+
{visibleDays.map((day) => (
|
|
109
|
+
<DayCell
|
|
110
|
+
dayLabel={dayName(day.date, t)}
|
|
111
|
+
code={day.weatherCode}
|
|
112
|
+
high={day.tempMax}
|
|
113
|
+
low={day.tempMin}
|
|
114
|
+
unit={unit}
|
|
115
|
+
/>
|
|
116
|
+
))}
|
|
117
|
+
</Grid>
|
|
118
|
+
)
|
|
119
|
+
: (
|
|
120
|
+
<Column gap="sm" grow justify="between">
|
|
121
|
+
{visibleDays.map((day) => (
|
|
122
|
+
<DayRow
|
|
123
|
+
dayLabel={dayName(day.date, t)}
|
|
124
|
+
code={day.weatherCode}
|
|
125
|
+
high={day.tempMax}
|
|
126
|
+
low={day.tempMin}
|
|
127
|
+
unit={unit}
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
</Column>
|
|
131
|
+
)}
|
|
132
|
+
</Column>
|
|
133
|
+
</Box>
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type BrickTypeSpec, Column, Icon, Skeleton, Text, type TextContent } from '@brika/sdk/bricks';
|
|
2
|
+
|
|
3
|
+
// ─── Shared brick config (city + unit dropdown) ─────────────────────────────
|
|
4
|
+
|
|
5
|
+
export const CITY_UNIT_CONFIG: NonNullable<BrickTypeSpec['config']> = [
|
|
6
|
+
{ type: 'text', name: 'city' },
|
|
7
|
+
{
|
|
8
|
+
type: 'dropdown',
|
|
9
|
+
name: 'unit',
|
|
10
|
+
options: [{ value: 'default' }, { value: 'celsius' }, { value: 'fahrenheit' }],
|
|
11
|
+
default: 'default',
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// ─── Shared Loading / Error states ──────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function WeatherLoading({ variant = 'default' }: Readonly<{ variant?: 'compact' | 'default' | 'forecast' }>) {
|
|
18
|
+
if (variant === 'compact') {
|
|
19
|
+
return (
|
|
20
|
+
<Column gap="sm" align="center" justify="center" grow>
|
|
21
|
+
<Skeleton variant="circle" width="32px" height="32px" />
|
|
22
|
+
<Skeleton variant="text" width="60%" />
|
|
23
|
+
</Column>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (variant === 'forecast') {
|
|
27
|
+
return (
|
|
28
|
+
<Column gap="md" align="center" justify="center" grow>
|
|
29
|
+
<Skeleton variant="text" width="40%" />
|
|
30
|
+
<Skeleton variant="rect" width="100%" height="80px" />
|
|
31
|
+
</Column>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<Column gap="md" align="center" justify="center" grow>
|
|
36
|
+
<Skeleton variant="circle" width="48px" height="48px" />
|
|
37
|
+
<Skeleton variant="text" width="60%" lines={2} />
|
|
38
|
+
</Column>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function WeatherError({ message }: Readonly<{ message: TextContent }>) {
|
|
43
|
+
return (
|
|
44
|
+
<Column gap="sm" align="center" justify="center" grow>
|
|
45
|
+
<Icon name="cloud-off" size="lg" color="muted" />
|
|
46
|
+
<Text content={message} variant="caption" color="muted" align="center" />
|
|
47
|
+
</Column>
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { log, onStop } from '@brika/sdk/lifecycle';
|
|
2
|
+
|
|
3
|
+
export { compactBrick } from './bricks/compact';
|
|
4
|
+
// Bricks (board UI)
|
|
5
|
+
export { currentBrick } from './bricks/current';
|
|
6
|
+
export { forecastBrick } from './bricks/forecast';
|
|
7
|
+
|
|
8
|
+
// Lifecycle
|
|
9
|
+
onStop(() => {
|
|
10
|
+
log.info('Weather plugin stopping');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
log.info('Weather plugin loaded');
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ─── Geocoding ─────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface GeoLocation {
|
|
4
|
+
name: string;
|
|
5
|
+
latitude: number;
|
|
6
|
+
longitude: number;
|
|
7
|
+
country: string;
|
|
8
|
+
timezone: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ─── Current Weather ───────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface CurrentWeather {
|
|
14
|
+
temperature: number;
|
|
15
|
+
apparentTemperature: number;
|
|
16
|
+
humidity: number;
|
|
17
|
+
weatherCode: number;
|
|
18
|
+
windSpeed: number;
|
|
19
|
+
windDirection: number;
|
|
20
|
+
pressure: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Hourly Forecast ───────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface HourlyForecast {
|
|
26
|
+
time: string;
|
|
27
|
+
temperature: number;
|
|
28
|
+
weatherCode: number;
|
|
29
|
+
precipitationProbability: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Daily Forecast ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export interface DailyForecast {
|
|
35
|
+
date: string;
|
|
36
|
+
weatherCode: number;
|
|
37
|
+
tempMax: number;
|
|
38
|
+
tempMin: number;
|
|
39
|
+
precipitationSum: number;
|
|
40
|
+
windSpeedMax: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Shared Store State ────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface WeatherState {
|
|
46
|
+
location: GeoLocation | null;
|
|
47
|
+
current: CurrentWeather | null;
|
|
48
|
+
daily: DailyForecast[];
|
|
49
|
+
hourly: HourlyForecast[];
|
|
50
|
+
lastUpdated: number | null;
|
|
51
|
+
loading: boolean;
|
|
52
|
+
error: string | null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWeather — unified hook for weather brick instances.
|
|
3
|
+
*
|
|
4
|
+
* Handles city resolution (per-instance → plugin-level → auto-detect),
|
|
5
|
+
* unit resolution (per-instance → plugin-level → celsius), polling
|
|
6
|
+
* lifecycle, and store subscription. All in one call.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const { weather, city, unit } = useWeather();
|
|
11
|
+
* // weather.current?.temperature, weather.loading, weather.error
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getDeviceLocation } from '@brika/sdk';
|
|
16
|
+
import {
|
|
17
|
+
useEffect,
|
|
18
|
+
usePluginPreference,
|
|
19
|
+
usePreference,
|
|
20
|
+
useState,
|
|
21
|
+
} from '@brika/sdk/bricks';
|
|
22
|
+
import type { WeatherState } from './types';
|
|
23
|
+
import { acquirePolling, DEFAULT_WEATHER, useWeatherMap } from './weather-store';
|
|
24
|
+
|
|
25
|
+
interface UseWeatherResult {
|
|
26
|
+
/** Weather data for the resolved city. */
|
|
27
|
+
weather: WeatherState;
|
|
28
|
+
/** The resolved city name being displayed. */
|
|
29
|
+
city: string;
|
|
30
|
+
/** The resolved temperature unit ('celsius' | 'fahrenheit'). */
|
|
31
|
+
unit: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Unified weather hook.
|
|
36
|
+
*
|
|
37
|
+
* Resolution order for **city**: brick config → plugin preference → auto-detect → "Zurich"
|
|
38
|
+
* Resolution order for **unit**: brick config → plugin preference → "celsius"
|
|
39
|
+
*/
|
|
40
|
+
export function useWeather(): UseWeatherResult {
|
|
41
|
+
// ─── City resolution ──────────────────────────────────────────────
|
|
42
|
+
const [brickCity] = usePreference<string>('city', '');
|
|
43
|
+
const pluginCity = usePluginPreference<string>('city', '');
|
|
44
|
+
const [autoCity, setAutoCity] = useState('');
|
|
45
|
+
|
|
46
|
+
const configuredCity = brickCity || pluginCity;
|
|
47
|
+
|
|
48
|
+
// Auto-detect location when no city is configured
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (configuredCity) return;
|
|
51
|
+
getDeviceLocation()
|
|
52
|
+
.then((loc) => {
|
|
53
|
+
if (loc?.city) setAutoCity(loc.city);
|
|
54
|
+
})
|
|
55
|
+
.catch(() => {
|
|
56
|
+
// Permission denied or location not configured — fall back to default city
|
|
57
|
+
});
|
|
58
|
+
}, [configuredCity]);
|
|
59
|
+
|
|
60
|
+
const resolvedCity = configuredCity || autoCity || 'Zurich';
|
|
61
|
+
|
|
62
|
+
// ─── Unit resolution ──────────────────────────────────────────────
|
|
63
|
+
const [brickUnit] = usePreference<string>('unit', 'default');
|
|
64
|
+
const pluginUnit = usePluginPreference<string>('unit', 'celsius');
|
|
65
|
+
const unit = brickUnit && brickUnit !== 'default' ? brickUnit : pluginUnit;
|
|
66
|
+
|
|
67
|
+
// ─── Polling lifecycle ────────────────────────────────────────────
|
|
68
|
+
useEffect(() => acquirePolling(resolvedCity), [resolvedCity]);
|
|
69
|
+
|
|
70
|
+
// ─── Store subscription ───────────────────────────────────────────
|
|
71
|
+
const weatherMap = useWeatherMap();
|
|
72
|
+
const weather = weatherMap[resolvedCity] ?? DEFAULT_WEATHER;
|
|
73
|
+
|
|
74
|
+
return { weather, city: resolvedCity, unit };
|
|
75
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// ─── WMO Weather Codes ─────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type WeatherCondition =
|
|
4
|
+
| 'clear'
|
|
5
|
+
| 'partly-cloudy'
|
|
6
|
+
| 'cloudy'
|
|
7
|
+
| 'fog'
|
|
8
|
+
| 'drizzle'
|
|
9
|
+
| 'rain'
|
|
10
|
+
| 'snow'
|
|
11
|
+
| 'showers'
|
|
12
|
+
| 'thunderstorm';
|
|
13
|
+
|
|
14
|
+
export interface WeatherMeta {
|
|
15
|
+
condition: WeatherCondition;
|
|
16
|
+
/** i18n key under `conditions.*` */
|
|
17
|
+
labelKey: string;
|
|
18
|
+
icon: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const WMO_MAP: Record<number, WeatherMeta> = {
|
|
22
|
+
0: { condition: 'clear', labelKey: 'conditions.clearSky', icon: 'sun' },
|
|
23
|
+
1: { condition: 'partly-cloudy', labelKey: 'conditions.mainlyClear', icon: 'cloud-sun' },
|
|
24
|
+
2: { condition: 'partly-cloudy', labelKey: 'conditions.partlyCloudy', icon: 'cloud-sun' },
|
|
25
|
+
3: { condition: 'cloudy', labelKey: 'conditions.overcast', icon: 'cloud' },
|
|
26
|
+
45: { condition: 'fog', labelKey: 'conditions.fog', icon: 'cloud-fog' },
|
|
27
|
+
48: { condition: 'fog', labelKey: 'conditions.rimeFog', icon: 'cloud-fog' },
|
|
28
|
+
51: { condition: 'drizzle', labelKey: 'conditions.lightDrizzle', icon: 'cloud-drizzle' },
|
|
29
|
+
53: { condition: 'drizzle', labelKey: 'conditions.drizzle', icon: 'cloud-drizzle' },
|
|
30
|
+
55: { condition: 'drizzle', labelKey: 'conditions.denseDrizzle', icon: 'cloud-drizzle' },
|
|
31
|
+
56: { condition: 'drizzle', labelKey: 'conditions.freezingDrizzle', icon: 'cloud-drizzle' },
|
|
32
|
+
57: { condition: 'drizzle', labelKey: 'conditions.heavyFreezingDrizzle', icon: 'cloud-drizzle' },
|
|
33
|
+
61: { condition: 'rain', labelKey: 'conditions.lightRain', icon: 'cloud-rain' },
|
|
34
|
+
63: { condition: 'rain', labelKey: 'conditions.rain', icon: 'cloud-rain' },
|
|
35
|
+
65: { condition: 'rain', labelKey: 'conditions.heavyRain', icon: 'cloud-rain' },
|
|
36
|
+
66: { condition: 'rain', labelKey: 'conditions.freezingRain', icon: 'cloud-rain' },
|
|
37
|
+
67: { condition: 'rain', labelKey: 'conditions.heavyFreezingRain', icon: 'cloud-rain' },
|
|
38
|
+
71: { condition: 'snow', labelKey: 'conditions.lightSnow', icon: 'snowflake' },
|
|
39
|
+
73: { condition: 'snow', labelKey: 'conditions.snow', icon: 'snowflake' },
|
|
40
|
+
75: { condition: 'snow', labelKey: 'conditions.heavySnow', icon: 'snowflake' },
|
|
41
|
+
77: { condition: 'snow', labelKey: 'conditions.snowGrains', icon: 'snowflake' },
|
|
42
|
+
80: { condition: 'showers', labelKey: 'conditions.lightShowers', icon: 'cloud-rain-wind' },
|
|
43
|
+
81: { condition: 'showers', labelKey: 'conditions.showers', icon: 'cloud-rain-wind' },
|
|
44
|
+
82: { condition: 'showers', labelKey: 'conditions.heavyShowers', icon: 'cloud-rain-wind' },
|
|
45
|
+
85: { condition: 'snow', labelKey: 'conditions.snowShowers', icon: 'snowflake' },
|
|
46
|
+
86: { condition: 'snow', labelKey: 'conditions.heavySnowShowers', icon: 'snowflake' },
|
|
47
|
+
95: { condition: 'thunderstorm', labelKey: 'conditions.thunderstorm', icon: 'cloud-lightning' },
|
|
48
|
+
96: { condition: 'thunderstorm', labelKey: 'conditions.thunderstormHail', icon: 'cloud-lightning' },
|
|
49
|
+
99: { condition: 'thunderstorm', labelKey: 'conditions.severeThunderstorm', icon: 'cloud-lightning' },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const FALLBACK: WeatherMeta = { condition: 'cloudy', labelKey: 'conditions.unknown', icon: 'cloud' };
|
|
53
|
+
|
|
54
|
+
export function getWeatherMeta(code: number): WeatherMeta {
|
|
55
|
+
return WMO_MAP[code] ?? FALLBACK;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Gradient Backgrounds ───────────────────────────────────────────────────
|
|
59
|
+
// All gradients dark enough for white text (WCAG AA on lightest stop).
|
|
60
|
+
|
|
61
|
+
const CONDITION_GRADIENTS: Record<WeatherCondition, string> = {
|
|
62
|
+
clear: 'linear-gradient(135deg, #1a56a0 0%, #2875c8 50%, #3d8fd4 100%)',
|
|
63
|
+
'partly-cloudy': 'linear-gradient(135deg, #2a5078 0%, #3b6a96 50%, #4d80ab 100%)',
|
|
64
|
+
cloudy: 'linear-gradient(135deg, #363d47 0%, #454e5b 50%, #545f6e 100%)',
|
|
65
|
+
fog: 'linear-gradient(135deg, #3e4652 0%, #4f5966 50%, #5e6b78 100%)',
|
|
66
|
+
drizzle: 'linear-gradient(135deg, #2a4c72 0%, #3a6490 50%, #4a7aaa 100%)',
|
|
67
|
+
rain: 'linear-gradient(135deg, #1a3352 0%, #264a72 50%, #336190 100%)',
|
|
68
|
+
snow: 'linear-gradient(135deg, #3a5a78 0%, #4d7396 50%, #608aab 100%)',
|
|
69
|
+
showers: 'linear-gradient(135deg, #142a48 0%, #1e3f68 50%, #2a5588 100%)',
|
|
70
|
+
thunderstorm: 'linear-gradient(135deg, #1a1530 0%, #2a2250 50%, #3a3070 100%)',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function getGradient(code: number): string {
|
|
74
|
+
const { condition } = getWeatherMeta(code);
|
|
75
|
+
return CONDITION_GRADIENTS[condition];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Icon Accent Colors ─────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const CONDITION_COLORS: Record<WeatherCondition, string> = {
|
|
81
|
+
clear: '#fbbf24',
|
|
82
|
+
'partly-cloudy': '#fbbf24',
|
|
83
|
+
cloudy: '#94a3b8',
|
|
84
|
+
fog: '#cbd5e1',
|
|
85
|
+
drizzle: '#7dd3fc',
|
|
86
|
+
rain: '#60a5fa',
|
|
87
|
+
snow: '#e0e7ff',
|
|
88
|
+
showers: '#60a5fa',
|
|
89
|
+
thunderstorm: '#c4b5fd',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function getConditionColor(code: number): string {
|
|
93
|
+
const { condition } = getWeatherMeta(code);
|
|
94
|
+
return CONDITION_COLORS[condition];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Weather Visuals (meta + color + gradient in one lookup) ────────────────
|
|
98
|
+
|
|
99
|
+
export interface WeatherVisuals {
|
|
100
|
+
meta: WeatherMeta;
|
|
101
|
+
color: string;
|
|
102
|
+
gradient: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Single lookup for all display properties of a weather code. */
|
|
106
|
+
export function getWeatherVisuals(code: number): WeatherVisuals {
|
|
107
|
+
const meta = getWeatherMeta(code);
|
|
108
|
+
return {
|
|
109
|
+
meta,
|
|
110
|
+
color: CONDITION_COLORS[meta.condition],
|
|
111
|
+
gradient: CONDITION_GRADIENTS[meta.condition],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Temperature Formatting ────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export function formatTemp(celsius: number, unit: string): string {
|
|
118
|
+
if (unit === 'fahrenheit') {
|
|
119
|
+
return `${Math.round(celsius * 9 / 5 + 32)}`;
|
|
120
|
+
}
|
|
121
|
+
return `${Math.round(celsius)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function tempUnit(unit: string): string {
|
|
125
|
+
return unit === 'fahrenheit' ? '\u00b0F' : '\u00b0C';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** formatTemp + tempUnit combined — e.g. "23°C" or "72°F" */
|
|
129
|
+
export function formatTempWithUnit(celsius: number, unit: string): string {
|
|
130
|
+
return `${formatTemp(celsius, unit)}${tempUnit(unit)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Wind Direction ────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export function windDirectionLabel(degrees: number): string {
|
|
136
|
+
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
|
|
137
|
+
const index = Math.round(degrees / 45) % 8;
|
|
138
|
+
return dirs[index];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Day Name Formatting ───────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
import type { I18nRef } from '@brika/sdk/bricks';
|
|
144
|
+
|
|
145
|
+
export type TranslateFn = (key: string, params?: Record<string, string | number>) => I18nRef;
|
|
146
|
+
|
|
147
|
+
const WEEKDAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
|
|
148
|
+
|
|
149
|
+
export function dayName(dateStr: string, t: TranslateFn): I18nRef {
|
|
150
|
+
const date = new Date(dateStr + 'T00:00:00');
|
|
151
|
+
const today = new Date();
|
|
152
|
+
today.setHours(0, 0, 0, 0);
|
|
153
|
+
|
|
154
|
+
const tomorrow = new Date(today);
|
|
155
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
156
|
+
|
|
157
|
+
if (date.getTime() === today.getTime()) return t('days.today');
|
|
158
|
+
if (date.getTime() === tomorrow.getTime()) return t('days.tomorrow');
|
|
159
|
+
return t(`days.${WEEKDAY_KEYS[date.getDay()]}`);
|
|
160
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared weather store — city-keyed, one polling loop per city.
|
|
3
|
+
*
|
|
4
|
+
* Built on `defineSharedStore` from the SDK. Multiple brick instances
|
|
5
|
+
* can show different cities; polling is reference-counted per city.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // In a brick:
|
|
10
|
+
* const weather = useWeatherMap()['Zurich'] ?? DEFAULT_WEATHER;
|
|
11
|
+
* useEffect(() => acquirePolling('Zurich'), []);
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { log } from '@brika/sdk';
|
|
16
|
+
import { defineSharedStore } from '@brika/sdk/bricks';
|
|
17
|
+
import { fetchWeather, geocodeCity } from './api';
|
|
18
|
+
import type { GeoLocation, WeatherState } from './types';
|
|
19
|
+
|
|
20
|
+
// ─── Default state for a city with no data yet ──────────────────────────────
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_WEATHER: WeatherState = {
|
|
23
|
+
location: null,
|
|
24
|
+
current: null,
|
|
25
|
+
daily: [],
|
|
26
|
+
hourly: [],
|
|
27
|
+
lastUpdated: null,
|
|
28
|
+
loading: false,
|
|
29
|
+
error: null,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ─── Store: city → weather data ──────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export const useWeatherMap = defineSharedStore<Record<string, WeatherState>>({});
|
|
35
|
+
|
|
36
|
+
// ─── Per-city polling ────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const POLL_MS = 10 * 60 * 1000; // 10 minutes
|
|
39
|
+
|
|
40
|
+
interface CityEntry {
|
|
41
|
+
refCount: number;
|
|
42
|
+
timer: ReturnType<typeof setInterval> | null;
|
|
43
|
+
cachedLocation: GeoLocation | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cities = new Map<string, CityEntry>();
|
|
47
|
+
|
|
48
|
+
function getEntry(city: string): CityEntry {
|
|
49
|
+
let entry = cities.get(city);
|
|
50
|
+
if (!entry) {
|
|
51
|
+
entry = { refCount: 0, timer: null, cachedLocation: null };
|
|
52
|
+
cities.set(city, entry);
|
|
53
|
+
}
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function updateCity(city: string, patch: Partial<WeatherState>): void {
|
|
58
|
+
useWeatherMap.set((prev) => ({
|
|
59
|
+
...prev,
|
|
60
|
+
[city]: { ...(prev[city] ?? DEFAULT_WEATHER), ...patch },
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function pollCity(city: string): Promise<void> {
|
|
65
|
+
const entry = cities.get(city);
|
|
66
|
+
if (!entry) return;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
let location = entry.cachedLocation;
|
|
70
|
+
if (!location) {
|
|
71
|
+
updateCity(city, { loading: true, error: null });
|
|
72
|
+
location = await geocodeCity(city);
|
|
73
|
+
if (!location) {
|
|
74
|
+
updateCity(city, { loading: false, error: `City not found: ${city}` });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
entry.cachedLocation = location;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const data = await fetchWeather(location.latitude, location.longitude);
|
|
81
|
+
if (!data) {
|
|
82
|
+
updateCity(city, { loading: false, error: 'Failed to fetch weather data' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
useWeatherMap.set((prev) => ({
|
|
87
|
+
...prev,
|
|
88
|
+
[city]: {
|
|
89
|
+
location,
|
|
90
|
+
current: data.current,
|
|
91
|
+
daily: data.daily,
|
|
92
|
+
hourly: data.hourly,
|
|
93
|
+
lastUpdated: Date.now(),
|
|
94
|
+
loading: false,
|
|
95
|
+
error: null,
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log.error(`Weather poll for "${city}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
100
|
+
updateCity(city, { loading: false, error: 'Network error' });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Acquire polling for a specific city.
|
|
106
|
+
* Starts polling on first subscriber, stops on last.
|
|
107
|
+
* Returns a release function to call on unmount.
|
|
108
|
+
*/
|
|
109
|
+
export function acquirePolling(city: string): () => void {
|
|
110
|
+
const entry = getEntry(city);
|
|
111
|
+
entry.refCount++;
|
|
112
|
+
|
|
113
|
+
if (entry.refCount === 1) {
|
|
114
|
+
pollCity(city);
|
|
115
|
+
entry.timer = setInterval(() => pollCity(city), POLL_MS);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let released = false;
|
|
119
|
+
return () => {
|
|
120
|
+
if (released) return;
|
|
121
|
+
released = true;
|
|
122
|
+
entry.refCount--;
|
|
123
|
+
if (entry.refCount === 0 && entry.timer) {
|
|
124
|
+
clearInterval(entry.timer);
|
|
125
|
+
entry.timer = null;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|