Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/(main)/MobileNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -54,6 +55,7 @@ export function MobileNav() {
);
})}
</NavMenu>
<MobileLanguageButton />
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
{isAdmin && <AdminNav onItemClick={close} />}
{isSettings && <SettingsNav onItemClick={close} />}
Expand Down
132 changes: 130 additions & 2 deletions src/components/charts/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<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({
chartData,
renderXLabel,
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
Expand All @@ -121,7 +249,7 @@ export function BarChart({
<Chart
{...props}
type="bar"
chartData={chartData}
chartData={paddedChartData}
chartOptions={chartOptions}
onTooltip={handleTooltip}
/>
Expand Down
74 changes: 74 additions & 0 deletions src/components/input/MobileLanguageButton.tsx
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Missing right: 0 or width: 100% means the bar won't stretch full width across the screen.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/input/MobileLanguageButton.tsx
Line: 20:20

Comment:
**style:** Missing `right: 0` or `width: 100%` means the bar won't stretch full width across the screen.

How can I resolve this? If you propose a fix, please make it concise.

padding: 16,
backgroundColor: 'var(--background)',
borderTop: '1px solid var(--border)',
gap: 8,
justifyContent: 'center',
zIndex: 1000,
}}
Comment on lines +18 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: position: fixed positions relative to the viewport, not the modal/dialog container. This will place the button bar at the bottom of the entire screen rather than within the mobile drawer, potentially overlapping other page content when the drawer is open.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/input/MobileLanguageButton.tsx
Line: 18:27

Comment:
**logic:** `position: fixed` positions relative to the viewport, not the modal/dialog container. This will place the button bar at the bottom of the entire screen rather than within the mobile drawer, potentially overlapping other page content when the drawer is open.

How can I resolve this? If you propose a fix, please make it concise.

>
<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',
}}
Comment on lines +40 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Using !important and setting both left and right to 0 is unusual. The popover positioning may conflict with the fixed positioning of the parent Row, causing rendering issues or misalignment.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/input/MobileLanguageButton.tsx
Line: 40:44

Comment:
**style:** Using `!important` and setting both `left` and `right` to 0 is unusual. The popover positioning may conflict with the fixed positioning of the parent Row, causing rendering issues or misalignment.

How can I resolve this? If you propose a fix, please make it concise.

>
<Dialog variant="menu" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Using maxHeight: '60vh' with overflowY: 'auto' inside a modal that's already constrained may cause scrolling issues if the language list is long and combined with the fixed bottom positioning.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/input/MobileLanguageButton.tsx
Line: 46:46

Comment:
**style:** Using `maxHeight: '60vh'` with `overflowY: 'auto'` inside a modal that's already constrained may cause scrolling issues if the language list is long and combined with the fixed bottom positioning.

How can I resolve this? If you propose a fix, please make it concise.

<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>
);
}