diff --git a/app/Http/Controllers/Landing/LandingController.php b/app/Http/Controllers/Landing/LandingController.php index 338336c..ae6c5ce 100644 --- a/app/Http/Controllers/Landing/LandingController.php +++ b/app/Http/Controllers/Landing/LandingController.php @@ -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)), ]); @@ -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}> + * @return array}> */ private function fetchAppReleases(): array { @@ -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(), @@ -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 diff --git a/resources/js/components/landing/spec-strip.tsx b/resources/js/components/landing/spec-strip.tsx index d93d10e..b2d1cfe 100644 --- a/resources/js/components/landing/spec-strip.tsx +++ b/resources/js/components/landing/spec-strip.tsx @@ -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'; @@ -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 }, /* @@ -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', @@ -239,6 +259,29 @@ export default function SpecStrip({ latestRelease }: Props) { + {/* + * 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. + */} + +

+ + #1 on GitHub Trending + {' '} + on 23 March 2026, and Trendshift’s #1 Swift repository of that week. +

+
+ +

What the numbers buy on day two diff --git a/resources/js/pages/Home.tsx b/resources/js/pages/Home.tsx index 21490b5..20447b2 100644 --- a/resources/js/pages/Home.tsx +++ b/resources/js/pages/Home.tsx @@ -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; } @@ -185,6 +192,7 @@ export default function Home({ downloadUrls, githubStars, latestRelease, + downloads, paymentProvider, teamMinSeats, }: Props) { @@ -222,7 +230,7 @@ export default function Home({ * that proves it. */} - + {/* Conviction peaks at the screenshots, so a download follows them. */} diff --git a/tests/Feature/Landing/HomePagePropsTest.php b/tests/Feature/Landing/HomePagePropsTest.php index b685852..7b05546 100644 --- a/tests/Feature/Landing/HomePagePropsTest.php +++ b/tests/Feature/Landing/HomePagePropsTest.php @@ -12,7 +12,7 @@ }); /** - * @param array $releases + * @param array $releases */ function fakeReleases(array $releases): void { @@ -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), ), @@ -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), + ); +});