From 9c372cd57aa23a570245e4f048b31311f210a97a Mon Sep 17 00:00:00 2001 From: Maarten Bode Date: Tue, 10 Mar 2026 20:04:19 +0100 Subject: [PATCH 1/5] feat(ui): group package download chart by version --- .../BuildPackageDownloadStatsAction.php | 59 ++++++ .../Data/PackageDownloadStatsData.php | 2 + .../Data/VersionDailyDownloadData.php | 19 ++ .../stats/version-download-chart.tsx | 169 ++++++++++++++++++ .../js/pages/organizations/packages/show.tsx | 44 ++--- resources/types/generated.d.ts | 5 + 6 files changed, 268 insertions(+), 30 deletions(-) create mode 100644 app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php create mode 100644 resources/js/components/stats/version-download-chart.tsx diff --git a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php index af9a949..fd6b58a 100644 --- a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php +++ b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php @@ -4,6 +4,7 @@ use App\Domains\Organization\Contracts\Data\DailyDownloadData; use App\Domains\Package\Contracts\Data\PackageDownloadStatsData; +use App\Domains\Package\Contracts\Data\VersionDailyDownloadData; use App\Domains\Package\Contracts\Data\VersionDownloadData; use App\Models\Package; use Illuminate\Support\Carbon; @@ -43,10 +44,68 @@ public function handle(Package $package): PackageDownloadStatsData )) ->all(); + $versionDailyDownloads = $this->buildVersionDailyDownloads($package, $startDate); + return new PackageDownloadStatsData( totalDownloads: $package->downloads()->count(), dailyDownloads: $dailyDownloads, versionBreakdown: $versionBreakdown, + versionDailyDownloads: $versionDailyDownloads, ); } + + /** + * @return array + */ + private function buildVersionDailyDownloads(Package $package, Carbon $startDate): array + { + $rows = $package->downloads() + ->where('downloaded_at', '>=', $startDate) + ->select( + DB::raw('DATE(downloaded_at) as date'), + 'version', + DB::raw('COUNT(*) as downloads'), + ) + ->groupBy('date', 'version') + ->get(); + + $topVersions = $rows + ->groupBy('version') + ->map(fn ($versionRows) => $versionRows->sum('downloads')) + ->sortDesc() + ->take(5) + ->keys() + ->all(); + + $hasOther = $rows->contains(fn ($row) => ! in_array($row->version, $topVersions)); + + $versionDailyMap = []; + foreach ($rows as $row) { + $version = in_array($row->version, $topVersions) ? $row->version : 'Other'; + $versionDailyMap[$version][$row->date] = ($versionDailyMap[$version][$row->date] ?? 0) + (int) $row->downloads; + } + + $orderedVersions = array_values(array_filter($topVersions, fn ($v) => isset($versionDailyMap[$v]))); + if ($hasOther) { + $orderedVersions[] = 'Other'; + } + + $versionDailyDownloads = []; + foreach ($orderedVersions as $version) { + $dailyData = []; + for ($i = 29; $i >= 0; $i--) { + $date = Carbon::now()->subDays($i)->format('Y-m-d'); + $dailyData[] = new DailyDownloadData( + date: $date, + downloads: $versionDailyMap[$version][$date] ?? 0, + ); + } + $versionDailyDownloads[] = new VersionDailyDownloadData( + version: $version, + dailyDownloads: $dailyData, + ); + } + + return $versionDailyDownloads; + } } diff --git a/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php b/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php index f4e0078..c0526fd 100644 --- a/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php +++ b/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php @@ -12,10 +12,12 @@ class PackageDownloadStatsData extends Data /** * @param array $dailyDownloads * @param array $versionBreakdown + * @param array $versionDailyDownloads */ public function __construct( public int $totalDownloads, public array $dailyDownloads, public array $versionBreakdown, + public array $versionDailyDownloads, ) {} } diff --git a/app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php b/app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php new file mode 100644 index 0000000..91e75d5 --- /dev/null +++ b/app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php @@ -0,0 +1,19 @@ + $dailyDownloads + */ + public function __construct( + public string $version, + public array $dailyDownloads, + ) {} +} diff --git a/resources/js/components/stats/version-download-chart.tsx b/resources/js/components/stats/version-download-chart.tsx new file mode 100644 index 0000000..611de47 --- /dev/null +++ b/resources/js/components/stats/version-download-chart.tsx @@ -0,0 +1,169 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart'; +import { cn } from '@/lib/utils'; +import { DateTime } from 'luxon'; +import { useMemo } from 'react'; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; + +type VersionDailyDownloadData = + App.Domains.Package.Contracts.Data.VersionDailyDownloadData; +type DailyDownloadData = + App.Domains.Organization.Contracts.Data.DailyDownloadData; + +interface VersionDownloadChartProps { + title: string; + versionData: VersionDailyDownloadData[]; + fallbackData: DailyDownloadData[]; + compact?: boolean; + className?: string; +} + +const CHART_COLORS = [ + 'var(--chart-1)', + 'var(--chart-2)', + 'var(--chart-3)', + 'var(--chart-4)', + 'var(--chart-5)', + 'var(--muted-foreground)', +] as const; + +export function VersionDownloadChart({ + title, + versionData, + fallbackData, + compact = false, + className, +}: VersionDownloadChartProps) { + const versions = versionData.map((v) => v.version); + + const { chartData, chartConfig, maxDownloads } = useMemo(() => { + if (versions.length === 0) { + return { + chartData: [], + chartConfig: {} as ChartConfig, + maxDownloads: 0, + }; + } + + const config: ChartConfig = {}; + versions.forEach((version, index) => { + config[version] = { + label: version, + color: CHART_COLORS[index % CHART_COLORS.length], + }; + }); + + const dateCount = versionData[0].dailyDownloads.length; + const data: Record[] = []; + + for (let i = 0; i < dateCount; i++) { + const entry: Record = { + date: versionData[0].dailyDownloads[i].date, + }; + let dayTotal = 0; + for (const vd of versionData) { + entry[vd.version] = vd.dailyDownloads[i].downloads; + dayTotal += vd.dailyDownloads[i].downloads; + } + entry._total = dayTotal; + data.push(entry); + } + + const max = Math.max(...data.map((d) => d._total as number)); + + return { chartData: data, chartConfig: config, maxDownloads: max }; + }, [versionData, versions]); + + const hasDownloads = + versions.length > 0 + ? chartData.some((d) => (d._total as number) > 0) + : fallbackData.some((d) => d.downloads > 0); + + const yAxisWidth = + maxDownloads >= 10000 ? 55 : maxDownloads >= 1000 ? 48 : 40; + + return ( + + + {title} + + + {!hasDownloads ? ( +
+ No downloads yet +
+ ) : ( + + + + + DateTime.fromISO(value).toFormat('LLL d') + } + interval={compact ? 4 : 2} + className="text-xs" + /> + + + DateTime.fromISO(label).toFormat( + 'DDD', + ) + } + /> + } + /> + {versions.map((version, index) => ( + + ))} + + + )} +
+
+ ); +} diff --git a/resources/js/pages/organizations/packages/show.tsx b/resources/js/pages/organizations/packages/show.tsx index 9a84625..7a829a4 100644 --- a/resources/js/pages/organizations/packages/show.tsx +++ b/resources/js/pages/organizations/packages/show.tsx @@ -1,7 +1,6 @@ import { show } from '@/actions/App/Domains/Repository/Http/Controllers/RepositoryController'; import HeadingSmall from '@/components/heading-small'; -import { DownloadChart } from '@/components/stats/download-chart'; -import { VersionDownloads } from '@/components/stats/version-downloads'; +import { VersionDownloadChart } from '@/components/stats/version-download-chart'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -316,14 +315,7 @@ export default function PackageShow({ }, ]; - const composerConfig = `{ - "repositories": [ - { - "type": "composer", - "url": "${composerRepositoryUrl}" - } - ] -}`; + const composerRepoCommand = `composer config repositories.${organization.slug} composer ${composerRepositoryUrl}`; return ( @@ -401,31 +393,23 @@ export default function PackageShow({

- Add this repository to your{' '} - - composer.json - {' '} - to install packages from this organization: + Add this repository to your project to install + packages from this organization:

-
-
-                                {composerConfig}
-                            
-
- -
+
+ + {composerRepoCommand} + +
-
- - -
+
; versionBreakdown: Array; +versionDailyDownloads: Array; }; export type PackageVersionData = { uuid: string; @@ -142,6 +143,10 @@ keywords: Array | null; isStable: boolean; isDev: boolean; }; +export type VersionDailyDownloadData = { +version: string; +dailyDownloads: Array; +}; export type VersionDownloadData = { version: string; downloads: number; From ff93c53153b5f9bb9e628d532168724eb79e4684 Mon Sep 17 00:00:00 2001 From: Maarten Bode Date: Tue, 10 Mar 2026 20:04:43 +0100 Subject: [PATCH 2/5] feat(ui): use composer config command instead of JSON snippet --- app/Domains/Package/Http/Controllers/PackageController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Domains/Package/Http/Controllers/PackageController.php b/app/Domains/Package/Http/Controllers/PackageController.php index 9f53687..c8e0873 100644 --- a/app/Domains/Package/Http/Controllers/PackageController.php +++ b/app/Domains/Package/Http/Controllers/PackageController.php @@ -66,7 +66,7 @@ public function show(Request $request, Organization $organization, Package $pack $package->repository?->repo_identifier )); - $composerRepositoryUrl = url("/{$organization->slug}/packages.json"); + $composerRepositoryUrl = url("/{$organization->slug}"); $versionUuid = $request->query('version'); $activeVersion = null; From a029e546d40088ab6db6f0640ac1b7cb3e925c34 Mon Sep 17 00:00:00 2001 From: Maarten Bode Date: Tue, 10 Mar 2026 20:05:50 +0100 Subject: [PATCH 3/5] docs: use composer config command for repository setup --- docs/api/index.md | 13 +++---------- docs/guide/packages.md | 11 ++--------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 49ace8e..1e98c18 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -162,17 +162,10 @@ Requires webhook token verification via the `X-Gitlab-Token` header (plain text ## Configuring Composer -Add Pricore as a repository in your project's `composer.json`: +Add Pricore as a Composer repository: -```json -{ - "repositories": [ - { - "type": "composer", - "url": "https://pricore.yourcompany.com/your-org" - } - ] -} +```bash +composer config repositories.your-org composer https://pricore.yourcompany.com/your-org ``` Then authenticate: diff --git a/docs/guide/packages.md b/docs/guide/packages.md index f52be4c..15671cc 100644 --- a/docs/guide/packages.md +++ b/docs/guide/packages.md @@ -62,15 +62,8 @@ Versions are synced automatically when: Add your Pricore organization as a Composer repository: -```json -{ - "repositories": [ - { - "type": "composer", - "url": "https://pricore.yourcompany.com/org/your-organization" - } - ] -} +```bash +composer config repositories.your-organization composer https://pricore.yourcompany.com/your-organization ``` ### 2. Authenticate From 02da477ac26cb41753054e08b1ba82771ab957e8 Mon Sep 17 00:00:00 2001 From: Maarten Bode Date: Tue, 10 Mar 2026 20:18:04 +0100 Subject: [PATCH 4/5] refactor(ui): remove redundant version breakdown chart and DTO --- .../BuildPackageDownloadStatsAction.php | 14 --- .../Data/PackageDownloadStatsData.php | 5 +- .../Data/VersionDailyDownloadData.php | 2 + .../Contracts/Data/VersionDownloadData.php | 15 ---- .../js/components/stats/version-downloads.tsx | 86 ------------------- resources/types/generated.d.ts | 11 +-- tests/Feature/PackageDownloadStatsTest.php | 14 ++- 7 files changed, 14 insertions(+), 133 deletions(-) delete mode 100644 app/Domains/Package/Contracts/Data/VersionDownloadData.php delete mode 100644 resources/js/components/stats/version-downloads.tsx diff --git a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php index fd6b58a..822388f 100644 --- a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php +++ b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php @@ -5,7 +5,6 @@ use App\Domains\Organization\Contracts\Data\DailyDownloadData; use App\Domains\Package\Contracts\Data\PackageDownloadStatsData; use App\Domains\Package\Contracts\Data\VersionDailyDownloadData; -use App\Domains\Package\Contracts\Data\VersionDownloadData; use App\Models\Package; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -32,24 +31,11 @@ public function handle(Package $package): PackageDownloadStatsData ); } - $versionBreakdown = $package->downloads() - ->select('version', DB::raw('COUNT(*) as downloads')) - ->groupBy('version') - ->orderByDesc('downloads') - ->limit(10) - ->get() - ->map(fn ($row) => new VersionDownloadData( - version: $row->version, - downloads: (int) $row->getAttribute('downloads'), - )) - ->all(); - $versionDailyDownloads = $this->buildVersionDailyDownloads($package, $startDate); return new PackageDownloadStatsData( totalDownloads: $package->downloads()->count(), dailyDownloads: $dailyDownloads, - versionBreakdown: $versionBreakdown, versionDailyDownloads: $versionDailyDownloads, ); } diff --git a/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php b/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php index c0526fd..945a10e 100644 --- a/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php +++ b/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php @@ -5,19 +5,20 @@ use App\Domains\Organization\Contracts\Data\DailyDownloadData; use Spatie\LaravelData\Data; use Spatie\TypeScriptTransformer\Attributes\TypeScript; +use Spatie\TypeScriptTransformer\Attributes\TypeScriptType; #[TypeScript] class PackageDownloadStatsData extends Data { /** * @param array $dailyDownloads - * @param array $versionBreakdown * @param array $versionDailyDownloads */ public function __construct( public int $totalDownloads, + #[TypeScriptType('array<'.DailyDownloadData::class.'>')] public array $dailyDownloads, - public array $versionBreakdown, + #[TypeScriptType('array<'.VersionDailyDownloadData::class.'>')] public array $versionDailyDownloads, ) {} } diff --git a/app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php b/app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php index 91e75d5..26dfdee 100644 --- a/app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php +++ b/app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php @@ -5,6 +5,7 @@ use App\Domains\Organization\Contracts\Data\DailyDownloadData; use Spatie\LaravelData\Data; use Spatie\TypeScriptTransformer\Attributes\TypeScript; +use Spatie\TypeScriptTransformer\Attributes\TypeScriptType; #[TypeScript] class VersionDailyDownloadData extends Data @@ -14,6 +15,7 @@ class VersionDailyDownloadData extends Data */ public function __construct( public string $version, + #[TypeScriptType('array<'.DailyDownloadData::class.'>')] public array $dailyDownloads, ) {} } diff --git a/app/Domains/Package/Contracts/Data/VersionDownloadData.php b/app/Domains/Package/Contracts/Data/VersionDownloadData.php deleted file mode 100644 index a0674aa..0000000 --- a/app/Domains/Package/Contracts/Data/VersionDownloadData.php +++ /dev/null @@ -1,15 +0,0 @@ - - - Downloads by Version - - -
- No version data available -
-
- - ); - } - - const chartHeight = Math.max(200, data.length * 36); - - return ( - - - Downloads by Version - - - - - - - - } - /> - - - - - - ); -} diff --git a/resources/types/generated.d.ts b/resources/types/generated.d.ts index 77b6e8a..71db3f3 100644 --- a/resources/types/generated.d.ts +++ b/resources/types/generated.d.ts @@ -111,9 +111,8 @@ repositoryUuid: string | null; }; export type PackageDownloadStatsData = { totalDownloads: number; -dailyDownloads: Array; -versionBreakdown: Array; -versionDailyDownloads: Array; +dailyDownloads: Array; +versionDailyDownloads: Array; }; export type PackageVersionData = { uuid: string; @@ -145,11 +144,7 @@ isDev: boolean; }; export type VersionDailyDownloadData = { version: string; -dailyDownloads: Array; -}; -export type VersionDownloadData = { -version: string; -downloads: number; +dailyDownloads: Array; }; } declare namespace App.Domains.Repository.Contracts.Data { diff --git a/tests/Feature/PackageDownloadStatsTest.php b/tests/Feature/PackageDownloadStatsTest.php index e017352..d7ec00d 100644 --- a/tests/Feature/PackageDownloadStatsTest.php +++ b/tests/Feature/PackageDownloadStatsTest.php @@ -32,7 +32,7 @@ ->has('downloadStats') ->has('downloadStats.totalDownloads') ->has('downloadStats.dailyDownloads') - ->has('downloadStats.versionBreakdown') + ->has('downloadStats.versionDailyDownloads') ); }); @@ -51,7 +51,7 @@ ); }); - it('groups downloads by version', function () { + it('groups downloads by version in daily data', function () { PackageDownload::factory() ->count(5) ->forOrganization($this->organization) @@ -68,11 +68,9 @@ ->get("/organizations/{$this->organization->slug}/packages/{$this->package->uuid}"); $response->assertInertia(fn ($page) => $page - ->has('downloadStats.versionBreakdown', 2) - ->where('downloadStats.versionBreakdown.0.version', '1.0.0') - ->where('downloadStats.versionBreakdown.0.downloads', 5) - ->where('downloadStats.versionBreakdown.1.version', '2.0.0') - ->where('downloadStats.versionBreakdown.1.downloads', 3) + ->has('downloadStats.versionDailyDownloads', 2) + ->where('downloadStats.versionDailyDownloads.0.version', '1.0.0') + ->where('downloadStats.versionDailyDownloads.1.version', '2.0.0') ); }); @@ -92,7 +90,7 @@ $response->assertInertia(fn ($page) => $page ->where('downloadStats.totalDownloads', 0) ->has('downloadStats.dailyDownloads', 30) - ->has('downloadStats.versionBreakdown', 0) + ->has('downloadStats.versionDailyDownloads', 0) ); }); From 2223e0b243d24cf8c62f11e26567792203b496f5 Mon Sep 17 00:00:00 2001 From: Maarten Bode Date: Tue, 10 Mar 2026 20:19:54 +0100 Subject: [PATCH 5/5] fix: resolve PHPStan errors for computed query columns --- .../Package/Actions/BuildPackageDownloadStatsAction.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php index 822388f..a41e1af 100644 --- a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php +++ b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php @@ -68,7 +68,8 @@ private function buildVersionDailyDownloads(Package $package, Carbon $startDate) $versionDailyMap = []; foreach ($rows as $row) { $version = in_array($row->version, $topVersions) ? $row->version : 'Other'; - $versionDailyMap[$version][$row->date] = ($versionDailyMap[$version][$row->date] ?? 0) + (int) $row->downloads; + $date = (string) $row->getAttribute('date'); + $versionDailyMap[$version][$date] = ($versionDailyMap[$version][$date] ?? 0) + (int) $row->getAttribute('downloads'); } $orderedVersions = array_values(array_filter($topVersions, fn ($v) => isset($versionDailyMap[$v])));