From 902e58c93d8620a7d9745d307bde3405791e10b4 Mon Sep 17 00:00:00 2001 From: DemaPy Date: Sat, 11 Oct 2025 21:50:05 +0200 Subject: [PATCH 1/2] feat: created Tab component --- client/src/components/Tabs/script.js | 10 ++ client/src/components/Tabs/tabs.ts | 135 +++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 client/src/components/Tabs/script.js create mode 100644 client/src/components/Tabs/tabs.ts diff --git a/client/src/components/Tabs/script.js b/client/src/components/Tabs/script.js new file mode 100644 index 0000000..6b0e757 --- /dev/null +++ b/client/src/components/Tabs/script.js @@ -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); + } +}); diff --git a/client/src/components/Tabs/tabs.ts b/client/src/components/Tabs/tabs.ts new file mode 100644 index 0000000..4657c1f --- /dev/null +++ b/client/src/components/Tabs/tabs.ts @@ -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 = ` + +
+ +
+ `; + } + + 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); From 7390fc00b125e9cda0c2318e66e8409db94ea8d7 Mon Sep 17 00:00:00 2001 From: DemaPy Date: Sat, 11 Oct 2025 21:55:58 +0200 Subject: [PATCH 2/2] feat: use Tab component in index.html with History storage --- .../components/Tabs/{script.js => index.ts} | 0 client/src/history-storage.ts | 86 ++++++++++++++ client/src/history-view.ts | 112 ++++++++++++++++++ client/src/index.html | 102 ++++++++++++---- client/src/index.ts | 18 +++ client/src/types.ts | 12 ++ 6 files changed, 307 insertions(+), 23 deletions(-) rename client/src/components/Tabs/{script.js => index.ts} (100%) create mode 100644 client/src/history-storage.ts create mode 100644 client/src/history-view.ts diff --git a/client/src/components/Tabs/script.js b/client/src/components/Tabs/index.ts similarity index 100% rename from client/src/components/Tabs/script.js rename to client/src/components/Tabs/index.ts diff --git a/client/src/history-storage.ts b/client/src/history-storage.ts new file mode 100644 index 0000000..bd55a91 --- /dev/null +++ b/client/src/history-storage.ts @@ -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; + } +} diff --git a/client/src/history-view.ts b/client/src/history-view.ts new file mode 100644 index 0000000..06cc2bb --- /dev/null +++ b/client/src/history-view.ts @@ -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 = ` +
+ ${entries.map(entry => this.renderHistoryCard(entry)).join("")} +
+ `; + } + + private renderEmptyState() { + this.innerHTML = ` +
+
+ + + +
+
No typing history yet
+

Complete some typing sessions to see your progress here!

+
+ `; + } + + 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 ` +
+
+
+
+
${this.escapeHtml(entry.snippet.name)}
+ ${entry.snippet.language} +
+
+ ${formattedDate}
+ ${formattedTime} +
+
+
+
+
+
CPM
+
${Math.round(entry.cpm)}
+
+
+
ACC
+
${Math.round(entry.acc * 100)}%
+
+
+
+ ${this.renderMiniChart(entry.report)} +
+
+
+
+ `; + } + + 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); diff --git a/client/src/index.html b/client/src/index.html index 9948ad8..9ab47ce 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -24,6 +24,8 @@ + + @@ -41,33 +88,42 @@

CoderType 2.0

Talk is cheap. Show me the code! - Linus Torvalds -
-
-
- - -
-
-
URL
-
- + + + +
+
+
+
+ + +
+
+
URL
+
+ +
+
+ +
+
-
- -
- +
+

+ Legal notice: the above code snippets are taken for educational purposes + from their respective repositories. Please follow the provided links + to get information about the license of each snippet. +

+
+
+ +
+
-
-

- Legal notice: the above code snippets are taken for educational purposes - from their respective repositories. Please follow the provided links - to get information about the license of each snippet. -

-