From b9b2565884015ba36d68a616dffff9d592bc5f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Thu, 9 Apr 2026 09:34:11 +0200 Subject: [PATCH] feat(06-01): extract shared utilities (tooltip, color-scale, stats, date-utils) --- assets/src/shared/color-scale.ts | 16 +++++++ assets/src/shared/date-utils.ts | 70 +++++++++++++++++++++++++++ assets/src/shared/stats.ts | 81 ++++++++++++++++++++++++++++++++ assets/src/shared/tooltip.ts | 26 ++++++++++ assets/test/color-scale.test.ts | 35 ++++++++++++++ assets/test/date-utils.test.ts | 61 ++++++++++++++++++++++++ assets/test/tooltip.test.ts | 52 ++++++++++++++++++++ 7 files changed, 341 insertions(+) create mode 100644 assets/src/shared/color-scale.ts create mode 100644 assets/src/shared/date-utils.ts create mode 100644 assets/src/shared/stats.ts create mode 100644 assets/src/shared/tooltip.ts create mode 100644 assets/test/color-scale.test.ts create mode 100644 assets/test/date-utils.test.ts create mode 100644 assets/test/tooltip.test.ts diff --git a/assets/src/shared/color-scale.ts b/assets/src/shared/color-scale.ts new file mode 100644 index 0000000..11e0080 --- /dev/null +++ b/assets/src/shared/color-scale.ts @@ -0,0 +1,16 @@ +import { scaleQuantize } from 'd3-scale'; +import { max } from 'd3-array'; +import type { DayEntry, DisplayMetric } from '../types'; + +export const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39']; + +export function buildColorScale( + days: DayEntry[], + metric: DisplayMetric = 'hours', +): ReturnType> { + const accessor = metric === 'hours' + ? (d: DayEntry) => d.hours + : (d: DayEntry) => d.count; + const maxVal = max(days, accessor) || 1; + return scaleQuantize().domain([0, maxVal]).range(FALLBACK_COLORS); +} diff --git a/assets/src/shared/date-utils.ts b/assets/src/shared/date-utils.ts new file mode 100644 index 0000000..7926651 --- /dev/null +++ b/assets/src/shared/date-utils.ts @@ -0,0 +1,70 @@ +import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time'; +import { timeFormat } from 'd3-time-format'; +import type { DayEntry } from '../types'; + +export const DAY_LABELS_MONDAY = ['Mon', '', 'Wed', '', 'Fri', '', '']; +export const DAY_LABELS_SUNDAY = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat']; + +export function getDayLabels(weekStart: string): string[] { + return weekStart === 'sunday' ? DAY_LABELS_SUNDAY : DAY_LABELS_MONDAY; +} + +export const MONTH_FORMAT = timeFormat('%b'); +export const DATE_FORMAT = timeFormat('%Y-%m-%d'); +export const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y'); + +export interface DayCell { + date: Date; + dateStr: string; + entry: DayEntry | null; + week: number; + day: number; + isWeekend: boolean; +} + +export function buildDateMap(days: DayEntry[]): Map { + const map = new Map(); + for (const d of days) { + map.set(d.date, d); + } + return map; +} + +export function getWeekInterval(weekStart: string) { + return weekStart === 'sunday' ? timeSunday : timeMonday; +} + +export function generateCells( + begin: Date, + end: Date, + dateMap: Map, + weekStart: string = 'monday', +): DayCell[] { + const weekInterval = getWeekInterval(weekStart); + const firstWeekDay = weekInterval.floor(begin); + const cells: DayCell[] = []; + let current = new Date(begin); + + while (current <= end) { + const dateStr = DATE_FORMAT(current); + const weeksSinceStart = weekInterval.count(firstWeekDay, current); + const jsDay = current.getDay(); // 0=Sunday, 6=Saturday + const dayOfWeek = weekStart === 'sunday' + ? jsDay // Sunday=0 already first + : (jsDay + 6) % 7; // Monday=0, Sunday=6 + const isWeekend = jsDay === 0 || jsDay === 6; + + cells.push({ + date: new Date(current), + dateStr, + entry: dateMap.get(dateStr) || null, + week: weeksSinceStart, + day: dayOfWeek, + isWeekend, + }); + + current = timeDay.offset(current, 1); + } + + return cells; +} diff --git a/assets/src/shared/stats.ts b/assets/src/shared/stats.ts new file mode 100644 index 0000000..585257d --- /dev/null +++ b/assets/src/shared/stats.ts @@ -0,0 +1,81 @@ +import { timeDay } from 'd3-time'; +import { timeFormat } from 'd3-time-format'; +import type { DayEntry } from '../types'; + +const DATE_FORMAT = timeFormat('%Y-%m-%d'); +const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y'); + +export function calculateStreak(days: DayEntry[]): number { + if (days.length === 0) return 0; + + const tracked = new Set( + days.filter((d) => d.hours > 0).map((d) => d.date), + ); + if (tracked.size === 0) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + let current = new Date(today); + + // If today has no entry, start from yesterday + if (!tracked.has(DATE_FORMAT(current))) { + current = timeDay.offset(current, -1); + } + + let streak = 0; + while (tracked.has(DATE_FORMAT(current))) { + streak++; + current = timeDay.offset(current, -1); + } + + return streak; +} + +export interface HeatmapStats { + totalHours: number; + avgHours: number; + busiestDay: { date: string; hours: number } | null; +} + +export function calculateStats(days: DayEntry[]): HeatmapStats { + const withEntries = days.filter((d) => d.hours > 0); + if (withEntries.length === 0) { + return { totalHours: 0, avgHours: 0, busiestDay: null }; + } + + const totalHours = Math.round(withEntries.reduce((sum, d) => sum + d.hours, 0) * 10) / 10; + const avgHours = Math.round((totalHours / withEntries.length) * 10) / 10; + const busiest = withEntries.reduce((best, d) => (d.hours > best.hours ? d : best)); + + return { + totalHours, + avgHours, + busiestDay: { date: busiest.date, hours: busiest.hours }, + }; +} + +export function renderStats(container: HTMLElement, days: DayEntry[]): void { + // Remove existing stats + const existing = container.querySelector('.heatmap-stats'); + if (existing) existing.remove(); + + const streak = calculateStreak(days); + const stats = calculateStats(days); + + const statsDiv = document.createElement('div'); + statsDiv.className = 'heatmap-stats'; + + const parts: string[] = []; + parts.push(`\u{1F525} ${streak} days`); + parts.push(`Total: ${stats.totalHours}h`); + parts.push(`Avg: ${stats.avgHours}h/day`); + + if (stats.busiestDay) { + const d = new Date(stats.busiestDay.date + 'T00:00:00'); + const label = DISPLAY_FORMAT(d); + parts.push(`Busiest: ${label} \u2014 ${stats.busiestDay.hours.toFixed(1)}h`); + } + + statsDiv.innerHTML = parts.join(''); + container.appendChild(statsDiv); +} diff --git a/assets/src/shared/tooltip.ts b/assets/src/shared/tooltip.ts new file mode 100644 index 0000000..6aae663 --- /dev/null +++ b/assets/src/shared/tooltip.ts @@ -0,0 +1,26 @@ +export function createTooltip(): HTMLDivElement { + document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove()); + + const tip = document.createElement('div'); + tip.className = 'heatmap-tooltip'; + tip.style.display = 'none'; + tip.style.position = 'fixed'; + document.body.appendChild(tip); + return tip; +} + +export function showTooltip( + tip: HTMLDivElement, + html: string, + anchorRect: DOMRect, + cellSize: number, +): void { + tip.innerHTML = html; + tip.style.display = 'block'; + tip.style.left = `${anchorRect.left + cellSize / 2}px`; + tip.style.top = `${anchorRect.top - tip.offsetHeight - 8}px`; +} + +export function hideTooltip(tip: HTMLDivElement): void { + tip.style.display = 'none'; +} diff --git a/assets/test/color-scale.test.ts b/assets/test/color-scale.test.ts new file mode 100644 index 0000000..093b492 --- /dev/null +++ b/assets/test/color-scale.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { buildColorScale, FALLBACK_COLORS } from '../src/shared/color-scale'; +import type { DayEntry } from '../src/types'; + +describe('buildColorScale', () => { + const days: DayEntry[] = [ + { date: '2025-01-01', hours: 2, count: 3 }, + { date: '2025-01-02', hours: 8, count: 5 }, + { date: '2025-01-03', hours: 0, count: 0 }, + ]; + + it('builds scale for hours metric', () => { + const scale = buildColorScale(days, 'hours'); + // max hours is 8, so domain is [0, 8] + expect(scale(0)).toBe(FALLBACK_COLORS[0]); + expect(scale(8)).toBe(FALLBACK_COLORS[3]); + }); + + it('builds scale for count metric', () => { + const scale = buildColorScale(days, 'count'); + // max count is 5, so domain is [0, 5] + expect(scale(0)).toBe(FALLBACK_COLORS[0]); + expect(scale(5)).toBe(FALLBACK_COLORS[3]); + }); + + it('defaults to hours metric', () => { + const scale = buildColorScale(days); + expect(scale(8)).toBe(FALLBACK_COLORS[3]); + }); + + it('handles empty array with domain [0, 1]', () => { + const scale = buildColorScale([]); + expect(scale.domain()).toEqual([0, 1]); + }); +}); diff --git a/assets/test/date-utils.test.ts b/assets/test/date-utils.test.ts new file mode 100644 index 0000000..8466e80 --- /dev/null +++ b/assets/test/date-utils.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { buildDateMap, generateCells, getWeekInterval } from '../src/shared/date-utils'; +import { timeMonday, timeSunday } from 'd3-time'; +import type { DayEntry } from '../src/types'; + +describe('date-utils', () => { + const days: DayEntry[] = [ + { date: '2025-01-06', hours: 4, count: 2 }, + { date: '2025-01-07', hours: 2, count: 1 }, + ]; + + describe('buildDateMap', () => { + it('creates Map keyed by date string', () => { + const map = buildDateMap(days); + expect(map).toBeInstanceOf(Map); + expect(map.size).toBe(2); + expect(map.get('2025-01-06')).toEqual(days[0]); + expect(map.get('2025-01-07')).toEqual(days[1]); + expect(map.get('2025-01-08')).toBeUndefined(); + }); + }); + + describe('getWeekInterval', () => { + it('returns timeMonday for monday', () => { + expect(getWeekInterval('monday')).toBe(timeMonday); + }); + + it('returns timeSunday for sunday', () => { + expect(getWeekInterval('sunday')).toBe(timeSunday); + }); + }); + + describe('generateCells', () => { + it('returns correct count for a 7-day range', () => { + const begin = new Date('2025-01-06'); // Monday + const end = new Date('2025-01-12'); // Sunday + const map = buildDateMap(days); + const cells = generateCells(begin, end, map, 'monday'); + expect(cells.length).toBe(7); + }); + + it('marks weekend cells correctly', () => { + const begin = new Date('2025-01-06'); + const end = new Date('2025-01-12'); + const map = buildDateMap(days); + const cells = generateCells(begin, end, map, 'monday'); + const weekendCells = cells.filter(c => c.isWeekend); + expect(weekendCells.length).toBe(2); // Saturday + Sunday + }); + + it('links entries from dateMap', () => { + const begin = new Date('2025-01-06'); + const end = new Date('2025-01-08'); + const map = buildDateMap(days); + const cells = generateCells(begin, end, map, 'monday'); + expect(cells[0].entry).toEqual(days[0]); + expect(cells[1].entry).toEqual(days[1]); + expect(cells[2].entry).toBeNull(); + }); + }); +}); diff --git a/assets/test/tooltip.test.ts b/assets/test/tooltip.test.ts new file mode 100644 index 0000000..717c56e --- /dev/null +++ b/assets/test/tooltip.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTooltip, showTooltip, hideTooltip } from '../src/shared/tooltip'; + +describe('tooltip', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + describe('createTooltip', () => { + it('returns a div with class heatmap-tooltip and display none', () => { + const tip = createTooltip(); + expect(tip).toBeInstanceOf(HTMLDivElement); + expect(tip.className).toBe('heatmap-tooltip'); + expect(tip.style.display).toBe('none'); + }); + + it('appends tooltip to document.body with fixed positioning', () => { + const tip = createTooltip(); + expect(document.body.contains(tip)).toBe(true); + expect(tip.style.position).toBe('fixed'); + }); + + it('removes existing tooltip before creating new one', () => { + const first = createTooltip(); + const second = createTooltip(); + const tooltips = document.querySelectorAll('.heatmap-tooltip'); + expect(tooltips.length).toBe(1); + expect(tooltips[0]).toBe(second); + expect(document.body.contains(first)).toBe(false); + }); + }); + + describe('showTooltip', () => { + it('sets display to block and positions tooltip', () => { + const tip = createTooltip(); + const rect = { left: 100, top: 200 } as DOMRect; + showTooltip(tip, 'Test', rect, 13); + expect(tip.style.display).toBe('block'); + expect(tip.innerHTML).toBe('Test'); + expect(tip.style.left).toBe('106.5px'); // 100 + 13/2 + }); + }); + + describe('hideTooltip', () => { + it('sets display to none', () => { + const tip = createTooltip(); + tip.style.display = 'block'; + hideTooltip(tip); + expect(tip.style.display).toBe('none'); + }); + }); +});