add changes

This commit is contained in:
AD2025
2025-12-26 00:18:28 +02:00
parent efb4f69e20
commit 54be275e05
17 changed files with 599 additions and 402 deletions

View File

@@ -313,7 +313,7 @@ router.get('/users/:userId', verifyToken, isAdmin, adminController.getUserById);
* type: string
* questionType:
* type: string
* enum: [multiple_choice, true_false, short_answer]
* enum: [multiple_choice, trueFalse, short_answer]
* options:
* type: array
* items:

View File

@@ -1,5 +1,5 @@
import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { provideRouter, withPreloading, PreloadAllModules, withInMemoryScrolling } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
@@ -13,7 +13,10 @@ export const appConfig: ApplicationConfig = {
provideZonelessChangeDetection(),
provideRouter(
routes,
withPreloading(PreloadAllModules)
withPreloading(PreloadAllModules),
withInMemoryScrolling({
scrollPositionRestoration: 'top'
})
),
provideAnimationsAsync(),
provideHttpClient(

View File

@@ -8,40 +8,55 @@
*/
export interface UserGrowthData {
date: string;
count: number;
newUsers: number;
}
/**
* Category popularity data for chart
*/
export interface CategoryPopularity {
categoryId: string;
categoryName: string;
id: string;
name: string;
slug: string;
icon: any;
color: string;
quizCount: number;
percentage: number;
averageScore: number;
}
/**
* System-wide statistics response
*/
export interface AdminStatistics {
totalUsers: number;
activeUsers: number; // Last 7 days
totalQuizSessions: number;
totalQuestions: number;
averageQuizScore: number;
users: AdminStatisticsUsers;
quizzes: AdminStatisticsQuizzes;
content: AdminStatisticsContent;
quizActivity: QuizActivity[];
userGrowth: UserGrowthData[];
popularCategories: CategoryPopularity[];
stats: {
newUsersToday: number;
newUsersThisWeek: number;
newUsersThisMonth: number;
quizzesToday: number;
quizzesThisWeek: number;
quizzesThisMonth: number;
}
export interface AdminStatisticsContent {
totalCategories: number;
totalQuestions: number;
questionsByDifficulty: {
easy: number;
medium: number;
hard: number;
};
}
export interface AdminStatisticsQuizzes {
totalSessions: number;
averageScore: number;
averageScorePercentage: number;
passRate: number;
passedQuizzes: number;
failedQuizzes: number;
}
export interface AdminStatisticsUsers {
total: number;
active: number;
inactiveLast7Days: number;
}
/**
* API response wrapper for statistics
*/
@@ -51,6 +66,10 @@ export interface AdminStatisticsResponse {
message?: string;
}
export interface QuizActivity {
date: string;
quizzesCompleted: number;
}
/**
* Date range filter for statistics
*/
@@ -71,43 +90,58 @@ export interface AdminCacheEntry<T> {
/**
* Guest session timeline data point
*/
export interface GuestSessionTimelineData {
date: string;
activeSessions: number;
newSessions: number;
convertedSessions: number;
}
// export interface GuestSessionTimelineData {
// date: string;
// activeSessions: number;
// newSessions: number;
// convertedSessions: number;
// }
/**
* Conversion funnel stage
*/
export interface ConversionFunnelStage {
stage: string;
count: number;
percentage: number;
dropoff?: number;
}
// export interface ConversionFunnelStage {
// stage: string;
// count: number;
// percentage: number;
// dropoff?: number;
// }
/**
* Guest analytics data
*/
export interface GuestAnalytics {
export interface GuestAnalyticsOverview {
totalGuestSessions: number;
activeGuestSessions: number;
conversionRate: number; // Percentage
averageQuizzesPerGuest: number;
totalConversions: number;
timeline: GuestSessionTimelineData[];
conversionFunnel: ConversionFunnelStage[];
stats: {
sessionsToday: number;
sessionsThisWeek: number;
sessionsThisMonth: number;
conversionsToday: number;
conversionsThisWeek: number;
conversionsThisMonth: number;
expiredGuestSessions: number;
convertedGuestSessions: number;
conversionRate: number;
}
export interface GuestAnalyticsQuizActivity {
totalGuestQuizzes: number;
completedGuestQuizzes: number;
guestQuizCompletionRate: number;
avgQuizzesPerGuest: number;
avgQuizzesBeforeConversion: number;
}
export interface GuestAnalyticsBehavior {
bounceRate: number;
avgSessionDurationMinutes: number;
}
export interface GuestAnalyticsRecentActivity {
last30Days: {
newGuestSessions: number;
conversions: number;
};
}
export interface GuestAnalytics {
overview: GuestAnalyticsOverview;
quizActivity: GuestAnalyticsQuizActivity;
behavior: GuestAnalyticsBehavior;
recentActivity: GuestAnalyticsRecentActivity;
}
/**
* API response wrapper for guest analytics
@@ -183,10 +217,10 @@ export interface AdminUserListResponse {
pagination: {
currentPage: number;
totalPages: number;
totalUsers: number;
limit: number;
hasNext: boolean;
hasPrev: boolean;
totalItems: number;
itemsPerPage: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
};
message?: string;
@@ -197,7 +231,13 @@ export interface AdminUserListResponse {
*/
export interface UserActivity {
id: string;
type: 'login' | 'quiz_start' | 'quiz_complete' | 'bookmark' | 'profile_update' | 'role_change';
type:
| 'login'
| 'quiz_start'
| 'quiz_complete'
| 'bookmark'
| 'profile_update'
| 'role_change';
description: string;
timestamp: string;
metadata?: {

View File

@@ -73,12 +73,14 @@ export class AdminService {
readonly isLoadingUsers = signal<boolean>(false);
readonly usersError = signal<string | null>(null);
readonly usersPagination = signal<{
currentPage: number;
totalPages: number;
totalUsers: number;
limit: number;
hasNext: boolean;
hasPrev: boolean;
totalItems: number;
itemsPerPage: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
} | null>(null);
readonly currentUserFilters = signal<UserListParams>({});
@@ -95,18 +97,18 @@ export class AdminService {
// Computed signals - Statistics
readonly hasStats = computed(() => this.adminStatsState() !== null);
readonly totalUsers = computed(() => this.adminStatsState()?.totalUsers ?? 0);
readonly activeUsers = computed(() => this.adminStatsState()?.activeUsers ?? 0);
readonly totalQuizSessions = computed(() => this.adminStatsState()?.totalQuizSessions ?? 0);
readonly totalQuestions = computed(() => this.adminStatsState()?.totalQuestions ?? 0);
readonly averageScore = computed(() => this.adminStatsState()?.averageQuizScore ?? 0);
readonly totalUsers = computed(() => this.adminStatsState()?.users.total ?? 0);
readonly activeUsers = computed(() => this.adminStatsState()?.users.active ?? 0);
readonly totalQuizSessions = computed(() => this.adminStatsState()?.quizzes.totalSessions ?? 0);
readonly totalQuestions = computed(() => this.adminStatsState()?.content.totalQuestions ?? 0);
readonly averageScore = computed(() => this.adminStatsState()?.quizzes.averageScore ?? 0);
// Computed signals - Guest Analytics
readonly hasAnalytics = computed(() => this.guestAnalyticsState() !== null);
readonly totalGuestSessions = computed(() => this.guestAnalyticsState()?.totalGuestSessions ?? 0);
readonly activeGuestSessions = computed(() => this.guestAnalyticsState()?.activeGuestSessions ?? 0);
readonly conversionRate = computed(() => this.guestAnalyticsState()?.conversionRate ?? 0);
readonly avgQuizzesPerGuest = computed(() => this.guestAnalyticsState()?.averageQuizzesPerGuest ?? 0);
readonly totalGuestSessions = computed(() => this.guestAnalyticsState()?.overview.totalGuestSessions ?? 0);
readonly activeGuestSessions = computed(() => this.guestAnalyticsState()?.overview.activeGuestSessions ?? 0);
readonly conversionRate = computed(() => this.guestAnalyticsState()?.overview.conversionRate ?? 0);
readonly avgQuizzesPerGuest = computed(() => this.guestAnalyticsState()?.quizActivity.avgQuizzesPerGuest ?? 0);
// Computed signals - Guest Settings
readonly hasSettings = computed(() => this.guestSettingsState() !== null);
@@ -116,7 +118,7 @@ export class AdminService {
// Computed signals - User Management
readonly hasUsers = computed(() => this.adminUsersState().length > 0);
readonly totalUsersCount = computed(() => this.usersPagination()?.totalUsers ?? 0);
readonly totalUsersCount = computed(() => this.usersPagination()?.totalItems ?? 0);
readonly currentPage = computed(() => this.usersPagination()?.currentPage ?? 1);
readonly totalPages = computed(() => this.usersPagination()?.totalPages ?? 1);

View File

@@ -16,13 +16,13 @@ export interface PaginationConfig {
*/
export interface PaginationState {
currentPage: number;
pageSize: number;
itemsPerPage: number;
totalItems: number;
totalPages: number;
startIndex: number;
endIndex: number;
hasPrev: boolean;
hasNext: boolean;
hasPreviousPage: boolean;
hasNextPage: boolean;
}
/**
@@ -61,13 +61,13 @@ export class PaginationService {
return {
currentPage: validCurrentPage,
pageSize,
itemsPerPage:pageSize,
totalItems,
totalPages,
startIndex,
endIndex,
hasPrev,
hasNext
hasNextPage:hasNext,
hasPreviousPage:hasPrev
};
}
@@ -208,13 +208,13 @@ export class PaginationService {
},
nextPage: () => {
if (state().hasNext) {
if (state().hasNextPage) {
config.update(c => ({ ...c, currentPage: c.currentPage + 1 }));
}
},
prevPage: () => {
if (state().hasPrev) {
if (state().hasPreviousPage) {
config.update(c => ({ ...c, currentPage: c.currentPage - 1 }));
}
},

View File

@@ -9,7 +9,12 @@
<p class="subtitle">System-wide statistics and analytics</p>
</div>
<div class="header-actions">
<button mat-icon-button (click)="refreshStats()" [disabled]="isLoading()" matTooltip="Refresh statistics">
<button
mat-icon-button
(click)="refreshStats()"
[disabled]="isLoading()"
matTooltip="Refresh statistics"
>
<mat-icon>refresh</mat-icon>
</button>
</div>
@@ -26,20 +31,40 @@
<div class="date-inputs">
<mat-form-field appearance="outline">
<mat-label>Start Date</mat-label>
<input matInput [matDatepicker]="startPicker" formControlName="startDate">
<mat-datepicker-toggle matIconSuffix [for]="startPicker"></mat-datepicker-toggle>
<input
matInput
[matDatepicker]="startPicker"
formControlName="startDate"
/>
<mat-datepicker-toggle
matIconSuffix
[for]="startPicker"
></mat-datepicker-toggle>
<mat-datepicker #startPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>End Date</mat-label>
<input matInput [matDatepicker]="endPicker" formControlName="endDate">
<mat-datepicker-toggle matIconSuffix [for]="endPicker"></mat-datepicker-toggle>
<input
matInput
[matDatepicker]="endPicker"
formControlName="endDate"
/>
<mat-datepicker-toggle
matIconSuffix
[for]="endPicker"
></mat-datepicker-toggle>
<mat-datepicker #endPicker></mat-datepicker>
</mat-form-field>
<button mat-raised-button color="primary" (click)="applyDateFilter()"
[disabled]="!dateRangeForm.value.startDate || !dateRangeForm.value.endDate">
<button
mat-raised-button
color="primary"
(click)="applyDateFilter()"
[disabled]="
!dateRangeForm.value.startDate || !dateRangeForm.value.endDate
"
>
Apply Filter
</button>
@@ -90,8 +115,10 @@
<div class="stat-info">
<h3>Total Users</h3>
<p class="stat-value">{{ formatNumber(totalUsers()) }}</p>
@if (stats() && stats()!.stats.newUsersThisWeek) {
<p class="stat-detail">+{{ stats()!.stats.newUsersThisWeek }} this week</p>
@if (stats() && stats()!.users.inactiveLast7Days) {
<p class="stat-detail">
+{{ stats()!.users.inactiveLast7Days }} this week
</p>
}
</div>
</mat-card-content>
@@ -118,8 +145,13 @@
<div class="stat-info">
<h3>Total Quizzes</h3>
<p class="stat-value">{{ formatNumber(totalQuizSessions()) }}</p>
@if (stats() && stats()!.stats.quizzesThisWeek) {
<p class="stat-detail">+{{ stats()!.stats.quizzesThisWeek }} this week</p>
@if (stats() && stats()!.quizzes) {
<p class="stat-detail">{{ stats()!.quizzes.averageScore }} Average score</p>
<p class="stat-detail">{{ stats()!.quizzes.averageScorePercentage }} Average score percentage</p>
<p class="stat-detail">{{ stats()!.quizzes.failedQuizzes }} Failed quizzes</p>
<p class="stat-detail">{{ stats()!.quizzes.passRate }} Pass rate</p>
<p class="stat-detail">{{ stats()!.quizzes.passedQuizzes }} Passed quizzes</p>
<p class="stat-detail">{{ stats()!.quizzes.totalSessions }} Total sessions</p>
}
</div>
</mat-card-content>
@@ -150,11 +182,15 @@
<mat-card-content>
<div class="score-display">
<div class="score-circle">
<span class="score-value">{{ formatPercentage(averageScore()) }}</span>
<span class="score-value">{{
formatPercentage(averageScore())
}}</span>
</div>
<p class="score-description">
@if (averageScore() >= 80) {
<span class="excellent">Excellent performance across all quizzes</span>
<span class="excellent"
>Excellent performance across all quizzes</span
>
} @else if (averageScore() >= 60) {
<span class="good">Good performance overall</span>
} @else {
@@ -166,7 +202,7 @@
</mat-card>
<!-- User Growth Chart -->
@if (userGrowthData().length > 0) {
<!-- @if (userGrowthData().length > 0) {
<mat-card class="chart-card">
<mat-card-header>
<mat-card-title>
@@ -176,31 +212,85 @@
</mat-card-header>
<mat-card-content>
<div class="chart-container">
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="line-chart">
<svg
[attr.width]="chartWidth"
[attr.height]="chartHeight"
class="line-chart"
> -->
<!-- Grid lines -->
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
<line x1="40" y1="260" x2="760" y2="260" stroke="#e0e0e0" stroke-width="1"/>
<!-- <line
x1="40"
y1="40"
x2="760"
y2="40"
stroke="#e0e0e0"
stroke-width="1"
/>
<line
x1="40"
y1="120"
x2="760"
y2="120"
stroke="#e0e0e0"
stroke-width="1"
/>
<line
x1="40"
y1="200"
x2="760"
y2="200"
stroke="#e0e0e0"
stroke-width="1"
/>
<line
x1="40"
y1="260"
x2="760"
y2="260"
stroke="#e0e0e0"
stroke-width="1"
/> -->
<!-- Axes -->
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
<!-- <line
x1="40"
y1="40"
x2="40"
y2="260"
stroke="#333"
stroke-width="2"
/>
<line
x1="40"
y1="260"
x2="760"
y2="260"
stroke="#333"
stroke-width="2"
/> -->
<!-- Data line -->
<path [attr.d]="getUserGrowthPath()" fill="none" stroke="#3f51b5" stroke-width="3"/>
<!-- <path
[attr.d]="getUserGrowthPath()"
fill="none"
stroke="#3f51b5"
stroke-width="3"
/> -->
<!-- Data points -->
@for (point of userGrowthData(); track point.date; let i = $index) {
<circle [attr.cx]="calculateChartX(i, userGrowthData().length)"
[attr.cy]="calculateChartY(point.count, i)"
r="4" fill="#3f51b5"/>
<!-- @for (point of userGrowthData(); track point.date; let i = $index) {
<circle
[attr.cx]="calculateChartX(i, userGrowthData().length)"
[attr.cy]="calculateChartY(point.newUsers, i)"
r="4"
fill="#3f51b5"
/>
}
</svg>
</div>
</mat-card-content>
</mat-card>
}
} -->
<!-- Popular Categories Chart -->
@if (popularCategories().length > 0) {
@@ -213,24 +303,83 @@
</mat-card-header>
<mat-card-content>
<div class="chart-container">
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="bar-chart">
<svg
[attr.width]="chartWidth"
[attr.height]="chartHeight"
class="bar-chart"
>
<!-- Grid lines -->
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
<line
x1="40"
y1="40"
x2="760"
y2="40"
stroke="#e0e0e0"
stroke-width="1"
/>
<line
x1="40"
y1="120"
x2="760"
y2="120"
stroke="#e0e0e0"
stroke-width="1"
/>
<line
x1="40"
y1="200"
x2="760"
y2="200"
stroke="#e0e0e0"
stroke-width="1"
/>
<!-- Axes -->
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
<line
x1="40"
y1="40"
x2="40"
y2="260"
stroke="#333"
stroke-width="2"
/>
<line
x1="40"
y1="260"
x2="760"
y2="260"
stroke="#333"
stroke-width="2"
/>
<!-- Bars -->
@for (bar of getCategoryBars(); track bar.label) {
<rect [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.width"
[attr.height]="bar.height" fill="#4caf50" opacity="0.8"/>
<text [attr.x]="bar.x + bar.width / 2" [attr.y]="bar.y - 5"
text-anchor="middle" font-size="12" fill="#333">{{ bar.value }}</text>
<text [attr.x]="bar.x + bar.width / 2" y="280"
text-anchor="middle" font-size="11" fill="#666">{{ bar.label }}</text>
<rect
[attr.x]="bar.x"
[attr.y]="bar.y"
[attr.width]="bar.width"
[attr.height]="bar.height"
fill="#4caf50"
opacity="0.8"
/>
<text
[attr.x]="bar.x + bar.width / 2"
[attr.y]="bar.y - 5"
text-anchor="middle"
font-size="12"
fill="#333"
>
{{ bar.value }}
</text>
<text
[attr.x]="bar.x + bar.width / 2"
y="280"
text-anchor="middle"
font-size="11"
fill="#666"
>
{{ bar.label }}
</text>
}
</svg>
</div>

View File

@@ -166,7 +166,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
getMaxUserCount(): number {
const data = this.userGrowthData();
if (data.length === 0) return 1;
return Math.max(...data.map(d => d.count), 1);
return Math.max(...data.map(d => d.newUsers), 1);
}
/**
@@ -197,7 +197,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
const data = this.userGrowthData();
if (data.length === 0) return '';
const maxCount = Math.max(...data.map(d => d.count), 1);
const maxCount = Math.max(...data.map(d => d.newUsers), 1);
const width = this.chartWidth;
const height = this.chartHeight;
const padding = 40;
@@ -206,7 +206,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
const points = data.map((d, i) => {
const x = padding + (i / (data.length - 1)) * plotWidth;
const y = height - padding - (d.count / maxCount) * plotHeight;
const y = height - padding - (d.newUsers / maxCount) * plotHeight;
return `${x},${y}`;
});
@@ -235,7 +235,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
y: height - padding - barHeight,
width: barWidth,
height: barHeight,
label: cat.categoryName,
label: cat.name,
value: cat.quizCount
};
});

View File

@@ -84,8 +84,8 @@ export class AdminQuestionFormComponent implements OnInit {
// Available options
readonly questionTypes = [
{ value: 'multiple_choice', label: 'Multiple Choice' },
{ value: 'true_false', label: 'True/False' },
{ value: 'multiple', label: 'Multiple Choice' },
{ value: 'trueFalse', label: 'True/False' },
{ value: 'written', label: 'Written Answer' }
];
@@ -198,7 +198,7 @@ export class AdminQuestionFormComponent implements OnInit {
private initializeForm(): void {
this.questionForm = this.fb.group({
questionText: ['', [Validators.required, Validators.minLength(10)]],
questionType: ['multiple_choice', Validators.required],
questionType: ['multiple', Validators.required],
categoryId: ['', Validators.required],
difficulty: ['medium', Validators.required],
options: this.fb.array([
@@ -333,7 +333,7 @@ export class AdminQuestionFormComponent implements OnInit {
const correctAnswer = formGroup.get('correctAnswer')?.value;
const options = formGroup.get('options') as FormArray;
if (questionType === 'multiple_choice' && correctAnswer && options) {
if (questionType === 'multiple' && correctAnswer && options) {
const optionTexts = options.controls.map(opt => opt.get('text')?.value);
const isValid = optionTexts.includes(correctAnswer);
@@ -378,7 +378,7 @@ export class AdminQuestionFormComponent implements OnInit {
};
// Add options for multiple choice
if (formValue.questionType === 'multiple_choice') {
if (formValue.questionType === 'multiple') {
questionData.options = this.getOptionTexts();
}

View File

@@ -54,8 +54,8 @@
<mat-label>Type</mat-label>
<mat-select formControlName="type">
<mat-option value="all">All Types</mat-option>
<mat-option value="multiple_choice">Multiple Choice</mat-option>
<mat-option value="true_false">True/False</mat-option>
<mat-option value="multiple">Multiple Choice</mat-option>
<mat-option value="trueFalse">True/False</mat-option>
<mat-option value="written">Written</mat-option>
</mat-select>
</mat-form-field>
@@ -137,10 +137,10 @@
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let question">
<mat-chip>
@if (question.questionType === 'multiple_choice') {
@if (question.questionType === 'multiple') {
<mat-icon>radio_button_checked</mat-icon>
<span>MCQ</span>
} @else if (question.questionType === 'true_false') {
<span class="px-5"> MCQ</span>
} @else if (question.questionType === 'trueFalse') {
<mat-icon>check_circle</mat-icon>
<span> T/F</span>
} @else {

View File

@@ -116,7 +116,7 @@
<h2>Users</h2>
@if (pagination()) {
<span class="total-count">
Total: {{ pagination()?.totalUsers }} user{{ pagination()?.totalUsers !== 1 ? 's' : '' }}
Total: {{ pagination()?.totalItems }} user{{ pagination()?.totalItems !== 1 ? 's' : '' }}
</span>
}
</div>

View File

@@ -83,8 +83,8 @@ export class AdminUsersComponent implements OnInit {
return this.paginationService.calculatePaginationState({
currentPage: pag.currentPage,
pageSize: pag.limit,
totalItems: pag.totalUsers
pageSize: pag.itemsPerPage,
totalItems: pag.totalItems
});
});

View File

@@ -61,8 +61,8 @@
<div class="stat-info">
<h3>Total Guest Sessions</h3>
<p class="stat-value">{{ formatNumber(totalSessions()) }}</p>
@if (analytics() && analytics()!.stats.sessionsThisWeek) {
<p class="stat-detail">+{{ analytics()!.stats.sessionsThisWeek }} this week</p>
@if (analytics() && analytics()!.recentActivity.last30Days) {
<p class="stat-detail">+{{ analytics()!.recentActivity.last30Days }} this 30 days</p>
}
</div>
</mat-card-content>
@@ -89,8 +89,8 @@
<div class="stat-info">
<h3>Conversion Rate</h3>
<p class="stat-value">{{ formatPercentage(conversionRate()) }}</p>
@if (analytics() && analytics()!.totalConversions) {
<p class="stat-detail">{{ analytics()!.totalConversions }} conversions</p>
@if (analytics() && analytics()!.overview.conversionRate) {
<p class="stat-detail">{{ analytics()!.overview.conversionRate }} conversions</p>
}
</div>
</mat-card-content>
@@ -111,7 +111,7 @@
</div>
<!-- Session Timeline Chart -->
@if (timelineData().length > 0) {
<!-- @if (timelineData().length > 0) {
<mat-card class="chart-card">
<mat-card-header>
<mat-card-title>
@@ -135,28 +135,28 @@
</div>
</div>
<div class="chart-container">
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="timeline-chart">
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="timeline-chart"> -->
<!-- Grid lines -->
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
<!-- <line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
<line x1="40" y1="260" x2="760" y2="260" stroke="#e0e0e0" stroke-width="1"/>
-->
<!-- Axes -->
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
<!-- <line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
-->
<!-- Active Sessions Line -->
<path [attr.d]="getTimelinePath('activeSessions')" fill="none" stroke="#3f51b5" stroke-width="3"/>
<!-- <path [attr.d]="getTimelinePath('activeSessions')" fill="none" stroke="#3f51b5" stroke-width="3"/> -->
<!-- New Sessions Line -->
<path [attr.d]="getTimelinePath('newSessions')" fill="none" stroke="#4caf50" stroke-width="3"/>
<!-- <path [attr.d]="getTimelinePath('newSessions')" fill="none" stroke="#4caf50" stroke-width="3"/> -->
<!-- Converted Sessions Line -->
<path [attr.d]="getTimelinePath('convertedSessions')" fill="none" stroke="#ff9800" stroke-width="3"/>
<!-- <path [attr.d]="getTimelinePath('convertedSessions')" fill="none" stroke="#ff9800" stroke-width="3"/> -->
<!-- Data points -->
@for (point of timelineData(); track point.date; let i = $index) {
<!-- @for (point of timelineData(); track point.date; let i = $index) {
<circle [attr.cx]="calculateTimelineX(i, timelineData().length)"
[attr.cy]="calculateTimelineY(point.activeSessions)"
r="4" fill="#3f51b5"/>
@@ -171,10 +171,10 @@
</div>
</mat-card-content>
</mat-card>
}
} -->
<!-- Conversion Funnel Chart -->
@if (funnelData().length > 0) {
<!-- @if (funnelData().length > 0) {
<mat-card class="chart-card">
<mat-card-header>
<mat-card-title>
@@ -184,21 +184,21 @@
</mat-card-header>
<mat-card-content>
<div class="chart-container">
<svg [attr.width]="chartWidth" [attr.height]="funnelHeight" class="funnel-chart">
<svg [attr.width]="chartWidth" [attr.height]="funnelHeight" class="funnel-chart"> -->
<!-- Funnel Bars -->
@for (bar of getFunnelBars(); track bar.label) {
<g>
<!-- @for (bar of getFunnelBars(); track bar.label) {
<g> -->
<!-- Bar -->
<rect [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.width"
<!-- <rect [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.width"
[attr.height]="bar.height" [attr.fill]="$index === 0 ? '#4caf50' : $index === getFunnelBars().length - 1 ? '#ff9800' : '#2196f3'"
opacity="0.8"/>
-->
<!-- Label -->
<text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 - 5"
<!-- <text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 - 5"
font-size="14" font-weight="600" fill="#fff">{{ bar.label }}</text>
-->
<!-- Count and Percentage -->
<text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 + 15"
<!-- <text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 + 15"
font-size="12" fill="#fff">{{ formatNumber(bar.count) }} ({{ formatPercentage(bar.percentage) }})</text>
</g>
}
@@ -216,7 +216,7 @@
</div>
</mat-card-content>
</mat-card>
}
} -->
<!-- Quick Actions -->
<div class="quick-actions">

View File

@@ -58,8 +58,8 @@ export class GuestAnalyticsComponent implements OnInit, OnDestroy {
readonly avgQuizzes = this.adminService.avgQuizzesPerGuest;
// Chart data computed signals
readonly timelineData = computed(() => this.analytics()?.timeline ?? []);
readonly funnelData = computed(() => this.analytics()?.conversionFunnel ?? []);
// readonly timelineData = computed(() => this.analytics()?.timeline ?? []);
// readonly funnelData = computed(() => this.analytics()?.conversionFunnel ?? []);
// Chart dimensions
readonly chartWidth = 800;
@@ -100,25 +100,25 @@ export class GuestAnalyticsComponent implements OnInit, OnDestroy {
/**
* Calculate max value for timeline chart
*/
getMaxTimelineValue(): number {
const data = this.timelineData();
if (data.length === 0) return 1;
return Math.max(
...data.map(d => Math.max(d.activeSessions, d.newSessions, d.convertedSessions)),
1
);
}
// getMaxTimelineValue(): number {
// const data = this.timelineData();
// if (data.length === 0) return 1;
// return Math.max(
// ...data.map(d => Math.max(d.activeSessions, d.newSessions, d.convertedSessions)),
// 1
// );
// }
/**
* Calculate Y coordinate for timeline chart
*/
calculateTimelineY(value: number): number {
const maxValue = this.getMaxTimelineValue();
const height = this.chartHeight;
const padding = 40;
const plotHeight = height - 2 * padding;
return height - padding - (value / maxValue) * plotHeight;
}
// calculateTimelineY(value: number): number {
// const maxValue = this.getMaxTimelineValue();
// const height = this.chartHeight;
// const padding = 40;
// const plotHeight = height - 2 * padding;
// return height - padding - (value / maxValue) * plotHeight;
// }
/**
* Calculate X coordinate for timeline chart
@@ -134,55 +134,55 @@ export class GuestAnalyticsComponent implements OnInit, OnDestroy {
/**
* Generate SVG path for timeline line
*/
getTimelinePath(dataKey: 'activeSessions' | 'newSessions' | 'convertedSessions'): string {
const data = this.timelineData();
if (data.length === 0) return '';
// getTimelinePath(dataKey: 'activeSessions' | 'newSessions' | 'convertedSessions'): string {
// const data = this.timelineData();
// if (data.length === 0) return '';
const points = data.map((d, i) => {
const x = this.calculateTimelineX(i, data.length);
const y = this.calculateTimelineY(d[dataKey]);
return `${x},${y}`;
});
// const points = data.map((d, i) => {
// const x = this.calculateTimelineX(i, data.length);
// const y = this.calculateTimelineY(d[dataKey]);
// return `${x},${y}`;
// });
return `M ${points.join(' L ')}`;
}
// return `M ${points.join(' L ')}`;
// }
/**
* Get conversion funnel bar data
*/
getFunnelBars(): Array<{
x: number;
y: number;
width: number;
height: number;
label: string;
count: number;
percentage: number;
}> {
const stages = this.funnelData();
if (stages.length === 0) return [];
// getFunnelBars(): Array<{
// x: number;
// y: number;
// width: number;
// height: number;
// label: string;
// count: number;
// percentage: number;
// }> {
// const stages = this.funnelData();
// if (stages.length === 0) return [];
const maxCount = Math.max(...stages.map(s => s.count), 1);
const width = this.chartWidth;
const height = this.funnelHeight;
const padding = 60;
const plotWidth = width - 2 * padding;
const plotHeight = height - 2 * padding;
const barHeight = plotHeight / stages.length - 20;
// const maxCount = Math.max(...stages.map(s => s.count), 1);
// const width = this.chartWidth;
// const height = this.funnelHeight;
// const padding = 60;
// const plotWidth = width - 2 * padding;
// const plotHeight = height - 2 * padding;
// const barHeight = plotHeight / stages.length - 20;
return stages.map((stage, i) => {
const barWidth = (stage.count / maxCount) * plotWidth;
return {
x: padding,
y: padding + i * (plotHeight / stages.length) + 10,
width: barWidth,
height: barHeight,
label: stage.stage,
count: stage.count,
percentage: stage.percentage
};
});
}
// return stages.map((stage, i) => {
// const barWidth = (stage.count / maxCount) * plotWidth;
// return {
// x: padding,
// y: padding + i * (plotHeight / stages.length) + 10,
// width: barWidth,
// height: barHeight,
// label: stage.stage,
// count: stage.count,
// percentage: stage.percentage
// };
// });
// }
/**
* Export analytics data to CSV
@@ -197,26 +197,26 @@ export class GuestAnalyticsComponent implements OnInit, OnDestroy {
// Summary statistics
csvContent += 'Summary Statistics\n';
csvContent += 'Metric,Value\n';
csvContent += `Total Guest Sessions,${analytics.totalGuestSessions}\n`;
csvContent += `Active Guest Sessions,${analytics.activeGuestSessions}\n`;
csvContent += `Conversion Rate,${analytics.conversionRate}%\n`;
csvContent += `Average Quizzes per Guest,${analytics.averageQuizzesPerGuest}\n`;
csvContent += `Total Conversions,${analytics.totalConversions}\n\n`;
csvContent += `Total Guest Sessions,${analytics.overview.activeGuestSessions}\n`;
csvContent += `Active Guest Sessions,${analytics.overview.activeGuestSessions}\n`;
csvContent += `Conversion Rate,${analytics.overview.conversionRate}%\n`;
csvContent += `Average Quizzes per Guest,${analytics.quizActivity.avgQuizzesPerGuest}\n`;
csvContent += `Total Conversions,${analytics.overview.conversionRate}\n\n`;
// Timeline data
csvContent += 'Timeline Data\n';
csvContent += 'Date,Active Sessions,New Sessions,Converted Sessions\n';
analytics.timeline.forEach(item => {
csvContent += `${item.date},${item.activeSessions},${item.newSessions},${item.convertedSessions}\n`;
});
// analytics.timeline.forEach(item => {
// csvContent += `${item.date},${item.activeSessions},${item.newSessions},${item.convertedSessions}\n`;
// });
csvContent += '\n';
// Funnel data
csvContent += 'Conversion Funnel\n';
csvContent += 'Stage,Count,Percentage,Dropoff\n';
analytics.conversionFunnel.forEach(stage => {
csvContent += `${stage.stage},${stage.count},${stage.percentage}%,${stage.dropoff ?? 'N/A'}\n`;
});
// analytics.conversionFunnel.forEach(stage => {
// csvContent += `${stage.stage},${stage.count},${stage.percentage}%,${stage.dropoff ?? 'N/A'}\n`;
// });
// Create and download file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });

View File

@@ -194,9 +194,9 @@ export class QuizResultsComponent implements OnInit, OnDestroy {
*/
getQuestionTypeText(type: string): string {
switch (type) {
case 'multiple_choice':
case 'multiple':
return 'Multiple Choice';
case 'true_false':
case 'trueFalse':
return 'True/False';
case 'written':
return 'Written';

View File

@@ -215,9 +215,9 @@ export class QuizReviewComponent implements OnInit, OnDestroy {
*/
getQuestionTypeText(type: string): string {
switch (type) {
case 'multiple_choice':
case 'multiple':
return 'Multiple Choice';
case 'true_false':
case 'trueFalse':
return 'True/False';
case 'written':
return 'Written';

View File

@@ -1,3 +1,4 @@
<div style="padding: 10px;">
<h2 mat-dialog-title>
@if (data.icon) {
<mat-icon>{{ data.icon }}</mat-icon>
@@ -22,3 +23,5 @@
{{ data.confirmText || 'Confirm' }}
</button>
</mat-dialog-actions>
</div>

View File

@@ -57,7 +57,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
@if (showPageSizeSelector()) {
<mat-form-field class="page-size-selector" appearance="outline">
<mat-select
[value]="state()!.pageSize"
[value]="state()!.itemsPerPage"
(selectionChange)="onPageSizeChange($event.value)"
aria-label="Items per page">
@for (option of pageSizeOptions(); track option) {
@@ -74,7 +74,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
<button
mat-icon-button
(click)="onPageChange(1)"
[disabled]="!state()!.hasPrev"
[disabled]="!state()!.hasPreviousPage"
matTooltip="First page"
aria-label="Go to first page">
<mat-icon>first_page</mat-icon>
@@ -85,7 +85,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
<button
mat-icon-button
(click)="onPageChange(state()!.currentPage - 1)"
[disabled]="!state()!.hasPrev"
[disabled]="!state()!.hasPreviousPage"
matTooltip="Previous page"
aria-label="Go to previous page">
<mat-icon>chevron_left</mat-icon>
@@ -114,7 +114,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
<button
mat-icon-button
(click)="onPageChange(state()!.currentPage + 1)"
[disabled]="!state()!.hasNext"
[disabled]="!state()!.hasNextPage"
matTooltip="Next page"
aria-label="Go to next page">
<mat-icon>chevron_right</mat-icon>
@@ -125,7 +125,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
<button
mat-icon-button
(click)="onPageChange(state()!.totalPages)"
[disabled]="!state()!.hasNext"
[disabled]="!state()!.hasNextPage"
matTooltip="Last page"
aria-label="Go to last page">
<mat-icon>last_page</mat-icon>