Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
132 changes: 132 additions & 0 deletions .github/workflows/web-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: Web Preview

# Label a PR `preview:web` to get a hosted-web preview deployment on Vercel for
# that push and every subsequent push. The deployment is a plain (non-prod,
# non-aliased) deploy into the existing hosted-web Vercel project, so the
# latest/nightly channel aliases are never touched.
#
# The build intentionally omits the T3 Connect cloud config (Clerk keys, relay
# URL): previews boot as the hosted-static app with manual pairing only. Pair a
# server into a preview with `t3 pair --tailscale` (or any reachable HTTPS
# backend) and open the pairing URL against the preview origin.
#
# The preview must be opened at the exact deployment URL from the PR comment.
# Vite bakes that URL in as the hosted origin (via VERCEL_URL), and
# `isHostedStaticApp` matches on origin, so branch-alias URLs will not
# self-identify as the hosted app.

on:
pull_request:
types: [labeled, synchronize, reopened]

permissions:
contents: read
pull-requests: write

concurrency:
group: web-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
deploy:
name: Deploy web preview
# Same-repo PRs only: fork PRs do not receive the Vercel secrets, and this
# workflow should skip rather than fail for them. On `labeled` events, only
# the preview label itself triggers a deploy.
if: >-
github.event.pull_request.head.repo.full_name == github.repository &&
contains(github.event.pull_request.labels.*.name, 'preview:web') &&
(github.event.action != 'labeled' || github.event.label.name == 'preview:web')
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false

- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: true
run-install: |
args:
- --filter=@t3tools/scripts...
- --filter=@t3tools/web...

- id: deploy
name: Deploy preview
shell: bash
run: |
set -euo pipefail

if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then
echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2
exit 1
fi

vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"

deployment_url="$(
vp dlx vercel@53.1.1 deploy \
--archive=tgz \
--yes \
--token "$VERCEL_TOKEN" \
--scope "$vercel_scope"
)"

echo "Deployed $deployment_url"
echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT"

- name: Comment deployment URL
uses: actions/github-script@v8
env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment_url }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
with:
script: |
const marker = "<!-- web-preview-deploy -->";
const body = [
marker,
"### Web preview",
"",
`${process.env.DEPLOYMENT_URL} (for ${process.env.HEAD_SHA.slice(0, 7)})`,
"",
"Open this exact URL — the hosted-app origin is baked in at build time.",
"Pair a server into it with `t3 pair --tailscale`, or paste a host + pairing",
"code under Settings → Connections.",
].join("\n");

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
per_page: 100,
});
const existing = comments.find((comment) => comment.body?.includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body,
});
}
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
import {
Expand Down Expand Up @@ -190,6 +191,13 @@ const SettingsSheetStack = createNativeStackNavigator({
title: "Client Storage",
},
}),
SettingsUsage: createNativeStackScreen({
screen: UsageRouteScreen,
linking: "usage",
options: {
title: "Usage",
},
}),
SettingsAuth: createNativeStackScreen({
screen: SettingsAuthRouteScreen,
linking: "auth",
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
IconBellRinging,
IconBolt,
IconCamera,
IconChartBar,
IconCheck,
IconChevronDown,
IconClock,
Expand Down Expand Up @@ -97,6 +98,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"bolt.circle": IconBolt,
"bolt.horizontal.circle": IconBolt,
camera: IconCamera,
"chart.bar.xaxis": IconChartBar,
checkmark: IconCheck,
"checkmark.circle": IconCircleCheck,
clock: IconClock,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ function GeneralSettingsSection() {
return (
<SettingsSection title="General">
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
</SettingsSection>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type SettingsSheetTarget =
| "SettingsArchive"
| "SettingsAppearance"
| "SettingsProjectGrouping"
| "SettingsClientStorage";
| "SettingsClientStorage"
| "SettingsUsage";

export type SettingsLegalDocumentTarget = "SettingsLegal";
50 changes: 50 additions & 0 deletions apps/mobile/src/features/usage/UsageDailyChart.ios.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Chart, Host, type ChartDataPoint } from "@expo/ui/swift-ui";
import { frame } from "@expo/ui/swift-ui/modifiers";
import { useMemo } from "react";

import type { DailyTotals } from "@t3tools/shared/usageMerge";

import { buildChartDays, type UsageChartMetric } from "./usageChartData";
import { useProviderColors } from "./usageProviders";

export interface UsageDailyChartProps {
readonly days: readonly string[];
readonly daily: readonly DailyTotals[];
readonly metric: UsageChartMetric;
readonly height: number;
}

/**
* Native Swift Charts daily bars. Points sharing an x value stack, so emitting
* one point per provider per day yields per-provider bands whose stack height
* is the day's total; changes animate natively.
*
* Axes are hidden: 30-90 categorical day labels cannot fit on a phone, so the
* screen renders its own edge labels under the chart instead.
*/
export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) {
const colors = useProviderColors();

const data = useMemo((): ChartDataPoint[] => {
return buildChartDays(days, daily, metric).flatMap((day) =>
day.values.map((entry) => ({
x: day.day,
y: entry.value,
color: colors[entry.provider],
})),
);
}, [days, daily, metric, colors]);

return (
<Host style={{ height, width: "100%" }}>
<Chart
type="bar"
data={data}
animate
showGrid={false}
barStyle={{ cornerRadius: 2 }}
modifiers={[frame({ height })]}
/>
</Host>
);
}
44 changes: 44 additions & 0 deletions apps/mobile/src/features/usage/UsageDailyChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useMemo } from "react";
import { View } from "react-native";

import type { DailyTotals } from "@t3tools/shared/usageMerge";

import { buildChartDays, type UsageChartMetric } from "./usageChartData";
import { useProviderColors } from "./usageProviders";

export interface UsageDailyChartProps {
readonly days: readonly string[];
readonly daily: readonly DailyTotals[];
readonly metric: UsageChartMetric;
readonly height: number;
}

/**
* Stacked daily bars drawn with plain views. Android and any platform without
* Swift Charts land here; iOS resolves `UsageDailyChart.ios.tsx` instead.
*/
export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) {
const colors = useProviderColors();
const chartDays = useMemo(() => buildChartDays(days, daily, metric), [days, daily, metric]);
const max = chartDays.reduce((peak, day) => Math.max(peak, day.total), 0);

return (
<View style={{ height }} className="flex-row items-end gap-px">
{/* column-reverse stacks the bottom-first provider values upward
without reversing the array (Hermes lacks Array#toReversed). */}
{chartDays.map((day) => (
<View key={day.day} className="h-full flex-1 flex-col-reverse overflow-hidden rounded-sm">
{day.values.map((entry) => (
<View
key={entry.provider}
style={{
height: max === 0 ? 0 : (entry.value / max) * height,
backgroundColor: colors[entry.provider],
}}
/>
))}
</View>
))}
</View>
);
}
Loading
Loading