@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 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.0",
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/maxscharwath/brika.git",
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
- "tsc": "bunx --bun tsc --noEmit",
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.0"
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",
@@ -1,60 +1,66 @@
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
- );
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
+ }
@@ -1,15 +1,66 @@
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';
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
- getWeatherVisuals,
23
+ LoadingSpinner,
24
+ resolveCity,
25
+ resolveUnit,
7
26
  tempUnit,
8
- windDirectionLabel,
9
- } from '../utils';
10
- import { CITY_UNIT_CONFIG, WeatherError, WeatherLoading } from './shared';
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
- // ─── Inline stat (icon + label + value + suffix) ────────────────────────────
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 | I18nRef;
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
- <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>
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
- // ─── Brick Definition ──────────────────────────────────────────────────────
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
- 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();
99
+ if (!data) return <LoadingSpinner />;
54
100
 
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 />;
101
+ const cityKey = resolveCity(config, data.defaultCity);
102
+ const d = data.cities[cityKey];
58
103
 
59
- const { meta, color, gradient } = getWeatherVisuals(weather.current.weatherCode);
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
- <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>
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
+ }
@@ -1,136 +1,161 @@
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;
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
- // ─── List row (narrow) — single-line per day, maximally scannable ───────────
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
- function DayRow({ dayLabel, code, high, low, unit }: Readonly<DayProps>) {
19
- const { meta, color } = getWeatherVisuals(code);
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
- <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>
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
- // ─── Grid cell (wide) compact vertical card ──────────────────────────────
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
- <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>
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
- // ─── 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
- );
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
+ }
@@ -1,49 +1,90 @@
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
- }
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
- <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>
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 WeatherError({ message }: Readonly<{ message: TextContent }>) {
77
+ export function CityError({ error }: Readonly<{ error?: string }>) {
43
78
  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>
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 { log, onStop } from '@brika/sdk/lifecycle';
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
- export { compactBrick } from './bricks/compact';
4
- // Bricks (board UI)
5
- export { currentBrick } from './bricks/current';
6
- export { forecastBrick } from './bricks/forecast';
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/bricks';
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
 
@@ -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
 
@@ -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
- }