Skip to content

Commit 1a003e3

Browse files
Add cross-platform mobile usage dashboard (#5743)
1 parent 963ebf5 commit 1a003e3

17 files changed

Lines changed: 758 additions & 6 deletions

File tree

apps/mobile/src/Stack.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
4848
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
4949
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
5050
import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
51+
import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
5152
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
5253
import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
5354
import {
@@ -190,6 +191,13 @@ const SettingsSheetStack = createNativeStackNavigator({
190191
title: "Client Storage",
191192
},
192193
}),
194+
SettingsUsage: createNativeStackScreen({
195+
screen: UsageRouteScreen,
196+
linking: "usage",
197+
options: {
198+
title: "Usage",
199+
},
200+
}),
193201
SettingsAuth: createNativeStackScreen({
194202
screen: SettingsAuthRouteScreen,
195203
linking: "auth",

apps/mobile/src/components/AppSymbol.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
IconBellRinging,
1515
IconBolt,
1616
IconCamera,
17+
IconChartBar,
1718
IconCheck,
1819
IconChevronDown,
1920
IconCode,
@@ -97,6 +98,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
9798
"bolt.circle": IconBolt,
9899
"bolt.horizontal.circle": IconBolt,
99100
camera: IconCamera,
101+
"chart.bar.xaxis": IconChartBar,
100102
checkmark: IconCheck,
101103
"checkmark.circle": IconCircleCheck,
102104
clock: IconClock,

apps/mobile/src/features/settings/SettingsRouteScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@ function GeneralSettingsSection() {
533533
return (
534534
<SettingsSection title="General">
535535
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
536+
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
536537
</SettingsSection>
537538
);
538539
}

apps/mobile/src/features/settings/components/settings-sheet-targets.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export type SettingsSheetTarget =
33
| "SettingsArchive"
44
| "SettingsAppearance"
55
| "SettingsProjectGrouping"
6-
| "SettingsClientStorage";
6+
| "SettingsClientStorage"
7+
| "SettingsUsage";
78

89
export type SettingsLegalDocumentTarget = "SettingsLegal";
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Chart, Host, type ChartDataPoint } from "@expo/ui/swift-ui";
2+
import { frame } from "@expo/ui/swift-ui/modifiers";
3+
import { useMemo } from "react";
4+
5+
import type { DailyTotals } from "@t3tools/shared/usageMerge";
6+
7+
import { buildChartDays, type UsageChartMetric } from "./usageChartData";
8+
import { useProviderColors } from "./usageProviders";
9+
10+
export interface UsageDailyChartProps {
11+
readonly days: readonly string[];
12+
readonly daily: readonly DailyTotals[];
13+
readonly metric: UsageChartMetric;
14+
readonly height: number;
15+
}
16+
17+
/**
18+
* Native Swift Charts daily bars. Points sharing an x value stack, so emitting
19+
* one point per provider per day yields per-provider bands whose stack height
20+
* is the day's total; changes animate natively.
21+
*
22+
* Axes are hidden: 30-90 categorical day labels cannot fit on a phone, so the
23+
* screen renders its own edge labels under the chart instead.
24+
*/
25+
export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) {
26+
const colors = useProviderColors();
27+
28+
const data = useMemo((): ChartDataPoint[] => {
29+
return buildChartDays(days, daily, metric).flatMap((day) =>
30+
day.values.map((entry) => ({
31+
x: day.day,
32+
y: entry.value,
33+
color: colors[entry.provider],
34+
})),
35+
);
36+
}, [days, daily, metric, colors]);
37+
38+
return (
39+
<Host style={{ height, width: "100%" }}>
40+
<Chart
41+
type="bar"
42+
data={data}
43+
animate
44+
showGrid={false}
45+
barStyle={{ cornerRadius: 2 }}
46+
modifiers={[frame({ height })]}
47+
/>
48+
</Host>
49+
);
50+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useMemo } from "react";
2+
import { View } from "react-native";
3+
4+
import type { DailyTotals } from "@t3tools/shared/usageMerge";
5+
6+
import { buildChartDays, type UsageChartMetric } from "./usageChartData";
7+
import { useProviderColors } from "./usageProviders";
8+
9+
export interface UsageDailyChartProps {
10+
readonly days: readonly string[];
11+
readonly daily: readonly DailyTotals[];
12+
readonly metric: UsageChartMetric;
13+
readonly height: number;
14+
}
15+
16+
/**
17+
* Stacked daily bars drawn with plain views. Android and any platform without
18+
* Swift Charts land here; iOS resolves `UsageDailyChart.ios.tsx` instead.
19+
*/
20+
export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) {
21+
const colors = useProviderColors();
22+
const chartDays = useMemo(() => buildChartDays(days, daily, metric), [days, daily, metric]);
23+
const max = chartDays.reduce((peak, day) => Math.max(peak, day.total), 0);
24+
25+
return (
26+
<View style={{ height }} className="flex-row items-end gap-px">
27+
{/* column-reverse stacks the bottom-first provider values upward
28+
without reversing the array (Hermes lacks Array#toReversed). */}
29+
{chartDays.map((day) => (
30+
<View key={day.day} className="h-full flex-1 flex-col-reverse overflow-hidden rounded-sm">
31+
{day.values.map((entry) => (
32+
<View
33+
key={entry.provider}
34+
style={{
35+
height: max === 0 ? 0 : (entry.value / max) * height,
36+
backgroundColor: colors[entry.provider],
37+
}}
38+
/>
39+
))}
40+
</View>
41+
))}
42+
</View>
43+
);
44+
}

0 commit comments

Comments
 (0)