Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19,333 changes: 19,333 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ const routes: Routes = [
data: {feedType: 'jobs'}
},
{path: 'item', loadChildren: () => import('./item-details/item-details.module').then(m => m.ItemDetailsModule)},
{path: 'user', loadChildren: () => import('./user/user.module').then(m => m.UserModule)}
{path: 'user', loadChildren: () => import('./user/user.module').then(m => m.UserModule)},
{path: 'metrics', loadChildren: () => import('./tracker/tracker.module').then(m => m.TrackerModule)}
];


Expand Down
2 changes: 2 additions & 0 deletions src/app/core/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<a routerLink="/ask/1" routerLinkActive="active" (click)="scrollTop()">ask</a>
|
<a routerLink="/jobs/1" routerLinkActive="active" (click)="scrollTop()">jobs</a>
|
<a routerLink="/metrics" routerLinkActive="active" (click)="scrollTop()">metrics</a>
</span>
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/app/feeds/feed/feed.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Subscription } from 'rxjs';
import { ActivatedRoute } from '@angular/router';

import { HackerNewsAPIService } from '../../shared/services/hackernews-api.service';
import { TrackerService } from '../../shared/services/tracker.service';
import { Story } from '../../shared/models/story';

@Component({
Expand All @@ -23,6 +24,7 @@ export class FeedComponent implements OnInit {

constructor(
private _hackerNewsAPIService: HackerNewsAPIService,
private _trackerService: TrackerService,
private route: ActivatedRoute
) { }

Expand All @@ -31,6 +33,7 @@ export class FeedComponent implements OnInit {
.data
.subscribe(data => {
this.feedType = (data as any).feedType;
this._trackerService.trackPageView(this.feedType);
});

this.pageSub = this.route.params.subscribe(params => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/feeds/item/item.component.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div [ngStyle]="{'margin-bottom': settings.listSpacing+'px'}">
<p *ngIf="hasUrl">
<a class="title" [ngStyle]="{'font-size': settings.titleFontSize+'px'}" href="{{item.url}}" [attr.target]="settings.openLinkInNewTab ? '_blank' : null" [attr.rel]="settings.openLinkInNewTab ? 'noopener' : null">
<a class="title" [ngStyle]="{'font-size': settings.titleFontSize+'px'}" href="{{item.url}}" [attr.target]="settings.openLinkInNewTab ? '_blank' : null" [attr.rel]="settings.openLinkInNewTab ? 'noopener' : null" (click)="trackStoryClick()">
{{item.title}}
</a>
<span *ngIf="item.domain" class="domain">({{item.domain}})</span>
Expand Down
10 changes: 9 additions & 1 deletion src/app/feeds/item/item.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core';
import { Story } from '../../shared/models/story';

import { SettingsService } from '../../shared/services/settings.service';
import { TrackerService } from '../../shared/services/tracker.service';
import { Settings } from '../../shared/models/settings';

@Component({
Expand All @@ -13,7 +14,10 @@ export class ItemComponent implements OnInit {
@Input() item: Story;
settings: Settings;

constructor(private _settingsService: SettingsService) {
constructor(
private _settingsService: SettingsService,
private _trackerService: TrackerService
) {
this.settings = this._settingsService.settings;
}

Expand All @@ -23,4 +27,8 @@ export class ItemComponent implements OnInit {
return this.item.url.indexOf('http') === 0;
}

trackStoryClick() {
this._trackerService.trackStoryClick(this.item.id, this.item.title);
}

}
3 changes: 3 additions & 0 deletions src/app/item-details/item-details.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Subscription } from 'rxjs/Subscription';

import { HackerNewsAPIService } from '../shared/services/hackernews-api.service';
import { SettingsService } from '../shared/services/settings.service';
import { TrackerService } from '../shared/services/tracker.service';

import { Story } from '../shared/models/story';
import { Settings } from '../shared/models/settings';
Expand All @@ -23,13 +24,15 @@ export class ItemDetailsComponent implements OnInit {
constructor(
private _hackerNewsAPIService: HackerNewsAPIService,
private _settingsService: SettingsService,
private _trackerService: TrackerService,
private route: ActivatedRoute,
private _location: Location
) {
this.settings = this._settingsService.settings;
}

ngOnInit() {
this._trackerService.trackPageView('item-details');
this.sub = this.route.params.subscribe(params => {
let itemID = +params['id'];
this._hackerNewsAPIService.fetchItemContent(itemID).subscribe(item => {
Expand Down
188 changes: 188 additions & 0 deletions src/app/shared/services/tracker.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { Injectable } from '@angular/core';

export interface PageView {
page: string;
timestamp: number;
duration?: number;
}

export interface StoryClick {
storyId: number;
title: string;
timestamp: number;
}

export interface UserView {
userId: string;
timestamp: number;
}

export interface TrackerData {
pageViews: PageView[];
storyClicks: StoryClick[];
userViews: UserView[];
sessionCount: number;
firstVisit: number;
}

@Injectable({
providedIn: 'root'
})
export class TrackerService {
private readonly STORAGE_KEY = 'hn_tracker_data';
private currentPageStart: number | null = null;
private currentPage: string | null = null;

constructor() {
this.incrementSession();
}

private getData(): TrackerData {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
return {
pageViews: [],
storyClicks: [],
userViews: [],
sessionCount: 0,
firstVisit: Date.now()
};
}

private saveData(data: TrackerData): void {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
}

private incrementSession(): void {
const data = this.getData();
data.sessionCount++;
this.saveData(data);
}

trackPageView(page: string): void {
this.endCurrentPageView();
this.currentPage = page;
this.currentPageStart = Date.now();

const data = this.getData();
data.pageViews.push({
page,
timestamp: this.currentPageStart
});
this.saveData(data);
}

endCurrentPageView(): void {
if (this.currentPage && this.currentPageStart) {
const duration = Date.now() - this.currentPageStart;
const data = this.getData();
const lastView = data.pageViews.find(
pv => pv.page === this.currentPage && pv.timestamp === this.currentPageStart
);
if (lastView) {
lastView.duration = duration;
this.saveData(data);
}
}
this.currentPage = null;
this.currentPageStart = null;
}

trackStoryClick(storyId: number, title: string): void {
const data = this.getData();
data.storyClicks.push({
storyId,
title,
timestamp: Date.now()
});
this.saveData(data);
}

trackUserView(userId: string): void {
const data = this.getData();
data.userViews.push({
userId,
timestamp: Date.now()
});
this.saveData(data);
}

getMetrics() {
const data = this.getData();

const pageViewsByType = this.aggregateByKey(data.pageViews, 'page');
const storyClickCounts = this.aggregateStoryClicks(data.storyClicks);
const userViewCounts = this.aggregateByKey(data.userViews, 'userId');
const activityByHour = this.aggregateByHour(data.pageViews);
const avgTimeByPage = this.calculateAvgTime(data.pageViews);

return {
totalPageViews: data.pageViews.length,
totalStoryClicks: data.storyClicks.length,
totalUserViews: data.userViews.length,
sessionCount: data.sessionCount,
firstVisit: data.firstVisit,
pageViewsByType: this.sortByCount(pageViewsByType),
topStories: storyClickCounts.slice(0, 10),
topUsers: this.sortByCount(userViewCounts).slice(0, 10),
activityByHour,
avgTimeByPage
};
}

private aggregateByKey(items: any[], key: string): { name: string; count: number }[] {
const counts: Record<string, number> = {};
items.forEach(item => {
const val = item[key];
counts[val] = (counts[val] || 0) + 1;
});
return Object.entries(counts).map(([name, count]) => ({ name, count }));
}

private aggregateStoryClicks(clicks: StoryClick[]): { storyId: number; title: string; count: number }[] {
const counts: Record<number, { title: string; count: number }> = {};
clicks.forEach(click => {
if (!counts[click.storyId]) {
counts[click.storyId] = { title: click.title, count: 0 };
}
counts[click.storyId].count++;
});
return Object.entries(counts)
.map(([id, data]) => ({ storyId: +id, title: data.title, count: data.count }))
.sort((a, b) => b.count - a.count);
}

private aggregateByHour(pageViews: PageView[]): { hour: number; count: number }[] {
const counts: Record<number, number> = {};
for (let i = 0; i < 24; i++) counts[i] = 0;
pageViews.forEach(pv => {
const hour = new Date(pv.timestamp).getHours();
counts[hour]++;
});
return Object.entries(counts).map(([hour, count]) => ({ hour: +hour, count }));
}

private calculateAvgTime(pageViews: PageView[]): { page: string; avgTime: number }[] {
const times: Record<string, number[]> = {};
pageViews.forEach(pv => {
if (pv.duration) {
if (!times[pv.page]) times[pv.page] = [];
times[pv.page].push(pv.duration);
}
});
return Object.entries(times).map(([page, durations]) => ({
page,
avgTime: Math.round(durations.reduce((a, b) => a + b, 0) / durations.length / 1000)
}));
}

private sortByCount(items: { name: string; count: number }[]): { name: string; count: number }[] {
return items.sort((a, b) => b.count - a.count);
}

clearData(): void {
localStorage.removeItem(this.STORAGE_KEY);
}
}
74 changes: 74 additions & 0 deletions src/app/tracker/tracker.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<div class="tracker-container">
<div class="tracker-header">
<h1>Usage Metrics</h1>
<button class="clear-btn" (click)="clearData()">Clear Data</button>
</div>

<div class="metrics-grid" *ngIf="metrics">
<div class="metric-card">
<h3>Overview</h3>
<table>
<tr><td>Total Page Views</td><td>{{metrics.totalPageViews}}</td></tr>
<tr><td>Total Story Clicks</td><td>{{metrics.totalStoryClicks}}</td></tr>
<tr><td>Total User Profile Views</td><td>{{metrics.totalUserViews}}</td></tr>
<tr><td>Sessions</td><td>{{metrics.sessionCount}}</td></tr>
<tr><td>Tracking Since</td><td>{{formatDate(metrics.firstVisit)}}</td></tr>
</table>
</div>

<div class="metric-card">
<h3>Page Views by Type</h3>
<table *ngIf="metrics.pageViewsByType.length">
<tr *ngFor="let pv of metrics.pageViewsByType">
<td>{{pv.name}}</td>
<td>{{pv.count}}</td>
</tr>
</table>
<p *ngIf="!metrics.pageViewsByType.length" class="no-data">No data yet</p>
</div>

<div class="metric-card">
<h3>Average Time per Page (seconds)</h3>
<table *ngIf="metrics.avgTimeByPage.length">
<tr *ngFor="let avg of metrics.avgTimeByPage">
<td>{{avg.page}}</td>
<td>{{avg.avgTime}}s</td>
</tr>
</table>
<p *ngIf="!metrics.avgTimeByPage.length" class="no-data">No data yet</p>
</div>

<div class="metric-card">
<h3>Top 10 Clicked Stories</h3>
<table *ngIf="metrics.topStories.length">
<tr *ngFor="let story of metrics.topStories">
<td class="story-title">{{story.title}}</td>
<td>{{story.count}}</td>
</tr>
</table>
<p *ngIf="!metrics.topStories.length" class="no-data">No data yet</p>
</div>

<div class="metric-card">
<h3>Top 10 Viewed Users</h3>
<table *ngIf="metrics.topUsers.length">
<tr *ngFor="let user of metrics.topUsers">
<td>{{user.name}}</td>
<td>{{user.count}}</td>
</tr>
</table>
<p *ngIf="!metrics.topUsers.length" class="no-data">No data yet</p>
</div>

<div class="metric-card wide">
<h3>Activity by Hour</h3>
<div class="hour-chart">
<div class="hour-bar" *ngFor="let h of metrics.activityByHour"
[style.height.%]="metrics.totalPageViews ? (h.count / metrics.totalPageViews * 100) : 0">
<span class="hour-label">{{formatHour(h.hour)}}</span>
<span class="hour-count">{{h.count}}</span>
</div>
</div>
</div>
</div>
</div>
Loading