Docker, API proxy, Drone CI, .env

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Rafał Miczek
2026-02-13 19:58:25 +01:00
co-authored by Cursor
commit d5d4883917
76 changed files with 15170 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
import { Component, OnInit, signal, effect, inject, computed } from '@angular/core';
import { RouterOutlet, NavigationEnd, Router } from '@angular/router';
import { ParticleBackgroundComponent } from './shared/components/particle-background/particle-background.component';
import { CustomCursorComponent } from './shared/components/custom-cursor/custom-cursor.component';
import { LoadingScreenComponent } from './shared/components/loading-screen/loading-screen.component';
import { NavbarComponent } from './shared/components/navbar/navbar.component';
import { FooterComponent } from './shared/components/footer/footer.component';
import { GoalsService } from './core/services/goals.service';
import { filter, map, startWith } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-root',
standalone: true,
imports: [
RouterOutlet,
ParticleBackgroundComponent,
CustomCursorComponent,
LoadingScreenComponent,
NavbarComponent,
FooterComponent,
],
template: `
<app-loading-screen
[progress]="loadingProgress"
[loadingText]="loadingText"
[fadeOut]="loadingComplete"
></app-loading-screen>
<app-custom-cursor></app-custom-cursor>
<div
class="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50 to-purple-50 relative overflow-hidden transition-opacity duration-500 flex flex-col"
[class.opacity-0]="!loadingComplete()"
>
<app-particle-background></app-particle-background>
<div class="relative z-10 flex flex-col min-h-screen">
@if (showNavbarAndFooter()) {
<app-navbar></app-navbar>
}
<main [class.max-w-7xl]="showNavbarAndFooter()" [class.mx-auto]="showNavbarAndFooter()" [class.px-4]="showNavbarAndFooter()" [class.sm:px-6]="showNavbarAndFooter()" [class.lg:px-8]="showNavbarAndFooter()" [class.py-8]="showNavbarAndFooter()" class="flex-1 w-full relative z-10">
<router-outlet></router-outlet>
</main>
@if (showNavbarAndFooter()) {
<app-footer></app-footer>
}
</div>
</div>
`,
styles: [],
})
export class AppComponent implements OnInit {
title = 'goals-tracker';
loadingProgress = signal(0);
loadingText = signal('Initializing...');
loadingComplete = signal(false);
private readonly goalsService = inject(GoalsService);
private readonly router = inject(Router);
private readonly currentUrl = toSignal(
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map((event) => (event as NavigationEnd).url),
startWith(this.router.url)
),
{ initialValue: this.router.url }
);
readonly showNavbarAndFooter = computed(() => {
const url = this.currentUrl();
// Hide navbar and footer on dashboard (root path)
return url !== '/' && !url.startsWith('/?');
});
constructor() {
// Track loading progress and text from service
effect(() => {
const serviceProgress = this.goalsService.loadingProgress$();
const serviceText = this.goalsService.loadingText$();
if (serviceProgress > 0) {
// Add 5% for initial setup, service progress goes 0-95%
this.loadingProgress.set(Math.min(95, 5 + serviceProgress));
}
if (serviceText) {
this.loadingText.set(serviceText);
}
});
effect(() => {
if (this.loadingComplete()) {
// Hide loading screen after fade-out animation
setTimeout(() => {
// Component will be hidden via CSS
}, 500);
}
});
}
ngOnInit(): void {
this.loadAllData();
// Track route changes for navigation loading
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
// Data is cached, no need to reload
});
}
private loadAllData(): void {
this.loadingText.set('Initializing...');
this.loadingProgress.set(5);
// Small delay to show initial state
setTimeout(() => {
this.goalsService.preloadAllData().subscribe({
next: () => {
this.loadingText.set('Complete!');
this.loadingProgress.set(100);
setTimeout(() => {
this.loadingComplete.set(true);
}, 300);
},
error: () => {
// Even on error, show the app
this.loadingText.set('Ready');
this.loadingProgress.set(100);
setTimeout(() => {
this.loadingComplete.set(true);
}, 300);
},
});
}, 200);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { apiInterceptor } from './core/interceptors/api.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([apiInterceptor])),
],
};
+33
View File
@@ -0,0 +1,33 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./features/dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'sport',
loadComponent: () =>
import('./features/sport/sport-goals.component').then(
(m) => m.SportGoalsComponent
),
},
{
path: 'gaming',
loadComponent: () =>
import('./features/gaming/gaming-goals.component').then(
(m) => m.GamingGoalsComponent
),
},
{
path: 'auth/strava/callback',
loadComponent: () =>
import('./features/sport/strava-callback.component').then(
(m) => m.StravaCallbackComponent
),
},
];
+12
View File
@@ -0,0 +1,12 @@
import { provideServerRendering } from '@angular/platform-server';
import { AppComponent } from './app.component';
import { appConfig } from './app.config';
export const AppServerModule = {
bootstrap: AppComponent,
providers: [
...appConfig.providers,
provideServerRendering(),
],
};
@@ -0,0 +1,57 @@
import { HttpInterceptorFn, HttpRequest } from '@angular/common/http';
let requestCount = 0;
const requestSummary: Record<string, number> = {};
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
console.log('=== API Request Summary ===');
Object.entries(requestSummary).forEach(([api, count]) => {
console.log(`${api}: ${count} requests`);
});
console.log(`Total: ${requestCount} requests`);
});
setTimeout(() => {
console.log('=== API Request Summary (5s) ===');
Object.entries(requestSummary).forEach(([api, count]) => {
console.log(`${api}: ${count} requests`);
});
console.log(`Total: ${requestCount} requests`);
}, 5000);
}
function getApiType(req: HttpRequest<unknown>): string {
const url = req.url;
if (url.includes('/api/auth/strava') || url.includes('strava.com')) {
return 'Strava';
}
if (url.includes('/api/proxy/riot') || url.includes('api.riotgames.com')) {
return 'Riot';
}
if (url.includes('/api/proxy/faceit') || url.includes('open.faceit.com')) {
return 'Faceit';
}
if (url.includes('/api/proxy/tracker') || url.includes('tracker.gg')) {
return 'Tracker.gg';
}
return 'Unknown';
}
function logRequest(req: HttpRequest<unknown>, apiType: string): void {
requestCount++;
requestSummary[apiType] = (requestSummary[apiType] || 0) + 1;
console.log(
`[API Request #${requestCount}] ${apiType} | ${req.method} ${req.url}`
);
}
/**
* API interceptor - pass through all requests (auth is handled by backend proxy).
* Optionally logs request summary.
*/
export const apiInterceptor: HttpInterceptorFn = (req, next) => {
const apiType = getApiType(req);
logRequest(req, apiType);
return next(req);
};
+304
View File
@@ -0,0 +1,304 @@
// Strava API Responses
export interface StravaActivity {
id: number;
name: string;
type: string;
distance: number; // meters
start_date: string;
moving_time: number; // seconds
sport_type?: string;
}
export interface StravaTokenResponse {
access_token: string;
refresh_token: string;
expires_at: number;
athlete: {
id: number;
};
}
// Riot API Responses
export interface RiotAccount {
puuid: string;
gameName: string;
tagLine: string;
}
export interface RiotSummoner {
id: string;
accountId: string;
puuid: string;
name: string;
profileIconId: number;
revisionDate: number;
summonerLevel: number;
}
export interface RiotLeagueEntry {
leagueId: string;
summonerId: string;
summonerName: string;
queueType: string;
tier: string;
rank: string;
leaguePoints: number;
wins: number;
losses: number;
veteran: boolean;
inactive: boolean;
freshBlood: boolean;
hotStreak: boolean;
}
export interface RiotMatch {
metadata: {
matchId: string;
participants: string[];
};
info: {
gameCreation: number;
gameDuration: number;
gameEndTimestamp: number;
participants: Array<{
puuid: string;
teamId: number;
win: boolean;
championName: string;
kills: number;
deaths: number;
assists: number;
}>;
teams: Array<{
teamId: number;
win: boolean;
}>;
};
}
export interface TFTMatch {
metadata: {
data_version: string;
match_id: string;
participants: string[];
};
info: {
game_datetime: number;
game_length: number;
game_version: string;
participants: Array<{
puuid: string;
placement: number;
level: number;
gold_left: number;
last_round: number;
time_eliminated: number;
total_damage_to_players: number;
traits: Array<{
name: string;
num_units: number;
style: number;
tier_current: number;
tier_total: number;
}>;
units: Array<{
character_id: string;
tier: number;
items: number[];
}>;
}>;
queue_id: number;
tft_game_type: string;
tft_set_core_name: string;
tft_set_number: number;
};
}
// Faceit API Responses
export interface FaceitPlayerSearch {
items: Array<{
player_id: string;
nickname: string;
avatar: string;
country: string;
}>;
}
export interface FaceitPlayer {
player_id: string;
nickname: string;
avatar: string;
country: string;
cover_image: string;
cover_featured_image: string;
infractions: unknown;
verified: boolean;
faceit_url: string;
membership_type: string;
membership_subscriptions: unknown[];
games: {
[key: string]: {
game_profile_id: string;
region: string;
regions: string[];
skill_level: number;
faceit_elo: number;
game_player_id: string;
game_player_name: string;
skill_level_label: string;
regions_object: unknown[];
game_regions: unknown[];
};
};
friends_ids: string[];
bans: unknown[];
new_steam_id: string;
steam_id_64: string;
steam_nickname: string;
memberships: string[];
faceit_elo: number;
created_at: number;
email: string;
}
export interface FaceitMatch {
match_id: string;
game_id: string;
region: string;
match_type: string;
game_mode: string;
max_players: number;
teams_size: number;
teams: {
faction1: {
team_id: string;
nickname: string;
avatar: string;
type: string;
players: Array<{
player_id: string;
nickname: string;
avatar: string;
skill_level: number;
game_player_id: string;
game_player_name: string;
faceit_elo: number;
}>;
};
faction2: {
team_id: string;
nickname: string;
avatar: string;
type: string;
players: Array<{
player_id: string;
nickname: string;
avatar: string;
skill_level: number;
game_player_id: string;
game_player_name: string;
faceit_elo: number;
}>;
};
};
playing_players: string[];
competition_id: string;
competition_name: string;
competition_type: string;
organizer_id: string;
status: string;
started_at: number;
finished_at: number;
results: {
winner: string;
score: {
faction1: number;
faction2: number;
};
};
}
export interface FaceitMatchStats {
rounds: Array<{
best_of: string;
competition_id: string;
game_id: string;
game_mode: string;
match_id: string;
match_round: string;
played: string;
round_stats: Record<string, string>;
teams: Array<{
team_id: string;
premade: boolean;
team_stats: Record<string, string>;
players: Array<{
player_id: string;
nickname: string;
player_stats: Record<string, string>;
}>;
}>;
}>;
}
// Tracker.gg API Responses
export interface TrackerGGProfile {
data: {
platformInfo: {
platformSlug: string;
platformUserId: string;
platformUserHandle: string;
platformUserIdentifier: string;
avatarUrl: string;
additionalParameters: unknown;
};
userInfo: {
userId: string;
isPremium: boolean;
isVerified: boolean;
isInfluencer: boolean;
isPartner: boolean;
countryCode: string;
customAvatarUrl: string;
customHeroUrl: string;
socialAccounts: unknown[];
pageviews: number;
isSuspicious: boolean;
};
metadata: {
lastUpdated: {
value: string;
displayValue: string;
};
};
segments: Array<{
type: string;
attributes: {
playlistId: string;
playlistName: string;
rank: {
metadata: {
iconUrl: string;
rankName: string;
tierName: string;
tier: number;
};
value: number;
displayValue: string;
};
rating: {
value: number;
displayValue: string;
};
};
metadata: {
name: string;
};
expiryDate: string;
stats: unknown;
}>;
availableSegments: unknown[];
expiryDate: string;
};
}
+74
View File
@@ -0,0 +1,74 @@
export type GameType = 'tft' | 'lol' | 'rocket-league' | 'faceit';
export interface Match {
id: string;
game: GameType;
date: Date;
result: 'win' | 'loss' | 'draw';
rank?: string;
lp?: number;
lpChange?: number;
score?: string;
opponent?: string;
champion?: string;
kda?: string;
placement?: number; // For TFT (1-8)
duration?: number; // Game duration in seconds
map?: string;
matchUrl?: string; // Link to match details on external site
isPromotion?: boolean; // Ranked up after this match
isDemotion?: boolean; // Ranked down after this match
newRank?: string; // The new rank after promotion/demotion
}
export interface RankInfo {
tier: string;
rank?: string;
leaguePoints: number;
wins: number;
losses: number;
hotStreak?: boolean;
veteran?: boolean;
freshBlood?: boolean;
}
export interface Streak {
type: 'win' | 'loss' | 'none';
count: number;
}
export interface GamingStats {
winRate: number;
totalGames: number;
recentWins: number;
recentLosses: number;
streak: Streak;
elo?: number;
peakRank?: string;
avgPlacement?: number; // For TFT
kda?: number; // KDA ratio (for Faceit)
adr?: number; // Average Damage per Round (for Faceit)
}
export interface GamingGoal {
game: GameType;
target: string; // 'Diamond', 'Champion', 'Level 10'
current: string;
progress: number; // 0-100
recentMatches: Match[];
rankInfo?: RankInfo;
stats?: GamingStats;
}
export interface GamingProgress {
tft: GamingGoal;
lol: GamingGoal;
rocketLeague: GamingGoal;
faceit: GamingGoal;
}
+41
View File
@@ -0,0 +1,41 @@
export type SportType = 'bike' | 'run' | 'swim';
export interface Activity {
id: number;
name: string;
type: SportType;
distance: number; // meters
startDate: Date;
movingTime: number; // seconds
}
export interface SportGoal {
type: SportType;
target: number; // km
current: number; // km
percentage: number;
activities: Activity[];
}
export interface SportProgress {
bike: SportGoal;
run: SportGoal;
swim: SportGoal;
overallPercentage: number;
}
+550
View File
@@ -0,0 +1,550 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, forkJoin, from } from 'rxjs';
import { map, catchError, switchMap, concatMap, delay, toArray } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import {
GamingGoal,
GamingStats,
Match,
Streak,
} from '../models/gaming-goal.model';
import {
FaceitPlayer,
FaceitMatch,
FaceitMatchStats,
FaceitPlayerSearch,
} from '../models/api-response.model';
const FACEIT_API_BASE = '/api/proxy/faceit/data/v4';
const FACEIT_GAME_ID = 'cs2'; // CS2 game ID
@Injectable({
providedIn: 'root',
})
export class FaceitService {
private readonly http = inject(HttpClient);
private readonly userId = environment.faceit.userId;
/**
* Get Faceit playerId (for Load More functionality)
*/
getFaceitPlayerId(): Observable<string | null> {
if (!this.userId) {
return of(null);
}
return this.searchPlayer(this.userId).pipe(
catchError(() => of(null))
);
}
/**
* Get Faceit level and progress with full stats
*/
getFaceitProgress(): Observable<GamingGoal> {
if (!this.userId) {
return of(this.getEmptyGoal());
}
// First search for player by nickname to get player_id
return this.searchPlayer(this.userId).pipe(
switchMap((playerId) => {
if (!playerId) {
console.warn('Faceit player not found. Returning empty goal.');
return of(this.getEmptyGoal());
}
// Fetch player info and match history in parallel
return forkJoin({
player: this.getPlayerById(playerId),
matches: this.getPlayerMatches(playerId),
stats: this.getPlayerStats(playerId),
}).pipe(
map(({ player, matches, stats }) => {
const gameData = player.games[FACEIT_GAME_ID];
const level = gameData?.skill_level || 0;
const currentElo = gameData?.faceit_elo || 0;
const current = `Level ${level}`;
// Calculate progress based on ELO (Level 10 = 2001+ ELO)
const progress = this.calculateEloProgress(currentElo, 2001);
// Calculate rank changes for each match based on ELO
const matchesWithRankChanges = this.calculateMatchRankChanges(
matches,
currentElo
);
// Calculate streak from recent matches
const streak = this.calculateStreak(matchesWithRankChanges);
const recentWins = matchesWithRankChanges.filter(
(m) => m.result === 'win'
).length;
const recentLosses = matchesWithRankChanges.filter(
(m) => m.result === 'loss'
).length;
const gamingStats: GamingStats = {
winRate: stats?.winRate ?? 0,
totalGames: stats?.totalGames ?? 0,
recentWins,
recentLosses,
streak,
elo: currentElo,
kda: stats?.kda,
adr: stats?.adr,
};
const goal: GamingGoal = {
game: 'faceit',
target: 'Level 10',
current,
progress,
recentMatches: matchesWithRankChanges,
stats: gamingStats,
};
return goal;
})
);
}),
catchError((error) => {
console.error('Error fetching Faceit progress:', error);
if (error.status === 404) {
console.warn(
'Faceit player not found. Verify your Faceit username. ' +
'Find it in your profile URL: https://www.faceit.com/en/players/YOUR_USERNAME'
);
} else if (error.status === 401 || error.status === 403) {
console.warn(
'Faceit API authentication failed. Verify your API key at: ' +
'https://developers.faceit.com/'
);
}
return of(this.getEmptyGoal());
})
);
}
/**
* Get recent matches (public method)
*/
getRecentMatches(): Observable<Match[]> {
if (!this.userId) {
return of([]);
}
return this.searchPlayer(this.userId).pipe(
switchMap((playerId) => {
if (!playerId) {
return of([]);
}
return this.getPlayerMatches(playerId);
}),
catchError(() => of([]))
);
}
/**
* Get player's match history by player ID with ELO changes
*/
private getPlayerMatches(playerId: string): Observable<Match[]> {
return this.http
.get<{ items: FaceitMatch[] }>(
`${FACEIT_API_BASE}/players/${playerId}/history`,
{ params: { game: FACEIT_GAME_ID, limit: '5' } }
)
.pipe(
switchMap((response) => {
const matches = response.items || [];
if (matches.length === 0) {
return of([]);
}
// Fetch match stats in batches of 5 to avoid rate limiting
const batchSize = 5;
const batches: typeof matches[] = [];
for (let i = 0; i < matches.length; i += batchSize) {
batches.push(matches.slice(i, i + batchSize));
}
// Process batches sequentially with delay between batches
return from(batches).pipe(
concatMap((batch, index) => {
const batchRequests = batch.map((match) =>
this.getMatchStats(match.match_id, playerId).pipe(
map((eloChange) => this.mapToMatch(match, playerId, eloChange)),
catchError(() => of(this.mapToMatch(match, playerId, null)))
)
);
return forkJoin(batchRequests).pipe(
// Add delay between batches (except first one)
delay(index === 0 ? 0 : 200)
);
}),
// Collect all batches into a single array
toArray(),
map((allBatches) => {
// Flatten array of arrays
return allBatches.flat();
})
);
}),
catchError(() => of([]))
);
}
/**
* Get more Faceit matches (for Load More functionality)
*/
getMoreFaceitMatches(playerId: string, startIndex: number, count: number = 5): Observable<Match[]> {
if (!playerId) {
return of([]);
}
return this.http
.get<{ items: FaceitMatch[] }>(
`${FACEIT_API_BASE}/players/${playerId}/history`,
{
params: {
game: FACEIT_GAME_ID,
limit: count.toString(),
offset: startIndex.toString(),
},
}
)
.pipe(
switchMap((response) => {
const matches = response.items || [];
if (matches.length === 0) {
return of([]);
}
// Fetch match stats in batches of 5 to avoid rate limiting
const batchSize = 5;
const batches: typeof matches[] = [];
for (let i = 0; i < matches.length; i += batchSize) {
batches.push(matches.slice(i, i + batchSize));
}
// Process batches sequentially with delay between batches
return from(batches).pipe(
concatMap((batch, index) => {
const batchRequests = batch.map((match) =>
this.getMatchStats(match.match_id, playerId).pipe(
map((eloChange) => this.mapToMatch(match, playerId, eloChange)),
catchError(() => of(this.mapToMatch(match, playerId, null)))
)
);
return forkJoin(batchRequests).pipe(
// Add delay between batches (except first one)
delay(index === 0 ? 0 : 200)
);
}),
// Collect all batches into a single array
toArray(),
map((allBatches) => {
// Flatten array of arrays
return allBatches.flat();
})
);
}),
catchError((error) => {
console.error('Error fetching more Faceit matches:', error);
return of([]);
})
);
}
/**
* Get match stats to extract ELO change for player
*/
private getMatchStats(
matchId: string,
playerId: string
): Observable<number | null> {
return this.http
.get<FaceitMatchStats>(`${FACEIT_API_BASE}/matches/${matchId}/stats`)
.pipe(
map((stats) => {
// Find player in the stats to get ELO change
for (const round of stats.rounds || []) {
for (const team of round.teams || []) {
const player = team.players?.find(
(p) => p.player_id === playerId
);
if (player?.player_stats?.['Elo']) {
// Some stats show current ELO, not change
// Try to find elo_change if available
const eloChange = player.player_stats?.['Elo Change'];
if (eloChange) {
return parseInt(eloChange, 10);
}
}
}
}
return null;
}),
catchError(() => of(null))
);
}
/**
* Get player's stats
*/
private getPlayerStats(
playerId: string
): Observable<{ winRate: number; totalGames: number; kda: number; adr: number } | null> {
return this.http
.get<{
lifetime: {
Matches: string;
'Win Rate %': string;
Wins: string;
'Current Win Streak': string;
'Longest Win Streak': string;
'Average K/D Ratio': string;
'Average Damage per Round': string;
};
}>(`${FACEIT_API_BASE}/players/${playerId}/stats/${FACEIT_GAME_ID}`)
.pipe(
map((response) => {
const lifetime = response.lifetime;
return {
winRate: parseFloat(lifetime['Win Rate %']) || 0,
totalGames: parseInt(lifetime.Matches, 10) || 0,
kda: parseFloat(lifetime['Average K/D Ratio']) || 0,
adr: parseFloat(lifetime['Average Damage per Round']) || 0,
};
}),
catchError(() => of(null))
);
}
/**
* Calculate current streak from matches
*/
private calculateStreak(matches: Match[]): Streak {
if (matches.length === 0) {
return { type: 'none', count: 0 };
}
const firstResult = matches[0].result;
if (firstResult === 'draw') {
return { type: 'none', count: 0 };
}
let count = 0;
for (const match of matches) {
if (match.result === firstResult) {
count++;
} else {
break;
}
}
return { type: firstResult, count };
}
/**
* Search for player by nickname to get player_id
*/
private searchPlayer(nickname: string): Observable<string | null> {
return this.http
.get<FaceitPlayerSearch>(`${FACEIT_API_BASE}/search/players`, {
params: {
nickname: nickname,
game: FACEIT_GAME_ID,
limit: '1',
},
})
.pipe(
map((response) => {
if (response.items && response.items.length > 0) {
// Find exact match (case-insensitive)
const exactMatch = response.items.find(
(item) =>
item.nickname.toLowerCase() === nickname.toLowerCase()
);
return exactMatch?.player_id || response.items[0].player_id;
}
return null;
}),
catchError((error) => {
console.error('Error searching for Faceit player:', error);
return of(null);
})
);
}
/**
* Get player by player_id
*/
private getPlayerById(playerId: string): Observable<FaceitPlayer> {
return this.http.get<FaceitPlayer>(
`${FACEIT_API_BASE}/players/${playerId}`
);
}
private mapToMatch(
match: FaceitMatch,
playerId: string,
eloChange: number | null = null
): Match {
// Determine which faction the player is on
let result: 'win' | 'loss' | 'draw' = 'draw';
let playerFaction: 'faction1' | 'faction2' | null = null;
if (match.teams?.faction1?.players?.some((p) => p.player_id === playerId)) {
playerFaction = 'faction1';
} else if (
match.teams?.faction2?.players?.some((p) => p.player_id === playerId)
) {
playerFaction = 'faction2';
}
if (playerFaction && match.results?.winner) {
result = match.results.winner === playerFaction ? 'win' : 'loss';
}
const score = match.results?.score
? `${match.results.score.faction1}-${match.results.score.faction2}`
: undefined;
// Faceit has direct match room URLs
const matchUrl = `https://www.faceit.com/en/cs2/room/${match.match_id}`;
// Only include ELO change if we have real data from the API
return {
id: match.match_id,
game: 'faceit',
date: new Date(match.started_at * 1000),
result,
score,
map: match.game_mode,
duration: match.finished_at
? match.finished_at - match.started_at
: undefined,
matchUrl,
lpChange: eloChange ?? undefined,
};
}
/**
* Faceit ELO thresholds for each level
* Source: https://support.faceit.com/hc/en-us/articles/208511105-Skill-Level-and-ELO
*/
private readonly eloThresholds: number[] = [
0, // Level 1: 1-500
501, // Level 2: 501-750
751, // Level 3: 751-900
901, // Level 4: 901-1050
1051, // Level 5: 1051-1200
1201, // Level 6: 1201-1350
1351, // Level 7: 1351-1530
1531, // Level 8: 1531-1750
1751, // Level 9: 1751-2000
2001, // Level 10: 2001+
];
/**
* Get Faceit level from ELO
*/
private getLevelFromElo(elo: number): number {
for (let i = this.eloThresholds.length - 1; i >= 0; i--) {
if (elo >= this.eloThresholds[i]) {
return i + 1;
}
}
return 1;
}
/**
* Check if ELO change resulted in promotion/demotion
*/
private checkRankChange(
currentElo: number,
eloChange: number
): { isPromotion: boolean; isDemotion: boolean; newRank?: string } {
const previousElo = currentElo - eloChange;
const currentLevel = this.getLevelFromElo(currentElo);
const previousLevel = this.getLevelFromElo(previousElo);
if (currentLevel > previousLevel) {
return {
isPromotion: true,
isDemotion: false,
newRank: `Level ${currentLevel}`,
};
} else if (currentLevel < previousLevel) {
return {
isPromotion: false,
isDemotion: true,
newRank: `Level ${currentLevel}`,
};
}
return { isPromotion: false, isDemotion: false };
}
/**
* Calculate rank changes for each match by working backwards from current ELO
* Matches are ordered most recent first
*/
private calculateMatchRankChanges(
matches: Match[],
currentElo: number
): Match[] {
let runningElo = currentElo;
// Process matches from most recent to oldest
return matches.map((match) => {
const eloChange = match.lpChange ?? 0;
const eloAfterMatch = runningElo;
// Check if this match caused a rank change
const rankChange = this.checkRankChange(eloAfterMatch, eloChange);
// Update running ELO for next iteration (going backwards in time)
runningElo = eloAfterMatch - eloChange;
return {
...match,
isPromotion: rankChange.isPromotion,
isDemotion: rankChange.isDemotion,
newRank: rankChange.newRank,
};
});
}
private calculateLevelProgress(current: number, target: number): number {
if (current >= target) {
return 100;
}
return (current / target) * 100;
}
/**
* Calculate progress based on ELO (more precise than level-based)
* Target Level 10 = 2001 ELO
*/
private calculateEloProgress(currentElo: number, targetElo: number): number {
if (currentElo >= targetElo) {
return 100;
}
// Calculate progress: (current ELO / target ELO) * 100
// This gives more granular progress than just level-based
return Math.min(100, (currentElo / targetElo) * 100);
}
private getEmptyGoal(): GamingGoal {
return {
game: 'faceit',
target: 'Level 10',
current: 'Level 0',
progress: 0,
recentMatches: [],
};
}
}
+359
View File
@@ -0,0 +1,359 @@
import { inject, Injectable, signal, computed } from '@angular/core';
import { Observable, combineLatest, of, forkJoin } from 'rxjs';
import { map, catchError, shareReplay } from 'rxjs/operators';
import { StravaService } from './strava.service';
import { RiotService } from './riot.service';
import { FaceitService } from './faceit.service';
import { TrackerGGService } from './tracker-gg.service';
import { environment } from '../../../environments/environment';
import {
SportProgress,
SportGoal,
SportType,
} from '../models/sport-goal.model';
import { GamingProgress, GamingGoal } from '../models/gaming-goal.model';
import { getIdealProgress } from '../../shared/utils/date.utils';
@Injectable({
providedIn: 'root',
})
export class GoalsService {
private readonly stravaService = inject(StravaService);
private readonly riotService = inject(RiotService);
private readonly faceitService = inject(FaceitService);
private readonly trackerService = inject(TrackerGGService);
private readonly sportProgress = signal<SportProgress | null>(null);
private readonly gamingProgress = signal<GamingProgress | null>(null);
private readonly isLoading = signal(false);
private readonly loadingProgress = signal(0);
private readonly loadingText = signal('Initializing...');
private sportGoalsRequest$: Observable<SportProgress> | null = null;
private gamingGoalsRequest$: Observable<GamingProgress> | null = null;
readonly sportProgress$ = this.sportProgress.asReadonly();
readonly gamingProgress$ = this.gamingProgress.asReadonly();
readonly isLoading$ = this.isLoading.asReadonly();
readonly loadingProgress$ = this.loadingProgress.asReadonly();
readonly loadingText$ = this.loadingText.asReadonly();
readonly overallSportProgress = computed(() => {
const progress = this.sportProgress();
if (!progress) {
return 0;
}
return progress.overallPercentage;
});
readonly overallGamingProgress = computed(() => {
const progress = this.gamingProgress();
if (!progress) {
return 0;
}
const total =
progress.tft.progress +
progress.lol.progress +
progress.rocketLeague.progress +
progress.faceit.progress;
return total / 4;
});
/**
* Load all sport goals (cached after first load)
*/
loadSportGoals(forceRefresh = false): Observable<SportProgress> {
// Return cached data if available and not forcing refresh
if (!forceRefresh && this.sportProgress() !== null) {
return of(this.sportProgress()!);
}
// Return existing request if already in progress
if (this.sportGoalsRequest$ && !forceRefresh) {
return this.sportGoalsRequest$;
}
const goals = environment.goals.sport;
this.sportGoalsRequest$ = combineLatest({
bike: this.stravaService.getSportProgress('bike', goals.bike),
run: this.stravaService.getSportProgress('run', goals.run),
swim: this.stravaService.getSportProgress('swim', goals.swim),
}).pipe(
map((progress) => {
// Calculate overall percentage as average of the three sports
const overallPercentage = Math.min(
100,
(progress.bike.percentage + progress.run.percentage + progress.swim.percentage) / 3
);
const sportProgress: SportProgress = {
bike: progress.bike,
run: progress.run,
swim: progress.swim,
overallPercentage,
};
this.sportProgress.set(sportProgress);
return sportProgress;
}),
catchError(() => {
const empty: SportProgress = {
bike: this.getEmptySportGoal('bike', goals.bike),
run: this.getEmptySportGoal('run', goals.run),
swim: this.getEmptySportGoal('swim', goals.swim),
overallPercentage: 0,
};
this.sportProgress.set(empty);
return of(empty);
}),
shareReplay(1)
);
return this.sportGoalsRequest$;
}
/**
* Load all gaming goals (cached after first load)
*/
loadGamingGoals(forceRefresh = false): Observable<GamingProgress> {
// Return cached data if available and not forcing refresh
if (!forceRefresh && this.gamingProgress() !== null) {
return of(this.gamingProgress()!);
}
// Return existing request if already in progress
if (this.gamingGoalsRequest$ && !forceRefresh) {
return this.gamingGoalsRequest$;
}
this.gamingGoalsRequest$ = combineLatest({
tft: this.riotService.getTFTRank(),
lol: this.riotService.getLoLRank(),
rocketLeague: this.trackerService.getRocketLeagueRank(),
faceit: this.faceitService.getFaceitProgress(),
}).pipe(
map((progress) => {
const gamingProgress: GamingProgress = {
tft: progress.tft,
lol: progress.lol,
rocketLeague: progress.rocketLeague,
faceit: progress.faceit,
};
this.gamingProgress.set(gamingProgress);
return gamingProgress;
}),
catchError(() => {
const empty: GamingProgress = {
tft: this.getEmptyGamingGoal('tft', 'Diamond'),
lol: this.getEmptyGamingGoal('lol', 'Diamond'),
rocketLeague: this.getEmptyGamingGoal('rocket-league', 'Champion'),
faceit: this.getEmptyGamingGoal('faceit', 'Level 10'),
};
this.gamingProgress.set(empty);
return of(empty);
}),
shareReplay(1)
);
return this.gamingGoalsRequest$;
}
/**
* Preload all data with progress tracking
*/
preloadAllData(): Observable<{ sport: SportProgress; gaming: GamingProgress }> {
this.isLoading.set(true);
this.loadingProgress.set(0);
this.loadingText.set('Loading sport data...');
const totalSteps = 7; // 3 sports + 4 gaming services
let completedSteps = 0;
const updateProgress = (stepName: string): void => {
completedSteps++;
const progress = Math.min(90, Math.round((completedSteps / totalSteps) * 85));
this.loadingProgress.set(progress);
this.loadingText.set(stepName);
};
// Create observables with progress tracking
const goals = environment.goals.sport;
const bike$ = this.stravaService.getSportProgress('bike', goals.bike).pipe(
map((result) => {
updateProgress('Loading biking data...');
return result;
})
);
const run$ = this.stravaService.getSportProgress('run', goals.run).pipe(
map((result) => {
updateProgress('Loading running data...');
return result;
})
);
const swim$ = this.stravaService.getSportProgress('swim', goals.swim).pipe(
map((result) => {
updateProgress('Loading swimming data...');
return result;
})
);
const tft$ = this.riotService.getTFTRank().pipe(
map((result) => {
updateProgress('Loading TFT data...');
return result;
})
);
const lol$ = this.riotService.getLoLRank().pipe(
map((result) => {
updateProgress('Loading LoL data...');
return result;
})
);
const rocketLeague$ = this.trackerService.getRocketLeagueRank().pipe(
map((result) => {
updateProgress('Loading Rocket League data...');
return result;
})
);
const faceit$ = this.faceitService.getFaceitProgress().pipe(
map((result) => {
updateProgress('Loading Faceit data...');
return result;
})
);
return forkJoin({
sport: combineLatest({ bike: bike$, run: run$, swim: swim$ }).pipe(
map((progress) => {
const overallPercentage = Math.min(
100,
(progress.bike.percentage + progress.run.percentage + progress.swim.percentage) / 3
);
const sportProgress: SportProgress = {
bike: progress.bike,
run: progress.run,
swim: progress.swim,
overallPercentage,
};
this.sportProgress.set(sportProgress);
return sportProgress;
}),
catchError(() => {
const empty: SportProgress = {
bike: this.getEmptySportGoal('bike', goals.bike),
run: this.getEmptySportGoal('run', goals.run),
swim: this.getEmptySportGoal('swim', goals.swim),
overallPercentage: 0,
};
this.sportProgress.set(empty);
return of(empty);
})
),
gaming: combineLatest({
tft: tft$,
lol: lol$,
rocketLeague: rocketLeague$,
faceit: faceit$,
}).pipe(
map((progress) => {
const gamingProgress: GamingProgress = {
tft: progress.tft,
lol: progress.lol,
rocketLeague: progress.rocketLeague,
faceit: progress.faceit,
};
this.gamingProgress.set(gamingProgress);
return gamingProgress;
}),
catchError(() => {
const empty: GamingProgress = {
tft: this.getEmptyGamingGoal('tft', 'Diamond'),
lol: this.getEmptyGamingGoal('lol', 'Diamond'),
rocketLeague: this.getEmptyGamingGoal('rocket-league', 'Champion'),
faceit: this.getEmptyGamingGoal('faceit', 'Level 10'),
};
this.gamingProgress.set(empty);
return of(empty);
})
),
}).pipe(
map((result) => {
this.loadingText.set('Finalizing...');
this.loadingProgress.set(95);
this.isLoading.set(false);
return result;
}),
catchError((error) => {
this.isLoading.set(false);
this.loadingText.set('Error loading data');
throw error;
})
);
}
/**
* Check if on track for sport goals
*/
isOnTrackForSport(progress: SportProgress): boolean {
const ideal = getIdealProgress();
return progress.overallPercentage >= ideal;
}
/**
* Get projection data for charts
*/
getSportProjection(progress: SportProgress): {
actual: number;
ideal: number;
projected: number;
} {
const ideal = getIdealProgress();
const daysElapsed = this.getDaysElapsed();
const totalDays = 365;
if (daysElapsed === 0) {
return { actual: 0, ideal: 0, projected: 0 };
}
const dailyAverage = progress.overallPercentage / daysElapsed;
const projected = dailyAverage * totalDays;
return {
actual: progress.overallPercentage,
ideal,
projected: Math.min(100, projected),
};
}
private getDaysElapsed(): number {
const now = new Date();
const start = new Date('2026-01-01');
const diff = now.getTime() - start.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
private getEmptySportGoal(type: SportType, target: number): SportGoal {
return {
type,
target,
current: 0,
percentage: 0,
activities: [],
};
}
private getEmptyGamingGoal(
game: 'tft' | 'lol' | 'rocket-league' | 'faceit',
target: string
): GamingGoal {
return {
game,
target,
current: 'Unranked',
progress: 0,
recentMatches: [],
};
}
}
+946
View File
@@ -0,0 +1,946 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, forkJoin, from } from 'rxjs';
import { map, catchError, switchMap, tap, shareReplay, concatMap, delay, toArray } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import {
GamingGoal,
GamingStats,
Match,
RankInfo,
Streak,
} from '../models/gaming-goal.model';
import { RiotMatch } from '../models/api-response.model';
import {
RiotAccount,
RiotLeagueEntry,
RiotSummoner,
TFTMatch,
} from '../models/api-response.model';
@Injectable({
providedIn: 'root',
})
export class RiotService {
readonly #http = inject(HttpClient);
readonly #region = environment.riot.region;
readonly #baseUrl = `/api/proxy/riot/${this.#region}`;
readonly #accountBaseUrl = this.#getAccountRoutingUrl();
// Cache for account data per game type (5 minute TTL)
// Puuid is encrypted per app, so we need separate caches for LoL and TFT
#accountCache: Map<'lol' | 'tft', { data: RiotAccount; timestamp: number }> = new Map();
// Cache for account Observable per game type to prevent duplicate requests
#accountObservable$: Map<'lol' | 'tft', Observable<RiotAccount>> = new Map();
readonly #CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Maps region code to Account API routing value
*/
#getAccountRoutingUrl(): string {
const routingMap: Record<string, string> = {
na1: 'americas',
la1: 'americas',
la2: 'americas',
br1: 'americas',
euw1: 'europe',
eun1: 'europe',
tr1: 'europe',
ru: 'europe',
kr: 'asia',
jp1: 'asia',
oc1: 'sea',
ph2: 'sea',
sg2: 'sea',
th2: 'sea',
tw2: 'sea',
vn2: 'sea',
};
const routing = routingMap[this.#region] || 'europe';
return `/api/proxy/riot/${routing}`;
}
/**
* Checks if API key is configured for a game type
*/
#hasApiKey(_gameType: 'lol' | 'tft'): boolean {
return true;
}
/**
* Get LoL puuid (for Load More functionality)
*/
getLoLPuuid(): Observable<string | null> {
const gameName = environment.riot.summonerNames.lol;
const tagLine = environment.riot.tagLine;
if (!gameName || !tagLine || !this.#hasApiKey('lol')) {
return of(null);
}
return this.#getAccountByRiotId(gameName, tagLine, 'lol').pipe(
map((account) => account?.puuid || null),
catchError(() => of(null))
);
}
/**
* Get TFT puuid (for Load More functionality)
*/
getTFTPuuid(): Observable<string | null> {
const gameName = environment.riot.summonerNames.tft || environment.riot.summonerNames.lol;
const tagLine = environment.riot.tagLine;
if (!gameName || !tagLine || !this.#hasApiKey('tft')) {
return of(null);
}
return this.#getAccountByRiotId(gameName, tagLine, 'tft').pipe(
map((account) => account?.puuid || null),
catchError(() => of(null))
);
}
/**
* Get LoL rank and progress
*/
getLoLRank(): Observable<GamingGoal> {
const gameName = environment.riot.summonerNames.lol;
const tagLine = environment.riot.tagLine;
if (!gameName || !tagLine || !this.#hasApiKey('lol')) {
return of(this.#getEmptyGoal('lol', 'Diamond'));
}
// LoL API only allows by-puuid method (not by-name)
return this.#getAccountByRiotId(gameName, tagLine, 'lol').pipe(
switchMap((account) => {
if (!account?.puuid || account.puuid === 'undefined') {
console.error('Invalid account puuid received:', account);
throw new Error('Failed to get valid account puuid');
}
console.log('[LoL] Account fetched, puuid:', account.puuid);
console.log('[LoL] Fetching summoner and league entry...');
return forkJoin({
rank: this.#getLeagueEntryByPuuid(account.puuid),
matches: this.#getLoLMatches(account.puuid),
}).pipe(
map((data) => {
console.log('[LoL] League entry data received:', data.rank);
const entry = data.rank[0];
const rankInfo: RankInfo = entry
? {
tier: entry.tier,
rank: entry.rank,
leaguePoints: entry.leaguePoints,
wins: entry.wins,
losses: entry.losses,
hotStreak: entry.hotStreak,
veteran: entry.veteran,
freshBlood: entry.freshBlood,
}
: {
tier: 'UNRANKED',
leaguePoints: 0,
wins: 0,
losses: 0,
};
return this.#mapToGamingGoal('lol', 'Diamond', {
rank: rankInfo,
matches: data.matches,
});
})
);
}),
catchError((error) => {
console.error('Error fetching LoL rank:', error);
if (error.status === 400) {
console.warn(
'LoL API returned 400. The player may not have played LoL in this region (EUNE), ' +
'or the puuid is not valid for LoL endpoints. Showing unranked.'
);
} else if (error.status === 401 || error.status === 403) {
console.warn(
`Riot API returned ${error.status}. Your LoL API key may be expired or invalid. ` +
'Riot API keys expire after 24 hours. ' +
'Get a new key at: https://developer.riotgames.com/'
);
} else if (error.status === 429) {
console.warn(
'Riot API rate limit exceeded. Please wait before making more requests.'
);
}
return of(this.#getEmptyGoal('lol', 'Diamond'));
}),
shareReplay(1)
);
}
/**
* Get TFT rank and progress
*/
getTFTRank(): Observable<GamingGoal> {
const gameName =
environment.riot.summonerNames.tft ||
environment.riot.summonerNames.lol;
const tagLine = environment.riot.tagLine;
if (!gameName || !tagLine || !this.#hasApiKey('tft')) {
return of(this.#getEmptyGoal('tft', 'Diamond'));
}
return this.#getAccountByRiotId(gameName, tagLine, 'tft').pipe(
switchMap((account) => {
if (!account?.puuid || account.puuid === 'undefined') {
console.error('Invalid account puuid received:', account);
throw new Error('Failed to get valid account puuid');
}
return forkJoin({
rank: this.#getTFTLeagueEntryByPuuid(account.puuid),
matches: this.#getTFTMatches(account.puuid),
}).pipe(
map((data) => {
const entry = data.rank[0];
const rankInfo: RankInfo = entry
? {
tier: entry.tier,
rank: entry.rank,
leaguePoints: entry.leaguePoints,
wins: entry.wins,
losses: entry.losses,
hotStreak: entry.hotStreak,
veteran: entry.veteran,
freshBlood: entry.freshBlood,
}
: {
tier: 'UNRANKED',
leaguePoints: 0,
wins: 0,
losses: 0,
};
return this.#mapToGamingGoal('tft', 'Diamond', {
rank: rankInfo,
matches: data.matches,
});
})
);
}),
catchError((error) => {
console.error('Error fetching TFT rank:', error);
return of(this.#getEmptyGoal('tft', 'Diamond'));
}),
shareReplay(1)
);
}
/**
* Get account by Riot ID (gameName and tagLine)
* This is the new way to get puuid
* @param gameType - Used to determine which API key to use
*/
#getAccountByRiotId(
gameName: string,
tagLine: string,
gameType: 'lol' | 'tft'
): Observable<RiotAccount> {
// Check data cache first for this specific game type
const cached = this.#accountCache.get(gameType);
if (cached) {
const cacheAge = Date.now() - cached.timestamp;
if (cacheAge < this.#CACHE_TTL) {
// Cache is still valid
return of(cached.data);
}
// Cache expired but exists - use it as fallback if request fails
}
// If there's already an in-flight request for this game type, return it
const existingObservable = this.#accountObservable$.get(gameType);
if (existingObservable) {
return existingObservable;
}
const url = `${this.#accountBaseUrl}/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}`;
// API key is added by the interceptor based on X-Riot-Game-Type header
// Puuid is encrypted per app, so we fetch separately for each game type
const accountObservable$ = this.#http.get<RiotAccount>(url, {
headers: { 'X-Riot-Game-Type': gameType },
}).pipe(
tap((account) => {
// Cache successful response per game type
if (account?.puuid) {
this.#accountCache.set(gameType, { data: account, timestamp: Date.now() });
}
// Clear the Observable cache after completion
this.#accountObservable$.delete(gameType);
}),
catchError((error) => {
console.error(
`Error fetching account by Riot ID (${gameName}#${tagLine}) for ${gameType.toUpperCase()}:`,
error
);
if (error.status === 401 || error.status === 403) {
console.warn(
`Riot Account API returned ${error.status}. Your ${gameType.toUpperCase()} API key may be expired or invalid. ` +
'Riot API keys expire after 24 hours. ' +
'Get a new key at: https://developer.riotgames.com/'
);
// If we have cached data for this game type (even expired), use it as fallback
const cached = this.#accountCache.get(gameType);
if (cached?.data?.puuid) {
console.warn(
`Using cached ${gameType.toUpperCase()} account data as fallback. Please refresh your API key to get updated data.`
);
this.#accountObservable$.delete(gameType);
return of(cached.data);
}
}
// Clear the Observable cache on error
this.#accountObservable$.delete(gameType);
throw error;
}),
shareReplay(1)
);
// Store the Observable for this game type
this.#accountObservable$.set(gameType, accountObservable$);
return accountObservable$;
}
/**
* Get summoner by name (region-specific, more reliable for LoL)
*/
#getSummonerByName(summonerName: string): Observable<RiotSummoner> {
if (!summonerName) {
throw new Error('Invalid summoner name provided');
}
const url = `${this.#baseUrl}/lol/summoner/v4/summoners/by-name/${encodeURIComponent(summonerName)}`;
// API key is added by the interceptor (detected from /lol/ path)
return this.#http.get<RiotSummoner>(url).pipe(
catchError((error) => {
console.error(
`Error fetching summoner by name ${summonerName}:`,
error
);
if (error.status === 404) {
console.warn(
'LoL summoner not found. The player may not have played LoL in this region (EUNE).'
);
} else if (error.status === 401 || error.status === 403) {
console.warn(
'LoL API returned ' + error.status + '. Your LoL API key may be expired or invalid.'
);
}
throw error;
}),
shareReplay(1)
);
}
/**
* Get summoner by puuid (used for TFT, but may fail for LoL if player hasn't played in region)
*/
#getSummonerByPuuid(puuid: string): Observable<RiotSummoner> {
if (!puuid || puuid === 'undefined') {
console.error('getSummonerByPuuid called with invalid puuid:', puuid);
throw new Error('Invalid puuid provided');
}
const url = `${this.#baseUrl}/lol/summoner/v4/summoners/by-puuid/${encodeURIComponent(puuid)}`;
console.log('[LoL] Fetching summoner by puuid:', puuid);
// API key is added by the interceptor (detected from /lol/ path)
return this.#http.get<RiotSummoner>(url).pipe(
tap((summoner) => {
console.log('[LoL] Summoner fetched, id:', summoner?.id);
}),
catchError((error) => {
console.error(
`Error fetching summoner by puuid ${puuid}:`,
error
);
if (error.status === 400) {
console.warn(
'LoL summoner API returned 400. This might indicate: ' +
'1) The puuid is not valid for LoL in this region, ' +
'2) The player has not played LoL in this region, ' +
'3) The API endpoint format is incorrect.'
);
} else if (error.status === 401 || error.status === 403) {
console.warn(
'LoL API returned ' + error.status + '. Your LoL API key may be expired or invalid.'
);
}
throw error;
}),
shareReplay(1)
);
}
/**
* Get LoL league entries by summoner ID
*/
#getLeagueEntryBySummonerId(
summonerId: string,
queueType: string
): Observable<RiotLeagueEntry[]> {
if (!summonerId) {
console.error('getLeagueEntryBySummonerId called with undefined summonerId');
return of([]);
}
const url = `${this.#baseUrl}/lol/league/v4/entries/by-summoner/${encodeURIComponent(summonerId)}`;
console.log('[LoL] Fetching league entry by summonerId:', summonerId, 'queueType:', queueType);
// API key is added by the interceptor (detected from /lol/ path)
return this.#http.get<RiotLeagueEntry[]>(url).pipe(
tap((entries) => {
console.log('[LoL] League entries received:', entries.length, 'entries');
}),
map((entries) => entries.filter((e) => e.queueType === queueType)),
catchError((error) => {
console.error(
`Error fetching league entry by summonerId ${summonerId}:`,
error
);
return of([]);
})
);
}
/**
* Get LoL league entries by puuid directly
* Uses the direct endpoint: /lol/league/v4/entries/by-puuid/{encryptedPUUID}
* Prefers RANKED_SOLO_5x5, falls back to RANKED_FLEX_SR if SOLO doesn't exist
*/
#getLeagueEntryByPuuid(puuid: string): Observable<RiotLeagueEntry[]> {
if (!puuid || puuid === 'undefined') {
console.error('getLeagueEntryByPuuid called with invalid puuid:', puuid);
return of([]);
}
const url = `${this.#baseUrl}/lol/league/v4/entries/by-puuid/${encodeURIComponent(puuid)}`;
console.log('[LoL] Fetching league entry by puuid:', puuid);
// API key is added by the interceptor (detected from /lol/ path)
return this.#http.get<RiotLeagueEntry[]>(url).pipe(
tap((entries) => {
console.log('[LoL] League entries received:', entries.length, 'entries', entries);
}),
map((entries) => {
if (!entries || entries.length === 0) {
return [];
}
// Prefer RANKED_SOLO_5x5, fall back to RANKED_FLEX_SR
const soloEntry = entries.find((e) => e.queueType === 'RANKED_SOLO_5x5');
if (soloEntry) {
return [soloEntry];
}
const flexEntry = entries.find((e) => e.queueType === 'RANKED_FLEX_SR');
if (flexEntry) {
return [flexEntry];
}
// If neither exists, return the first entry (or empty array)
return entries.length > 0 ? [entries[0]] : [];
}),
catchError((error) => {
console.error(
`Error fetching league entry by puuid ${puuid}:`,
error
);
if (error.status === 400) {
console.warn(
'LoL league API returned 400. The player may not have played LoL in this region, ' +
'or the puuid is not valid for LoL league endpoints.'
);
} else if (error.status === 401 || error.status === 403) {
console.warn(
'LoL API returned ' + error.status + '. Your LoL API key may be expired or invalid.'
);
}
return of([]);
})
);
}
/**
* Get TFT league entries by puuid
*/
#getTFTLeagueEntryByPuuid(
puuid: string
): Observable<RiotLeagueEntry[]> {
if (!puuid) {
console.error('getTFTLeagueEntryByPuuid called with undefined puuid');
return of([]);
}
// TFT endpoint: /tft/league/v1/by-puuid/{puuid} (no "entries" in path)
const url = `${this.#baseUrl}/tft/league/v1/by-puuid/${encodeURIComponent(puuid)}`;
// API key is added by the interceptor (detected from /tft/ path)
return this.#http.get<RiotLeagueEntry[]>(url).pipe(
catchError((error) => {
console.error(
`Error fetching TFT league entry by puuid ${puuid}:`,
error
);
return of([]);
})
);
}
/**
* Get recent LoL matches for a player
*/
#getLoLMatches(puuid: string): Observable<Match[]> {
if (!puuid || puuid === 'undefined') {
console.warn('getLoLMatches called with invalid puuid:', puuid);
return of([]);
}
// Match v5 API uses regional routing
const matchListUrl = `${this.#accountBaseUrl}/lol/match/v5/matches/by-puuid/${encodeURIComponent(puuid)}/ids`;
return this.#http.get<string[]>(matchListUrl, {
headers: { 'X-Riot-Game-Type': 'lol' },
params: { start: '0', count: '5', type: 'ranked' },
}).pipe(
switchMap((matchIds) => {
if (!matchIds?.length) {
return of([]);
}
// Fetch matches in batches of 5 to avoid rate limiting
const matchIdsToFetch = matchIds.slice(0, 5);
const batchSize = 5;
const batches: string[][] = [];
for (let i = 0; i < matchIdsToFetch.length; i += batchSize) {
batches.push(matchIdsToFetch.slice(i, i + batchSize));
}
// Process batches sequentially with delay between batches
return from(batches).pipe(
concatMap((batch, index) => {
const batchRequests = batch.map((matchId) =>
this.#http.get<RiotMatch>(
`${this.#accountBaseUrl}/lol/match/v5/matches/${matchId}`,
{ headers: { 'X-Riot-Game-Type': 'lol' } }
).pipe(catchError(() => of(null)))
);
return forkJoin(batchRequests).pipe(
// Add delay between batches (except first one)
delay(index === 0 ? 0 : 200)
);
}),
// Collect all batches into a single array
toArray(),
map((allBatches) => {
// Flatten array of arrays
const flattened = allBatches.flat();
return flattened
.filter((m): m is RiotMatch => m !== null)
.map((m) => this.#mapLoLMatch(m, puuid));
})
);
}),
catchError((error) => {
if (error.status === 400) {
console.warn(
'LoL match list API returned 400. This might indicate: ' +
'1) The puuid is not valid for LoL in this region, ' +
'2) The player has no ranked matches, ' +
'3) The API endpoint format is incorrect.'
);
} else if (error.status === 401 || error.status === 403) {
console.warn(
'LoL match list API returned ' + error.status + '. Your LoL API key may be expired or invalid.'
);
}
return of([]);
})
);
}
/**
* Get recent TFT matches for a player
*/
#getTFTMatches(puuid: string): Observable<Match[]> {
if (!puuid) {
return of([]);
}
// TFT Match v1 API
const matchListUrl = `${this.#accountBaseUrl}/tft/match/v1/matches/by-puuid/${encodeURIComponent(puuid)}/ids`;
return this.#http.get<string[]>(matchListUrl, {
headers: { 'X-Riot-Game-Type': 'tft' },
params: { start: '0', count: '5' },
}).pipe(
switchMap((matchIds) => {
if (!matchIds?.length) {
return of([]);
}
// Get details for matches
const matchRequests = matchIds.slice(0, 5).map((matchId) =>
this.#http.get<TFTMatch>(
`${this.#accountBaseUrl}/tft/match/v1/matches/${matchId}`,
{ headers: { 'X-Riot-Game-Type': 'tft' } }
).pipe(catchError(() => of(null)))
);
return forkJoin(matchRequests).pipe(
map((matches) =>
matches
.filter((m): m is TFTMatch => m !== null)
.map((m) => this.#mapTFTMatch(m, puuid))
)
);
}),
catchError(() => of([]))
);
}
/**
* Get more LoL matches (for Load More functionality)
*/
getMoreLoLMatches(puuid: string, startIndex: number, count: number = 5): Observable<Match[]> {
if (!puuid || puuid === 'undefined') {
console.warn('getMoreLoLMatches called with invalid puuid:', puuid);
return of([]);
}
const matchListUrl = `${this.#accountBaseUrl}/lol/match/v5/matches/by-puuid/${encodeURIComponent(puuid)}/ids`;
return this.#http.get<string[]>(matchListUrl, {
headers: { 'X-Riot-Game-Type': 'lol' },
params: { start: startIndex.toString(), count: count.toString(), type: 'ranked' },
}).pipe(
switchMap((matchIds) => {
if (!matchIds?.length) {
return of([]);
}
// Fetch matches in batches of 5 to avoid rate limiting
const matchIdsToFetch = matchIds.slice(0, count);
const batchSize = 5;
const batches: string[][] = [];
for (let i = 0; i < matchIdsToFetch.length; i += batchSize) {
batches.push(matchIdsToFetch.slice(i, i + batchSize));
}
// Process batches sequentially with delay between batches
return from(batches).pipe(
concatMap((batch, index) => {
const batchRequests = batch.map((matchId) =>
this.#http.get<RiotMatch>(
`${this.#accountBaseUrl}/lol/match/v5/matches/${matchId}`,
{ headers: { 'X-Riot-Game-Type': 'lol' } }
).pipe(catchError(() => of(null)))
);
return forkJoin(batchRequests).pipe(
// Add delay between batches (except first one)
delay(index === 0 ? 0 : 200)
);
}),
// Collect all batches into a single array
toArray(),
map((allBatches) => {
// Flatten array of arrays
const flattened = allBatches.flat();
return flattened
.filter((m): m is RiotMatch => m !== null)
.map((m) => this.#mapLoLMatch(m, puuid));
})
);
}),
catchError((error) => {
console.error('Error fetching more LoL matches:', error);
return of([]);
})
);
}
/**
* Get more TFT matches (for Load More functionality)
*/
getMoreTFTMatches(puuid: string, startIndex: number, count: number = 5): Observable<Match[]> {
if (!puuid || puuid === 'undefined') {
console.warn('getMoreTFTMatches called with invalid puuid:', puuid);
return of([]);
}
const matchListUrl = `${this.#accountBaseUrl}/tft/match/v1/matches/by-puuid/${encodeURIComponent(puuid)}/ids`;
return this.#http.get<string[]>(matchListUrl, {
headers: { 'X-Riot-Game-Type': 'tft' },
params: { start: startIndex.toString(), count: count.toString() },
}).pipe(
switchMap((matchIds) => {
if (!matchIds?.length) {
return of([]);
}
// Get details for matches
const matchRequests = matchIds.slice(0, count).map((matchId) =>
this.#http.get<TFTMatch>(
`${this.#accountBaseUrl}/tft/match/v1/matches/${matchId}`,
{ headers: { 'X-Riot-Game-Type': 'tft' } }
).pipe(catchError(() => of(null)))
);
return forkJoin(matchRequests).pipe(
map((matches) =>
matches
.filter((m): m is TFTMatch => m !== null)
.map((m) => this.#mapTFTMatch(m, puuid))
)
);
}),
catchError((error) => {
console.error('Error fetching more TFT matches:', error);
return of([]);
})
);
}
/**
* Map Riot LoL match to Match model
*/
#mapLoLMatch(match: RiotMatch, puuid: string): Match {
const participant = match.info.participants.find((p) => p.puuid === puuid);
const isWin = participant?.win ?? false;
const gameName = environment.riot.summonerNames.lol;
const tagLine = environment.riot.tagLine;
// Build op.gg match URL
// Format: https://www.op.gg/summoners/{region}/{name}-{tag}/matches/{matchId}
const opggRegion = this.#getOpggRegion();
const matchUrl = `https://www.op.gg/summoners/${opggRegion}/${encodeURIComponent(gameName)}-${encodeURIComponent(tagLine)}/matches/${match.metadata.matchId}`;
// Note: Riot API doesn't provide LP changes, so we don't include lpChange
return {
id: match.metadata.matchId,
game: 'lol',
date: new Date(match.info.gameEndTimestamp),
result: isWin ? 'win' : 'loss',
champion: participant?.championName,
kda: participant
? `${participant.kills}/${participant.deaths}/${participant.assists}`
: undefined,
duration: match.info.gameDuration,
matchUrl,
};
}
/**
* Map TFT match to Match model
*/
#mapTFTMatch(match: TFTMatch, puuid: string): Match {
const participant = match.info.participants.find((p) => p.puuid === puuid);
const placement = participant?.placement ?? 8;
// Top 4 is considered a win in TFT
const isWin = placement <= 4;
const gameName = environment.riot.summonerNames.tft ||
environment.riot.summonerNames.lol;
const tagLine = environment.riot.tagLine;
// Build tactics.tools match URL
// Format: https://tactics.tools/player/{region}/{name}/{tag}
const tacticsRegion = this.#getTacticsRegion();
const matchUrl = `https://tactics.tools/player/${tacticsRegion}/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}`;
// Note: Riot API doesn't provide LP changes, so we don't include lpChange
return {
id: match.metadata.match_id,
game: 'tft',
date: new Date(match.info.game_datetime),
result: isWin ? 'win' : 'loss',
placement,
duration: match.info.game_length,
matchUrl,
};
}
/**
* Get op.gg region code from Riot region
*/
#getOpggRegion(): string {
const regionMap: Record<string, string> = {
na1: 'na',
euw1: 'euw',
eun1: 'eune',
kr: 'kr',
br1: 'br',
la1: 'lan',
la2: 'las',
oc1: 'oce',
ru: 'ru',
tr1: 'tr',
jp1: 'jp',
};
return regionMap[this.#region] || this.#region;
}
/**
* Get tactics.tools region code from Riot region
*/
#getTacticsRegion(): string {
const regionMap: Record<string, string> = {
na1: 'na',
euw1: 'euw',
eun1: 'eune',
kr: 'kr',
br1: 'br',
la1: 'lan',
la2: 'las',
oc1: 'oce',
ru: 'ru',
tr1: 'tr',
jp1: 'jp',
};
return regionMap[this.#region] || this.#region;
}
#mapToGamingGoal(
game: 'lol' | 'tft',
target: string,
data: { rank: RankInfo; matches: Match[] }
): GamingGoal {
const current = this.#formatRank(data.rank);
const progress = this.#calculateRankProgress(data.rank, target);
const stats = this.#calculateStats(data.rank, data.matches, game);
return {
game,
target,
current,
progress,
recentMatches: data.matches,
rankInfo: data.rank,
stats,
};
}
/**
* Calculate gaming stats from rank info and matches
*/
#calculateStats(
rank: RankInfo,
matches: Match[],
game: 'lol' | 'tft'
): GamingStats {
const totalGames = rank.wins + rank.losses;
const winRate = totalGames > 0 ? (rank.wins / totalGames) * 100 : 0;
// Calculate streak from recent matches
const streak = this.#calculateStreak(matches);
// Count recent wins/losses
const recentWins = matches.filter((m) => m.result === 'win').length;
const recentLosses = matches.filter((m) => m.result === 'loss').length;
// Calculate average placement for TFT
const avgPlacement = game === 'tft' && matches.length > 0
? matches.reduce((sum, m) => sum + (m.placement ?? 0), 0) / matches.length
: undefined;
return {
winRate,
totalGames,
recentWins,
recentLosses,
streak,
avgPlacement,
};
}
/**
* Calculate current streak from recent matches
*/
#calculateStreak(matches: Match[]): Streak {
if (matches.length === 0) {
return { type: 'none', count: 0 };
}
const firstResult = matches[0].result;
if (firstResult === 'draw') {
return { type: 'none', count: 0 };
}
let count = 0;
for (const match of matches) {
if (match.result === firstResult) {
count++;
} else {
break;
}
}
return { type: firstResult, count };
}
#formatRank(rank: RankInfo): string {
if (rank.tier === 'UNRANKED') {
return 'Unranked';
}
return `${rank.tier} ${rank.rank || ''}`.trim();
}
#calculateRankProgress(rank: RankInfo, target: string): number {
const tierOrder = [
'IRON',
'BRONZE',
'SILVER',
'GOLD',
'PLATINUM',
'EMERALD',
'DIAMOND',
'MASTER',
'GRANDMASTER',
'CHALLENGER',
];
const targetIndex = tierOrder.indexOf(target.toUpperCase());
const currentIndex = tierOrder.indexOf(rank.tier);
if (currentIndex >= targetIndex) {
return 100;
}
if (currentIndex < 0) {
return 0;
}
// Calculate overall progress from Iron to target tier
// Each tier has 4 divisions (IV, III, II, I), each division requires ~100 LP
const divisionOrder = ['IV', 'III', 'II', 'I'];
const currentDivisionIndex = divisionOrder.indexOf(rank.rank || 'IV');
// Calculate total divisions from Iron (index 0) to target tier
// Example: Diamond (index 6) = 6 tiers * 4 divisions = 24 divisions total
const totalDivisionsToTarget = targetIndex * 4;
// If already at or above target tier, return 100%
if (currentIndex >= targetIndex) {
return 100;
}
// Calculate how many FULL divisions we've completed from Iron to current position
// Completed tiers: currentIndex tiers (each with 4 divisions)
// Completed divisions in current tier: currentDivisionIndex
const completedTiersDivisions = currentIndex * 4;
const completedDivisionsInCurrentTier = currentDivisionIndex;
// Calculate LP progress within current division (0-100 LP per division)
const lpInDivision = Math.min(rank.leaguePoints, 100);
const lpProgressFraction = lpInDivision / 100; // 0.0 to 1.0
// Total completed divisions = completed tiers + completed divisions in current tier + LP progress
// Example: Emerald IV (index 5) with 46 LP targeting Diamond (index 6)
// - Completed tiers: 5 tiers * 4 = 20 divisions
// - Completed divisions in Emerald: 0 (we're in IV)
// - LP progress: 46/100 = 0.46 divisions
// - Total completed: 20 + 0 + 0.46 = 20.46 divisions
// - Total to target: 6 tiers * 4 = 24 divisions
// - Progress: 20.46 / 24 = 85.25%
const totalCompletedDivisions = completedTiersDivisions + completedDivisionsInCurrentTier + lpProgressFraction;
const totalProgress = (totalCompletedDivisions / totalDivisionsToTarget) * 100;
return Math.min(100, Math.max(0, totalProgress));
}
#getEmptyGoal(game: 'lol' | 'tft', target: string): GamingGoal {
return {
game,
target,
current: 'Unranked',
progress: 0,
recentMatches: [],
};
}
}
+398
View File
@@ -0,0 +1,398 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError, tap, switchMap } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import {
Activity,
SportType,
SportGoal,
} from '../models/sport-goal.model';
import {
StravaActivity,
StravaTokenResponse,
} from '../models/api-response.model';
import { metersToKm, isIn2026 } from '../../shared/utils/date.utils';
const STRAVA_API_BASE = 'https://www.strava.com/api/v3';
const STRAVA_AUTH_BASE = '/api/auth/strava';
const STORAGE_KEY_TOKEN = 'strava_access_token';
const STORAGE_KEY_REFRESH = 'strava_refresh_token';
const STORAGE_KEY_EXPIRES = 'strava_token_expires';
@Injectable({
providedIn: 'root',
})
export class StravaService {
private readonly http = inject(HttpClient);
/**
* Initiate OAuth flow by redirecting to Strava
*/
initiateOAuth(): void {
const clientId = environment.strava.clientId;
const redirectUri = environment.strava.redirectUri;
const scope = 'activity:read_all';
const state = this.generateState();
const url = `${STRAVA_API_BASE}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}&state=${state}`;
if (typeof window !== 'undefined') {
window.location.href = url;
}
}
/**
* Exchange authorization code for access token (via backend proxy)
*/
exchangeCodeForToken(code: string): Observable<StravaTokenResponse> {
return this.http
.post<StravaTokenResponse>(`${STRAVA_AUTH_BASE}/token`, { code })
.pipe(
tap((response) => {
this.storeToken(response);
})
);
}
/**
* Refresh access token (via backend proxy)
*/
refreshToken(): Observable<StravaTokenResponse> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
return new Observable((observer) => {
observer.error(new Error('No refresh token available. Please re-authenticate.'));
});
}
return this.http
.post<StravaTokenResponse>(`${STRAVA_AUTH_BASE}/refresh`, { refresh_token: refreshToken })
.pipe(
tap((response) => {
// Always store refreshed token in localStorage (takes precedence over expired env token)
this.storeToken(response);
console.log('Strava token refreshed successfully');
}),
catchError((error) => {
// If refresh token is also invalid, clear stored tokens
if (error.status === 400 || error.status === 401) {
console.error('Refresh token is invalid. Please re-authenticate.');
this.logout(); // Clear invalid tokens
}
throw error;
})
);
}
/**
* Check if token is expired or about to expire (within 5 minutes)
*/
private isTokenExpired(): boolean {
const expires = this.getTokenExpires();
if (!expires) {
// If no expiration set, assume valid (but might be expired on server)
return false;
}
// Consider expired if less than 5 minutes remaining
return Date.now() >= expires - 5 * 60 * 1000;
}
/**
* Ensure token is valid, refresh if needed
*/
private ensureValidToken(): Observable<string> {
const token = this.getAccessToken();
if (!token) {
return new Observable((observer) => {
observer.error(new Error('No access token available'));
});
}
// If token is expired or about to expire, refresh it
if (this.isTokenExpired()) {
const refreshToken = this.getRefreshToken();
if (refreshToken) {
return this.refreshToken().pipe(
map((response) => response.access_token),
catchError((error) => {
console.error('Failed to refresh token:', error);
// If refresh token is invalid, clear tokens and throw error
if (error.status === 400 || error.status === 401) {
this.logout();
throw new Error('Refresh token is invalid. Please re-authenticate.');
}
// For other errors, return original token as fallback
return of(token);
})
);
}
}
return of(token);
}
/**
* Get activities for 2026
*/
getActivities(): Observable<Activity[]> {
return this.ensureValidToken().pipe(
switchMap((token) => {
const headers = { Authorization: `Bearer ${token}` };
const params = new HttpParams()
.set('per_page', '200')
.set('page', '1');
return this.http
.get<StravaActivity[]>(`${STRAVA_API_BASE}/athlete/activities`, {
headers,
params,
})
.pipe(
map((activities) =>
activities
.filter((activity) => {
const date = new Date(activity.start_date);
return isIn2026(date);
})
.map((activity) => this.mapToActivity(activity))
.filter((activity): activity is Activity => activity !== null)
),
catchError((error) => {
// Try to refresh token on 401 (in case automatic refresh didn't work)
if (error.status === 401) {
const refreshToken = this.getRefreshToken();
if (refreshToken) {
return this.refreshToken().pipe(
switchMap((response) => {
const newHeaders = {
Authorization: `Bearer ${response.access_token}`,
};
return this.http.get<StravaActivity[]>(
`${STRAVA_API_BASE}/athlete/activities`,
{
headers: newHeaders,
params,
}
);
}),
map((activities) =>
activities
.filter((activity) => {
const date = new Date(activity.start_date);
return isIn2026(date);
})
.map((activity) => this.mapToActivity(activity))
.filter((activity): activity is Activity => activity !== null)
),
catchError((refreshError) => {
console.error(
'Error fetching Strava activities after refresh:',
refreshError
);
if (refreshError.status === 400 || refreshError.status === 401) {
console.error(
'Refresh token is also invalid. Please re-authenticate via OAuth.'
);
}
return of([]);
})
);
} else {
console.error(
'No refresh token available. Please re-authenticate via OAuth.'
);
}
}
console.error('Error fetching Strava activities:', error);
return of([]);
})
);
}),
catchError(() => {
console.error('No valid token available for Strava');
return of([]);
})
);
}
/**
* Get sport progress for a specific type
*/
getSportProgress(type: SportType, target: number): Observable<SportGoal> {
return this.getActivities().pipe(
map((activities) => {
const filtered = activities.filter((a) => a.type === type);
const totalDistance = filtered.reduce(
(sum, activity) => sum + metersToKm(activity.distance),
0
);
const percentage = Math.min(100, (totalDistance / target) * 100);
return {
type,
target,
current: totalDistance,
percentage,
activities: filtered,
};
})
);
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
const token = this.getAccessToken();
if (!token) {
return false;
}
const expires = this.getTokenExpires();
// If no expiration set (environment token), assume valid
if (!expires) {
return true;
}
return Date.now() < expires;
}
/**
* Logout - clear stored tokens
*/
logout(): void {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_KEY_TOKEN);
localStorage.removeItem(STORAGE_KEY_REFRESH);
localStorage.removeItem(STORAGE_KEY_EXPIRES);
}
}
private mapToActivity(activity: StravaActivity): Activity | null {
const sportType = this.mapSportType(activity.sport_type || activity.type);
// If we can't categorize the activity, return null to exclude it
if (!sportType) {
return null;
}
return {
id: activity.id,
name: activity.name,
type: sportType,
distance: activity.distance,
startDate: new Date(activity.start_date),
movingTime: activity.moving_time,
};
}
/**
* Map Strava activity type to our SportType
* Returns null if activity type doesn't match bike, run, or swim
*/
private mapSportType(stravaType: string | undefined): SportType | null {
if (!stravaType) {
return null;
}
const normalized = stravaType.toLowerCase();
// Bike activities
if (
normalized === 'ride' ||
normalized === 'ebikeride' ||
normalized === 'handcycle' ||
normalized === 'virtualride' ||
normalized.includes('bike') ||
normalized.includes('cycle')
) {
return 'bike';
}
// Run activities
if (
normalized === 'run' ||
normalized === 'trailrun' ||
normalized === 'walk' ||
normalized.includes('run')
) {
return 'run';
}
// Swim activities
if (
normalized === 'swim' ||
normalized === 'openwaterswim' ||
normalized.includes('swim')
) {
return 'swim';
}
// Unknown activity type - don't default to bike, return null to exclude
return null;
}
private storeToken(response: StravaTokenResponse): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY_TOKEN, response.access_token);
localStorage.setItem(STORAGE_KEY_REFRESH, response.refresh_token);
localStorage.setItem(
STORAGE_KEY_EXPIRES,
String(response.expires_at * 1000)
);
}
}
private getAccessToken(): string | null {
// Check localStorage first (refreshed tokens take precedence)
if (typeof localStorage !== 'undefined') {
const storedToken = localStorage.getItem(STORAGE_KEY_TOKEN);
if (storedToken) {
// Check if stored token is still valid
const storedExpires = localStorage.getItem(STORAGE_KEY_EXPIRES);
if (storedExpires) {
const expires = Number.parseInt(storedExpires, 10);
// If stored token is still valid (or expired but we'll refresh), use it
if (Date.now() < expires + 5 * 60 * 1000) {
return storedToken;
}
} else {
// No expiration info, use it
return storedToken;
}
}
}
// Fall back to environment token (for initial setup)
if (environment.strava.accessToken) {
return environment.strava.accessToken;
}
return null;
}
private getRefreshToken(): string | null {
// First check environment (for single-page app without login)
if (environment.strava.refreshToken) {
return environment.strava.refreshToken;
}
// Fall back to localStorage (from OAuth flow)
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(STORAGE_KEY_REFRESH);
}
private getTokenExpires(): number | null {
// First check environment (for single-page app without login)
if (environment.strava.tokenExpiresAt) {
return environment.strava.tokenExpiresAt;
}
// Fall back to localStorage (from OAuth flow)
if (typeof localStorage === 'undefined') {
return null;
}
const expires = localStorage.getItem(STORAGE_KEY_EXPIRES);
return expires ? Number.parseInt(expires, 10) : null;
}
private generateState(): string {
return Math.random().toString(36).substring(2, 15);
}
}
+145
View File
@@ -0,0 +1,145 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { GamingGoal, Match } from '../models/gaming-goal.model';
import { TrackerGGProfile } from '../models/api-response.model';
const TRACKER_API_BASE =
'/api/proxy/tracker/api/v1/rocket-league/standard/profile';
@Injectable({
providedIn: 'root',
})
export class TrackerGGService {
private readonly http = inject(HttpClient);
private readonly platform = environment.tracker.rocketLeague.platform;
private readonly username = environment.tracker.rocketLeague.username;
/**
* Get Rocket League rank and progress (via backend proxy)
*/
getRocketLeagueRank(): Observable<GamingGoal> {
if (!this.platform || !this.username) {
return of(this.getEmptyGoal());
}
const url = `${TRACKER_API_BASE}/${this.platform}/${encodeURIComponent(this.username)}`;
return this.http
.get<TrackerGGProfile>(url)
.pipe(
map((profile) => {
// Check if response has expected structure
if (!profile || !profile.data || !profile.data.segments) {
console.warn('Unexpected Tracker.gg API response structure:', profile);
return this.getEmptyGoal();
}
const rankSegment = profile.data.segments.find(
(s) => s.type === 'playlist'
);
if (!rankSegment) {
console.warn('No playlist segment found in Tracker.gg response');
return this.getEmptyGoal();
}
const rankName =
rankSegment.attributes?.rank?.metadata?.rankName || 'Unranked';
const tierName =
rankSegment.attributes?.rank?.metadata?.tierName || '';
const current = tierName
? `${tierName} ${rankName}`.trim()
: rankName;
const progress = this.calculateRankProgress(
tierName,
'Champion'
);
const goal: GamingGoal = {
game: 'rocket-league',
target: 'Champion',
current,
progress,
recentMatches: [],
};
return goal;
}),
catchError((error) => {
console.error('Error fetching Rocket League rank:', error);
if (error.error) {
// Try to log the error response
if (typeof error.error === 'string') {
console.error('Error response (string):', error.error.substring(0, 500));
} else {
console.error('Error response (object):', error.error);
}
}
if (error.status === 0) {
console.warn(
'CORS error detected. Tracker.gg API only works server-side (SSR). ' +
'Run the app with SSR to fetch Rocket League data.'
);
} else if (error.status === 200 && error.error) {
console.warn(
'Response parsing failed. The API might have returned HTML or invalid JSON. ' +
'This could indicate an invalid API key or incorrect endpoint.'
);
console.warn('Full error:', error);
} else if (error.status === 401 || error.status === 403) {
console.warn(
'Tracker.gg API authentication failed. Verify your API key is valid.'
);
} else if (error.status === 404) {
console.warn(
'Rocket League profile not found. Verify your platform and username are correct.'
);
}
return of(this.getEmptyGoal());
})
);
}
private calculateRankProgress(current: string, target: string): number {
const rankOrder = [
'Unranked',
'Bronze',
'Silver',
'Gold',
'Platinum',
'Diamond',
'Champion',
'Grand Champion',
'Supersonic Legend',
];
const targetIndex = rankOrder.indexOf(target);
const currentIndex = rankOrder.findIndex((r) =>
current.toLowerCase().includes(r.toLowerCase())
);
if (currentIndex >= targetIndex) {
return 100;
}
if (currentIndex < 0) {
return 0;
}
return (currentIndex / targetIndex) * 100;
}
private getEmptyGoal(): GamingGoal {
return {
game: 'rocket-league',
target: 'Champion',
current: 'Unranked',
progress: 0,
recentMatches: [],
};
}
}
@@ -0,0 +1,17 @@
// Component styles if needed
@@ -0,0 +1,560 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { GoalsService } from '../../core/services/goals.service';
import { ThreeTrophyComponent } from '../../shared/components/three-trophy/three-trophy.component';
import { ThreeGeometricComponent } from '../../shared/components/three-geometric/three-geometric.component';
import { ProgressRingComponent } from '../../shared/components/progress-ring/progress-ring.component';
import { AnimatedCounterComponent } from '../../shared/components/animated-counter/animated-counter.component';
import { ConfettiComponent } from '../../shared/components/confetti/confetti.component';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
CommonModule,
RouterLink,
ThreeTrophyComponent,
ThreeGeometricComponent,
ProgressRingComponent,
AnimatedCounterComponent,
ConfettiComponent,
],
template: `
<div class="dashboard-scroll-container">
<app-confetti *ngIf="showConfetti"></app-confetti>
<!-- Hero Section -->
<section class="full-page-section hero-section">
<app-three-geometric type="floating" color="#3b82f6"></app-three-geometric>
<div class="relative z-10 flex items-center justify-center h-full">
<div class="text-center px-4 max-w-5xl mx-auto">
<div class="mb-8 animate-fade-in-up">
<h1 class="text-7xl md:text-9xl font-black mb-6 bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent">
2026 Goals
</h1>
<p class="text-3xl md:text-4xl text-gray-700 font-medium mb-12">
Track your progress, achieve your dreams
</p>
</div>
<div *ngIf="!loading" class="animate-fade-in-up-delay">
<div class="text-9xl mb-8">🎯</div>
<div class="text-8xl font-black text-gray-900 mb-4">
{{ overallProgress.toFixed(1) }}%
</div>
<p class="text-2xl text-gray-600 font-medium">Overall Progress</p>
</div>
</div>
</div>
<div class="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce z-10">
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
</svg>
</div>
</section>
<!-- Trophy Section -->
<section class="full-page-section trophy-section">
<app-three-geometric type="rings" color="#8b5cf6"></app-three-geometric>
<div class="relative z-10 flex items-center justify-center h-full">
<div class="max-w-6xl mx-auto px-4 text-center">
<h2 class="section-title-large mb-6">Overall Achievement</h2>
<p class="section-subtitle-large mb-12">Your journey to success visualized</p>
<div class="bg-white/40 backdrop-blur-xl rounded-3xl shadow-2xl p-8 md:p-16 border border-white/30 inline-block">
<div class="h-[500px] w-[500px] max-w-full mx-auto">
<app-three-trophy [progress]="overallProgress" [size]="500"></app-three-trophy>
</div>
</div>
</div>
</div>
</section>
<!-- Sport Section -->
<section class="full-page-section sport-section">
<app-three-geometric type="floating" color="#3b82f6"></app-three-geometric>
<div class="relative z-10 flex items-center justify-center h-full">
<div class="max-w-7xl mx-auto px-4 w-full">
<div class="max-w-2xl">
<div>
<h2 class="section-title-large mb-6">Sport Goals</h2>
<p class="section-subtitle-large mb-8">
Track your fitness journey across biking, running, and swimming
</p>
<div class="mb-8">
<div class="text-7xl font-black text-blue-600 mb-4">
{{ sportProgress.toFixed(1) }}%
</div>
<div class="w-full bg-white/30 rounded-full h-4 mb-4 overflow-hidden backdrop-blur-sm">
<div
class="bg-gradient-to-r from-blue-500 to-blue-700 h-4 rounded-full transition-all duration-1000 ease-out relative overflow-hidden"
[style.width.%]="Math.min(100, sportProgress)"
>
<div class="absolute inset-0 w-full h-full animate-shimmer"></div>
</div>
</div>
</div>
<button
(click)="toggleSportExpanded()"
class="px-8 py-4 bg-white/80 backdrop-blur-md text-blue-600 rounded-xl font-bold hover:bg-white transition-all transform hover:scale-105 shadow-xl text-lg mb-4 mr-4"
>
{{ sportExpanded() ? 'Show Less' : 'Learn More' }} ↓
</button>
<a
routerLink="/sport"
class="px-8 py-4 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-700 transition-all transform hover:scale-105 shadow-xl text-lg inline-block"
>
View Details →
</a>
</div>
</div>
<!-- Expanded Content -->
<div
*ngIf="sportExpanded()"
class="mt-12 bg-white/40 backdrop-blur-xl rounded-3xl shadow-2xl p-8 md:p-12 border border-white/30 animate-expand"
>
<h3 class="text-3xl font-bold text-gray-900 mb-6">Sport Goals Breakdown</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white/60 rounded-2xl p-6">
<div class="text-4xl mb-4">🚴</div>
<h4 class="text-xl font-bold text-gray-900 mb-2">Biking</h4>
<p class="text-gray-600 mb-4">Target: 7,500 km</p>
<div class="w-full bg-gray-200 rounded-full h-3">
<div class="bg-blue-500 h-3 rounded-full" [style.width.%]="Math.min(100, sportProgress)"></div>
</div>
</div>
<div class="bg-white/60 rounded-2xl p-6">
<div class="text-4xl mb-4">🏃</div>
<h4 class="text-xl font-bold text-gray-900 mb-2">Running</h4>
<p class="text-gray-600 mb-4">Target: 2,500 km</p>
<div class="w-full bg-gray-200 rounded-full h-3">
<div class="bg-blue-500 h-3 rounded-full" [style.width.%]="Math.min(100, sportProgress)"></div>
</div>
</div>
<div class="bg-white/60 rounded-2xl p-6">
<div class="text-4xl mb-4">🏊</div>
<h4 class="text-xl font-bold text-gray-900 mb-2">Swimming</h4>
<p class="text-gray-600 mb-4">Target: 250 km</p>
<div class="w-full bg-gray-200 rounded-full h-3">
<div class="bg-blue-500 h-3 rounded-full" [style.width.%]="Math.min(100, sportProgress)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Gaming Section -->
<section class="full-page-section gaming-section">
<app-three-geometric type="rings" color="#8b5cf6"></app-three-geometric>
<div class="relative z-10 flex items-center justify-center h-full">
<div class="max-w-7xl mx-auto px-4 w-full">
<div class="max-w-2xl">
<div>
<h2 class="section-title-large mb-6">Gaming Goals</h2>
<p class="section-subtitle-large mb-8">
Climb the ranks across multiple competitive games
</p>
<div class="mb-8">
<div class="text-7xl font-black text-purple-600 mb-4">
{{ gamingProgress.toFixed(1) }}%
</div>
<div class="w-full bg-white/30 rounded-full h-4 mb-4 overflow-hidden backdrop-blur-sm">
<div
class="bg-gradient-to-r from-purple-500 to-pink-600 h-4 rounded-full transition-all duration-1000 ease-out relative overflow-hidden"
[style.width.%]="Math.min(100, gamingProgress)"
>
<div class="absolute inset-0 w-full h-full animate-shimmer"></div>
</div>
</div>
</div>
<button
(click)="toggleGamingExpanded()"
class="px-8 py-4 bg-white/80 backdrop-blur-md text-purple-600 rounded-xl font-bold hover:bg-white transition-all transform hover:scale-105 shadow-xl text-lg mb-4 mr-4"
>
{{ gamingExpanded() ? 'Show Less' : 'Learn More' }} ↓
</button>
<a
routerLink="/gaming"
class="px-8 py-4 bg-purple-600 text-white rounded-xl font-bold hover:bg-purple-700 transition-all transform hover:scale-105 shadow-xl text-lg inline-block"
>
View Details →
</a>
</div>
</div>
<!-- Expanded Content -->
<div
*ngIf="gamingExpanded()"
class="mt-12 bg-white/40 backdrop-blur-xl rounded-3xl shadow-2xl p-8 md:p-12 border border-white/30 animate-expand"
>
<h3 class="text-3xl font-bold text-gray-900 mb-6">Gaming Goals Breakdown</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="bg-white/60 rounded-2xl p-6 text-center">
<div class="text-4xl mb-4">🎮</div>
<h4 class="text-xl font-bold text-gray-900 mb-2">TFT</h4>
<p class="text-gray-600 mb-4">Target: Diamond</p>
<div class="w-full bg-gray-200 rounded-full h-3">
<div class="bg-purple-500 h-3 rounded-full" [style.width.%]="Math.min(100, gamingProgress)"></div>
</div>
</div>
<div class="bg-white/60 rounded-2xl p-6 text-center">
<div class="text-4xl mb-4">⚔️</div>
<h4 class="text-xl font-bold text-gray-900 mb-2">LoL</h4>
<p class="text-gray-600 mb-4">Target: Diamond</p>
<div class="w-full bg-gray-200 rounded-full h-3">
<div class="bg-purple-500 h-3 rounded-full" [style.width.%]="Math.min(100, gamingProgress)"></div>
</div>
</div>
<div class="bg-white/60 rounded-2xl p-6 text-center">
<div class="text-4xl mb-4">🚗</div>
<h4 class="text-xl font-bold text-gray-900 mb-2">Rocket League</h4>
<p class="text-gray-600 mb-4">Target: Champion</p>
<div class="w-full bg-gray-200 rounded-full h-3">
<div class="bg-purple-500 h-3 rounded-full" [style.width.%]="Math.min(100, gamingProgress)"></div>
</div>
</div>
<div class="bg-white/60 rounded-2xl p-6 text-center">
<div class="text-4xl mb-4">🎯</div>
<h4 class="text-xl font-bold text-gray-900 mb-2">Faceit</h4>
<p class="text-gray-600 mb-4">Target: Level 10</p>
<div class="w-full bg-gray-200 rounded-full h-3">
<div class="bg-purple-500 h-3 rounded-full" [style.width.%]="Math.min(100, gamingProgress)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Stats Section -->
<section class="full-page-section stats-section">
<app-three-geometric type="particles" color="#10b981"></app-three-geometric>
<div class="relative z-10 flex items-center justify-center h-full">
<div class="max-w-7xl mx-auto px-4 w-full">
<h2 class="section-title-large text-center mb-12">Quick Overview</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
<div class="stat-card">
<div class="text-5xl mb-6">🏃</div>
<div class="mb-6 flex justify-center">
<app-progress-ring
[percentage]="sportProgress"
[size]="150"
[strokeWidth]="12"
progressColor="#3b82f6"
></app-progress-ring>
</div>
<h4 class="text-2xl font-bold text-gray-900 mb-3">Sport Goals</h4>
<div class="text-4xl font-black text-blue-600 mb-2">
<app-animated-counter
[value]="sportProgress"
[decimals]="1"
suffix="%"
color="#3b82f6"
></app-animated-counter>
</div>
<p class="text-gray-600">Fitness progress</p>
</div>
<div class="stat-card">
<div class="text-5xl mb-6">🎮</div>
<div class="mb-6 flex justify-center">
<app-progress-ring
[percentage]="gamingProgress"
[size]="150"
[strokeWidth]="12"
progressColor="#8b5cf6"
></app-progress-ring>
</div>
<h4 class="text-2xl font-bold text-gray-900 mb-3">Gaming Goals</h4>
<div class="text-4xl font-black text-purple-600 mb-2">
<app-animated-counter
[value]="gamingProgress"
[decimals]="1"
suffix="%"
color="#8b5cf6"
></app-animated-counter>
</div>
<p class="text-gray-600">Rank progress</p>
</div>
<div class="stat-card">
<div class="text-5xl mb-6">📊</div>
<div class="mb-6 flex justify-center">
<app-progress-ring
[percentage]="overallProgress"
[size]="150"
[strokeWidth]="12"
progressColor="#10b981"
></app-progress-ring>
</div>
<h4 class="text-2xl font-bold text-gray-900 mb-3">Overall</h4>
<div class="text-4xl font-black text-green-600 mb-2">
<app-animated-counter
[value]="overallProgress"
[decimals]="1"
suffix="%"
color="#10b981"
></app-animated-counter>
</div>
<p class="text-gray-600">Total progress</p>
</div>
<div class="stat-card">
<div class="text-5xl mb-6">⏰</div>
<div class="mb-6 flex items-center justify-center h-[150px]">
<div class="text-6xl font-black text-orange-600">
<app-animated-counter
[value]="daysRemaining"
[decimals]="0"
suffix=""
color="#f59e0b"
></app-animated-counter>
</div>
</div>
<h4 class="text-2xl font-bold text-gray-900 mb-3">Days Remaining</h4>
<p class="text-gray-600">Until 2026 ends</p>
</div>
</div>
</div>
</div>
</section>
</div>
`,
styles: [
`
.dashboard-scroll-container {
height: 100vh;
overflow-y: scroll;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
}
.full-page-section {
height: 100vh;
width: 100%;
position: relative;
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.hero-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.trophy-section {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.sport-section {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.gaming-section {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
}
.stats-section {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
}
.section-title-large {
font-size: 4rem;
font-weight: 900;
color: white;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
@media (min-width: 768px) {
.section-title-large {
font-size: 5rem;
}
}
.section-subtitle-large {
font-size: 1.5rem;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
@media (min-width: 768px) {
.section-subtitle-large {
font-size: 2rem;
}
}
.stat-card {
background: white;
backdrop-filter: blur(20px);
border-radius: 2rem;
padding: 3rem 2rem;
text-align: center;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 2px solid rgba(255, 255, 255, 0.5);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-10px) scale(1.05);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 1s ease-out;
}
.animate-fade-in-up-delay {
animation: fade-in-up 1s ease-out 0.3s both;
}
@keyframes expand {
from {
opacity: 0;
max-height: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
max-height: 1000px;
transform: translateY(0);
}
}
.animate-expand {
animation: expand 0.5s ease-out;
overflow: hidden;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 3s infinite;
}
/* Hide scrollbar but keep functionality */
.dashboard-scroll-container::-webkit-scrollbar {
width: 8px;
}
.dashboard-scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.dashboard-scroll-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.dashboard-scroll-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
`,
],
})
export class DashboardComponent implements OnInit {
loading = true;
sportProgress = 0;
gamingProgress = 0;
overallProgress = 0;
daysRemaining = 0;
showConfetti = false;
sportExpanded = signal(false);
gamingExpanded = signal(false);
readonly Math = Math;
constructor(private readonly goalsService: GoalsService) {}
ngOnInit(): void {
this.loadData();
this.calculateDaysRemaining();
}
toggleSportExpanded(): void {
this.sportExpanded.set(!this.sportExpanded());
}
toggleGamingExpanded(): void {
this.gamingExpanded.set(!this.gamingExpanded());
}
loadData(): void {
this.loading = true;
this.goalsService.loadSportGoals().subscribe({
next: (progress) => {
this.sportProgress = progress.overallPercentage;
this.updateOverallProgress();
this.loading = false;
},
error: () => {
this.loading = false;
},
});
this.goalsService.loadGamingGoals().subscribe({
next: (progress) => {
this.gamingProgress =
(progress.tft.progress +
progress.lol.progress +
progress.rocketLeague.progress +
progress.faceit.progress) /
4;
this.updateOverallProgress();
},
error: () => {
// Error already handled
},
});
}
private updateOverallProgress(): void {
const newProgress = (this.sportProgress + this.gamingProgress) / 2;
const wasComplete = this.overallProgress >= 100;
this.overallProgress = newProgress;
if (!wasComplete && this.overallProgress >= 100) {
this.showConfetti = true;
setTimeout(() => {
this.showConfetti = false;
}, 5000);
}
}
private calculateDaysRemaining(): void {
const now = new Date();
const end = new Date('2026-12-31');
const diff = end.getTime() - now.getTime();
this.daysRemaining = Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
}
}
@@ -0,0 +1,641 @@
.gaming-container {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
margin-bottom: 2rem;
}
.title {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 0.5rem 0;
}
.subtitle {
color: #6b7280;
margin: 0;
}
/* Loading State */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 1rem;
color: #6b7280;
}
/* Overall Card */
.overall-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 2rem;
color: white;
}
.overall-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.overall-label {
display: block;
font-size: 0.875rem;
opacity: 0.9;
}
.overall-value {
font-size: 2.5rem;
font-weight: 700;
}
.completed-badge {
text-align: right;
}
.completed-count {
font-size: 1.5rem;
font-weight: 700;
}
.completed-label {
font-size: 0.875rem;
opacity: 0.9;
}
.overall-bar {
height: 8px;
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
overflow: hidden;
}
.overall-bar-fill {
height: 100%;
background: white;
border-radius: 4px;
transition: width 0.5s ease;
}
/* Games Grid */
.games-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
/* Game Card */
.game-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
transition: transform 0.2s, box-shadow 0.2s;
}
.game-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.game-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.game-icon {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.game-icon img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
}
/* Adjust filter for logos that need better visibility */
.tft-icon img {
filter: brightness(0) invert(1);
}
.lol-icon img {
filter: brightness(0) invert(1);
}
.rl-icon img {
filter: none;
}
.faceit-icon img {
filter: brightness(0) invert(1);
}
.tft-icon {
background: linear-gradient(135deg, #92400e 0%, #78350f 100%);
box-shadow: 0 4px 12px rgba(146, 64, 14, 0.4);
}
.lol-icon {
background: linear-gradient(135deg, #0ac8b9 0%, #0a7b72 100%);
box-shadow: 0 4px 12px rgba(10, 200, 185, 0.4);
}
.rl-icon {
background: linear-gradient(135deg, #0078d7 0%, #005a9e 100%);
box-shadow: 0 4px 12px rgba(0, 120, 215, 0.4);
}
.faceit-icon {
background: linear-gradient(135deg, #ff5500 0%, #cc4400 100%);
box-shadow: 0 4px 12px rgba(255, 85, 0, 0.4);
}
.faceit-icon img {
padding: 8px;
}
.game-info {
flex: 1;
min-width: 120px;
}
.game-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.game-rank {
font-size: 0.875rem;
color: #6b7280;
}
.hot-streak-badge, .elo-badge {
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-weight: 600;
}
.hot-streak-badge {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
color: #92400e;
}
.elo-badge {
background: linear-gradient(135deg, #fed7aa 0%, #fdba74 100%);
color: #9a3412;
}
/* Progress Section */
.progress-section {
margin-bottom: 1.25rem;
}
.progress-labels {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.5rem;
}
.progress-percent {
font-weight: 600;
color: #1f2937;
}
.progress-bar {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease;
}
.tft-fill { background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%); }
.lol-fill { background: linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%); }
.rl-fill { background: linear-gradient(90deg, #10b981 0%, #059669 100%); }
.faceit-fill { background: linear-gradient(90deg, #f97316 0%, #ea580c 100%); }
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.75rem;
}
.stat-item {
text-align: center;
}
.stat-value {
display: block;
font-size: 1.125rem;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 0.75rem;
color: #6b7280;
}
/* Streak Banner */
.streak-banner {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
text-align: center;
margin-bottom: 1rem;
}
.win-streak {
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
color: #166534;
}
.loss-streak {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
color: #991b1b;
}
/* Matches Section */
.matches-section {
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.matches-title {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin: 0 0 0.75rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.estimated-tag {
font-size: 0.625rem;
font-weight: 500;
color: #9ca3af;
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.lp-change {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 4px;
margin-left: 0.25rem;
}
.lp-change.positive {
color: #166534;
background: rgba(34, 197, 94, 0.15);
}
.lp-change.negative {
color: #991b1b;
background: rgba(239, 68, 68, 0.15);
}
/* Promotion/Demotion styles */
.match-item.promotion {
border-left: 3px solid #fbbf24 !important;
background: linear-gradient(135deg, #fefce8 0%, #fef9c3 100%) !important;
}
.match-item.demotion {
border-left: 3px solid #8b5cf6 !important;
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%) !important;
}
.rank-change-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 4px;
margin-left: 0.25rem;
white-space: nowrap;
}
.promotion-badge {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
color: #92400e;
border: 1px solid #fbbf24;
}
.demotion-badge {
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
color: #5b21b6;
border: 1px solid #a78bfa;
}
.match-item.promotion:hover {
background: linear-gradient(135deg, #fef9c3 0%, #fef08a 100%) !important;
}
.match-item.demotion:hover {
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%) !important;
}
.matches-list-container {
max-height: 280px;
overflow-y: auto;
border-radius: 0.5rem;
scrollbar-width: thin;
scrollbar-color: #d1d5db #f3f4f6;
}
.matches-list-container::-webkit-scrollbar {
width: 6px;
}
.matches-list-container::-webkit-scrollbar-track {
background: #f3f4f6;
border-radius: 3px;
}
.matches-list-container::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.matches-list-container::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
.matches-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.load-more-btn {
width: 100%;
margin-top: 0.75rem;
padding: 0.625rem 1rem;
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.8rem;
font-weight: 500;
color: #4b5563;
cursor: pointer;
transition: all 0.2s ease;
}
.load-more-btn:hover {
background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
border-color: #9ca3af;
color: #1f2937;
}
.load-more-btn:active {
transform: scale(0.98);
}
.match-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
background: #f9fafb;
}
.match-item.win {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border-left: 3px solid #22c55e;
}
.match-item.loss {
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
border-left: 3px solid #ef4444;
}
.match-main {
display: flex;
align-items: center;
gap: 0.5rem;
}
.match-result {
font-weight: 600;
}
.match-item.win .match-result { color: #166534; }
.match-item.loss .match-result { color: #991b1b; }
.match-champion, .match-score {
color: #6b7280;
}
.match-details {
display: flex;
align-items: center;
gap: 0.5rem;
}
.match-kda {
font-weight: 500;
color: #374151;
}
.match-date {
color: #9ca3af;
font-size: 0.75rem;
}
.match-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.match-link-icon {
font-size: 0.875rem;
color: #9ca3af;
transition: color 0.2s, transform 0.2s;
}
.match-item.clickable {
text-decoration: none;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.match-item.clickable:hover {
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.match-item.clickable:hover .match-link-icon {
color: #6b7280;
transform: translate(2px, -2px);
}
.match-item.win.clickable:hover {
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
}
.match-item.loss.clickable:hover {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
}
/* SSR Notice */
.ssr-notice {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #f3f4f6;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #6b7280;
}
/* No Data State */
.no-data {
text-align: center;
padding: 4rem 2rem;
}
.no-data-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.no-data-text {
font-size: 1.25rem;
color: #374151;
margin: 0 0 0.5rem 0;
}
.no-data-hint {
color: #9ca3af;
margin: 0;
}
/* New Stats Grid */
.stats-grid-new {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.stat-item-new {
text-align: center;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.stat-value-new {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.stat-label-new {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Chart Center Text */
.chart-center-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
z-index: 10;
}
.center-text-main {
font-size: 1.25rem;
font-weight: 700;
color: #1f2937;
line-height: 1.2;
margin-bottom: 0.25rem;
}
.center-text-lp {
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
line-height: 1;
}
/* Responsive */
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.stats-grid-new {
grid-template-columns: repeat(2, 1fr);
}
.overall-value {
font-size: 2rem;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgChartsModule } from 'ng2-charts';
import {
ChartConfiguration,
ChartData,
} from 'chart.js';
import { SportProgress } from '../../../core/models/sport-goal.model';
import { ChartCardComponent } from '../../../shared/components/chart-card/chart-card.component';
@Component({
selector: 'app-overall-progress-section',
standalone: true,
imports: [CommonModule, NgChartsModule, ChartCardComponent],
template: `
<div class="bg-gradient-to-br from-purple-50/50 via-pink-50/50 to-purple-100/50 backdrop-blur-sm rounded-2xl shadow-xl p-6 md:p-8 border-2 border-purple-200/50">
<div class="flex items-center gap-3 mb-6 pb-4 border-b-2 border-purple-200/30">
<span class="text-5xl">📊</span>
<div>
<h3 class="text-2xl font-bold text-gray-900">Overall Progress</h3>
<p class="text-sm text-gray-600">2026 year overview and combined sports progress</p>
</div>
</div>
<!-- Year Progress and Sports Progress Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Year Progress Card -->
<div class="bg-white/80 backdrop-blur-md rounded-xl shadow-lg p-6 border border-purple-200/50">
<h4 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<span class="text-2xl">📅</span>
<span>2026 Year Progress</span>
</h4>
<div class="flex flex-col items-center">
<div class="h-48 w-48 relative">
<!-- Outer ring - Optimal -->
<canvas
baseChart
[data]="yearProgressOptimalDoughnutData"
[type]="'doughnut'"
[options]="yearProgressOptimalDoughnutOptions"
class="absolute inset-0"
></canvas>
<!-- Inner ring - Actual -->
<div class="absolute" style="inset: 20%;">
<canvas
baseChart
[data]="yearProgressDoughnutData"
[type]="'doughnut'"
[options]="yearProgressDoughnutOptions"
class="w-full h-full"
></canvas>
</div>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="text-center">
<div class="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
{{ progress.overallPercentage.toFixed(1) }}%
</div>
<div class="text-xs text-gray-600 mt-1">Actual</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="mt-4 flex flex-wrap justify-center gap-4 text-xs">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
<span class="text-gray-600">Actual: {{ progress.overallPercentage.toFixed(1) }}%</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-gray-400"></div>
<span class="text-gray-600">Optimal: {{ idealProgress.toFixed(1) }}%</span>
</div>
</div>
<p class="text-xs text-gray-600 mt-3 font-medium">
<span class="text-base">⏱️</span> {{ daysElapsed }} / {{ totalDaysInYear }} days
</p>
<div *ngIf="daysBehind !== null" class="text-sm font-medium mt-3">
<span *ngIf="daysBehind > 0" class="bg-yellow-400 bg-opacity-30 px-3 py-1 rounded-full inline-block">
<span>⏰</span> {{ daysBehind.toFixed(0) }} day{{ daysBehind !== 1 ? 's' : '' }} behind schedule
</span>
<span *ngIf="daysBehind < 0" class="bg-green-400 bg-opacity-30 px-3 py-1 rounded-full inline-block">
<span>🚀</span> {{ Math.abs(daysBehind).toFixed(0) }} day{{ Math.abs(daysBehind) !== 1 ? 's' : '' }} ahead of schedule
</span>
<span *ngIf="daysBehind === 0" class="bg-green-400 bg-opacity-30 px-3 py-1 rounded-full inline-block">
<span>✨</span> Right on schedule!
</span>
</div>
</div>
</div>
<!-- Sports Progress Card -->
<div class="bg-gradient-to-r from-blue-500/90 via-purple-600/90 to-pink-600/90 backdrop-blur-md rounded-xl shadow-lg p-6 text-white relative overflow-hidden">
<div class="absolute top-0 right-0 w-32 h-32 bg-white opacity-10 rounded-full -mr-16 -mt-16"></div>
<div class="absolute bottom-0 left-0 w-24 h-24 bg-white opacity-10 rounded-full -ml-12 -mb-12"></div>
<div class="relative z-10">
<h4 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="text-2xl">🎯</span>
<span>Sports Progress</span>
</h4>
<div class="flex flex-col items-center">
<div class="h-48 w-full">
<canvas
baseChart
[data]="sportsProgressBikeDoughnutData"
[type]="'doughnut'"
[options]="sportsProgressBikeDoughnutOptions"
></canvas>
</div>
<div class="mt-2 text-center">
<div class="text-2xl font-bold drop-shadow-lg">
{{ progress.overallPercentage.toFixed(1) }}%
</div>
<div class="text-xs opacity-90 mt-1">Overall</div>
</div>
<!-- Legend -->
<div class="mt-4 flex flex-wrap justify-center gap-4 text-xs">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
<span class="opacity-90">Bike: {{ progress.bike.percentage.toFixed(1) }}%</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="opacity-90">Run: {{ progress.run.percentage.toFixed(1) }}%</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<span class="opacity-90">Swim: {{ progress.swim.percentage.toFixed(1) }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Combined Chart -->
<app-chart-card
title="Combined Progress Over Time"
[chartData]="combinedChartData"
[chartType]="'line'"
[chartOptions]="progressChartOptions"
></app-chart-card>
</div>
`,
})
export class OverallProgressSectionComponent {
@Input() progress!: SportProgress;
@Input() yearProgress = 0;
@Input() idealProgress = 0;
@Input() daysElapsed = 0;
@Input() totalDaysInYear = 365;
@Input() daysBehind: number | null = null;
@Input() isOnTrack = false;
@Input() yearProgressDoughnutData!: ChartData<'doughnut'>;
@Input() yearProgressOptimalDoughnutData!: ChartData<'doughnut'>;
@Input() sportsProgressBikeDoughnutData!: ChartData<'doughnut'>;
@Input() combinedChartData!: ChartData<'line'>;
@Input() yearProgressDoughnutOptions!: ChartConfiguration<'doughnut'>['options'];
@Input() yearProgressOptimalDoughnutOptions!: ChartConfiguration<'doughnut'>['options'];
@Input() sportsProgressBikeDoughnutOptions!: ChartConfiguration<'doughnut'>['options'];
@Input() progressChartOptions!: ChartConfiguration['options'];
readonly Math = Math;
}
@@ -0,0 +1,17 @@
// Component styles if needed
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgChartsModule } from 'ng2-charts';
import {
ChartConfiguration,
ChartData,
} from 'chart.js';
import { SportGoal, Activity } from '../../../core/models/sport-goal.model';
import { ProgressCardComponent } from '../../../shared/components/progress-card/progress-card.component';
import { ChartCardComponent } from '../../../shared/components/chart-card/chart-card.component';
@Component({
selector: 'app-sport-section',
standalone: true,
imports: [CommonModule, NgChartsModule, ProgressCardComponent, ChartCardComponent],
template: `
<div
class="backdrop-blur-sm rounded-2xl shadow-xl p-6 md:p-8 border-2 mt-8"
[ngClass]="gradientClass"
>
<div class="flex items-center gap-3 mb-6 pb-4 border-b-2" [ngClass]="borderClass">
<span class="text-5xl">{{ emoji }}</span>
<div>
<h3 class="text-2xl font-bold text-gray-900">{{ title }}</h3>
<p class="text-sm text-gray-600">{{ description }}</p>
</div>
</div>
<div class="space-y-6">
<app-progress-card
[title]="title"
[emoji]="emoji"
[percentage]="goal.percentage"
[currentLabel]="formatDistance(goal.current) + ' km'"
[targetLabel]="goal.target + ' km'"
[description]="
'Progress: ' +
formatDistance(goal.current) +
' / ' +
goal.target +
' km'
"
[estimatedCompletion]="estimatedCompletion"
[dailyKmNeeded]="dailyKmNeeded"
[currentDailyAvg]="currentDailyAvg"
[optimalDailyAvg]="optimalDailyAvg"
[daysAheadOrBehind]="daysAheadOrBehind"
[recentActivities]="recentActivities"
></app-progress-card>
<div class="bg-white/90 backdrop-blur-md rounded-xl shadow-lg p-6 border" [ngClass]="borderColorClass">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="text-lg font-semibold text-gray-900 mb-4 text-center">Progress</h4>
<div class="h-64">
<canvas
baseChart
[data]="doughnutData"
[type]="'doughnut'"
[options]="doughnutOptions"
></canvas>
</div>
<div class="mt-4 text-center space-y-2">
<p class="text-sm text-gray-600">
<span class="font-semibold text-gray-900">{{ goal.percentage.toFixed(1) }}%</span> Actual
</p>
<p class="text-xs text-gray-500">
Optimal: <span class="font-semibold">{{ idealProgress.toFixed(1) }}%</span>
</p>
</div>
</div>
<div>
<app-chart-card
[title]="title + ' Progress Over Time'"
[chartData]="chartData"
[chartType]="'line'"
[chartOptions]="chartOptions"
></app-chart-card>
</div>
</div>
</div>
</div>
</div>
`,
})
export class SportSectionComponent {
@Input() title = '';
@Input() emoji = '';
@Input() description = '';
@Input() goal!: SportGoal;
@Input() estimatedCompletion: string | null = null;
@Input() dailyKmNeeded: number | null = null;
@Input() currentDailyAvg: number | null = null;
@Input() optimalDailyAvg: number | null = null;
@Input() daysAheadOrBehind: number | null = null;
@Input() recentActivities: Activity[] | null = null;
@Input() idealProgress = 0;
@Input() doughnutData!: ChartData<'doughnut'>;
@Input() chartData!: ChartData<'line'>;
@Input() doughnutOptions!: ChartConfiguration<'doughnut'>['options'];
@Input() chartOptions!: ChartConfiguration['options'];
@Input() gradientClass = '';
@Input() borderClass = '';
@Input() borderColorClass = '';
readonly Math = Math;
formatDistance(km: number): string {
return km.toFixed(2);
}
}
@@ -0,0 +1,91 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { StravaService } from '../../core/services/strava.service';
@Component({
selector: 'app-strava-callback',
standalone: true,
imports: [CommonModule],
template: `
<div class="flex justify-center items-center h-screen">
<div class="text-center">
<p *ngIf="loading" class="text-gray-600">Connecting to Strava...</p>
<p *ngIf="error" class="text-red-600">{{ error }}</p>
<p *ngIf="success" class="text-green-600">Successfully connected to Strava!</p>
</div>
</div>
`,
styles: [],
})
export class StravaCallbackComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly stravaService = inject(StravaService);
loading = true;
error: string | null = null;
success = false;
ngOnInit(): void {
this.route.queryParams.subscribe((params) => {
const code = params['code'];
const error = params['error'];
if (error) {
this.error = 'Authorization failed. Please try again.';
this.loading = false;
setTimeout(() => {
this.router.navigate(['/sport']);
}, 3000);
return;
}
if (code) {
this.exchangeCodeForToken(code);
} else {
this.error = 'No authorization code received.';
this.loading = false;
setTimeout(() => {
this.router.navigate(['/sport']);
}, 3000);
}
});
}
private exchangeCodeForToken(code: string): void {
this.stravaService.exchangeCodeForToken(code).subscribe({
next: () => {
this.success = true;
this.loading = false;
setTimeout(() => {
this.router.navigate(['/sport']);
}, 2000);
},
error: (err) => {
this.error = 'Failed to connect to Strava. Please try again.';
this.loading = false;
console.error('Error exchanging code for token:', err);
setTimeout(() => {
this.router.navigate(['/sport']);
}, 3000);
},
});
}
}
@@ -0,0 +1,71 @@
import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-animated-counter',
standalone: true,
imports: [CommonModule],
template: `
<span class="font-bold" [style.color]="color">
{{ displayValue }}{{ suffix }}
</span>
`,
styles: [],
})
export class AnimatedCounterComponent implements OnInit, OnDestroy {
@Input() value: number = 0;
@Input() duration: number = 2000;
@Input() decimals: number = 1;
@Input() suffix: string = '';
@Input() color: string = '';
displayValue: number = 0;
private animationId: number | null = null;
private startTime: number = 0;
private startValue: number = 0;
ngOnInit(): void {
this.startValue = this.displayValue;
this.startTime = performance.now();
this.animate();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
}
private animate = (): void => {
const currentTime = performance.now();
const elapsed = currentTime - this.startTime;
const progress = Math.min(elapsed / this.duration, 1);
// Easing function (ease-out)
const easeOut = 1 - Math.pow(1 - progress, 3);
this.displayValue = this.startValue + (this.value - this.startValue) * easeOut;
if (progress < 1) {
this.animationId = requestAnimationFrame(this.animate);
} else {
this.displayValue = this.value;
}
};
}
@@ -0,0 +1,524 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import * as THREE from 'three';
@Component({
selector: 'app-basketball-game',
standalone: true,
imports: [CommonModule],
template: `
<div class="basketball-game-container">
<canvas #canvas class="absolute inset-0 w-full h-full"></canvas>
<div class="absolute top-4 left-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm font-bold z-10">
Score: {{ score }}
</div>
</div>
`,
styles: [
`
.basketball-game-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
z-index: 50;
pointer-events: none;
touch-action: pan-y;
}
canvas {
display: block;
cursor: crosshair;
background: transparent;
width: 100vw;
height: 100vh;
pointer-events: auto;
touch-action: pan-y pinch-zoom;
}
.basketball-game-container > div {
pointer-events: auto;
}
`,
],
})
export class BasketballGameComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private scene!: THREE.Scene;
private camera!: THREE.OrthographicCamera;
private renderer!: THREE.WebGLRenderer;
private animationId: number | null = null;
private ball!: THREE.Mesh;
private hoop!: THREE.Group;
private isDragging = false;
private dragStart = new THREE.Vector2();
private ballVelocity = new THREE.Vector2();
private lastMouseX = 0;
private lastMouseY = 0;
private trajectory: THREE.Vector2[] = [];
private showTrajectory = false;
private trajectoryDots: THREE.Points[] = [];
score = 0;
private gravity = 0.4;
private bounce = 0.65;
private friction = 0.985;
private aspect = 1;
private readonly ballStartPos = { x: -15, y: 5 };
ngOnInit(): void {}
ngAfterViewInit(): void {
this.initThree();
this.createScene();
this.setupEventListeners();
this.animate();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
if (this.renderer) {
this.renderer.dispose();
}
}
private initThree(): void {
const canvas = this.canvasRef.nativeElement;
const width = window.innerWidth;
const height = window.innerHeight;
this.aspect = width / height;
this.scene = new THREE.Scene();
this.scene.background = null;
// Orthographic camera for 2D
const viewSize = 20;
this.camera = new THREE.OrthographicCamera(
-viewSize * this.aspect,
viewSize * this.aspect,
viewSize,
-viewSize,
0.1,
1000
);
this.camera.position.z = 10;
this.renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
});
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
window.addEventListener('resize', () => this.onWindowResize());
}
private createScene(): void {
// Ball (2D circle)
const ballGeometry = new THREE.CircleGeometry(0.8, 32);
const ballMaterial = new THREE.MeshBasicMaterial({
color: 0xff8c00,
});
this.ball = new THREE.Mesh(ballGeometry, ballMaterial);
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
this.scene.add(this.ball);
// Add basketball lines (2D)
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 2 });
// Horizontal line
const hLineGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-0.8, 0, 0.01),
new THREE.Vector3(0.8, 0, 0.01),
]);
const hLine = new THREE.Line(hLineGeometry, lineMaterial);
this.ball.add(hLine);
// Vertical line
const vLineGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, -0.8, 0.01),
new THREE.Vector3(0, 0.8, 0.01),
]);
const vLine = new THREE.Line(vLineGeometry, lineMaterial);
this.ball.add(vLine);
// Hoop (on the right side, 2D) - make it VERY visible with filled shapes and larger size
this.hoop = new THREE.Group();
// Backboard (filled rectangle for visibility - larger)
const backboardShape = new THREE.Shape();
backboardShape.moveTo(17, 1.5);
backboardShape.lineTo(19, 1.5);
backboardShape.lineTo(19, 6.5);
backboardShape.lineTo(17, 6.5);
backboardShape.lineTo(17, 1.5);
const backboardGeometry = new THREE.ShapeGeometry(backboardShape);
const backboardMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: false,
});
const backboard = new THREE.Mesh(backboardGeometry, backboardMaterial);
this.hoop.add(backboard);
// Backboard outline (thick line)
const backboardLineGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(17, 1.5, 0.01),
new THREE.Vector3(19, 1.5, 0.01),
new THREE.Vector3(19, 6.5, 0.01),
new THREE.Vector3(17, 6.5, 0.01),
new THREE.Vector3(17, 1.5, 0.01),
]);
const backboardLineMaterial = new THREE.LineBasicMaterial({
color: 0x000000,
linewidth: 5,
});
const backboardLine = new THREE.Line(backboardLineGeometry, backboardLineMaterial);
this.hoop.add(backboardLine);
// Rim (filled semi-circle for visibility - larger)
const rimRadius = 1.5; // Increased from 1.2
const rimShape = new THREE.Shape();
for (let i = 0; i <= 64; i++) {
const angle = (i / 64) * Math.PI;
const x = 18 + Math.cos(angle) * rimRadius;
const y = 4 + Math.sin(angle) * rimRadius;
if (i === 0) {
rimShape.moveTo(x, y);
} else {
rimShape.lineTo(x, y);
}
}
// Close the shape
rimShape.lineTo(18 - rimRadius, 4);
rimShape.lineTo(18 + rimRadius, 4);
const rimGeometry = new THREE.ShapeGeometry(rimShape);
const rimMaterial = new THREE.MeshBasicMaterial({
color: 0xff0000, // Bright red
transparent: false,
});
const rim = new THREE.Mesh(rimGeometry, rimMaterial);
this.hoop.add(rim);
// Rim outline (thick line - larger)
const rimPoints: THREE.Vector3[] = [];
for (let i = 0; i <= 64; i++) {
const angle = (i / 64) * Math.PI;
rimPoints.push(new THREE.Vector3(18 + Math.cos(angle) * rimRadius, 4 + Math.sin(angle) * rimRadius, 0.01));
}
const rimLineGeometry = new THREE.BufferGeometry().setFromPoints(rimPoints);
const rimLineMaterial = new THREE.LineBasicMaterial({
color: 0x000000,
linewidth: 5,
});
const rimLine = new THREE.Line(rimLineGeometry, rimLineMaterial);
this.hoop.add(rimLine);
// Net (vertical lines - more visible and larger)
const netMaterial = new THREE.LineBasicMaterial({
color: 0xffffff,
linewidth: 4,
transparent: false,
});
for (let i = 0; i < 12; i++) {
const x = 18 + Math.cos((i / 12) * Math.PI) * rimRadius;
const y = 4 + Math.sin((i / 12) * Math.PI) * rimRadius;
const netGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(x, y, 0.01),
new THREE.Vector3(x, y - 3, 0.01),
]);
const netLine = new THREE.Line(netGeometry, netMaterial);
this.hoop.add(netLine);
}
this.scene.add(this.hoop);
// Trajectory will be rendered as dots
// Floor line (subtle)
const floorGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-20 * this.aspect, -8, 0),
new THREE.Vector3(20 * this.aspect, -8, 0),
]);
const floorMaterial = new THREE.LineBasicMaterial({
color: 0xffffff,
linewidth: 1,
transparent: true,
opacity: 0.3
});
const floor = new THREE.Line(floorGeometry, floorMaterial);
this.scene.add(floor);
}
private setupEventListeners(): void {
const canvas = this.canvasRef.nativeElement;
canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
canvas.addEventListener('mouseleave', (e) => this.onMouseUp(e));
// Allow wheel events to pass through for scrolling
canvas.addEventListener('wheel', (e) => {
// Don't prevent default - allow scrolling
}, { passive: true });
}
private screenToWorld(x: number, y: number): THREE.Vector2 {
const canvas = this.canvasRef.nativeElement;
const rect = canvas.getBoundingClientRect();
const mouseX = ((x - rect.left) / rect.width) * 2 - 1;
const mouseY = -((y - rect.top) / rect.height) * 2 + 1;
const viewSize = 20;
return new THREE.Vector2(
mouseX * viewSize * this.aspect,
mouseY * viewSize
);
}
private onMouseDown(event: MouseEvent): void {
const worldPos = this.screenToWorld(event.clientX, event.clientY);
const distance = new THREE.Vector2(
worldPos.x - this.ball.position.x,
worldPos.y - this.ball.position.y
).length();
// Allow dragging if clicking near the ball (slingshot style)
if (distance < 1.5) {
this.isDragging = true;
// Stop the ball immediately when clicked
this.ballVelocity.set(0, 0);
// Reset ball to starting position
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
// Store the ball's starting position as the anchor point
this.dragStart.set(this.ballStartPos.x, this.ballStartPos.y);
this.showTrajectory = true;
}
}
private onMouseMove(event: MouseEvent): void {
// Store last mouse position for onMouseUp
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
if (this.isDragging) {
const worldPos = this.screenToWorld(event.clientX, event.clientY);
// Calculate drag vector (from ball start position to mouse)
const dragVector = new THREE.Vector2().subVectors(worldPos, this.dragStart);
const dragLength = dragVector.length();
const maxDrag = 8;
if (dragLength > maxDrag) {
dragVector.normalize().multiplyScalar(maxDrag);
}
// Ball stays at starting position, only trajectory updates
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
// Calculate trajectory based on drag vector
this.calculateTrajectory(dragVector);
}
}
private onMouseUp(event?: MouseEvent): void {
if (this.isDragging) {
this.isDragging = false;
this.showTrajectory = false;
// Get the final drag vector from last mouse position
let worldPos: THREE.Vector2;
if (event) {
worldPos = this.screenToWorld(event.clientX, event.clientY);
} else {
// Use stored last mouse position
worldPos = this.screenToWorld(this.lastMouseX, this.lastMouseY);
}
// Clear trajectory dots
this.trajectoryDots.forEach(dot => {
this.scene.remove(dot);
dot.geometry.dispose();
(dot.material as THREE.Material).dispose();
});
this.trajectoryDots = [];
// Calculate velocity in opposite direction of drag (slingshot)
const dragVector = new THREE.Vector2().subVectors(worldPos, this.dragStart);
const dragLength = dragVector.length();
if (dragLength > 0.1) {
// Velocity is opposite to drag direction (slingshot effect)
const power = Math.min(dragLength * 0.15, 1.5); // Much slower speed
const direction = dragVector.normalize().multiplyScalar(-1); // Opposite direction
this.ballVelocity.copy(direction.multiplyScalar(power));
} else {
this.ballVelocity.set(0, 0);
}
// Ball is already at start position, no need to reset
}
}
private calculateTrajectory(dragVector?: THREE.Vector2): void {
// Clear existing trajectory dots
this.trajectoryDots.forEach(dot => {
this.scene.remove(dot);
dot.geometry.dispose();
(dot.material as THREE.Material).dispose();
});
this.trajectoryDots = [];
// If dragVector is not provided, calculate it from current ball position
if (!dragVector) {
dragVector = new THREE.Vector2().subVectors(
new THREE.Vector2(this.ball.position.x, this.ball.position.y),
this.dragStart
);
}
const dragLength = dragVector.length();
if (dragLength < 0.1) {
return;
}
// Calculate velocity in opposite direction (slingshot)
const power = Math.min(dragLength * 0.15, 1.5); // Much slower speed
const direction = dragVector.clone().normalize().multiplyScalar(-1); // Opposite direction
const velocity = direction.multiplyScalar(power);
this.trajectory = [];
const pos = new THREE.Vector2(this.dragStart.x, this.dragStart.y);
const vel = new THREE.Vector2(velocity.x, velocity.y);
// Sample trajectory points every few steps
for (let i = 0; i < 200; i++) {
if (i % 3 === 0) { // Only add every 3rd point for dots
this.trajectory.push(new THREE.Vector2(pos.x, pos.y));
}
pos.add(vel);
vel.y -= this.gravity;
vel.multiplyScalar(this.friction);
if (pos.y < -8 || Math.abs(pos.x) > 20) break;
}
// Create dots for trajectory
this.trajectory.forEach((point, index) => {
const dotGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(point.x, point.y, 0)
]);
const dotMaterial = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.3,
transparent: true,
opacity: 0.7,
});
const dot = new THREE.Points(dotGeometry, dotMaterial);
this.scene.add(dot);
this.trajectoryDots.push(dot);
});
}
private updateBall(): void {
if (this.isDragging) return;
const speed = this.ballVelocity.length();
if (speed < 0.05) {
this.ballVelocity.set(0, 0);
// Reset ball to starting position when it stops
if (this.ball.position.x !== this.ballStartPos.x || this.ball.position.y !== this.ballStartPos.y) {
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
}
return;
}
this.ball.position.x += this.ballVelocity.x;
this.ball.position.y += this.ballVelocity.y;
this.ballVelocity.y -= this.gravity;
this.ballVelocity.multiplyScalar(this.friction);
const viewSize = 20;
// Bounce off floor
if (this.ball.position.y < -7.2) {
this.ball.position.y = -7.2;
this.ballVelocity.y *= -this.bounce;
this.ballVelocity.x *= 0.9; // Reduce horizontal velocity on bounce
if (Math.abs(this.ballVelocity.y) < 0.2) {
this.ballVelocity.y = 0;
// Reset ball position if it stops
if (Math.abs(this.ballVelocity.x) < 0.15) {
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
this.ballVelocity.set(0, 0);
}
}
}
// Bounce off walls
if (Math.abs(this.ball.position.x) > viewSize * this.aspect - 1) {
this.ballVelocity.x *= -this.bounce;
this.ball.position.x = Math.max(
-viewSize * this.aspect + 1,
Math.min(viewSize * this.aspect - 1, this.ball.position.x)
);
}
// Bounce off top
if (this.ball.position.y > viewSize - 1) {
this.ballVelocity.y *= -this.bounce;
this.ball.position.y = viewSize - 1;
}
// Check basket (right side, around y=4)
// Ball must pass through the rim area while moving downward
const rimCenterX = 18;
const rimCenterY = 4;
const rimRadius = 1.5; // Match the visual rim radius
const distFromRimCenter = Math.sqrt(
Math.pow(this.ball.position.x - rimCenterX, 2) +
Math.pow(this.ball.position.y - rimCenterY, 2)
);
// Check if ball passes through the rim (within radius and moving downward)
if (
distFromRimCenter < rimRadius + 0.5 &&
this.ball.position.y > 3 &&
this.ball.position.y < 5 &&
this.ballVelocity.y < 0.5 &&
this.ballVelocity.y > -3
) {
this.score++;
this.ballVelocity.set(0, 0);
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
}
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
this.updateBall();
this.renderer.render(this.scene, this.camera);
};
private onWindowResize(): void {
const width = window.innerWidth;
const height = window.innerHeight;
this.aspect = width / height;
const viewSize = 20;
this.camera.left = -viewSize * this.aspect;
this.camera.right = viewSize * this.aspect;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
}
@@ -0,0 +1,145 @@
import {
Component,
Input,
OnInit,
OnChanges,
SimpleChanges,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
BaseChartDirective,
NgChartsModule,
} from 'ng2-charts';
import {
ChartConfiguration,
ChartData,
ChartType,
} from 'chart.js';
@Component({
selector: 'app-chart-card',
standalone: true,
imports: [CommonModule, NgChartsModule],
template: `
<div class="bg-white/80 backdrop-blur-md rounded-lg shadow-md p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 border border-gray-200/50">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<span class="text-2xl">📈</span>
<span>{{ title }}</span>
</h3>
<div class="h-80 md:h-96">
<canvas
baseChart
[data]="chartData"
[type]="chartType"
[options]="mergedOptions"
></canvas>
</div>
</div>
`,
styles: [],
})
export class ChartCardComponent implements OnInit, OnChanges, AfterViewInit {
@Input() title = '';
@Input() chartData: ChartData = { datasets: [], labels: [] };
@Input() chartType: ChartType = 'line';
@Input() chartOptions: ChartConfiguration['options'] = {};
@ViewChild(BaseChartDirective) chart?: BaseChartDirective;
mergedOptions: ChartConfiguration['options'] = {};
ngOnInit(): void {
this.updateOptions();
}
ngAfterViewInit(): void {
// Set initial zoom after chart is rendered
setTimeout(() => {
this.setInitialZoom();
}, 200);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['chartOptions'] || changes['chartData']) {
this.updateOptions();
if (this.chart) {
this.chart.update();
setTimeout(() => {
this.setInitialZoom();
}, 200);
}
}
}
private setInitialZoom(): void {
if (!this.chart || !this.chart.chart) {
return;
}
const chart = this.chart.chart;
const xScale = chart.scales?.['x'];
if (!xScale || !chart.data || !chart.data.labels) {
return;
}
// Calculate current week index
const now = new Date();
const startOfYear = new Date('2026-01-01');
const weeksSinceStart = Math.floor(
(now.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24 * 7)
);
// Show from week 0 to current week (add 1 because week 0 is included)
// If we're on week 3, we want to show weeks 0, 1, 2, 3 (4 weeks total)
const totalWeeks = chart.data.labels.length;
const startWeek = 0;
const endWeek = Math.min(totalWeeks - 1, weeksSinceStart + 1);
// Use zoom plugin's zoomScale method to set initial zoom
const zoomPlugin = (chart as any).plugins?.plugins?.zoom;
if (zoomPlugin) {
// Try using the zoom plugin's method
if (typeof (zoomPlugin as any).zoomScale === 'function') {
(zoomPlugin as any).zoomScale('x', {
min: startWeek,
max: endWeek,
});
} else if ((chart as any).zoomScale) {
(chart as any).zoomScale('x', {
min: startWeek,
max: endWeek,
});
}
}
// Also directly set scale options as fallback
if (xScale.options) {
(xScale.options as any).min = startWeek;
(xScale.options as any).max = endWeek;
}
chart.update('none');
}
private updateOptions(): void {
const defaultOptions: ChartConfiguration['options'] = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
},
},
};
this.mergedOptions = {
...defaultOptions,
...this.chartOptions,
};
}
}
@@ -0,0 +1,128 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
interface ConfettiParticle {
x: number;
y: number;
vx: number;
vy: number;
color: string;
size: number;
rotation: number;
rotationSpeed: number;
}
@Component({
selector: 'app-confetti',
standalone: true,
imports: [CommonModule],
template: `
<canvas #canvas class="fixed inset-0 pointer-events-none z-50"></canvas>
`,
styles: [
`
canvas {
display: block;
}
`,
],
})
export class ConfettiComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private particles: ConfettiParticle[] = [];
private animationId: number | null = null;
private colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#ef4444'];
ngOnInit(): void {}
ngAfterViewInit(): void {
const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d')!;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
this.createParticles();
this.animate();
window.addEventListener('resize', () => this.onResize());
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
window.removeEventListener('resize', () => this.onResize());
}
private createParticles(): void {
const count = 150;
for (let i = 0; i < count; i++) {
this.particles.push({
x: Math.random() * window.innerWidth,
y: -10,
vx: (Math.random() - 0.5) * 4,
vy: Math.random() * 3 + 2,
color: this.colors[Math.floor(Math.random() * this.colors.length)],
size: Math.random() * 8 + 4,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.2,
});
}
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
this.ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.1; // Gravity
p.rotation += p.rotationSpeed;
this.ctx.save();
this.ctx.translate(p.x, p.y);
this.ctx.rotate(p.rotation);
this.ctx.fillStyle = p.color;
this.ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
this.ctx.restore();
if (p.y > window.innerHeight + 10) {
this.particles.splice(i, 1);
}
}
if (this.particles.length === 0) {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
}
};
private onResize(): void {
const canvas = this.canvasRef.nativeElement;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
}
@@ -0,0 +1,239 @@
import {
Component,
OnInit,
OnDestroy,
HostListener,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-custom-cursor',
standalone: true,
imports: [CommonModule],
template: `
<div
class="cursor-dot"
[class.hover]="isHoveringClickable()"
[style.left.px]="x"
[style.top.px]="y"
></div>
<div
class="cursor-ring"
[class.hover]="isHoveringClickable()"
[style.left.px]="ringX"
[style.top.px]="ringY"
[style.width.px]="ringSize()"
[style.height.px]="ringSize()"
></div>
`,
styles: [
`
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 0.7;
transform: translate(-50%, -50%) scale(1.1);
}
}
@keyframes rotate {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes glow {
0%, 100% {
box-shadow: 0 0 5px rgba(255, 255, 255, 0.5),
0 0 10px rgba(255, 255, 255, 0.3);
}
50% {
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8),
0 0 20px rgba(255, 255, 255, 0.5),
0 0 30px rgba(255, 255, 255, 0.3);
}
}
.cursor-dot {
position: fixed;
pointer-events: none;
z-index: 9999;
width: 8px;
height: 8px;
background: white;
border-radius: 50%;
transform: translate(-50%, -50%);
mix-blend-mode: difference;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
height 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cursor-dot.hover {
width: 12px;
height: 12px;
transform: translate(-50%, -50%) scale(1.5);
animation: pulse 1.5s ease-in-out infinite;
}
.cursor-ring {
position: fixed;
pointer-events: none;
z-index: 9998;
width: 40px;
height: 40px;
border: 2px solid white;
border-radius: 50%;
transform: translate(-50%, -50%);
mix-blend-mode: difference;
transition: width 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
height 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
border-width 0.3s ease-out, border-color 0.3s ease-out,
opacity 0.3s ease-out;
}
.cursor-ring.hover {
width: 25px;
height: 25px;
border-width: 3px;
border-color: rgba(255, 255, 255, 1);
opacity: 0.9;
animation: rotate 3s linear infinite, glow 2s ease-in-out infinite;
}
@media (hover: none) {
.cursor-dot,
.cursor-ring {
display: none;
}
}
/* Hide default cursor */
:host {
cursor: none;
}
`,
],
})
export class CustomCursorComponent implements OnInit, OnDestroy {
x = 0;
y = 0;
ringX = 0;
ringY = 0;
private animationId: number | null = null;
private targetX = 0;
private targetY = 0;
readonly isHoveringClickable = signal(false);
private readonly clickableSelectors = [
'a',
'button',
'[role="button"]',
'input',
'textarea',
'select',
'[onclick]',
'[ng-click]',
'.clickable',
'[tabindex]:not([tabindex="-1"])',
].join(',');
ngOnInit(): void {
this.animate();
this.setupClickableDetection();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
this.cleanupClickableDetection();
}
@HostListener('document:mousemove', ['$event'])
onMouseMove(event: MouseEvent): void {
this.x = event.clientX;
this.y = event.clientY;
this.targetX = event.clientX;
this.targetY = event.clientY;
this.checkClickableElement(event.target as Element);
}
private readonly isMouseDown = signal(false);
readonly ringSize = signal(40);
@HostListener('document:mousedown', [])
onMouseDown(): void {
this.isMouseDown.set(true);
this.updateRingSize();
}
@HostListener('document:mouseup', [])
onMouseUp(): void {
this.isMouseDown.set(false);
this.updateRingSize();
}
private updateRingSize(): void {
const hovering = this.isHoveringClickable();
const mouseDown = this.isMouseDown();
if (mouseDown) {
this.ringSize.set(hovering ? 20 : 25);
} else {
this.ringSize.set(hovering ? 25 : 40);
}
}
private checkClickableElement(element: Element | null): void {
if (!element) {
this.isHoveringClickable.set(false);
this.updateRingSize();
return;
}
const isClickable =
element.matches(this.clickableSelectors) ||
element.closest(this.clickableSelectors) !== null;
this.isHoveringClickable.set(isClickable);
this.updateRingSize();
}
private setupClickableDetection(): void {
document.addEventListener('mouseover', this.handleMouseOver, true);
document.addEventListener('mouseout', this.handleMouseOut, true);
}
private cleanupClickableDetection(): void {
document.removeEventListener('mouseover', this.handleMouseOver, true);
document.removeEventListener('mouseout', this.handleMouseOut, true);
}
private readonly handleMouseOver = (event: MouseEvent): void => {
this.checkClickableElement(event.target as Element);
};
private readonly handleMouseOut = (event: MouseEvent): void => {
const relatedTarget = event.relatedTarget as Element | null;
if (!relatedTarget || !relatedTarget.closest(this.clickableSelectors)) {
this.isHoveringClickable.set(false);
this.updateRingSize();
}
};
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
// Smooth ring follow using lerp
this.ringX += (this.targetX - this.ringX) * 0.15;
this.ringY += (this.targetY - this.ringY) * 0.15;
};
}
@@ -0,0 +1,506 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import * as THREE from 'three';
interface Duck {
mesh: THREE.Group;
velocity: THREE.Vector3;
alive: boolean;
}
interface Bullet {
mesh: THREE.Mesh;
velocity: THREE.Vector3;
active: boolean;
}
@Component({
selector: 'app-duck-shooting-game',
standalone: true,
imports: [CommonModule],
template: `
<div class="duck-shooting-game-container">
<canvas #canvas class="absolute inset-0 w-full h-full"></canvas>
<div class="crosshair" [style.left.px]="crosshairX" [style.top.px]="crosshairY"></div>
<div class="absolute top-2 left-2 bg-black/50 text-white px-2 py-1 rounded text-xs font-bold z-10">
Score: {{ score }} | Ducks: {{ ducksShot }}
</div>
</div>
`,
styles: [
`
.duck-shooting-game-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
z-index: 50;
pointer-events: none;
touch-action: pan-y;
}
canvas {
display: block;
cursor: none !important;
background: transparent;
width: 100vw;
height: 100vh;
pointer-events: auto;
touch-action: pan-y pinch-zoom;
}
.duck-shooting-game-container > div {
pointer-events: auto;
}
.crosshair {
position: fixed;
width: 25px;
height: 25px;
pointer-events: none;
z-index: 100;
transform: translate(-50%, -50%);
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
}
.crosshair::before,
.crosshair::after {
content: '';
position: absolute;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 3px rgba(255, 255, 255, 0.6);
}
.crosshair::before {
left: 50%;
top: -4px;
width: 2px;
height: 8px;
transform: translateX(-50%);
}
.crosshair::after {
left: 50%;
bottom: -4px;
width: 2px;
height: 8px;
transform: translateX(-50%);
}
`,
],
})
export class DuckShootingGameComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private scene!: THREE.Scene;
private camera!: THREE.PerspectiveCamera;
private renderer!: THREE.WebGLRenderer;
private animationId: number | null = null;
private ducks: Duck[] = [];
private bullets: Bullet[] = [];
private raycaster = new THREE.Raycaster();
private mouse = new THREE.Vector2();
private duckSpawnTimer = 0;
private duckSpawnInterval = 2000; // Spawn every 2 seconds
crosshairX = 0;
crosshairY = 0;
score = 0;
ducksShot = 0;
gameOver = false;
private gameTime = 0;
private gameDuration = 60000; // 60 seconds
ngOnInit(): void {}
ngAfterViewInit(): void {
this.initThree();
this.createScene();
this.setupEventListeners();
this.animate();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
if (this.renderer) {
this.renderer.dispose();
}
}
private initThree(): void {
const canvas = this.canvasRef.nativeElement;
const width = window.innerWidth;
const height = window.innerHeight;
this.scene = new THREE.Scene();
this.scene.background = null;
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
this.camera.position.set(0, 5, 20);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
});
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
window.addEventListener('resize', () => this.onWindowResize());
}
private createScene(): void {
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9);
directionalLight.position.set(10, 10, 5);
directionalLight.castShadow = true;
this.scene.add(directionalLight);
}
private createDuck(): Duck {
const duck = new THREE.Group();
// Body (more duck-like shape - wider and flatter)
const bodyGeometry = new THREE.SphereGeometry(0.4, 16, 16);
bodyGeometry.scale(1.2, 0.8, 1.5);
const bodyMaterial = new THREE.MeshStandardMaterial({
color: 0xffa500,
roughness: 0.7,
});
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
body.castShadow = true;
duck.add(body);
// Head (larger, more prominent)
const headGeometry = new THREE.SphereGeometry(0.3, 16, 16);
const headMaterial = new THREE.MeshStandardMaterial({
color: 0xffa500,
roughness: 0.7,
});
const head = new THREE.Mesh(headGeometry, headMaterial);
head.position.set(0, 0.4, 0.5);
head.castShadow = true;
duck.add(head);
// Beak (more prominent, duck-like)
const beakGeometry = new THREE.ConeGeometry(0.12, 0.25, 8);
beakGeometry.scale(1, 1, 1.3);
const beakMaterial = new THREE.MeshStandardMaterial({
color: 0xff8c00,
roughness: 0.5,
});
const beak = new THREE.Mesh(beakGeometry, beakMaterial);
beak.rotation.x = Math.PI / 2;
beak.position.set(0, 0.4, 0.75);
duck.add(beak);
// Left Wing
const wingGeometry = new THREE.SphereGeometry(0.25, 16, 16);
wingGeometry.scale(1.8, 0.6, 0.4);
const wingMaterial = new THREE.MeshStandardMaterial({
color: 0xff8c00,
roughness: 0.7,
});
const leftWing = new THREE.Mesh(wingGeometry, wingMaterial);
leftWing.position.set(0.35, 0, 0);
leftWing.castShadow = true;
duck.add(leftWing);
// Right Wing
const rightWing = new THREE.Mesh(wingGeometry, wingMaterial);
rightWing.position.set(-0.35, 0, 0);
rightWing.castShadow = true;
duck.add(rightWing);
// Left Eye
const eyeGeometry = new THREE.SphereGeometry(0.06, 8, 8);
const eyeMaterial = new THREE.MeshStandardMaterial({ color: 0x000000 });
const leftEye = new THREE.Mesh(eyeGeometry, eyeMaterial);
leftEye.position.set(0.12, 0.45, 0.6);
duck.add(leftEye);
// Right Eye
const rightEye = new THREE.Mesh(eyeGeometry, eyeMaterial);
rightEye.position.set(-0.12, 0.45, 0.6);
duck.add(rightEye);
// Tail (small)
const tailGeometry = new THREE.SphereGeometry(0.15, 12, 12);
tailGeometry.scale(0.8, 1.2, 0.6);
const tailMaterial = new THREE.MeshStandardMaterial({
color: 0xff8c00,
roughness: 0.7,
});
const tail = new THREE.Mesh(tailGeometry, tailMaterial);
tail.position.set(0, 0, -0.5);
duck.add(tail);
// Random starting position (off screen to the left or right)
const side = Math.random() > 0.5 ? 1 : -1;
duck.position.set(side * 30, Math.random() * 10 + 2, Math.random() * 10 - 5);
// Random velocity
const speed = 0.1 + Math.random() * 0.1;
const velocity = new THREE.Vector3(-side * speed, (Math.random() - 0.5) * 0.05, (Math.random() - 0.5) * 0.03);
// Rotate duck to face direction of travel (90 degrees offset for proper orientation)
const angle = Math.atan2(velocity.x, velocity.z);
duck.rotation.y = angle + Math.PI / 2;
this.scene.add(duck);
return {
mesh: duck,
velocity,
alive: true,
};
}
private createBullet(position: THREE.Vector3, direction: THREE.Vector3): Bullet {
const bulletGeometry = new THREE.SphereGeometry(0.1, 8, 8);
const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 });
const bulletMesh = new THREE.Mesh(bulletGeometry, bulletMaterial);
bulletMesh.position.copy(position);
const speed = 1.5;
const velocity = direction.normalize().multiplyScalar(speed);
this.scene.add(bulletMesh);
return {
mesh: bulletMesh,
velocity,
active: true,
};
}
private setupEventListeners(): void {
const canvas = this.canvasRef.nativeElement;
canvas.addEventListener('mousemove', (e) => {
this.crosshairX = e.clientX;
this.crosshairY = e.clientY;
const rect = canvas.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
}, { passive: true });
canvas.addEventListener('click', (e) => {
e.stopPropagation();
this.shoot();
});
// Allow wheel events to pass through for scrolling
canvas.addEventListener('wheel', (e) => {
// Don't prevent default - allow scrolling
}, { passive: true });
// Hide default cursor on the entire container
document.addEventListener('mousemove', () => {
if (this.canvasRef?.nativeElement) {
this.canvasRef.nativeElement.style.cursor = 'none';
}
}, { passive: true });
}
private shoot(): void {
if (this.gameOver) return;
this.raycaster.setFromCamera(this.mouse, this.camera);
const direction = this.raycaster.ray.direction.clone();
const bullet = this.createBullet(this.camera.position.clone(), direction);
this.bullets.push(bullet);
}
private spawnDuck(): void {
if (this.ducks.length < 10) {
this.ducks.push(this.createDuck());
}
}
private updateDucks(): void {
for (let i = this.ducks.length - 1; i >= 0; i--) {
const duck = this.ducks[i];
if (!duck.alive) continue;
duck.mesh.position.add(duck.velocity);
// Rotate duck to face direction of travel
const angle = Math.atan2(duck.velocity.x, duck.velocity.z);
duck.mesh.rotation.y = angle + Math.PI / 2;
// Animate wing flapping (both wings)
const leftWing = duck.mesh.children.find((child) => child.position.x > 0.3);
const rightWing = duck.mesh.children.find((child) => child.position.x < -0.3);
const flapAngle = Math.sin(Date.now() * 0.015 + i) * 0.4;
if (leftWing) {
leftWing.rotation.z = flapAngle;
}
if (rightWing) {
rightWing.rotation.z = -flapAngle;
}
// Remove if off screen
if (Math.abs(duck.mesh.position.x) > 40 || duck.mesh.position.y < -5 || duck.mesh.position.y > 15) {
this.scene.remove(duck.mesh);
duck.mesh.children.forEach((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
});
this.ducks.splice(i, 1);
}
}
}
private updateBullets(): void {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const bullet = this.bullets[i];
if (!bullet.active) continue;
bullet.mesh.position.add(bullet.velocity);
// Remove if too far
if (bullet.mesh.position.distanceTo(this.camera.position) > 100) {
this.scene.remove(bullet.mesh);
bullet.mesh.geometry.dispose();
(bullet.mesh.material as THREE.Material).dispose();
this.bullets.splice(i, 1);
continue;
}
// Check collision with ducks
for (const duck of this.ducks) {
if (!duck.alive) continue;
const distance = bullet.mesh.position.distanceTo(duck.mesh.position);
if (distance < 0.8) {
// Hit!
duck.alive = false;
bullet.active = false;
// Animate duck falling
duck.velocity.set(0, -0.2, 0);
duck.mesh.rotation.x = Math.PI / 2;
this.score += 10;
this.ducksShot++;
// Remove bullet
this.scene.remove(bullet.mesh);
bullet.mesh.geometry.dispose();
(bullet.mesh.material as THREE.Material).dispose();
this.bullets.splice(i, 1);
// Remove duck after a delay
setTimeout(() => {
this.scene.remove(duck.mesh);
duck.mesh.children.forEach((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
});
const index = this.ducks.indexOf(duck);
if (index > -1) {
this.ducks.splice(index, 1);
}
}, 1000);
break;
}
}
}
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
if (!this.gameOver) {
this.gameTime += 16; // ~60fps
// Spawn ducks
this.duckSpawnTimer += 16;
if (this.duckSpawnTimer >= this.duckSpawnInterval) {
this.spawnDuck();
this.duckSpawnTimer = 0;
// Decrease spawn interval over time
this.duckSpawnInterval = Math.max(1000, this.duckSpawnInterval - 10);
}
// Auto restart after game duration
if (this.gameTime >= this.gameDuration) {
this.restart();
}
this.updateDucks();
this.updateBullets();
}
this.renderer.render(this.scene, this.camera);
};
private onWindowResize(): void {
const width = window.innerWidth;
const height = window.innerHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
restart(): void {
// Clean up existing ducks and bullets
this.ducks.forEach((duck) => {
this.scene.remove(duck.mesh);
duck.mesh.children.forEach((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
});
});
this.ducks = [];
this.bullets.forEach((bullet) => {
this.scene.remove(bullet.mesh);
bullet.mesh.geometry.dispose();
(bullet.mesh.material as THREE.Material).dispose();
});
this.bullets = [];
this.score = 0;
this.ducksShot = 0;
this.gameOver = false;
this.gameTime = 0;
this.duckSpawnTimer = 0;
this.duckSpawnInterval = 2000;
}
}
@@ -0,0 +1,114 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-footer',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<footer class="bg-white/80 backdrop-blur-md border-t-2 border-blue-200 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Brand Section -->
<div class="space-y-4">
<div class="flex items-center gap-3">
<span class="text-3xl">🎯</span>
<h3 class="text-xl font-bold text-gray-900">2026 Goals Tracker</h3>
</div>
<p class="text-gray-600 text-sm">
Track your sport and gaming goals for 2026. Stay motivated and achieve your targets!
</p>
</div>
<!-- Quick Links -->
<div class="space-y-4">
<h4 class="text-lg font-semibold text-gray-900">Quick Links</h4>
<ul class="space-y-2">
<li>
<a
routerLink="/"
class="text-gray-600 hover:text-blue-600 transition-colors duration-200 flex items-center gap-2 text-sm"
>
<span>📊</span>
<span>Dashboard</span>
</a>
</li>
<li>
<a
routerLink="/sport"
class="text-gray-600 hover:text-blue-600 transition-colors duration-200 flex items-center gap-2 text-sm"
>
<span>🏃</span>
<span>Sport Goals</span>
</a>
</li>
<li>
<a
routerLink="/gaming"
class="text-gray-600 hover:text-purple-600 transition-colors duration-200 flex items-center gap-2 text-sm"
>
<span>🎮</span>
<span>Gaming Goals</span>
</a>
</li>
</ul>
</div>
<!-- Info Section -->
<div class="space-y-4">
<h4 class="text-lg font-semibold text-gray-900">About</h4>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-center gap-2">
<span>📅</span>
<span>Tracking goals for 2026</span>
</li>
<li class="flex items-center gap-2">
<span></span>
<span>Built with Angular 19</span>
</li>
<li class="flex items-center gap-2">
<span>🔗</span>
<span>Integrated with Strava, Riot Games, Faceit</span>
</li>
</ul>
</div>
</div>
<!-- Bottom Bar -->
<div class="mt-8 pt-8 border-t border-gray-200">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-sm text-gray-600">
© {{ currentYear }} 2026 Goals Tracker. All rights reserved.
</p>
<div class="flex items-center gap-4 text-sm text-gray-600">
<span class="flex items-center gap-1">
<span></span>
<span>Stay motivated!</span>
</span>
</div>
</div>
</div>
</div>
</footer>
`,
styles: [],
})
export class FooterComponent {
readonly currentYear = new Date().getFullYear();
}
@@ -0,0 +1,229 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
AfterViewInit,
Input,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import * as THREE from 'three';
@Component({
selector: 'app-loading-screen',
standalone: true,
imports: [CommonModule],
template: `
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-gray-50 via-blue-50 to-purple-50 transition-opacity duration-500"
[class.opacity-0]="getFadeOut()"
[class.pointer-events-none]="getFadeOut()"
>
<canvas #canvas class="absolute inset-0 w-full h-full"></canvas>
<div class="relative z-10 text-center">
<h1 class="text-5xl font-bold text-gray-900 mb-4 flex items-center justify-center gap-3">
<span class="text-6xl">🎯</span>
<span>2026 Goals Tracker</span>
</h1>
<div class="w-64 h-2 bg-white/30 rounded-full overflow-hidden mx-auto mb-4">
<div
class="h-full bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-full transition-all duration-300 ease-out"
[style.width.%]="getProgress()"
>
<div class="h-full w-full animate-shimmer"></div>
</div>
</div>
<p class="text-gray-600 text-lg">{{ getLoadingText() }}</p>
</div>
</div>
`,
styles: [
`
canvas {
display: block;
width: 100%;
height: 100%;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
`,
],
})
export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
@Input() progress = signal(0);
@Input() loadingText = signal('Loading...');
@Input() fadeOut = signal(false);
private scene!: THREE.Scene;
private camera!: THREE.PerspectiveCamera;
private renderer!: THREE.WebGLRenderer;
private particles!: THREE.Points;
private animationId: number | null = null;
private particleCount = 300;
private particleVelocities: Float32Array | null = null;
ngOnInit(): void {}
getProgress(): number {
return typeof this.progress === 'function' ? this.progress() : this.progress;
}
getLoadingText(): string {
return typeof this.loadingText === 'function' ? this.loadingText() : this.loadingText;
}
getFadeOut(): boolean {
return typeof this.fadeOut === 'function' ? this.fadeOut() : this.fadeOut;
}
ngAfterViewInit(): void {
this.initThree();
this.createParticles();
this.animate();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
if (this.renderer) {
this.renderer.dispose();
}
if (this.particles) {
this.particles.geometry.dispose();
(this.particles.material as THREE.PointsMaterial).dispose();
}
}
private initThree(): void {
const canvas = this.canvasRef.nativeElement;
const width = window.innerWidth;
const height = window.innerHeight;
// Scene
this.scene = new THREE.Scene();
// Camera
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
this.camera.position.z = 5;
// Renderer
this.renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
});
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearColor(0x000000, 0);
// Handle resize
window.addEventListener('resize', () => this.onWindowResize());
}
private createParticles(): void {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(this.particleCount * 3);
const colors = new Float32Array(this.particleCount * 3);
this.particleVelocities = new Float32Array(this.particleCount * 3);
const colorPalette = [
new THREE.Color(0x3b82f6), // Blue
new THREE.Color(0x8b5cf6), // Purple
new THREE.Color(0xec4899), // Pink
];
for (let i = 0; i < this.particleCount; i++) {
const i3 = i * 3;
// Position
positions[i3] = (Math.random() - 0.5) * 10;
positions[i3 + 1] = (Math.random() - 0.5) * 10;
positions[i3 + 2] = (Math.random() - 0.5) * 10;
// Color
const color = colorPalette[Math.floor(Math.random() * colorPalette.length)];
colors[i3] = color.r;
colors[i3 + 1] = color.g;
colors[i3 + 2] = color.b;
// Velocity
this.particleVelocities![i3] = (Math.random() - 0.5) * 0.02;
this.particleVelocities![i3 + 1] = (Math.random() - 0.5) * 0.02;
this.particleVelocities![i3 + 2] = (Math.random() - 0.5) * 0.02;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.1,
vertexColors: true,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
});
this.particles = new THREE.Points(geometry, material);
this.scene.add(this.particles);
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
if (!this.particles || !this.particleVelocities) return;
const positions = this.particles.geometry.attributes['position'].array as Float32Array;
const velocities = this.particleVelocities;
// Update particle positions
for (let i = 0; i < positions.length; i += 3) {
positions[i] += velocities[i];
positions[i + 1] += velocities[i + 1];
positions[i + 2] += velocities[i + 2];
// Wrap around boundaries
if (Math.abs(positions[i]) > 5) velocities[i] *= -1;
if (Math.abs(positions[i + 1]) > 5) velocities[i + 1] *= -1;
if (Math.abs(positions[i + 2]) > 5) velocities[i + 2] *= -1;
}
// Rotate particles around center
this.particles.rotation.y += 0.001;
this.particles.rotation.x += 0.0005;
// Update geometry
this.particles.geometry.attributes['position'].needsUpdate = true;
this.renderer.render(this.scene, this.camera);
};
private onWindowResize(): void {
const width = window.innerWidth;
const height = window.innerHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
}
@@ -0,0 +1,327 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import * as THREE from 'three';
interface Block {
mesh: THREE.Mesh;
alive: boolean;
}
@Component({
selector: 'app-magic-ball-game',
standalone: true,
imports: [CommonModule],
template: `
<div class="magic-ball-game-container">
<canvas #canvas class="absolute inset-0 w-full h-full"></canvas>
<div class="absolute top-4 left-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm font-bold z-10">
Score: {{ score }} | Lives: {{ lives }}
</div>
<div class="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm z-10">
Move mouse up/down to control paddle
</div>
@if (gameOver) {
<div class="absolute inset-0 flex items-center justify-center bg-black/70 z-20">
<div class="text-center text-white">
<h2 class="text-4xl font-bold mb-4">Game Over!</h2>
<p class="text-2xl mb-4">Final Score: {{ score }}</p>
<button
(click)="restart()"
class="px-6 py-3 bg-purple-600 text-white rounded-lg font-bold hover:bg-purple-700 transition-all"
>
Play Again
</button>
</div>
</div>
}
</div>
`,
styles: [
`
.magic-ball-game-container {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(2px);
}
canvas {
display: block;
}
`,
],
})
export class MagicBallGameComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private scene!: THREE.Scene;
private camera!: THREE.OrthographicCamera;
private renderer!: THREE.WebGLRenderer;
private animationId: number | null = null;
private ball!: THREE.Mesh;
private paddle!: THREE.Mesh;
private blocks: Block[] = [];
private ballVelocity = new THREE.Vector2(0.15, 0.15);
private mouseY = 0;
private aspect = 1;
score = 0;
lives = 3;
gameOver = false;
ngOnInit(): void {}
ngAfterViewInit(): void {
this.initThree();
this.createScene();
this.setupEventListeners();
this.animate();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
if (this.renderer) {
this.renderer.dispose();
}
}
private initThree(): void {
const canvas = this.canvasRef.nativeElement;
const width = window.innerWidth;
const height = window.innerHeight;
this.aspect = width / height;
this.scene = new THREE.Scene();
this.scene.background = null;
// Orthographic camera for 2D
const viewSize = 20;
this.camera = new THREE.OrthographicCamera(
-viewSize * this.aspect,
viewSize * this.aspect,
viewSize,
-viewSize,
0.1,
1000
);
this.camera.position.z = 10;
this.renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
});
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
window.addEventListener('resize', () => this.onWindowResize());
}
private createScene(): void {
// Ball (2D circle)
const ballGeometry = new THREE.CircleGeometry(0.4, 32);
const ballMaterial = new THREE.MeshBasicMaterial({
color: 0x8b5cf6,
});
this.ball = new THREE.Mesh(ballGeometry, ballMaterial);
this.ball.position.set(-15, 0, 0);
this.scene.add(this.ball);
// Paddle (on the left, vertical rectangle)
const paddleGeometry = new THREE.PlaneGeometry(0.6, 4);
const paddleMaterial = new THREE.MeshBasicMaterial({
color: 0xec4899,
});
this.paddle = new THREE.Mesh(paddleGeometry, paddleMaterial);
this.paddle.position.set(-17, 0, 0);
this.scene.add(this.paddle);
// Create blocks (on the right side, 2D)
this.createBlocks();
}
private createBlocks(): void {
const rows = 8;
const cols = 4;
const blockWidth = 2;
const blockHeight = 1.5;
const spacing = 0.3;
const startX = 12;
const startY = (rows * (blockHeight + spacing)) / 2 - blockHeight / 2;
const colors = [0xff0000, 0xff8800, 0xffff00, 0x00ff00, 0x0088ff, 0x0000ff, 0x8800ff, 0xff00ff];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const blockGeometry = new THREE.PlaneGeometry(blockWidth, blockHeight);
const blockMaterial = new THREE.MeshBasicMaterial({
color: colors[row % colors.length],
});
const blockMesh = new THREE.Mesh(blockGeometry, blockMaterial);
blockMesh.position.set(
startX + col * (blockWidth + spacing),
startY - row * (blockHeight + spacing),
0
);
this.scene.add(blockMesh);
this.blocks.push({ mesh: blockMesh, alive: true });
}
}
}
private setupEventListeners(): void {
const canvas = this.canvasRef.nativeElement;
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const normalizedY = ((e.clientY - rect.top) / rect.height) * 2 - 1;
const viewSize = 20;
this.mouseY = -normalizedY * viewSize; // Convert to world space
});
}
private updatePaddle(): void {
this.paddle.position.y = this.mouseY;
// Keep paddle in bounds
const viewSize = 20;
this.paddle.position.y = Math.max(-viewSize + 2, Math.min(viewSize - 2, this.paddle.position.y));
}
private updateBall(): void {
if (this.gameOver) return;
this.ball.position.x += this.ballVelocity.x;
this.ball.position.y += this.ballVelocity.y;
const viewSize = 20;
// Bounce off top/bottom walls
if (Math.abs(this.ball.position.y) > viewSize - 0.5) {
this.ballVelocity.y *= -1;
this.ball.position.y = Math.max(-viewSize + 0.5, Math.min(viewSize - 0.5, this.ball.position.y));
}
// Bounce off right wall
if (this.ball.position.x > viewSize * this.aspect - 0.5) {
this.ballVelocity.x *= -1;
this.ball.position.x = viewSize * this.aspect - 0.5;
}
// Paddle collision (left side)
const paddleBox = new THREE.Box2(
new THREE.Vector2(this.paddle.position.x - 0.3, this.paddle.position.y - 2),
new THREE.Vector2(this.paddle.position.x + 0.3, this.paddle.position.y + 2)
);
const ballPos = new THREE.Vector2(this.ball.position.x, this.ball.position.y);
const ballRadius = 0.4;
if (
ballPos.x - ballRadius < paddleBox.max.x &&
ballPos.x + ballRadius > paddleBox.min.x &&
ballPos.y - ballRadius < paddleBox.max.y &&
ballPos.y + ballRadius > paddleBox.min.y &&
this.ballVelocity.x < 0
) {
this.ballVelocity.x *= -1;
// Add some angle based on where ball hits paddle
const hitY = (this.ball.position.y - this.paddle.position.y) / 2;
this.ballVelocity.y += hitY * 0.05;
this.ball.position.x = -16.5;
}
// Block collisions
for (const block of this.blocks) {
if (!block.alive) continue;
const blockBox = new THREE.Box2(
new THREE.Vector2(block.mesh.position.x - 1, block.mesh.position.y - 0.75),
new THREE.Vector2(block.mesh.position.x + 1, block.mesh.position.y + 0.75)
);
if (
ballPos.x - ballRadius < blockBox.max.x &&
ballPos.x + ballRadius > blockBox.min.x &&
ballPos.y - ballRadius < blockBox.max.y &&
ballPos.y + ballRadius > blockBox.min.y
) {
block.alive = false;
block.mesh.visible = false;
this.score += 10;
// Determine bounce direction
const blockCenter = new THREE.Vector2(block.mesh.position.x, block.mesh.position.y);
const dx = ballPos.x - blockCenter.x;
const dy = ballPos.y - blockCenter.y;
if (Math.abs(dx) > Math.abs(dy)) {
this.ballVelocity.x *= -1;
} else {
this.ballVelocity.y *= -1;
}
break;
}
}
// Check if ball went past paddle (left side)
if (this.ball.position.x < -viewSize * this.aspect) {
this.lives--;
if (this.lives <= 0) {
this.gameOver = true;
} else {
// Reset ball
this.ball.position.set(-15, 0, 0);
this.ballVelocity.set(0.15, 0.15);
}
}
// Check if all blocks destroyed
if (this.blocks.every((b) => !b.alive)) {
// Reset blocks
this.blocks.forEach((block) => {
block.alive = true;
block.mesh.visible = true;
});
this.ballVelocity.multiplyScalar(1.1); // Speed up
}
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
this.updatePaddle();
this.updateBall();
this.renderer.render(this.scene, this.camera);
};
private onWindowResize(): void {
const width = window.innerWidth;
const height = window.innerHeight;
this.aspect = width / height;
const viewSize = 20;
this.camera.left = -viewSize * this.aspect;
this.camera.right = viewSize * this.aspect;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
restart(): void {
this.score = 0;
this.lives = 3;
this.gameOver = false;
this.ball.position.set(-15, 0, 0);
this.ballVelocity.set(0.15, 0.15);
this.blocks.forEach((block) => {
block.alive = true;
block.mesh.visible = true;
});
}
}
@@ -0,0 +1,63 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Match } from '../../../core/models/gaming-goal.model';
import { formatDate } from '../../utils/date.utils';
@Component({
selector: 'app-match-history',
standalone: true,
imports: [CommonModule],
template: `
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Recent Matches
</h3>
<div class="space-y-2">
<div
*ngFor="let match of matches"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center space-x-3">
<span
class="w-3 h-3 rounded-full"
[class.bg-green-500]="match.result === 'win'"
[class.bg-red-500]="match.result === 'loss'"
[class.bg-gray-400]="match.result === 'draw'"
></span>
<div>
<p class="text-sm font-medium text-gray-900">
{{ formatDate(match.date) }}
</p>
<p class="text-xs text-gray-500" *ngIf="match.rank">
{{ match.rank }}
</p>
</div>
</div>
<div class="text-right">
<span
class="text-sm font-semibold"
[class.text-green-600]="match.result === 'win'"
[class.text-red-600]="match.result === 'loss'"
[class.text-gray-600]="match.result === 'draw'"
>
{{ match.result.toUpperCase() }}
</span>
<p class="text-xs text-gray-500" *ngIf="match.score">
Score: {{ match.score }}
</p>
</div>
</div>
<p *ngIf="matches.length === 0" class="text-sm text-gray-500">
No recent matches available
</p>
</div>
</div>
`,
styles: [],
})
export class MatchHistoryComponent {
@Input() matches: Match[] = [];
readonly formatDate = formatDate;
}
@@ -0,0 +1,174 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive],
template: `
<nav class="bg-white/80 backdrop-blur-md shadow-lg border-b-2 border-blue-200 sticky top-0 z-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo/Brand -->
<div class="flex items-center">
<a
routerLink="/"
class="flex items-center gap-3 text-2xl font-bold text-gray-900 hover:text-blue-600 transition-colors duration-200"
>
<span class="text-3xl">🎯</span>
<span>2026 Goals Tracker</span>
</a>
</div>
<!-- Navigation Links -->
<div class="hidden md:flex items-center gap-1">
<a
routerLink="/"
routerLinkActive="bg-blue-500 text-white"
[routerLinkActiveOptions]="{ exact: true }"
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-all duration-200 flex items-center gap-2"
>
<span>📊</span>
<span>Dashboard</span>
</a>
<a
routerLink="/sport"
routerLinkActive="bg-blue-500 text-white"
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-all duration-200 flex items-center gap-2"
>
<span>🏃</span>
<span>Sport</span>
</a>
<a
routerLink="/gaming"
routerLinkActive="bg-purple-500 text-white"
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-purple-50 hover:text-purple-600 transition-all duration-200 flex items-center gap-2"
>
<span>🎮</span>
<span>Gaming</span>
</a>
</div>
<!-- Mobile Menu Button -->
<button
(click)="toggleMobileMenu()"
class="md:hidden p-2 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors duration-200"
[attr.aria-label]="mobileMenuOpen ? 'Close menu' : 'Open menu'"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
[class.hidden]="mobileMenuOpen"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
></path>
</svg>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
[class.hidden]="!mobileMenuOpen"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
<!-- Mobile Menu -->
<div
class="md:hidden pb-4"
[class.hidden]="!mobileMenuOpen"
>
<div class="flex flex-col gap-2 mt-2">
<a
routerLink="/"
routerLinkActive="bg-blue-500 text-white"
[routerLinkActiveOptions]="{ exact: true }"
(click)="closeMobileMenu()"
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-all duration-200 flex items-center gap-2"
>
<span>📊</span>
<span>Dashboard</span>
</a>
<a
routerLink="/sport"
routerLinkActive="bg-blue-500 text-white"
(click)="closeMobileMenu()"
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-all duration-200 flex items-center gap-2"
>
<span>🏃</span>
<span>Sport</span>
</a>
<a
routerLink="/gaming"
routerLinkActive="bg-purple-500 text-white"
(click)="closeMobileMenu()"
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-purple-50 hover:text-purple-600 transition-all duration-200 flex items-center gap-2"
>
<span>🎮</span>
<span>Gaming</span>
</a>
</div>
</div>
</div>
</nav>
`,
styles: [
`
:host {
display: block;
}
a[routerLinkActive] {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
`,
],
})
export class NavbarComponent implements OnInit {
mobileMenuOpen = false;
constructor(private readonly router: Router) {}
ngOnInit(): void {
// Close mobile menu on route change
this.router.events.subscribe(() => {
this.closeMobileMenu();
});
}
toggleMobileMenu(): void {
this.mobileMenuOpen = !this.mobileMenuOpen;
}
closeMobileMenu(): void {
this.mobileMenuOpen = false;
}
}
@@ -0,0 +1,468 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import * as THREE from 'three';
@Component({
selector: 'app-particle-background',
standalone: true,
imports: [CommonModule],
template: `
<canvas #canvas class="fixed inset-0 w-full h-full pointer-events-none"></canvas>
`,
styles: [
`
canvas {
display: block;
z-index: 0;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
`,
],
})
export class ParticleBackgroundComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private scene!: THREE.Scene;
private camera!: THREE.PerspectiveCamera;
private renderer!: THREE.WebGLRenderer;
private particles!: THREE.Points;
private animationId: number | null = null;
private particleCount = 500;
private mouseX = 0;
private mouseY = 0;
private mouseWorldX = 0;
private mouseWorldY = 0;
private particleVelocities: Float32Array | null = null;
private mouseDownTime = 0;
private isMouseDown = false;
private clickWorldX = 0;
private clickWorldY = 0;
private repulsionWaves: Array<{
x: number;
y: number;
radius: number;
maxRadius: number;
opacity: number;
time: number;
}> = [];
private waveObjects: THREE.Mesh[] = [];
ngOnInit(): void {}
ngAfterViewInit(): void {
this.initThree();
this.createParticles();
this.animate();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
// Clean up wave objects
this.waveObjects.forEach((wave) => {
this.scene.remove(wave);
wave.geometry.dispose();
(wave.material as THREE.Material).dispose();
});
this.waveObjects = [];
if (this.renderer) {
this.renderer.dispose();
}
}
private initThree(): void {
const canvas = this.canvasRef.nativeElement;
const width = window.innerWidth;
const height = window.innerHeight;
// Scene
this.scene = new THREE.Scene();
// Camera
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
this.camera.position.z = 5;
// Renderer
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
alpha: true,
antialias: true,
});
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
window.addEventListener('resize', () => this.onWindowResize());
this.addMouseInteraction();
}
private screenToWorld(x: number, y: number): { x: number; y: number } {
// Convert screen coordinates to normalized device coordinates
const mouseX = (x / window.innerWidth) * 2 - 1;
const mouseY = -(y / window.innerHeight) * 2 + 1;
// Create a vector in normalized device coordinates
const vector = new THREE.Vector3(mouseX, mouseY, 0.5);
// Unproject to world coordinates
vector.unproject(this.camera);
// Calculate direction from camera
const dir = vector.sub(this.camera.position).normalize();
// Find intersection with z=0 plane (where particles are)
const distance = -this.camera.position.z / dir.z;
const pos = this.camera.position.clone().add(dir.multiplyScalar(distance));
return { x: pos.x, y: pos.y };
}
private addMouseInteraction(): void {
let lastMouseEvent: MouseEvent | null = null;
window.addEventListener('mousemove', (event) => {
lastMouseEvent = event;
this.mouseX = (event.clientX / window.innerWidth) * 2 - 1;
this.mouseY = -(event.clientY / window.innerHeight) * 2 + 1;
const worldPos = this.screenToWorld(event.clientX, event.clientY);
this.mouseWorldX = worldPos.x;
this.mouseWorldY = worldPos.y;
});
window.addEventListener('mousedown', (event) => {
this.isMouseDown = true;
this.mouseDownTime = Date.now();
});
window.addEventListener('mouseup', (event) => {
if (this.isMouseDown) {
// Calculate world position from event coordinates directly
const worldPos = this.screenToWorld(event.clientX, event.clientY);
this.clickWorldX = worldPos.x;
this.clickWorldY = worldPos.y;
const holdDuration = Date.now() - this.mouseDownTime;
this.applyRepulsion(holdDuration);
this.isMouseDown = false;
}
});
// Handle mouse leaving window
window.addEventListener('mouseleave', () => {
if (this.isMouseDown && lastMouseEvent) {
// Use last known mouse position with proper conversion
const worldPos = this.screenToWorld(lastMouseEvent.clientX, lastMouseEvent.clientY);
this.clickWorldX = worldPos.x;
this.clickWorldY = worldPos.y;
const holdDuration = Date.now() - this.mouseDownTime;
this.applyRepulsion(holdDuration);
this.isMouseDown = false;
}
});
}
private applyRepulsion(holdDuration: number): void {
if (!this.particleVelocities) return;
const positions = this.particles.geometry.attributes['position'].array as Float32Array;
const velocities = this.particleVelocities;
// Calculate power based on hold duration with limits
const minHoldTime = 50; // Minimum 50ms to register
const maxHoldTime = 1000; // Maximum 1 second for full power
const clampedHold = Math.max(minHoldTime, Math.min(holdDuration, maxHoldTime));
const normalizedHold = (clampedHold - minHoldTime) / (maxHoldTime - minHoldTime);
// Strength limits
const baseStrength = 0.15;
const maxStrength = 0.8;
const repulsionStrength = Math.min(
baseStrength + (maxStrength - baseStrength) * normalizedHold,
maxStrength
);
// Radius limits
const minRadius = 2.5;
const maxRadius = 5;
const repulsionRadius = Math.min(
minRadius + (maxRadius - minRadius) * normalizedHold,
maxRadius
);
// Add visual repulsion wave - scale based on hold duration
const waveOpacity = 0.3 + normalizedHold * 0.3; // 0.3 to 0.6 based on hold
const waveMaxRadius = repulsionRadius * (1.2 + normalizedHold * 0.5); // 1.2x to 1.7x based on hold
this.repulsionWaves.push({
x: this.clickWorldX,
y: this.clickWorldY,
radius: 0,
maxRadius: waveMaxRadius,
opacity: waveOpacity,
time: Date.now(),
});
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i];
const y = positions[i + 1];
const z = positions[i + 2];
// Calculate distance from click point
const dx = x - this.clickWorldX;
const dy = y - this.clickWorldY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Apply repulsion if within radius
if (distance < repulsionRadius && distance > 0.1) {
const normalizedDx = dx / distance;
const normalizedDy = dy / distance;
const force = (1 - distance / repulsionRadius) * repulsionStrength;
// Add force to velocity (physics-based)
velocities[i] += normalizedDx * force;
velocities[i + 1] += normalizedDy * force;
velocities[i + 2] += (Math.random() - 0.5) * force * 0.4; // Z-axis movement
}
}
}
private createParticles(): void {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(this.particleCount * 3);
const colors = new Float32Array(this.particleCount * 3);
const color1 = new THREE.Color(0x3b82f6); // Blue
const color2 = new THREE.Color(0x8b5cf6); // Purple
const color3 = new THREE.Color(0xec4899); // Pink
for (let i = 0; i < this.particleCount; i++) {
const i3 = i * 3;
// Position
positions[i3] = (Math.random() - 0.5) * 20;
positions[i3 + 1] = (Math.random() - 0.5) * 20;
positions[i3 + 2] = (Math.random() - 0.5) * 20;
// Color
const colorChoice = Math.random();
let color: THREE.Color;
if (colorChoice < 0.33) {
color = color1;
} else if (colorChoice < 0.66) {
color = color2;
} else {
color = color3;
}
colors[i3] = color.r;
colors[i3 + 1] = color.g;
colors[i3 + 2] = color.b;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
// Initialize velocities for physics
this.particleVelocities = new Float32Array(this.particleCount * 3);
for (let i = 0; i < this.particleVelocities.length; i++) {
this.particleVelocities[i] = 0;
}
const material = new THREE.PointsMaterial({
size: 0.08,
vertexColors: true,
transparent: true,
opacity: 0.3,
blending: THREE.AdditiveBlending,
});
this.particles = new THREE.Points(geometry, material);
this.scene.add(this.particles);
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
if (this.particles) {
// Much slower rotation
this.particles.rotation.x += 0.0001;
this.particles.rotation.y += 0.0002;
// Mouse interaction - subtle rotation
this.particles.rotation.x += this.mouseY * 0.0001;
this.particles.rotation.y += this.mouseX * 0.00015;
// Physics-based movement with ease-out
const positions = this.particles.geometry.attributes['position'].array as Float32Array;
const velocities = this.particleVelocities!;
const damping = 0.92; // Ease-out damping (lower = faster decay)
const maxVelocity = 0.5; // Increased for stronger repulsion effects
// Continuous mouse repulsion (weaker, always active)
const mouseRepulsionRadius = 2;
const mouseRepulsionStrength = 0.0003; // Much weaker than click repulsion
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i];
const y = positions[i + 1];
const z = positions[i + 2];
// Calculate distance from mouse
const dx = x - this.mouseWorldX;
const dy = y - this.mouseWorldY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Apply continuous mouse repulsion if within radius
if (distance < mouseRepulsionRadius && distance > 0.1) {
const normalizedDx = dx / distance;
const normalizedDy = dy / distance;
const force = (1 - distance / mouseRepulsionRadius) * mouseRepulsionStrength;
// Add weak force to velocity
velocities[i] += normalizedDx * force;
velocities[i + 1] += normalizedDy * force;
velocities[i + 2] += (Math.random() - 0.5) * force * 0.2;
}
// Apply damping (ease-out effect)
velocities[i] *= damping;
velocities[i + 1] *= damping;
velocities[i + 2] *= damping;
// Limit max velocity
const vx = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i]));
const vy = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i + 1]));
const vz = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i + 2]));
velocities[i] = vx;
velocities[i + 1] = vy;
velocities[i + 2] = vz;
// Update position based on velocity
positions[i] += velocities[i];
positions[i + 1] += velocities[i + 1];
positions[i + 2] += velocities[i + 2];
// Slower vertical drift
positions[i + 1] += 0.001;
// Wrap around boundaries
if (positions[i] > 10) positions[i] = -10;
if (positions[i] < -10) positions[i] = 10;
if (positions[i + 1] > 10) positions[i + 1] = -10;
if (positions[i + 1] < -10) positions[i + 1] = 10;
if (positions[i + 2] > 10) positions[i + 2] = -10;
if (positions[i + 2] < -10) positions[i + 2] = 10;
}
this.particles.geometry.attributes['position'].needsUpdate = true;
}
// Update and render repulsion waves
this.updateRepulsionWaves();
// Camera follows mouse slightly
this.camera.position.x += (this.mouseX * 0.3 - this.camera.position.x) * 0.03;
this.camera.position.y += (this.mouseY * 0.3 - this.camera.position.y) * 0.03;
this.camera.lookAt(0, 0, 0);
this.renderer.render(this.scene, this.camera);
};
private updateRepulsionWaves(): void {
const now = Date.now();
const waveDuration = 1500; // Longer, softer animation duration
// Update existing waves
for (let i = this.repulsionWaves.length - 1; i >= 0; i--) {
const wave = this.repulsionWaves[i];
const elapsed = now - wave.time;
const progress = Math.min(elapsed / waveDuration, 1);
if (progress >= 1) {
// Remove expired waves
if (this.waveObjects[i]) {
this.scene.remove(this.waveObjects[i]);
this.waveObjects[i].geometry.dispose();
(this.waveObjects[i].material as THREE.Material).dispose();
}
this.waveObjects.splice(i, 1);
this.repulsionWaves.splice(i, 1);
continue;
}
// Smooth easing function for softer expansion
const easeOut = 1 - Math.pow(1 - progress, 3);
// Expand radius with easing
wave.radius = wave.maxRadius * easeOut;
// Softer fade out - start from initial opacity
const initialOpacity = wave.opacity;
wave.opacity = initialOpacity * (1 - progress * progress); // Quadratic fade for softer effect
// Create or update wave object
if (!this.waveObjects[i]) {
this.createWaveObject(wave, i);
} else {
this.updateWaveObject(wave, i);
}
}
}
private createWaveObject(wave: { x: number; y: number; radius: number; maxRadius: number; opacity: number }, index: number): void {
const geometry = new THREE.RingGeometry(0, 0.05, 32);
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: wave.opacity * 0.5, // Softer initial opacity
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
});
const waveMesh = new THREE.Mesh(geometry, material);
waveMesh.position.set(wave.x, wave.y, 0);
this.scene.add(waveMesh);
this.waveObjects[index] = waveMesh;
}
private updateWaveObject(wave: { x: number; y: number; radius: number; maxRadius: number; opacity: number }, index: number): void {
const waveMesh = this.waveObjects[index];
if (!waveMesh) return;
const material = waveMesh.material as THREE.MeshBasicMaterial;
const progress = wave.radius / wave.maxRadius;
// Update geometry for expanding ring - thinner ring for softer look
waveMesh.geometry.dispose();
const innerRadius = Math.max(0, wave.radius * 0.85); // Thinner ring (85% instead of 70%)
waveMesh.geometry = new THREE.RingGeometry(innerRadius, wave.radius, 32);
// Softer opacity
material.opacity = wave.opacity * 0.6; // Additional softening multiplier
// Softer color variation (subtle blue to purple gradient)
const hue = (progress * 40 + 240) % 360; // Slower color transition
material.color.setHSL(hue / 360, 0.5, 0.8); // Lower saturation, higher lightness for softer look
}
private onWindowResize(): void {
const width = window.innerWidth;
const height = window.innerHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
}
@@ -0,0 +1,232 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Activity } from '../../../core/models/sport-goal.model';
@Component({
selector: 'app-progress-card',
standalone: true,
imports: [CommonModule],
template: `
<div class="bg-white/80 backdrop-blur-md rounded-lg shadow-md p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 border border-gray-200/50">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<span class="text-2xl">{{ emoji }}</span>
{{ title }}
</h3>
<span
class="text-2xl font-bold"
[class.text-green-600]="percentage >= 100"
[class.text-blue-600]="percentage >= 50 && percentage < 100"
[class.text-gray-600]="percentage < 50"
>
{{ percentage.toFixed(1) }}%
</span>
</div>
<div class="mb-4">
<div class="flex justify-between text-sm text-gray-600 mb-2">
<span>{{ currentLabel }}</span>
<span>{{ targetLabel }}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div
class="h-4 rounded-full transition-all duration-500 ease-out relative overflow-hidden"
[ngClass]="{
'bg-green-500': percentage >= 100,
'bg-blue-500': percentage >= 50 && percentage < 100,
'bg-gray-400': percentage < 50
}"
[style.width.%]="Math.min(100, percentage)"
>
<div class="absolute inset-0 w-full h-full animate-shimmer"></div>
</div>
</div>
</div>
<div class="text-sm text-gray-500 mb-4">
<p>{{ description }}</p>
</div>
<div *ngIf="estimatedCompletion || dailyKmNeeded !== null || currentDailyAvg !== null || optimalDailyAvg !== null || daysAheadOrBehind !== null" class="bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg p-3 space-y-2 border border-gray-200">
<div *ngIf="daysAheadOrBehind !== null" class="flex items-center justify-between">
<span class="text-gray-600 flex items-center gap-1">
<span></span>
<span>Schedule status:</span>
</span>
<span
class="font-semibold px-3 py-1 rounded-full text-sm"
[ngClass]="{
'bg-green-400 text-white': daysAheadOrBehind < 0,
'bg-yellow-400 text-white': daysAheadOrBehind > 0,
'bg-green-500 text-white': daysAheadOrBehind === 0
}"
>
<span *ngIf="daysAheadOrBehind > 0">
{{ daysAheadOrBehind.toFixed(0) }} day{{ daysAheadOrBehind !== 1 ? 's' : '' }} behind
</span>
<span *ngIf="daysAheadOrBehind < 0">
{{ Math.abs(daysAheadOrBehind).toFixed(0) }} day{{ Math.abs(daysAheadOrBehind) !== 1 ? 's' : '' }} ahead
</span>
<span *ngIf="daysAheadOrBehind === 0">
On schedule
</span>
</span>
</div>
<div *ngIf="currentDailyAvg !== null" class="flex items-center justify-between">
<span class="text-gray-600 flex items-center gap-1">
<span>📊</span>
<span>Current daily avg:</span>
</span>
<span class="font-semibold text-gray-900">
{{ currentDailyAvg.toFixed(2) }} km/day
</span>
</div>
<div *ngIf="optimalDailyAvg !== null" class="flex items-center justify-between">
<span class="text-gray-600 flex items-center gap-1">
<span></span>
<span>Optimal daily avg:</span>
</span>
<span class="font-semibold text-gray-900">
{{ optimalDailyAvg.toFixed(2) }} km/day
</span>
</div>
<div *ngIf="estimatedCompletion" class="flex items-center justify-between">
<span class="text-gray-600 flex items-center gap-1">
<span>📅</span>
<span>Est. completion:</span>
</span>
<span class="font-semibold text-gray-900">
{{ estimatedCompletion }}
</span>
</div>
<div *ngIf="dailyKmNeeded !== null" class="flex items-center justify-between">
<span class="text-gray-600 flex items-center gap-1">
<span>🎯</span>
<span>Daily needed:</span>
</span>
<span class="font-semibold text-gray-900">
{{ dailyKmNeeded.toFixed(2) }} km/day
</span>
</div>
</div>
<div *ngIf="recentActivities && recentActivities.length > 0" class="mt-4 bg-gradient-to-br from-blue-50 to-purple-50 rounded-lg p-4 border border-blue-200">
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<span>🏃</span>
<span>Last 3 Trainings</span>
</h4>
<div class="space-y-2">
<div
*ngFor="let activity of recentActivities; let i = index"
class="bg-white/60 backdrop-blur-sm rounded-lg p-3 border border-gray-200/50 hover:shadow-md transition-shadow"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 text-sm truncate mb-1">
{{ activity.name }}
</p>
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-600">
<span class="flex items-center gap-1">
<span>📅</span>
<span>{{ formatActivityDate(activity.startDate) }}</span>
</span>
<span class="flex items-center gap-1">
<span>📏</span>
<span class="font-semibold text-gray-900">{{ formatDistance(activity.distance) }} km</span>
</span>
<span *ngIf="activity.movingTime > 0" class="flex items-center gap-1">
<span></span>
<span>{{ formatTime(activity.movingTime) }}</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`,
styles: [
`
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.2) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 5s infinite;
}
`,
],
})
export class ProgressCardComponent {
@Input() title = '';
@Input() percentage = 0;
@Input() currentLabel = '';
@Input() targetLabel = '';
@Input() description = '';
@Input() estimatedCompletion: string | null = null;
@Input() dailyKmNeeded: number | null = null;
@Input() currentDailyAvg: number | null = null;
@Input() optimalDailyAvg: number | null = null;
@Input() daysAheadOrBehind: number | null = null;
@Input() recentActivities: Activity[] | null = null;
@Input() emoji = '🏃';
readonly Math = Math;
formatDistance(meters: number): string {
return (meters / 1000).toFixed(2);
}
formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
formatActivityDate(date: Date | string): string {
const activityDate = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
// Normalize both dates to midnight (start of day) for accurate day comparison
const activityDay = new Date(
activityDate.getFullYear(),
activityDate.getMonth(),
activityDate.getDate()
);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diffTime = today.getTime() - activityDay.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Today';
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return activityDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: activityDate.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
}
}
}
@@ -0,0 +1,82 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-progress-ring',
standalone: true,
imports: [CommonModule],
template: `
<div class="relative inline-flex items-center justify-center">
<svg class="transform -rotate-90" [attr.width]="size" [attr.height]="size">
<!-- Background circle -->
<circle
[attr.cx]="center"
[attr.cy]="center"
[attr.r]="radius"
fill="none"
[attr.stroke]="backgroundColor"
[attr.stroke-width]="strokeWidth"
class="opacity-20"
/>
<!-- Progress circle -->
<circle
[attr.cx]="center"
[attr.cy]="center"
[attr.r]="radius"
fill="none"
[attr.stroke]="progressColor"
[attr.stroke-width]="strokeWidth"
[attr.stroke-linecap]="'round'"
[attr.stroke-dasharray]="circumference"
[attr.stroke-dashoffset]="offset"
class="transition-all duration-500 ease-out"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<div class="text-2xl font-bold" [style.color]="progressColor">
{{ percentage.toFixed(0) }}%
</div>
<div class="text-xs text-gray-500" *ngIf="label">{{ label }}</div>
</div>
</div>
</div>
`,
styles: [],
})
export class ProgressRingComponent implements OnChanges {
@Input() percentage: number = 0;
@Input() size: number = 120;
@Input() strokeWidth: number = 8;
@Input() progressColor: string = '#3b82f6';
@Input() backgroundColor: string = '#e5e7eb';
@Input() label: string = '';
radius: number = 0;
center: number = 0;
circumference: number = 0;
offset: number = 0;
ngOnChanges(changes: SimpleChanges): void {
this.radius = (this.size - this.strokeWidth) / 2;
this.center = this.size / 2;
this.circumference = 2 * Math.PI * this.radius;
this.offset = this.circumference - (this.percentage / 100) * this.circumference;
}
}
@@ -0,0 +1,367 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
interface Bullet {
x: number;
y: number;
speed: number;
}
interface Enemy {
x: number;
y: number;
width: number;
height: number;
alive: boolean;
}
@Component({
selector: 'app-space-invaders',
standalone: true,
imports: [CommonModule],
template: `
<div class="space-invaders-container">
<canvas #canvas class="w-full h-full rounded-lg"></canvas>
<div class="absolute top-4 left-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm font-bold">
Score: {{ score }} | Lives: {{ lives }}
</div>
<div class="absolute top-4 right-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
to move Space to shoot
</div>
@if (gameOver) {
<div class="absolute inset-0 flex items-center justify-center bg-black/70 rounded-lg">
<div class="text-center text-white">
<h2 class="text-4xl font-bold mb-4">Game Over!</h2>
<p class="text-2xl mb-4">Final Score: {{ score }}</p>
<button
(click)="restart()"
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-all"
>
Play Again
</button>
</div>
</div>
}
</div>
`,
styles: [
`
.space-invaders-container {
position: relative;
width: 100%;
height: 100%;
background: #000000;
border-radius: 1rem;
overflow: hidden;
}
canvas {
display: block;
}
`,
],
})
export class SpaceInvadersComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private animationId: number | null = null;
private player = { x: 0, y: 0, width: 50, height: 30, speed: 5 };
private bullets: Bullet[] = [];
private enemies: Enemy[] = [];
private enemyBullets: Bullet[] = [];
private keys: { [key: string]: boolean } = {};
private lastShot = 0;
private enemyDirection = 1;
private enemySpeed = 1;
private lastEnemyShot = 0;
score = 0;
lives = 3;
gameOver = false;
ngOnInit(): void {}
ngAfterViewInit(): void {
const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d')!;
this.resizeCanvas();
this.setupEventListeners();
this.initGame();
this.animate();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
this.removeEventListeners();
}
private resizeCanvas(): void {
const canvas = this.canvasRef.nativeElement;
const container = canvas.parentElement!;
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
this.player.x = canvas.width / 2 - this.player.width / 2;
this.player.y = canvas.height - 50;
}
private initGame(): void {
this.enemies = [];
this.bullets = [];
this.enemyBullets = [];
this.score = 0;
this.lives = 3;
this.gameOver = false;
this.enemyDirection = 1;
this.enemySpeed = 1;
// Create enemies in grid
const rows = 5;
const cols = 10;
const spacing = 60;
const startX = 50;
const startY = 50;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
this.enemies.push({
x: startX + col * spacing,
y: startY + row * spacing,
width: 40,
height: 30,
alive: true,
});
}
}
}
private setupEventListeners(): void {
window.addEventListener('keydown', (e) => {
this.keys[e.key] = true;
if (e.key === ' ' && !this.gameOver) {
e.preventDefault();
this.shoot();
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.key] = false;
});
window.addEventListener('resize', () => this.resizeCanvas());
}
private removeEventListeners(): void {
window.removeEventListener('keydown', () => {});
window.removeEventListener('keyup', () => {});
window.removeEventListener('resize', () => {});
}
private updatePlayer(): void {
if (this.keys['ArrowLeft'] && this.player.x > 0) {
this.player.x -= this.player.speed;
}
if (this.keys['ArrowRight'] && this.player.x < this.canvasRef.nativeElement.width - this.player.width) {
this.player.x += this.player.speed;
}
}
private shoot(): void {
const now = Date.now();
if (now - this.lastShot > 200) {
this.bullets.push({
x: this.player.x + this.player.width / 2,
y: this.player.y,
speed: -8,
});
this.lastShot = now;
}
}
private updateBullets(): void {
// Player bullets
this.bullets = this.bullets.filter((bullet) => {
bullet.y += bullet.speed;
return bullet.y > 0;
});
// Enemy bullets
this.enemyBullets = this.enemyBullets.filter((bullet) => {
bullet.y += bullet.speed;
return bullet.y < this.canvasRef.nativeElement.height;
});
}
private updateEnemies(): void {
if (this.enemies.length === 0) {
this.initGame();
this.enemySpeed += 0.5;
return;
}
// Move enemies
let shouldMoveDown = false;
for (const enemy of this.enemies) {
if (!enemy.alive) continue;
enemy.x += this.enemyDirection * this.enemySpeed;
if (enemy.x <= 0 || enemy.x >= this.canvasRef.nativeElement.width - enemy.width) {
shouldMoveDown = true;
}
}
if (shouldMoveDown) {
this.enemyDirection *= -1;
for (const enemy of this.enemies) {
if (enemy.alive) {
enemy.y += 20;
if (enemy.y > this.player.y) {
this.gameOver = true;
}
}
}
}
// Enemy shooting
const now = Date.now();
if (now - this.lastEnemyShot > 1000 && this.enemies.some((e) => e.alive)) {
const aliveEnemies = this.enemies.filter((e) => e.alive);
if (aliveEnemies.length > 0) {
const randomEnemy = aliveEnemies[Math.floor(Math.random() * aliveEnemies.length)];
this.enemyBullets.push({
x: randomEnemy.x + randomEnemy.width / 2,
y: randomEnemy.y + randomEnemy.height,
speed: 3,
});
this.lastEnemyShot = now;
}
}
}
private checkCollisions(): void {
// Player bullets vs enemies
for (const bullet of this.bullets) {
for (const enemy of this.enemies) {
if (
enemy.alive &&
bullet.x > enemy.x &&
bullet.x < enemy.x + enemy.width &&
bullet.y > enemy.y &&
bullet.y < enemy.y + enemy.height
) {
enemy.alive = false;
this.bullets = this.bullets.filter((b) => b !== bullet);
this.score += 10;
break;
}
}
}
// Enemy bullets vs player
for (const bullet of this.enemyBullets) {
if (
bullet.x > this.player.x &&
bullet.x < this.player.x + this.player.width &&
bullet.y > this.player.y &&
bullet.y < this.player.y + this.player.height
) {
this.enemyBullets = this.enemyBullets.filter((b) => b !== bullet);
this.lives--;
if (this.lives <= 0) {
this.gameOver = true;
}
}
}
}
private draw(): void {
const canvas = this.canvasRef.nativeElement;
const ctx = this.ctx;
// Clear canvas
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw stars
ctx.fillStyle = '#ffffff';
for (let i = 0; i < 50; i++) {
const x = (i * 37) % canvas.width;
const y = (i * 53) % canvas.height;
ctx.fillRect(x, y, 2, 2);
}
// Draw player
ctx.fillStyle = '#00ff00';
ctx.beginPath();
ctx.moveTo(this.player.x + this.player.width / 2, this.player.y);
ctx.lineTo(this.player.x, this.player.y + this.player.height);
ctx.lineTo(this.player.x + this.player.width, this.player.y + this.player.height);
ctx.closePath();
ctx.fill();
// Draw bullets
ctx.fillStyle = '#ffff00';
for (const bullet of this.bullets) {
ctx.fillRect(bullet.x - 2, bullet.y, 4, 10);
}
// Draw enemy bullets
ctx.fillStyle = '#ff0000';
for (const bullet of this.enemyBullets) {
ctx.fillRect(bullet.x - 2, bullet.y, 4, 10);
}
// Draw enemies
ctx.fillStyle = '#ff00ff';
for (const enemy of this.enemies) {
if (enemy.alive) {
ctx.fillRect(enemy.x, enemy.y, enemy.width, enemy.height);
// Draw eyes
ctx.fillStyle = '#ffffff';
ctx.fillRect(enemy.x + 8, enemy.y + 8, 6, 6);
ctx.fillRect(enemy.x + enemy.width - 14, enemy.y + 8, 6, 6);
ctx.fillStyle = '#ff00ff';
}
}
}
private update(): void {
if (this.gameOver) return;
this.updatePlayer();
this.updateBullets();
this.updateEnemies();
this.checkCollisions();
}
private animate = (): void => {
this.update();
this.draw();
this.animationId = requestAnimationFrame(this.animate);
};
restart(): void {
this.initGame();
}
}
@@ -0,0 +1,258 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
Input,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import * as THREE from 'three';
@Component({
selector: 'app-three-geometric',
standalone: true,
imports: [CommonModule],
template: `
<canvas #canvas class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
`,
styles: [
`
canvas {
display: block;
width: 100%;
height: 100%;
}
`,
],
})
export class ThreeGeometricComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
@Input() type: 'floating' | 'rings' | 'particles' = 'floating';
@Input() color: string = '#3b82f6';
private scene!: THREE.Scene;
private camera!: THREE.PerspectiveCamera;
private renderer!: THREE.WebGLRenderer;
private animationId: number | null = null;
private objects: THREE.Object3D[] = [];
ngOnInit(): void {}
ngAfterViewInit(): void {
this.initThree();
this.createGeometry();
this.animate();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
this.objects.forEach((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
if (Array.isArray(obj.material)) {
obj.material.forEach((m) => m.dispose());
} else {
obj.material.dispose();
}
}
});
if (this.renderer) {
this.renderer.dispose();
}
}
private initThree(): void {
const canvas = this.canvasRef.nativeElement;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
this.scene = new THREE.Scene();
this.scene.background = null;
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
this.camera.position.z = 5;
this.renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
});
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
window.addEventListener('resize', () => this.onWindowResize());
}
private createGeometry(): void {
const color = new THREE.Color(this.color);
if (this.type === 'floating') {
this.createFloatingShapes(color);
} else if (this.type === 'rings') {
this.createRings(color);
} else if (this.type === 'particles') {
this.createParticles(color);
}
}
private createFloatingShapes(color: THREE.Color): void {
// Create floating geometric shapes
const shapes = [
new THREE.BoxGeometry(0.5, 0.5, 0.5),
new THREE.SphereGeometry(0.3, 16, 16),
new THREE.OctahedronGeometry(0.4, 0),
new THREE.TetrahedronGeometry(0.4, 0),
];
for (let i = 0; i < 8; i++) {
const geometry = shapes[i % shapes.length];
const material = new THREE.MeshStandardMaterial({
color: color.clone().multiplyScalar(0.8 + Math.random() * 0.4),
transparent: true,
opacity: 0.3,
metalness: 0.7,
roughness: 0.3,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(
(Math.random() - 0.5) * 8,
(Math.random() - 0.5) * 8,
(Math.random() - 0.5) * 5
);
mesh.rotation.set(
Math.random() * Math.PI,
Math.random() * Math.PI,
Math.random() * Math.PI
);
(mesh.userData as any).speed = {
x: (Math.random() - 0.5) * 0.01,
y: (Math.random() - 0.5) * 0.01,
z: (Math.random() - 0.5) * 0.01,
rotX: (Math.random() - 0.5) * 0.02,
rotY: (Math.random() - 0.5) * 0.02,
rotZ: (Math.random() - 0.5) * 0.02,
};
this.scene.add(mesh);
this.objects.push(mesh);
}
// Add ambient light
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
this.scene.add(ambientLight);
const pointLight = new THREE.PointLight(color, 1, 100);
pointLight.position.set(5, 5, 5);
this.scene.add(pointLight);
}
private createRings(color: THREE.Color): void {
// Create rotating rings
for (let i = 0; i < 3; i++) {
const geometry = new THREE.TorusGeometry(1 + i * 0.5, 0.05, 8, 50);
const material = new THREE.MeshStandardMaterial({
color: color.clone().multiplyScalar(0.6 + i * 0.2),
transparent: true,
opacity: 0.4,
metalness: 0.8,
roughness: 0.2,
});
const ring = new THREE.Mesh(geometry, material);
ring.rotation.x = Math.PI / 2;
ring.position.z = -i * 0.5;
(ring.userData as any).speed = 0.005 + i * 0.002;
this.scene.add(ring);
this.objects.push(ring);
}
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(color, 0.8);
directionalLight.position.set(5, 5, 5);
this.scene.add(directionalLight);
}
private createParticles(color: THREE.Color): void {
const geometry = new THREE.BufferGeometry();
const count = 200;
const positions = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i++) {
positions[i] = (Math.random() - 0.5) * 10;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: color,
size: 0.1,
transparent: true,
opacity: 0.6,
});
const points = new THREE.Points(geometry, material);
this.scene.add(points);
this.objects.push(points);
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
this.objects.forEach((obj) => {
if (obj instanceof THREE.Mesh) {
const userData = obj.userData as any;
if (userData.speed) {
if (userData.speed.x !== undefined) {
obj.position.x += userData.speed.x;
obj.position.y += userData.speed.y;
obj.position.z += userData.speed.z;
obj.rotation.x += userData.speed.rotX;
obj.rotation.y += userData.speed.rotY;
obj.rotation.z += userData.speed.rotZ;
} else if (userData.speed !== undefined) {
obj.rotation.y += userData.speed;
}
}
} else if (obj instanceof THREE.Points) {
obj.rotation.y += 0.001;
}
});
this.renderer.render(this.scene, this.camera);
};
private onWindowResize(): void {
const canvas = this.canvasRef.nativeElement;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
}
@@ -0,0 +1,241 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
Input,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import * as THREE from 'three';
@Component({
selector: 'app-three-trophy',
standalone: true,
imports: [CommonModule],
template: `
<div class="relative w-full h-full">
<canvas #canvas class="w-full h-full"></canvas>
<div
*ngIf="progress !== null"
class="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-50 text-white px-4 py-2 rounded-lg text-sm font-bold"
>
{{ progress.toFixed(1) }}%
</div>
</div>
`,
styles: [
`
canvas {
display: block;
outline: none;
}
`,
],
})
export class ThreeTrophyComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
@Input() progress: number | null = null;
@Input() size: number = 200;
private scene!: THREE.Scene;
private camera!: THREE.PerspectiveCamera;
private renderer!: THREE.WebGLRenderer;
private trophy!: THREE.Group;
private animationId: number | null = null;
private mouseX = 0;
private mouseY = 0;
ngOnInit(): void {}
ngAfterViewInit(): void {
this.initThree();
this.createTrophy();
this.animate();
this.addMouseInteraction();
}
ngOnDestroy(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
if (this.renderer) {
this.renderer.dispose();
}
}
private initThree(): void {
const canvas = this.canvasRef.nativeElement;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
// Scene
this.scene = new THREE.Scene();
this.scene.background = null;
// Camera
this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
this.camera.position.set(0, 2, 5);
this.camera.lookAt(0, 0, 0);
// Renderer
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
alpha: true,
});
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 10, 5);
directionalLight.castShadow = true;
this.scene.add(directionalLight);
const pointLight = new THREE.PointLight(0x4f46e5, 1, 100);
pointLight.position.set(-5, 5, 5);
this.scene.add(pointLight);
// Handle resize
window.addEventListener('resize', () => this.onWindowResize());
}
private createTrophy(): void {
this.trophy = new THREE.Group();
// Trophy base
const baseGeometry = new THREE.CylinderGeometry(0.8, 1, 0.3, 32);
const baseMaterial = new THREE.MeshStandardMaterial({
color: 0xffd700,
metalness: 0.8,
roughness: 0.2,
});
const base = new THREE.Mesh(baseGeometry, baseMaterial);
base.position.y = -1.5;
base.castShadow = true;
base.receiveShadow = true;
this.trophy.add(base);
// Trophy stem
const stemGeometry = new THREE.CylinderGeometry(0.15, 0.15, 1.5, 16);
const stemMaterial = new THREE.MeshStandardMaterial({
color: 0xffd700,
metalness: 0.8,
roughness: 0.2,
});
const stem = new THREE.Mesh(stemGeometry, stemMaterial);
stem.position.y = -0.5;
stem.castShadow = true;
this.trophy.add(stem);
// Trophy cup (bottom)
const cupBottomGeometry = new THREE.ConeGeometry(0.6, 0.8, 32);
const cupMaterial = new THREE.MeshStandardMaterial({
color: 0xffd700,
metalness: 0.9,
roughness: 0.1,
});
const cupBottom = new THREE.Mesh(cupBottomGeometry, cupMaterial);
cupBottom.position.y = 0.3;
cupBottom.rotation.x = Math.PI;
cupBottom.castShadow = true;
this.trophy.add(cupBottom);
// Trophy cup (top rim)
const cupTopGeometry = new THREE.TorusGeometry(0.6, 0.05, 16, 32);
const cupTop = new THREE.Mesh(cupTopGeometry, cupMaterial);
cupTop.position.y = 0.7;
cupTop.castShadow = true;
this.trophy.add(cupTop);
// Trophy handles
const handleGeometry = new THREE.TorusGeometry(0.3, 0.05, 8, 16);
const handle1 = new THREE.Mesh(handleGeometry, cupMaterial);
handle1.position.set(0.6, 0.5, 0);
handle1.rotation.z = Math.PI / 2;
handle1.castShadow = true;
this.trophy.add(handle1);
const handle2 = new THREE.Mesh(handleGeometry, cupMaterial);
handle2.position.set(-0.6, 0.5, 0);
handle2.rotation.z = -Math.PI / 2;
handle2.castShadow = true;
this.trophy.add(handle2);
// Progress indicator - particles
if (this.progress !== null) {
const particleCount = Math.floor((this.progress / 100) * 50);
const particles = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
const angle = (i / particleCount) * Math.PI * 2;
const radius = 0.8;
positions[i * 3] = Math.cos(angle) * radius;
positions[i * 3 + 1] = 0.7 + Math.sin(angle) * 0.3;
positions[i * 3 + 2] = Math.sin(angle) * radius;
}
particles.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const particleMaterial = new THREE.PointsMaterial({
color: 0x4f46e5,
size: 0.05,
transparent: true,
opacity: 0.8,
});
const particleSystem = new THREE.Points(particles, particleMaterial);
this.trophy.add(particleSystem);
}
this.scene.add(this.trophy);
}
private addMouseInteraction(): void {
const canvas = this.canvasRef.nativeElement;
canvas.addEventListener('mousemove', (event) => {
const rect = canvas.getBoundingClientRect();
this.mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1;
});
canvas.addEventListener('mouseleave', () => {
this.mouseX = 0;
this.mouseY = 0;
});
}
private animate = (): void => {
this.animationId = requestAnimationFrame(this.animate);
// Rotate trophy - much slower
if (this.trophy) {
this.trophy.rotation.y += 0.001;
// Subtle mouse interaction
this.trophy.rotation.y += this.mouseX * 0.01;
this.trophy.rotation.x = this.mouseY * 0.15;
}
this.renderer.render(this.scene, this.camera);
};
private onWindowResize(): void {
const canvas = this.canvasRef.nativeElement;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Check if a date is within the year 2026
*/
export function isIn2026(date: Date): boolean {
return date.getFullYear() === 2026;
}
/**
* Get the start date of 2026
*/
export function get2026Start(): Date {
return new Date('2026-01-01T00:00:00Z');
}
/**
* Get the end date of 2026
*/
export function get2026End(): Date {
return new Date('2026-12-31T23:59:59Z');
}
/**
* Get days elapsed in 2026
*/
export function getDaysElapsedIn2026(): number {
const now = new Date();
const start = get2026Start();
const diff = now.getTime() - start.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
/**
* Get total days in 2026
*/
export function getTotalDaysIn2026(): number {
const start = get2026Start();
const end = get2026End();
const diff = end.getTime() - start.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24)) + 1;
}
/**
* Calculate ideal progress percentage based on linear progression
*/
export function getIdealProgress(): number {
const elapsed = getDaysElapsedIn2026();
const total = getTotalDaysIn2026();
return Math.min(100, (elapsed / total) * 100);
}
/**
* Format date to readable string
*/
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Convert meters to kilometers
*/
export function metersToKm(meters: number): number {
return meters / 1000;
}
+13
View File
@@ -0,0 +1,13 @@
# Gaming Logos
Logo files in this folder:
- `tft.svg` ✓ - Teamfight Tactics logo
- `lol.svg` ✓ - League of Legends logo
- `rl.svg` ✓ - Rocket League logo
- `faceit.svg` ✓ - Faceit logo
All logos are loaded and ready to use!
Supported formats: SVG (recommended) or PNG
+1
View File
@@ -0,0 +1 @@
<svg height="1520" viewBox="29.3 101.1 451.7 357.9" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m481 104.8c0-1.8-1.9-3.7-1.9-3.7-1.8 0-1.8 0-3.7 1.9-37.5 58.1-76.8 116.2-114.3 176.2h-326.2c-3.7 0-5.6 5.6-1.8 7.5 134.9 50.5 331.7 127.3 440.4 170.4 3.7 1.9 7.5-1.9 7.5-3.7z" fill="#fd5a00"/><path d="m481 104.8c0-1.8-1.9-3.7-1.9-3.7-1.8 0-1.8 0-3.7 1.9-37.5 58.1-76.8 116.2-114.3 176.2l119.9 1.23z" fill="#ff690a"/></svg>

After

Width:  |  Height:  |  Size: 432 B

+1
View File
@@ -0,0 +1 @@
<svg version="1.1" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" style="enable-background:new 0 0 882.72 703.44;" xml:space="preserve" viewBox="237.72 133.67 408.05 435.23"> <style type="text/css"> .st0{clip-path:url(#SVGID_2_);} .st1{fill:#C28F2B;} </style> <g> <defs> <rect id="SVGID_1_" x="237.72" y="133.68" width="408" height="435.2"></rect> </defs> <clipPath id="SVGID_2_"> <use xlink:href="#SVGID_1_" style="overflow:visible;"></use> </clipPath> <g class="st0"> <path class="st1" d="M262.29,266.28c-15.66,28.38-24.57,60.88-24.57,95.44s8.91,67.09,24.57,95.47V266.28z"></path> <path class="st1" d="M441.72,161.18c-16.55,0-32.61,2-48.02,5.63v31.56c15.26-4.3,31.35-6.66,48.02-6.66 c95.5,0,172.93,75.88,172.93,169.5c0,42.16-15.75,80.72-41.74,110.38l-4.93,17.3l-10.91,38.29 c53.54-36.14,88.68-96.75,88.68-165.41C645.72,250.96,554.39,161.18,441.72,161.18z"></path> <path class="st1" d="M393.7,465.8h156.47h3.42c26.48-27.2,42.79-64.03,42.79-104.59c0-83.72-69.23-151.57-154.65-151.57 c-16.77,0-32.89,2.67-48.02,7.48V465.8z"></path> <path class="st1" d="M375.2,133.68h-116.4l21.98,44.86v345.51l-21.98,44.83h267.13l24.23-84.9H375.2V133.68z"></path> </g> </g> </svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

+1
View File
@@ -0,0 +1 @@
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" style="enable-background:new 0 0 96 72;" xml:space="preserve" viewBox="26.5 15.98 43.12 40.03"> <style type="text/css"> .st0{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);} </style> <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="34.8739" y1="12.8925" x2="64.828" y2="60.9741" gradientTransform="matrix(1 0 0 -1 0 74)"> <stop offset="4.909790e-02" style="stop-color:#AF893D"></stop> <stop offset="0.2178" style="stop-color:#D29D40"></stop> <stop offset="0.6393" style="stop-color:#FFCC74"></stop> <stop offset="1.8142" style="stop-color:#E8B55D"></stop> </linearGradient> <path class="st0" d="M30.7,40c0.3,0.3,0.9,0.7,1.9,1.3v3.6l12,7l0-1.7c3.6,1.6,6.4,2.6,8.4,2.9l0.1,0L48,56L30.7,45.9V40z M48,16 l17.3,10v15.2c1.7,1.2,3.1,2.7,4.3,4.5c-0.3-0.1-0.9-0.4-1.8-0.8c1.1,2.5,1.3,6.3-2.8,7.2c-9.8,2.3-22.6-4.1-27.4-7.3 c2.5,1,4.8,1.9,7,2.6l0-1.6c-2.3-0.8-6.3-2.4-10.6-5.3c-4-2.7-7.1-6-7.5-6.6c0.7,0.2,1.2,0.3,1.7,0.3c-1.5-2.7,0.1-5.6,2.6-6.7V26 L48,16z M48,18.1L32.6,27v7.2c-0.7-0.6-1.7-2.3-1.9-3.3c-0.7,0.5-0.8,1.5-0.4,2.6c1.8,4.6,9.4,9.5,14.2,12.1l0-12.2h-7.5l-2.6-5.9 h27.2L59,33.5h-7.5l0,15.7c2.2,0.4,4.3,0.7,6.2,0.8c8.3,0.4,8.9-2,7.5-4l-4.8,2.8c-1.3,0.1-2.5,0.1-3.6-0.1l6.6-3.8V27L48,18.1z"></path> </svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+42
View File
@@ -0,0 +1,42 @@
/**
* Production environment.
* Non-secret config from process.env at build time.
* API keys stay in backend .env only.
*/
declare const process: { env: Record<string, string | undefined> };
export const environment = {
production: true,
strava: {
clientId: process.env['STRAVA_CLIENT_ID'] || '',
redirectUri: process.env['STRAVA_REDIRECT_URI'] || '',
accessToken: '',
refreshToken: '',
tokenExpiresAt: null as number | null,
},
riot: {
region: process.env['RIOT_REGION'] || 'eun1',
summonerNames: {
lol: process.env['RIOT_SUMMONER_NAME_LOL'] || '',
tft: process.env['RIOT_SUMMONER_NAME_TFT'] || '',
},
tagLine: process.env['RIOT_TAG_LINE'] || '',
},
faceit: {
userId: process.env['FACEIT_USER_ID'] || '',
},
tracker: {
rocketLeague: {
platform: process.env['ROCKET_LEAGUE_PLATFORM'] || 'steam',
username: process.env['ROCKET_LEAGUE_USERNAME'] || '',
},
},
goals: {
sport: {
bike: 7500,
run: 2500,
swim: 250,
},
endDate: new Date('2026-12-31'),
},
};
+40
View File
@@ -0,0 +1,40 @@
/**
* Development environment.
* No secrets - API keys are handled by the backend proxy.
* Copy .env.example to .env and configure for local backend.
*/
export const environment = {
production: false,
strava: {
clientId: '196635',
redirectUri: 'http://localhost:4000/auth/strava/callback',
accessToken: '',
refreshToken: '',
tokenExpiresAt: null as number | null,
},
riot: {
region: 'eun1',
summonerNames: {
lol: 'R4K4N1',
tft: 'R4K4N1',
},
tagLine: 'EUNE',
},
faceit: {
userId: 'rakani',
},
tracker: {
rocketLeague: {
platform: 'steam',
username: 'rakani32',
},
},
goals: {
sport: {
bike: 7500,
run: 2500,
swim: 250,
},
endDate: new Date('2026-12-31'),
},
};
+16
View File
@@ -0,0 +1,16 @@
placeholder
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Goals Tracker 2026</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
export { AppServerModule as default } from './app/app.server';
+23
View File
@@ -0,0 +1,23 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);
+86
View File
@@ -0,0 +1,86 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
/* Global Animations */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out;
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #3b82f6, #8b5cf6);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #2563eb, #7c3aed);
}
/* Hide default cursor globally - only on desktop */
@media (hover: hover) and (pointer: fine) {
* {
cursor: none !important;
}
a, button, [role="button"], input, textarea, select {
cursor: none !important;
}
}