From Chaos to Clarity: Designing a Flexible Student Analytics Dashboard For an LMS Project
1. Introduction
At Reevo, our e-learning platform has been helping students and educators connect and learn effectively for years. Our platform offers a wide range of courses, from programming to digital marketing, all accessible through an intuitive interface. Recently, our client approached us with a request that would take our platform to the next level: they wanted to implement a comprehensive analytics and metrics system in the Student dashboard.
This request seemed straightforward at first, but as we delved deeper, we realized it would challenge our existing architecture and push us to create a more robust and extensible system. This article details our journey from a messy helper class to a well-designed, component-based dashboard system that not only met our client's needs but also set us up for future growth and flexibility.
Join us as we explore the challenges we faced, the solutions we devised, and the lessons we learned along the way. Whether you're a seasoned developer or just starting out, this case study offers valuable insights into creating extensible architectures and reusable components in a real-world scenario.
2. The Initial Approach and Its Challenges
When we first received the request for analytics and metrics in the Student dashboard, our initial approach was to create a helper class that would handle all the necessary calculations. It seemed like a quick and easy solution at the time. Here's what our initial DashboardHelper
class looked like:
class DashboardHelper
{
public function getCourseCompletionRate($user)
{
$totalCourses = $user->enrollments()->count();
$completedCourses = $user->enrollments()->whereNotNull('completed_at')->count();
$completionRate = $totalCourses > 0 ? ($completedCourses / $totalCourses) * 100 : 0;
return [
'label' => 'Course Completion Rate',
'value' => $completionRate,
'unit' => '%'
];
}
public function getEnrollmentTrend($user)
{
$enrollments = $user->enrollments()
->select(DB::raw('DATE(created_at) as date'), DB::raw('COUNT(*) as count'))
->groupBy('date')
->orderBy('date')
->get();
return [
'label' => 'Enrollment Trend',
'data' => $enrollments->map(function ($item) {
return ['date' => $item->date, 'value' => $item->count];
})
];
}
// More helper methods...
}
While this approach allowed us to quickly implement the requested metrics, we soon ran into several challenges:
Code Manageability: As we added more metrics, the
DashboardHelper
class grew unwieldy. It became difficult to navigate and maintain.Different Output Types: Some metrics returned simple values, while others returned complex data structures for trends or charts. Handling these different types in a consistent manner became challenging.
Repetitive Patterns: We noticed that many of our metric methods followed similar patterns, with repetitive code for permissions checking, data formatting, and error handling.
Lack of Encapsulation: Each metric calculation was exposed as a public method, making it difficult to change implementations without affecting other parts of the system.
Testing Difficulties: The monolithic nature of the helper class made it challenging to write focused, maintainable tests for individual metrics.
As these challenges mounted, we realized we needed a better approach - one that would allow us to scale our metrics system efficiently while maintaining clean, manageable code.
3. Identifying the Need for a Better Architecture
As we grappled with the challenges of our initial approach, we began to recognize common patterns across our metrics:
Common Structure: Each metric had a label, a value (or dataset for trends), and often a unit of measurement.
Permissions: Different metrics required different levels of access. We needed a consistent way to handle permissions.
Calculation Logic: While the specific calculations varied, the process of fetching data, performing calculations, and formatting results was similar across metrics.
Output Types: We had two main types of metrics: simple value metrics (like completion rates) and trend metrics (like enrollment over time).
We also identified several key requirements for our new system:
Flexibility: We needed to support both simple value metrics and more complex trend/chart data.
Extensibility: It should be easy to create and integrate new metrics without modifying existing code.
Reusability: We wanted to maximize code reuse to minimize redundancy and improve maintainability.
Consistency: A standardized structure for metrics would make them easier to work with, both in the backend and frontend.
Performance: As the number of metrics grew, we needed an architecture that would allow for efficient calculation and caching of metric data.
These realizations led us to explore a more object-oriented approach, leveraging interfaces and abstract classes to create a flexible, extensible architecture for our metrics system.
4. Designing the New Architecture
With our requirements and patterns identified, we set out to design a new architecture that would address our challenges and set us up for future growth. We decided to use a combination of interfaces and abstract classes to create a flexible yet structured system for our metrics.
4.1 Core Interfaces
We started by defining core interfaces that would serve as the foundation of our metrics system:
interface Metric
{
public function calculate(): void;
public function label(): string;
public function uriKey(): string;
public function canView(User $user): bool;
}
interface ValueMetric extends Metric
{
public function value();
public function unit(): string;
}
interface TrendMetric extends Metric
{
public function data(): array;
}
The Metric
interface defines the basic structure that all metrics must follow. It includes methods for calculation, retrieving the label, a unique URI key, and a permission check.
The ValueMetric
and TrendMetric
interfaces extend the base Metric
interface to provide specific methods for different types of metrics. ValueMetric
is for simple numerical values, while TrendMetric
is for time-series or categorical data that might be displayed in a chart.
4.2 Abstract Base Classes
To provide common functionality and reduce code duplication, we created abstract base classes that implement these interfaces:
abstract class AbstractMetric implements Metric
{
protected $label;
protected $calculatedValue;
public function calculate(): void
{
// This method will be implemented by concrete classes
}
public function label(): string
{
return $this->label;
}
abstract public function uriKey(): string;
public function canView(User $user): bool
{
// Default implementation, can be overridden
return true;
}
}
abstract class AbstractValueMetric extends AbstractMetric implements ValueMetric
{
protected $unit;
public function value()
{
return $this->calculatedValue;
}
public function unit(): string
{
return $this->unit;
}
}
abstract class AbstractTrendMetric extends AbstractMetric implements TrendMetric
{
public function data(): array
{
return $this->calculatedValue;
}
}
These abstract classes provide default implementations for some methods and enforce the structure defined by our interfaces. They also allow us to add shared functionality that all metrics might need.
4.3 The Dashboard Concept
To bring our metrics together into a cohesive unit, we introduced the concept of a Dashboard:
interface Dashboard
{
public function metrics(): array;
public function canView(User $user): bool;
}
abstract class AbstractDashboard implements Dashboard
{
protected $metrics = [];
public function addMetric(Metric $metric): self
{
$this->metrics[] = $metric;
return $this;
}
public function metrics(): array
{
return $this->metrics;
}
public function canView(User $user): bool
{
// Default implementation, can be overridden
return true;
}
}
The Dashboard concept allows us to group related metrics together and provides a single point of access for fetching all metrics for a particular view. It also allows us to implement dashboard-level permissions if needed.
This architecture provides a solid foundation for building our metrics system. In the next sections, we'll look at how we implemented concrete metrics and built our student dashboard using these components.
5. Implementing Concrete Metrics
With our architecture in place, implementing concrete metrics became a straightforward process. Here are two examples of how we implemented specific metrics using our new system:
Course Completion Rate
class CourseCompletionRate extends AbstractValueMetric
{
protected $label = 'Course Completion Rate';
protected $unit = '%';
public function calculate(): void
{
$user = Auth::user();
$totalCourses = $user->enrollments()->count();
$completedCourses = $user->enrollments()->whereNotNull('completed_at')->count();
$this->calculatedValue = $totalCourses > 0
? round(($completedCourses / $totalCourses) * 100, 2)
: 0;
}
public function uriKey(): string
{
return 'course-completion-rate';
}
public function canView(User $user): bool
{
return $user->can('view-course-stats');
}
}
Enrollment Trend
class EnrollmentTrend extends AbstractTrendMetric
{
protected $label = 'Enrollment Trend';
public function calculate(): void
{
$user = Auth::user();
$startDate = now()->subDays(30);
$enrollments = $user->enrollments()
->where('created_at', '>=', $startDate)
->select(DB::raw('DATE(created_at) as date'), DB::raw('COUNT(*) as count'))
->groupBy('date')
->orderBy('date')
->get();
$this->calculatedValue = $enrollments->map(function ($item) {
return ['date' => $item->date, 'value' => $item->count];
})->toArray();
}
public function uriKey(): string
{
return 'enrollment-trend';
}
public function canView(User $user): bool
{
return $user->can('view-enrollment-stats');
}
}
These implementations showcase how our new architecture simplifies the creation of metrics:
Each metric is self-contained, encapsulating its own calculation logic and permissions.
The abstract base classes handle common functionality, reducing code duplication.
The interfaces ensure that each metric provides all necessary data in a consistent format.
This approach makes it easy to add new metrics or modify existing ones without affecting other parts of the system.
6. Building the Dashboard
With our metrics in place, we can now build our StudentDashboard:
class StudentDashboard extends AbstractDashboard
{
public function __construct()
{
$this->addMetric(new CourseCompletionRate())
->addMetric(new EnrollmentTrend())
->addMetric(new LearningTimeMetric())
->addMetric(new QuizPerformanceMetric());
}
public function canView(User $user): bool
{
return $user->hasRole('student');
}
}
This dashboard class brings together all the relevant metrics for a student. The canView
method ensures that only users with the 'student' role can access this dashboard.
To use this dashboard in our application, we might have a controller method like this:
class DashboardController extends Controller
{
public function studentDashboard(StudentDashboard $dashboard)
{
if (!$dashboard->canView(Auth::user())) {
abort(403);
}
$metrics = collect($dashboard->metrics())->map(function ($metric) {
if (!$metric->canView(Auth::user())) {
return null;
}
$metric->calculate();
return [
'label' => $metric->label(),
'uriKey' => $metric->uriKey(),
'value' => $metric instanceof ValueMetric ? $metric->value() : null,
'unit' => $metric instanceof ValueMetric ? $metric->unit() : null,
'data' => $metric instanceof TrendMetric ? $metric->data() : null,
];
})->filter();
return view('student.dashboard', ['metrics' => $metrics]);
}
}
This controller method handles dashboard-level and metric-level permissions, calculates all accessible metrics, and prepares the data for the view.
7. Frontend Implementation
On the frontend, we created reusable Vue components to display our metrics. Here's an example of how we might implement a component for displaying value metrics:
<!-- ValueMetric.vue -->
<template>
<div class="metric value-metric">
<h3>{{ metric.label }}</h3>
<div class="value">{{ metric.value }}{{ metric.unit }}</div>
</div>
</template>
<script>
export default {
props: {
metric: {
type: Object,
required: true
}
}
}
</script>
And for trend metrics:
<!-- TrendMetric.vue -->
<template>
<div class="metric trend-metric">
<h3>{{ metric.label }}</h3>
<line-chart :data="metric.data"></line-chart>
</div>
</template>
<script>
import LineChart from './LineChart.vue'
export default {
components: { LineChart },
props: {
metric: {
type: Object,
required: true
}
}
}
</script>
These components can then be used in a dashboard view:
<!-- Dashboard.vue -->
<template>
<div class="dashboard">
<component
v-for="metric in metrics"
:key="metric.uriKey"
:is="metric.data ? 'trend-metric' : 'value-metric'"
:metric="metric"
></component>
</div>
</template>
<script>
import ValueMetric from './ValueMetric.vue'
import TrendMetric from './TrendMetric.vue'
export default {
components: { ValueMetric, TrendMetric },
data() {
return {
metrics: []
}
},
mounted() {
this.fetchMetrics()
},
methods: {
async fetchMetrics() {
const response = await fetch('/api/student-dashboard')
this.metrics = await response.json()
}
}
}
</script>
This approach allows us to easily add new types of metrics in the future by creating new Vue components and conditionally rendering them based on the metric type.
8. Facilitating Metric Creation
To make it even easier for our team to create new metrics, we implemented an Artisan command:
class CreateMetricCommand extends Command
{
protected $signature = 'make:metric {name} {--type=value}';
public function handle()
{
$name = $this->argument('name');
$type = $this->option('type');
if (!in_array($type, ['value', 'trend'])) {
$this->error('Invalid metric type. Use either "value" or "trend".');
return 1;
}
$stub = $this->getStub($type);
$className = Str::studly($name);
$content = str_replace('{{className}}', $className, $stub);
$path = app_path("Metrics/{$className}.php");
File::put($path, $content);
$this->info("Metric {$className} created successfully.");
}
private function getStub($type)
{
return File::get(resource_path("stubs/metric-{$type}.stub"));
}
}
This command can be used like this:
php artisan make:metric CourseCompletionRate --type=value
It generates a new metric class with the appropriate boilerplate, allowing developers to focus on implementing the specific calculation logic for each metric.
9. The Final Architecture
+----------------+
| Dashboard |
+----------------+
|
| aggregates
|
+----------------+
| Metric |
+----------------+
^
|
+---------+---------+
| |
+----------------+ +----------------+
| ValueMetric | | TrendMetric |
+----------------+ +----------------+
^ ^
| |
+----------------+ +----------------+
| ConcreteValue | | ConcreteTrend |
| Metric | | Metric |
+----------------+ +----------------+
This architecture provides several key benefits:
Separation of Concerns: Each component (Dashboard, Metric, concrete implementations) has a clear, single responsibility.
Extensibility: New types of metrics can be easily added by implementing the appropriate interface.
Reusability: Common functionality is encapsulated in abstract classes, promoting code reuse.
Flexibility: The system can accommodate both simple value metrics and complex trend data.
Consistency: All metrics follow the same structure, making them easier to work with in both backend and frontend code.
The interaction between these components is straightforward:
The Dashboard aggregates multiple Metrics.
Each Metric is either a ValueMetric or a TrendMetric.
Concrete implementations of these metrics contain the specific calculation logic.
The frontend components render the metrics based on their type.
This design allows for easy extension of the system. For example, if we needed to add a new type of metric (say, a PieChartMetric), we could simply add a new interface extending Metric, create an abstract base class, and then implement concrete metrics of this new type.
10. Results and Benefits
Implementing this new architecture brought numerous benefits to our project:
Improved Code Organization: Each metric is now encapsulated in its own class, making the codebase much easier to navigate and maintain.
Enhanced Flexibility: Adding new metrics or modifying existing ones became a straightforward process, without risk of affecting other parts of the system.
Better Performance: By making each metric responsible for its own calculation, we were able to implement more efficient caching strategies. We could cache individual metrics rather than entire dashboard results.
Easier Testing: The modular nature of the new system made it much easier to write focused unit tests for each metric.
Improved Reusability: We found that some metrics could be reused across different dashboards, further reducing code duplication.
Streamlined Development Process: The Artisan command for creating new metrics significantly reduced the time needed to implement new analytics features.
Better Collaboration: The clear structure made it easier for team members to work on different metrics concurrently without conflicts.
Future-Proofing: The extensible nature of the architecture means we're well-prepared for future requirements and changes.
11. Lessons Learned
This project taught us several valuable lessons:
Importance of Early Pattern Recognition: Identifying common patterns early in the development process can lead to more elegant and maintainable solutions.
Value of Abstraction: Proper use of interfaces and abstract classes can significantly reduce code duplication and improve system flexibility.
Benefits of SOLID Principles: Our new architecture adheres closely to SOLID principles, particularly the Single Responsibility Principle and the Open/Closed Principle, which greatly improved our code quality.
Invest in Developer Tools: Creating the Artisan command for generating new metrics was a small time investment that paid off enormously in increased developer productivity.
Importance of Frontend Considerations: Designing our backend with frontend needs in mind (e.g., consistent data structures for different metric types) made the frontend implementation much smoother.
Value of Incremental Refactoring: We didn't rewrite the entire system at once. Instead, we refactored incrementally, which allowed us to deliver value continuously throughout the process.
12. Future Improvements
While our new architecture has served us well, we've identified some areas for future improvement:
Metric Dependencies: Implement a system for metrics that depend on the results of other metrics, to optimize calculation efficiency.
Advanced Caching: Introduce more sophisticated caching strategies, possibly using Laravel's cache tags for granular cache invalidation.
Real-time Updates: Explore ways to update dashboard metrics in real-time using WebSockets.
Machine Learning Integration: Investigate the possibility of using machine learning models to generate predictive metrics.
Customizable Dashboards: Allow users to customize their dashboards by selecting which metrics they want to see.
Metric Versioning: Implement a versioning system for metrics to manage changes in calculation methodologies over time.