@brika/plugin-weather 0.3.0 → 0.3.1
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/package.json +8 -4
- package/src/bricks/compact.tsx +66 -60
- package/src/bricks/current.tsx +176 -108
- package/src/bricks/forecast.tsx +147 -122
- package/src/bricks/shared.tsx +83 -42
- package/src/index.tsx +206 -6
- package/src/utils.ts +1 -1
- package/src/weather-store.ts +1 -2
- package/src/use-weather.ts +0 -75
package/package.json
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"$schema": "https://schema.brika.dev/plugin.schema.json",
|
|
3
3
|
"name": "@brika/plugin-weather",
|
|
4
4
|
"displayName": "Weather",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.1",
|
|
6
6
|
"description": "Beautiful weather display with current conditions, forecast, and compact widget",
|
|
7
7
|
"author": "BRIKA Team",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "https://github.com/
|
|
11
|
+
"url": "https://github.com/brikalabs/brika.git",
|
|
12
12
|
"directory": "plugins/weather"
|
|
13
13
|
},
|
|
14
14
|
"icon": "./icon.svg",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
],
|
|
33
33
|
"scripts": {
|
|
34
34
|
"link": "bun link",
|
|
35
|
-
"
|
|
35
|
+
"typecheck": "tsgo --noEmit",
|
|
36
36
|
"prepublishOnly": "brika-verify-plugin"
|
|
37
37
|
},
|
|
38
38
|
"preferences": [
|
|
@@ -170,7 +170,11 @@
|
|
|
170
170
|
}
|
|
171
171
|
],
|
|
172
172
|
"dependencies": {
|
|
173
|
-
"@brika/sdk": "0.3.
|
|
173
|
+
"@brika/sdk": "0.3.1"
|
|
174
|
+
},
|
|
175
|
+
"devDependencies": {
|
|
176
|
+
"class-variance-authority": "^0.7.1",
|
|
177
|
+
"clsx": "^2.1.1"
|
|
174
178
|
},
|
|
175
179
|
"files": [
|
|
176
180
|
"src",
|
package/src/bricks/compact.tsx
CHANGED
|
@@ -1,60 +1,66 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Compact weather brick — client-side rendered.
|
|
3
|
+
*
|
|
4
|
+
* This brick runs in the browser as a real React component.
|
|
5
|
+
* Weather data is pushed from the plugin process via setBrickData().
|
|
6
|
+
* Imports are resolved by the bridge (globalThis.__brika) at build time.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useBrickConfig, useBrickData } from '@brika/sdk/brick-views';
|
|
10
|
+
import { useLocale } from '@brika/sdk/ui-kit/hooks';
|
|
11
|
+
import { MapPin, Thermometer } from 'lucide-react';
|
|
12
|
+
import { CityError, formatTempWithUnit, LoadingSpinner, resolveCity, resolveUnit } from './shared';
|
|
13
|
+
|
|
14
|
+
// ─── Types (inlined — can't import from plugin runtime code) ────────────────
|
|
15
|
+
|
|
16
|
+
interface CompactCityData {
|
|
17
|
+
temperature: number;
|
|
18
|
+
apparentTemperature: number;
|
|
19
|
+
conditionKey: string;
|
|
20
|
+
city: string;
|
|
21
|
+
gradient: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CompactWeatherData {
|
|
25
|
+
defaultCity: string;
|
|
26
|
+
unit: string;
|
|
27
|
+
cities: Record<string, CompactCityData>;
|
|
28
|
+
cityErrors?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Component ──────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export default function CompactWeather() {
|
|
34
|
+
const data = useBrickData<CompactWeatherData>();
|
|
35
|
+
const config = useBrickConfig();
|
|
36
|
+
const { t } = useLocale();
|
|
37
|
+
|
|
38
|
+
if (!data) return <LoadingSpinner />;
|
|
39
|
+
|
|
40
|
+
const cityKey = resolveCity(config, data.defaultCity);
|
|
41
|
+
const cityData = data.cities[cityKey];
|
|
42
|
+
|
|
43
|
+
if (!cityData) return <CityError error={data.cityErrors?.[cityKey]} />;
|
|
44
|
+
|
|
45
|
+
const unit = resolveUnit(config, data.unit);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className="flex h-full flex-col justify-center gap-2 rounded-lg p-3"
|
|
50
|
+
style={{ background: cityData.gradient }}
|
|
51
|
+
>
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<span className="text-2xl font-bold text-white">{formatTempWithUnit(cityData.temperature, unit)}</span>
|
|
54
|
+
<span className="text-sm text-white/70">{t(`conditions.${cityData.conditionKey}`)}</span>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex items-center gap-1 text-xs text-white/60">
|
|
57
|
+
<MapPin className="size-3" />
|
|
58
|
+
<span className="truncate">{cityData.city}</span>
|
|
59
|
+
</div>
|
|
60
|
+
<div className="flex items-center gap-1 text-xs text-white/50">
|
|
61
|
+
<Thermometer className="size-3" />
|
|
62
|
+
<span>{t('stats.feelsLike')} {formatTempWithUnit(cityData.apparentTemperature, unit)}</span>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/bricks/current.tsx
CHANGED
|
@@ -1,15 +1,66 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Current weather brick — client-side rendered.
|
|
3
|
+
*
|
|
4
|
+
* Displays live weather conditions with temperature, feels-like,
|
|
5
|
+
* humidity, wind, pressure, and a gradient background matching the condition.
|
|
6
|
+
* Data is pushed from the plugin process via setBrickData().
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useBrickConfig, useBrickData, useBrickSize } from '@brika/sdk/brick-views';
|
|
10
|
+
import { useLocale } from '@brika/sdk/ui-kit/hooks';
|
|
11
|
+
import {
|
|
12
|
+
Droplets,
|
|
13
|
+
Gauge,
|
|
14
|
+
MapPin,
|
|
15
|
+
Thermometer,
|
|
16
|
+
Wind,
|
|
17
|
+
} from 'lucide-react';
|
|
18
|
+
import type { ComponentType } from 'react';
|
|
3
19
|
import {
|
|
20
|
+
CityError,
|
|
4
21
|
formatTemp,
|
|
5
22
|
formatTempWithUnit,
|
|
6
|
-
|
|
23
|
+
LoadingSpinner,
|
|
24
|
+
resolveCity,
|
|
25
|
+
resolveUnit,
|
|
7
26
|
tempUnit,
|
|
8
|
-
|
|
9
|
-
} from '
|
|
10
|
-
|
|
27
|
+
WeatherIcon,
|
|
28
|
+
} from './shared';
|
|
29
|
+
|
|
30
|
+
// ─── Types (inlined — can't import from plugin runtime code) ────────────────
|
|
31
|
+
|
|
32
|
+
interface CurrentCityData {
|
|
33
|
+
temperature: number;
|
|
34
|
+
apparentTemperature: number;
|
|
35
|
+
humidity: number;
|
|
36
|
+
weatherCode: number;
|
|
37
|
+
windSpeed: number;
|
|
38
|
+
windDirection: number;
|
|
39
|
+
pressure: number;
|
|
40
|
+
conditionKey: string;
|
|
41
|
+
icon: string;
|
|
42
|
+
gradient: string;
|
|
43
|
+
color: string;
|
|
44
|
+
city: string;
|
|
45
|
+
lastUpdated: number | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CurrentWeatherData {
|
|
49
|
+
defaultCity: string;
|
|
50
|
+
unit: string;
|
|
51
|
+
cities: Record<string, CurrentCityData>;
|
|
52
|
+
cityErrors?: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function windDirectionLabel(degrees: number): string {
|
|
58
|
+
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
|
|
59
|
+
const index = Math.round(degrees / 45) % 8;
|
|
60
|
+
return dirs[index];
|
|
61
|
+
}
|
|
11
62
|
|
|
12
|
-
// ───
|
|
63
|
+
// ─── Stat row ────────────────────────────────────────────────────────────────
|
|
13
64
|
|
|
14
65
|
function WeatherStat({
|
|
15
66
|
icon,
|
|
@@ -17,117 +68,134 @@ function WeatherStat({
|
|
|
17
68
|
value,
|
|
18
69
|
suffix,
|
|
19
70
|
}: Readonly<{
|
|
20
|
-
icon: string
|
|
21
|
-
label: string
|
|
71
|
+
icon: ComponentType<{ className?: string }>;
|
|
72
|
+
label: string;
|
|
22
73
|
value: string;
|
|
23
74
|
suffix?: string;
|
|
24
75
|
}>) {
|
|
76
|
+
const StatIcon = icon;
|
|
25
77
|
return (
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
<
|
|
30
|
-
</
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
{suffix ?
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
</Row>
|
|
37
|
-
</Column>
|
|
78
|
+
<div className="flex flex-1 flex-col gap-1">
|
|
79
|
+
<div className="flex items-center gap-1.5">
|
|
80
|
+
<StatIcon className="size-3 shrink-0 text-white/50" />
|
|
81
|
+
<span className="truncate text-[10px] text-white/60">{label}</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex items-baseline gap-1">
|
|
84
|
+
<span className="font-bold text-white">{value}</span>
|
|
85
|
+
{suffix ? <span className="text-[10px] text-white/50">{suffix}</span> : null}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
38
88
|
);
|
|
39
89
|
}
|
|
40
90
|
|
|
41
|
-
// ───
|
|
91
|
+
// ─── Component ──────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export default function CurrentWeather() {
|
|
94
|
+
const data = useBrickData<CurrentWeatherData>();
|
|
95
|
+
const config = useBrickConfig();
|
|
96
|
+
const { width, height } = useBrickSize();
|
|
97
|
+
const { t } = useLocale();
|
|
42
98
|
|
|
43
|
-
|
|
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();
|
|
99
|
+
if (!data) return <LoadingSpinner />;
|
|
54
100
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!weather.current || !weather.location) return <WeatherLoading />;
|
|
101
|
+
const cityKey = resolveCity(config, data.defaultCity);
|
|
102
|
+
const d = data.cities[cityKey];
|
|
58
103
|
|
|
59
|
-
|
|
104
|
+
if (!d) return <CityError error={data.cityErrors?.[cityKey]} />;
|
|
60
105
|
|
|
106
|
+
const unit = resolveUnit(config, data.unit);
|
|
107
|
+
const isCompact = width <= 2 && height <= 1;
|
|
108
|
+
|
|
109
|
+
// ─── Compact layout ─────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
if (isCompact) {
|
|
61
112
|
return (
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
</
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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>
|
|
113
|
+
<div
|
|
114
|
+
className="flex h-full flex-col justify-center gap-1.5 rounded-lg p-3"
|
|
115
|
+
style={{ background: d.gradient }}
|
|
116
|
+
>
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<WeatherIcon name={d.icon} className="size-5 text-white/80" />
|
|
119
|
+
<span className="text-xl font-bold text-white">
|
|
120
|
+
{formatTempWithUnit(d.temperature, unit)}
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="flex items-center gap-1 text-xs text-white/60">
|
|
124
|
+
<MapPin className="size-3 shrink-0" />
|
|
125
|
+
<span className="truncate">{d.city}</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
131
128
|
);
|
|
132
|
-
}
|
|
133
|
-
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Default layout ─────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
className="flex h-full flex-col gap-3 rounded-lg p-4"
|
|
136
|
+
style={{ background: d.gradient }}
|
|
137
|
+
>
|
|
138
|
+
{/* Header: location + condition label */}
|
|
139
|
+
<div className="flex items-center gap-1.5">
|
|
140
|
+
<MapPin className="size-3.5 shrink-0 text-white/50" />
|
|
141
|
+
<span className="truncate font-bold text-white">{d.city}</span>
|
|
142
|
+
<span className="ml-auto shrink-0 text-xs text-white/60">{t(`conditions.${d.conditionKey}`)}</span>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{/* Main: icon + temp + feels like */}
|
|
146
|
+
<div className="flex flex-1 items-center gap-3">
|
|
147
|
+
<div className="flex size-12 items-center justify-center rounded-full" style={{ backgroundColor: `${d.color}33` }}>
|
|
148
|
+
<WeatherIcon name={d.icon} className="size-7" color={d.color} />
|
|
149
|
+
</div>
|
|
150
|
+
<div className="flex flex-col gap-0.5">
|
|
151
|
+
<span className="text-3xl font-bold leading-none text-white">
|
|
152
|
+
{formatTempWithUnit(d.temperature, unit)}
|
|
153
|
+
</span>
|
|
154
|
+
<span className="text-xs text-white/60">
|
|
155
|
+
{t('stats.feelsLike')} {formatTempWithUnit(d.apparentTemperature, unit)}
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Divider */}
|
|
161
|
+
<div className="h-px bg-white/12" />
|
|
162
|
+
|
|
163
|
+
{/* Stats grid */}
|
|
164
|
+
<div className="grid auto-cols-fr grid-flow-col gap-3">
|
|
165
|
+
<WeatherStat
|
|
166
|
+
icon={Thermometer}
|
|
167
|
+
label={t('stats.feelsLike')}
|
|
168
|
+
value={formatTemp(d.apparentTemperature, unit)}
|
|
169
|
+
suffix={tempUnit(unit)}
|
|
170
|
+
/>
|
|
171
|
+
<WeatherStat
|
|
172
|
+
icon={Droplets}
|
|
173
|
+
label={t('stats.humidity')}
|
|
174
|
+
value={`${d.humidity}`}
|
|
175
|
+
suffix="%"
|
|
176
|
+
/>
|
|
177
|
+
<WeatherStat
|
|
178
|
+
icon={Wind}
|
|
179
|
+
label={t('stats.wind')}
|
|
180
|
+
value={`${Math.round(d.windSpeed)}`}
|
|
181
|
+
suffix={`km/h ${windDirectionLabel(d.windDirection)}`}
|
|
182
|
+
/>
|
|
183
|
+
{width >= 3 ? (
|
|
184
|
+
<WeatherStat
|
|
185
|
+
icon={Gauge}
|
|
186
|
+
label={t('stats.pressure')}
|
|
187
|
+
value={`${Math.round(d.pressure)}`}
|
|
188
|
+
suffix="hPa"
|
|
189
|
+
/>
|
|
190
|
+
) : null}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Updated timestamp */}
|
|
194
|
+
{d.lastUpdated ? (
|
|
195
|
+
<span className="text-[10px] text-white/35">
|
|
196
|
+
{t('ui.updated', { time: new Date(d.lastUpdated).toLocaleTimeString() })}
|
|
197
|
+
</span>
|
|
198
|
+
) : null}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
package/src/bricks/forecast.tsx
CHANGED
|
@@ -1,136 +1,161 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Forecast weather brick — client-side rendered.
|
|
3
|
+
*
|
|
4
|
+
* Displays a multi-day weather forecast with highs, lows, and condition icons.
|
|
5
|
+
* Responsive: grid layout when wide, list layout when narrow.
|
|
6
|
+
* Data is pushed from the plugin process via setBrickData().
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useBrickConfig, useBrickData, useBrickSize } from '@brika/sdk/brick-views';
|
|
10
|
+
import { useLocale } from '@brika/sdk/ui-kit/hooks';
|
|
11
|
+
import { MapPin } from 'lucide-react';
|
|
12
|
+
import {
|
|
13
|
+
CityError,
|
|
14
|
+
formatTempWithUnit,
|
|
15
|
+
LoadingSpinner,
|
|
16
|
+
resolveCity,
|
|
17
|
+
resolveUnit,
|
|
18
|
+
WeatherIcon,
|
|
19
|
+
} from './shared';
|
|
20
|
+
|
|
21
|
+
// ─── Types (inlined — can't import from plugin runtime code) ────────────────
|
|
22
|
+
|
|
23
|
+
interface ForecastDay {
|
|
24
|
+
date: string;
|
|
25
|
+
weatherCode: number;
|
|
26
|
+
tempMax: number;
|
|
27
|
+
tempMin: number;
|
|
28
|
+
icon: string;
|
|
29
|
+
color: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ForecastCityData {
|
|
33
|
+
days: ForecastDay[];
|
|
34
|
+
gradient: string;
|
|
35
|
+
city: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ForecastWeatherData {
|
|
39
|
+
defaultCity: string;
|
|
13
40
|
unit: string;
|
|
41
|
+
cities: Record<string, ForecastCityData>;
|
|
42
|
+
cityErrors?: Record<string, string>;
|
|
14
43
|
}
|
|
15
44
|
|
|
16
|
-
// ───
|
|
45
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const WEEKDAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
|
|
48
|
+
|
|
49
|
+
function dayLabel(dateStr: string, t: (key: string) => string): string {
|
|
50
|
+
const date = new Date(dateStr + 'T00:00:00');
|
|
51
|
+
const today = new Date();
|
|
52
|
+
today.setHours(0, 0, 0, 0);
|
|
17
53
|
|
|
18
|
-
|
|
19
|
-
|
|
54
|
+
const tomorrow = new Date(today);
|
|
55
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
20
56
|
|
|
57
|
+
if (date.getTime() === today.getTime()) return t('days.today');
|
|
58
|
+
if (date.getTime() === tomorrow.getTime()) return t('days.tomorrow');
|
|
59
|
+
return t(`days.${WEEKDAY_KEYS[date.getDay()]}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Day row (narrow list layout) ───────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function DayRow({ day, unit }: Readonly<{ day: ForecastDay; unit: string }>) {
|
|
65
|
+
const { t } = useLocale();
|
|
21
66
|
return (
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
</
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
</
|
|
67
|
+
<div className="flex items-center gap-2">
|
|
68
|
+
<div className="flex size-7 items-center justify-center rounded-full" style={{ backgroundColor: `${day.color}33` }}>
|
|
69
|
+
<WeatherIcon name={day.icon} color={day.color} className="size-4" />
|
|
70
|
+
</div>
|
|
71
|
+
<span className="flex-1 truncate text-sm font-medium text-white">{dayLabel(day.date, t)}</span>
|
|
72
|
+
<span className="font-bold text-white">{formatTempWithUnit(day.tempMax, unit)}</span>
|
|
73
|
+
<span className="text-sm text-white/35">{formatTempWithUnit(day.tempMin, unit)}</span>
|
|
74
|
+
</div>
|
|
30
75
|
);
|
|
31
76
|
}
|
|
32
77
|
|
|
33
|
-
// ───
|
|
34
|
-
|
|
35
|
-
function DayCell({ dayLabel, code, high, low, unit }: Readonly<DayProps>) {
|
|
36
|
-
const { meta, color } = getWeatherVisuals(code);
|
|
78
|
+
// ─── Day cell (wide grid layout) ────────────────────────────────────────────
|
|
37
79
|
|
|
80
|
+
function DayCell({ day, unit }: Readonly<{ day: ForecastDay; unit: string }>) {
|
|
81
|
+
const { t } = useLocale();
|
|
38
82
|
return (
|
|
39
|
-
<
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
83
|
+
<div className="flex flex-col items-center gap-1.5">
|
|
84
|
+
<span className="text-[11px] font-semibold text-white/70">{dayLabel(day.date, t)}</span>
|
|
85
|
+
<div className="flex size-8 items-center justify-center rounded-full" style={{ backgroundColor: `${day.color}33` }}>
|
|
86
|
+
<WeatherIcon name={day.icon} color={day.color} className="size-5" />
|
|
87
|
+
</div>
|
|
88
|
+
<div className="flex items-baseline gap-1">
|
|
89
|
+
<span className="text-sm font-bold text-white">{formatTempWithUnit(day.tempMax, unit)}</span>
|
|
90
|
+
<span className="text-[11px] text-white/35">{formatTempWithUnit(day.tempMin, unit)}</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
47
93
|
);
|
|
48
94
|
}
|
|
49
95
|
|
|
50
|
-
// ───
|
|
51
|
-
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
);
|
|
96
|
+
// ─── Component ──────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export default function WeatherForecast() {
|
|
99
|
+
const data = useBrickData<ForecastWeatherData>();
|
|
100
|
+
const config = useBrickConfig();
|
|
101
|
+
const { width, height } = useBrickSize();
|
|
102
|
+
const { t } = useLocale();
|
|
103
|
+
|
|
104
|
+
if (!data) return <LoadingSpinner />;
|
|
105
|
+
|
|
106
|
+
const cityKey = resolveCity(config, data.defaultCity);
|
|
107
|
+
const cityData = data.cities[cityKey];
|
|
108
|
+
|
|
109
|
+
if (!cityData) return <CityError error={data.cityErrors?.[cityKey]} />;
|
|
110
|
+
|
|
111
|
+
const unit = resolveUnit(config, data.unit);
|
|
112
|
+
const configDays = typeof config.days === 'number' ? config.days : 7;
|
|
113
|
+
const useGrid = width >= 4;
|
|
114
|
+
|
|
115
|
+
// Grid: cap days by width. List: cap by height.
|
|
116
|
+
let maxVisible = width;
|
|
117
|
+
if (!useGrid) {
|
|
118
|
+
if (height >= 3) maxVisible = 7;
|
|
119
|
+
else if (height >= 2) maxVisible = 5;
|
|
120
|
+
else maxVisible = 3;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const visibleDays = cityData.days.slice(0, Math.min(configDays, maxVisible));
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
className="flex h-full flex-col gap-2 rounded-lg p-4"
|
|
128
|
+
style={{ background: cityData.gradient }}
|
|
129
|
+
>
|
|
130
|
+
{/* Header — location left, day count right */}
|
|
131
|
+
<div className="flex items-center gap-1.5">
|
|
132
|
+
<MapPin className="size-3.5 shrink-0 text-white/50" />
|
|
133
|
+
<span className="truncate font-semibold text-white">{cityData.city}</span>
|
|
134
|
+
<span className="ml-auto shrink-0 text-xs text-white/45">
|
|
135
|
+
{t('ui.dayForecast', { count: visibleDays.length })}
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Divider */}
|
|
140
|
+
<div className="h-px bg-white/10" />
|
|
141
|
+
|
|
142
|
+
{/* Forecast — grid when wide, list when narrow */}
|
|
143
|
+
{useGrid ? (
|
|
144
|
+
<div
|
|
145
|
+
className="flex flex-1 items-center justify-around"
|
|
146
|
+
style={{ display: 'grid', gridTemplateColumns: `repeat(${visibleDays.length}, 1fr)`, gap: '0.75rem' }}
|
|
147
|
+
>
|
|
148
|
+
{visibleDays.map((day) => (
|
|
149
|
+
<DayCell key={day.date} day={day} unit={unit} />
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
) : (
|
|
153
|
+
<div className="flex flex-1 flex-col justify-between gap-1.5">
|
|
154
|
+
{visibleDays.map((day) => (
|
|
155
|
+
<DayRow key={day.date} day={day} unit={unit} />
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
package/src/bricks/shared.tsx
CHANGED
|
@@ -1,49 +1,90 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared weather brick utilities — temperature formatting, icon map,
|
|
3
|
+
* city resolution, and loading/error states.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Cloud,
|
|
8
|
+
CloudDrizzle,
|
|
9
|
+
CloudFog,
|
|
10
|
+
CloudLightning,
|
|
11
|
+
CloudRain,
|
|
12
|
+
CloudRainWind,
|
|
13
|
+
CloudSun,
|
|
14
|
+
Loader2,
|
|
15
|
+
MapPin,
|
|
16
|
+
Snowflake,
|
|
17
|
+
Sun,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
import type { ComponentType } from 'react';
|
|
20
|
+
|
|
21
|
+
// ─── Temperature helpers ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export function formatTemp(celsius: number, unit: string): string {
|
|
24
|
+
if (unit === 'fahrenheit') return `${Math.round(celsius * 9 / 5 + 32)}`;
|
|
25
|
+
return `${Math.round(celsius)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function tempUnit(unit: string): string {
|
|
29
|
+
return unit === 'fahrenheit' ? '\u00b0F' : '\u00b0C';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatTempWithUnit(celsius: number, unit: string): string {
|
|
33
|
+
return `${formatTemp(celsius, unit)}${tempUnit(unit)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Icon map ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export const ICON_MAP: Record<string, ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
|
|
39
|
+
'sun': Sun,
|
|
40
|
+
'cloud-sun': CloudSun,
|
|
41
|
+
'cloud': Cloud,
|
|
42
|
+
'cloud-fog': CloudFog,
|
|
43
|
+
'cloud-drizzle': CloudDrizzle,
|
|
44
|
+
'cloud-rain': CloudRain,
|
|
45
|
+
'snowflake': Snowflake,
|
|
46
|
+
'cloud-rain-wind': CloudRainWind,
|
|
47
|
+
'cloud-lightning': CloudLightning,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function WeatherIcon({ name, className, color }: Readonly<{ name: string; className?: string; color?: string }>) {
|
|
51
|
+
const IconComponent = ICON_MAP[name] ?? Cloud;
|
|
52
|
+
return <IconComponent className={className} style={color ? { color } : undefined} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── City + unit resolution ───────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export function resolveCity(config: Record<string, unknown>, defaultCity: string): string {
|
|
58
|
+
const raw = typeof config.city === 'string' ? config.city.trim() : '';
|
|
59
|
+
return raw || defaultCity;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveUnit(config: Record<string, unknown>, dataUnit: string): string {
|
|
63
|
+
const raw = typeof config.unit === 'string' ? config.unit : '';
|
|
64
|
+
return raw && raw !== 'default' ? raw : dataUnit;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Loading / error states ──────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export function LoadingSpinner() {
|
|
34
70
|
return (
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
</Column>
|
|
71
|
+
<div className="flex h-full items-center justify-center">
|
|
72
|
+
<Loader2 className="size-5 animate-spin text-white/50" />
|
|
73
|
+
</div>
|
|
39
74
|
);
|
|
40
75
|
}
|
|
41
76
|
|
|
42
|
-
export function
|
|
77
|
+
export function CityError({ error }: Readonly<{ error?: string }>) {
|
|
43
78
|
return (
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
79
|
+
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
|
|
80
|
+
{error ? (
|
|
81
|
+
<>
|
|
82
|
+
<MapPin className="size-5 text-white/40" />
|
|
83
|
+
<span className="text-sm text-white/60">{error}</span>
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
<Loader2 className="size-5 animate-spin text-white/50" />
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
48
89
|
);
|
|
49
90
|
}
|
package/src/index.tsx
CHANGED
|
@@ -1,12 +1,212 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getDeviceLocation, getPreferences, setBrickData } from '@brika/sdk';
|
|
2
|
+
import {
|
|
3
|
+
log,
|
|
4
|
+
onBrickConfigChange,
|
|
5
|
+
onInit,
|
|
6
|
+
onPreferencesChange,
|
|
7
|
+
onStop,
|
|
8
|
+
} from '@brika/sdk/lifecycle';
|
|
9
|
+
import { getConditionColor, getGradient, getWeatherMeta } from './utils';
|
|
10
|
+
import { acquirePolling, useWeatherMap } from './weather-store';
|
|
2
11
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
12
|
+
// All bricks are client-rendered — no server-side brick exports.
|
|
13
|
+
|
|
14
|
+
// ─── Preferences ────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
interface WeatherPrefs {
|
|
17
|
+
city?: string;
|
|
18
|
+
unit?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getUnit(prefs?: WeatherPrefs): string {
|
|
22
|
+
return (prefs ?? getPreferences<WeatherPrefs>()).unit ?? 'celsius';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FALLBACK_CITY = 'Zurich';
|
|
26
|
+
|
|
27
|
+
let defaultCity = FALLBACK_CITY;
|
|
28
|
+
let currentUnit = getUnit();
|
|
29
|
+
|
|
30
|
+
// ─── Multi-city polling ─────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const cityReleases = new Map<string, () => void>();
|
|
33
|
+
|
|
34
|
+
function ensurePolling(city: string): void {
|
|
35
|
+
if (!city || cityReleases.has(city)) return;
|
|
36
|
+
cityReleases.set(city, acquirePolling(city));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stopPolling(city: string): void {
|
|
40
|
+
const release = cityReleases.get(city);
|
|
41
|
+
if (release) {
|
|
42
|
+
release();
|
|
43
|
+
cityReleases.delete(city);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setDefaultCity(city: string): void {
|
|
48
|
+
if (!city || city === defaultCity) return;
|
|
49
|
+
stopPolling(defaultCity);
|
|
50
|
+
defaultCity = city;
|
|
51
|
+
ensurePolling(defaultCity);
|
|
52
|
+
pushBrickData();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Push brick data to client-side bricks ──────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function pushBrickData() {
|
|
58
|
+
const weatherMap = useWeatherMap.get();
|
|
59
|
+
|
|
60
|
+
// Build per-city formatted data for each brick type
|
|
61
|
+
const compactCities: Record<string, unknown> = {};
|
|
62
|
+
const currentCities: Record<string, unknown> = {};
|
|
63
|
+
const forecastCities: Record<string, unknown> = {};
|
|
64
|
+
const cityErrors: Record<string, string> = {};
|
|
65
|
+
|
|
66
|
+
for (const [city, weather] of Object.entries(weatherMap)) {
|
|
67
|
+
// Surface error states so the client can show feedback instead of a
|
|
68
|
+
// permanent loader when a city name is invalid or the API is down.
|
|
69
|
+
if (weather?.error) {
|
|
70
|
+
cityErrors[city] = weather.error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!weather?.current || !weather.location) continue;
|
|
74
|
+
|
|
75
|
+
const code = weather.current.weatherCode;
|
|
76
|
+
const meta = getWeatherMeta(code);
|
|
77
|
+
const gradient = getGradient(code);
|
|
78
|
+
const color = getConditionColor(code);
|
|
79
|
+
const conditionKey = meta.labelKey.replace('conditions.', '');
|
|
80
|
+
|
|
81
|
+
compactCities[city] = {
|
|
82
|
+
temperature: weather.current.temperature,
|
|
83
|
+
apparentTemperature: weather.current.apparentTemperature,
|
|
84
|
+
conditionKey,
|
|
85
|
+
city: weather.location.name,
|
|
86
|
+
gradient,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
currentCities[city] = {
|
|
90
|
+
temperature: weather.current.temperature,
|
|
91
|
+
apparentTemperature: weather.current.apparentTemperature,
|
|
92
|
+
humidity: weather.current.humidity,
|
|
93
|
+
weatherCode: code,
|
|
94
|
+
windSpeed: weather.current.windSpeed,
|
|
95
|
+
windDirection: weather.current.windDirection,
|
|
96
|
+
pressure: weather.current.pressure,
|
|
97
|
+
conditionKey,
|
|
98
|
+
icon: meta.icon,
|
|
99
|
+
gradient,
|
|
100
|
+
color,
|
|
101
|
+
city: weather.location.name,
|
|
102
|
+
lastUpdated: weather.lastUpdated,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
forecastCities[city] = {
|
|
106
|
+
days: weather.daily.map((day) => {
|
|
107
|
+
const dayMeta = getWeatherMeta(day.weatherCode);
|
|
108
|
+
return {
|
|
109
|
+
date: day.date,
|
|
110
|
+
weatherCode: day.weatherCode,
|
|
111
|
+
tempMax: day.tempMax,
|
|
112
|
+
tempMin: day.tempMin,
|
|
113
|
+
icon: dayMeta.icon,
|
|
114
|
+
color: getConditionColor(day.weatherCode),
|
|
115
|
+
};
|
|
116
|
+
}),
|
|
117
|
+
gradient,
|
|
118
|
+
city: weather.location.name,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const shared = { defaultCity, unit: currentUnit, cityErrors };
|
|
123
|
+
setBrickData('compact', { ...shared, cities: compactCities });
|
|
124
|
+
setBrickData('current', { ...shared, cities: currentCities });
|
|
125
|
+
setBrickData('forecast', { ...shared, cities: forecastCities });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Subscribe to weather store changes and push data to all client bricks
|
|
129
|
+
useWeatherMap.subscribe(() => {
|
|
130
|
+
pushBrickData();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ─── Start polling immediately with fallback city ───────────────────────────
|
|
134
|
+
|
|
135
|
+
ensurePolling(defaultCity);
|
|
136
|
+
|
|
137
|
+
// ─── Brick config changes (per-instance city) ──────────────────────────────
|
|
138
|
+
|
|
139
|
+
/** Tracks which city each brick instance is currently polling. */
|
|
140
|
+
const instanceCities = new Map<string, string>();
|
|
141
|
+
|
|
142
|
+
onBrickConfigChange((instanceId, config) => {
|
|
143
|
+
const city = typeof config.city === 'string' ? config.city.trim() : '';
|
|
144
|
+
const previousCity = instanceCities.get(instanceId);
|
|
145
|
+
|
|
146
|
+
if (city === previousCity) return;
|
|
147
|
+
|
|
148
|
+
// Release the old city polling if no other instance uses it
|
|
149
|
+
if (previousCity) {
|
|
150
|
+
instanceCities.delete(instanceId);
|
|
151
|
+
if (previousCity !== defaultCity && ![...instanceCities.values()].includes(previousCity)) {
|
|
152
|
+
stopPolling(previousCity);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (city) {
|
|
157
|
+
instanceCities.set(instanceId, city);
|
|
158
|
+
ensurePolling(city);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ─── On init: refine default city from real preferences + hub location ──────
|
|
163
|
+
|
|
164
|
+
onInit(async () => {
|
|
165
|
+
const prefs = getPreferences<WeatherPrefs>();
|
|
166
|
+
const prefCity = prefs.city?.trim();
|
|
167
|
+
currentUnit = getUnit(prefs);
|
|
168
|
+
|
|
169
|
+
if (prefCity) {
|
|
170
|
+
setDefaultCity(prefCity);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No preference city — try hub location
|
|
175
|
+
try {
|
|
176
|
+
const location = await getDeviceLocation();
|
|
177
|
+
if (location?.city) {
|
|
178
|
+
log.info(`Auto-detected city from hub location: ${location.city}`);
|
|
179
|
+
setDefaultCity(location.city);
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Location permission denied or unavailable — keep fallback
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ─── Subsequent preference changes ──────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
onPreferencesChange<WeatherPrefs>((prefs) => {
|
|
189
|
+
const newUnit = getUnit(prefs);
|
|
190
|
+
const newCity = prefs.city?.trim() || '';
|
|
191
|
+
|
|
192
|
+
if (newUnit !== currentUnit) {
|
|
193
|
+
currentUnit = newUnit;
|
|
194
|
+
pushBrickData();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (newCity) {
|
|
198
|
+
setDefaultCity(newCity);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
7
203
|
|
|
8
|
-
// Lifecycle
|
|
9
204
|
onStop(() => {
|
|
205
|
+
for (const release of cityReleases.values()) {
|
|
206
|
+
release();
|
|
207
|
+
}
|
|
208
|
+
cityReleases.clear();
|
|
209
|
+
instanceCities.clear();
|
|
10
210
|
log.info('Weather plugin stopping');
|
|
11
211
|
});
|
|
12
212
|
|
package/src/utils.ts
CHANGED
|
@@ -140,7 +140,7 @@ export function windDirectionLabel(degrees: number): string {
|
|
|
140
140
|
|
|
141
141
|
// ─── Day Name Formatting ───────────────────────────────────────────────────
|
|
142
142
|
|
|
143
|
-
import type { I18nRef } from '@brika/sdk/
|
|
143
|
+
import type { I18nRef } from '@brika/sdk/ui-kit';
|
|
144
144
|
|
|
145
145
|
export type TranslateFn = (key: string, params?: Record<string, string | number>) => I18nRef;
|
|
146
146
|
|
package/src/weather-store.ts
CHANGED
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
* ```
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { log } from '@brika/sdk';
|
|
16
|
-
import { defineSharedStore } from '@brika/sdk/bricks';
|
|
15
|
+
import { defineSharedStore, log } from '@brika/sdk';
|
|
17
16
|
import { fetchWeather, geocodeCity } from './api';
|
|
18
17
|
import type { GeoLocation, WeatherState } from './types';
|
|
19
18
|
|
package/src/use-weather.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
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
|
-
}
|