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/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10555,6 +10555,8 @@ const CONST = {
AMOUNT: 'amount',
},
},
// The backend started recording violations on this day, so it has no violation data for earlier dates
VIOLATIONS_TRACKED_FROM: '2026-06-01',
},

SECTION_LIST_ITEM_TYPE: {
Expand Down
4 changes: 3 additions & 1 deletion src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2418,12 +2418,14 @@ const ROUTES = {
graphKey,
itemKey,
periodEnd,
asOfDate,
}: {
dashboardID: ValueOf<typeof CONST.INSIGHTS.DASHBOARD>;
graphKey: ValueOf<typeof CONST.INSIGHTS.GRAPH>;
itemKey: string;
periodEnd?: string;
}) => `insights/${dashboardID}/details/${graphKey}?item=${encodeURIComponent(itemKey)}${periodEnd ? `&end=${periodEnd}` : ''}` as const,
asOfDate?: string;
}) => `insights/${dashboardID}/details/${graphKey}?item=${encodeURIComponent(itemKey)}${periodEnd ? `&end=${periodEnd}` : ''}${asOfDate ? `&asOf=${asOfDate}` : ''}` as const,
},

EXPENSE_REPORT_RHP: {
Expand Down
21 changes: 16 additions & 5 deletions src/components/Search/SearchChartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ type SearchChartViewProps = {
/** Whether pressing a bar, point or slice opens Search filtered to it */
shouldDrillDownOnPress?: boolean;

/** Query a pressed group is narrowed from instead of `queryJSON`, for charts whose data stops short of their date range, so Search lists only what the point plots */
drillDownQueryJSON?: Readonly<SearchQueryJSON>;

/** Called with the group whose bar, point, slice or touch-opened tooltip is pressed. Takes precedence over `shouldDrillDownOnPress`. */
onGroupPress?: (item: GroupedItem) => void;

Expand All @@ -90,6 +93,12 @@ type SearchChartViewProps = {

/** Style of the view around the chart, which the details below it don't share */
chartContainerStyle?: StyleProp<ViewStyle>;

/** First day the data was recorded, as a yyyy-MM-dd string, so compared buckets before it plot nothing instead of 0 */
trackedFrom?: string;

/** Day the data was counted through, as a yyyy-MM-dd string, which decides where the line ends and which period is in progress. Defaults to the device's today. */
today?: string;
};

/**
Expand All @@ -107,13 +116,16 @@ function SearchChartView({
shouldShowGroupLabels = true,
shouldUseResponsiveLabels = false,
shouldDrillDownOnPress = true,
drillDownQueryJSON,
onGroupPress,
tooltipFooter,
shouldShowSinglePointAsBar = false,
comparison,
shouldShowRunningTotal = false,
renderDetails,
chartContainerStyle,
trackedFrom,
today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING),
}: SearchChartViewProps) {
const theme = useTheme();
const {preferredLocale, translate, dateFnsLocale, numberFormat} = useLocalize();
Expand All @@ -123,7 +135,6 @@ function SearchChartView({

const {getLabel, getShortLabel, getLineChartLabel, getFilterQuery, getDateRange, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy];

const today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING);
const dateFilterRange = queryJSON ? getDateFilterRange(queryJSON) : {};
const isCountMetric = metric === CONST.SEARCH.CHART_METRIC.COUNT;

Expand All @@ -139,7 +150,6 @@ function SearchChartView({
const formatTotal = isCountMetric ? convertToDisplayString : undefined;

const emptyLaterKeys = new Set(data.filter((item) => isEmptyPeriodAfterToday(item, groupBy, today, dateFilterRange.start)).map((item) => item.keyForList));
const comparedKeysWithExpenses = new Set(comparison && !isChartAverage(comparison) ? comparison.data.filter((item) => !!item.count).map((item) => item.keyForList) : []);
const {data: chartData, comparison: chartComparison} = shouldShowRunningTotal ? applyRunningTotal({data, comparison, groupBy}) : {data, comparison};

const {series, rows} = buildChartSeries({
Expand All @@ -158,7 +168,7 @@ function SearchChartView({
return !!range && isPeriodAfterToday(range.start, today, dateFilterRange.start);
},
isEmptyLaterPeriod: (item) => emptyLaterKeys.has(item.keyForList),
hasComparedExpenses: (item) => comparedKeysWithExpenses.has(item.keyForList),
trackedFrom,
translate,
dateFnsLocale,
colorScheme: theme.colorScheme,
Expand All @@ -179,11 +189,12 @@ function SearchChartView({
return;
}

if (!queryJSON) {
const pressedQueryJSON = drillDownQueryJSON ?? queryJSON;
if (!pressedQueryJSON) {
return;
}

const query = buildChartDrillDownQuery(queryJSON, getFilterQuery(item));
const query = buildChartDrillDownQuery(pressedQueryJSON, getFilterQuery(item));

if (!query) {
return;
Expand Down
19 changes: 8 additions & 11 deletions src/components/Search/buildChartSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ type BuildChartSeriesParams = {
/** Returns whether a group's period starts after today with no expenses of its own, which a running total's count no longer shows */
isEmptyLaterPeriod?: (item: GroupedItem) => boolean;

/** Returns whether a compared group has expenses of its own, which a running total's count no longer shows. Reads the group's count by default. */
hasComparedExpenses?: (item: GroupedItem) => boolean;
/** First day the data was recorded, as a yyyy-MM-dd string. A compared bucket that ends before it has no data, so it plots nothing instead of 0. */
trackedFrom?: string;

/** Formats labels shared by paired time buckets, and the name of an average */
translate: LocaleContextProps['translate'];
Expand Down Expand Up @@ -149,7 +149,7 @@ function buildChartSeries({
getInProgressLabel,
isLaterPeriod,
isEmptyLaterPeriod,
hasComparedExpenses = (item) => !!item.count,
trackedFrom,
translate,
dateFnsLocale,
colorScheme,
Expand Down Expand Up @@ -190,12 +190,12 @@ function buildChartSeries({
if (periods && isTimeGroupBy(groupBy) && bucketRange) {
const counterpartRange = getCounterpartBucketRange(bucketRange, periods, groupBy);
const comparedItem = counterpartRange && comparedByKey.get(counterpartRange.start);
const compared = comparedItem ?? (counterpartRange && buildEmptyGroup(item));
const isCounterpartTracked = !!counterpartRange && (!trackedFrom || counterpartRange.end >= trackedFrom);
const compared = comparedItem ?? (isCounterpartTracked ? buildEmptyGroup(item) : undefined);
return {
value: compared && getValue(compared),
percentOfTotal: compared?.percentOfTotal,
total: compared && getTotal(compared),
hasExpenses: !!comparedItem && hasComparedExpenses(comparedItem),
bucketLabels: getComparedBucketLabels(bucketRange, counterpartRange, periods, groupBy, translate, dateFnsLocale),
counterpartStart: counterpartRange?.start,
sortDate: bucketRange.start,
Expand Down Expand Up @@ -241,7 +241,6 @@ function buildChartSeries({
return {
row: {point, item},
isEmptyLaterPeriod: !!isEmptyLaterPeriod?.(item),
hasComparedExpenses: !!compared?.hasExpenses,
counterpartStart: compared?.counterpartStart,
sortDate: compared?.sortDate ?? '',
};
Expand All @@ -260,7 +259,7 @@ function buildChartSeries({
isInProgress: false,
};
const row: SearchChartDataRow = {point};
return {row, isEmptyLaterPeriod: false, hasComparedExpenses: hasComparedExpenses(comparedItem), sortDate: getPositionInPrimaryPeriod(range, comparisonPeriods, timeGroupBy)};
return {row, isEmptyLaterPeriod: false, sortDate: getPositionInPrimaryPeriod(range, comparisonPeriods, timeGroupBy)};
};

const pairedStarts = new Set(pairedRows.map((pairedRow) => pairedRow.counterpartStart));
Expand All @@ -280,14 +279,12 @@ function buildChartSeries({
return first.sortDate < second.sortDate ? -1 : 1;
});

// Ends the current line at today, or at the last future expense, instead of drawing $0 through periods that haven't come yet.
// Ends the current line at today instead of drawing $0 through periods that haven't come yet.
const lastCurrentIndex = sortedRows.findLastIndex((sortedRow) => !!sortedRow.row.item && !sortedRow.isEmptyLaterPeriod);
const plottedRows = sortedRows.map(({row, isEmptyLaterPeriod: isEmpty}, index) =>
index > lastCurrentIndex && isEmpty ? {...row, point: {...row.point, values: {...row.point.values, [CHART_SERIES_KEY.PRIMARY]: undefined}}} : row,
);
// The chart goes on through the compared period's last expense
const lastPlottedIndex = sortedRows.findLastIndex((sortedRow, index) => index <= lastCurrentIndex || sortedRow.hasComparedExpenses);
const rows = plottedRows.slice(0, lastPlottedIndex + 1);
const rows = comparedRows ? plottedRows : plottedRows.slice(0, lastCurrentIndex + 1);

const pieColors = view === CONST.SEARCH.VIEW.PIE ? getSliceColorsByDataIndex(rows.map((row) => row.point)) : undefined;
const primarySeries: ChartSeries = {key: CHART_SERIES_KEY.PRIMARY, label: periods && formatDates(periods.primary, dateFnsLocale)};
Expand Down
2 changes: 2 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'Compliance',
runningTotal: 'Laufende Summe',
futureExpensesNotShown: (amount: string) => `Nicht angezeigt: ${amount} in zukünftigen Ausgaben.`,
violationsTrackedFrom: (date: string) => `Verstöße werden ab dem ${date} erfasst.`,
upsellState: {
title: 'Upgrade auf Control für mehr Einblicke',
subtitle: 'Verfolgen Sie Trends bei der Einhaltung der Spesenrichtlinie und markieren Sie Wiederholungstäter.',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'Συμμόρφωση',
runningTotal: 'Τρέχον σύνολο',
futureExpensesNotShown: (amount: string) => `Δεν εμφανίζονται: ${amount} σε μελλοντικές δαπάνες.`,
violationsTrackedFrom: (date: string) => `Οι παραβάσεις παρακολουθούνται από ${date}.`,
upsellState: {
title: 'Αναβαθμίστε σε Control για περισσότερες πληροφορίες',
subtitle: 'Παρακολουθήστε τις τάσεις συμμόρφωσης με την πολιτική εξόδων και επισημάνετε τους επανειλημμένους παραβάτες.',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,8 @@ const translations = {
slowestToApprove: 'Slowest to approve',
mostTimeToPay: 'Most time to pay',
runningTotal: 'Running total',
futureExpensesNotShown: (amount: string) => `Not shown: ${amount} in future expenses.`,
violationsTrackedFrom: (date: string) => `Violations are tracked from ${date}.`,
compare: {
label: 'Compare',
previousPeriod: 'Previous period',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'Cumplimiento',
runningTotal: 'Total acumulado',
futureExpensesNotShown: (amount: string) => `No se muestran: ${amount} en gastos futuros.`,
violationsTrackedFrom: (date: string) => `Las infracciones se registran desde el ${date}.`,
upsellState: {
title: 'Mejora al plan Controlar para obtener más información',
subtitle: 'Supervisa las tendencias de cumplimiento de la política de gastos y señala a los infractores reincidentes.',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'Conformité',
runningTotal: 'Total cumulé',
futureExpensesNotShown: (amount: string) => `Non affiché : ${amount} en dépenses futures.`,
violationsTrackedFrom: (date: string) => `Les violations sont suivies à partir du ${date}.`,
upsellState: {
title: "Passez à Control pour obtenir plus d'informations",
subtitle: 'Suivez les tendances de conformité aux politiques de dépenses et signalez les récidivistes.',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'Conformità',
runningTotal: 'Totale progressivo',
futureExpensesNotShown: (amount: string) => `Non mostrato: ${amount} in spese future.`,
violationsTrackedFrom: (date: string) => `Le violazioni vengono tracciate a partire dal ${date}.`,
upsellState: {
title: 'Passa a Control per maggiori informazioni',
subtitle: 'Tieni traccia delle tendenze di conformità alla policy spese e segnala i recidivi.',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'コンプライアンス',
runningTotal: '累計合計',
futureExpensesNotShown: (amount: string) => `非表示:将来の経費に${amount}あります。`,
violationsTrackedFrom: (date: string) => `違反は${date}から追跡されます。`,
upsellState: {
title: 'より多くのインサイトを得るには Control にアップグレードしてください',
subtitle: '経費ポリシー順守の傾向を追跡し、違反を繰り返すユーザーにフラグを付けます。',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'Naleving',
runningTotal: 'Doorlopend totaal',
futureExpensesNotShown: (amount: string) => `Niet weergegeven: ${amount} aan toekomstige uitgaven.`,
violationsTrackedFrom: (date: string) => `Overtredingen worden bijgehouden vanaf ${date}.`,
upsellState: {
title: 'Upgrade naar Control voor meer inzichten',
subtitle: 'Volg trends in naleving van onkostenbeleid en markeer herhaalde overtreders.',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'Zgodność',
runningTotal: 'Suma narastająco',
futureExpensesNotShown: (amount: string) => `Nie pokazano: ${amount} w przyszłych wydatkach.`,
violationsTrackedFrom: (date: string) => `Naruszenia są śledzone od ${date}.`,
upsellState: {
title: 'Ulepsz do Control, aby zyskać więcej wglądu',
subtitle: 'Śledź trendy przestrzegania zasad dotyczących wydatków i oznaczaj osoby wielokrotnie je naruszające.',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: 'Conformidade',
runningTotal: 'Total acumulado',
futureExpensesNotShown: (amount: string) => `Não mostrado: ${amount} em despesas futuras.`,
violationsTrackedFrom: (date: string) => `Violações são registradas a partir de ${date}.`,
upsellState: {
title: 'Faça upgrade para o plano Control para ter mais insights',
subtitle: 'Acompanhe tendências de conformidade com a política de despesas e sinalize reincidentes.',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,8 @@ const translations: TranslationDeepObject<typeof en> = {
},
compliance: '合规',
runningTotal: '累计总额',
futureExpensesNotShown: (amount: string) => `未显示:未来报销金额 ${amount}。`,
violationsTrackedFrom: (date: string) => `从 ${date} 起开始记录违规情况。`,
upsellState: {title: '升级到 Control,获取更多洞察', subtitle: '跟踪报销政策合规趋势,并标记屡次违规的用户。', button: '升级至 Control'},
explain: '解释',
explainChartPrompt: (chartTitle: string) => `解释「${chartTitle}」中的数据。`,
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,9 @@ type InsightsDetailsParamList = {

/** Last day of the clicked period, trimmed to the date range like the chart's label */
end?: string;

/** Last day the dashboard counted expenses through, so Top expenses stops on the same day as the chart. Missing when the panel is opened from a link. */
asOf?: string;
};
};

Expand Down
25 changes: 23 additions & 2 deletions src/pages/Insights/InsightsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import variables from '@styles/variables';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {AIInsight, InsightsDashboard as InsightsDashboardRecord, InsightsDashboardID, InsightsScope} from '@src/types/onyx';
import type {AIInsight, InsightsDashboard as InsightsDashboardRecord, InsightsDashboardID, InsightsFutureExpenses, InsightsScope} from '@src/types/onyx';
import viewRef from '@src/types/utils/viewRef';

import type {OnyxCollection} from 'react-native-onyx';
Expand All @@ -47,6 +47,7 @@ import type {InsightsDashboardChart, InsightsDashboardState} from './resolveDash

import InsightsAICardRow from './ai/InsightsAICardRow';
import InsightsChartWidget from './charts/InsightsChartWidget';
import InsightsFutureExpensesNote from './charts/InsightsFutureExpensesNote';
import InsightsPageControls from './controls/InsightsPageControls';
import INSIGHTS_DASHBOARD_SPECS, {getPoliciesInScope, getUpgradeablePolicyIDsInScope, getVisibleCharts, shouldUpsellControlPlan} from './dashboardSpecs';
import {getQueryGroupBy} from './insightsGroupByUtils';
Expand All @@ -70,6 +71,12 @@ type InsightsDashboardContentProps = {

headlineChart: InsightsDashboardChart;

/** Last day the charts count expenses through, as the backend resolved today */
asOfDate: string | undefined;

/** Expenses in the date range dated after `asOfDate`, which every chart leaves out */
futureExpenses: InsightsFutureExpenses | undefined;

/** Charts in the grid below the headline, left out when no workspace in scope is eligible for them */
supportingCharts: InsightsDashboardChart[];

Expand Down Expand Up @@ -99,6 +106,8 @@ function InsightsDashboardContent({
insightsScope,
hasFailedToLoad,
headlineChart,
asOfDate,
futureExpenses,
supportingCharts,
filters,
upgradeablePolicyIDs,
Expand Down Expand Up @@ -258,7 +267,16 @@ function InsightsDashboardContent({
supportsDrillIn={supportsDrillIn}
onRetry={onRetry}
onGroupByChange={onGroupByChange}
/>
asOfDate={asOfDate}
>
{!!asOfDate && !!futureExpenses?.count && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep exclusion notes reachable for empty dashboards

When the selected Spend range contains only expenses after asOfDate, all graph snapshots have no counted rows, so getDashboardState resolves EMPTY/NO_EXPENSES and the stand-in branch returns before this InsightsChartWidget and its note child are mounted. The same happens for a Compliance range wholly before trackedFrom, leaving users with a generic empty state and no View link or tracking-start explanation; render these notes in the stand-in path or avoid the stand-in when excluded data explains the empty result.

Useful? React with 👍 / 👎.

<InsightsFutureExpensesNote
futureExpenses={futureExpenses}
asOfDate={asOfDate}
filters={filters}
/>
)}
</InsightsChartWidget>
<View style={styles.insightsChartGrid(shouldUseNarrowLayout)}>
{columns.map((columnCharts, columnIndex) => (
<View
Expand All @@ -282,6 +300,7 @@ function InsightsDashboardContent({
countDimension={countDimension}
supportsDrillIn={supportsDrillIn}
onRetry={onRetry}
asOfDate={asOfDate}
/>
))}
</View>
Expand Down Expand Up @@ -467,6 +486,8 @@ function InsightsDashboard({dashboardID, accessibleDashboards}: {dashboardID: In
}}
hasFailedToLoad={!!dashboard?.errors}
headlineChart={headlineChart}
asOfDate={dashboard?.asOfDate}
futureExpenses={dashboard?.futureExpenses}
supportingCharts={supportingCharts}
filters={filters}
upgradeablePolicyIDs={upgradeablePolicyIDs}
Expand Down
Loading
Loading