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
10 changes: 10 additions & 0 deletions client/src/components/Tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
document.addEventListener("DOMContentLoaded", () => {
const tabs = document.querySelector("ui-tabs");
const typeTabContent = document.getElementById("type-tab-content");
const historyTabContent = document.getElementById("history-tab-content");

if (tabs && typeTabContent && historyTabContent) {
tabs.addTab("type", "Type", typeTabContent, true);
tabs.addTab("history", "History", historyTabContent, false);
}
});
135 changes: 135 additions & 0 deletions client/src/components/Tabs/tabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
declare global {
interface TabsTagNameMap {
[Tabs.tag]: Tabs;
}
interface HTMLElementTagNameMap extends TabsTagNameMap {}

interface TabsEventMap {
[Tabs.events.change]: CustomEvent<{ activeTab: string }>;
}
interface ElementEventMap extends TabsEventMap {}
}

export class Tabs extends HTMLElement {
static tag = "ui-tabs" as const;
static events = {
change: "change",
} as const;

private tabButtons: HTMLButtonElement[] = [];
private tabContents: HTMLElement[] = [];
private activeTab: string = "";

constructor() {
super();
this.classList.add("ui-tabs");
}

connectedCallback() {
this.render();
this.setupEventListeners();
}

private render() {
this.innerHTML = `
<ul class="nav nav-tabs" role="tablist">
<!-- Tab buttons will be inserted here -->
</ul>
<div class="tab-content">
<!-- Tab content will be inserted here -->
</div>
`;
}

addTab(id: string, label: string, content: HTMLElement, active: boolean = false) {
const nav = this.querySelector(".nav") as HTMLUListElement;
const tabContent = this.querySelector(".tab-content") as HTMLDivElement;

const tabButton = document.createElement("button");
tabButton.className = `nav-link ${active ? "active" : ""}`;
tabButton.id = `tab-${id}`;
tabButton.setAttribute("data-bs-toggle", "tab");
tabButton.setAttribute("data-bs-target", `#content-${id}`);
tabButton.setAttribute("role", "tab");
tabButton.setAttribute("aria-controls", `content-${id}`);
tabButton.setAttribute("aria-selected", active ? "true" : "false");
tabButton.textContent = label;

const listItem = document.createElement("li");
listItem.className = "nav-item";
listItem.setAttribute("role", "presentation");
listItem.appendChild(tabButton);

const contentDiv = document.createElement("div");
contentDiv.className = `tab-pane fade ${active ? "show active" : ""}`;
contentDiv.id = `content-${id}`;
contentDiv.setAttribute("role", "tabpanel");
contentDiv.setAttribute("aria-labelledby", `tab-${id}`);
contentDiv.appendChild(content);

nav.appendChild(listItem);
tabContent.appendChild(contentDiv);

this.tabButtons.push(tabButton);
this.tabContents.push(contentDiv);

if (active || this.activeTab === "") {
this.activeTab = id;
}
}

private setupEventListeners() {
this.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
if (target.matches("[data-bs-toggle='tab']")) {
const tabId = target.getAttribute("data-bs-target")?.replace("#content-", "");
if (tabId && tabId !== this.activeTab) {
this.setActiveTab(tabId);
}
}
});

// Listen for Bootstrap tab events
this.addEventListener("shown.bs.tab", (event) => {
const target = event.target as HTMLElement;
const tabId = target.getAttribute("data-bs-target")?.replace("#content-", "");
if (tabId && tabId !== this.activeTab) {
this.setActiveTab(tabId);
}
});
}

setActiveTab(tabId: string) {
if (this.activeTab === tabId) return;

this.activeTab = tabId;

// Update tab buttons
this.tabButtons.forEach(button => {
const isActive = button.getAttribute("data-bs-target") === `#content-${tabId}`;
button.classList.toggle("active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
});

// Update tab content
this.tabContents.forEach(content => {
const isActive = content.id === `content-${tabId}`;
content.classList.toggle("show", isActive);
content.classList.toggle("active", isActive);
});

// Dispatch change event
this.dispatchEvent(
new CustomEvent(Tabs.events.change, {
detail: { activeTab: tabId },
bubbles: true,
})
);
}

getActiveTab(): string {
return this.activeTab;
}
}

customElements.define(Tabs.tag, Tabs);
86 changes: 86 additions & 0 deletions client/src/history-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { HistoryEntry, TypingReport } from "./types";

export class HistoryStorage {
private static readonly STORAGE_KEY = "coderType_history";
private static readonly MAX_ENTRIES = 100;

static save(report: TypingReport, snippet: { name: string; language: string }): void {
try {
const entries = this.getAll();
const cpm = this.calculateCPM(report);
const acc = this.calculateAccuracy(report);

const newEntry: HistoryEntry = {
id: Date.now().toString(),
report,
snippet,
timestamp: Date.now(),
cpm,
acc,
};

entries.unshift(newEntry);

const trimmedEntries = entries.slice(0, this.MAX_ENTRIES);

localStorage.setItem(this.STORAGE_KEY, JSON.stringify(trimmedEntries));
} catch (error) {
console.error("Failed to save history entry:", error);
}
}

static getAll(): HistoryEntry[] {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (!stored) return [];

const entries = JSON.parse(stored) as HistoryEntry[];
return Array.isArray(entries) ? entries.toSorted((a, b) => b.timestamp - a.timestamp) : [];
} catch (error) {
console.error("Failed to load history entries:", error);
return [];
}
}

static getItem(id: string): HistoryEntry | null {
const entries = this.getAll();
return entries.find(entry => entry.id === id) || null;
}

static clear(): void {
try {
localStorage.removeItem(this.STORAGE_KEY);
} catch (error) {
console.error("Failed to clear history:", error);
}
}

static getStats(): { totalEntries: number; averageCPM: number; averageAccuracy: number } {
const entries = this.getAll();

if (entries.length === 0) {
return { totalEntries: 0, averageCPM: 0, averageAccuracy: 0 };
}

const totalCPM = entries.reduce((sum, entry) => sum + entry.cpm, 0);
const totalAccuracy = entries.reduce((sum, entry) => sum + entry.acc, 0);

return {
totalEntries: entries.length,
averageCPM: Math.round(totalCPM / entries.length),
averageAccuracy: Math.round((totalAccuracy / entries.length) * 100) / 100,
};
}

private static calculateCPM(report: TypingReport): number {
const correctChars = report.tracked.filter(t => t.detail?.isCorrect).length;
const durationMinutes = report.duration / 60000;
return Math.round(correctChars / durationMinutes);
}

private static calculateAccuracy(report: TypingReport): number {
const totalChars = report.tracked.length;
const correctChars = report.tracked.filter(t => t.detail?.isCorrect).length;
return totalChars > 0 ? correctChars / totalChars : 0;
}
}
112 changes: 112 additions & 0 deletions client/src/history-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { HistoryEntry } from "./types";
import { HistoryStorage } from "./history-storage";
import { ReportView } from "./report-view";

declare global {
interface HistoryViewTagNameMap {
[HistoryView.tag]: HistoryView;
}
interface HTMLElementTagNameMap extends HistoryViewTagNameMap {}
}

export class HistoryView extends HTMLElement {
static tag = "history-view" as const;

constructor() {
super();
this.classList.add("history-view");
}

connectedCallback() {
this.render();
}

private render() {
const entries = HistoryStorage.getAll();

if (entries.length === 0) {
this.renderEmptyState();
return;
}

this.innerHTML = `
<div class="row g-3">
${entries.map(entry => this.renderHistoryCard(entry)).join("")}
</div>
`;
}

private renderEmptyState() {
this.innerHTML = `
<div class="text-center py-5">
<div class="text-muted mb-3">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" class="opacity-50">
<path d="M9 11H7v6h2v-6zm4 0h-2v6h2v-6zm4 0h-2v6h2v-6zm2.5-9H19V1h-2v1H7V1H5v1H4.5C3.67 2 3 2.67 3 3.5v15C3 19.33 3.67 20 4.5 20h15c.83 0 1.5-.67 1.5-1.5v-15C21 2.67 20.33 2 19.5 2zM19 18H5V8h14v10z"/>
</svg>
</div>
<h5 class="text-muted">No typing history yet</h5>
<p class="text-muted mb-0">Complete some typing sessions to see your progress here!</p>
</div>
`;
}

private renderHistoryCard(entry: HistoryEntry): string {
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleDateString();
const formattedTime = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });

return `
<div class="col-md-6 col-lg-4">
<div class="card h-100 history-card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h6 class="card-title mb-0">${this.escapeHtml(entry.snippet.name)}</h6>
<small class="text-muted">${entry.snippet.language}</small>
</div>
<div class="text-end">
<small class="text-muted">${formattedDate}</small><br>
<small class="text-muted">${formattedTime}</small>
</div>
</div>
<div class="card-body">
<div class="d-flex gap-3 mb-3">
<div class="flex-fill">
<div class="text-primary small fw-bold">CPM</div>
<div class="fs-5 fw-bold">${Math.round(entry.cpm)}</div>
</div>
<div class="flex-fill">
<div class="text-info small fw-bold">ACC</div>
<div class="fs-5 fw-bold">${Math.round(entry.acc * 100)}%</div>
</div>
</div>
<div class="mini-chart">
${this.renderMiniChart(entry.report)}
</div>
</div>
</div>
</div>
`;
}

private renderMiniChart(report: any): string {
const buckets = ReportView.compute(report, 8);
return ReportView.chart(buckets);
}

private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

refresh() {
this.render();
}

clearHistory() {
HistoryStorage.clear();
this.render();
}
}

customElements.define(HistoryView.tag, HistoryView);
Loading