Skip to content
Draft
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
4 changes: 2 additions & 2 deletions src/components/Charts/BarChart/BarChartContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
useDynamicYDomain,
useLabelHitTesting,
} from '@components/Charts/hooks';
import {calculateMinDomainPadding, getYAxisLabelWidth} from '@components/Charts/utils';
import {calculateMinDomainPadding, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils';
import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme';

import useTheme from '@hooks/useTheme';
Expand Down Expand Up @@ -88,7 +88,7 @@ function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'l
const totalDomainPadding = domainPadding.left + domainPadding.right;
const paddingScale = barAreaWidth > 0 ? barAreaWidth / (barAreaWidth + totalDomainPadding) : 0;

const originalLabels = data.map((p) => p.label);
const originalLabels = data.map(getXAxisLabel);

const measurements = useChartLabelMeasurements(data, fontManager, variables.iconSizeExtraSmall);

Expand Down
4 changes: 2 additions & 2 deletions src/components/Charts/LineChart/LineChartContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
useDynamicYDomain,
useLabelHitTesting,
} from '@components/Charts/hooks';
import {getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils';
import {getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils';
import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from '@components/Charts/VictoryTheme';

import useTheme from '@hooks/useTheme';
Expand Down Expand Up @@ -129,7 +129,7 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = '
measurements,
});

const originalLabels = data.map((p) => p.label);
const originalLabels = data.map(getXAxisLabel);

const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({
fontManager,
Expand Down
15 changes: 8 additions & 7 deletions src/components/Charts/hooks/useChartLabelMeasurements.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {ChartDataPoint} from '@components/Charts/types';
import {getFontLineMetrics, measureTextWidth} from '@components/Charts/utils';
import {getFontLineMetrics, getXAxisLabel, measureTextWidth} from '@components/Charts/utils';
import {ELLIPSIS, MIN_TRUNCATED_CHARS} from '@components/Charts/VictoryTheme';

import type {SkTypefaceFontProvider} from '@shopify/react-native-skia';
Expand All @@ -15,22 +15,23 @@ function useChartLabelMeasurements(data: ChartDataPoint[], fontManager: SkTypefa
const {ascent, descent} = getFontLineMetrics(fontManager, fontSize);
const lineHeight = Math.abs(ascent) + Math.abs(descent);
const ellipsisWidth = measureTextWidth(ELLIPSIS, fontManager, fontSize);
const labelWidths = data.map((point) => measureTextWidth(point.label, fontManager, fontSize));
const labels = data.map(getXAxisLabel);
const labelWidths = labels.map((label) => measureTextWidth(label, fontManager, fontSize));
const maxLabelWidth = Math.max(...labelWidths);
const firstLabelWidth = labelWidths.at(0) ?? 0;
const lastLabelWidth = labelWidths.at(-1) ?? 0;

const minTruncatedWidth = Math.max(
...data.map((point, index) => {
if (point.label.length <= MIN_TRUNCATED_CHARS) {
...labels.map((label, index) => {
if (label.length <= MIN_TRUNCATED_CHARS) {
return labelWidths.at(index) ?? 0;
}
return measureTextWidth(point.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize);
return measureTextWidth(label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize);
}),
);

const firstLabel = data.at(0)?.label ?? '';
const lastLabel = data.at(-1)?.label ?? '';
const firstLabel = labels.at(0) ?? '';
const lastLabel = labels.at(-1) ?? '';
const firstMinTrunc = firstLabel.length <= MIN_TRUNCATED_CHARS ? firstLabelWidth : measureTextWidth(firstLabel.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize);
const lastMinTrunc = lastLabel.length <= MIN_TRUNCATED_CHARS ? lastLabelWidth : measureTextWidth(lastLabel.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize);

Expand Down
5 changes: 4 additions & 1 deletion src/components/Charts/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import type {SkParagraph} from '@shopify/react-native-skia';
import type {ValueOf} from 'type-fest';

type ChartDataPoint = {
/** Label displayed under the data point (e.g., "Amazon", "Nov 2025") */
/** Full label for the data point (e.g., "Amazon", "November 2025") */
label: string;

/** Compact label for the x-axis (e.g., "Nov ’25"). Defaults to `label`. */
shortLabel?: string;

/** Total amount (pre-formatted, e.g., dollars not cents) */
total: number;

Expand Down
6 changes: 6 additions & 0 deletions src/components/Charts/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ function processDataIntoSlices(
).slices;
}

/** Label to render on the x-axis for a data point: the compact one when provided, otherwise the full label. */
function getXAxisLabel(point: ChartDataPoint): string {
return point.shortLabel ?? point.label;
}

/** Truncate `label` so its pixel width fits within `maxWidth`, adding ellipsis. */
function truncateLabel(label: string, labelWidth: number, maxWidth: number, ellipsisWidth: number): string {
if (labelWidth <= maxWidth) {
Expand Down Expand Up @@ -460,6 +465,7 @@ export {
isAngleInSlice,
findSliceAtPosition,
processDataIntoSlices,
getXAxisLabel,
truncateLabel,
effectiveWidth,
effectiveHeight,
Expand Down
3 changes: 2 additions & 1 deletion src/components/Search/SearchBarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import React from 'react';

import type {SearchChartProps} from './types';

function SearchBarChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) {
function SearchBarChart({data, getLabel, getShortLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) {
const {getCurrencyDecimals} = useCurrencyListActions();
const chartData: ChartDataPoint[] = data.map((item) => {
const currency = item.currency ?? 'USD';
Expand All @@ -18,6 +18,7 @@ function SearchBarChart({data, getLabel, getFilterQuery, onItemPress, isLoading,

return {
label: getLabel(item),
shortLabel: getShortLabel?.(item),
total: totalInDisplayUnits,
};
});
Expand Down
3 changes: 2 additions & 1 deletion src/components/Search/SearchChartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar
const {preferredLocale} = useLocalize();
const {getCurrencySymbol} = useCurrencyListActions();

const {getLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy];
const {getLabel, getShortLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy];
const ChartComponent = CHART_VIEW_TO_COMPONENT[view];

const handleItemPress = (filterQuery: string) => {
Expand Down Expand Up @@ -90,6 +90,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar
<ChartComponent
data={data}
getLabel={(item) => StringUtils.normalize(getLabel(item))}
getShortLabel={getShortLabel}
getFilterQuery={getFilterQuery}
onItemPress={handleItemPress}
isLoading={isLoading}
Expand Down
3 changes: 2 additions & 1 deletion src/components/Search/SearchLineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import React from 'react';

import type {SearchChartProps} from './types';

function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) {
function SearchLineChart({data, getLabel, getShortLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) {
const {getCurrencyDecimals} = useCurrencyListActions();
const chartData: ChartDataPoint[] = data.map((item) => {
const currency = item.currency ?? 'USD';
Expand All @@ -18,6 +18,7 @@ function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading

return {
label: getLabel(item),
shortLabel: getShortLabel?.(item),
total: totalInDisplayUnits,
};
});
Expand Down
9 changes: 9 additions & 0 deletions src/components/Search/SearchList/ListItem/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,9 @@ type TransactionMonthGroupListItemType = TransactionGroupListItemType & {grouped
/** Final and formatted "month" value used for displaying */
formattedMonth: string;

/** Compact "month" value used where space is tight, e.g. chart axis labels */
shortFormattedMonth: string;

/** Key used for sorting */
sortKey: number;
};
Expand Down Expand Up @@ -450,6 +453,9 @@ type TransactionTagGroupListItemType = TransactionGroupListItemType & {groupedBy
type TransactionWeekGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.WEEK} & SearchWeekGroup & {
/** Final and formatted "week" value used for displaying */
formattedWeek: string;

/** Compact "week" value used where space is tight, e.g. chart axis labels */
shortFormattedWeek: string;
};

type TransactionYearGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.YEAR} & SearchYearGroup & {
Expand All @@ -464,6 +470,9 @@ type TransactionQuarterGroupListItemType = TransactionGroupListItemType & {group
/** Final and formatted "quarter" value used for displaying */
formattedQuarter: string;

/** Compact "quarter" value used where space is tight, e.g. chart axis labels */
shortFormattedQuarter: string;

/** Sort key for sorting */
sortKey: number;
};
Expand Down
11 changes: 11 additions & 0 deletions src/components/Search/chartGroupByConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ import type {
import type {GroupedItem, SearchGroupBy} from './types';

type ChartGroupByConfig = {
/** Name of the icon rendered next to the chart title */
titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar';

/** Returns the full label for a group (e.g. "Amazon", "November 2025") */
getLabel: (item: GroupedItem) => string;

/** Returns the compact label for chart axes, or undefined to fall back to `getLabel` */
getShortLabel?: (item: GroupedItem) => string | undefined;

/** Builds the query fragment appended to the current query to drill into a group's transactions */
getFilterQuery: (item: GroupedItem) => string;
};

Expand Down Expand Up @@ -61,6 +69,7 @@ const CHART_GROUP_BY_CONFIG: Record<SearchGroupBy, ChartGroupByConfig> = {
[CONST.SEARCH.GROUP_BY.MONTH]: {
titleIconName: 'Calendar',
getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '',
getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? item.shortFormattedMonth : undefined),
getFilterQuery: (item: GroupedItem) => {
const monthItem = item as TransactionMonthGroupListItemType;
const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month);
Expand All @@ -70,6 +79,7 @@ const CHART_GROUP_BY_CONFIG: Record<SearchGroupBy, ChartGroupByConfig> = {
[CONST.SEARCH.GROUP_BY.WEEK]: {
titleIconName: 'Calendar',
getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '',
getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.shortFormattedWeek : undefined),
getFilterQuery: (item: GroupedItem) => {
const weekItem = item as TransactionWeekGroupListItemType;
const {start, end} = DateUtils.getWeekDateRange(weekItem.week);
Expand All @@ -88,6 +98,7 @@ const CHART_GROUP_BY_CONFIG: Record<SearchGroupBy, ChartGroupByConfig> = {
[CONST.SEARCH.GROUP_BY.QUARTER]: {
titleIconName: 'Calendar',
getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '',
getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? item.shortFormattedQuarter : undefined),
getFilterQuery: (item: GroupedItem) => {
const quarterItem = item as TransactionQuarterGroupListItemType;
const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter);
Expand Down
3 changes: 3 additions & 0 deletions src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@ type SearchChartProps = {
/** Function to extract label from grouped item */
getLabel: (item: GroupedItem) => string;

/** Function to extract the compact axis label from grouped item. When it returns undefined, `getLabel` is used. */
getShortLabel?: (item: GroupedItem) => string | undefined;

/** Function to build filter query from grouped item */
getFilterQuery: (item: GroupedItem) => string;

Expand Down
38 changes: 38 additions & 0 deletions src/libs/DateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,20 @@ function isDateStringInMonth(dateString: string, year: number, month: number): b
return datePart >= monthStart && datePart <= monthEnd;
}

/**
* Returns a month label, e.g. "September 2025".
*/
function getFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string {
return format(new Date(year, month - 1, 1), 'MMMM yyyy', {locale: dateFnsLocale});
}

/**
* Returns a compact month label, e.g. "Sep ’25".
*/
function getShortFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string {
return format(new Date(year, month - 1, 1), 'MMM ’yy', {locale: dateFnsLocale});
}

/**
* Returns a formatted date range.
*/
Expand All @@ -1133,6 +1147,19 @@ function getFormattedDateRangeForSearch(startDate: string, endDate: string, date
return `${format(start, 'MMM d', {locale: dateFnsLocale})} - ${format(end, 'MMM d, yyyy', {locale: dateFnsLocale})}`;
}

/**
* Returns a compact date range, e.g. "Sep 1 - 7 ’25".
*/
function getShortFormattedDateRangeForSearch(startDate: string, endDate: string, dateFnsLocale: DateFnsLocale | undefined): string {
const start = parse(startDate, 'yyyy-MM-dd', new Date());
const end = parse(endDate, 'yyyy-MM-dd', new Date());
if (!isSameYear(start, end)) {
return `${format(start, 'MMM d ’yy', {locale: dateFnsLocale})} - ${format(end, 'MMM d ’yy', {locale: dateFnsLocale})}`;
}
const formattedEnd = isSameMonth(start, end) ? format(end, 'd ’yy', {locale: dateFnsLocale}) : format(end, 'MMM d ’yy', {locale: dateFnsLocale});
return `${format(start, 'MMM d', {locale: dateFnsLocale})} - ${formattedEnd}`;
}

function getYearDateRange(year: number): {start: string; end: string} {
return {
start: `${year}-01-01`,
Expand Down Expand Up @@ -1163,6 +1190,13 @@ function getFormattedQuarterForSearch(year: number, quarter: number, dateFnsLoca
return `Q${quarter} ${year} (${format(quarterStart, 'MMM d', {locale: dateFnsLocale})} - ${format(quarterEnd, 'MMM d', {locale: dateFnsLocale})})`;
}

/**
* Returns a compact quarter label, e.g. "Q3 ’25".
*/
function getShortFormattedQuarterForSearch(year: number, quarter: number): string {
return `Q${quarter} ’${String(year).slice(-2)}`;
}

function getNextNthOfMonth(nth: number) {
const now = new Date();
const year = now.getFullYear();
Expand Down Expand Up @@ -1246,10 +1280,14 @@ const DateUtils = {
getMonthDateRange,
getWeekDateRange,
isDateStringInMonth,
getFormattedMonthForSearch,
getShortFormattedMonthForSearch,
getFormattedDateRangeForSearch,
getShortFormattedDateRangeForSearch,
getYearDateRange,
getQuarterDateRange,
getFormattedQuarterForSearch,
getShortFormattedQuarterForSearch,
getNextNthOfMonth,
};

Expand Down
14 changes: 9 additions & 5 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3726,16 +3726,14 @@ function getMonthSections(
queryJSON && monthGroup.year && monthGroup.month ? buildDateRangeGroupQuery(queryJSON, DateUtils.getMonthDateRange(monthGroup.year, monthGroup.month)) : undefined;
const transactionsQueryJSON = dateResult?.transactionsQueryJSON;

const monthDate = new Date(monthGroup.year, monthGroup.month - 1, 1);
const formattedMonth = format(monthDate, 'MMMM yyyy', {locale: dateFnsLocale});

monthSections[key] = {
groupedBy: CONST.SEARCH.GROUP_BY.MONTH,
transactions: [],
transactionsQueryJSON,
keyForList: key,
...monthGroup,
formattedMonth,
formattedMonth: DateUtils.getFormattedMonthForSearch(monthGroup.year, monthGroup.month, dateFnsLocale),
shortFormattedMonth: DateUtils.getShortFormattedMonthForSearch(monthGroup.year, monthGroup.month, dateFnsLocale),
sortKey: monthGroup.year * 100 + monthGroup.month,
};
}
Expand Down Expand Up @@ -3764,14 +3762,18 @@ function getWeekSections(
const rawRange = DateUtils.getWeekDateRange(weekGroup.week);
const dateResult = queryJSON && weekGroup.week ? buildDateRangeGroupQuery(queryJSON, rawRange) : undefined;
const transactionsQueryJSON = dateResult?.transactionsQueryJSON;
const formattedWeek = DateUtils.getFormattedDateRangeForSearch(dateResult?.start ?? rawRange.start, dateResult?.end ?? rawRange.end, dateFnsLocale);
const weekStart = dateResult?.start ?? rawRange.start;
const weekEnd = dateResult?.end ?? rawRange.end;
const formattedWeek = DateUtils.getFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale);
const shortFormattedWeek = DateUtils.getShortFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale);

weekSections[key] = {
groupedBy: CONST.SEARCH.GROUP_BY.WEEK,
transactions: [],
transactionsQueryJSON,
...weekGroup,
formattedWeek,
shortFormattedWeek,
keyForList: key,
};
}
Expand Down Expand Up @@ -3830,13 +3832,15 @@ function getQuarterSections(
? buildDateRangeGroupQuery(queryJSON, DateUtils.getQuarterDateRange(quarterGroup.year, quarterGroup.quarter))?.transactionsQueryJSON
: undefined;
const formattedQuarter = DateUtils.getFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter, dateFnsLocale);
const shortFormattedQuarter = DateUtils.getShortFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter);

quarterSections[key] = {
groupedBy: CONST.SEARCH.GROUP_BY.QUARTER,
transactions: [],
transactionsQueryJSON,
...quarterGroup,
formattedQuarter,
shortFormattedQuarter,
sortKey: quarterGroup.year * 10 + quarterGroup.quarter, // Sort by year*10 + quarter (e.g., 20241, 20242, etc.)
keyForList: key,
};
Expand Down
Loading
Loading