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
37 changes: 36 additions & 1 deletion app/Http/Controllers/Landing/LandingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public function home(): Response
'downloadUrls' => $this->fetchDownloadUrls(),
'githubStars' => $this->fetchGitHubStars(),
'latestRelease' => $this->fetchLatestRelease(),
'downloads' => $this->fetchDownloadStats(),
'paymentProvider' => config('payment.provider', 'lemonsqueezy'),
'teamMinSeats' => max(1, (int) config('pricing.team_min_seats', 5)),
]);
Expand Down Expand Up @@ -96,7 +97,7 @@ private function fetchGitHubStars(): ?int
* landing pages use. Cached once and shared by every reader so adding a
* consumer never adds an HTTP call.
*
* @return array<int, array{tag_name: string, published_at: ?string, assets: array<int, array{name: string, browser_download_url: string}>}>
* @return array<int, array{tag_name: string, published_at: ?string, assets: array<int, array{name: string, browser_download_url: string, download_count: int}>}>
*/
private function fetchAppReleases(): array
{
Expand All @@ -123,6 +124,9 @@ private function fetchAppReleases(): array
->map(fn(array $asset): array => [
'name' => (string) ($asset['name'] ?? ''),
'browser_download_url' => (string) ($asset['browser_download_url'] ?? ''),
// Already in this payload. Reading it costs nothing;
// it was simply being discarded.
'download_count' => (int) ($asset['download_count'] ?? 0),
])
->values()
->all(),
Expand Down Expand Up @@ -189,6 +193,37 @@ private function fetchLatestRelease(): array
];
}

/**
* How many times the Mac app has been downloaded, and over how many
* releases that figure was counted.
*
* DMG assets only. Each app release also ships a .zip, which is the update
* feed the installed app pulls from — counting those would fold automatic
* updates into a number the page presents as people choosing to install.
* The DMG is what the download button serves, so it is the honest one.
*
* `releases` travels with the count because this is a floor, not a lifetime
* total: the API returns one page of releases and most of them are plugin
* releases, so the window reaches back only so far. The page says how many
* releases it counted rather than implying it counted them all.
*
* @return array{total: ?int, releases: int}
*/
private function fetchDownloadStats(): array
{
$releases = $this->fetchAppReleases();

$total = collect($releases)
->flatMap(fn(array $release): array => $release['assets'])
->filter(fn(array $asset): bool => str_ends_with($asset['name'], '.dmg'))
->sum('download_count');

return [
'total' => $total > 0 ? $total : null,
'releases' => count($releases),
];
}

/**
* Determine whether a GitHub release is a TablePro app release rather than
* a plugin release. Both live in the same repo, but only app releases ship
Expand Down
47 changes: 45 additions & 2 deletions resources/js/components/landing/spec-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const BEHAVIOURS: Behaviour[] = [

interface Props {
latestRelease?: { version: string | null; publishedAt: string | null; countLast30Days: number | null } | null;
/** DMG downloads, and how many releases that figure was counted over. */
downloads?: { total: number | null; releases: number } | null;
}

const GITHUB_REPO_URL = 'https://github.com/TableProApp/TablePro';
Expand Down Expand Up @@ -89,7 +91,7 @@ function formatReleaseDate(iso: string): string {
* Adding a fifth unlabelled scroll container while the rest of this work is
* busy labelling the two that exist would be a poor trade.
*/
export default function SpecStrip({ latestRelease }: Props) {
export default function SpecStrip({ latestRelease, downloads }: Props) {
const specs: Spec[] = [
{ label: 'databases', type: 'int', value: '25', sub: '9 bundled · 16 on demand', numeric: true },
/*
Expand All @@ -101,7 +103,25 @@ export default function SpecStrip({ latestRelease }: Props) {
*/
{ label: 'cold_start', type: 'interval', value: 'Under 1s', sub: 'Cold, to first window', numeric: true },
{ label: 'idle_rss', type: 'bytes', value: '~80 MB', sub: 'One connection, open and idle', numeric: true },
{ label: 'download', type: 'bytes', value: '~20 MB', sub: 'Apple Silicon or Intel', numeric: true },
/*
* Download count, not download size. The size is already in the hero
* fine print and again in the closing call to action, and a third copy
* bought nothing; an adoption number is the most persuasive thing this
* slot can hold and it costs no new request — the controller was
* already fetching it and throwing it away.
*
* Falls back to the size when the GitHub API is unreachable, so the
* table never renders a hole.
*/
downloads?.total
? {
label: 'downloads',
type: 'int',
value: downloads.total.toLocaleString('en-US'),
sub: `Across ${downloads.releases} releases`,
numeric: true,
}
: { label: 'download', type: 'bytes', value: '~20 MB', sub: 'Apple Silicon or Intel', numeric: true },
{
label: 'license',
type: 'text',
Expand Down Expand Up @@ -239,6 +259,29 @@ export default function SpecStrip({ latestRelease }: Props) {
</Container>
<FullLine />

{/*
* The only social proof on this page, and every word of it is
* somebody else's judgement rather than ours — which is the whole
* point. It is linked so a reader can check it in one click.
*
* Written as fine print on purpose. A trophy row would be louder
* than the numbers above it, and the numbers are the argument.
*/}
<Container>
<p className="px-4 py-3 font-mono text-xs text-muted-foreground">
<a
href="https://trendshift.io/repositories/24114"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4 transition-colors hover:text-foreground"
>
#1 on GitHub Trending
</a>{' '}
on 23 March 2026, and Trendshift&rsquo;s #1 Swift repository of that week.
</p>
</Container>
<FullLine />

<Container>
<h3 className="px-4 py-3 font-mono text-2xs font-semibold tracking-widest text-muted-foreground uppercase">
What the numbers buy on day two
Expand Down
10 changes: 9 additions & 1 deletion resources/js/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,17 @@ interface LatestRelease {
countLast30Days: number | null;
}

interface DownloadStats {
/** DMG downloads across the app releases GitHub returned. Null when the API is unreachable. */
total: number | null;
releases: number;
}

interface Props {
downloadUrls: { arm64: string; x86_64: string };
githubStars?: number | null;
latestRelease?: LatestRelease | null;
downloads?: DownloadStats | null;
paymentProvider: string;
teamMinSeats: number;
}
Expand Down Expand Up @@ -185,6 +192,7 @@ export default function Home({
downloadUrls,
githubStars,
latestRelease,
downloads,
paymentProvider,
teamMinSeats,
}: Props) {
Expand Down Expand Up @@ -222,7 +230,7 @@ export default function Home({
* that proves it.
*/}
<Hero githubStars={githubStars} latestRelease={latestRelease} />
<SpecStrip latestRelease={latestRelease} />
<SpecStrip latestRelease={latestRelease} downloads={downloads} />
<DatabaseGrid />

{/* Conviction peaks at the screenshots, so a download follows them. */}
Expand Down
52 changes: 50 additions & 2 deletions tests/Feature/Landing/HomePagePropsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
});

/**
* @param array<int, array{tag: string, published: string, app?: bool}> $releases
* @param array<int, array{tag: string, published: string, app?: bool, downloads?: int}> $releases
*/
function fakeReleases(array $releases): void
{
Expand All @@ -26,14 +26,22 @@ function fakeReleases(array $releases): void
[
'name' => 'TablePro-' . ltrim($release['tag'], 'v') . '-arm64.dmg',
'browser_download_url' => 'https://example.com/' . $release['tag'] . '-arm64.dmg',
'download_count' => $release['downloads'] ?? 0,
],
[
'name' => 'TablePro-' . ltrim($release['tag'], 'v') . '-x86_64.dmg',
'browser_download_url' => 'https://example.com/' . $release['tag'] . '-x86_64.dmg',
'download_count' => $release['downloads'] ?? 0,
],
// The update feed the installed app pulls from. Never counted.
[
'name' => 'TablePro-' . ltrim($release['tag'], 'v') . '-arm64.zip',
'browser_download_url' => 'https://example.com/' . $release['tag'] . '-arm64.zip',
'download_count' => 9_000,
],
]
: [
['name' => 'EtcdDriverPlugin-arm64.zip', 'browser_download_url' => 'https://example.com/p.zip'],
['name' => 'EtcdDriverPlugin-arm64.zip', 'browser_download_url' => 'https://example.com/p.zip', 'download_count' => 777],
],
], $releases),
),
Expand Down Expand Up @@ -106,3 +114,43 @@ function fakeReleases(array $releases): void
->where('teamMinSeats', 5),
);
});

it('passes a download count derived from the releases it already fetched', function (): void {
/*
* The releases payload always carried `download_count` on every asset and
* the controller discarded it, so the page had no adoption number while one
* was arriving in a response it was already caching.
*
* DMG assets only. Each app release also ships a .zip, which is the feed
* the installed app updates from — folding those in would report automatic
* updates as people choosing to install. The fake gives every zip 9,000
* downloads so a regression that counts them cannot pass quietly.
*/
fakeReleases([
['tag' => 'v1.2.0', 'published' => now()->subDays(2)->toIso8601String(), 'downloads' => 245],
['tag' => 'v1.1.0', 'published' => now()->subDays(9)->toIso8601String(), 'downloads' => 5],
// A plugin release: no DMG, so isAppRelease drops it before counting.
['tag' => 'plugin-etcd-v1.0.1', 'published' => now()->subDays(4)->toIso8601String(), 'app' => false],
]);

get(route('landing.home'))
->assertOk()
->assertInertia(
fn($page) => $page->component('Home')
// (245 + 245) + (5 + 5), across two app releases.
->where('downloads.total', 500)
->where('downloads.releases', 2),
);
});

it('reports no download count rather than a zero when GitHub is unreachable', function (): void {
// A hard zero would render "0 downloads", which is worse than the download
// size the table falls back to.
Http::fake(['*' => Http::response([], 500)]);

get(route('landing.home'))
->assertOk()
->assertInertia(
fn($page) => $page->component('Home')->where('downloads.total', null),
);
});
Loading