A full-stack GitHub activity dashboard built with Spring Boot 3 + Vue 3
Architecture • Features • Endpoints • Getting Started • Performance • Tech Stack
GitTrack is a application that consumes the GitHub REST and GraphQL APIs to provide a comprehensive dashboard for tracking and visualizing any GitHub user's activity, repositories, contribution calendar, pinned repositories, and language statistics.
Built with a clean architecture separating concerns across backend (Java/Spring Boot) and frontend (Vue 3), it features a GitHub-inspired dark theme, real contribution graphs, and full Docker support.
┌────────────────────────────────────────────────────────────────┐
│ Frontend (Vue 3 + Vite) │
│ ┌──────────────── KeepAlive (persists state across tabs) ──┐ │
│ │ ┌──────────┐ ┌──────────────┐ ┌───────────┐ │ │
│ │ │Dashboard │ │ Repositories │ │ Activity │ (Pages) │ │
│ │ │ (1 call) │ │ (paginated) │ │(paginated)│ │ │
│ │ └────┬─────┘ └──────┬───────┘ └─────┬─────┘ │ │
│ └───────┼───────────────┼────────────────┼────────────────┘ │
│ │ │ │ │
│ ┌───────┴───────────────┴────────────────┴─────────┐ │
│ │ GithubService (Axios) │ │
│ │ + Per-endpoint In-Memory Cache │ │
│ │ + Error Interceptor (AppError) │ │
│ └──────────────────────┬───────────────────────────┘ │
│ │ HTTP REST (page, perPage) │
└─────────────────────────┼──────────────────────────────────────┘
│
┌─────────────────────────┼────────────────────────────────────────┐
│ Backend (Spring Boot 3.4.2) │
│ ┌──────────────────────┴───────────────────────────┐ │
│ │ GithubController (REST) │ │
│ │ @RequestParam page, perPage → PagedResponse<T> │ │
│ └──────────────────────┬───────────────────────────┘ │
│ ┌──────────────────────┴───────────────────────────┐ │
│ │ GithubService │ │
│ │ + @Cacheable (Caffeine, 15-min TTL) │ │
│ │ + getDashboard() → 1 GraphQL call │ │
│ │ + getReposPaged() → paginated REST │ │
│ │ + getEventsPaged()→ paginated REST │ │
│ └──────────────────────┬───────────────────────────┘ │
│ ┌──────────────────────┴───────────────────────────┐ │
│ │ GithubClient (REST + GraphQL) │ │
│ │ + RateLimitTracker (pre-check X-RateLimit-*) │ │
│ │ + RestTemplate (timeouts: 3s/5s) │ │
│ └──────────────────────┬───────────────────────────┘ │
│ ┌──────────────────────┴───────────────────────────┐ │
│ │ DTO: PagedResponse<T> (items, page, perPage, │ │
│ │ totalPages, totalItems) │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────┬───────────────────────────────────────┘
│ HTTPS
┌─────────┴──────────┐
│ GitHub API │
│ (REST + GraphQL) │
└────────────────────┘
Dashboard tab (loaded immediately on search):
- User enters a GitHub username → App.vue sets
searchedUsername DashboardPagemount/watch triggersgetDashboard(username)via Axios- Backend
/api/{username}/dashboardcallsGithubService.getDashboard() - Service executes 1 combined GraphQL query returning: profile fields, top 100 repos, languages, pinned repos, AND contribution calendar
- No REST calls needed — a single GraphQL response contains everything
- Backend extracts profile, top 6 repos (sorted by stars), language stats (aggregated from repo edges), pinned items (6), and contribution calendar weeks
- Response is cached (Caffeine + frontend Map with 5-min TTL)
- Dashboard renders progressively per section (profile, stats, graph, languages, pinned, repos)
Repositories tab (loaded on-demand when user clicks tab):
RepositoriesPagemount/watch triggersgetReposPaged(username, page=1, perPage=30)- Backend calls GitHub REST
/users/{username}/repos?page=1&per_page=30&sort=updated - Total count estimated from GraphQL
repoCount.totalCount - Response:
PagedResponse<GithubRepository>with prev/next pagination - Client-side search filter + sort (stars, name, updated) on current page
- Cache per page (each page has its own cache key)
Activity tab (loaded on-demand):
ActivityPagemount/watch triggersgetEventsPaged(username, page=1, perPage=30)- Backend calls GitHub REST
/users/{username}/events?page=1&per_page=30 - Response:
PagedResponse<Map>with timeline events + prev/next buttons
| Technique | Before | After | Reduction |
|---|---|---|---|
| Combined GraphQL (dashboard) | 3 calls (REST profile + REST repos + GraphQL) | 1 GraphQL call | 67% fewer HTTP calls |
| Paginated repos (REST) | per_page=100, no pagination |
page & perPage params |
Bandwidth per page |
| Paginated events (REST) | per_page=100, no pagination |
page & perPage params |
Bandwidth per page |
| On-demand tab loading | App.vue fetches ALL on search | Each tab fetches its own data | Lazy loading |
| KeepAlive components | Tab switch re-creates + re-fetches | State preserved, no re-fetch | Instant tab switch |
| Rate limit pre-check | Only reacted to 403 | Checks X-RateLimit-Remaining before each call |
Prevents 403s |
| HTTP timeouts | Infinite (hangs forever) | Connect: 3s, Read: 5s | No more hangs |
| Circuit breaker | None | 50% failure rate → open circuit for 30s | Stops cascading |
| Frontend caching | None | In-memory cache with TTL per endpoint | Reduces network calls |
| Progressive rendering | All-or-nothing | Per-section loading | Instant profile view |
The application implements multi-layered rate limit protection:
- Pre-check: Before any API call,
RateLimitTrackerchecks if the remaining quota is exhausted - Header tracking: After each response,
X-RateLimit-RemainingandX-RateLimit-Resetheaders are parsed and stored - Automatic backoff: When rate limited,
RateLimitExceptioncarriesretryAfterseconds - Frontend countdown: The error banner shows a live countdown and auto-retries when time expires
- Circuit breaker: If rate limit errors exceed 50% in a window of 10 calls, all requests are blocked for 30s
Unauthenticated users: 60 requests/hour (very restrictive — a token is strongly recommended)
Authenticated users: 5,000 requests/hour (set GITHUB_API_TOKEN environment variable)
| Pattern | Implementation | Why It Matters |
|---|---|---|
| GraphQL Batching | Single query fetches profile + 100 repos + languages + pinned + contributions | Eliminates N+1 problem; 1 call instead of 103 |
| Offset Pagination with PagedResponse | GET /repos?page=1&perPage=30 returning PagedResponse<T> |
Market-standard pagination; client controls page size |
| On-Demand Lazy Loading per Tab | Each tab is a KeepAlive component that fetches its own data independently |
No wasted bandwidth; instant tab switching; scales to N tabs |
| Percentage-Based Language Stats | Aggregates bytes across all repos, calculates (bytes/total)*100 |
More accurate than GitHub's per-repo view |
| Progressive Rendering (Section Skeleton) | Each dashboard section (<div class="skeleton">) hides independently as data arrives |
Instant perceived load; users see profile while languages compute |
| Circuit Breaker + Rate Limit Pre-Check | Resilience4j + X-RateLimit-Remaining header tracking |
Prevents cascading failures; graceful degradation under load |
| Frontend + Backend Dual Caching | Caffeine (server, 15-min) + Map (client, 5-15 min per endpoint) | Two layers of cache; server cache survives page refresh |
| Error Taxonomy + Retry Countdown | AppError.toDisplayError() with severity, retryable flag, i18n key; countdown auto-retry |
User-facing resilience; rate limit shows "waiting 23s..." |
| GitHub Token Seamless Fallback | Token present → 5,000 req/h; absent → 60 req/h; all features work either way | Zero-config for demo; token for production |
| KeepAlive Component Caching | Vue <KeepAlive> preserves tab state across switches |
Components are not destroyed/re-created; no data re-fetch on tab switch |
All GitHub API calls are cached at both backend and frontend:
- TTL: 15 minutes
- Max entries: 500
- Async mode: Enabled (non-blocking cache population)
- Cache names:
userProfile,userEvents,userRepos,repoLanguages,allLanguages,userStarred,pinnedRepos,contributionCalendar,graphqlBatch,reposPaged,eventsPaged
- Dashboard: 5 min TTL
- Profile/Repos/Pinned/Contributions: 15 min TTL
- Events: 5 min TTL
- Cache is cleared on new search
- REST API with 9 endpoints for GitHub data
- GraphQL API with 3 query endpoints (pinned repos, contribution calendar, top repos)
- Caching with Caffeine for optimal performance
- Error handling with global exception handler and proper HTTP status codes
- Rate limit handling for GitHub API
- GitHub token support for authenticated requests (higher rate limits)
- Dashboard with user profile, stats cards, contribution graph, pinned repos, language breakdown, and top repositories
- Repositories page with search, filter, and sort capabilities
- Activity page with animated timeline showing all GitHub events
- Contribution graph (green squares) inspired by GitHub's own calendar
- GitHub-inspired dark theme with proper typography, spacing, and color system
- Responsive design for desktop, tablet, and mobile
- Loading skeletons for better perceived performance
- Animations including fade-in, staggered list animations, hover effects, and tooltips
Base URL: http://localhost:8080/api
| Method | Endpoint | Params | Description |
|---|---|---|---|
| GET | /{username}/dashboard |
— | Aggregated dashboard (profile + top repos + languages + pinned + contributions) via 1 combined GraphQL query |
| GET | /{username}/repos |
?page=1&perPage=30 |
Paginated repositories (sorted by updated, returns PagedResponse<GithubRepository>) |
| GET | /{username}/events |
?page=1&perPage=30 |
Paginated recent events (returns PagedResponse<Map>) |
| GET | /{username}/profile |
— | User profile information |
| GET | /{username}/starred |
— | Starred repositories |
| GET | /{username}/languages |
— | Aggregated language statistics |
| GET | /{username}/repos/{repoName}/languages |
— | Language breakdown for a specific repo |
| GET | /{username}/pinned |
— | Pinned repositories (via GraphQL) |
| GET | /{username}/contributions |
— | Contribution calendar (via GraphQL) |
PagedResponse format:
{
"items": [ ... ],
"page": 1,
"perPage": 30,
"totalPages": 5,
"totalItems": 142
}Endpoint: http://localhost:8080/graphql (GraphiQL available at /graphiql)
type Query {
pinnedRepos(username: String!): [PinnedRepo!]!
contributionCalendar(username: String!): ContributionCalendar!
topRepos(username: String!, limit: Int): [GithubRepository!]!
}GET /api/{username}/profile
{
"login": "octocat",
"name": "The Octocat",
"avatarUrl": "https://avatars.githubusercontent.com/u/583231?v=4",
"htmlUrl": "https://github.com/octocat",
"bio": "A cat with eight lives",
"publicRepos": 8,
"followers": 9000,
"following": 9
}GET /api/{username}/dashboard
{
"profile": { ... },
"topRepos": [ ... ],
"totalRepos": 42,
"totalStars": 1500,
"totalForks": 300,
"languages": [
{ "language": "TypeScript", "bytes": 250000, "percentage": 45.2 },
{ "language": "Java", "bytes": 180000, "percentage": 32.6 }
],
"pinnedRepos": [ ... ],
"contributionCalendar": {
"totalContributions": 847,
"weeks": [ ... ]
}
}- Java 17+
- Node.js 18+
- Docker & Docker Compose (optional)
- Maven (optional, can use wrapper)
git clone https://github.com/Cunegundess/GitTrack.git
cd GitTrackcd backend
./mvnw spring-boot:runThe backend starts on http://localhost:8080.
cd frontend
npm install
npm run devThe frontend starts on http://localhost:4200.
docker compose up --build- Backend:
http://localhost:8080 - Frontend:
http://localhost:4200 - GraphiQL:
http://localhost:8080/graphiql
Create a GitHub Personal Access Token and configure it in backend/src/main/resources/application.properties:
github.api.token=ghp_your_token_hereThis enables:
- Higher API rate limits (5,000 vs 60 requests/hour)
- Access to GraphQL API (required for pinned repos and contribution calendar)
| Technology | Version |
|---|---|
| Java | 17 |
| Spring Boot | 3.4.2 |
| Spring GraphQL | 1.3.x |
| Spring Cache | 3.4.x |
| Caffeine Cache | 3.x |
| Maven | 3.9+ |
| Technology | Version |
|---|---|
| Vue | 3.5 |
| TypeScript | 5.7 |
| Vite | 6.x |
| Vue Router | 4.x |
| Axios | 1.7 |
| Vitest | 3.x |
| Technology | Version |
|---|---|
| Docker | Latest |
| Docker Compose | V2 |
GitTrack/
├── backend/
│ ├── src/main/java/com/cunegundess/GitTrack/
│ │ ├── GitTrackApplication.java
│ │ ├── client/
│ │ │ └── GithubClient.java # HTTP client with pagination + rate limit pre-check
│ │ ├── config/
│ │ │ ├── AppConfig.java # Bean configuration + ThreadPoolTaskExecutor
│ │ │ ├── CacheConfig.java # Caffeine cache setup
│ │ │ └── GlobalExceptionHandler.java # Centralized error handling
│ │ ├── controller/
│ │ │ ├── GithubController.java # REST endpoints with page/perPage params
│ │ │ └── GraphQLController.java # GraphQL resolver
│ │ ├── dto/
│ │ │ └── PagedResponse.java # Generic pagination DTO (items, page, perPage, totalPages, totalItems)
│ │ ├── exception/
│ │ │ └── RateLimitException.java # Rate limit error with retryAfter seconds
│ │ ├── model/
│ │ │ ├── UserProfile.java
│ │ │ ├── GithubRepository.java
│ │ │ ├── CommitActivity.java
│ │ │ ├── ContributionDay.java
│ │ │ ├── ContributionCalendar.java
│ │ │ ├── PinnedRepo.java
│ │ │ └── LanguageStat.java
│ │ └── service/
│ │ ├── GithubService.java # Combined GraphQL dashboard + paginated repos/events
│ │ ├── GithubEventTypes.java # Event type enum
│ │ └── RateLimitTracker.java # X-RateLimit-Remaining/Reset pre-check
│ └── src/main/resources/
│ ├── application.properties
│ └── graphql/
│ └── schema.graphqls
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── ContributionGraph.vue # Green squares calendar
│ │ │ ├── ErrorBanner.vue # Rate limit countdown + retry
│ │ │ ├── LanguageSwitcher.vue # EN/PT-BR toggle
│ │ │ ├── RepoCard.vue # Repository card
│ │ │ ├── SearchBar.vue # Username search
│ │ │ ├── TabContainer.vue # Tab navigation
│ │ │ ├── ThemeToggle.vue # Dark/light mode
│ │ │ ├── UserProfileCard.vue # User profile card
│ │ │ ├── StatCard.vue # Statistics card
│ │ │ ├── LanguageBar.vue # Language distribution bar
│ │ │ └── ActivityTimeline.vue # Event timeline
│ │ ├── pages/
│ │ │ ├── DashboardPage.vue # 1 combined GraphQL call, progressive per-section loading
│ │ │ ├── RepositoriesPage.vue # Paginated with prev/next + client-side filter/sort
│ │ │ └── ActivityPage.vue # Paginated events with prev/next
│ │ ├── services/
│ │ │ └── github.service.ts # Axios client with pagination + per-endpoint cache
│ │ ├── types/
│ │ │ ├── index.ts # TypeScript interfaces + PagedResponse<T>
│ │ │ └── errors.ts # AppError, DisplayError, ApiError
│ │ ├── locales/
│ │ │ ├── en.json # English translations
│ │ │ └── pt-BR.json # Brazilian Portuguese translations
│ │ ├── App.vue # Root with KeepAlive tab components
│ │ ├── main.ts # App entry point
│ │ └── style.css # Global styles + CSS variables
│ ├── index.html # Vite entry point
│ ├── vite.config.ts
│ └── package.json
├── docker-compose.yml
├── images/
│ └── frontend.png
└── README.md
The backend uses a centralized exception handler that returns consistent error responses:
{
"timestamp": "2025-02-06T12:00:00",
"status": 404,
"error": "Not Found",
"message": "GitHub user not found"
}| HTTP Status | Scenario |
|---|---|
| 404 | User not found |
| 403 | Rate limit exceeded |
| 401 | Invalid/missing GitHub token |
| 500 | Internal server error |
This section documents the real challenges faced during development, the reasoning behind each architectural decision, and the evolution of the codebase across multiple refactoring phases. It serves as both a knowledge base and a record of how real-world API constraints shaped the design.
| Phase | Problem | Root Cause | Solution | API Calls Per Search |
|---|---|---|---|---|
| 1. Initial | Dashboard took 5+ seconds to load | 101+ sequential REST calls (1 profile + 100 per-repo language calls) | N/A | 103+ |
| 2. Parallel + Cache | Still too slow; sequential bottleneck | RestTemplate called each repo's /languages endpoint one by one |
CompletableFuture.allOf() parallel execution + Caffeine @Cacheable |
3 |
| 3. Combined GraphQL | 3 calls per dashboard was still chatty | Profile (REST) + Repos (REST) + Pinned/Contribs (GraphQL) were independent | Single GraphQL query with aliases returning profile + repos + languages + pinned + contributions | 1 |
| 4. Pagination | Repos/Events pages returned 100 items with no pagination | Backend used per_page=100 without page param |
PagedResponse<T> DTO + @RequestParam page, perPage + frontend prev/next buttons |
On-demand (1 per page) |
| 5. KeepAlive Tabs | Tab switching re-created components and re-fetched data | v-if destroyed component on tab deactivation |
<KeepAlive> preserves component state across tab switches; each tab fetches its own data |
0 on tab switch |
| 6. GraphQL Auth Wall | App broke immediately without a GitHub token | GitHub GraphQL API requires authentication; returns 403 Forbidden without token, which markExceeded() treated as rate limit |
hasToken flag → GraphQL path if token present, REST fallback otherwise |
2-4 (REST) or 1 (GraphQL) |
| 7. RateLimitTracker Permanent Block | Once rate limited, app stayed blocked for 1 hour | RateLimitInfo cached with Caffeine expireAfterWrite(1, HOURS); isExceeded() only checked remaining <= 0 without verifying reset time |
isExceeded() checks now >= resetTime and auto-invalidates; reset honored |
N/A |
| 8. GraphQL 403 Misidentified | 403 from unauthenticated GraphQL called markExceeded() |
handleHttpClientError checked status == FORBIDDEN before differentiating auth vs rate-limit |
Added check: if context is "graphql" and token is blank, throw UnauthorizedException instead |
N/A |
| 9. REST Fallback N+1 | REST fallback made N language calls per repo | buildDashboardFromRest() called getAllLanguages() which iterates repos and calls getRepoLanguages per repo |
Aggregates primary language field from repo list (already returned by /users/{user}/repos) |
0 extra |
public Map<String, Object> getDashboard(String username) {
if (hasToken) {
try {
return buildDashboardFromGraphQL(username); // 1 call
} catch (UnauthorizedException | GitHubApiException e) {
log.warn("Falling back to REST");
}
}
return buildDashboardFromRest(username); // 2 calls
}Decision: Dual-path. GraphQL requires authentication but delivers everything in 1 round-trip. REST works without auth but costs 2-4 calls. The hasToken flag is set once at startup from github.api.token. This ensures the app works out-of-the-box for demo (no token) and performs optimally in production (with token).
Trade-off: More code to maintain (two dashboard builders) vs single path. Worth it for zero-config demo experience.
GraphQL does not allow querying the same field twice with different arguments in the same selection set. To get both repositories { totalCount } and repositories(first: 100, ...) { nodes { ... } }, we used an alias:
repoCount: repositories {
totalCount
}
repositories(first: 100, ownerAffiliations: OWNER, orderBy: {field: STARGAZERS, direction: DESC}) {
nodes { ... }
}Decision: Aliases are the standard GraphQL solution for this pattern. Without it, we would need two separate queries (two round-trips).
public class PagedResponse<T> {
private List<T> items;
private int page;
private int perPage;
private int totalPages;
private long totalItems;
}Decision: A generic DTO avoids duplicating pagination logic for every endpoint. The frontend PagedResponse<T> interface mirrors it exactly for type safety. totalPages is computed server-side from totalItems / perPage, keeping the client simple.
Why not cursor-based pagination? GitHub REST API uses offset-based pagination (page + per_page), so we match that. Cursor-based would require mapping GitHub's Link header, adding complexity without benefit for this use case.
<KeepAlive>
<DashboardPage v-if="activeTab === 'dashboard'" :username="searchedUsername" />
<RepositoriesPage v-else-if="activeTab === 'repositories'" :username="searchedUsername" />
<ActivityPage v-else-if="activeTab === 'activity'" :username="searchedUsername" />
</KeepAlive>Decision: KeepAlive caches the component instance so switching tabs is instant — no re-fetch, no re-render. Each tab's watch on username fires only when the username actually changes (new search), not on tab switch. This pattern is the Vue 3 equivalent of TanStack Query's staleTime.
Alternative considered: Keeping all tab data in a parent store (Pinia). Rejected because it would couple tab loading to App.vue lifecycle and prevent independent error handling per tab.
public boolean isExceeded() {
RateLimitInfo info = cache.getIfPresent("default");
if (info == null) return false;
if (info.remaining() > 0) return false;
long now = System.currentTimeMillis() / 1000;
if (now >= info.resetTime()) {
cache.invalidate("default");
return false;
}
return true;
}Decision: The original implementation used atomBoolean exceeded which once set to true was never reset. By storing resetTime from X-RateLimit-Reset header and checking it on every isExceeded() call, we automatically honor GitHub's reset window without manual intervention.
Map<String, Long> langCount = new LinkedHashMap<>();
for (GithubRepository repo : repos) {
String lang = repo.getLanguage();
if (lang != null) langCount.merge(lang, 1L, Long::sum);
}Decision: Instead of calling GET /repos/{owner}/{repo}/languages per repository (N+1), we aggregate the language field already present in each repo object from the /users/{user}/repos response. This gives "repo count per language" rather than "byte count per language", but uses zero additional API calls.
Trade-off: Less precise (counts repos, not bytes) vs saving N API calls. For the REST fallback path (60 req/h limit), saving calls is critical.
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager(/* 11 cache names */);
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(15, TimeUnit.MINUTES)
.maximumSize(500)
.recordStats());
cacheManager.setAsyncCacheMode(true);
return cacheManager;
}Decision: Caffeine (in-memory) over Redis (external). The application is a single-instance Spring Boot backend — there is no horizontal scaling that would require a shared, external cache. Redis would introduce an entire infrastructure dependency (server, volume, health checks, network config) for zero benefit in the current architecture.
Why not Redis?
| Concern | Caffeine | Redis |
|---|---|---|
| Deployment complexity | Zero — embedded in the JAR | Requires a separate Redis server + configuration |
| Latency | Sub-microsecond (same heap) | ~1-5ms per network round-trip |
| Cache size | 500 entries ~ few MB — fits comfortably in heap | Overkill for this volume |
| Persistence | Not needed — data comes from GitHub API and can be re-fetched | Would add persistence that isn't required |
| Operational cost | None | Must provision, monitor, and back up Redis |
Trade-off: If the application ever needs to scale to multiple backend instances, Redis would become necessary for a shared cache. At that point, switching from Caffeine to Redis is straightforward via Spring Cache abstraction — only the CacheManager bean changes, no @Cacheable annotations need modification.
-
Rate limits are not theoretical — GitHub's 60 req/h for unauthenticated users is extremely restrictive. Every single call counts. Design for minimal call count from day one.
-
GraphQL requires auth — Unlike GitHub REST API (which works anonymously), GraphQL always requires a token. Plan for fallback paths.
-
N+1 problems are insidious — A single loop (
for each repo: fetch languages) consumes the entire rate limit in one page load. Always count API calls in loops. -
Cache invalidation is not optional — Without auto-clearing the exceeded flag, a single 403 permanently breaks the app. The
isExceeded()check must consider time, not just state. -
KeepAlive is superior to store for tab state — Vue's
<KeepAlive>preserves DOM state (scroll position, focused inputs, animation state) that a store cannot. Tab components should own their data fetching. -
Dual caching layers protect differently — Backend Caffeine survives server restarts and is shared across users. Frontend Map cache avoids unnecessary HTTP round-trips. Both are needed.
This project is licensed under the MIT License - see the LICENSE file for details.
Developed with ❤️ by <Cunegundes />