Skip to content

feat(in-app-analytics)/filters-rework - #1166

Merged
siiick merged 42 commits into
mainfrom
feat/in-app-analytics-dates
Nov 3, 2025
Merged

feat(in-app-analytics)/filters-rework#1166
siiick merged 42 commits into
mainfrom
feat/in-app-analytics-dates

Conversation

@siiick

@siiick siiick commented Oct 20, 2025

Copy link
Copy Markdown
Contributor

This PR refactors and enhances the filter logic used in the in-app analytics module to make it more robust, flexible, and user friendly. Key changes include UI improvements, logic simplification, and bug fixes.

🛠️ What’s Changed
• Refactor core filter logic to simplify and clarify flow, improving maintainability and reducing complexity.
• Add “Apply” button to explicitly trigger filter execution rather than applying changes immediately on interaction.
• Introduce “loading” + “touched” states to better communicate form interactivity and pending updates.
• Make compareRange optional, allowing filters to operate when comparison ranges are not defined.
• Move filter buttons to the second line for cleaner layout and better visual grouping.
• Support filter “unavailability” (i.e., marking filters as unavailable/inactive) to improve the UX when certain filters don’t apply.
• Fix scenario filter naming, correcting inconsistencies in how scenario filters are labeled.
• Fix select display issue, resolving UI glitches in how dropdown selections were rendered.
• Fix edit modal behavior, improving stability and addressing edge cases when editing existing filters.
• Prevent render warnings, eliminating React warnings triggered during certain render paths.

✅ Benefits & Impact
• Better UX clarity — users now see an explicit “Apply” action, and UI states (loading, touched) show better feedback.
• More robust filtering — optional comparison ranges and unavailable states allow filters to adapt to more data conditions.
• Cleaner, maintainable code — the refactor cleans up branching and makes the filter logic easier to follow and extend.
• Fewer UI glitches — thanks to fixes around select/label rendering and modal handling.


Note

Revamps in-app analytics filtering with an Apply-based FiltersBar, dynamic/static date ranges, backend field filters, and updated API/UX across app and UI kit.

  • Analytics UI/UX (app + UI kit):

    • FiltersBar redesign: controlled API with draft state, explicit "Apply" + "Clear" actions, instantUpdate for select filters, loading skeletons, and unavailable filter indicators/tooltip.
    • Filter value model: switches to {op, value} for text/number; supports boolean/select and date-range popover (dynamic/static via Temporal).
    • Analytics page (routes/_analytics/analytics.$scenarioId.tsx): parses q (base64 JSON) via analyticsFiltersQuery; volatile updates without navigation; builds trigger payloads from filter values; fetches available filters for selected ranges.
    • Charts (Decisions.tsx): sanitize data, stabilize ResponsiveBar via key, minor tick/format tweaks.
  • Models & Queries:

    • Date ranges: introduce dateRangeFilterSchema, getIsoBoundsFromDateRanges, dynamic/static transforms.
    • Triggers → Fields: replace trigger schema with name/op/value and map to backend FieldFilterDto.
    • Available filters: request now takes ranges (computed to start/end); adapter updated.
    • Client queries: useGetAnalytics/useGetAvailableFilters consume q, send new payloads, and expect { success, data } envelopes with error handling.
  • Server & Repository:

    • Move resource routes to createServerFn with auth/redirect middleware; validate via zod; return { success, data }.
    • Repository computes merged date limits across base/compare ranges; simplifies fetch/merge/fill logic.
  • API/OpenAPI:

    • Replace TriggerFilterDto with FieldFilterDto; AnalyticsQueryDto now carries fields.
    • Regenerate marble-api types accordingly.
  • Localization & deps:

    • Add new i18n strings for filters (Apply/Clear, descriptions, unavailable tooltip) in en/fr/ar.
    • Add dependencies: remeda, temporal-polyfill, ts-pattern to app and UI kit.
  • Misc fixes:

    • Data model exported-fields routes: safe array handling and correct error toast type.
    • Remove obsolete useDateRangeSearchParams hook.

Written by Cursor Bugbot for commit 891489a. This will update automatically on new commits. Configure here.

@siiick
siiick marked this pull request as draft October 23, 2025 13:16
@siiick
siiick force-pushed the feat/in-app-analytics-dates branch from 89e4184 to 69cba3b Compare October 23, 2025 21:56
@siiick
siiick marked this pull request as ready for review October 24, 2025 11:00
@siiick
siiick requested a review from ChibiBlasphem October 24, 2025 11:01
cursor[bot]

This comment was marked as outdated.

@ChibiBlasphem ChibiBlasphem left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall there's a lot of comments/logs left in the code. Some parts are complex to understand due to naming and/or not being extracted in their own function.

Comment on lines 38 to 94
getDecisionOutcomesPerDay: async (
args: AnalyticsQuery,
): Promise<DecisionOutcomesPerPeriod | null> => {
const parsed = transformAnalyticsQuery.parse(args);
if (!parsed.length) throw new Error('No date range provided');

try {
const [raw, rawCompare] = await Promise.all([
client.getDecisionOutcomesPerDay(parsed[0]!),
...(parsed[1] ? [client.getDecisionOutcomesPerDay(parsed[1])] : []),
]);

const merged = mergeDateRanges([raw, ...(rawCompare ? [rawCompare] : [])]);

const start = args.compareDateRange
? [args.dateRange.start, args.compareDateRange.start].sort(compareAsc)[0]!
: args.dateRange.start;
const end = args.compareDateRange
? [args.dateRange.end, args.compareDateRange.end].sort(compareDesc)[0]!
: args.dateRange.end;
const start =
parsed.length === 2
? [parsed[0]!.start, parsed[1]!.start].sort(compareAsc)[0]!
: parsed[0]!.start;
const end =
parsed.length === 2
? [parsed[0]!.end, parsed[1]!.end].sort(compareDesc)[0]!
: parsed[0]!.end;

const startDate: LimitDate = {
date: start,
rangeId: start === args.dateRange.start ? 'base' : 'compare',
rangeId: start === parsed[0]!.start ? 'base' : 'compare',
};
const endDate: LimitDate = {
date: end,
rangeId: end === args.dateRange.end ? 'base' : 'compare',
rangeId: end === parsed[0]!.end ? 'base' : 'compare',
};

if (!merged.length) {
merged.push({
...startDate,
approve: 0,
block_and_review: 0,
decline: 0,
review: 0,
});
merged.push({
...endDate,
approve: 0,
block_and_review: 0,
decline: 0,
review: 0,
});
}
const rangeSize = differenceInDays(end, start);

return adaptDecisionOutcomesPerDay(
rangeSize === merged.length ? merged : fillMissingDays(merged, startDate, endDate),
);
} catch (error) {
console.error(error);
console.error('error in getDecisionOutcomesPerDay', error);
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That's quite complex to understand what's happening with the naming. Do you think you can rename those vars and maybe create an util function (IMO input refining is not the main point of the repository function)? And why is there a need to try/catch all that code?

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.

You are right. I moved the date ranges computation appart.
The try/catch block was useless

Comment on lines +21 to +32
try {
const urlParams = urlParamsSchema.parse(params);
const queryParams = queryParamsSchema.parse(await request.json());
const query = await analytics.getAvailableFilters({
scenarioId: urlParams.scenarioId,
ranges: queryParams.ranges,
});
return Response.json(query);
} catch (error) {
console.error('error in available_filters', error);
return Response.json({ error: 'Invalid request' }, { status: 400 });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No success true or false?

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.

fixed. Thanks

Comment on lines +38 to +45
return Response.json({
decisionOutcomesPerDay,
ruleHitTable,
});
} catch (error) {
console.error('error in analytics query', error);
return Response.json({ error: 'Internal server error' }, { status: 500 });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here, no success field ?

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.

fixed. Thanks

Comment on lines +19 to +27
const toTextArray = (selected: unknown): string[] => {
const arr = selected as any[];
if (!Array.isArray(arr)) return [];
return arr.flatMap((item) => {
if (typeof item === 'string') return item;
const val = (item as any)?.value;
return Array.isArray(val) ? val : val != null ? [val] : [];
});
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems like this function could be created outside the component

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 was to keep the Text filter resilient to different formats.
Let's discuss this later

@ChibiBlasphem ChibiBlasphem Oct 31, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I understand but the function is not using any component variable, hence it should be outside. I kinda want to know what you me to "keep it resilient to different formats" .

const isActive = (name: string) => active.includes(name);
return { emitSet, emitRemove, emitToggleActive, getValue, isActive };
}, [value, active, onChange]);
console.log(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔥

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.

done

...(additionalFilters.length > 0 ? [['additional', additionalFilters] as const] : []),
]);
}, [mainFilters, additionalFilters]);
console.log(allDescriptors);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔥

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.

done

@siiick
siiick requested a review from ChibiBlasphem October 29, 2025 21:38
cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@siiick
siiick force-pushed the feat/in-app-analytics-dates branch from 889037a to 872212d Compare October 29, 2025 22:00
cursor[bot]

This comment was marked as outdated.

@siiick
siiick requested a review from ChibiBlasphem October 31, 2025 10:50

@ChibiBlasphem ChibiBlasphem left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think those are the last ones, sorry for having miss those.

Comment on lines +20 to +22
const [localText, setLocalText] = useState<string[]>(
filter.selectedValue?.map((item) => item.value) ?? [],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you really need a state for that? localText seems to be a computed value from filter.selectedValue.

Suggested change
const [localText, setLocalText] = useState<string[]>(
filter.selectedValue?.map((item) => item.value) ?? [],
);
const localText = filter.selectedValue?.map((item) => item.value) ?? [];

Comment on lines 38 to 40
useEffect(() => {
if (isOpen) setLocalText(toTextArray(filter.selectedValue));
if (isOpen) setLocalText(filter.selectedValue?.map((item) => item.value) ?? []);
}, [isOpen, filter.selectedValue]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You don't need the effect with my suggestion above

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@ChibiBlasphem ChibiBlasphem left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall it's good but I still have a big issue with naming (sv instead of selectedValue or op instead of operator). Think about new developers or developers who didn't code the feature as it can be a headache trying to understand the global flow of the feature while also trying to understand the meaning of some variables

const exportedFields =
await context.authInfo.dataModelRepository.updateDataModelTableExportedFields(tableId, {
triggerObjectFields: current.triggerObjectFields,
ingestedDataFields: [...(current.ingestedDataFields ?? undefined), field],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🆙 this. I think it's better to do ...(current.ingestedDataFields ?? [])

(() => {
const sv = filter.selectedValue ?? { operator: 'eq', value: 0 };
const raw = (sv as any).value as unknown;
const sv = filter.selectedValue ?? { op: '=', value: 0 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some variable naming makes it hard to read, sv could easily be named selectedValue

cursor[bot]

This comment was marked as outdated.

@siiick
siiick requested a review from ChibiBlasphem November 3, 2025 10:53
cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@siiick
siiick force-pushed the feat/in-app-analytics-dates branch from 6cae943 to 891489a Compare November 3, 2025 11:06
} catch {
setToastMessage(toastSession, {
type: 'success',
type: 'error',

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: Guard Clause Absence Causes Undefined Iteration Error

The update.ts route attempts to spread undefined into an array literal when current.ingestedDataFields is null or undefined. This causes a runtime TypeError: undefined is not iterable. The corresponding delete.ts route correctly uses an empty array fallback.

Additional Locations (1)

Fix in Cursor Fix in Web

@ChibiBlasphem ChibiBlasphem left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lgtm

@siiick
siiick merged commit ea68f71 into main Nov 3, 2025
6 of 7 checks passed
@siiick
siiick deleted the feat/in-app-analytics-dates branch November 3, 2025 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants