|
| 1 | +<script lang="ts"> |
| 2 | + import type {Memo} from '$lib/server/memos'; |
| 3 | + import {format, startOfDay, startOfWeek, endOfWeek, eachDayOfInterval, subDays} from 'date-fns'; |
| 4 | + import {onMount} from 'svelte'; |
| 5 | +
|
| 6 | + let {memos}: {memos: Memo[]} = $props(); |
| 7 | +
|
| 8 | + const today = startOfDay(new Date()); |
| 9 | +
|
| 10 | + const days = $derived.by(() => { |
| 11 | + let earliestDate = today; |
| 12 | + if (memos && memos.length > 0) { |
| 13 | + const minDateRaw = new Date(Math.min(...memos.map((m) => new Date(m.date).getTime()))); |
| 14 | + earliestDate = startOfDay(minDateRaw); |
| 15 | + } |
| 16 | +
|
| 17 | + // Enforce a minimum of half a year (approx 180 days) |
| 18 | + const sixMonthsAgo = subDays(today, 360); |
| 19 | + if (earliestDate > sixMonthsAgo) { |
| 20 | + earliestDate = sixMonthsAgo; |
| 21 | + } |
| 22 | +
|
| 23 | + const startDate = startOfWeek(earliestDate, {weekStartsOn: 0}); |
| 24 | + const endDate = endOfWeek(today, {weekStartsOn: 0}); |
| 25 | + return eachDayOfInterval({start: startDate, end: endDate}); |
| 26 | + }); |
| 27 | +
|
| 28 | + // Calculate columns needed to set the grid dynamically |
| 29 | + const numColumns = $derived(Math.ceil(days.length / 7)); |
| 30 | +
|
| 31 | + // Count actual memos |
| 32 | + const memoCounts = $derived.by(() => { |
| 33 | + const counts = new Map<string, number>(); |
| 34 | +
|
| 35 | + // Initialize all days |
| 36 | + days.forEach((day) => { |
| 37 | + counts.set(format(day, 'yyyy-MM-dd'), 0); |
| 38 | + }); |
| 39 | +
|
| 40 | + // Increment counts for each memo |
| 41 | + memos?.forEach((memo) => { |
| 42 | + const dateStr = format(new Date(memo.date), 'yyyy-MM-dd'); |
| 43 | + if (counts.has(dateStr)) { |
| 44 | + counts.set(dateStr, counts.get(dateStr)! + 1); |
| 45 | + } |
| 46 | + }); |
| 47 | +
|
| 48 | + return counts; |
| 49 | + }); |
| 50 | +
|
| 51 | + const maxMemos = $derived(Math.max(1, ...Array.from(memoCounts.values()))); |
| 52 | +
|
| 53 | + const getLevel = (count: number) => { |
| 54 | + if (count === 0) return 0; |
| 55 | + if (count === 1) return 1; |
| 56 | + if (maxMemos <= 3) return count; |
| 57 | +
|
| 58 | + const percentage = count / maxMemos; |
| 59 | + if (percentage <= 0.33) return 2; |
| 60 | + if (percentage <= 0.66) return 3; |
| 61 | + return 4; |
| 62 | + }; |
| 63 | +
|
| 64 | + let scrollContainer: HTMLDivElement | undefined = $state(); |
| 65 | +
|
| 66 | + onMount(() => { |
| 67 | + // Scroll to the end on mobile so user sees recent days first |
| 68 | + if (scrollContainer && scrollContainer.scrollWidth > scrollContainer.clientWidth) { |
| 69 | + scrollContainer.scrollLeft = scrollContainer.scrollWidth; |
| 70 | + } |
| 71 | + }); |
| 72 | +
|
| 73 | + // Tooltip State |
| 74 | + let hoveredCell = $state<{x: number; y: number; date: string; count: number} | null>(null); |
| 75 | +
|
| 76 | + const handlePointerEnter = (e: PointerEvent, day: Date, count: number) => { |
| 77 | + const rect = (e.target as HTMLElement).getBoundingClientRect(); |
| 78 | + hoveredCell = { |
| 79 | + x: window.scrollX + rect.left + rect.width / 2, // Document-relative X |
| 80 | + y: window.scrollY + rect.top - 8, // Document-relative Y |
| 81 | + date: format(day, 'MMM d, yyyy'), |
| 82 | + count, |
| 83 | + }; |
| 84 | + }; |
| 85 | +
|
| 86 | + const handlePointerLeave = () => { |
| 87 | + hoveredCell = null; |
| 88 | + }; |
| 89 | +
|
| 90 | + function portal(node: HTMLElement) { |
| 91 | + if (typeof document === 'undefined') return; |
| 92 | + document.body.appendChild(node); |
| 93 | + return { |
| 94 | + destroy() { |
| 95 | + if (node.parentNode) { |
| 96 | + node.parentNode.removeChild(node); |
| 97 | + } |
| 98 | + }, |
| 99 | + }; |
| 100 | + } |
| 101 | +</script> |
| 102 | + |
| 103 | +<div |
| 104 | + use:portal |
| 105 | + class="absolute z-[10000] text-white pointer-events-none transform -translate-x-1/2 -translate-y-full px-2.5 py-1.5 rounded-[4px] text-[11px] font-medium tracking-wide whitespace-nowrap shadow-lg transition-opacity duration-150 {hoveredCell |
| 106 | + ? 'opacity-100' |
| 107 | + : 'opacity-0'}" |
| 108 | + style="left: {hoveredCell?.x ?? -9999}px; top: {hoveredCell?.y ?? |
| 109 | + -9999}px; background-color: #24292f; font-family: -apple-system, BlinkMacSystemFont, sans-serif;" |
| 110 | +> |
| 111 | + {hoveredCell?.count ?? 0} memo{(hoveredCell?.count ?? 0) !== 1 ? 's' : ''} on {hoveredCell?.date ?? ''} |
| 112 | + |
| 113 | + <!-- Tooltip Arrow --> |
| 114 | + <div |
| 115 | + class="absolute left-1/2 bottom-[1px] transform -translate-x-1/2 translate-y-full border-solid border-[5px] border-transparent" |
| 116 | + style="border-top-color: #24292f;" |
| 117 | + ></div> |
| 118 | +</div> |
| 119 | + |
| 120 | +<div class="w-full flex justify-center px-0 relative z-[9900]"> |
| 121 | + <div |
| 122 | + bind:this={scrollContainer} |
| 123 | + class="heatmap-container w-full max-w-2xl overflow-x-auto py-4 flex sm:justify-center will-change-scroll" |
| 124 | + style="justify-content: {numColumns > 40 ? 'flex-start' : 'center'};" |
| 125 | + > |
| 126 | + <div |
| 127 | + class="heatmap-grid gap-[3px] sm:gap-[4px] min-w-max pb-2 px-1" |
| 128 | + style="grid-template-columns: repeat({numColumns}, 1fr);" |
| 129 | + > |
| 130 | + {#each days as day} |
| 131 | + <div |
| 132 | + class="heatmap-cell rounded-[2px] w-[10px] h-[10px] sm:w-[12px] sm:h-[12px] transition-colors duration-200" |
| 133 | + role="presentation" |
| 134 | + data-level={getLevel(memoCounts.get(format(day, 'yyyy-MM-dd')) || 0)} |
| 135 | + onpointerenter={(e) => handlePointerEnter(e, day, memoCounts.get(format(day, 'yyyy-MM-dd')) || 0)} |
| 136 | + onpointerleave={handlePointerLeave} |
| 137 | + ></div> |
| 138 | + {/each} |
| 139 | + </div> |
| 140 | + </div> |
| 141 | +</div> |
| 142 | + |
| 143 | +<style> |
| 144 | + .heatmap-grid { |
| 145 | + display: grid; |
| 146 | + grid-template-rows: repeat(7, 1fr); |
| 147 | + grid-auto-flow: column; |
| 148 | + } |
| 149 | +
|
| 150 | + .heatmap-cell { |
| 151 | + background-color: color-mix(in srgb, var(--accent-color, var(--text-color)) 10%, transparent); |
| 152 | + } |
| 153 | +
|
| 154 | + .heatmap-cell[data-level='1'] { |
| 155 | + background-color: color-mix(in srgb, var(--accent-color, var(--text-color)) 30%, transparent); |
| 156 | + } |
| 157 | +
|
| 158 | + .heatmap-cell[data-level='2'] { |
| 159 | + background-color: color-mix(in srgb, var(--accent-color, var(--text-color)) 55%, transparent); |
| 160 | + } |
| 161 | +
|
| 162 | + .heatmap-cell[data-level='3'] { |
| 163 | + background-color: color-mix(in srgb, var(--accent-color, var(--text-color)) 80%, transparent); |
| 164 | + } |
| 165 | +
|
| 166 | + .heatmap-cell[data-level='4'] { |
| 167 | + background-color: var(--accent-color, var(--text-color)); |
| 168 | + } |
| 169 | +
|
| 170 | + .heatmap-cell:hover { |
| 171 | + outline: 1px solid var(--text-color); |
| 172 | + outline-offset: 1px; |
| 173 | + z-index: 10; |
| 174 | + } |
| 175 | +
|
| 176 | + .heatmap-container::-webkit-scrollbar { |
| 177 | + height: 4px; |
| 178 | + background: transparent; |
| 179 | + } |
| 180 | +
|
| 181 | + .heatmap-container::-webkit-scrollbar-thumb { |
| 182 | + background: color-mix(in srgb, var(--text-color) 20%, transparent); |
| 183 | + border-radius: 4px; |
| 184 | + } |
| 185 | +
|
| 186 | + .heatmap-container::-webkit-scrollbar-thumb:hover { |
| 187 | + background: color-mix(in srgb, var(--text-color) 40%, transparent); |
| 188 | + } |
| 189 | +</style> |
0 commit comments