@@ -95,7 +110,11 @@ const sortedEntries = computed(() => {
v-for="p in periods"
:key="p.value"
class="top-relative__period-btn"
- :class="currentPeriod === p.value ? 'top-relative__period-btn--active' : 'top-relative__period-btn--inactive'"
+ :class="
+ currentPeriod === p.value
+ ? 'top-relative__period-btn--active'
+ : 'top-relative__period-btn--inactive'
+ "
@click="setPeriod(p.value)"
>
{{ p.label }}
@@ -104,7 +123,10 @@ const sortedEntries = computed(() => {
@@ -117,7 +139,14 @@ const sortedEntries = computed(() => {
@click="handleSort('rank')"
>
#
-
{{ sortIndicator('rank') }}
+
{{ sortIndicator('rank') }}
{
@click="handleSort('name')"
>
Plugin
- {{ sortIndicator('name') }}
+ {{ sortIndicator('name') }}
|
{
@click="handleSort('pct_growth')"
>
% Change
- {{ sortIndicator('pct_growth') }}
+ {{ sortIndicator('pct_growth') }}
|
{
@click="handleSort('absolute_growth')"
>
Installs
- {{ sortIndicator('absolute_growth') }}
+ {{ sortIndicator('absolute_growth') }}
|
|
@@ -151,9 +201,15 @@ const sortedEntries = computed(() => {
v-for="(entry, index) in sortedEntries"
:key="entry.plugin.id"
class="top-relative__row"
- :class="index % 2 === 0 ? 'top-relative__row--even' : 'top-relative__row--odd'"
+ :class="
+ index % 2 === 0
+ ? 'top-relative__row--even'
+ : 'top-relative__row--odd'
+ "
>
-
{{ entry.rank }} |
+
+ {{ entry.rank }}
+ |
|
- {{ hasWindowBaseline(entry) ? (entry.pct_growth >= 0 ? '+' : '') + entry.pct_growth.toFixed(1) + '%' : '—' }}
+ {{
+ hasWindowBaseline(entry)
+ ? (entry.pct_growth >= 0 ? '+' : '') +
+ entry.pct_growth.toFixed(1) +
+ '%'
+ : '—'
+ }}
|
-
+ |
- {{ formatNumber(entry.plugin.current_installs) }}
- +{{ formatNumber(entry.absolute_growth) }}
+ {{
+ formatNumber(entry.plugin.current_installs)
+ }}
+ +{{
+ formatNumber(entry.absolute_growth)
+ }}
|
-
- Stats
+ |
+ Stats
|
@@ -212,7 +296,7 @@ const sortedEntries = computed(() => {
/* ── Period selector ── */
.top-relative__periods {
- @apply flex flex-shrink-0 flex-wrap gap-1.5 sm:gap-2 sm:justify-end;
+ @apply flex shrink-0 flex-wrap gap-1.5 sm:justify-end sm:gap-2;
}
.top-relative__period-btn {
@@ -276,7 +360,7 @@ const sortedEntries = computed(() => {
}
.top-relative__head-cell {
- @apply px-2 py-2 text-xs font-medium uppercase tracking-wider text-gray-500 sm:px-4 sm:py-3;
+ @apply px-2 py-2 text-xs font-medium tracking-wider text-gray-500 uppercase sm:px-4 sm:py-3;
}
.top-relative__head-cell--rank {
@@ -332,7 +416,7 @@ const sortedEntries = computed(() => {
}
.top-relative__cell--rank {
- @apply w-8 text-right text-sm font-medium tabular-nums text-gray-400 sm:w-12;
+ @apply w-8 text-right text-sm font-medium text-gray-400 tabular-nums sm:w-12;
padding-right: 0;
}
@@ -346,7 +430,7 @@ const sortedEntries = computed(() => {
}
.top-relative__installs-total {
- @apply text-sm font-bold tabular-nums text-gray-200;
+ @apply text-sm font-bold text-gray-200 tabular-nums;
}
.top-relative__installs-gain {
@@ -355,7 +439,7 @@ const sortedEntries = computed(() => {
}
.top-relative__cell--pct {
- @apply text-right tabular-nums font-semibold;
+ @apply text-right font-semibold tabular-nums;
color: #86efac;
width: 1%;
white-space: nowrap;
@@ -370,7 +454,7 @@ const sortedEntries = computed(() => {
/* ── Plugin name + author ── */
.top-relative__plugin {
- @apply flex flex-col gap-0 min-w-0;
+ @apply flex min-w-0 flex-col gap-0;
}
.top-relative__plugin-name {
@@ -378,7 +462,7 @@ const sortedEntries = computed(() => {
}
.top-relative__plugin-author {
- @apply truncate text-xs text-gray-400;
+ @apply truncate text-xs text-gray-300;
}
/* ── Stats link ── */
diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts
index 79ece52..9db3a03 100644
--- a/resources/js/types/index.ts
+++ b/resources/js/types/index.ts
@@ -45,3 +45,11 @@ export interface Top100Metrics {
computed_at: string;
rankings: RankedPlugin[];
}
+
+export interface SharedInertiaProps {
+ errors: Record
;
+ name: string;
+ apiUrl: string;
+ appUrl: string;
+ plugins: Plugin[];
+}
diff --git a/resources/js/utils/formatting.ts b/resources/js/utils/formatting.ts
index 7b68bb1..72d9388 100644
--- a/resources/js/utils/formatting.ts
+++ b/resources/js/utils/formatting.ts
@@ -6,15 +6,28 @@ export function formatDate(dateString: string): string {
month: 'long',
day: 'numeric',
};
- return new Intl.DateTimeFormat('en-US', options).format(new Date(dateString));
+ return new Intl.DateTimeFormat('en-US', options).format(
+ new Date(dateString),
+ );
}
-export function formatChartDate(dateString: string, includeTime: boolean): string {
+export function formatChartDate(
+ dateString: string,
+ includeTime: boolean,
+): string {
const date = new Date(dateString);
if (includeTime) {
- return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).format(date);
+ return new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ }).format(date);
}
- return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
+ return new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ }).format(date);
}
export function formatNumber(num: number): string {
@@ -37,7 +50,13 @@ export function scoreSearchResult(plugin: Plugin, query: string): number {
let score = 0;
const wordsInFields = new Set();
- const fieldsToSearch = [plugin.name, plugin.display, plugin.author, plugin.description, plugin.tags];
+ const fieldsToSearch = [
+ plugin.name,
+ plugin.display,
+ plugin.author,
+ plugin.description,
+ plugin.tags,
+ ];
fieldsToSearch.forEach((field) => {
if (field) {
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
index 77932df..693f8ad 100644
--- a/tests/Feature/ExampleTest.php
+++ b/tests/Feature/ExampleTest.php
@@ -2,7 +2,9 @@
namespace Tests\Feature;
+use App\Services\RuneliteApiService;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Mockery;
use Tests\TestCase;
class ExampleTest extends TestCase
@@ -11,7 +13,14 @@ class ExampleTest extends TestCase
public function test_returns_a_successful_response()
{
- $response = $this->get(route('home'));
+ $mock = Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $response = $this->get(route('home'), [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ]);
$response->assertOk();
}
diff --git a/tests/Feature/PublicPagesTest.php b/tests/Feature/PublicPagesTest.php
new file mode 100644
index 0000000..1a793cb
--- /dev/null
+++ b/tests/Feature/PublicPagesTest.php
@@ -0,0 +1,263 @@
+ 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ];
+}
+
+it('renders the home page with plugin data', function () {
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([
+ [
+ 'id' => 1,
+ 'name' => 'gpu',
+ 'display' => 'GPU',
+ 'author' => 'RuneLite',
+ 'description' => 'GPU plugin',
+ 'tags' => 'graphics',
+ 'current_installs' => 100,
+ 'all_time_high' => 120,
+ 'updated_on' => '2026-04-19 00:00:00',
+ 'created_on' => '2025-01-01 00:00:00',
+ ],
+ ]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $response = $this->get(route('home'), inertiaHeaders());
+
+ $response->assertSuccessful();
+ $response->assertJsonPath('component', 'Index');
+ $response->assertJsonPath('props.plugins.0.name', 'gpu');
+});
+
+it('exposes shared inertia props needed by the app shell', function () {
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $response = $this->get(route('home'), inertiaHeaders());
+
+ $response->assertSuccessful()->assertJsonPath('component', 'Index');
+
+ $payload = $response->json();
+
+ expect(data_get($payload, 'props.name'))->toBeString()->not->toBeEmpty()
+ ->and(data_get($payload, 'props.apiUrl'))->toBeString()->toContain('http')
+ ->and(data_get($payload, 'props.appUrl'))->toBeString()->toContain('http');
+});
+
+it('renders plugin detail page and returns 404 for unknown plugin', function () {
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getPlugin')->with('gpu', \Mockery::type('array'))->once()->andReturn([
+ 'id' => 1,
+ 'name' => 'gpu',
+ 'display' => 'GPU',
+ 'author' => 'RuneLite',
+ 'description' => 'GPU plugin',
+ 'tags' => 'graphics',
+ 'current_installs' => 100,
+ 'all_time_high' => 120,
+ 'updated_on' => '2026-04-19 00:00:00',
+ 'created_on' => '2025-01-01 00:00:00',
+ ]);
+ $mock->shouldReceive('getPlugin')->with('missing', \Mockery::type('array'))->once()->andReturn(null);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $this->get(route('plugin.show', ['name' => 'gpu']), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'PluginDetail')
+ ->assertJsonPath('props.plugin.name', 'gpu');
+
+ $this->get(route('plugin.show', ['name' => 'missing']))
+ ->assertNotFound();
+});
+
+it('renders top metrics pages', function () {
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getTopHundred')->once()->andReturn([
+ 'rankings' => [
+ [
+ 'rank' => 1,
+ 'plugin' => [
+ 'id' => 1,
+ 'name' => 'gpu',
+ 'display' => 'GPU',
+ 'author' => 'RuneLite',
+ 'current_installs' => 100,
+ ],
+ ],
+ ],
+ ]);
+ $mock->shouldReceive('getTopAbsolute')->with('30d')->once()->andReturn([]);
+ $mock->shouldReceive('getTopRelative')->with('30d')->once()->andReturn([]);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $this->get(route('top'), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'Top100')
+ ->assertJsonPath('props.metrics.rankings.0.rank', 1)
+ ->assertJsonPath('props.metrics.rankings.0.plugin.name', 'gpu');
+
+ $this->get(route('top.absolute'), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'TopAbsolute');
+
+ $this->get(route('top.relative'), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'TopRelative');
+});
+
+it('renders absolute and relative pages with entry rows for ui tables', function () {
+ $entries = [
+ [
+ 'plugin' => [
+ 'name' => 'gpu',
+ 'display' => 'GPU',
+ ],
+ 'delta' => 123,
+ 'current_installs' => 1000,
+ ],
+ ];
+
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getTopAbsolute')->with('30d')->once()->andReturn($entries);
+ $mock->shouldReceive('getTopRelative')->with('30d')->once()->andReturn($entries);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $this->get(route('top.absolute'), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'TopAbsolute')
+ ->assertJsonPath('props.entries.0.plugin.name', 'gpu')
+ ->assertJsonPath('props.entries.0.delta', 123);
+
+ $this->get(route('top.relative'), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'TopRelative')
+ ->assertJsonPath('props.entries.0.plugin.name', 'gpu')
+ ->assertJsonPath('props.entries.0.delta', 123);
+});
+
+it('redirects random route to plugin detail', function () {
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getRandomPlugin')->once()->andReturn([
+ 'name' => 'gpu',
+ ]);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $this->get(route('plugin.random'))
+ ->assertRedirect(route('plugin.show', ['name' => 'gpu']));
+});
+
+it('renders sitemap xml and uses cached value when present', function () {
+ Cache::put('sitemap.xml', 'https://example.test/gpu', now()->addMinute());
+
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $this->get(route('sitemap'))
+ ->assertSuccessful()
+ ->assertHeader('Content-Type', 'application/xml')
+ ->assertSee('https://example.test/gpu', false);
+});
+
+it('generates sitemap xml when cache is missing', function () {
+ Cache::forget('sitemap.xml');
+
+ $plugins = [
+ [
+ 'name' => 'gpu',
+ 'updated_on' => '2026-04-19 00:00:00',
+ ],
+ ];
+
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn($plugins);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $response = $this->get(route('sitemap'));
+
+ $response->assertSuccessful()
+ ->assertHeader('Content-Type', 'application/xml')
+ ->assertSee(route('plugin.show', ['name' => 'gpu']), false);
+
+ expect(Cache::get('sitemap.xml'))->toBeString();
+});
+
+it('returns 404 for missing og image plugin', function () {
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getPlugin')->with('missing-plugin', \Mockery::type('array'))->once()->andReturn(null);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $this->get(route('og.image', ['name' => 'missing-plugin']))
+ ->assertNotFound();
+});
+
+it('passes range query to plugin detail endpoint', function () {
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getPlugin')->with('gpu', ['range' => '7d'])->once()->andReturn([
+ 'id' => 1,
+ 'name' => 'gpu',
+ 'display' => 'GPU',
+ 'author' => 'RuneLite',
+ 'description' => 'GPU plugin',
+ 'tags' => 'graphics',
+ 'current_installs' => 100,
+ 'all_time_high' => 120,
+ 'updated_on' => '2026-04-19 00:00:00',
+ 'created_on' => '2025-01-01 00:00:00',
+ ]);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $this->get(route('plugin.show', ['name' => 'gpu', 'range' => '7d']), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'PluginDetail')
+ ->assertJsonPath('props.plugin.name', 'gpu');
+});
+
+it('passes selected period to absolute and relative metrics pages', function (string $period) {
+ $mock = \Mockery::mock(RuneliteApiService::class);
+ $mock->shouldReceive('getTopAbsolute')->with($period)->once()->andReturn([]);
+ $mock->shouldReceive('getTopRelative')->with($period)->once()->andReturn([]);
+ $mock->shouldReceive('getPlugins')->atLeast()->once()->andReturn([]);
+
+ app()->instance(RuneliteApiService::class, $mock);
+
+ $this->get(route('top.absolute', ['period' => $period]), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'TopAbsolute')
+ ->assertJsonPath('props.period', $period);
+
+ $this->get(route('top.relative', ['period' => $period]), inertiaHeaders())
+ ->assertSuccessful()
+ ->assertJsonPath('component', 'TopRelative')
+ ->assertJsonPath('props.period', $period);
+})->with([
+ '1 day' => '24h',
+ '7 days' => '7d',
+ '30 days' => '30d',
+ '6 months' => '6m',
+ '1 year' => '1y',
+]);
diff --git a/tests/Pest.php b/tests/Pest.php
index e69de29..4809776 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -0,0 +1,5 @@
+extend(TestCase::class)->in('Feature', 'Unit');