Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,20 @@ <h2 class="text-lg font-semibold text-gray-900" data-testid="email-ctr-drawer-ti
<div class="flex gap-4" data-testid="email-ctr-drawer-stats">
<div class="flex-1 flex flex-col gap-1 p-4 border border-gray-200 rounded-lg">
<span class="text-sm text-gray-500">Current CTR</span>
<span class="text-2xl font-semibold text-gray-900">{{ data().currentCtr.toFixed(2) }}%</span>
<span class="text-2xl font-semibold text-gray-900">{{ drawerData().currentCtr.toFixed(2) }}%</span>
</div>
<div class="flex-1 flex flex-col gap-1 p-4 border border-gray-200 rounded-lg">
<span class="text-sm text-gray-500">Month-over-Month</span>
<div class="flex items-center gap-2">
<span class="text-2xl font-semibold" [class]="data().trend === 'up' ? 'text-green-600' : 'text-red-600'">
{{ data().changePercentage > 0 ? '+' : '' }}{{ data().changePercentage }}%
</span>
<i class="text-sm" [class]="data().trend === 'up' ? 'fa-light fa-arrow-up text-green-600' : 'fa-light fa-arrow-down text-red-600'"></i>
</div>
@if (drawerData().changePercentage !== 0) {
<div class="flex items-center gap-2">
<span class="text-2xl font-semibold" [class]="drawerData().trend === 'up' ? 'text-green-600' : 'text-red-600'">
{{ drawerData().changePercentage > 0 ? '+' : '' }}{{ drawerData().changePercentage }}%
</span>
<i class="text-sm" [class]="drawerData().trend === 'up' ? 'fa-light fa-arrow-up text-green-600' : 'fa-light fa-arrow-down text-red-600'"></i>
</div>
} @else {
<span class="text-2xl font-semibold text-gray-500">0%</span>
}
</div>
</div>

Expand Down Expand Up @@ -108,7 +112,7 @@ <h3 class="flex items-center gap-2 text-sm font-semibold text-gray-900">
<h3 class="text-sm font-semibold text-gray-900">Monthly CTR Trend</h3>
<p class="text-sm text-gray-600">Email click-through rate over the last 6 months</p>
</div>
@if (data().monthlyData.length > 0) {
@if (drawerData().monthlyData.length > 0) {
<div class="h-[240px]" data-testid="email-ctr-drawer-chart">
<lfx-chart type="bar" [data]="chartData()" [options]="chartOptions" height="100%"></lfx-chart>
</div>
Expand All @@ -126,7 +130,7 @@ <h3 class="text-sm font-semibold text-gray-900">Monthly CTR Trend</h3>
<h3 class="text-sm font-semibold text-gray-900">CTR by Campaign</h3>
<p class="text-sm text-gray-600">Average click-through rate per campaign over the last 6 months</p>
</div>
@if (data().campaignGroups.length > 1) {
@if (drawerData().campaignGroups.length > 1) {
<div class="h-[240px]" data-testid="email-ctr-drawer-campaigns-chart">
<lfx-chart type="bar" [data]="campaignChartData()" [options]="campaignChartOptions" height="100%"></lfx-chart>
</div>
Expand All @@ -144,7 +148,7 @@ <h3 class="text-sm font-semibold text-gray-900">CTR by Campaign</h3>
<h3 class="text-sm font-semibold text-gray-900">Campaign Reach vs Opens</h3>
<p class="text-sm text-gray-600">Monthly email sends compared to opens over the last 6 months</p>
</div>
@if (data().monthlySends.length > 0) {
@if (drawerData().monthlySends.length > 0) {
<div class="h-[240px]" data-testid="email-ctr-drawer-reach-chart">
<lfx-chart type="bar" [data]="reachVsOpensChartData()" [options]="reachVsOpensChartOptions" height="100%"></lfx-chart>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import { Component, computed, input, model, Signal } from '@angular/core';
import { Component, computed, inject, model, signal, Signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { ChartComponent } from '@components/chart/chart.component';
import { lfxColors } from '@lfx-one/shared/constants';
import { AnalyticsService } from '@services/analytics.service';
import { ProjectContextService } from '@services/project-context.service';
import { catchError, filter, of, skip, switchMap, tap } from 'rxjs';
import { DrawerModule } from 'primeng/drawer';
import { SkeletonModule } from 'primeng/skeleton';

import type { ChartData, ChartOptions } from 'chart.js';
import type { EmailCtrResponse, MarketingKeyInsight, MarketingRecommendedAction } from '@lfx-one/shared/interfaces';

@Component({
selector: 'lfx-email-ctr-drawer',
imports: [DrawerModule, ChartComponent],
imports: [DrawerModule, ChartComponent, SkeletonModule],
templateUrl: './email-ctr-drawer.component.html',
})
export class EmailCtrDrawerComponent {
// === Services ===
private readonly analyticsService = inject(AnalyticsService);
private readonly projectContextService = inject(ProjectContextService);

// === Model Signals (two-way binding) ===
public readonly visible = model<boolean>(false);

// === Inputs ===
public readonly data = input<EmailCtrResponse>({
currentCtr: 0,
changePercentage: 0,
trend: 'up',
monthlyData: [],
monthlyLabels: [],
campaignGroups: [],
monthlySends: [],
monthlyOpens: [],
});
// === WritableSignals ===
protected readonly drawerLoading = signal(false);

// === Computed Signals ===
// === Computed Signals (lazy-loaded data) ===
protected readonly drawerData: Signal<EmailCtrResponse> = this.initDrawerData();
protected readonly recommendedActions: Signal<MarketingRecommendedAction[]> = this.initRecommendedActions();
protected readonly keyInsights: Signal<MarketingKeyInsight[]> = this.initKeyInsights();
protected readonly chartData: Signal<ChartData<'bar'>> = this.initChartData();
Expand Down Expand Up @@ -158,7 +159,7 @@ export class EmailCtrDrawerComponent {
font: { size: 11 },
callback: (value) => {
const num = Number(value);
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 999_950) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
return String(num);
},
Expand All @@ -176,9 +177,45 @@ export class EmailCtrDrawerComponent {
}

// === Private Initializers ===
private initDrawerData(): Signal<EmailCtrResponse> {
const defaultValue: EmailCtrResponse = {
currentCtr: 0,
changePercentage: 0,
trend: 'up',
monthlyData: [],
monthlyLabels: [],
campaignGroups: [],
monthlySends: [],
monthlyOpens: [],
};

return toSignal(
toObservable(this.visible).pipe(
skip(1),
filter((isVisible) => isVisible),
tap(() => this.drawerLoading.set(true)),
switchMap(() => {
const foundation = this.projectContextService.selectedFoundation();
if (!foundation?.name) {
this.drawerLoading.set(false);
return of(defaultValue);
}
return this.analyticsService.getEmailCtr(foundation.name).pipe(
tap(() => this.drawerLoading.set(false)),
catchError(() => {
this.drawerLoading.set(false);
return of(defaultValue);
})
);
})
),
{ initialValue: defaultValue }
);
}

private initChartData(): Signal<ChartData<'bar'>> {
return computed(() => {
const { monthlyData, monthlyLabels } = this.data();
const { monthlyData, monthlyLabels } = this.drawerData();
return {
labels: monthlyLabels,
datasets: [
Expand All @@ -194,7 +231,7 @@ export class EmailCtrDrawerComponent {

private initCampaignChartData(): Signal<ChartData<'bar'>> {
return computed(() => {
const { campaignGroups } = this.data();
const { campaignGroups } = this.drawerData();
const sorted = [...campaignGroups].sort((a, b) => b.avgCtr - a.avgCtr);
return {
labels: sorted.map((c) => c.campaignName),
Expand All @@ -212,7 +249,7 @@ export class EmailCtrDrawerComponent {

private initRecommendedActions(): Signal<MarketingRecommendedAction[]> {
return computed(() => {
const { changePercentage, campaignGroups, monthlySends, monthlyOpens } = this.data();
const { changePercentage, campaignGroups, monthlySends, monthlyOpens } = this.drawerData();
const actions: MarketingRecommendedAction[] = [];

if (changePercentage < 0) {
Expand Down Expand Up @@ -272,7 +309,7 @@ export class EmailCtrDrawerComponent {

private initKeyInsights(): Signal<MarketingKeyInsight[]> {
return computed(() => {
const { currentCtr, changePercentage, monthlyData, campaignGroups, monthlySends, monthlyOpens } = this.data();
const { currentCtr, changePercentage, monthlyData, campaignGroups, monthlySends, monthlyOpens } = this.drawerData();
const insights: MarketingKeyInsight[] = [];

if (currentCtr === 0 && monthlyData.length === 0) {
Expand Down Expand Up @@ -330,7 +367,7 @@ export class EmailCtrDrawerComponent {

private initReachVsOpensChartData(): Signal<ChartData<'bar'>> {
return computed(() => {
const { monthlySends, monthlyOpens, monthlyLabels } = this.data();
const { monthlySends, monthlyOpens, monthlyLabels } = this.drawerData();
return {
labels: monthlyLabels,
datasets: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,75 +32,96 @@ <h2 class="text-lg font-semibold text-gray-900" data-testid="engaged-community-d
<div class="flex gap-4" data-testid="engaged-community-drawer-stats">
<div class="flex-1 flex flex-col gap-1 p-4 border border-gray-200 rounded-lg">
<span class="text-sm text-gray-500">Total Members</span>
<span class="text-2xl font-semibold text-gray-900">{{ formatNumber(data().totalMembers) }}</span>
<span class="text-2xl font-semibold text-gray-900">{{ formattedTotalMembers() }}</span>
</div>
<div class="flex-1 flex flex-col gap-1 p-4 border border-gray-200 rounded-lg">
<span class="text-sm text-gray-500">Newsletter</span>
<span class="text-2xl font-semibold text-gray-900">{{ formatNumber(data().breakdown.newsletterSubscribers) }}</span>
<span class="text-2xl font-semibold text-gray-900">{{ formattedNewsletterSubscribers() }}</span>
</div>
<div class="flex-1 flex flex-col gap-1 p-4 border border-gray-200 rounded-lg">
<span class="text-sm text-gray-500">Growth</span>
<div class="flex items-center gap-2">
<span class="text-2xl font-semibold" [class]="data().trend === 'up' ? 'text-green-600' : 'text-red-600'">
{{ data().changePercentage > 0 ? '+' : '' }}{{ data().changePercentage }}%
</span>
<i class="text-sm" [class]="data().trend === 'up' ? 'fa-light fa-arrow-up text-green-600' : 'fa-light fa-arrow-down text-red-600'"></i>
</div>
@if (data().changePercentage !== 0) {
<div class="flex items-center gap-2">
<span class="text-2xl font-semibold" [class]="data().trend === 'up' ? 'text-green-600' : 'text-red-600'">
{{ data().changePercentage > 0 ? '+' : '' }}{{ data().changePercentage }}%
</span>
<i class="text-sm" [class]="data().trend === 'up' ? 'fa-light fa-arrow-up text-green-600' : 'fa-light fa-arrow-down text-red-600'"></i>
</div>
} @else {
<span class="text-2xl font-semibold text-gray-500">0%</span>
}
</div>
</div>

<!-- Recommended Actions -->
@if (recommendedActions().length > 0) {
<div class="flex flex-col gap-3" data-testid="engaged-community-drawer-actions">
<!-- FIRST FOLD: Needs Your Attention -->
@if (attentionActions().length > 0 || attentionInsights().length > 0) {
<div class="flex flex-col gap-4 p-4 bg-red-50 border border-red-200 rounded-lg" data-testid="engaged-community-drawer-attention">
<div class="flex items-center gap-2">
<h3 class="flex items-center gap-2 text-sm font-semibold text-gray-900">
<i class="fa-light fa-lightbulb text-amber-500"></i>
Recommended Actions
<h3 class="flex items-center gap-2 text-sm font-semibold text-red-800">
<i class="fa-light fa-triangle-exclamation text-red-500"></i>
Needs Your Attention
</h3>
</div>
@for (action of recommendedActions(); track action.title) {
<div class="flex items-start gap-3 p-4 border border-gray-200 rounded-lg">
<div class="flex items-center justify-center w-9 h-9 rounded-full bg-gray-100 flex-shrink-0 mt-0.5">
<i [class]="action.iconClass + ' text-gray-600'"></i>

@for (action of attentionActions(); track action.title) {
<div class="flex items-start gap-3 p-4 bg-white border border-red-200 rounded-lg">
<div class="flex items-center justify-center w-9 h-9 rounded-full bg-red-100 flex-shrink-0 mt-0.5">
<i [class]="action.iconClass + ' text-red-600'"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-gray-900">{{ action.title }}</span>
@if (action.priority === 'high') {
<span class="text-xs font-medium px-2.5 py-0.5 rounded-full flex-shrink-0 bg-red-100 text-red-700">High Priority</span>
} @else if (action.priority === 'medium') {
<span class="text-xs font-medium px-2.5 py-0.5 rounded-full flex-shrink-0 bg-gray-100 text-gray-700">Medium Priority</span>
} @else {
<span class="text-xs font-medium px-2.5 py-0.5 rounded-full flex-shrink-0 bg-blue-100 text-blue-700">Low Priority</span>
<span class="text-xs font-medium px-2.5 py-0.5 rounded-full flex-shrink-0 bg-amber-100 text-amber-700">Medium Priority</span>
}
</div>
<p class="text-sm text-gray-500 mt-0.5">{{ action.description }}</p>
<p class="text-xs text-gray-400 mt-1">Due: {{ action.dueLabel }}</p>
</div>
</div>
}

@for (insight of attentionInsights(); track insight.text) {
<div class="flex items-center gap-2 text-sm px-1">
<i class="fa-light fa-triangle-exclamation text-red-500"></i>
<span class="text-red-800">{{ insight.text }}</span>
</div>
}
</div>
}

<!-- Key Insights -->
@if (keyInsights().length > 0) {
<div class="flex flex-col gap-3 p-4 bg-gray-50 rounded-lg" data-testid="engaged-community-drawer-insights">
<!-- SECOND FOLD: Performing Well -->
@if (performingActions().length > 0 || performingInsights().length > 0) {
<div class="flex flex-col gap-4 p-4 bg-green-50 border border-green-200 rounded-lg" data-testid="engaged-community-drawer-performing">
<div class="flex items-center gap-2">
<h3 class="flex items-center gap-2 text-sm font-semibold text-gray-900">
<i class="fa-light fa-triangle-exclamation text-amber-500"></i>
Key Insights
<h3 class="flex items-center gap-2 text-sm font-semibold text-green-800">
<i class="fa-light fa-check-circle text-green-500"></i>
Performing Well
</h3>
</div>
@for (insight of keyInsights(); track insight.text) {
<div class="flex items-center gap-2 text-sm">

@for (action of performingActions(); track action.title) {
<div class="flex items-start gap-3 p-4 bg-white border border-green-200 rounded-lg">
<div class="flex items-center justify-center w-9 h-9 rounded-full bg-green-100 flex-shrink-0 mt-0.5">
<i [class]="action.iconClass + ' text-green-600'"></i>
</div>
<div class="flex-1 min-w-0">
<span class="text-sm font-semibold text-gray-900">{{ action.title }}</span>
<p class="text-sm text-gray-500 mt-0.5">{{ action.description }}</p>
</div>
</div>
}

@for (insight of performingInsights(); track insight.text) {
<div class="flex items-center gap-2 text-sm px-1">
@if (insight.type === 'driver') {
<i class="fa-light fa-bullseye text-gray-500"></i>
} @else if (insight.type === 'warning') {
<i class="fa-light fa-triangle-exclamation text-amber-500"></i>
<i class="fa-light fa-bullseye text-green-500"></i>
} @else {
<i class="fa-light fa-circle-info text-blue-500"></i>
}
<span class="text-gray-700">{{ insight.text }}</span>
<span class="text-green-800">{{ insight.text }}</span>
</div>
}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,18 @@ export class EngagedCommunityDrawerComponent {
});

// === Computed Signals ===
protected readonly formattedTotalMembers: Signal<string> = computed(() => this.formatNumber(this.data().totalMembers));
protected readonly formattedNewsletterSubscribers: Signal<string> = computed(() => this.formatNumber(this.data().breakdown.newsletterSubscribers));
protected readonly recommendedActions: Signal<MarketingRecommendedAction[]> = this.initRecommendedActions();
protected readonly keyInsights: Signal<MarketingKeyInsight[]> = this.initKeyInsights();
protected readonly attentionActions: Signal<MarketingRecommendedAction[]> = computed(() =>
this.recommendedActions().filter((a) => a.priority === 'high' || a.priority === 'medium')
);
protected readonly attentionInsights: Signal<MarketingKeyInsight[]> = computed(() => this.keyInsights().filter((i) => i.type === 'warning'));
protected readonly performingActions: Signal<MarketingRecommendedAction[]> = computed(() => this.recommendedActions().filter((a) => a.priority === 'low'));
protected readonly performingInsights: Signal<MarketingKeyInsight[]> = computed(() =>
this.keyInsights().filter((i) => i.type === 'driver' || i.type === 'info')
);
protected readonly trendChartData: Signal<ChartData<'line'>> = this.initTrendChartData();
protected readonly breakdownChartData: Signal<ChartData<'bar'>> = this.initBreakdownChartData();

Expand Down Expand Up @@ -127,7 +137,7 @@ export class EngagedCommunityDrawerComponent {
}

protected formatNumber(num: number): string {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 999_950) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return num.toLocaleString();
}
Expand Down
Loading
Loading