diff --git a/src/app/(main)/MobileNav.tsx b/src/app/(main)/MobileNav.tsx index a194496063..934c43faac 100644 --- a/src/app/(main)/MobileNav.tsx +++ b/src/app/(main)/MobileNav.tsx @@ -8,6 +8,7 @@ import { Grid, IconLabel, NavMenu, NavMenuItem, Row, Text } from '@umami/react-z import Link from 'next/link'; import { AdminNav } from './admin/AdminNav'; import { SettingsNav } from './settings/SettingsNav'; +import { MobileLanguageButton } from '@/components/input/MobileLanguageButton'; export function MobileNav() { const { formatMessage, labels } = useMessages(); @@ -54,6 +55,7 @@ export function MobileNav() { ); })} + {websiteId && } {isAdmin && } {isSettings && } diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx index 3f3efd3be3..dd433a5619 100644 --- a/src/components/charts/BarChart.tsx +++ b/src/components/charts/BarChart.tsx @@ -7,6 +7,8 @@ import { renderNumberLabels } from '@/lib/charts'; import { getThemeColors } from '@/lib/colors'; import { formatDate, DATE_FORMATS } from '@/lib/date'; import { formatLongCurrency, formatLongNumber } from '@/lib/format'; +import dayjs from 'dayjs'; +import type { ManipulateType } from 'dayjs'; const dateFormats = { millisecond: 'T', @@ -32,6 +34,124 @@ export interface BarChartProps extends ChartProps { maxDate?: Date; } +function stepByUnit(start: dayjs.Dayjs, end: dayjs.Dayjs, unit: ManipulateType) { + const steps: dayjs.Dayjs[] = []; + let cur = start.startOf(unit); + const endBound = end.startOf(unit); + while (cur.isBefore(endBound) || cur.isSame(endBound)) { + steps.push(cur); + cur = cur.add(1, unit); + // safety guard + if (steps.length > 10000) break; + } + return steps; +} + +/** + * Pads time-series chartData between minDate..maxDate by unit. + * Supports common chartData shapes: + * 1) Chart.js style: { labels: string[], datasets: [{ label, data: number[] | {x,y}[] }] } + * 2) Series style: [{ label, data: [{ x, y }] }] + */ + +function padTimeSeriesChartData( + chartData: any, + unit: ManipulateType, + minDate?: Date, + maxDate?: Date, +) { + if (!unit || !minDate || !maxDate || !chartData) return chartData; + + const start = dayjs(minDate); + const end = dayjs(maxDate); + + // build the canonical list of step timestamps (ISO strings) + const steps = stepByUnit(start, end, unit); + const stepKeys = steps.map(s => s.toISOString()); + + // helper to find value by x in an array of {x,y} + const mapArrayXY = (arr: any[]) => { + const m = new Map(); + arr.forEach(d => { + if (!d) return; + const x = d.x ? dayjs(d.x).toISOString() : d[0] ? dayjs(d[0]).toISOString() : null; + const y = + typeof d.y === 'number' ? d.y : Array.isArray(d) && typeof d[1] === 'number' ? d[1] : 0; + if (x) { + // accumulate if duplicates exist + m.set(x, (m.get(x) || 0) + (y || 0)); + } + }); + return m; + }; + + // Case A: Chart.js style + if (chartData && chartData.labels && Array.isArray(chartData.datasets)) { + // Normalize: if dataset.data is array of numbers aligned with labels -> create label->value map + const newLabels = stepKeys.map(k => formatDate(new Date(k), DATE_FORMATS[unit], 'en')); // labels formatted; locale handled by Chart options + const newDatasets = chartData.datasets.map((ds: any) => { + // two subcases: ds.data is array of primitives aligning to chartData.labels OR array of {x,y} + if (!ds || !Array.isArray(ds.data)) return { ...ds, data: Array(newLabels.length).fill(0) }; + + // detect object entries + const first = ds.data[0]; + if (first && typeof first === 'object' && ('x' in first || 'y' in first)) { + const m = mapArrayXY(ds.data); + const data = stepKeys.map(k => m.get(k) || 0); + return { ...ds, data }; + } + + // otherwise assume ds.data aligns with chartData.labels + const labelMap = new Map(); + (chartData.labels || []).forEach((lbl: any, idx: number) => { + const key = (lbl && new Date(lbl).toISOString()) || lbl; // try to convert label -> ISO if possible + labelMap.set(key, ds.data[idx] ?? 0); + // also store raw label string + labelMap.set(String(lbl), ds.data[idx] ?? 0); + }); + + const data = stepKeys.map(k => labelMap.get(k) ?? labelMap.get(new Date(k).toString()) ?? 0); + return { ...ds, data }; + }); + + return { ...chartData, labels: newLabels, datasets: newDatasets }; + } + + // Case A2: Chart.js-style object with datasets but without labels, + // where datasets[].data is [{ x, y }] (this is the shape EventsChart produces) + if (chartData && Array.isArray(chartData.datasets) && !chartData.labels) { + const newDatasets = chartData.datasets.map((ds: any) => { + if (!ds || !Array.isArray(ds.data)) { + // produce zero series aligned to steps + const data = stepKeys.map(k => ({ x: k, y: 0 })); + return { ...ds, data }; + } + const m = mapArrayXY(ds.data); + const data = stepKeys.map(k => ({ x: k, y: m.get(k) || 0 })); + return { ...ds, data }; + }); + + // keep any other fields (like focusLabel) intact + return { ...chartData, datasets: newDatasets }; + } + + // Case B: Series style: array of series objects { label, data: [{ x, y }] } + if (Array.isArray(chartData)) { + const paddedSeries = chartData.map(series => { + if (!series || !Array.isArray(series.data)) return { ...series, data: stepKeys.map(() => 0) }; + const m = mapArrayXY(series.data); + // produce data array aligned with steps (each element { x: , y: } or number depending on original) + // We'll return in the { x, y } form so Chart can understand timeseries data + const data = stepKeys.map(k => ({ x: k, y: m.get(k) || 0 })); + return { ...series, data }; + }); + return paddedSeries; + } + + // fallback: return original + return chartData; +} + export function BarChart({ chartData, renderXLabel, @@ -50,6 +170,14 @@ export function BarChart({ const { locale } = useLocale(); const { colors } = useMemo(() => getThemeColors(theme), [theme]); + // If this is a timeseries and we have min/max and a time unit, pad missing steps + const paddedChartData = useMemo(() => { + if (XAxisType === 'timeseries' && unit && minDate && maxDate) { + return padTimeSeriesChartData(chartData, unit as ManipulateType, minDate, maxDate); + } + return chartData; + }, [chartData, unit, XAxisType, minDate?.toString(), maxDate?.toString()]); + const chartOptions: any = useMemo(() => { return { __id: new Date().getTime(), @@ -94,7 +222,7 @@ export function BarChart({ }, }, }; - }, [chartData, colors, unit, stacked, renderXLabel, renderYLabel]); + }, [paddedChartData, colors, unit, stacked, renderXLabel, renderYLabel]); const handleTooltip = ({ tooltip }: { tooltip: any }) => { const { opacity, labelColors, dataPoints } = tooltip; @@ -121,7 +249,7 @@ export function BarChart({ diff --git a/src/components/input/MobileLanguageButton.tsx b/src/components/input/MobileLanguageButton.tsx new file mode 100644 index 0000000000..e53be70e6b --- /dev/null +++ b/src/components/input/MobileLanguageButton.tsx @@ -0,0 +1,74 @@ +import { Icon, Button, MenuTrigger, Popover, Grid, Text, Dialog, Row } from '@umami/react-zen'; +import { languages } from '@/lib/lang'; +import { useLocale } from '@/components/hooks'; +import { Globe } from 'lucide-react'; +import { ThemeButton } from '@umami/react-zen'; + +export function MobileLanguageButton() { + const { locale, saveLocale } = useLocale(); + const items = Object.keys(languages).map(key => ({ ...languages[key], value: key })); + + function handleSelect(value: string) { + saveLocale(value); + } + + return ( + + + + + + + {items.map(({ value, label }) => ( + + ))} + + + + + + + ); +}