From a740655d93b13a807abc3443be411eb236e0a499 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 18 Aug 2026 09:44:42 +0700 Subject: [PATCH] feat(landing): show adoption the site could already prove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three things the teardown said were missing. The third, motion, still is — there are no recordings in this repository and I cannot make any. The download count was arriving all along. Every asset in the GitHub releases payload carries `download_count`, the controller mapped past it, and the page had no adoption number while one sat in a response it was already caching for sixty seconds. Reading it costs no request, no key and no database. DMG assets only. Each app release also ships a .zip, which is the feed the installed app updates from, and its count runs close to the DMG's — folding those in would report automatic updates as people choosing to install. The DMG is what the download button serves, so it is the honest one. At the time of writing that is 50,506 across 24 app releases. It replaces the download-size cell rather than adding a seventh. The size was already in the hero fine print and again in the closing call to action, and a third copy bought nothing. When the API is unreachable the cell falls back to the size, so the table never renders a hole — worth having, since GitHub's unauthenticated limit is sixty an hour against a sixty-second cache. The count travels with the number of releases it covers, and the cell says so. One page of releases reaches back only so far and most of them are plugin releases, so this is a floor rather than a lifetime total, and the page should not imply otherwise. For social proof: no testimonial has been invented, and none was found to quote. Hacker News has no TablePro thread — the 456 search hits are Tablerone, Tableplop and table_print. What does exist is third-party and checkable: #1 on GitHub Trending on 23 March 2026, and Trendshift's #1 Swift repository of that week. It renders as one fine-print line, linked so a reader can verify it in a click. A trophy row would be louder than the numbers above it, and the numbers are the argument. --- .../Controllers/Landing/LandingController.php | 37 ++++++++++++- .../js/components/landing/spec-strip.tsx | 47 ++++++++++++++++- resources/js/pages/Home.tsx | 10 +++- tests/Feature/Landing/HomePagePropsTest.php | 52 ++++++++++++++++++- 4 files changed, 140 insertions(+), 6 deletions(-) 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), + ); +});