@bardioc/create-bardioc-app 0.4.0 → 0.5.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/README.md +7 -13
- package/bin/create.mjs +5 -5
- package/package.json +1 -1
- package/src/scaffold.js +2 -2
- package/templates/_base/.changeset/README.md +3 -3
- package/templates/_base/.claude/commands/changeset-app.md +5 -5
- package/templates/_base/.claude/commands/refresh-bundle.md +5 -5
- package/templates/_base/README.md +9 -9
- package/templates/angular/package.json +6 -6
- package/templates/nextjs/.env.example +1 -1
- package/templates/nextjs/package.json +1 -1
- package/templates/preact/package.json +1 -1
- package/templates/solid/package.json +1 -1
- package/templates/svelte/package.json +1 -1
- package/templates/vite/.env.example +1 -1
- package/templates/vite/CLAUDE.md +199 -59
- package/templates/vite/package.json +5 -1
- package/templates/vite/public/app-manifest.json +25 -0
- package/templates/vite/src/App.tsx +11 -133
- package/templates/vite/src/auth/onboarding.tsx +67 -0
- package/templates/vite/src/climate/climate-data.ts +92 -0
- package/templates/vite/src/climate/conditions-chart.tsx +38 -0
- package/templates/vite/src/climate/location-card.tsx +50 -0
- package/templates/vite/src/climate/location-select.tsx +26 -0
- package/templates/vite/src/climate/metrics-grid.tsx +22 -0
- package/templates/vite/src/climate/overview-view.tsx +52 -0
- package/templates/vite/src/climate/stations-view.tsx +149 -0
- package/templates/vite/src/climate/types.ts +93 -0
- package/templates/vite/src/climate/use-climate.ts +123 -0
- package/templates/vite/src/dashboard-provider.tsx +27 -0
- package/templates/vite/src/events/live-events-view.tsx +74 -0
- package/templates/vite/src/graph/explorer-view.tsx +118 -0
- package/templates/vite/src/graph/requests.ts +142 -0
- package/templates/vite/src/i18n.tsx +68 -0
- package/templates/vite/src/index.css +12 -64
- package/templates/vite/src/locales/de.json +141 -0
- package/templates/vite/src/locales/en.json +141 -0
- package/templates/vite/src/main.tsx +10 -4
- package/templates/vite/src/profile/profile-view.tsx +75 -0
- package/templates/vite/src/profile/use-profile.ts +52 -0
- package/templates/vite/src/shell/about-dialog.tsx +129 -0
- package/templates/vite/src/shell/app-shell.tsx +160 -0
- package/templates/vite/src/shell/header.tsx +39 -0
- package/templates/vite/src/use-app-notify.ts +23 -0
- package/templates/vite/src/vite-env.d.ts +5 -5
- package/templates/vue/package.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BarChartMultiple } from '@bardioc/ui/components/charts';
|
|
2
|
+
import type { ChartConfig } from '@bardioc/ui/shadcn/chart';
|
|
3
|
+
import { useTranslation } from '../i18n';
|
|
4
|
+
import type { ForecastDay } from './types';
|
|
5
|
+
|
|
6
|
+
interface ConditionsChartProps {
|
|
7
|
+
forecast: ForecastDay[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ConditionsChart({ forecast }: Readonly<ConditionsChartProps>) {
|
|
11
|
+
const { t } = useTranslation();
|
|
12
|
+
|
|
13
|
+
const chartConfig: ChartConfig = {
|
|
14
|
+
sunny: { label: t('conditions.sunny', { fallback: 'Sunny' }), color: 'var(--chart-2)' },
|
|
15
|
+
cloudy: { label: t('conditions.cloudy', { fallback: 'Cloudy' }), color: 'var(--chart-4)' },
|
|
16
|
+
rainy: { label: t('conditions.rainy', { fallback: 'Rainy' }), color: 'var(--chart-1)' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const seriesData = forecast.map(
|
|
20
|
+
(entry): Record<string, string | number> => ({
|
|
21
|
+
day: entry.day,
|
|
22
|
+
sunny: entry.sunny,
|
|
23
|
+
cloudy: entry.cloudy,
|
|
24
|
+
rainy: entry.rainy,
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<BarChartMultiple
|
|
30
|
+
seriesData={seriesData}
|
|
31
|
+
xKey="day"
|
|
32
|
+
chartConfig={chartConfig}
|
|
33
|
+
title={t('chart.title', { fallback: '7-day outlook' })}
|
|
34
|
+
description={t('chart.description', { fallback: 'Daylight hours by condition' })}
|
|
35
|
+
heightClass="h-[220px]"
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Badge, BadgeStatus, Card } from '@bardioc/ui/components';
|
|
2
|
+
import { Cloud, CloudRain, Sun, type LucideIcon } from 'lucide-react';
|
|
3
|
+
import { useTranslation } from '../i18n';
|
|
4
|
+
import type { Condition, DataSource, Station } from './types';
|
|
5
|
+
|
|
6
|
+
type StatusVariant = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
|
7
|
+
|
|
8
|
+
const CONDITION: Record<Condition, { icon: LucideIcon; status: StatusVariant }> = {
|
|
9
|
+
sunny: { icon: Sun, status: 'warning' },
|
|
10
|
+
cloudy: { icon: Cloud, status: 'neutral' },
|
|
11
|
+
rainy: { icon: CloudRain, status: 'info' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
interface LocationCardProps {
|
|
15
|
+
station: Station;
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
source: DataSource;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LocationCard({ station, updatedAt, source }: Readonly<LocationCardProps>) {
|
|
21
|
+
const { t } = useTranslation();
|
|
22
|
+
const condition = CONDITION[station.condition];
|
|
23
|
+
const Icon = condition.icon;
|
|
24
|
+
const time = new Date(updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Card>
|
|
28
|
+
<div className="flex items-center gap-4">
|
|
29
|
+
<div className="bg-muted text-foreground flex size-12 shrink-0 items-center justify-center rounded-full">
|
|
30
|
+
<Icon className="size-6" />
|
|
31
|
+
</div>
|
|
32
|
+
<div className="min-w-0 flex-1">
|
|
33
|
+
<div className="truncate text-lg font-semibold">{station.name}</div>
|
|
34
|
+
<div className="text-muted-foreground truncate text-xs">{station.region}</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
37
|
+
<BadgeStatus variant={condition.status}>
|
|
38
|
+
{t(`conditions.${station.condition}`, { fallback: station.condition })}
|
|
39
|
+
</BadgeStatus>
|
|
40
|
+
<span className="text-muted-foreground text-xs">
|
|
41
|
+
{t('overview.updated', { fallback: 'Updated {time}', params: { time } })}
|
|
42
|
+
</span>
|
|
43
|
+
<Badge variant={source === 'graph' ? 'success' : 'secondary'}>
|
|
44
|
+
{t(`source.${source}`, { fallback: source })}
|
|
45
|
+
</Badge>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</Card>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Combobox } from '@bardioc/ui/components';
|
|
2
|
+
import { useTranslation } from '../i18n';
|
|
3
|
+
import type { Station } from './types';
|
|
4
|
+
|
|
5
|
+
interface LocationSelectProps {
|
|
6
|
+
stations: Station[];
|
|
7
|
+
value: string;
|
|
8
|
+
onSelect: (id: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function LocationSelect({ stations, value, onSelect }: Readonly<LocationSelectProps>) {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const nameById = new Map(stations.map(station => [station.id, station.name]));
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Combobox
|
|
17
|
+
items={stations.map(station => station.id)}
|
|
18
|
+
value={value}
|
|
19
|
+
onValueChange={next => onSelect(next as string)}
|
|
20
|
+
itemToStringValue={item => item as string}
|
|
21
|
+
itemToStringLabel={item => nameById.get(item as string) ?? (item as string)}
|
|
22
|
+
placeholder={t('header.location', { fallback: 'Station' })}
|
|
23
|
+
className="h-8 w-52"
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { GenericMetrics } from '@bardioc/ui/components/metrics';
|
|
2
|
+
import { useTranslation } from '../i18n';
|
|
3
|
+
import { METRICS, type ClimateReading } from './types';
|
|
4
|
+
|
|
5
|
+
interface MetricsGridProps {
|
|
6
|
+
reading: ClimateReading;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function MetricsGrid({ reading }: Readonly<MetricsGridProps>) {
|
|
10
|
+
const { t } = useTranslation();
|
|
11
|
+
|
|
12
|
+
const metrics = METRICS.map(def => ({
|
|
13
|
+
title: t(def.labelKey, { fallback: def.key }),
|
|
14
|
+
value: reading[def.key],
|
|
15
|
+
icon: def.icon,
|
|
16
|
+
description: t(def.hintKey, { fallback: '' }),
|
|
17
|
+
formatValue: (value: number) => `${value}${def.unit}`,
|
|
18
|
+
color: def.color,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
return <GenericMetrics metrics={metrics} className="grid grid-cols-2 gap-3 md:grid-cols-5" />;
|
|
22
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Database } from 'lucide-react';
|
|
2
|
+
import { useDashboard } from '../dashboard-provider';
|
|
3
|
+
import { useTranslation } from '../i18n';
|
|
4
|
+
import { ConditionsChart } from './conditions-chart';
|
|
5
|
+
import { LocationCard } from './location-card';
|
|
6
|
+
import { MetricsGrid } from './metrics-grid';
|
|
7
|
+
|
|
8
|
+
export function OverviewView() {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { state, activeStation, historyCount, persisted } = useDashboard();
|
|
11
|
+
|
|
12
|
+
if (!state || !activeStation) return null;
|
|
13
|
+
|
|
14
|
+
const savedTime = new Date(state.updatedAt).toLocaleTimeString([], {
|
|
15
|
+
hour: '2-digit',
|
|
16
|
+
minute: '2-digit',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex flex-col gap-4">
|
|
21
|
+
<LocationCard station={activeStation} updatedAt={state.updatedAt} source={state.source} />
|
|
22
|
+
<MetricsGrid reading={activeStation.reading} />
|
|
23
|
+
<ConditionsChart forecast={state.forecast} />
|
|
24
|
+
|
|
25
|
+
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
|
26
|
+
<Database className="size-3.5" />
|
|
27
|
+
{persisted ? (
|
|
28
|
+
<>
|
|
29
|
+
<span>{t('overview.persistence', { fallback: 'Persisted to IndexedDB' })}</span>
|
|
30
|
+
<span>·</span>
|
|
31
|
+
<span>
|
|
32
|
+
{t('header.savedAt', { fallback: 'Saved {time}', params: { time: savedTime } })}
|
|
33
|
+
</span>
|
|
34
|
+
<span>·</span>
|
|
35
|
+
<span>
|
|
36
|
+
{t('overview.historyOther', {
|
|
37
|
+
fallback: '{count} snapshots in history',
|
|
38
|
+
params: { count: historyCount },
|
|
39
|
+
})}
|
|
40
|
+
</span>
|
|
41
|
+
</>
|
|
42
|
+
) : (
|
|
43
|
+
<span>
|
|
44
|
+
{t('overview.notPersisted', {
|
|
45
|
+
fallback: 'Preview — not persisted (open inside Bardioc OS to persist)',
|
|
46
|
+
})}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BadgeStatus,
|
|
3
|
+
CheckboxGroup,
|
|
4
|
+
DataTable,
|
|
5
|
+
FilterDropdown,
|
|
6
|
+
SortableColumnHeader,
|
|
7
|
+
TruncatedCellText,
|
|
8
|
+
} from '@bardioc/ui/components';
|
|
9
|
+
import type { TReactTable } from '@bardioc/ui/types';
|
|
10
|
+
import { useMemo, useState } from 'react';
|
|
11
|
+
import { useDashboard } from '../dashboard-provider';
|
|
12
|
+
import { useTranslation } from '../i18n';
|
|
13
|
+
import type { Condition, Station } from './types';
|
|
14
|
+
|
|
15
|
+
const CONDITIONS: Condition[] = ['sunny', 'cloudy', 'rainy'];
|
|
16
|
+
const STATUS: Record<Condition, 'neutral' | 'info' | 'success' | 'warning' | 'error'> = {
|
|
17
|
+
sunny: 'warning',
|
|
18
|
+
cloudy: 'neutral',
|
|
19
|
+
rainy: 'info',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface ConditionFilterProps {
|
|
23
|
+
selected: Condition[];
|
|
24
|
+
onToggle: (condition: Condition) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ConditionFilter({ selected, onToggle }: Readonly<ConditionFilterProps>) {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const [open, setOpen] = useState(false);
|
|
30
|
+
const label = t('stations.filter', { fallback: 'Condition' });
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<FilterDropdown
|
|
34
|
+
immediate
|
|
35
|
+
open={open}
|
|
36
|
+
setOpen={setOpen}
|
|
37
|
+
buttonLabel={selected.length ? `${label} (${selected.length})` : label}
|
|
38
|
+
sections={[
|
|
39
|
+
{
|
|
40
|
+
id: 'condition',
|
|
41
|
+
title: label,
|
|
42
|
+
content: (
|
|
43
|
+
<CheckboxGroup
|
|
44
|
+
className="gap-3 capitalize"
|
|
45
|
+
items={CONDITIONS.map(condition => ({
|
|
46
|
+
id: condition,
|
|
47
|
+
label: t(`conditions.${condition}`, { fallback: condition }),
|
|
48
|
+
checked: selected.includes(condition),
|
|
49
|
+
onCheckedChange: () => onToggle(condition),
|
|
50
|
+
}))}
|
|
51
|
+
/>
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
]}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function StationsView() {
|
|
60
|
+
const { t } = useTranslation();
|
|
61
|
+
const { state, setActiveStation } = useDashboard();
|
|
62
|
+
const [conditions, setConditions] = useState<Condition[]>([]);
|
|
63
|
+
|
|
64
|
+
const onToggle = (condition: Condition) =>
|
|
65
|
+
setConditions(prev =>
|
|
66
|
+
prev.includes(condition) ? prev.filter(item => item !== condition) : [...prev, condition]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const columns = useMemo<TReactTable.ColumnDef<Station>[]>(
|
|
70
|
+
() => [
|
|
71
|
+
{
|
|
72
|
+
accessorKey: 'name',
|
|
73
|
+
enableSorting: true,
|
|
74
|
+
header: ({ column }) => (
|
|
75
|
+
<SortableColumnHeader
|
|
76
|
+
label={t('stations.colName', { fallback: 'Station' })}
|
|
77
|
+
column={column}
|
|
78
|
+
/>
|
|
79
|
+
),
|
|
80
|
+
cell: ({ row }) => <TruncatedCellText value={row.original.name} />,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
accessorKey: 'region',
|
|
84
|
+
header: () => t('stations.colRegion', { fallback: 'Region' }),
|
|
85
|
+
cell: ({ row }) => <TruncatedCellText value={row.original.region} />,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
accessorKey: 'condition',
|
|
89
|
+
header: () => t('stations.colCondition', { fallback: 'Condition' }),
|
|
90
|
+
cell: ({ row }) => (
|
|
91
|
+
<BadgeStatus variant={STATUS[row.original.condition]}>
|
|
92
|
+
{t(`conditions.${row.original.condition}`, { fallback: row.original.condition })}
|
|
93
|
+
</BadgeStatus>
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'temp',
|
|
98
|
+
header: () => t('stations.colTemp', { fallback: 'Temp' }),
|
|
99
|
+
cell: ({ row }) => `${row.original.reading.temperature}°C`,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'humidity',
|
|
103
|
+
header: () => t('stations.colHumidity', { fallback: 'Humidity' }),
|
|
104
|
+
cell: ({ row }) => `${row.original.reading.humidity}%`,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'vegetation',
|
|
108
|
+
header: () => t('stations.colVegetation', { fallback: 'Vegetation' }),
|
|
109
|
+
cell: ({ row }) => `${row.original.reading.vegetation}%`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
[t]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (!state) return null;
|
|
116
|
+
|
|
117
|
+
const data = conditions.length
|
|
118
|
+
? state.stations.filter(station => conditions.includes(station.condition))
|
|
119
|
+
: state.stations;
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex flex-col gap-3">
|
|
123
|
+
<div>
|
|
124
|
+
<h2 className="text-lg font-semibold">
|
|
125
|
+
{t('stations.title', { fallback: 'Monitoring stations' })}
|
|
126
|
+
</h2>
|
|
127
|
+
<p className="text-muted-foreground text-xs">
|
|
128
|
+
{t('stations.description', { fallback: '' })}
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
<DataTable
|
|
132
|
+
boxed
|
|
133
|
+
data={data}
|
|
134
|
+
columns={columns}
|
|
135
|
+
filterKey={['name', 'region']}
|
|
136
|
+
showSearchBar
|
|
137
|
+
filterPlaceholder={t('stations.search', { fallback: 'Search stations…' })}
|
|
138
|
+
itemLabel={t('stations.itemLabel', { fallback: 'stations' })}
|
|
139
|
+
onRowClick={station => setActiveStation(station.id)}
|
|
140
|
+
filterSlot={<ConditionFilter selected={conditions} onToggle={onToggle} />}
|
|
141
|
+
emptyState={
|
|
142
|
+
<p className="text-muted-foreground p-6 text-center text-sm">
|
|
143
|
+
{t('stations.empty', { fallback: 'No stations match your filters' })}
|
|
144
|
+
</p>
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Droplets, Gauge, Leaf, Thermometer, Wind, type LucideIcon } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
export type Condition = 'sunny' | 'cloudy' | 'rainy';
|
|
4
|
+
export type ViewId = 'overview' | 'stations' | 'explorer' | 'events' | 'profile';
|
|
5
|
+
export type DataSource = 'graph' | 'mock';
|
|
6
|
+
|
|
7
|
+
export interface ClimateReading {
|
|
8
|
+
oxygen: number;
|
|
9
|
+
temperature: number;
|
|
10
|
+
humidity: number;
|
|
11
|
+
windSpeed: number;
|
|
12
|
+
vegetation: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Station {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
region: string;
|
|
19
|
+
condition: Condition;
|
|
20
|
+
reading: ClimateReading;
|
|
21
|
+
reference: ClimateReading;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ForecastDay {
|
|
25
|
+
day: string;
|
|
26
|
+
sunny: number;
|
|
27
|
+
cloudy: number;
|
|
28
|
+
rainy: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ClimateState {
|
|
32
|
+
stations: Station[];
|
|
33
|
+
activeStationId: string;
|
|
34
|
+
forecast: ForecastDay[];
|
|
35
|
+
updatedAt: number;
|
|
36
|
+
source: DataSource;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MetricDef {
|
|
40
|
+
key: keyof ClimateReading;
|
|
41
|
+
labelKey: string;
|
|
42
|
+
hintKey: string;
|
|
43
|
+
icon: LucideIcon;
|
|
44
|
+
unit: string;
|
|
45
|
+
color: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const METRICS: MetricDef[] = [
|
|
49
|
+
{
|
|
50
|
+
key: 'oxygen',
|
|
51
|
+
labelKey: 'metrics.oxygen',
|
|
52
|
+
hintKey: 'metrics.oxygenHint',
|
|
53
|
+
icon: Gauge,
|
|
54
|
+
unit: '%',
|
|
55
|
+
color: 'text-yellow-500',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: 'temperature',
|
|
59
|
+
labelKey: 'metrics.temperature',
|
|
60
|
+
hintKey: 'metrics.temperatureHint',
|
|
61
|
+
icon: Thermometer,
|
|
62
|
+
unit: '°C',
|
|
63
|
+
color: 'text-red-500',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'humidity',
|
|
67
|
+
labelKey: 'metrics.humidity',
|
|
68
|
+
hintKey: 'metrics.humidityHint',
|
|
69
|
+
icon: Droplets,
|
|
70
|
+
unit: '%',
|
|
71
|
+
color: 'text-blue-500',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: 'windSpeed',
|
|
75
|
+
labelKey: 'metrics.windSpeed',
|
|
76
|
+
hintKey: 'metrics.windSpeedHint',
|
|
77
|
+
icon: Wind,
|
|
78
|
+
unit: 'km/h',
|
|
79
|
+
color: 'text-[var(--chart-4)]',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: 'vegetation',
|
|
83
|
+
labelKey: 'metrics.vegetation',
|
|
84
|
+
hintKey: 'metrics.vegetationHint',
|
|
85
|
+
icon: Leaf,
|
|
86
|
+
unit: '%',
|
|
87
|
+
color: 'text-green-500',
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
export const IDB_STORE = 'climate';
|
|
92
|
+
export const IDB_STATE_KEY = 'state';
|
|
93
|
+
export const IDB_LOG_PREFIX = 'log:';
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useSdk } from '@bardioc/app-sdk/react';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { useTranslation } from '../i18n';
|
|
4
|
+
import { useAppNotify } from '../use-app-notify';
|
|
5
|
+
import { generateState, syncState } from './climate-data';
|
|
6
|
+
import { IDB_LOG_PREFIX, IDB_STATE_KEY, IDB_STORE, type ClimateState } from './types';
|
|
7
|
+
|
|
8
|
+
export function useClimate() {
|
|
9
|
+
const bridge = useSdk();
|
|
10
|
+
const notify = useAppNotify();
|
|
11
|
+
const { t } = useTranslation();
|
|
12
|
+
const store = useMemo(() => bridge.idb(IDB_STORE), [bridge]);
|
|
13
|
+
|
|
14
|
+
const [state, setState] = useState<ClimateState | null>(null);
|
|
15
|
+
const [syncing, setSyncing] = useState(false);
|
|
16
|
+
const [historyCount, setHistoryCount] = useState(0);
|
|
17
|
+
const [persisted, setPersisted] = useState(false);
|
|
18
|
+
|
|
19
|
+
const refreshHistory = useCallback(async () => {
|
|
20
|
+
const logs = await store.query({ prefix: IDB_LOG_PREFIX, limit: 100 }).catch(() => []);
|
|
21
|
+
setHistoryCount(logs.length);
|
|
22
|
+
}, [store]);
|
|
23
|
+
|
|
24
|
+
const persist = useCallback(
|
|
25
|
+
async (next: ClimateState) => {
|
|
26
|
+
const ok = await store
|
|
27
|
+
.put(IDB_STATE_KEY, next)
|
|
28
|
+
.then(() => true)
|
|
29
|
+
.catch(() => false);
|
|
30
|
+
if (ok) {
|
|
31
|
+
await store
|
|
32
|
+
.put(`${IDB_LOG_PREFIX}${next.updatedAt}`, { at: next.updatedAt, source: next.source })
|
|
33
|
+
.catch(() => undefined);
|
|
34
|
+
await refreshHistory();
|
|
35
|
+
}
|
|
36
|
+
setPersisted(ok);
|
|
37
|
+
return ok;
|
|
38
|
+
},
|
|
39
|
+
[refreshHistory, store]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let active = true;
|
|
44
|
+
void (async () => {
|
|
45
|
+
const raw = await store.get<unknown>(IDB_STATE_KEY).catch(() => null);
|
|
46
|
+
const saved =
|
|
47
|
+
raw !== null &&
|
|
48
|
+
raw !== undefined &&
|
|
49
|
+
typeof raw === 'object' &&
|
|
50
|
+
Array.isArray((raw as ClimateState).stations)
|
|
51
|
+
? (raw as ClimateState)
|
|
52
|
+
: null;
|
|
53
|
+
const next = saved ?? generateState();
|
|
54
|
+
if (saved) {
|
|
55
|
+
setPersisted(true);
|
|
56
|
+
await refreshHistory();
|
|
57
|
+
} else {
|
|
58
|
+
await persist(next);
|
|
59
|
+
}
|
|
60
|
+
if (active) setState(next);
|
|
61
|
+
})();
|
|
62
|
+
return () => {
|
|
63
|
+
active = false;
|
|
64
|
+
};
|
|
65
|
+
}, [persist, refreshHistory, store]);
|
|
66
|
+
|
|
67
|
+
const refresh = useCallback(async () => {
|
|
68
|
+
setSyncing(true);
|
|
69
|
+
try {
|
|
70
|
+
let source: ClimateState['source'] = 'graph';
|
|
71
|
+
try {
|
|
72
|
+
await bridge.transport.os.profile.get();
|
|
73
|
+
} catch {
|
|
74
|
+
source = 'mock';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const raw = await store.get<unknown>(IDB_STATE_KEY).catch(() => null);
|
|
78
|
+
const saved =
|
|
79
|
+
raw !== null &&
|
|
80
|
+
raw !== undefined &&
|
|
81
|
+
typeof raw === 'object' &&
|
|
82
|
+
Array.isArray((raw as ClimateState).stations)
|
|
83
|
+
? (raw as ClimateState)
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
const next = saved ? syncState(saved, source) : generateState(undefined, source);
|
|
87
|
+
await persist(next);
|
|
88
|
+
setState(next);
|
|
89
|
+
|
|
90
|
+
notify(
|
|
91
|
+
t('notify.synced', {
|
|
92
|
+
fallback: 'Synced {count} stations',
|
|
93
|
+
params: { count: next.stations.length },
|
|
94
|
+
}),
|
|
95
|
+
source === 'graph' ? 'success' : 'warning'
|
|
96
|
+
);
|
|
97
|
+
} finally {
|
|
98
|
+
setSyncing(false);
|
|
99
|
+
}
|
|
100
|
+
}, [bridge, notify, persist, store, t]);
|
|
101
|
+
|
|
102
|
+
const setActiveStation = useCallback(
|
|
103
|
+
(id: string) => {
|
|
104
|
+
if (!state) return;
|
|
105
|
+
const next = { ...state, activeStationId: id };
|
|
106
|
+
setState(next);
|
|
107
|
+
void store.put(IDB_STATE_KEY, next).catch(() => undefined);
|
|
108
|
+
const station = next.stations.find(item => item.id === id);
|
|
109
|
+
notify(
|
|
110
|
+
t('notify.stationSelected', {
|
|
111
|
+
fallback: 'Active station: {name}',
|
|
112
|
+
params: { name: station?.name ?? id },
|
|
113
|
+
}),
|
|
114
|
+
'info'
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
[notify, state, store, t]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const activeStation = state?.stations?.find(item => item.id === state.activeStationId) ?? null;
|
|
121
|
+
|
|
122
|
+
return { state, activeStation, syncing, historyCount, persisted, refresh, setActiveStation };
|
|
123
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
2
|
+
import { useClimate } from './climate/use-climate';
|
|
3
|
+
import { useProfile, type ProfileState } from './profile/use-profile';
|
|
4
|
+
|
|
5
|
+
type ClimateApi = ReturnType<typeof useClimate>;
|
|
6
|
+
type DashboardContextValue = ClimateApi & { profile: ProfileState };
|
|
7
|
+
|
|
8
|
+
const DashboardContext = createContext<DashboardContextValue | null>(null);
|
|
9
|
+
|
|
10
|
+
export function DashboardProvider({ children }: Readonly<{ children: ReactNode }>) {
|
|
11
|
+
const climate = useClimate();
|
|
12
|
+
const profile = useProfile();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<DashboardContext.Provider value={{ ...climate, profile }}>
|
|
16
|
+
{children}
|
|
17
|
+
</DashboardContext.Provider>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useDashboard(): DashboardContextValue {
|
|
22
|
+
const context = useContext(DashboardContext);
|
|
23
|
+
if (!context) {
|
|
24
|
+
throw new Error('useDashboard must be used within DashboardProvider');
|
|
25
|
+
}
|
|
26
|
+
return context;
|
|
27
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { GraphEvent } from '@bardioc/app-sdk';
|
|
2
|
+
import { isInsideIframe } from '@bardioc/app-sdk/dev';
|
|
3
|
+
import { useGraphEvents } from '@bardioc/app-sdk/react';
|
|
4
|
+
import { Badge, Card, Empty } from '@bardioc/ui/components';
|
|
5
|
+
import { Activity } from 'lucide-react';
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
import { useTranslation } from '../i18n';
|
|
8
|
+
|
|
9
|
+
// OGIT types to watch. Broad `ogit/Timeseries` writes are the most common signal in a live
|
|
10
|
+
// instance; swap these for the entity types your app cares about.
|
|
11
|
+
const WATCHED_TYPES = ['ogit/Timeseries', 'ogit/Automation/Issue'];
|
|
12
|
+
const MAX_EVENTS = 50;
|
|
13
|
+
|
|
14
|
+
export function LiveEventsView() {
|
|
15
|
+
const { t } = useTranslation();
|
|
16
|
+
const [events, setEvents] = useState<GraphEvent[]>([]);
|
|
17
|
+
|
|
18
|
+
// Subscribes for the mounted lifetime; the host streams matching graph events over SSE.
|
|
19
|
+
// Outside a valid session no events arrive and the empty state stays.
|
|
20
|
+
useGraphEvents({ types: WATCHED_TYPES }, event => {
|
|
21
|
+
setEvents(prev => [event, ...prev].slice(0, MAX_EVENTS));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex h-full min-w-0 flex-col gap-3">
|
|
26
|
+
<div>
|
|
27
|
+
<h2 className="text-lg font-semibold">
|
|
28
|
+
{t('events.title', { fallback: 'Live Events' })}
|
|
29
|
+
</h2>
|
|
30
|
+
<p className="text-muted-foreground text-xs">
|
|
31
|
+
{t('events.description', {
|
|
32
|
+
fallback: 'Live graph events streamed from the OS over SSE (permission "events").',
|
|
33
|
+
})}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<Card className="min-h-0 min-w-0 flex-1">
|
|
38
|
+
{events.length === 0 ? (
|
|
39
|
+
<Empty
|
|
40
|
+
icon={Activity}
|
|
41
|
+
iconVariant="chip"
|
|
42
|
+
title={t('events.emptyTitle', { fallback: 'Waiting for events' })}
|
|
43
|
+
description={
|
|
44
|
+
isInsideIframe()
|
|
45
|
+
? t('events.emptyHost', {
|
|
46
|
+
fallback: 'No graph events yet — activity will appear here as it happens.',
|
|
47
|
+
})
|
|
48
|
+
: t('events.emptyStandalone', {
|
|
49
|
+
fallback: 'Open inside Bardioc OS (or a dev session) to receive live events.',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
/>
|
|
53
|
+
) : (
|
|
54
|
+
<div className="flex min-h-0 flex-col gap-1 overflow-auto">
|
|
55
|
+
{events.map(event => (
|
|
56
|
+
<div
|
|
57
|
+
key={event.id}
|
|
58
|
+
className="border-border flex min-w-0 items-center gap-2 border-b py-1.5 font-mono text-xs last:border-b-0"
|
|
59
|
+
>
|
|
60
|
+
<Badge variant="info" numerical>
|
|
61
|
+
{event.type}
|
|
62
|
+
</Badge>
|
|
63
|
+
<span className="text-muted-foreground shrink-0">
|
|
64
|
+
{new Date(event.timestamp).toLocaleTimeString()}
|
|
65
|
+
</span>
|
|
66
|
+
<span className="truncate">{event.id}</span>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</Card>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|