Building a GitHub-Style Activity Dashboard: Lessons from My First Internship
Note: Due to NDA restrictions, all code examples and UI descriptions in this case study are reconstructed from memory for educational purposes. They represent the patterns and approaches I used, not the actual proprietary implementation.
The Project
During my internship, I was tasked with building an internal analytics dashboard to track product usage patterns. Think GitHub's contribution graph, but for tracking user interactions with various product features across the organization.
The goal was simple: give stakeholders a visual way to understand how users engaged with the product over time.
Timeline: 1-2 months Role: Solo developer (with mentorship) Tech Stack: Node.js, Express, EJS, jQuery, Chart.js, Bootstrap, MongoDB with Mongoose
The Challenge
As someone new to development, this project threw me into the deep end. I had to:
- Design a data model for tracking user activities
- Build aggregation pipelines to process thousands of records
- Create a heatmap visualization similar to GitHub's contribution graph
- Make it all render in a reasonable time
The reality? My initial queries took 3-5 seconds to render a single page. That's an eternity in web development.
Technical Architecture
Stack Overview:
| Layer | Technology | Purpose |
|---|---|---|
| Server | Express.js | Route handling, API endpoints |
| Database | MongoDB + Mongoose | Data storage, aggregation queries |
| Templating | EJS | Server-side HTML rendering |
| Frontend | jQuery + Chart.js | DOM manipulation, heatmap visualization |
| Styling | Bootstrap | Responsive UI components |
The Activity Tracking Schema
The core data model tracked every user interaction:
// Activity Schema (Reconstructed pattern)
const activitySchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
productId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Product',
required: true,
index: true
},
action: {
type: String,
enum: ['view', 'click', 'purchase', 'share', 'download'],
required: true
},
metadata: {
duration: Number, // Time spent in seconds
source: String, // Where the action originated
device: String // Desktop, mobile, tablet
},
timestamp: {
type: Date,
default: Date.now,
index: true
}
});
// Compound index for common queries
activitySchema.index({ userId: 1, timestamp: -1 });
activitySchema.index({ productId: 1, timestamp: -1 });
The Performance Problem
My first aggregation query looked something like this:
// Initial approach - SLOW (3-5 seconds)
async function getActivityHeatmap(userId, year) {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
const activities = await Activity.find({
userId: userId,
timestamp: { $gte: startDate, $lte: endDate }
});
// Process in JavaScript - BAD IDEA
const heatmapData = {};
activities.forEach(activity => {
const dateKey = activity.timestamp.toISOString().split('T')[0];
if (!heatmapData[dateKey]) {
heatmapData[dateKey] = 0;
}
heatmapData[dateKey]++;
});
return heatmapData;
}
Why was it slow?
- Fetching ALL records to Node.js memory
- Processing aggregation in JavaScript instead of MongoDB
- No pagination or limits
- Missing proper indexes
The Optimization Journey
Step 1: Move Aggregation to MongoDB
// Optimized approach - Let MongoDB do the heavy lifting
async function getActivityHeatmap(userId, year) {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
const pipeline = [
// Match only relevant documents
{
$match: {
userId: mongoose.Types.ObjectId(userId),
timestamp: { $gte: startDate, $lt: endDate }
}
},
// Group by date
{
$group: {
_id: {
$dateToString: { format: '%Y-%m-%d', date: '$timestamp' }
},
count: { $sum: 1 }
}
},
// Sort by date
{
$sort: { _id: 1 }
}
];
return Activity.aggregate(pipeline);
}
Result: Query time dropped from 3-5 seconds to ~500ms.
Step 2: Add Proper Indexes
// Compound index for this specific query pattern
activitySchema.index({ userId: 1, timestamp: 1 });
Result: Query time dropped to ~100-200ms.
Step 3: Implement Caching for Static Data
// Simple in-memory cache for dashboard summaries
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function getCachedHeatmap(userId, year) {
const cacheKey = `heatmap:${userId}:${year}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
const data = await getActivityHeatmap(userId, year);
cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}
Building the Heatmap Visualization
For the GitHub-style activity grid, I used Chart.js with a custom configuration:
// Client-side rendering with jQuery and Chart.js
$(document).ready(function() {
const ctx = document.getElementById('activityHeatmap').getContext('2d');
// Transform API data into heatmap format
const heatmapData = transformToHeatmapFormat(apiResponse);
new Chart(ctx, {
type: 'matrix', // Using chartjs-chart-matrix plugin
data: {
datasets: [{
label: 'Activity',
data: heatmapData,
backgroundColor: function(context) {
const value = context.dataset.data[context.dataIndex].v;
return getColorForValue(value);
},
width: function(context) {
return (context.chart.chartArea || {}).width / 53;
},
height: function(context) {
return (context.chart.chartArea || {}).height / 7;
}
}]
},
options: {
responsive: true,
plugins: {
tooltip: {
callbacks: {
title: function(context) {
return context[0].raw.d;
},
label: function(context) {
return context.raw.v + ' activities';
}
}
}
},
scales: {
x: {
type: 'category',
labels: getWeekLabels(),
grid: { display: false }
},
y: {
type: 'category',
labels: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
grid: { display: false }
}
}
}
});
});
// Color scale similar to GitHub
function getColorForValue(value) {
if (value === 0) return '#ebedf0';
if (value <= 3) return '#9be9a8';
if (value <= 6) return '#40c463';
if (value <= 9) return '#30a14e';
return '#216e39';
}
Heatmap Color Scale:
| Intensity | Color | Activities |
|---|---|---|
| None | #ebedf0 | 0 |
| Low | #9be9a8 | 1-3 |
| Medium | #40c463 | 4-6 |
| High | #30a14e | 7-9 |
| Very High | #216e39 | 10+ |
EJS Template Structure
The dashboard used EJS for server-side rendering:
<!-- views/dashboard.ejs -->
<!DOCTYPE html>
<html>
<head>
<title>Activity Dashboard</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/dashboard.css">
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-2 sidebar">
<%- include('partials/sidebar') %>
</nav>
<!-- Main content -->
<main class="col-md-10 main-content">
<h1>Activity Overview</h1>
<!-- Summary cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>Total Activities</h5>
<p class="display-4"><%= summary.total %></p>
</div>
</div>
</div>
<!-- More summary cards... -->
</div>
<!-- Activity heatmap -->
<div class="card">
<div class="card-header">
<h5>Activity Heatmap - <%= year %></h5>
</div>
<div class="card-body">
<canvas id="activityHeatmap"></canvas>
</div>
</div>
<!-- Recent activity table -->
<div class="card mt-4">
<div class="card-header">
<h5>Recent Activity</h5>
</div>
<div class="card-body">
<table class="table" id="activityTable">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Product</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
<% activities.forEach(function(activity) { %>
<tr>
<td><%= formatDate(activity.timestamp) %></td>
<td><%= activity.action %></td>
<td><%= activity.productId.name %></td>
<td><%= activity.metadata.duration %>s</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</div>
</main>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/chart.min.js"></script>
<script src="/js/dashboard.js"></script>
<script>
// Pass server data to client
window.heatmapData = <%- JSON.stringify(heatmapData) %>;
</script>
</body>
</html>
Lessons Learned
1. Let the Database Do the Work
My biggest mistake was pulling raw data into Node.js and processing it there. MongoDB's aggregation pipeline is incredibly powerful—use it.
Before: App Memory ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ (All data loaded)
After: App Memory ▓▓▓░░░░░░░░░░░░░░░░░ (Only results)
2. Indexes Are Not Optional
Without proper indexes, MongoDB scans every document. With indexes, it jumps directly to matching records.
Without Index: O(n) - Scans all documents
With Index: O(log n) - Binary search
For 100,000 documents:
Without: ~100,000 comparisons
With: ~17 comparisons
3. Server-Side Rendering Has Its Place
While modern apps trend toward SPAs, EJS and server-side rendering made sense for this internal tool:
- Simpler architecture
- Better initial load performance
- Easier to secure (no exposed API endpoints)
- jQuery was sufficient for interactivity
4. Delivery Over Perfection (Sometimes)
As an intern focused on getting the project done, I made tradeoffs:
- Basic caching instead of Redis
- Simple authentication instead of OAuth
- Manual deployment instead of CI/CD
These were the right calls for the timeline and context. Perfect is the enemy of done.
5. Performance Problems Compound
A 3-second query might seem acceptable, but:
- User requests summary page → 3 seconds
- User requests detailed view → 3 more seconds
- User filters by date range → 3 more seconds
Bad performance creates frustration that multiplies.
What I'd Do Differently Today
1. Use Time-Series Optimizations
MongoDB now has better time-series collection support:
db.createCollection("activities", {
timeseries: {
timeField: "timestamp",
metaField: "metadata",
granularity: "hours"
}
});
2. Implement Proper Pagination
// Cursor-based pagination instead of skip/limit
async function getActivities(userId, lastId, limit = 50) {
const query = { userId };
if (lastId) {
query._id = { $lt: lastId };
}
return Activity.find(query)
.sort({ _id: -1 })
.limit(limit);
}
3. Add Request-Level Caching
// Redis for shared cache across instances
const Redis = require('ioredis');
const redis = new Redis();
async function getCachedData(key, ttl, fetchFn) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await fetchFn();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
4. Consider Pre-Aggregation
For dashboards that don't need real-time data:
// Nightly job to pre-compute daily summaries
const dailySummary = await Activity.aggregate([
{ $match: { timestamp: { $gte: yesterday, $lt: today } } },
{ $group: {
_id: { userId: '$userId', date: { $dateToString: { format: '%Y-%m-%d', date: '$timestamp' } } },
count: { $sum: 1 },
totalDuration: { $sum: '$metadata.duration' }
}},
{ $merge: { into: 'daily_summaries' } }
]);
Dashboard Layout Overview
┌─────────────────────────────────────────────────────────────────┐
│ ACTIVITY DASHBOARD [User ▼] │
├─────────┬───────────────────────────────────────────────────────┤
│ │ │
│ NAV │ SUMMARY CARDS │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ Overview│ │ Total │ │ Today │ │ Week │ │ Trend │ │
│ Reports │ │ 12,453 │ │ 47 │ │ 312 │ │ +12% │ │
│ Settings│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ │ ACTIVITY HEATMAP │
│ │ ┌─────────────────────────────────────────────────┐ │
│ │ │ Jan Feb Mar Apr May Jun Jul Aug Sep ... │ │
│ │ │ ░░░▓░░▓▓░░░▓▓▓░░░▓░▓▓░▓▓▓░░░░▓▓░░▓▓▓▓░░░▓▓░░░ │ │
│ │ │ ░▓▓░░▓░░▓▓░░░▓░▓▓░░▓▓▓░░░▓▓▓░░░▓░░▓░░▓▓▓░░▓▓▓ │ │
│ │ │ ... (7 rows for days of week) │ │
│ │ └─────────────────────────────────────────────────┘ │
│ │ │
│ │ RECENT ACTIVITY │
│ │ ┌─────────────────────────────────────────────────┐ │
│ │ │ Date │ Action │ Product │ Duration │ │
│ │ ├────────────┼─────────┼────────────┼─────────────┤ │
│ │ │ 2024-01-15 │ view │ Product A │ 45s │ │
│ │ │ 2024-01-15 │ click │ Product B │ 12s │ │
│ │ │ 2024-01-14 │ download│ Product A │ 3s │ │
│ │ └─────────────────────────────────────────────────┘ │
│ │ │
└─────────┴───────────────────────────────────────────────────────┘
Final Thoughts
This project was my crash course in real-world development. I learned that:
- Performance matters even for internal tools
- Indexes are your best friend in MongoDB
- Aggregation pipelines are more powerful than you think
- Simple stacks (EJS, jQuery) can build functional products
- Done is better than perfect when you're learning
Two months of building this dashboard taught me more than months of tutorials could. The 3-5 second load times were painful, but debugging them taught me to think about data flow, database optimization, and user experience.
If you're early in your career and get a challenging project like this—embrace it. The struggle is where the learning happens.
Have questions about building dashboards with Node.js and MongoDB? Feel free to reach out on LinkedIn or Twitter.