From db269387917b255dd60166bfd8839505ae398375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 24 Mar 2026 07:32:07 +0100 Subject: [PATCH] wizualne --- backend/config.py | 2 +- frontend/package-lock.json | 63 ---------------- frontend/src/App.tsx | 147 ++++++++++++++++++++++++++++--------- frontend/src/api/client.ts | 2 +- frontend/src/demo/data.ts | 4 +- frontend/src/i18n.ts | 6 +- frontend/src/index.css | 68 +++++++++++++++-- 7 files changed, 180 insertions(+), 112 deletions(-) diff --git a/backend/config.py b/backend/config.py index be95513..f1435f9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -110,7 +110,7 @@ TIME_RANGES = { REALTIME = { "refresh_seconds": env_int("REALTIME_REFRESH_SECONDS", 8), - "history_default_range": os.getenv("REALTIME_HISTORY_DEFAULT_RANGE", "6h"), + "history_default_range": os.getenv("REALTIME_HISTORY_DEFAULT_RANGE", "today"), } ANALYTICS = { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e08b912..6bb5c31 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -897,9 +897,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -914,9 +911,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -931,9 +925,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -948,9 +939,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -965,9 +953,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -982,9 +967,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -999,9 +981,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1016,9 +995,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1033,9 +1009,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1050,9 +1023,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1067,9 +1037,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1084,9 +1051,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1101,9 +1065,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1327,9 +1288,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1347,9 +1305,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1367,9 +1322,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1387,9 +1339,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2035,9 +1984,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2059,9 +2005,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2083,9 +2026,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2107,9 +2047,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8cea48..7da6256 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,6 @@ import { IconHistory, IconLanguage, IconLayoutDashboard, - IconLock, IconLogin2, IconLogout, IconMoon, @@ -86,7 +85,6 @@ const widgetOrder: Array<{ id: WidgetId; tab: TabKey; icon: typeof IconLayoutDas { id: "strings", tab: "realtime", icon: IconArrowsMove }, { id: "production", tab: "analytics", icon: IconChartBar }, { id: "comparison", tab: "analytics", icon: IconRefresh }, - { id: "distribution", tab: "analytics", icon: IconChartBar }, { id: "importStatus", tab: "warehouse", icon: IconDatabaseImport }, ]; @@ -161,23 +159,31 @@ function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, l series: [{ type: "bar", barMaxWidth: 24, itemStyle: { borderRadius: [6, 6, 0, 0] }, data: points.map((point) => point.value) }], }; } -function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeMode, language: Language): EChartsOption { +function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeMode, language: Language, comparisonDisplayMode: "line" | "bar"): EChartsOption { const palette = buildTablerChartTheme(theme); const current = data?.current ?? []; - const comparisonSeries = (data?.comparisons?.length ? data.comparisons : [{ key: data?.compare_mode ?? "comparison", label: t(language, "comparisonPeriod"), points: data?.comparison ?? [] }]).filter((item) => item.points?.length).map((item) => ({ ...item, label: translateCompareMode(language, item.label || item.key) })); + const comparisonSeries = (data?.comparisons?.length ? data.comparisons : [{ key: data?.compare_mode ?? "comparison", label: t(language, "comparisonPeriod"), points: data?.comparison ?? [] }]) + .filter((item) => item.points?.length) + .map((item) => ({ ...item, label: translateCompareMode(language, item.label || item.key) })); + const verticalLegend = comparisonSeries.length > 2; return { color: palette.series, - tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } }, - legend: { top: 0, textStyle: { color: palette.text } }, - grid: { left: 16, right: 20, top: 42, bottom: 18, containLabel: true }, + tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } }, + legend: verticalLegend + ? { type: "scroll", orient: "vertical", top: 12, right: 0, bottom: 12, textStyle: { color: palette.text }, pageTextStyle: { color: palette.text } } + : { type: "scroll", top: 0, left: 0, right: 0, textStyle: { color: palette.text }, pageTextStyle: { color: palette.text } }, + grid: { left: 16, right: verticalLegend ? 180 : 20, top: verticalLegend ? 20 : 48, bottom: 18, containLabel: true }, xAxis: { type: "category", axisLabel: { color: palette.text, interval: 0, rotate: current.length > 12 ? 35 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: current.map((point) => point.label) }, yAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } }, series: [ - { name: t(language, "currentPeriod"), type: "bar", barMaxWidth: 18, data: current.map((point) => point.value) }, - ...comparisonSeries.map((seriesItem, index) => ({ name: seriesItem.label, type: "bar" as const, barMaxWidth: 18, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? 0), itemStyle: { opacity: index === 0 ? 0.9 : 0.75 } })), + { name: t(language, "currentPeriod"), type: "bar", barMaxWidth: comparisonDisplayMode === "bar" ? 18 : 22, emphasis: { focus: "series" }, data: current.map((point) => point.value) }, + ...comparisonSeries.map((seriesItem) => comparisonDisplayMode === "bar" + ? { name: seriesItem.label, type: "bar" as const, barMaxWidth: 18, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) } + : { name: seriesItem.label, type: "line" as const, smooth: true, showSymbol: false, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) }), ], }; } + function buildPieOption(data: DistributionPayload | undefined, theme: ThemeMode): EChartsOption { const palette = buildTablerChartTheme(theme); const slices = [...(data?.slices ?? [])].sort((a, b) => b.value - a.value).slice(0, 12); @@ -212,17 +218,70 @@ function analyticsRangeOptions(language: Language) { { key: "365d", label: language === "en" ? "365 days" : "365 dni" }, ]; } -function archiveRangeOptions(language: Language) { +function archiveQuickRangeOptions(language: Language) { return [ - { key: "1d", label: language === "en" ? "1 day" : "1 dzień" }, - { key: "3d", label: "3d" }, - { key: "7d", label: "7d" }, - { key: "14d", label: "14d" }, - { key: "30d", label: "30d" }, - { key: "60d", label: "60d" }, - { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }, + { key: "today", label: language === "en" ? "Today" : "Dziś" }, + { key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" }, + { key: "day_before_yesterday", label: language === "en" ? "2 days ago" : "Przedwczoraj" }, ]; } +function archiveListRangeOptions(language: Language) { + return [ + { key: "3d", label: language === "en" ? "3 days" : "3 dni" }, + { key: "7d", label: language === "en" ? "7 days" : "7 dni" }, + { key: "14d", label: language === "en" ? "14 days" : "14 dni" }, + { key: "30d", label: language === "en" ? "30 days" : "30 dni" }, + { key: "60d", label: language === "en" ? "60 days" : "60 dni" }, + ]; +} +const SINGLE_DAY_ARCHIVE_KEYS = new Set(["today", "yesterday", "day_before_yesterday"]); +const ONE_HOUR_MS = 60 * 60 * 1000; + +function toDateTimeLocalValue(value: Date): string { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + const hours = String(value.getHours()).padStart(2, "0"); + const minutes = String(value.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} +function startOfOffsetDay(daysOffset: number): Date { + const value = new Date(); + value.setHours(0, 0, 0, 0); + value.setDate(value.getDate() + daysOffset); + return value; +} +function archivePresetWindow(rangeKey: string): { start: string; end: string } | null { + if (rangeKey === "today") return { start: toDateTimeLocalValue(startOfOffsetDay(0)), end: toDateTimeLocalValue(new Date()) }; + if (rangeKey === "yesterday") return { start: toDateTimeLocalValue(startOfOffsetDay(-1)), end: toDateTimeLocalValue(startOfOffsetDay(0)) }; + if (rangeKey === "day_before_yesterday") return { start: toDateTimeLocalValue(startOfOffsetDay(-2)), end: toDateTimeLocalValue(startOfOffsetDay(-1)) }; + return null; +} +function trimSingleDayHistory(history: HistoryPayload | undefined, mode: string): HistoryPayload | undefined { + if (!history || !SINGLE_DAY_ARCHIVE_KEYS.has(mode)) return history; + const timestamps = history.series + .flatMap((series) => series.points) + .filter((point) => point.value !== null) + .map((point) => new Date(point.timestamp).getTime()) + .filter((value) => Number.isFinite(value)); + if (!timestamps.length) return history; + const historyStart = new Date(history.start).getTime(); + const historyEnd = new Date(history.end).getTime(); + const trimmedStart = Math.max(historyStart, Math.min(...timestamps) - ONE_HOUR_MS); + const trimmedEnd = Math.min(historyEnd, Math.max(...timestamps) + ONE_HOUR_MS); + return { + ...history, + start: new Date(trimmedStart).toISOString(), + end: new Date(trimmedEnd).toISOString(), + series: history.series.map((series) => ({ + ...series, + points: series.points.filter((point) => { + const timestamp = new Date(point.timestamp).getTime(); + return timestamp >= trimmedStart && timestamp <= trimmedEnd; + }), + })), + }; +} function getInitialTheme(config?: DashboardConfig): ThemeMode { return readStorage(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark")); } @@ -255,23 +314,24 @@ export default function App() { const [theme, setTheme] = useState(() => getInitialTheme(undefined)); const [language, setLanguage] = useState(() => getInitialLanguage(undefined)); const [activeTab, setActiveTab] = useState(publicMode ? "kiosk" : "realtime"); - const [realtimeRange, setRealtimeRange] = useState("6h"); + const [realtimeRange, setRealtimeRange] = useState("today"); const [analyticsRange, setAnalyticsRange] = useState("30d"); const [bucket, setBucket] = useState("day"); const [compare, setCompare] = useState("previous_year"); const [analyticsStart, setAnalyticsStart] = useState(""); const [analyticsEnd, setAnalyticsEnd] = useState(""); const [compareRanges, setCompareRanges] = useState>([{ key: "cmp_1", label: "Porównanie 1", start: "", end: "" }, { key: "cmp_2", label: "Porównanie 2", start: "", end: "" }]); - const [archiveStart, setArchiveStart] = useState(""); - const [archiveEnd, setArchiveEnd] = useState(""); - const [archiveRange, setArchiveRange] = useState("1d"); + const initialArchiveWindow = archivePresetWindow("today"); + const [archiveStart, setArchiveStart] = useState(initialArchiveWindow?.start ?? ""); + const [archiveEnd, setArchiveEnd] = useState(initialArchiveWindow?.end ?? ""); + const [archiveRange, setArchiveRange] = useState("today"); const [liveMetrics, setLiveMetrics] = useState(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS)); const [archiveMetrics, setArchiveMetrics] = useState(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS)); const [viewMode, setViewMode] = useState(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); }); const [kioskWidgets, setKioskWidgets] = useState(() => getVisibleWidgets(readStorage(STORAGE_KEYS.kioskWidgets, DEFAULT_KIOSK_WIDGETS))); const [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private"); - const [privateKioskDraft, setPrivateKioskDraft] = useState({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }); - const [publicKioskDraft, setPublicKioskDraft] = useState({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }); + const [privateKioskDraft, setPrivateKioskDraft] = useState({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }); + const [publicKioskDraft, setPublicKioskDraft] = useState({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }); const [blockConfig, setBlockConfig] = useState>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG)); const [loginForm, setLoginForm] = useState({ username: "", password: "" }); const [loginError, setLoginError] = useState(null); @@ -280,8 +340,8 @@ export default function App() { const [kioskSaveNotice, setKioskSaveNotice] = useState>({ public: null, private: null }); const initializedRef = useRef(false); const defaultKioskSerializedRef = useRef>({ - public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }), - private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }), + public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }), + private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }), }); const lastSyncedKioskRef = useRef>({ public: defaultKioskSerializedRef.current.public, @@ -347,6 +407,8 @@ export default function App() { const analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions); const historical = useHistoricalImport(dataEnabled && !publicMode); const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode }); + const liveHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]); + const archiveHistoryData = useMemo(() => trimSingleDayHistory(archiveQuery.data, archiveRange), [archiveQuery.data, archiveRange]); const usersQuery = useQuery({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && (authQuery.data?.role === "admin"), staleTime: 15_000 }); const loginMutation = useMutation({ mutationFn: () => api.login(loginForm.username, loginForm.password), onSuccess: async () => { setLoginError(null); setLoginForm((value) => ({ ...value, password: "" })); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); await queryClient.invalidateQueries({ queryKey: ["dashboard-config"] }); }, onError: (error: Error) => setLoginError(parseError(error) || t(language, "loginError")) }); @@ -403,12 +465,12 @@ export default function App() { const allWidgets: Record = { hero: , quickMetrics: , - history: , + history: , status: , strings: , production: , comparison: effectiveCompare !== "none" ? : null, - distribution: , + distribution: null, importStatus: , }; const renderWidget = (widgetId: WidgetId) => { const content = allWidgets[widgetId]; if (!content) return null; return
{content}
; }; @@ -425,8 +487,8 @@ export default function App() { {connected ? t(language, "connected") : t(language, "disconnected")} - {!publicMode ? : null} - {!publicMode ? : null} + {!publicMode ? : null} + {!publicMode ? : null} @@ -450,15 +512,15 @@ export default function App() {
{navbar}{menu}
{activeTab === "realtime" && <>
{renderWidget("hero")}
{allWidgets.quickMetrics}
{allWidgets.history}
{allWidgets.status}
{allWidgets.strings}
} - {activeTab === "archive" && <>
{ setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveRangeOptions(language)} />
{ setArchiveRange("custom"); setArchiveStart(e.target.value); }} />
{ setArchiveRange("custom"); setArchiveEnd(e.target.value); }} />
item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} />
} + {activeTab === "archive" && <>
{ setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveQuickRangeOptions(language)} /> item.key === archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={[{ key: "", label: language === "en" ? "Choose range" : "Wybierz zakres" }, ...archiveListRangeOptions(language), { key: "custom", label: language === "en" ? "Custom range" : "Własny zakres" }]} />
{ setArchiveRange("custom"); setArchiveStart(e.target.value); }} />
{ setArchiveRange("custom"); setArchiveEnd(e.target.value); }} />
item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} />
} - {activeTab === "analytics" && <>
{ if (value !== "custom") { setAnalyticsRange(value); setAnalyticsStart(""); setAnalyticsEnd(""); } }} options={[...config.capabilities.ranges.filter((item) => !["6h", "24h"].includes(item.key)).map((item) => ({ key: item.key, label: item.label })), { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }]} /> ({ key: item.key, label: translateBucket(language, item.key) }))} />
{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ?
setAnalyticsStart(e.target.value)} />
setAnalyticsEnd(e.target.value)} />
{compare === "custom_multi" ?
{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}
{compareRanges.map((item, index) =>
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} />
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} />
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} />
)}
: null}
: null}
item.key === compare)?.label ?? compare} />
{allWidgets.production}
{allWidgets.distribution}
{compare !== "none" ?
{allWidgets.comparison}
: null}
} + {activeTab === "analytics" && <>
{ if (value !== "custom") { setAnalyticsRange(value); setAnalyticsStart(""); setAnalyticsEnd(""); } }} options={[...config.capabilities.ranges.filter((item) => !["6h", "24h", "48h", "1d", "3d", "14d", "60d", "ytd"].includes(item.key)).map((item) => ({ key: item.key, label: translateRangeLabel(language, item.key, item.label) })), { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }]} /> ({ key: item.key, label: translateBucket(language, item.key) }))} />
{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ?
setAnalyticsStart(e.target.value)} />
setAnalyticsEnd(e.target.value)} />
{compare === "custom_multi" ?
{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}
{compareRanges.map((item, index) =>
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} />
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} />
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} />
)}
: null}
: null}
item.key === compare)?.label ?? compare} />
{allWidgets.production}
{compare !== "none" ?
{allWidgets.comparison}
: null}
} {activeTab === "warehouse" && <>
historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} />
} {activeTab === "kiosk" && <>
applyKioskDraftChange(kioskEditorMode, value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} dirty={currentKioskDirty} canSave={canPersistKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} />
} - {activeTab === "settings" && <>
item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} />
{isAdmin ?
createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} />
: null}
} + {activeTab === "settings" && <>
item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} />
{isAdmin ?
createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} />
: null}
}
); } @@ -469,6 +531,12 @@ function translateBucket(language: Language, key: string): string { const map: R function comparisonOptions(language: Language) { return [{ key: "none", label: translateCompareMode(language, "none") }, { key: "previous_period", label: translateCompareMode(language, "previous_period") }, { key: "previous_year", label: translateCompareMode(language, "previous_year") }, { key: "previous_year_2", label: translateCompareMode(language, "previous_year_2") }, { key: "previous_year_3", label: translateCompareMode(language, "previous_year_3") }, { key: "previous_month_12", label: translateCompareMode(language, "previous_month_12") }, { key: "previous_month_24", label: translateCompareMode(language, "previous_month_24") }, { key: "custom_multi", label: translateCompareMode(language, "custom_multi") }]; } function applyArchivePreset(rangeKey: string, setStart: (value: string) => void, setEnd: (value: string) => void) { + const preset = archivePresetWindow(rangeKey); + if (preset) { + setStart(preset.start); + setEnd(preset.end); + return; + } if (rangeKey !== "custom") { setStart(""); setEnd(""); @@ -478,7 +546,9 @@ function LoadingScreen({ language }: { language: Language }) { return
void; onSubmit: () => void; onThemeToggle: () => void; onLanguageToggle: () => void; loading: boolean; error: string | null; }) { return

{t(language, "loginTitle")}

{t(language, "loginSubtitle")}
onChange({ ...form, username: event.target.value })} autoComplete="username" />
onChange({ ...form, password: event.target.value })} autoComplete="current-password" onKeyDown={(event) => event.key === "Enter" && onSubmit()} />
{error ?
{error}
: null}
; } function NavItem({ icon, active, onClick, label }: { icon: ReactElement; active: boolean; onClick: () => void; label: string }) { return
  • ; } function PageHeader({ title, subtitle, children }: { title: string; subtitle: string; children?: ReactNode }) { return
    PV Insight

    {title}

    {subtitle}
    {children ?
    {children}
    : null}
    ; } -function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
    {label}
    {options.map((option) => )}
    ; } +function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
    {label}
    {options.map((option) => )}
    ; } +function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
    ; } +function translateRangeLabel(language: Language, key: string, fallback: string): string { const map: Record = { today: { pl: "Dziś", en: "Today" }, yesterday: { pl: "Wczoraj", en: "Yesterday" }, "7d": { pl: "7 dni", en: "7d" }, "30d": { pl: "30 dni", en: "30d" }, "90d": { pl: "90 dni", en: "90d" }, "365d": { pl: "365 dni", en: "365 days" }, custom: { pl: "Ręczny", en: "Custom" } }; return map[key]?.[language] ?? fallback; } function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return
    {cards.map((card) =>
    {iconForMetric(card.metric_id)}{card.unit || "live"}
    {labelForMetric(language, card.metric_id, card.label)}
    {formatValue(card.value, card.unit, card.unit === "kWh" ? 2 : 2, locale)}
    {card.subtitle}
    )}
    ; } function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return

    {t(language, "quickMetrics")}

    {metrics.map((metric) =>
    {iconForMetric(metric.metric_id)}
    {labelForMetric(language, metric.metric_id, metric.label)}
    {metric.unit || t(language, "status")}
    {formatValue(metric.value, metric.unit, 2, locale)}
    )}
    ; } function LiveHistoryPanel({ data, language, theme, title, subtitle }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string }) { return

    {title}

    {subtitle}
    ; } @@ -486,15 +556,20 @@ function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; lo function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return

    {t(language, "strings")}

    {rows.length === 0 ?
    {t(language, "noDataDescription")}
    : rows.map((row) =>
    {row.label}
    DC
    {Object.values(row.values).map((metric) =>
    {labelForMetric(language, metric.metric_id, metric.label)}{formatValue(metric.value, metric.unit, 2, locale)}
    )}
    )}
    ; } function SummaryCards({ summary, language, locale, compareLabel }: { summary?: AnalyticsPayload["summary"]; language: Language; locale: string; compareLabel: string }) { const items = [{ key: t(language, "summaryTotal"), value: formatValue(summary?.total, summary?.unit ?? "kWh", 2, locale), badge: compareLabel }, { key: t(language, "summaryAverage"), value: formatValue(summary?.average_bucket, summary?.unit ?? "kWh", 2, locale) }, { key: t(language, "summaryBest"), value: summary ? `${summary.best_bucket_label} · ${formatValue(summary.best_bucket_value, summary.unit, 2, locale)}` : "--" }, { key: t(language, "summaryCo2"), value: formatValue(summary?.co2_saved_kg, "kg", 1, locale) }]; return
    {items.map((item) =>
    {item.key}
    {item.value}
    {item.badge ?
    {item.badge}
    : null}
    )}
    ; } function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return

    {t(language, "chartProduction")}

    {t(language, "chartProductionSubtitle")}
    ; } -function ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return

    {t(language, "chartComparison")}

    ; } +function ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { + const [comparisonDisplayMode, setComparisonDisplayMode] = useState<"line" | "bar">("line"); + return

    {t(language, "chartComparison")}

    {comparisonDisplayMode === "line" + ? (language === "en" ? "Current period stays in bars, and comparisons are shown as green lines for readability." : "Możesz widok przełączyć do słupków w celu poprawy czytelności.") + : (language === "en" ? "Switch to grouped bars when you want a direct bar-to-bar comparison." : "Przełącz do porównania wykresu linią.")}
    setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} />
    ; +} function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return

    {t(language, "chartDistribution")}

    {formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}
    ; } function HistoricalPanel({ status, language, locale, compact = false }: { status?: HistoricalStatus; language: Language; locale: string; compact?: boolean }) { if (!status) return
    {t(language, "noDataDescription")}
    ; return

    {language === "en" ? "Data warehouse" : "Hurtownia danych"}

    {t(language, "importArchiveSubtitle")}
    {!compact ? <>
    {t(language, "activeChunk")}{status.active_chunk_index}/{status.total_chunks}
    {status.recent_chunks.map((chunk) => )}
    {t(language, "recentChunks")}{t(language, "status")}kWh
    #{chunk.chunk_index}
    {chunk.start_date} → {chunk.end_date}
    {chunk.state}{formatValue(chunk.energy_kwh, "kWh", 2, locale)}
    {status.recent_events.map((event, index) =>
    {event.title}
    {event.message}
    {formatShortTime(event.timestamp, locale)}
    )}
    : null}
    ; } function StatusStat({ label, value }: { label: string; value: string }) { return
    {label}
    {value}
    ; } function ImportControls({ status, language, onStart, onSyncNow, onCancel }: { status?: HistoricalStatus; language: Language; onStart: (payload: { start_date?: string; end_date?: string; chunk_days?: number; force?: boolean }) => void; onSyncNow: () => void; onCancel: () => void; }) { const [startDate, setStartDate] = useState(status?.available_start_date ?? ""); const [endDate, setEndDate] = useState(status?.available_end_date ?? ""); const [chunkDays, setChunkDays] = useState(String(status?.default_chunk_days ?? 7)); useEffect(() => { if (!status) return; setStartDate((current) => current || status.available_start_date || ""); setEndDate((current) => current || status.available_end_date || ""); setChunkDays((current) => current || String(status.default_chunk_days || 7)); }, [status]); return

    {language === "en" ? "Import controls" : "Sterowanie importem"}

    setStartDate(event.target.value)} />
    setEndDate(event.target.value)} />
    setChunkDays(event.target.value)} />
    ; } -function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map; }) { const available = widgetOrder.map((item) => item.id); const selected = widgets; const unselected = available.filter((item) => !selected.includes(item)); const move = (id: WidgetId, direction: -1 | 1) => { const index = selected.indexOf(id); if (index === -1) return; const target = index + direction; if (target < 0 || target >= selected.length) return; const next = [...selected]; [next[index], next[target]] = [next[target], next[index]]; onChange(next); }; const toggle = (id: WidgetId) => { if (selected.includes(id)) { const next = selected.filter((item) => item !== id); onChange(next.length ? next : selected); return; } onChange([...selected, id]); }; return

    {t(language, "kioskLayout")}

    {t(language, "kioskLayoutSubtitle")}
    {t(language, "saveLayout")}
    {t(language, "selected")}
    {selected.map((id) =>
    {labels.get(id)}
    )}
    {t(language, "available")}
    {unselected.map((id) => )}
    ; } +function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map; }) { const available = widgetOrder.map((item) => item.id); const selected = widgets; const unselected = available.filter((item) => !selected.includes(item)); const move = (id: WidgetId, direction: -1 | 1) => { const index = selected.indexOf(id); if (index === -1) return; const target = index + direction; if (target < 0 || target >= selected.length) return; const next = [...selected]; [next[index], next[target]] = [next[target], next[index]]; onChange(next); }; const toggle = (id: WidgetId) => { if (selected.includes(id)) { const next = selected.filter((item) => item !== id); onChange(next.length ? next : selected); return; } onChange([...selected, id]); }; return

    {t(language, "kioskLayout")}

    {t(language, "kioskLayoutSubtitle")}
    {t(language, "infoSave")}
    {t(language, "selected")}
    {selected.map((id) =>
    {labels.get(id)}
    )}
    {t(language, "available")}
    {unselected.map((id) => )}
    ; } function KioskSettingsEditorPanel({ language, value, onChange, onSave, onReset, selectedMode, onModeChange, labels, buckets, compareModes, saving, dirty, canSave, saveNotice }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; onReset: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; }) { const widgets = toWidgetIds(value.widgets); return

    {language === "en" ? "Kiosk settings" : "Ustawienia kiosku"}

    {dirty ? (language === "en" ? "You have local changes." : "Masz lokalne zmiany.") : (language === "en" ? "No unsaved changes." : "Brak niezapisanych zmian.")}{saveNotice ? {saveNotice} : null}
    onChange({ ...value, widgets: widgetsValue })} labels={labels} />
    ; } function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload }) { const [copied, setCopied] = useState<"public" | "private" | null>(null); const copy = async (value: string, mode: "public" | "private") => { await navigator.clipboard.writeText(value); setCopied(mode); window.setTimeout(() => setCopied(null), 1500); }; return

    {language === "en" ? "Kiosk links" : "Linki kiosku"}

    {language === "en" ? "Public kiosk" : "Kiosk publiczny"}
    {language === "en" ? "Read-only access without login." : "Podgląd bez logowania, tylko odczyt."}
    {language === "en" ? "Ranges:" : "Zakresy:"} live {publicSettings.realtime_range}, analytics {publicSettings.analytics_range}
    {language === "en" ? "Private kiosk" : "Kiosk prywatny"}
    {language === "en" ? "Requires login and uses private kiosk settings." : "Wymaga logowania i używa prywatnych ustawień kiosku."}
    {language === "en" ? "Ranges:" : "Zakresy:"} live {privateSettings.realtime_range}, analytics {privateSettings.analytics_range}
    ; } -function AppearanceSecurityPanel({ language, theme, setTheme, viewMode, setViewMode, authEnabled, userName }: { language: Language; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; authEnabled: boolean; userName: string; }) { return

    {t(language, "theme")}

    {t(language, "viewMode")}

    {t(language, "security")}

    {authEnabled ? t(language, "authEnabled") : t(language, "authDisabled")}
    {language === "en" ? "Admin user management is available below." : "Zarządzanie użytkownikami admina jest dostępne niżej."}
    {userName ?
    {userName}
    : null}
    ; } +function AppearancePanel({ language, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return

    {language === "en" ? "Appearance and mode" : "Wygląd i tryb pracy"}

    {language === "en" ? "Most-used display settings in one place." : "Najczęściej używane ustawienia wyświetlania w jednym miejscu."}
    {userName ? {userName} : null}
    {t(language, "theme")}
    {t(language, "viewMode")}
    ; } function MetricSelectorCard({ language, title, items, selected, onChange }: { language: Language; title: string; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { const toggle = (metricId: string) => onChange(selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId]); return

    {title}

    {items.map((item) => )}
    ; } function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return ; } function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record; onChange: (value: Record) => void; }) { const toggle = (target: BlockTarget, metricId: string) => { const selected = config[target]; onChange({ ...config, [target]: selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId] }); }; return

    {language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}

    Hero metrics
    {items.map((item) => )}
    Quick metrics
    {items.map((item) => )}
    ; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3b17f60..d547687 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -69,7 +69,7 @@ async function demoResponse(factory: () => T): Promise { export const api = { getConfig: () => (DEMO_MODE ? demoResponse(() => demoConfig) : request("/dashboard/config")), - getKioskSettings: (mode: "public" | "private") => (DEMO_MODE ? demoResponse(() => ({ mode, widgets: ["hero", "history", "strings", "status", "production", "comparison", "importStatus"], realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" })) : request(`/dashboard/kiosk-settings?mode=${mode}`)), + getKioskSettings: (mode: "public" | "private") => (DEMO_MODE ? demoResponse(() => ({ mode, widgets: ["hero", "history", "strings", "status", "production", "comparison", "importStatus"], realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" })) : request(`/dashboard/kiosk-settings?mode=${mode}`)), saveKioskSettings: (payload: KioskSettingsPayload) => (DEMO_MODE ? demoResponse(() => payload) : request("/dashboard/kiosk-settings", { method: "PUT", body: JSON.stringify(payload) })), getAuthStatus: () => (DEMO_MODE ? demoResponse(() => demoAuthStatus) : request("/auth/status")), login: (username: string, password: string) => diff --git a/frontend/src/demo/data.ts b/frontend/src/demo/data.ts index 5d44bfa..77bb0f6 100644 --- a/frontend/src/demo/data.ts +++ b/frontend/src/demo/data.ts @@ -20,7 +20,7 @@ export const demoConfig: DashboardConfig = { installed_power_kwp: 9.86, }, defaults: { - realtime_range: "6h", + realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", tab: "realtime", @@ -39,6 +39,8 @@ export const demoConfig: DashboardConfig = { realtime_enabled: true, comparison_modes: ["none", "previous_period", "previous_year"], ranges: [ + { key: "today", label: "Dziś" }, + { key: "yesterday", label: "Wczoraj" }, { key: "6h", label: "6h" }, { key: "24h", label: "24h" }, { key: "1d", label: "1 dzień" }, diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 290288f..64dabb3 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -12,7 +12,7 @@ const messages = { disconnected: "Brak połączenia", updatedAt: "Aktualizacja", loginTitle: "Logowanie", - loginSubtitle: "Minitoring fotowoltaiki", + loginSubtitle: "Monitoring fotowoltaiki", username: "Login", password: "Hasło", signIn: "Zaloguj", @@ -78,7 +78,7 @@ const messages = { recentEvents: "Ostatnie zdarzenia", kioskLayout: "Układ kiosku", kioskLayoutSubtitle: "Wybierz widżety i kolejność widoku kiosku", - saveLayout: "Układ zapisuje się automatycznie", + infoSave: "Po zmianie układu zapisz przyciskiem powyżej", kioskHint: "Tryb kiosku.", widgetSelect: "Widżety", moveUp: "W górę", @@ -185,7 +185,7 @@ const messages = { recentEvents: "Recent events", kioskLayout: "Kiosk layout", kioskLayoutSubtitle: "Choose widgets and their order for kiosk view", - saveLayout: "Layout is saved automatically", + infoSave: "After changing the layout, save using the button above", kioskHint: "Kiosk mode.", widgetSelect: "Widgets", moveUp: "Move up", diff --git a/frontend/src/index.css b/frontend/src/index.css index e228b96..29ff050 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -118,19 +118,19 @@ body { display: inline-flex !important; align-items: center; justify-content: center; - gap: 0.6rem; - min-height: 2.75rem; - padding: 0.7rem 1rem !important; + gap: 0.1rem; + min-height: 1.75rem; + padding: 0.5rem 0.5rem !important; } .pv-nav-icon { - width: 1.25rem; - min-width: 1.25rem; - height: 1.25rem; + width: 1rem; + min-width: 1rem; + height: 1rem; display: inline-flex; align-items: center; justify-content: center; - flex: 0 0 1.25rem; + flex: 0 0 1.1rem; line-height: 1; } @@ -146,3 +146,57 @@ body { line-height: 1.1; white-space: nowrap; } + +.pv-navbar-action { + border-color: transparent !important; + background: rgba(127, 127, 127, 0.1) !important; + color: inherit !important; +} + +.pv-navbar-action:hover, +.pv-navbar-action:focus { + background: rgba(127, 127, 127, 0.18) !important; +} + +.pv-filter-grid { + display: grid; + grid-template-columns: repeat(3, minmax(180px, 1fr)); + gap: 0.75rem; + align-items: end; +} + +.pv-filter-grid-archive { + grid-template-columns: minmax(240px, 1.35fr) minmax(180px, 0.9fr) repeat(2, minmax(160px, 1fr)); +} + +.pv-segmented { + min-width: 0; +} + +.pv-segmented-label { + margin-bottom: 0.35rem; + font-size: 0.75rem; + color: var(--tblr-secondary); +} + +.pv-segmented-group { + display: flex !important; + flex-wrap: wrap; + gap: 0.45rem; +} + +.pv-segmented-group > .btn { + border-radius: 0.65rem !important; + flex: 1 0 auto; +} + +.pv-filter-field { + min-width: 0; +} + +@media (max-width: 992px) { + .pv-filter-grid, + .pv-filter-grid-archive { + grid-template-columns: 1fr; + } +}