diff --git a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php index af9a949..a41e1af 100644 --- a/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php +++ b/app/Domains/Package/Actions/BuildPackageDownloadStatsAction.php @@ -4,7 +4,7 @@ use App\Domains\Organization\Contracts\Data\DailyDownloadData; use App\Domains\Package\Contracts\Data\PackageDownloadStatsData; -use App\Domains\Package\Contracts\Data\VersionDownloadData; +use App\Domains\Package\Contracts\Data\VersionDailyDownloadData; use App\Models\Package; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -31,22 +31,68 @@ 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, ); } + + /** + * @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'; + $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]))); + 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..945a10e 100644 --- a/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php +++ b/app/Domains/Package/Contracts/Data/PackageDownloadStatsData.php @@ -5,17 +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 new file mode 100644 index 0000000..26dfdee --- /dev/null +++ b/app/Domains/Package/Contracts/Data/VersionDailyDownloadData.php @@ -0,0 +1,21 @@ + $dailyDownloads + */ + 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 @@ -repository?->repo_identifier )); - $composerRepositoryUrl = url("/{$organization->slug}/packages.json"); + $composerRepositoryUrl = url("/{$organization->slug}"); $versionUuid = $request->query('version'); $activeVersion = null; 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 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/components/stats/version-downloads.tsx b/resources/js/components/stats/version-downloads.tsx deleted file mode 100644 index 95b98ab..0000000 --- a/resources/js/components/stats/version-downloads.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from '@/components/ui/chart'; -import { Bar, BarChart, XAxis, YAxis } from 'recharts'; - -type VersionDownloadData = - App.Domains.Package.Contracts.Data.VersionDownloadData; - -interface VersionDownloadsProps { - data: VersionDownloadData[]; -} - -const chartConfig: ChartConfig = { - downloads: { - label: 'Downloads', - color: 'var(--chart-1)', - }, -}; - -export function VersionDownloads({ data }: VersionDownloadsProps) { - if (data.length === 0) { - return ( - - - Downloads by Version - - -
- No version data available -
-
-
- ); - } - - const chartHeight = Math.max(200, data.length * 36); - - return ( - - - Downloads by Version - - - - - - - - } - /> - - - - - - ); -} 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; +dailyDownloads: Array; +versionDailyDownloads: Array; }; export type PackageVersionData = { uuid: string; @@ -142,9 +142,9 @@ keywords: Array | null; isStable: boolean; isDev: boolean; }; -export type VersionDownloadData = { +export type VersionDailyDownloadData = { 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) ); });