Skip to content

Commit 7df6c89

Browse files
chore: refactor card components
Signed-off-by: Henry Gressmann <[email protected]>
1 parent 0c52a28 commit 7df6c89

File tree

7 files changed

+395
-393
lines changed

7 files changed

+395
-393
lines changed

web/src/api/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createClient, type NormalizeOAS, type OASModel } from "fets";
22
export { queryClient, useMutation, useQuery, getUsername } from "./utils";
33
import type dashboardspec from "./dashboard";
44
import { queryClient, useQuery } from "./utils";
5+
import { useMemo } from "react";
56

67
export type DashboardSpec = NormalizeOAS<typeof dashboardspec>;
78
export type Metric = OASModel<DashboardSpec, "Metric">;
@@ -98,6 +99,43 @@ export const useUsers = () => {
9899
return { users: data?.users ?? [], isLoading, error };
99100
};
100101

102+
export const useDimension = ({
103+
project,
104+
dimension,
105+
metric,
106+
range,
107+
}: {
108+
project: ProjectResponse;
109+
dimension: Dimension;
110+
metric: Metric;
111+
range: DateRange;
112+
}): {
113+
data: DimensionTableRow[] | undefined;
114+
biggest: number;
115+
order: string[] | undefined;
116+
} => {
117+
const { data } = useQuery({
118+
placeholderData: (prev) => prev,
119+
queryKey: ["dimension", project.id, dimension, metric, range],
120+
queryFn: () =>
121+
api["/api/dashboard/project/{project_id}/dimension"]
122+
.post({
123+
params: { project_id: project.id },
124+
json: {
125+
dimension,
126+
metric,
127+
range,
128+
},
129+
})
130+
.json(),
131+
});
132+
133+
const biggest = useMemo(() => data?.data?.reduce((acc, d) => Math.max(acc, d.value), 0) ?? 0, [data]);
134+
const order = useMemo(() => data?.data?.sort((a, b) => b.value - a.value).map((d) => d.dimensionValue), [data]);
135+
136+
return { data: data?.data, biggest, order };
137+
};
138+
101139
export const invalidateProjects = () => queryClient.invalidateQueries({ queryKey: ["projects"] });
102140
export const invalidateEntities = () => queryClient.invalidateQueries({ queryKey: ["entities"] });
103141
export const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ["users"] });

web/src/api/ranges.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ export const ranges: Record<RangeName, () => { range: DateRange; dataPoints: num
6363
},
6464
yearToDate: () => {
6565
const now = new Date().getTime();
66-
const start = startOfYear(now).getTime();
66+
const start = endOfDay(subDays(startOfYear(now), 1)).getTime() - 1000;
6767
const months = differenceInMonths(now, start);
68-
return { range: { start, end: now }, dataPoints: months, graphRange: "month" };
68+
return { range: { start, end: now }, dataPoints: months + 1, graphRange: "month" };
6969
},
7070
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
.card {
2+
padding: 1rem;
3+
background-color: var(--pico-form-element-background-color);
4+
border-radius: var(--pico-border-radius);
5+
background: var(--pico-card-background-color);
6+
box-shadow: var(--pico-card-box-shadow);
7+
}
8+
9+
.tabs {
10+
.tabsList {
11+
display: flex;
12+
gap: 1rem;
13+
margin-bottom: 1rem;
14+
}
15+
16+
button {
17+
all: unset;
18+
cursor: pointer;
19+
20+
&[aria-selected="true"] {
21+
text-decoration: underline;
22+
font-weight: 600;
23+
}
24+
25+
&:last-of-type {
26+
margin-right: auto;
27+
}
28+
}
29+
}
30+
31+
.percentage {
32+
flex: 1;
33+
position: relative;
34+
z-index: 1;
35+
padding-left: 0.5rem;
36+
padding-bottom: 0.5rem;
37+
display: flex;
38+
align-items: center;
39+
gap: 0.2rem;
40+
41+
&:hover {
42+
&::after {
43+
opacity: 0.3;
44+
}
45+
}
46+
47+
&::after {
48+
content: "";
49+
position: absolute;
50+
left: 0;
51+
width: var(--percentage);
52+
height: 100%;
53+
background: var(--pico-h5-color);
54+
opacity: 0.09;
55+
z-index: -1;
56+
transition: width 0.3s ease-in-out, opacity 0.1s ease-in-out;
57+
border-radius: 1rem;
58+
}
59+
}
60+
61+
.dimensionHeader {
62+
display: flex;
63+
justify-content: space-between;
64+
gap: 1rem;
65+
color: var(--pico-h5-color);
66+
margin-bottom: 1rem;
67+
}
68+
69+
.dimensionRow {
70+
display: flex;
71+
justify-content: space-between;
72+
gap: 1rem;
73+
margin-bottom: 0.2rem;
74+
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import styles from "./dimensions.module.css";
2+
import { LinkIcon } from "lucide-react";
3+
import * as Tabs from "@radix-ui/react-tabs";
4+
5+
import {
6+
dimensionNames,
7+
formatMetricVal,
8+
metricNames,
9+
useDimension,
10+
type DateRange,
11+
type Dimension,
12+
type DimensionTableRow,
13+
type Metric,
14+
type ProjectResponse,
15+
} from "../../api";
16+
17+
import { BrowserIcon, MobileDeviceIcon, OSIcon, ReferrerIcon } from "../icons";
18+
import { countryCodeToFlag, formatFullUrl, formatHost, getHref, tryParseUrl } from "./utils";
19+
20+
export const cardStyles = styles.card;
21+
22+
export const DimensionCard = ({
23+
project,
24+
dimension,
25+
metric,
26+
range,
27+
}: {
28+
project: ProjectResponse;
29+
dimension: Dimension;
30+
metric: Metric;
31+
range: DateRange;
32+
}) => {
33+
return (
34+
<div className={styles.card}>
35+
<div className={styles.dimensionHeader}>
36+
<div>{dimensionNames[dimension]}</div>
37+
<div>{metricNames[metric]}</div>
38+
</div>
39+
<DimensionTable project={project} dimension={dimension} metric={metric} range={range} />
40+
</div>
41+
);
42+
};
43+
44+
export const DimensionTabsCard = ({
45+
project,
46+
metric,
47+
range,
48+
dimensions,
49+
}: { project: ProjectResponse; dimensions: Dimension[]; metric: Metric; range: DateRange }) => {
50+
return (
51+
<div className={styles.card}>
52+
<DimensionTabs project={project} dimensions={dimensions} metric={metric} range={range} />
53+
</div>
54+
);
55+
};
56+
57+
export const DimensionTabs = ({
58+
project,
59+
metric,
60+
range,
61+
dimensions,
62+
}: { project: ProjectResponse; dimensions: Dimension[]; metric: Metric; range: DateRange }) => {
63+
return (
64+
<Tabs.Root className={styles.tabs} defaultValue={dimensions[0]}>
65+
<Tabs.List className={styles.tabsList}>
66+
{Object.entries(dimensions).map(([key, value]) => (
67+
<Tabs.Trigger key={key} value={value}>
68+
{dimensionNames[value]}
69+
</Tabs.Trigger>
70+
))}
71+
<div>{metricNames[metric]}</div>
72+
</Tabs.List>
73+
{dimensions.map((dimension) => (
74+
<Tabs.Content key={dimension} value={dimension}>
75+
<DimensionTable dimension={dimension} metric={metric} range={range} project={project} noHeader />
76+
</Tabs.Content>
77+
))}
78+
</Tabs.Root>
79+
);
80+
};
81+
82+
export const DimensionTable = ({
83+
project,
84+
dimension,
85+
metric,
86+
range,
87+
}: { project: ProjectResponse; dimension: Dimension; metric: Metric; range: DateRange; noHeader?: boolean }) => {
88+
const { data, biggest, order } = useDimension({ project, dimension, metric, range });
89+
return <DimensionList value={data ?? []} dimension={dimension} metric={metric} biggest={biggest} order={order} />;
90+
};
91+
92+
export const DimensionList = ({
93+
value,
94+
dimension,
95+
metric,
96+
biggest,
97+
order,
98+
}: {
99+
value: DimensionTableRow[];
100+
dimension: Dimension;
101+
metric: Metric;
102+
biggest: number;
103+
order?: string[];
104+
}) => {
105+
return (
106+
<div>
107+
{value.map((d) => {
108+
return (
109+
<div
110+
key={d.dimensionValue}
111+
style={{ order: order?.indexOf(d.dimensionValue) }}
112+
className={styles.dimensionRow}
113+
>
114+
<DimensionValueBar value={d.value} biggest={biggest}>
115+
<DimensionLabel dimension={dimension} value={d} />
116+
</DimensionValueBar>
117+
<div>{formatMetricVal(metric, d.value)}</div>
118+
</div>
119+
);
120+
})}
121+
</div>
122+
);
123+
};
124+
125+
const dimensionLabels: Record<Dimension, (value: DimensionTableRow) => React.ReactNode> = {
126+
platform: (value) => (
127+
<>
128+
<OSIcon os={value.dimensionValue} size={24} />
129+
&nbsp;
130+
{value.dimensionValue}
131+
</>
132+
),
133+
browser: (value) => (
134+
<>
135+
<BrowserIcon browser={value.dimensionValue} size={24} />
136+
&nbsp;
137+
{value.dimensionValue}
138+
</>
139+
),
140+
url: (value) => {
141+
const url = tryParseUrl(value.dimensionValue);
142+
143+
return (
144+
<>
145+
<LinkIcon size={16} />
146+
&nbsp;
147+
<a target="_blank" rel="noreferrer" href={getHref(url)}>
148+
{formatFullUrl(url)}
149+
</a>
150+
</>
151+
);
152+
},
153+
fqdn: (value) => {
154+
const url = tryParseUrl(value.dimensionValue);
155+
return (
156+
<>
157+
<LinkIcon size={16} />
158+
&nbsp;
159+
<a target="_blank" rel="noreferrer" href={getHref(url)}>
160+
{formatHost(url)}
161+
</a>
162+
</>
163+
);
164+
},
165+
mobile: (value) => (
166+
<>
167+
<MobileDeviceIcon isMobile={value.dimensionValue === "true"} size={24} />
168+
&nbsp;
169+
{value.dimensionValue === "true" ? "Mobile" : "Desktop"}
170+
</>
171+
),
172+
country: (value) => (
173+
<>
174+
{countryCodeToFlag(value.dimensionValue)}
175+
&nbsp;
176+
{value.displayName ?? value.dimensionValue ?? "Unknown"}
177+
</>
178+
),
179+
city: (value) => (
180+
<>
181+
{countryCodeToFlag(value.icon || "XX")}
182+
&nbsp;
183+
{value.displayName ?? value.dimensionValue ?? "Unknown"}
184+
</>
185+
),
186+
referrer: (value) => (
187+
<>
188+
<ReferrerIcon referrer={value.dimensionValue} icon={value.icon} size={24} />
189+
&nbsp;
190+
{value.displayName ?? value.dimensionValue ?? "Unknown"}
191+
</>
192+
),
193+
path: (value) => value.dimensionValue,
194+
};
195+
196+
const DimensionLabel = ({ dimension, value }: { dimension: Dimension; value: DimensionTableRow }) =>
197+
dimensionLabels[dimension](value);
198+
199+
const DimensionValueBar = ({
200+
value,
201+
biggest,
202+
children,
203+
}: { value: number; biggest: number; children?: React.ReactNode }) => (
204+
<div className={styles.percentage} style={{ "--percentage": `${(value / biggest) * 100}%` } as React.CSSProperties}>
205+
{children}
206+
</div>
207+
);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export const tryParseUrl = (url: string) => {
2+
try {
3+
return new URL(url);
4+
} catch {
5+
try {
6+
return new URL(`https://${url}`);
7+
} catch {
8+
return url;
9+
}
10+
}
11+
};
12+
13+
export const formatHost = (url: string | URL) => {
14+
if (typeof url === "string") return url;
15+
return url.hostname;
16+
};
17+
18+
export const formatFullUrl = (url: string | URL) => {
19+
if (typeof url === "string") return url;
20+
return `${url.hostname}${url.pathname}${url.search}`;
21+
};
22+
23+
export const getHref = (url: string | URL) => {
24+
if (typeof url === "string") {
25+
if (!url.startsWith("http")) return `https://${url}`;
26+
return url;
27+
}
28+
29+
return url.href;
30+
};
31+
32+
export const countryCodeToFlag = (countryCode: string) => {
33+
const code = countryCode.length === 2 ? countryCode : "XX";
34+
const codePoints = code
35+
.toUpperCase()
36+
.split("")
37+
.map((char) => 127397 + char.charCodeAt(0));
38+
return String.fromCodePoint(...codePoints);
39+
};

0 commit comments

Comments
 (0)