Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
302529a
feat(analytics): add DecisionsScoreDistribution
siiick Nov 12, 2025
11021b2
refactor(analytics): change outcome colors
siiick Nov 12, 2025
9dfb480
feat: highlight outcome filters on first Decisions hover
siiick Nov 18, 2025
b47d5a0
refactor(analytics): improve Decsions tooltip display
siiick Nov 18, 2025
f0cae8c
refactor(analytics): Add a minimum display height for outcomes in Dec…
siiick Nov 18, 2025
9536234
feat(analytics): add tooltips to analytics table headers and componen…
siiick Nov 19, 2025
5ec4f49
cleanup
siiick Nov 19, 2025
caab222
fix(Table): change header element from paragraph to div for better st…
siiick Nov 19, 2025
7e5e10e
fix(analytics): update trigger condition to ensure it only applies wh…
siiick Nov 19, 2025
33b085c
feat(analytics): add legacy analytics link to analytics layout
siiick Nov 19, 2025
eb392f2
feat(analytics): enable comparison date range selection in analytics …
siiick Nov 19, 2025
23fd073
feat(FiltersBar): add buttons for reapplying and clearing dynamic fil…
siiick Nov 19, 2025
4b6cd84
cleanup
siiick Nov 20, 2025
f43f1b8
feat(Decisions): implement scale type toggle
siiick Nov 21, 2025
b5dec51
refactor(Decisions): remove minimum display height for outcomes in De…
siiick Nov 21, 2025
2210178
feat(analytics): centralize outcome colors and update imports across …
siiick Nov 21, 2025
a3a3cac
feat(analytics): replace TooltipV2 with centralized AnalyticsTooltip …
siiick Nov 21, 2025
680ff3a
feat(Decisions): add scale type labels and conditional rendering for …
siiick Nov 21, 2025
13a68f7
refactor(Decisions): extract utility functions from the component
siiick Nov 21, 2025
646ac8c
feat(FiltersBar): add preset date range options for quick selection i…
siiick Nov 21, 2025
675ed06
refactor(RulesHit): simplify percentage formatting by removing redund…
siiick Nov 21, 2025
33f0ae1
refactor(Analytics): update AnalyticsTooltip usage across components …
siiick Nov 24, 2025
8e49872
refactor(OutcomeFilter): enhance highlight handling with useEffect fo…
siiick Nov 24, 2025
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
211 changes: 148 additions & 63 deletions packages/app-builder/src/components/Analytics/Decisions.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Spinner } from '@app-builder/components/Spinner';
import { OUTCOME_COLORS } from '@app-builder/constants/analytics';
import { useResizeObserver } from '@app-builder/hooks/useResizeObserver';
import {
type DecisionOutcomesAbsolute,
import type {
DecisionOutcomes,
DecisionOutcomesAbsolute,
DecisionOutcomesPerPeriod,
type DecisionsFilter,
type Outcome,
outcomeColors,
type RangeId,
DecisionsFilter,
Outcome,
RangeId,
} from '@app-builder/models/analytics';
import { useFormatLanguage } from '@app-builder/utils/format';
import { type ComputedDatum, ResponsiveBar } from '@nivo/bar';
import { getWeek, getYear } from 'date-fns';
import { differenceInDays, getWeek, getYear } from 'date-fns';
import { useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { ButtonV2 } from 'ui-design-system';
Expand All @@ -37,6 +38,26 @@ interface DecisionsProps {
isLoading?: boolean;
}

// Decision filter default values
const defaultDecisions: DecisionsFilter = new Map([
['decline', true],
['blockAndReview', true],
['review', true],
['approve', true],
]);

const getBarColors = (d: ComputedDatum<DecisionsPerOutcome>) => {
const id = String(d.id) as 'approve' | 'decline' | 'review' | 'blockAndReview';
return OUTCOME_COLORS[id] ?? '#9ca3af';
};

const getOutcomeTranslationKey = (outcome: Outcome): string => {
if (outcome === 'blockAndReview') {
return 'decisions:outcome.block_and_review';
}
return `decisions:outcome.${outcome}`;
};

export function Decisions({ data, scenarioVersions, isLoading = false }: DecisionsProps) {
const { t } = useTranslation();
const language = useFormatLanguage();
Expand All @@ -46,40 +67,23 @@ export function Decisions({ data, scenarioVersions, isLoading = false }: Decisio
observeHeight: false,
});

// Decision filter default values
const defaultDecisions: DecisionsFilter = new Map([
['decline', true],
['blockAndReview', true],
['review', true],
['approve', true],
]);
const SYMLOG_SCALE_MIN_ENABLED = false;

const [decisions, setDecisions] = useState<DecisionsFilter>(defaultDecisions);
const [percentage, setPercentage] = useState(false);
const [scale, setScale] = useState<'linear' | 'symlog'>('linear');
const [groupDate, setGroupDate] = useState<'daily' | 'weekly' | 'monthly'>('weekly');
const [isHovered, setIsHovered] = useState(false);

const currentDataGroup = useMemo(() => data?.[groupDate], [data, groupDate]);

// Sanitize data to ensure all values are valid numbers
const sanitizedData = useMemo(() => {
const sourceData = percentage ? (currentDataGroup?.data.ratio ?? []) : (currentDataGroup?.data.absolute ?? []);

return sourceData.map((item) => {
const sanitized: DecisionsPerOutcome = {
...item,
approve: Number.isFinite(item.approve) ? item.approve : 0,
decline: Number.isFinite(item.decline) ? item.decline : 0,
review: Number.isFinite(item.review) ? item.review : 0,
blockAndReview: Number.isFinite(item.blockAndReview) ? item.blockAndReview : 0,
};

// Only add total if it exists (absolute data has total, ratio doesn't)
if ('total' in item && typeof item.total === 'number') {
sanitized.total = Number.isFinite(item.total) ? item.total : 0;
}

return sanitized;
});
}, [percentage, currentDataGroup]);
const sanitizedData = useMemo(
(): DecisionOutcomes[] | DecisionOutcomesAbsolute[] =>
percentage ? (currentDataGroup?.data.ratio ?? []) : (currentDataGroup?.data.absolute ?? []),
[percentage, currentDataGroup],
);

const chartData = useMemo(() => sanitizedData as DecisionsPerOutcome[], [sanitizedData]);

const isSameYear: boolean = getYear(data?.metadata.start!) === getYear(data?.metadata.end!);

Expand Down Expand Up @@ -129,10 +133,25 @@ export function Decisions({ data, scenarioVersions, isLoading = false }: Decisio
// .filter((v) => v !== undefined);
// };

const getBarColors = (d: ComputedDatum<DecisionsPerOutcome>) => {
const id = String(d.id) as 'approve' | 'decline' | 'review' | 'blockAndReview';
return outcomeColors[id] ?? '#9ca3af';
};
const padding = useMemo(() => {
if (scale !== 'symlog') {
return 0.5;
}
if (!data?.metadata.start || !data?.metadata.end) {
return 0.01;
}

const days = Math.abs(differenceInDays(new Date(data.metadata.end), new Date(data.metadata.start)));
const threshold = 90; // 3 months

if (days > threshold) {
return 0.01;
}

// progressively increase from 0.01 (at 90 days) to 0.5 (at 0 days)
const ratio = days / threshold;
return 0.5 - ratio * (0.5 - 0.01);
}, [scale, data?.metadata.start, data?.metadata.end]);

const getTootlipDateFormat = (date: string) => {
const dateObj = new Date(date);
Expand Down Expand Up @@ -228,7 +247,11 @@ export function Decisions({ data, scenarioVersions, isLoading = false }: Decisio
};

return (
<div>
<div
onMouseEnter={() => {
setIsHovered(true);
}}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Missing onMouseLeave handler causes stuck hover state

The isHovered state is set to true on onMouseEnter but never reset to false on onMouseLeave. This causes the hover state to persist indefinitely after the user moves their mouse over the component once, preventing the highlight animation from working correctly on subsequent hovers and potentially affecting UI behavior that depends on the hover state.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional. We want the ping effect only once, the first time the user hover the component.

<div className="flex items-center justify-between">
<h2 className="text-h2 font-semibold">{t('analytics:decisions.title')}</h2>
<ButtonV2
Expand Down Expand Up @@ -285,23 +308,50 @@ export function Decisions({ data, scenarioVersions, isLoading = false }: Decisio
</ButtonV2>
</div>
</div>

{SYMLOG_SCALE_MIN_ENABLED ? (
<div className="flex items-center gap-v2-sm">
<span className="text-s">{t('analytics:decisions.scale.label')}:</span>
<div className="flex gap-v2-sm">
<ButtonV2
variant="secondary"
onClick={() => {
setScale('linear');
setDecisions(
new Map([
['decline', true],
['blockAndReview', true],
['review', true],
['approve', true],
]),
);
}}
className={scale === 'linear' ? 'bg-purple-98 border-purple-65 text-purple-65' : ''}
>
{t('analytics:decisions.scale.linear.label')}
</ButtonV2>
<ButtonV2
variant="secondary"
onClick={() => setScale('symlog')}
className={scale === 'symlog' ? 'bg-purple-98 border-purple-65 text-purple-65' : ''}
>
{t('analytics:decisions.scale.symlog.label')}
</ButtonV2>
</div>
</div>
) : null}
</div>
<div className="flex-1 w-full">
<ResponsiveBar<DecisionsPerOutcome>
key={`${percentage ? 'percentage' : 'absolute'}-${groupDate}`}
data={sanitizedData}
data={chartData}
indexBy="date"
enableLabel={false}
keys={
// percentage
// ? ['decline', 'blockAndReview', 'review', 'approve']
// :
Array.from(decisions)
.filter(([_, value]) => value)
.map(([key]) => key)
}
padding={0.5}
margin={{ top: 5, right: 5, bottom: 24, left: 50 }}
keys={Array.from(decisions)
.filter(([_, value]) => value)
.map(([key]) => key)}
padding={padding}
margin={{ top: 5, right: 5, bottom: 24, left: 54 }}
colors={getBarColors}
defs={[
{
Expand All @@ -319,7 +369,12 @@ export function Decisions({ data, scenarioVersions, isLoading = false }: Decisio
id: 'compareOpacity',
},
]}
valueScale={!data?.metadata.totalDecisions ? { type: 'linear', min: 0, max: 1000 } : undefined}
groupMode={scale === 'symlog' ? 'grouped' : 'stacked'}
valueScale={
!data?.metadata.totalDecisions
? { type: 'linear', min: 0, max: 1000 }
: { type: scale, round: true, nice: true }
}
axisLeft={{
legend: 'outcome (indexBy)',
legendOffset: -70,
Expand All @@ -337,16 +392,47 @@ export function Decisions({ data, scenarioVersions, isLoading = false }: Decisio
});
},
}}
tooltip={({ id, value, data }) => (
<div className="flex flex-col gap-v2-xs w-auto max-w-max bg-white p-v2-sm rounded-lg border border-grey-90 shadow-sm whitespace-nowrap">
<div className="flex items-center gap-v2-sm">
<strong className="text-grey-00 font-semibold">
{id}: {percentage ? `${value.toFixed(1)}%` : value}
</strong>
tooltip={({ data }) => {
const outcomes: Outcome[] = ['approve', 'decline', 'review', 'blockAndReview'];
const totalValue = !percentage && typeof data.total === 'number' ? data.total : undefined;
return (
<div className="flex flex-col gap-v2-xs bg-white p-v2-sm rounded-lg border border-grey-90 shadow-sm">
<div className="text-s text-grey-60 mb-v2-xs">{getTootlipDateFormat(data?.date)}</div>
<div className="flex flex-col gap-v2-xs">
{outcomes.map((outcome) => {
const outcomeValue = data?.[outcome] ?? 0;
const displayValue = percentage ? `${outcomeValue.toFixed(1)}%` : outcomeValue;
return (
<div key={outcome} className="flex items-center gap-v2-sm whitespace-nowrap">
<div
className="size-3 rounded-sm flex-shrink-0"
style={{ backgroundColor: OUTCOME_COLORS[outcome] }}
/>
<span className="text-s text-grey-00">
{t(getOutcomeTranslationKey(outcome))}:{' '}
<strong className="font-semibold">{displayValue}</strong>
</span>
</div>
);
})}
</div>
{!percentage && totalValue !== undefined && (
<div className="flex items-center gap-v2-sm pt-v2-xs border-t border-grey-90 mt-v2-xs">
<span className="text-s text-grey-00 font-semibold">
{t('analytics:decisions.tooltip.total', { defaultValue: 'Total' })}: {totalValue}
</span>
</div>
)}
</div>
<div className="text-s text-grey-60">{getTootlipDateFormat(data?.date)}</div>
</div>
)}
);
}}
theme={{
tooltip: {
container: {
transform: 'translateX(16px)',
},
},
}}
layout="vertical"
motionConfig={{
mass: 1,
Expand All @@ -356,7 +442,6 @@ export function Decisions({ data, scenarioVersions, isLoading = false }: Decisio
precision: 0.01,
velocity: 0,
}}

// markers={currentDataGroup?.scenarioVersionsXMarkers}
/>
</div>
Expand Down Expand Up @@ -392,7 +477,7 @@ export function Decisions({ data, scenarioVersions, isLoading = false }: Decisio
</div>
</div>
<div className="flex w-full justify-center">
<OutcomeFilter decisions={decisions} onChange={setDecisions} />
<OutcomeFilter decisions={decisions} onChange={setDecisions} highlight={isHovered} />
</div>
</div>
</div>
Expand Down
45 changes: 33 additions & 12 deletions packages/app-builder/src/components/Analytics/OutcomeFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,65 @@
import { DecisionsFilter, type Outcome, outcomeColors } from '@app-builder/models/analytics';
import { OUTCOME_COLORS } from '@app-builder/constants/analytics';
import { DecisionsFilter, type Outcome } from '@app-builder/models/analytics';
import { useEffect, useRef } from 'react';
import { cn } from 'ui-design-system';
import { Icon } from 'ui-icons';

export function OutcomeFilter({
decisions,
highlight = false,
onChange,
}: {
decisions: DecisionsFilter;
highlight: boolean;
onChange: (decisions: DecisionsFilter) => void;
}) {
const hasHighlightedRef = useRef(false);

useEffect(() => {
if (!highlight) {
hasHighlightedRef.current = false;
}
}, [highlight]);

const handleToggle = (key: Outcome) => {
const newDecisions = new Map(decisions);
newDecisions.set(key, !decisions.get(key));
hasHighlightedRef.current = true;
onChange(newDecisions);
};

const FilterItem = ({ label, outcome, checked }: { label: string; outcome: Outcome; checked: boolean }) => (
<div
className={`flex items-center gap-2 cursor-pointer flex-1 min-w-0 ${outcome === 'blockAndReview' ? 'min-w-40' : ''} ${!checked ? 'text-grey-50' : ''}`}
onClick={() => handleToggle(outcome)}
>
<div className={cn('flex items-center gap-2 cursor-pointer flex-1 min-w-40')} onClick={() => handleToggle(outcome)}>
<button
className={
'w-4 h-4 border border-grey-90 rounded-sm flex items-center justify-center hover:bg-grey-50 ' +
(checked ? outcomeColors[outcome] : 'bg-transparent') +
(checked ? OUTCOME_COLORS[outcome] : 'bg-transparent') +
' ' +
outcomeColors[outcome]
OUTCOME_COLORS[outcome]
}
style={{ backgroundColor: outcomeColors[outcome] }}
style={{ backgroundColor: OUTCOME_COLORS[outcome] }}
></button>
<div className="flex items-center flex-1 whitespace-nowrap min-w-0">
<span className="text-xs">{label}</span>
<div className="w-4 h-4 flex items-center justify-center flex-shrink-0 ml-4">
{!checked && <Icon icon="eye-slash" className="w-4 h-4 text-gray-400" />}
<div className="w-4 h-4 flex items-center justify-center flex-shrink-0 ml-4 relative">
{highlight && !hasHighlightedRef.current ? (
<Icon
icon={checked ? 'eye' : 'eye-slash'}
className={cn('absolute size-4 animate-ping-once', checked ? 'text-blue-58' : 'text-grey-50')}
/>
) : null}
{highlight || !checked ? (
<Icon
icon={checked ? 'eye' : 'eye-slash'}
className={cn('relative size-4', checked ? 'text-blue-58' : 'text-grey-50')}
/>
) : null}
</div>
</div>
</div>
);

return (
<div className={`flex flex-row gap-6`}>
<div className="flex flex-row gap-6 select-none">
<FilterItem label="Approve" outcome="approve" checked={decisions.get('approve') ?? false} />
<FilterItem label="Review" outcome="review" checked={decisions.get('review') ?? false} />
<FilterItem
Expand Down
Loading