Show Telegram subscribers on post metrics; render reactions as compact pills

This commit is contained in:
Paulo Castellano 2026-06-14 13:50:12 -03:00
parent fc1d51ca60
commit a5b33f5b0a
3 changed files with 85 additions and 16 deletions

View file

@ -46,19 +46,25 @@ public function getMetrics(SocialAccount $account): array
* Post-level metrics. Reaction counts are pushed by the webhook and stored
* on the post platform's meta (the Bot API offers no post views to bots).
*
* @return array<int, array{label: string, value: int}>
* @return array<int, array{label: string, value: int, kind?: string}>
*/
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$metrics = $account ? $this->getMetrics($account) : [];
$reactions = data_get($postPlatform->meta, 'reactions', []);
if (! is_array($reactions)) {
return [];
if (is_array($reactions)) {
foreach ($reactions as $reaction) {
$metrics[] = [
'label' => (string) data_get($reaction, 'type'),
'value' => (int) data_get($reaction, 'count'),
'kind' => 'reaction',
];
}
}
return array_values(array_map(fn (array $reaction): array => [
'label' => (string) data_get($reaction, 'type'),
'value' => (int) data_get($reaction, 'count'),
], $reactions));
return $metrics;
}
}

View file

@ -8,6 +8,7 @@ import { metrics as metricsRoute } from '@/routes/app/posts/platforms';
interface Metric {
label: string;
value: number;
kind?: string;
}
type MetricsResponse = Metric[] | { unsupported: true; reason: string };
@ -22,10 +23,18 @@ const props = defineProps<Props>();
const loading = ref(true);
const metrics = ref<Metric[]>([]);
const stats = computed(() =>
metrics.value.filter((m) => m.kind !== 'reaction'),
);
const reactions = computed(() =>
metrics.value.filter((m) => m.kind === 'reaction'),
);
const hasMetrics = computed(() => metrics.value.length > 0);
const formatNumber = (n: number): string => {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1_000_000)
return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toString();
};
@ -35,7 +44,10 @@ const http = useHttp<Record<string, never>, MetricsResponse>({});
onMounted(async () => {
try {
const response = await http.get(
metricsRoute.url({ post: props.postId, postPlatform: props.postPlatformId }),
metricsRoute.url({
post: props.postId,
postPlatform: props.postPlatformId,
}),
);
if (Array.isArray(response)) {
@ -61,20 +73,45 @@ onMounted(async () => {
<!-- Loaded with data: full metrics block. -->
<div v-else-if="hasMetrics" class="border-t px-4 py-3">
<div class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<div
class="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase"
>
<IconChartBar class="h-3 w-3" />
{{ $t('posts.show.metrics') }}
</div>
<div class="grid grid-cols-3 gap-2">
<div v-if="stats.length > 0" class="grid grid-cols-3 gap-2">
<div
v-for="metric in metrics"
v-for="metric in stats"
:key="metric.label"
class="rounded-md bg-muted/50 px-2.5 py-1.5"
>
<p class="text-[10px] uppercase tracking-wider text-muted-foreground">{{ metric.label }}</p>
<p class="text-sm font-semibold tabular-nums">{{ formatNumber(metric.value) }}</p>
<p
class="text-[10px] tracking-wider text-muted-foreground uppercase"
>
{{ metric.label }}
</p>
<p class="text-sm font-semibold tabular-nums">
{{ formatNumber(metric.value) }}
</p>
</div>
</div>
<div
v-if="reactions.length > 0"
class="flex flex-wrap gap-1.5"
:class="{ 'mt-2': stats.length > 0 }"
>
<span
v-for="reaction in reactions"
:key="reaction.label"
class="inline-flex items-center gap-1 rounded-full bg-muted/50 px-2 py-1 text-xs"
>
<span class="text-sm leading-none">{{ reaction.label }}</span>
<span class="font-semibold tabular-nums">{{
formatNumber(reaction.value)
}}</span>
</span>
</div>
</div>
<!-- No data / unsupported: render nothing so the card stays clean. -->

View file

@ -33,17 +33,43 @@
expect(app(TelegramAnalytics::class)->getMetrics($account))->toBe([]);
});
it('maps stored reactions to post metrics', function () {
it('maps stored reactions to post metrics, tagged as reactions', function () {
config(['trypost.platforms.telegram.bot_token' => '']); // skip the subscriber lookup
$postPlatform = PostPlatform::factory()->create([
'platform' => Platform::Telegram,
'meta' => ['reactions' => [['type' => '👍', 'count' => 12], ['type' => '❤️', 'count' => 5]]],
]);
expect(app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform))
->toBe([['label' => '👍', 'value' => 12], ['label' => '❤️', 'value' => 5]]);
->toBe([
['label' => '👍', 'value' => 12, 'kind' => 'reaction'],
['label' => '❤️', 'value' => 5, 'kind' => 'reaction'],
]);
});
it('includes the channel subscriber count alongside reactions', function () {
$account = SocialAccount::factory()->telegram()->create();
$postPlatform = PostPlatform::factory()->create([
'social_account_id' => $account->id,
'platform' => Platform::Telegram,
'meta' => ['reactions' => [['type' => '👍', 'count' => 3]]],
]);
Http::fake([
'*/botTESTTOKEN/getChatMemberCount*' => Http::response(['ok' => true, 'result' => 50], 200),
]);
expect(app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform))
->toBe([
['label' => 'Subscribers', 'value' => 50],
['label' => '👍', 'value' => 3, 'kind' => 'reaction'],
]);
});
it('returns no post metrics when there are no reactions yet', function () {
config(['trypost.platforms.telegram.bot_token' => '']);
$postPlatform = PostPlatform::factory()->create([
'platform' => Platform::Telegram,
'meta' => [],