Skip to content

Commit 840ec17

Browse files
authored
feat(in-app-analytics): improve design integration (#1140)
1 parent a7346d3 commit 840ec17

14 files changed

Lines changed: 553 additions & 417 deletions

File tree

packages/app-builder/src/components/Analytics/Decisions.tsx

Lines changed: 262 additions & 121 deletions
Large diffs are not rendered by default.

packages/app-builder/src/components/Analytics/Filters.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function Filters({
1616
scenarios: Scenario[];
1717
onSelectedScenarioIdChange: (scenarioId: string) => void;
1818
}) {
19-
const { t } = useTranslation(['decisions', 'common']);
19+
const { t } = useTranslation(['decisions', 'common', 'analytics']);
2020
const language = useFormatLanguage();
2121
const selectedScenario = scenarios.find((scenario) => scenario.id === selectedScenarioId);
2222
const { range, compareRange, setDateRangeFilter, setCompareRange } = useDateRangeSearchParams();
@@ -81,7 +81,7 @@ export function Filters({
8181
</FilterPopover.Root>
8282
<FilterPopover.Root key={'dateRangeCompare'}>
8383
<FilterItem.Root>
84-
<FilterItem.Trigger>
84+
<FilterItem.Trigger className={!compareRange ? 'opacity-50' : ''}>
8585
<Icon icon="calendar-month" className="size-5" />
8686
{compareRange ? (
8787
<span className="text-s font-semibold first-letter:capitalize">
@@ -98,7 +98,7 @@ export function Filters({
9898
</span>
9999
) : (
100100
<span className="text-s font-semibold first-letter:capitalize">
101-
Add compare period
101+
{t('analytics:filters.add_compare_period')}
102102
</span>
103103
)}
104104
</FilterItem.Trigger>

packages/app-builder/src/hooks/useDateRangeSearchParams.ts

Lines changed: 72 additions & 188 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useSearchParams } from '@remix-run/react';
2-
import { subMonths } from 'date-fns';
2+
import { subDays, subMonths } from 'date-fns';
33
import { useCallback, useMemo } from 'react';
44

55
type StaticDateRange = { type: 'static'; startDate: string; endDate: string };
66
type DynamicDateRange = { type: 'dynamic'; fromNow: string };
77
type DateRangeFilterParam = StaticDateRange | DynamicDateRange | null | undefined;
8+
type IsoRange = { start: string; end: string };
89

910
function toIso(date: Date): string {
1011
return date.toISOString();
@@ -18,224 +19,107 @@ function getDefaultRange(): { start: string; end: string } {
1819

1920
export function useDateRangeSearchParams() {
2021
const [searchParams, setSearchParams] = useSearchParams();
21-
const encodeBase64Url = (value: string) =>
22-
btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
23-
const decodeBase64Url = (value: string) => {
24-
const withPadding = value.replace(/-/g, '+').replace(/_/g, '/');
25-
const pad = withPadding.length % 4 ? 4 - (withPadding.length % 4) : 0;
26-
const padded = withPadding + '='.repeat(pad);
27-
return atob(padded);
28-
};
29-
30-
const { range, compareRange } = useMemo(() => {
31-
const q = searchParams.get('q');
32-
if (q) {
33-
try {
34-
const obj = JSON.parse(decodeBase64Url(q)) as {
35-
range?: { start?: string; end?: string } | null;
36-
compareRange?: { start?: string; end?: string } | null;
37-
};
38-
// New shape
39-
if ('range' in obj && obj.range?.start && obj.range?.end) {
40-
return {
41-
range: { start: obj.range.start, end: obj.range.end },
42-
compareRange:
43-
obj.compareRange?.start && obj.compareRange?.end
44-
? { start: obj.compareRange.start, end: obj.compareRange.end }
45-
: null,
46-
} as const;
22+
const parseQ = useCallback(
23+
(qValue: string | null): { range: IsoRange; compareRange: IsoRange | null } => {
24+
if (qValue) {
25+
try {
26+
const obj = JSON.parse(atob(qValue)) as {
27+
range?: { start?: string; end?: string } | null;
28+
compareRange?: { start?: string; end?: string } | null;
29+
};
30+
if (obj?.range?.start && obj?.range?.end) {
31+
return {
32+
range: { start: obj.range.start, end: obj.range.end },
33+
compareRange:
34+
obj.compareRange?.start && obj.compareRange?.end
35+
? { start: obj.compareRange.start, end: obj.compareRange.end }
36+
: null,
37+
};
38+
}
39+
} catch {
40+
// ignore malformed q
4741
}
48-
// otherwise fallthrough to defaults below
49-
} catch {
50-
// ignore malformed q
5142
}
52-
}
53-
const defaults = getDefaultRange();
54-
return { range: { start: defaults.start, end: defaults.end }, compareRange: null } as const;
55-
}, [searchParams]);
43+
const defaults = getDefaultRange();
44+
return { range: { start: defaults.start, end: defaults.end }, compareRange: null };
45+
},
46+
[],
47+
);
48+
49+
const { range, compareRange } = useMemo(
50+
() => parseQ(searchParams.get('q')),
51+
[parseQ, searchParams],
52+
);
53+
54+
const computeDynamicRange = useCallback((fromNow: string): IsoRange => {
55+
const now = new Date();
56+
const match = /-?P-?(\d+)([MD])/i.exec(fromNow);
57+
const amount = match && match[1] ? Number(match[1]) : 1;
58+
const unit = match && match[2] ? match[2].toUpperCase() : 'M';
59+
const startDate = unit === 'D' ? subDays(now, amount) : subMonths(now, amount);
60+
return { start: toIso(startDate), end: toIso(now) };
61+
}, []);
62+
63+
const writeQ = useCallback(
64+
(prevParams: URLSearchParams, next: { range?: IsoRange; compareRange?: IsoRange | null }) => {
65+
const params = new URLSearchParams(prevParams);
66+
const current = parseQ(params.get('q'));
67+
const nextPayload = {
68+
range: next.range ?? current.range,
69+
compareRange: next.compareRange === undefined ? current.compareRange : next.compareRange,
70+
};
71+
params.set('q', btoa(JSON.stringify(nextPayload)));
72+
params.delete('compareStart');
73+
params.delete('compareEnd');
74+
params.delete('start');
75+
params.delete('end');
76+
return params;
77+
},
78+
[parseQ],
79+
);
5680

5781
const setDateRangeFilter = useCallback(
5882
(dateRange: DateRangeFilterParam) => {
5983
setSearchParams(
6084
(prev) => {
61-
const params = new URLSearchParams(prev);
62-
const writeQ = (payload: {
63-
range: { start: string; end: string };
64-
compareRange?: { start: string; end: string } | null;
65-
}) => {
66-
params.set('q', encodeBase64Url(JSON.stringify(payload)));
67-
};
68-
6985
if (!dateRange) {
70-
const d = getDefaultRange();
71-
// Clear to defaults; preserve existing compareRange if any
72-
const qv = params.get('q');
73-
let existingCompare: { start: string; end: string } | null = null;
74-
if (qv) {
75-
try {
76-
const parsed = JSON.parse(decodeBase64Url(qv)) as {
77-
compareRange?: { start?: string; end?: string } | null;
78-
};
79-
if (parsed.compareRange?.start && parsed.compareRange?.end) {
80-
existingCompare = {
81-
start: parsed.compareRange.start,
82-
end: parsed.compareRange.end,
83-
};
84-
}
85-
} catch {
86-
// ignore
87-
}
88-
}
89-
writeQ({ range: { start: d.start, end: d.end }, compareRange: existingCompare });
90-
return params;
86+
const defaults = getDefaultRange();
87+
return writeQ(prev, { range: defaults, compareRange: undefined });
9188
}
9289

9390
if (dateRange.type === 'static') {
94-
const current = { start: dateRange.startDate, end: dateRange.endDate } as const;
95-
// retain existing compareRange if any
96-
const qv = params.get('q');
97-
if (qv) {
98-
try {
99-
const parsed = JSON.parse(decodeBase64Url(qv)) as {
100-
compareRange?: { start?: string; end?: string } | null;
101-
};
102-
const compare =
103-
parsed.compareRange?.start && parsed.compareRange?.end
104-
? { start: parsed.compareRange.start, end: parsed.compareRange.end }
105-
: null;
106-
writeQ({ range: current, compareRange: compare });
107-
return params;
108-
} catch {
109-
// ignore malformed q
110-
}
111-
}
112-
writeQ({ range: current });
113-
return params;
91+
const nextRange = { start: dateRange.startDate, end: dateRange.endDate } as IsoRange;
92+
return writeQ(prev, { range: nextRange, compareRange: undefined });
11493
}
11594

116-
// dynamic
117-
try {
118-
const now = new Date();
119-
const fallback = subMonths(now, 1);
120-
let computedStart = fallback;
121-
if (dateRange.fromNow.includes('M')) {
122-
const match = dateRange.fromNow.match(/-P(\d+)M|P-?(\d+)M/i);
123-
const months = match ? Number(match[1] ?? match[2]) : undefined;
124-
if (months && months > 0) {
125-
computedStart = subMonths(now, months);
126-
}
127-
} else if (dateRange.fromNow.includes('D')) {
128-
const match = dateRange.fromNow.match(/-P(\d+)D|P-?(\d+)D/i);
129-
const days = match ? Number(match[1] ?? match[2]) : undefined;
130-
if (days && days > 0) {
131-
const ms = 24 * 60 * 60 * 1000 * days;
132-
computedStart = new Date(now.getTime() - ms);
133-
}
134-
}
135-
// preserve existing compareRange if any
136-
const qv = params.get('q');
137-
let existingCompare: { start: string; end: string } | null = null;
138-
if (qv) {
139-
try {
140-
const parsed = JSON.parse(decodeBase64Url(qv)) as {
141-
compareRange?: { start?: string; end?: string } | null;
142-
};
143-
if (parsed.compareRange?.start && parsed.compareRange?.end) {
144-
existingCompare = {
145-
start: parsed.compareRange.start,
146-
end: parsed.compareRange.end,
147-
};
148-
}
149-
} catch {
150-
// ignore
151-
}
152-
}
153-
writeQ({
154-
range: { start: computedStart.toISOString(), end: now.toISOString() },
155-
compareRange: existingCompare,
156-
});
157-
} catch {
158-
const d = getDefaultRange();
159-
writeQ({ range: { start: d.start, end: d.end } });
160-
}
161-
return params;
95+
const dynamicRange = computeDynamicRange(dateRange.fromNow);
96+
return writeQ(prev, { range: dynamicRange, compareRange: undefined });
16297
},
16398
{ replace: true },
16499
);
165100
},
166-
[setSearchParams],
101+
[computeDynamicRange, setSearchParams, writeQ],
167102
);
168103

169104
const setCompareRange = useCallback(
170105
(dateRange: DateRangeFilterParam) => {
171106
setSearchParams(
172107
(prev) => {
173-
const params = new URLSearchParams(prev);
174-
const qv = params.get('q');
175-
const base = (() => {
176-
if (qv) {
177-
try {
178-
const parsed = JSON.parse(decodeBase64Url(qv)) as {
179-
range?: { start?: string; end?: string } | null;
180-
};
181-
if (parsed.range?.start && parsed.range?.end) {
182-
return { start: parsed.range.start, end: parsed.range.end } as const;
183-
}
184-
} catch {
185-
// ignore malformed q
186-
}
187-
}
188-
const defaults = getDefaultRange();
189-
return { start: defaults.start, end: defaults.end };
190-
})();
191-
192-
let compare: { start: string; end: string } | null = null;
108+
let nextCompare: IsoRange | null;
193109
if (!dateRange) {
194-
compare = null;
110+
nextCompare = null;
195111
} else if (dateRange.type === 'static') {
196-
compare = { start: dateRange.startDate, end: dateRange.endDate };
197-
} else if (dateRange.type === 'dynamic') {
198-
try {
199-
const now = new Date();
200-
const fallback = subMonths(now, 1);
201-
let computedStart = fallback;
202-
if (dateRange.fromNow.includes('M')) {
203-
const match = dateRange.fromNow.match(/-P(\d+)M|P-?(\d+)M/i);
204-
const months = match ? Number(match[1] ?? match[2]) : undefined;
205-
if (months && months > 0) {
206-
computedStart = subMonths(now, months);
207-
}
208-
} else if (dateRange.fromNow.includes('D')) {
209-
const match = dateRange.fromNow.match(/-P(\d+)D|P-?(\d+)D/i);
210-
const days = match ? Number(match[1] ?? match[2]) : undefined;
211-
if (days && days > 0) {
212-
const ms = 24 * 60 * 60 * 1000 * days;
213-
computedStart = new Date(now.getTime() - ms);
214-
}
215-
}
216-
compare = { start: computedStart.toISOString(), end: now.toISOString() };
217-
} catch {
218-
compare = null;
219-
}
112+
nextCompare = { start: dateRange.startDate, end: dateRange.endDate };
113+
} else {
114+
nextCompare = computeDynamicRange(dateRange.fromNow);
220115
}
221116

222-
const payload = {
223-
range: { start: base.start, end: base.end },
224-
compareRange: compare,
225-
} as const;
226-
227-
params.set('q', encodeBase64Url(JSON.stringify(payload)));
228-
// cleanup legacy flat params if present
229-
params.delete('compareStart');
230-
params.delete('compareEnd');
231-
params.delete('start');
232-
params.delete('end');
233-
return params;
117+
return writeQ(prev, { compareRange: nextCompare });
234118
},
235119
{ replace: true },
236120
);
237121
},
238-
[setSearchParams],
122+
[computeDynamicRange, setSearchParams, writeQ],
239123
);
240124

241125
return {

0 commit comments

Comments
 (0)