feat(06-01): extract shared utilities (tooltip, color-scale, stats, date-utils)

This commit is contained in:
Christopher Mühl 2026-04-09 09:34:11 +02:00
parent fe24e8bdd7
commit b9b2565884
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
7 changed files with 341 additions and 0 deletions

View file

@ -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<typeof scaleQuantize<string>> {
const accessor = metric === 'hours'
? (d: DayEntry) => d.hours
: (d: DayEntry) => d.count;
const maxVal = max(days, accessor) || 1;
return scaleQuantize<string>().domain([0, maxVal]).range(FALLBACK_COLORS);
}

View file

@ -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<string, DayEntry> {
const map = new Map<string, DayEntry>();
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<string, DayEntry>,
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;
}

View file

@ -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(`<span>\u{1F525} <span class="stat-value">${streak}</span> days</span>`);
parts.push(`<span>Total: <span class="stat-value">${stats.totalHours}h</span></span>`);
parts.push(`<span>Avg: <span class="stat-value">${stats.avgHours}h/day</span></span>`);
if (stats.busiestDay) {
const d = new Date(stats.busiestDay.date + 'T00:00:00');
const label = DISPLAY_FORMAT(d);
parts.push(`<span>Busiest: <span class="stat-value">${label} \u2014 ${stats.busiestDay.hours.toFixed(1)}h</span></span>`);
}
statsDiv.innerHTML = parts.join('');
container.appendChild(statsDiv);
}

View file

@ -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';
}

View file

@ -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]);
});
});

View file

@ -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();
});
});
});

View file

@ -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, '<strong>Test</strong>', rect, 13);
expect(tip.style.display).toBe('block');
expect(tip.innerHTML).toBe('<strong>Test</strong>');
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');
});
});
});