mirror of
https://github.com/umami-software/umami.git
synced 2025-12-08 05:12:36 +01:00
Merge 84b47200fc into a19b92a5cb
This commit is contained in:
commit
fd0ad0cd61
3 changed files with 206 additions and 2 deletions
|
|
@ -8,6 +8,7 @@ import { Grid, IconLabel, NavMenu, NavMenuItem, Row, Text } from '@umami/react-z
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AdminNav } from './admin/AdminNav';
|
import { AdminNav } from './admin/AdminNav';
|
||||||
import { SettingsNav } from './settings/SettingsNav';
|
import { SettingsNav } from './settings/SettingsNav';
|
||||||
|
import { MobileLanguageButton } from '@/components/input/MobileLanguageButton';
|
||||||
|
|
||||||
export function MobileNav() {
|
export function MobileNav() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
@ -54,6 +55,7 @@ export function MobileNav() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</NavMenu>
|
</NavMenu>
|
||||||
|
<MobileLanguageButton />
|
||||||
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
|
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
|
||||||
{isAdmin && <AdminNav onItemClick={close} />}
|
{isAdmin && <AdminNav onItemClick={close} />}
|
||||||
{isSettings && <SettingsNav onItemClick={close} />}
|
{isSettings && <SettingsNav onItemClick={close} />}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { renderNumberLabels } from '@/lib/charts';
|
||||||
import { getThemeColors } from '@/lib/colors';
|
import { getThemeColors } from '@/lib/colors';
|
||||||
import { formatDate, DATE_FORMATS } from '@/lib/date';
|
import { formatDate, DATE_FORMATS } from '@/lib/date';
|
||||||
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { ManipulateType } from 'dayjs';
|
||||||
|
|
||||||
const dateFormats = {
|
const dateFormats = {
|
||||||
millisecond: 'T',
|
millisecond: 'T',
|
||||||
|
|
@ -32,6 +34,124 @@ export interface BarChartProps extends ChartProps {
|
||||||
maxDate?: Date;
|
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<string, number>();
|
||||||
|
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<string, number>();
|
||||||
|
(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: <iso>, y: <num> } 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({
|
export function BarChart({
|
||||||
chartData,
|
chartData,
|
||||||
renderXLabel,
|
renderXLabel,
|
||||||
|
|
@ -50,6 +170,14 @@ export function BarChart({
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { colors } = useMemo(() => getThemeColors(theme), [theme]);
|
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(() => {
|
const chartOptions: any = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
__id: new Date().getTime(),
|
__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 handleTooltip = ({ tooltip }: { tooltip: any }) => {
|
||||||
const { opacity, labelColors, dataPoints } = tooltip;
|
const { opacity, labelColors, dataPoints } = tooltip;
|
||||||
|
|
@ -121,7 +249,7 @@ export function BarChart({
|
||||||
<Chart
|
<Chart
|
||||||
{...props}
|
{...props}
|
||||||
type="bar"
|
type="bar"
|
||||||
chartData={chartData}
|
chartData={paddedChartData}
|
||||||
chartOptions={chartOptions}
|
chartOptions={chartOptions}
|
||||||
onTooltip={handleTooltip}
|
onTooltip={handleTooltip}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
74
src/components/input/MobileLanguageButton.tsx
Normal file
74
src/components/input/MobileLanguageButton.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Row
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuTrigger>
|
||||||
|
<Button variant="quiet">
|
||||||
|
<Icon>
|
||||||
|
<Globe />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
placement="top"
|
||||||
|
style={{
|
||||||
|
width: '75vw',
|
||||||
|
maxWidth: '100vw',
|
||||||
|
left: '0 !important',
|
||||||
|
right: '0 !important',
|
||||||
|
marginBottom: 16,
|
||||||
|
position: 'fixed',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="menu" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
<Grid columns="1fr" gap={1} style={{ padding: '8px 0' }}>
|
||||||
|
{items.map(({ value, label }) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
variant="quiet"
|
||||||
|
onPress={() => handleSelect(value)}
|
||||||
|
style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
minHeight: '48px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
weight={value === locale ? 'bold' : 'medium'}
|
||||||
|
color={value === locale ? undefined : 'muted'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Dialog>
|
||||||
|
</Popover>
|
||||||
|
</MenuTrigger>
|
||||||
|
<ThemeButton />
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue