diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5615ef..086fafa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,11 +20,11 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm run lint + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium - - name: Type check - run: pnpm run check + - name: Checks + run: pnpm run checks - name: Test run: pnpm run test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..26a57a8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,31 @@ +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v5 + with: + version: 10 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Deploy to Cloudflare Workers + run: pnpm exec wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 0000000..6c84822 --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,28 @@ +name: Preview Cleanup + +on: + pull_request: + types: [closed] + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v5 + with: + version: 10 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Delete preview alias + run: pnpm exec wrangler versions delete-alias pr-${{ github.event.pull_request.number }} + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..3955d88 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,54 @@ +name: Preview Deploy + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + preview: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v5 + with: + version: 10 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Deploy preview + run: pnpm exec wrangler versions upload --preview-alias pr-${{ github.event.pull_request.number }} + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Find existing comment + id: find-comment + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: Preview Deployed + + - name: Post preview link comment + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + **Preview Deployed!** + + Preview your changes at: https://pr-${{ github.event.pull_request.number }}-github-activity-dashboard.${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}.workers.dev + + This preview will be automatically deleted when the PR is closed. + edit-mode: replace diff --git a/package.json b/package.json index 54f6f38..0324294 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,14 @@ "build": "vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check . && eslint .", - "format": "prettier --write .", + "types:check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "types:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "eslint . && prettier --check .", + "format": "prettier --write . && eslint --fix .", + "format:check": "prettier --check .", "test:unit": "vitest", "test": "pnpm run test:unit --run", - "format:check": "prettier --check ." + "checks": "pnpm run types:check && pnpm run lint" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260317.1", @@ -38,7 +39,8 @@ "typescript-eslint": "^8.54.0", "vite": "^7.3.1", "vitest": "^4.1.0", - "vitest-browser-svelte": "^2.0.2" + "vitest-browser-svelte": "^2.0.2", + "wrangler": "^4.77.0" }, "dependencies": { "@sveltejs/adapter-cloudflare": "^7.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e1c802..3307fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@sveltejs/adapter-cloudflare': specifier: ^7.2.8 - version: 7.2.8(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.0)(vite@7.3.1(@types/node@22.19.15)))(svelte@5.54.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)))(wrangler@4.76.0(@cloudflare/workers-types@4.20260317.1)) + version: 7.2.8(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.0)(vite@7.3.1(@types/node@22.19.15)))(svelte@5.54.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)))(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1)) octokit: specifier: ^5.0.5 version: 5.0.5 @@ -81,6 +81,9 @@ importers: vitest-browser-svelte: specifier: ^2.0.2 version: 2.1.0(svelte@5.54.0)(vitest@4.1.0) + wrangler: + specifier: ^4.77.0 + version: 4.77.0(@cloudflare/workers-types@4.20260317.1) packages: @@ -1476,8 +1479,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - miniflare@4.20260317.1: - resolution: {integrity: sha512-A3csI1HXEIfqe3oscgpoRMHdYlkReQKPH/g5JE53vFSjoM6YIAOGAzyDNeYffwd9oQkPWDj9xER8+vpxei8klA==} + miniflare@4.20260317.2: + resolution: {integrity: sha512-qNL+yWAFMX6fr0pWU6Lx1vNpPobpnDSF1V8eunIckWvoIQl8y1oBjL2RJFEGY3un+l3f9gwW9dirDPP26usYJQ==} engines: {node: '>=18.0.0'} hasBin: true @@ -1889,9 +1892,9 @@ packages: resolution: {integrity: sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==} engines: {node: '>=12'} - wrangler@4.76.0: - resolution: {integrity: sha512-Wan+CU5a0tu4HIxGOrzjNbkmxCT27HUmzrMj6kc7aoAnjSLv50Ggcn2Ant7wNQrD6xW3g31phKupZJgTZ8wZfQ==} - engines: {node: '>=20.0.0'} + wrangler@4.77.0: + resolution: {integrity: sha512-E2Gm69+K++BFd3QvoWjC290RPQj1vDOUotA++sNHmtKPb7EP6C8Qv+1D5Ii73tfZtyNgakpqHlh8lBBbVWTKAQ==} + engines: {node: '>=20.3.0'} hasBin: true peerDependencies: '@cloudflare/workers-types': ^4.20260317.1 @@ -2576,12 +2579,12 @@ snapshots: dependencies: '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.0)(vite@7.3.1(@types/node@22.19.15)))(svelte@5.54.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)) - '@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.0)(vite@7.3.1(@types/node@22.19.15)))(svelte@5.54.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)))(wrangler@4.76.0(@cloudflare/workers-types@4.20260317.1))': + '@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.0)(vite@7.3.1(@types/node@22.19.15)))(svelte@5.54.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)))(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1))': dependencies: '@cloudflare/workers-types': 4.20260317.1 '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.0)(vite@7.3.1(@types/node@22.19.15)))(svelte@5.54.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)) worktop: 0.8.0-next.18 - wrangler: 4.76.0(@cloudflare/workers-types@4.20260317.1) + wrangler: 4.77.0(@cloudflare/workers-types@4.20260317.1) '@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.0)(vite@7.3.1(@types/node@22.19.15)))(svelte@5.54.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15))': dependencies: @@ -3175,7 +3178,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - miniflare@4.20260317.1: + miniflare@4.20260317.2: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 @@ -3580,13 +3583,13 @@ snapshots: mrmime: 2.0.1 regexparam: 3.0.0 - wrangler@4.76.0(@cloudflare/workers-types@4.20260317.1): + wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1) blake3-wasm: 2.1.5 esbuild: 0.27.3 - miniflare: 4.20260317.1 + miniflare: 4.20260317.2 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 workerd: 1.20260317.1 diff --git a/src/lib/components/ActivityHeatmap.svelte b/src/lib/components/ActivityHeatmap.svelte index 2624fcc..4e6f3d8 100644 --- a/src/lib/components/ActivityHeatmap.svelte +++ b/src/lib/components/ActivityHeatmap.svelte @@ -16,9 +16,7 @@ const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', '']; - // Tooltip state let tooltip = $state<{ text: string; x: number; y: number } | null>(null); - let containerRef = $state(null); function getColor(count: number, maxCount: number): string { if (count === 0) return 'var(--heatmap-0)'; @@ -29,72 +27,81 @@ return 'var(--heatmap-4)'; } - interface WeekColumn { - weekIndex: number; - days: { entry: HeatmapEntry; dayOfWeek: number }[]; + // Compute grid position for each cell and month labels from the flat entries list. + // Each entry gets a column (week index) and row (day of week) based on the + // number of days since the start date's week-start (Sunday). + interface Cell { + entry: HeatmapEntry; + col: number; + row: number; } - let weeks = $derived.by(() => { - if (entries.length === 0) return []; - - const cols: WeekColumn[] = []; - let currentWeek: WeekColumn = { weekIndex: 0, days: [] }; + let grid = $derived.by(() => { + if (entries.length === 0) + return { + cells: [] as Cell[], + monthLabels: [] as { text: string; col: number }[], + totalCols: 0 + }; + + // Find the Sunday on or before the first entry + const firstDate = new Date(entries[0].date + 'T00:00:00'); + const startTime = firstDate.getTime() - firstDate.getDay() * 86400000; + const msPerDay = 86400000; + + const cells: Cell[] = []; + const monthLabels: { text: string; col: number }[] = []; + let lastMonth = -1; + // First pass: collect all month boundaries + const rawLabels: { text: string; col: number }[] = []; for (const entry of entries) { const date = new Date(entry.date + 'T00:00:00'); - const dayOfWeek = date.getDay(); // 0=Sun, 6=Sat - - if (dayOfWeek === 0 && currentWeek.days.length > 0) { - cols.push(currentWeek); - currentWeek = { weekIndex: cols.length, days: [] }; + const daysSinceStart = Math.round((date.getTime() - startTime) / msPerDay); + const col = Math.floor(daysSinceStart / 7); + const row = daysSinceStart % 7; + + cells.push({ entry, col, row }); + + const month = date.getMonth(); + if (month !== lastMonth) { + rawLabels.push({ + text: date.toLocaleDateString('en-US', { month: 'short' }), + col + }); + lastMonth = month; } - - currentWeek.days.push({ entry, dayOfWeek }); } - if (currentWeek.days.length > 0) { - cols.push(currentWeek); - } - - return cols; - }); - - let maxCount = $derived(Math.max(1, ...entries.map((e) => e.count))); - - let monthLabels = $derived.by(() => { - const labels: { text: string; weekIndex: number }[] = []; - let lastMonth = -1; - - for (const week of weeks) { - for (const day of week.days) { - const date = new Date(day.entry.date + 'T00:00:00'); - const month = date.getMonth(); - if (month !== lastMonth) { - labels.push({ - text: date.toLocaleDateString('en-US', { month: 'short' }), - weekIndex: week.weekIndex - }); - lastMonth = month; - } - break; // Only check first day of each week + // Second pass: remove labels that are too close, preferring the later one + // (i.e., if Dec col=0 and Jan col=1, drop Dec and keep Jan) + for (let i = 0; i < rawLabels.length - 1; i++) { + if (rawLabels[i + 1].col - rawLabels[i].col < 3) { + // Skip the earlier label + continue; } + monthLabels.push(rawLabels[i]); + } + // Always include the last label + if (rawLabels.length > 0) { + monthLabels.push(rawLabels[rawLabels.length - 1]); } - return labels; + const totalCols = cells.length > 0 ? cells[cells.length - 1].col + 1 : 0; + return { cells, monthLabels, totalCols }; }); - let svgWidth = $derived(dayLabelWidth + weeks.length * cellStep + cellGap); + let maxCount = $derived(Math.max(1, ...entries.map((e) => e.count))); + let svgWidth = $derived(dayLabelWidth + grid.totalCols * cellStep + cellGap); let svgHeight = $derived(monthLabelHeight + 7 * cellStep + cellGap); function showTooltip(entry: HeatmapEntry, event: MouseEvent) { - if (!containerRef) return; - const rect = containerRef.getBoundingClientRect(); const count = entry.count; const label = count === 0 ? 'No activity' : `${count} activit${count === 1 ? 'y' : 'ies'}`; tooltip = { text: `${label} on ${formatDisplayDate(entry.date)}`, - x: event.clientX - rect.left, - y: event.clientY - rect.top - 8 + x: event.clientX, + y: event.clientY - 8 }; } @@ -103,47 +110,36 @@ } -
+
- - {#each monthLabels as label (label.weekIndex)} - + {#each grid.monthLabels as label (label.col)} + {label.text} {/each} - {#each dayLabels as label, i (i)} {label} {/each} - - {#each weeks as week (week.weekIndex)} - {#each week.days as day (day.entry.date)} - showTooltip(day.entry, e)} - onmouseleave={hideTooltip} - role="img" - aria-label={`${day.entry.count} activit${day.entry.count === 1 ? 'y' : 'ies'} on ${formatDisplayDate(day.entry.date)}`} - /> - {/each} + {#each grid.cells as cell (cell.entry.date)} + showTooltip(cell.entry, e)} + onmouseleave={hideTooltip} + role="img" + aria-label={`${cell.entry.count} activit${cell.entry.count === 1 ? 'y' : 'ies'} on ${formatDisplayDate(cell.entry.date)}`} + /> {/each} - {#if tooltip} -
- {tooltip.text} -
- {/if} -
Less @@ -155,10 +151,15 @@
+{#if tooltip} +
+ {tooltip.text} +
+{/if} + diff --git a/src/lib/utils.ts b/src/lib/utils.ts index cd73726..eb7dfda 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -108,14 +108,14 @@ export function buildHeatmapData(items: ActivityItem[], from: string, to: string counts.set(day, (counts.get(day) || 0) + 1); } - // Fill in all days in the range + // Fill in all days in the range using UTC to avoid DST issues const entries: HeatmapEntry[] = []; - const current = new Date(from); - const end = new Date(to); + const current = new Date(from + 'T00:00:00Z'); + const end = new Date(to + 'T00:00:00Z'); while (current <= end) { - const dateStr = formatDate(current); + const dateStr = current.toISOString().split('T')[0]; entries.push({ date: dateStr, count: counts.get(dateStr) || 0 }); - current.setDate(current.getDate() + 1); + current.setUTCDate(current.getUTCDate() + 1); } return entries; diff --git a/src/routes/results/+page.svelte b/src/routes/results/+page.svelte index 90867d9..01f94b0 100644 --- a/src/routes/results/+page.svelte +++ b/src/routes/results/+page.svelte @@ -3,6 +3,7 @@ import { ACTIVITY_TYPE_LABELS } from '$lib/types'; import { buildHeatmapData, + canonicalQueryString, computeSummaryStats, encodeQueryParams, formatRelativeTime, @@ -33,6 +34,43 @@ // Parse query from URL let queryParams = $derived(parseQueryParams(page.url.searchParams)); + // Client-side cache helpers + function getCacheKey(params: ReturnType): string { + return `dashboard:${canonicalQueryString(params!)}`; + } + + function loadFromSessionCache(params: ReturnType): boolean { + if (!params || typeof sessionStorage === 'undefined') return false; + try { + const cached = sessionStorage.getItem(getCacheKey(params)); + if (!cached) return false; + const data = JSON.parse(cached); + dashboard = data.dashboard; + fromCache = true; + errors = data.errors ?? []; + rateLimitInfo = data.rateLimitInfo; + return true; + } catch { + return false; + } + } + + function saveToSessionCache( + params: ReturnType, + data: { + dashboard: DashboardData; + errors: FetchError[]; + rateLimitInfo?: GitHubRateLimitInfo; + } + ) { + if (!params || typeof sessionStorage === 'undefined') return; + try { + sessionStorage.setItem(getCacheKey(params), JSON.stringify(data)); + } catch { + // sessionStorage full — non-fatal + } + } + // Fetch data on mount and when URL changes $effect(() => { const params = queryParams; @@ -42,12 +80,19 @@ return; } + const refresh = page.url.searchParams.get('refresh') === 'true'; + + // Check client-side cache first (unless refresh requested) + if (!refresh && loadFromSessionCache(params)) { + loading = false; + return; + } + loading = true; loadError = null; activeTab = 'all'; const queryString = encodeQueryParams(params); - const refresh = page.url.searchParams.get('refresh') === 'true'; let url = `/api/activity?${queryString}`; if (refresh) url += '&refresh=true'; @@ -74,8 +119,23 @@ fromCache = data.fromCache ?? false; errors = data.errors ?? []; rateLimitInfo = data.rateLimitInfo; + saveToSessionCache(params, { + dashboard: data.dashboard, + errors: data.errors ?? [], + rateLimitInfo: data.rateLimitInfo + }); } loading = false; + + // Strip refresh param from URL so it doesn't persist + if (refresh) { + const cleanUrl = new URL(page.url); + cleanUrl.searchParams.delete('refresh'); + goto(cleanUrl.pathname + '?' + cleanUrl.searchParams.toString(), { + replaceState: true, + keepFocus: true + }); + } }) .catch((err) => { loadError = err?.message || 'Failed to fetch activity data.'; diff --git a/wrangler.jsonc b/wrangler.jsonc index 62e5ea3..f04dcc9 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -9,7 +9,8 @@ "kv_namespaces": [ { "binding": "CACHE", - "id": "" + "id": "333b7b5cf0b14c1986dba3bb17489491", + "preview_id": "394556b512d04b5794ea208739b6d79a" } ] }