Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@ TWITTER_ACCESS_SECRET=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHANNEL=

FATHOM_SITE_ID=
FATHOM_TOKEN=

FLARE_KEY=
VITE_FLARE_KEY="${FLARE_KEY}"
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ TELEGRAM_BOT_TOKEN=
TELEGRAM_CHANNEL=
```

### Fathom Analytics (optional)

To enable view counts on articles, you'll need to register a [Fathom Analytics](https://app.usefathom.com/register) account and [install](https://usefathom.com/docs/start/install) it on the site. You will then need to create an API token and find your site ID before updating the below environment variables in your `.env` file.

```
FATHOM_SITE_ID=
FATHOM_TOKEN=
```

## Commands

Command | Description
Expand Down
2 changes: 1 addition & 1 deletion app/Console/Commands/GenerateSitemap.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

class GenerateSitemap extends Command
{
protected $signature = 'sitemap:generate';
protected $signature = 'lio:generate-sitemap';

protected $description = 'Crawl the site to generate a sitemap.xml file';

Expand Down
2 changes: 1 addition & 1 deletion app/Console/Commands/PostArticleToTwitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

final class PostArticleToTwitter extends Command
{
protected $signature = 'post-article-to-twitter';
protected $signature = 'lio:post-article-to-twitter';

protected $description = 'Posts the latest unshared article to Twitter';

Expand Down
68 changes: 68 additions & 0 deletions app/Console/Commands/UpdateArticleViewCounts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace App\Console\Commands;

use App\Models\Article;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;

final class UpdateArticleViewCounts extends Command
{
protected $signature = 'lio:update-article-view-counts';

protected $description = 'Queries the Fathom Analytics API to update the view counts for all articles';

protected $siteId;

protected $token;

public function __construct()
{
parent::__construct();

$this->siteId = config('services.fathom.site_id');
$this->token = config('services.fathom.token');
}

public function handle()
{
if (! $this->siteId || ! $this->token) {
$this->error('Fathom site ID and token must be configured');

return;
}

Article::published()->chunk(100, function ($articles) {
$articles->each(function ($article) {
$article->update([
'view_count' => $this->getViewCountFor($article),
]);
});
});
}

protected function getViewCountFor(Article $article): ?int
{
$response = Http::withToken($this->token)
->get('https://api.usefathom.com/v1/aggregations', [
'date_from' => '2021-03-01 00:00:00', // Fathom data aggregations not accurate prior to this date.
'field_grouping' => 'pathname',
'entity' => 'pageview',
'aggregates' => 'pageviews,visits,uniques',
'entity_id' => $this->siteId,
'filters' => json_encode([
[
'property' => 'pathname',
'operator' => 'is',
'value' => "/articles/{$article->slug()}",
],
]),
]);

if ($response->failed()) {
return null;
}

return $response->json('0.pageviews');
}
}
5 changes: 3 additions & 2 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ protected function schedule(Schedule $schedule)
$schedule->command('schedule-monitor:sync')->dailyAt('04:56');
$schedule->command('model:prune', ['--model' => MonitoredScheduledTaskLogItem::class])->daily();
$schedule->command('horizon:snapshot')->everyFiveMinutes();
$schedule->command('post-article-to-twitter')->twiceDaily(14, 18);
$schedule->command('sitemap:generate')->daily()->graceTimeInMinutes(25);
$schedule->command('lio:post-article-to-twitter')->twiceDaily(14, 18);
$schedule->command('lio:generate-sitemap')->daily()->graceTimeInMinutes(25);
$schedule->command('lio:update-article-view-counts')->twiceDaily();
}

/**
Expand Down
6 changes: 6 additions & 0 deletions app/Models/Article.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ final class Article extends Model implements Feedable
'slug',
'hero_image',
'is_pinned',
'view_count',
'tweet_id',
'submitted_at',
'approved_at',
Expand Down Expand Up @@ -192,6 +193,11 @@ public function readTime()
return $minutes == 0 ? 1 : $minutes;
}

public function viewCount()
{
return number_format($this->view_count);
}

public function isUpdated(): bool
{
return $this->updated_at->gt($this->created_at);
Expand Down
5 changes: 5 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,9 @@
'channel' => env('TELEGRAM_CHANNEL'),
],

'fathom' => [
'site_id' => env('FATHOM_SITE_ID'),
'token' => env('FATHOM_TOKEN'),
],

];
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up()
{
Schema::table('articles', function (Blueprint $table) {
$table->bigInteger('view_count')->nullable()->after('is_pinned');
});
}
};
2 changes: 2 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,7 @@
<env name="SCOUT_DRIVER" value="null"/>
<env name="TELEGRAM_BOT_TOKEN" value="null"/>
<env name="TELEGRAM_CHANNEL" value="null"/>
<env name="FATHOM_SITE_ID" value="1234"/>
<env name="FATHOM_TOKEN" value="5678"/>
</php>
</phpunit>
10 changes: 8 additions & 2 deletions resources/views/articles/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,20 @@
</a>
</div>

<div class="flex items-center">
<span class="font-mono text-sm mr-6 lg:mt-0">
<div class="flex items-center gap-x-6">
<span class="font-mono lg:mt-0">
{{ $article->createdAt()->format('j M, Y') }}
</span>

<span class="text-sm">
{{ $article->readTime() }} min read
</span>

@unless($article->viewCount() < 10)
<span class="text-sm">
{{ $article->viewCount() }} views
</span>
@endunless
</div>
</div>
</div>
Expand Down
14 changes: 14 additions & 0 deletions tests/Feature/ArticleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,17 @@
->see('My First Article')
->dontSee('My Second Article');
});

test('only articles with ten or more views render a view count', function () {
$article = Article::factory()->create(['title' => 'My First Article', 'slug' => 'my-first-article', 'submitted_at' => now(), 'approved_at' => now(), 'view_count' => 9]);

$this->get("/articles/{$article->slug()}")
->see('My First Article')
->dontSee('9 views');

$article->update(['view_count' => 10]);

$this->get("/articles/{$article->slug()}")
->see('My First Article')
->see('10 views');
});
60 changes: 60 additions & 0 deletions tests/Integration/Commands/UpdateArticleViewCountsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

use App\Console\Commands\UpdateArticleViewCounts;
use App\Models\Article;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

uses(TestCase::class);
uses(DatabaseMigrations::class);

test('article view counts can be updated', function () {
Http::fake(function () {
return Http::response([[
'pageviews' => 1234,
]]);
});

$article = Article::factory()->create([
'title' => 'My First Article',
'slug' => 'my-first-article',
'submitted_at' => now(),
'approved_at' => now(),
]);

(new UpdateArticleViewCounts)->handle();

expect($article->fresh()->view_count)->toBe(1234);
});

test('article view counts are not updated if API call fails', function () {
Http::fake(function () {
return Http::response('Uh oh', 500);
});

$article = Article::factory()->create([
'title' => 'My First Article',
'slug' => 'my-first-article',
'submitted_at' => now(),
'approved_at' => now(),
]);

(new UpdateArticleViewCounts)->handle();

expect($article->fresh()->view_count)->toBeNull();
});

test('view counts are not updated for unpublished articles', function () {
Http::fake();

Article::factory()->create([
'title' => 'My First Article',
'slug' => 'my-first-article',
'submitted_at' => now(),
]);

(new UpdateArticleViewCounts)->handle();

Http::assertNothingSent();
});