feat(06-01): extract shared utilities (tooltip, color-scale, stats, date-utils)
This commit is contained in:
parent
fe24e8bdd7
commit
b9b2565884
7 changed files with 341 additions and 0 deletions
16
assets/src/shared/color-scale.ts
Normal file
16
assets/src/shared/color-scale.ts
Normal 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);
|
||||
}
|
||||
70
assets/src/shared/date-utils.ts
Normal file
70
assets/src/shared/date-utils.ts
Normal 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;
|
||||
}
|
||||
81
assets/src/shared/stats.ts
Normal file
81
assets/src/shared/stats.ts
Normal 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);
|
||||
}
|
||||
26
assets/src/shared/tooltip.ts
Normal file
26
assets/src/shared/tooltip.ts
Normal 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';
|
||||
}
|
||||
35
assets/test/color-scale.test.ts
Normal file
35
assets/test/color-scale.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
61
assets/test/date-utils.test.ts
Normal file
61
assets/test/date-utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
52
assets/test/tooltip.test.ts
Normal file
52
assets/test/tooltip.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue