diff --git a/package.json b/package.json
index 0324294..f3ceece 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,8 @@
},
"dependencies": {
"@sveltejs/adapter-cloudflare": "^7.2.8",
- "octokit": "^5.0.5"
+ "nanostores": "^1.2.0",
+ "octokit": "^5.0.5",
+ "xlsx": "^0.18.5"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3307fc0..7e155f5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,9 +11,15 @@ importers:
'@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.77.0(@cloudflare/workers-types@4.20260317.1))
+ nanostores:
+ specifier: ^1.2.0
+ version: 1.2.0
octokit:
specifier: ^5.0.5
version: 5.0.5
+ xlsx:
+ specifier: ^0.18.5
+ version: 0.18.5
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20260317.1
@@ -1123,6 +1129,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ adler-32@1.3.1:
+ resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+ engines: {node: '>=0.8'}
+
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
@@ -1172,6 +1182,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ cfb@1.2.2:
+ resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
+ engines: {node: '>=0.8'}
+
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -1188,6 +1202,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ codepage@1.15.0:
+ resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+ engines: {node: '>=0.8'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1209,6 +1227,11 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
+ crc-32@1.2.2:
+ resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1372,6 +1395,10 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+ frac@1.1.2:
+ resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+ engines: {node: '>=0.8'}
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1507,6 +1534,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanostores@1.2.0:
+ resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==}
+ engines: {node: ^20.0.0 || >=22.0.0}
+
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -1671,6 +1702,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ ssf@0.11.2:
+ resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
+ engines: {node: '>=0.8'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -1879,10 +1914,18 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ wmf@1.0.2:
+ resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+ engines: {node: '>=0.8'}
+
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ word@0.3.0:
+ resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
+ engines: {node: '>=0.8'}
+
workerd@1.20260317.1:
resolution: {integrity: sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==}
engines: {node: '>=16'}
@@ -1926,6 +1969,11 @@ packages:
utf-8-validate:
optional: true
+ xlsx@0.18.5:
+ resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
@@ -2816,6 +2864,8 @@ snapshots:
acorn@8.16.0: {}
+ adler-32@1.3.1: {}
+
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -2856,6 +2906,11 @@ snapshots:
callsites@3.1.0: {}
+ cfb@1.2.2:
+ dependencies:
+ adler-32: 1.3.1
+ crc-32: 1.2.2
+
chai@6.2.2: {}
chalk@4.1.2:
@@ -2869,6 +2924,8 @@ snapshots:
clsx@2.1.1: {}
+ codepage@1.15.0: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -2883,6 +2940,8 @@ snapshots:
cookie@1.1.1: {}
+ crc-32@1.2.2: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -3098,6 +3157,8 @@ snapshots:
flatted@3.4.2: {}
+ frac@1.1.2: {}
+
fsevents@2.3.2:
optional: true
@@ -3206,6 +3267,8 @@ snapshots:
nanoid@3.3.11: {}
+ nanostores@1.2.0: {}
+
natural-compare@1.4.0: {}
obug@2.1.1: {}
@@ -3396,6 +3459,10 @@ snapshots:
source-map-js@1.2.1: {}
+ ssf@0.11.2:
+ dependencies:
+ frac: 1.1.2
+
stackback@0.0.2: {}
std-env@4.0.0: {}
@@ -3568,8 +3635,12 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ wmf@1.0.2: {}
+
word-wrap@1.2.5: {}
+ word@0.3.0: {}
+
workerd@1.20260317.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260317.1
@@ -3604,6 +3675,16 @@ snapshots:
ws@8.19.0: {}
+ xlsx@0.18.5:
+ dependencies:
+ adler-32: 1.3.1
+ cfb: 1.2.2
+ codepage: 1.15.0
+ crc-32: 1.2.2
+ ssf: 0.11.2
+ wmf: 1.0.2
+ word: 0.3.0
+
yaml@1.10.2: {}
yocto-queue@0.1.0: {}
diff --git a/src/lib/components/ActivityHeatmap.svelte b/src/lib/components/ActivityHeatmap.svelte
index 4e6f3d8..628051a 100644
--- a/src/lib/components/ActivityHeatmap.svelte
+++ b/src/lib/components/ActivityHeatmap.svelte
@@ -1,6 +1,9 @@
@@ -131,11 +85,21 @@
width={cellSize}
height={cellSize}
rx="2"
- fill={getColor(cell.entry.count, maxCount)}
+ fill={getHeatmapColor(cell.entry.count, maxCount)}
+ opacity={hasFilter && !isSelected(cell.entry.date) ? 0.3 : 1}
onmouseenter={(e) => showTooltip(cell.entry, e)}
onmouseleave={hideTooltip}
- role="img"
+ onclick={() => handleCellClick(cell.entry)}
+ role="button"
+ tabindex={0}
+ onkeydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleCellClick(cell.entry);
+ }
+ }}
aria-label={`${cell.entry.count} activit${cell.entry.count === 1 ? 'y' : 'ies'} on ${formatDisplayDate(cell.entry.date)}`}
+ aria-pressed={isSelected(cell.entry.date)}
/>
{/each}
diff --git a/src/lib/components/ActivityList.svelte b/src/lib/components/ActivityList.svelte
index e8f99c5..cc1ca12 100644
--- a/src/lib/components/ActivityList.svelte
+++ b/src/lib/components/ActivityList.svelte
@@ -1,329 +1,37 @@
-
-
+
-
- {#if filteredItems.length === 0}
+ {#if items.value.length === 0}
No activity matches your filters.
{:else}
- {#each groupedSections as section (section.category)}
-
-
- {#if !collapsedSections[section.category]}
-
- {#each section.items as item (item.id)}
- {@const typeLabels = getTypeLabels(item.labels)}
- {@const otherLabels = getNonTypeLabels(item.labels)}
- -
-
- {ACTIVITY_TYPE_SHORT_LABELS[item.type]}
-
-
-
-
- {item.repo}
- {#if typeLabels.length > 0}
- {#each typeLabels as tl (tl)}
- {tl}
- {/each}
- {/if}
- {formatDisplayDate(item.date)}
-
- {#if otherLabels.length > 0}
-
- {#each otherLabels as label (label)}
- {label}
- {/each}
-
- {/if}
-
-
- {/each}
-
- {/if}
-
+ {#each sections.value as section (section.category)}
+
toggleSection(section.category)}
+ />
{/each}
-
- {#if totalPages > 1}
-
- {/if}
+
{/if}
@@ -332,275 +40,9 @@
margin-top: 12px;
}
- /* Toolbar */
- .toolbar {
- display: flex;
- flex-direction: column;
- gap: 20px;
- margin-bottom: 20px;
- }
-
- .search-box input {
- width: 100%;
- padding: 7px 10px;
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- font-size: 13px;
- }
-
- .search-box input:focus {
- border-color: var(--color-link);
- outline: 2px solid color-mix(in srgb, var(--color-link) 20%, transparent);
- }
-
- .filter-groups {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
-
- .filter-group {
- display: flex;
- align-items: baseline;
- gap: 8px;
- }
-
- .filter-label {
- font-size: 11px;
- font-weight: 600;
- color: var(--color-text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.03em;
- min-width: 40px;
- flex-shrink: 0;
- }
-
- .filter-chips {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- }
-
- .chip {
- padding: 2px 8px;
- background: var(--color-bg-secondary);
- border: 1px solid var(--color-border);
- border-radius: 12px;
- font-size: 12px;
- color: var(--color-text-secondary);
- display: inline-flex;
- align-items: center;
- gap: 4px;
- }
-
- .chip:hover {
- background: var(--color-border);
- color: var(--color-text);
- }
-
- .chip.active {
- background: color-mix(in srgb, var(--color-link) 10%, transparent);
- border-color: var(--color-link);
- color: var(--color-link);
- font-weight: 600;
- }
-
- .filter-status {
- display: flex;
- align-items: center;
- gap: 10px;
- font-size: 12px;
- }
-
- .result-count {
- color: var(--color-text-secondary);
- }
-
- .clear-btn {
- background: none;
- border: none;
- color: var(--color-link);
- font-size: 12px;
- padding: 0;
- text-decoration: underline;
- }
-
- /* Category sections */
- .category-section {
- margin-bottom: 20px;
- }
-
- .category-header {
- font-size: 13px;
- font-weight: 600;
- color: var(--color-text-secondary);
- padding: 8px 0;
- border-bottom: 1px solid var(--color-border);
- display: flex;
- align-items: center;
- gap: 6px;
- width: 100%;
- background: none;
- border-top: none;
- border-left: none;
- border-right: none;
- text-align: left;
- }
-
- .category-header:hover {
- color: var(--color-text);
- }
-
- .collapse-icon {
- width: 18px;
- height: 18px;
- flex-shrink: 0;
- transition: transform 0.15s ease;
- }
-
- .collapse-icon.collapsed {
- transform: rotate(-90deg);
- }
-
- .category-icon {
- font-size: 14px;
- }
-
- .category-count {
- font-size: 11px;
- padding: 1px 6px;
- background: var(--color-bg-secondary);
- border-radius: 10px;
- font-weight: 600;
- margin-left: 2px;
- }
-
.empty {
color: var(--color-text-secondary);
padding: 24px 0;
text-align: center;
}
-
- /* Items */
- .items {
- list-style: none;
- padding: 0;
- }
-
- .item {
- display: flex;
- gap: 10px;
- padding: 10px 0;
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
- }
-
- .item:last-child {
- border-bottom: none;
- }
-
- .activity-type-label {
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.03em;
- white-space: nowrap;
- flex-shrink: 0;
- width: 62px;
- padding-top: 3px;
- }
-
- .item-content {
- flex: 1;
- min-width: 0;
- }
-
- .item-title-row {
- display: flex;
- align-items: flex-start;
- gap: 6px;
- margin-bottom: 4px;
- }
-
- .item-title {
- font-size: 14px;
- font-weight: 600;
- }
-
- .item-number {
- color: var(--color-text-secondary);
- font-weight: 400;
- }
-
- .item-meta {
- display: flex;
- gap: 10px;
- font-size: 12px;
- color: var(--color-text-secondary);
- flex-wrap: wrap;
- align-items: center;
- }
-
- .item-repo {
- font-family: var(--font-mono);
- }
-
- .type-badge {
- font-size: 11px;
- font-weight: 600;
- padding: 0 6px;
- border-radius: 10px;
- background: color-mix(in srgb, var(--color-link) 10%, transparent);
- color: var(--color-link);
- text-transform: capitalize;
- }
-
- /* Labels */
- .item-labels {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- margin-top: 4px;
- }
-
- .label-badge {
- font-size: 11px;
- padding: 0 6px;
- border: 1px solid var(--color-border);
- border-radius: 10px;
- color: var(--color-text-secondary);
- background: var(--color-bg-secondary);
- }
-
- /* Pagination */
- .pagination {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12px;
- padding: 16px 0;
- border-top: 1px solid var(--color-border);
- }
-
- .page-btn {
- padding: 5px 12px;
- background: var(--color-bg-secondary);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- font-size: 13px;
- color: var(--color-text);
- }
-
- .page-btn:disabled {
- opacity: 0.4;
- cursor: default;
- }
-
- .page-btn:not(:disabled):hover {
- background: var(--color-border);
- }
-
- .page-info {
- font-size: 13px;
- color: var(--color-text-secondary);
- }
diff --git a/src/lib/components/QueryForm.svelte b/src/lib/components/QueryForm.svelte
index e3f6174..182a735 100644
--- a/src/lib/components/QueryForm.svelte
+++ b/src/lib/components/QueryForm.svelte
@@ -1,19 +1,32 @@
diff --git a/src/lib/components/SummaryStats.svelte b/src/lib/components/SummaryStats.svelte
index 1a6c480..35a4c40 100644
--- a/src/lib/components/SummaryStats.svelte
+++ b/src/lib/components/SummaryStats.svelte
@@ -48,6 +48,8 @@
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
+ display: flex;
+ flex-direction: column;
}
.stat-value {
@@ -64,5 +66,6 @@
.stat-label {
font-size: 12px;
color: var(--color-text-secondary);
+ margin-top: auto;
}
diff --git a/src/lib/components/activity-list/ActivityItemCard.svelte b/src/lib/components/activity-list/ActivityItemCard.svelte
new file mode 100644
index 0000000..9ef1c3e
--- /dev/null
+++ b/src/lib/components/activity-list/ActivityItemCard.svelte
@@ -0,0 +1,206 @@
+
+
+
+
+
+ {ACTIVITY_TYPE_SHORT_LABELS[item.type]}
+
+ {formatDisplayDate(item.date)}
+
+
+
+
+ {item.repo}
+
+ {#if typeLabel || (item.assignees && item.assignees.length > 0)}
+
+ {#if typeLabel}
+
{typeLabel}
+ {/if}
+ {#if item.assignees && item.assignees.length > 0}
+
+
+ {item.assignees.join(', ')}
+
+ {/if}
+
+ {/if}
+ {#if otherLabels.length > 0}
+
+ {#each otherLabels as label (label)}
+ {label}
+ {/each}
+
+ {/if}
+
+
+
+
diff --git a/src/lib/components/activity-list/CategorySection.svelte b/src/lib/components/activity-list/CategorySection.svelte
new file mode 100644
index 0000000..7bd7c7f
--- /dev/null
+++ b/src/lib/components/activity-list/CategorySection.svelte
@@ -0,0 +1,104 @@
+
+
+
+
+ {#if !collapsed}
+
+ {#each items as item (item.id)}
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/activity-list/Pagination.svelte b/src/lib/components/activity-list/Pagination.svelte
new file mode 100644
index 0000000..9e42c37
--- /dev/null
+++ b/src/lib/components/activity-list/Pagination.svelte
@@ -0,0 +1,65 @@
+
+
+{#if total.value > 1}
+
+{/if}
+
+
diff --git a/src/lib/components/toolbar/ChipFilterGroup.svelte b/src/lib/components/toolbar/ChipFilterGroup.svelte
new file mode 100644
index 0000000..61a7379
--- /dev/null
+++ b/src/lib/components/toolbar/ChipFilterGroup.svelte
@@ -0,0 +1,77 @@
+
+
+{#if options.length > 0}
+
+
{label}
+
+ {#each options as option (option)}
+
+ {/each}
+
+
+{/if}
+
+
diff --git a/src/lib/components/toolbar/DateRangeFilter.svelte b/src/lib/components/toolbar/DateRangeFilter.svelte
new file mode 100644
index 0000000..dd28581
--- /dev/null
+++ b/src/lib/components/toolbar/DateRangeFilter.svelte
@@ -0,0 +1,69 @@
+
+
+
+
+
diff --git a/src/lib/components/toolbar/FilterGroups.svelte b/src/lib/components/toolbar/FilterGroups.svelte
new file mode 100644
index 0000000..1528b4b
--- /dev/null
+++ b/src/lib/components/toolbar/FilterGroups.svelte
@@ -0,0 +1,51 @@
+
+
+
+ {#if repoOptions.length > 1}
+ r.split('/')[1]}
+ />
+ {/if}
+
+ {#if labelsAll.value.length > 0}
+
+ {/if}
+
+
+
+
+
diff --git a/src/lib/components/toolbar/FilterStatus.svelte b/src/lib/components/toolbar/FilterStatus.svelte
new file mode 100644
index 0000000..f6c3620
--- /dev/null
+++ b/src/lib/components/toolbar/FilterStatus.svelte
@@ -0,0 +1,117 @@
+
+
+
+ {#if active.value}
+
{items.value.length} result{items.value.length !== 1 ? 's' : ''}
+
+
+ {/if}
+ {#if items.value.length > 0}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/toolbar/SearchBar.svelte b/src/lib/components/toolbar/SearchBar.svelte
new file mode 100644
index 0000000..cdbf28a
--- /dev/null
+++ b/src/lib/components/toolbar/SearchBar.svelte
@@ -0,0 +1,31 @@
+
+
+
+ searchQuery.set((e.target as HTMLInputElement).value)}
+ placeholder="Search by title, repo, or #number..."
+ aria-label="Search activity items"
+ />
+
+
+
diff --git a/src/lib/components/toolbar/Toolbar.svelte b/src/lib/components/toolbar/Toolbar.svelte
new file mode 100644
index 0000000..ee7f7a4
--- /dev/null
+++ b/src/lib/components/toolbar/Toolbar.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/server/github.ts b/src/lib/server/github.ts
index 1db2912..6cf72ce 100644
--- a/src/lib/server/github.ts
+++ b/src/lib/server/github.ts
@@ -52,6 +52,14 @@ function buildSearchQuery(
}
}
+/**
+ * Extract assignee login names from an issue/PR search result.
+ */
+function extractAssignees(assignees?: { login?: string }[] | null): string[] {
+ if (!assignees) return [];
+ return assignees.map((a) => a.login).filter((l): l is string => !!l);
+}
+
/**
* Extract "owner/repo" from a full GitHub repo URL or API URL.
*/
@@ -97,7 +105,8 @@ async function fetchIssuesOpened(
url: issue.html_url,
state: issue.state,
labels: issue.labels.map((l) => (typeof l === 'string' ? l : l.name || '')),
- number: issue.number
+ number: issue.number,
+ assignees: extractAssignees(issue.assignees)
});
}
@@ -123,7 +132,8 @@ async function fetchIssuesOpened(
url: issue.html_url,
state: issue.state,
labels: issue.labels.map((l) => (typeof l === 'string' ? l : l.name || '')),
- number: issue.number
+ number: issue.number,
+ assignees: extractAssignees(issue.assignees)
});
}
}
@@ -167,7 +177,8 @@ async function fetchIssuesClosed(
url: issue.html_url,
state: 'closed',
labels: issue.labels.map((l) => (typeof l === 'string' ? l : l.name || '')),
- number: issue.number
+ number: issue.number,
+ assignees: extractAssignees(issue.assignees)
});
}
} catch (err) {
@@ -224,7 +235,8 @@ async function fetchIssueComments(
url: comment.html_url,
state: issue.state,
labels: issue.labels.map((l) => (typeof l === 'string' ? l : l.name || '')),
- number: issue.number
+ number: issue.number,
+ assignees: extractAssignees(issue.assignees)
});
}
} catch (commentErr) {
@@ -264,7 +276,8 @@ async function fetchPrsOpened(
url: pr.html_url,
state: pr.pull_request?.merged_at ? 'merged' : pr.state,
labels: pr.labels.map((l) => (typeof l === 'string' ? l : l.name || '')),
- number: pr.number
+ number: pr.number,
+ assignees: extractAssignees(pr.assignees)
});
}
} catch (err) {
@@ -317,7 +330,8 @@ async function fetchPrReviews(
url: review.html_url,
state: pr.pull_request?.merged_at ? 'merged' : pr.state,
labels: pr.labels.map((l) => (typeof l === 'string' ? l : l.name || '')),
- number: pr.number
+ number: pr.number,
+ assignees: extractAssignees(pr.assignees)
});
}
} catch (reviewErr) {
@@ -357,7 +371,8 @@ async function fetchPrsMerged(
url: pr.html_url,
state: 'merged',
labels: pr.labels.map((l) => (typeof l === 'string' ? l : l.name || '')),
- number: pr.number
+ number: pr.number,
+ assignees: extractAssignees(pr.assignees)
});
}
} catch (err) {
@@ -510,13 +525,11 @@ export async function fetchGitHubActivity(params: QueryParams): Promise 0.6) return [];
+ const assignees = [user];
+ if (Math.random() > 0.5) {
+ assignees.push(others[Math.floor(Math.random() * others.length)]);
+ }
+ return assignees;
+}
+
function generateLabels(): string[] {
const allLabels = ['bug', 'enhancement', 'documentation', 'good first issue', 'help wanted'];
if (Math.random() > 0.6) return [];
diff --git a/src/lib/stores/activity-filter-store.spec.ts b/src/lib/stores/activity-filter-store.spec.ts
new file mode 100644
index 0000000..559aced
--- /dev/null
+++ b/src/lib/stores/activity-filter-store.spec.ts
@@ -0,0 +1,218 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import type { DashboardData } from '$lib/types';
+import { dashboard, activeTab } from './dashboard-store';
+import { filterFrom, filterTo } from './date-filter';
+import {
+ searchQuery,
+ selectedRepos,
+ selectedLabels,
+ currentPage,
+ collapsedSections,
+ filteredItems,
+ totalPages,
+ pagedItems,
+ groupedSections,
+ allLabels,
+ hasActiveFilters,
+ toggleRepo,
+ toggleLabel,
+ clearFilters,
+ toggleSection,
+ setPage
+} from './activity-filter-store';
+
+const mockDashboard: DashboardData = {
+ query: {
+ user: 'octocat',
+ repos: ['org/alpha', 'org/beta'],
+ from: '2025-01-01',
+ to: '2025-03-20',
+ types: ['issues_opened', 'prs_opened']
+ },
+ items: [
+ {
+ id: '1',
+ type: 'issues_opened',
+ title: 'Fix login bug',
+ repo: 'org/alpha',
+ date: '2025-02-01T00:00:00Z',
+ url: '',
+ labels: ['bug', 'urgent']
+ },
+ {
+ id: '2',
+ type: 'prs_opened',
+ title: 'Add dashboard',
+ repo: 'org/beta',
+ date: '2025-02-15T00:00:00Z',
+ url: '',
+ labels: ['feature']
+ },
+ {
+ id: '3',
+ type: 'issues_opened',
+ title: 'Update docs',
+ repo: 'org/alpha',
+ date: '2025-03-01T00:00:00Z',
+ url: '',
+ labels: []
+ }
+ ],
+ fetchedAt: '2025-03-20T12:00:00Z'
+};
+
+describe('activity-filter-store', () => {
+ beforeEach(() => {
+ // Reset all atoms
+ dashboard.set(null);
+ activeTab.set('all');
+ searchQuery.set('');
+ selectedRepos.set([]);
+ selectedLabels.set([]);
+ currentPage.set(1);
+ collapsedSections.set({});
+ filterFrom.set(null);
+ filterTo.set(null);
+ });
+
+ it('returns empty filtered items when no dashboard', () => {
+ expect(filteredItems.get()).toHaveLength(0);
+ });
+
+ it('returns all items when no filters active', () => {
+ dashboard.set(mockDashboard);
+ expect(filteredItems.get()).toHaveLength(3);
+ });
+
+ it('filters by activity type tab', () => {
+ dashboard.set(mockDashboard);
+ activeTab.set('prs_opened');
+ expect(filteredItems.get()).toHaveLength(1);
+ expect(filteredItems.get()[0].id).toBe('2');
+ });
+
+ it('filters by search query', () => {
+ dashboard.set(mockDashboard);
+ searchQuery.set('login');
+ expect(filteredItems.get()).toHaveLength(1);
+ expect(filteredItems.get()[0].id).toBe('1');
+ });
+
+ it('filters by selected repos', () => {
+ dashboard.set(mockDashboard);
+ selectedRepos.set(['org/beta']);
+ expect(filteredItems.get()).toHaveLength(1);
+ expect(filteredItems.get()[0].id).toBe('2');
+ });
+
+ it('filters by selected labels', () => {
+ dashboard.set(mockDashboard);
+ selectedLabels.set(['bug']);
+ expect(filteredItems.get()).toHaveLength(1);
+ expect(filteredItems.get()[0].id).toBe('1');
+ });
+
+ it('filters by date range', () => {
+ dashboard.set(mockDashboard);
+ filterFrom.set('2025-02-10');
+ filterTo.set('2025-02-20');
+ expect(filteredItems.get()).toHaveLength(1);
+ expect(filteredItems.get()[0].id).toBe('2');
+ });
+
+ it('chains multiple filters', () => {
+ dashboard.set(mockDashboard);
+ activeTab.set('issues_opened');
+ selectedRepos.set(['org/alpha']);
+ searchQuery.set('docs');
+ expect(filteredItems.get()).toHaveLength(1);
+ expect(filteredItems.get()[0].id).toBe('3');
+ });
+
+ it('computes totalPages correctly', () => {
+ dashboard.set(mockDashboard);
+ expect(totalPages.get()).toBe(1);
+ });
+
+ it('computes pagedItems as a slice', () => {
+ dashboard.set(mockDashboard);
+ expect(pagedItems.get()).toHaveLength(3);
+ });
+
+ it('groups items by category', () => {
+ dashboard.set(mockDashboard);
+ const sections = groupedSections.get();
+ expect(sections).toHaveLength(2);
+ expect(sections[0].category).toBe('issues');
+ expect(sections[0].items).toHaveLength(2);
+ expect(sections[1].category).toBe('pull_requests');
+ expect(sections[1].items).toHaveLength(1);
+ });
+
+ it('computes allLabels from dashboard items', () => {
+ dashboard.set(mockDashboard);
+ const labels = allLabels.get();
+ expect(labels).toEqual(['bug', 'feature', 'urgent']);
+ });
+
+ it('detects hasActiveFilters', () => {
+ dashboard.set(mockDashboard);
+ expect(hasActiveFilters.get()).toBe(false);
+
+ searchQuery.set('test');
+ expect(hasActiveFilters.get()).toBe(true);
+
+ searchQuery.set('');
+ selectedRepos.set(['org/alpha']);
+ expect(hasActiveFilters.get()).toBe(true);
+ });
+
+ it('toggleRepo adds and removes repos', () => {
+ toggleRepo('org/alpha');
+ expect(selectedRepos.get()).toEqual(['org/alpha']);
+
+ toggleRepo('org/beta');
+ expect(selectedRepos.get()).toEqual(['org/alpha', 'org/beta']);
+
+ toggleRepo('org/alpha');
+ expect(selectedRepos.get()).toEqual(['org/beta']);
+ });
+
+ it('toggleLabel adds and removes labels', () => {
+ toggleLabel('bug');
+ expect(selectedLabels.get()).toEqual(['bug']);
+
+ toggleLabel('bug');
+ expect(selectedLabels.get()).toEqual([]);
+ });
+
+ it('clearFilters resets all filter state', () => {
+ searchQuery.set('test');
+ selectedRepos.set(['org/alpha']);
+ selectedLabels.set(['bug']);
+ filterFrom.set('2025-01-01');
+
+ clearFilters();
+
+ expect(searchQuery.get()).toBe('');
+ expect(selectedRepos.get()).toEqual([]);
+ expect(selectedLabels.get()).toEqual([]);
+ expect(filterFrom.get()).toBeNull();
+ expect(filterTo.get()).toBeNull();
+ });
+
+ it('toggleSection toggles collapsed state', () => {
+ expect(collapsedSections.get()).toEqual({});
+
+ toggleSection('issues');
+ expect(collapsedSections.get()).toEqual({ issues: true });
+
+ toggleSection('issues');
+ expect(collapsedSections.get()).toEqual({ issues: false });
+ });
+
+ it('setPage updates currentPage', () => {
+ setPage(3);
+ expect(currentPage.get()).toBe(3);
+ });
+});
diff --git a/src/lib/stores/activity-filter-store.ts b/src/lib/stores/activity-filter-store.ts
new file mode 100644
index 0000000..edbc85b
--- /dev/null
+++ b/src/lib/stores/activity-filter-store.ts
@@ -0,0 +1,145 @@
+import { atom, computed } from 'nanostores';
+import type { ActivityItem, ActivityType, GroupedSection } from '$lib/types';
+import {
+ filterByDateRange,
+ filterByRepos,
+ filterByLabels,
+ filterBySearch,
+ filterByActivityType,
+ groupByCategory
+} from '$lib/utils/filters';
+import { dashboard, activeTab } from './dashboard-store';
+import { effectiveDateFilter, clearDateFilter } from './date-filter';
+
+const PAGE_SIZE = 100;
+
+// =======================================================================
+// Filter atoms
+// =======================================================================
+
+export const searchQuery = atom('');
+export const selectedRepos = atom([]);
+export const selectedLabels = atom([]);
+export const currentPage = atom(1);
+export const collapsedSections = atom>({});
+
+// =======================================================================
+// Computed stores
+// =======================================================================
+
+// Computed: items filtered only by activity type tab (for heatmap — no date/repo/label/search)
+export const tabFilteredItems = computed([dashboard, activeTab], (dash, tab) => {
+ if (!dash) return [];
+ return filterByActivityType(dash.items, tab);
+});
+
+// Computed: filtered items (chains all filters)
+export const filteredItems = computed(
+ [dashboard, activeTab, effectiveDateFilter, selectedRepos, selectedLabels, searchQuery],
+ (dash, tab, dateFilter, repos, labels, query) => {
+ if (!dash) return [];
+ let items: ActivityItem[] = dash.items;
+ items = filterByActivityType(items, tab);
+ if (dateFilter) {
+ items = filterByDateRange(items, dateFilter.from, dateFilter.to);
+ }
+ items = filterByRepos(items, repos);
+ items = filterByLabels(items, labels);
+ items = filterBySearch(items, query);
+ return items;
+ }
+);
+
+// Computed: pagination
+export const totalPages = computed(filteredItems, (items) =>
+ Math.max(1, Math.ceil(items.length / PAGE_SIZE))
+);
+
+export const pagedItems = computed([filteredItems, currentPage], (items, page) =>
+ items.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
+);
+
+// Computed: grouped sections for display
+export const groupedSections = computed(pagedItems, (items): GroupedSection[] =>
+ groupByCategory(items)
+);
+
+// Computed: all unique labels from dashboard items
+export const allLabels = computed(dashboard, (dash) => {
+ if (!dash) return [];
+ return [...new Set(dash.items.flatMap((i) => i.labels ?? []))].filter(Boolean).sort();
+});
+
+// Computed: whether any filter is active
+export const hasActiveFilters = computed(
+ [searchQuery, selectedRepos, selectedLabels, effectiveDateFilter],
+ (query, repos, labels, dateFilter) =>
+ query.trim() !== '' || repos.length > 0 || labels.length > 0 || dateFilter !== null
+);
+
+// =======================================================================
+// Actions
+// =======================================================================
+
+export function toggleRepo(repo: string) {
+ const current = selectedRepos.get();
+ selectedRepos.set(
+ current.includes(repo) ? current.filter((r) => r !== repo) : [...current, repo]
+ );
+}
+
+export function toggleLabel(label: string) {
+ const current = selectedLabels.get();
+ selectedLabels.set(
+ current.includes(label) ? current.filter((l) => l !== label) : [...current, label]
+ );
+}
+
+export function clearFilters() {
+ searchQuery.set('');
+ selectedRepos.set([]);
+ selectedLabels.set([]);
+ clearDateFilter();
+}
+
+export function setActiveTab(tab: 'all' | ActivityType) {
+ activeTab.set(tab);
+}
+
+export function toggleSection(category: string) {
+ const current = collapsedSections.get();
+ collapsedSections.set({ ...current, [category]: !current[category] });
+}
+
+export function setPage(page: number) {
+ currentPage.set(page);
+}
+
+// =======================================================================
+// Auto-reset currentPage to 1 when any filter changes
+// =======================================================================
+
+let initialized = false;
+function setupAutoReset() {
+ if (initialized) return;
+ initialized = true;
+
+ const resetPage = () => {
+ currentPage.set(1);
+ collapsedSections.set({});
+ };
+
+ // Listen to filter changes, but skip the first emission
+ for (const store of [searchQuery, selectedRepos, selectedLabels, effectiveDateFilter]) {
+ let first = true;
+ store.listen(() => {
+ if (first) {
+ first = false;
+ return;
+ }
+ resetPage();
+ });
+ }
+}
+
+setupAutoReset();
diff --git a/src/lib/stores/dashboard-store.spec.ts b/src/lib/stores/dashboard-store.spec.ts
new file mode 100644
index 0000000..a79da39
--- /dev/null
+++ b/src/lib/stores/dashboard-store.spec.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import type { DashboardData, QueryParams } from '$lib/types';
+import {
+ dashboard,
+ loading,
+ loadError,
+ fromCache,
+ errors,
+ rateLimitInfo,
+ activeTab,
+ fetchDashboard,
+ resetDashboard
+} from './dashboard-store';
+
+const mockParams: QueryParams = {
+ user: 'octocat',
+ repos: ['org/repo'],
+ from: '2025-01-01',
+ to: '2025-03-20',
+ types: ['issues_opened']
+};
+
+const mockDashboardData: DashboardData = {
+ query: mockParams,
+ items: [
+ {
+ id: '1',
+ type: 'issues_opened',
+ title: 'Test',
+ repo: 'org/repo',
+ date: '2025-02-01T00:00:00Z',
+ url: 'https://github.com'
+ }
+ ],
+ fetchedAt: '2025-03-20T12:00:00Z'
+};
+
+describe('dashboard-store', () => {
+ let storage: Map;
+
+ beforeEach(() => {
+ resetDashboard();
+ storage = new Map();
+ vi.stubGlobal('sessionStorage', {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key),
+ clear: () => storage.clear(),
+ get length() {
+ return storage.size;
+ },
+ key: (index: number) => [...storage.keys()][index] ?? null
+ });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() =>
+ Promise.resolve({
+ json: () =>
+ Promise.resolve({
+ dashboard: mockDashboardData,
+ fromCache: false,
+ errors: [],
+ rateLimitInfo: { remaining: 50, limit: 60, resetAt: '2025-03-20T13:00:00Z' }
+ })
+ })
+ )
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('starts in loading state after reset', () => {
+ expect(loading.get()).toBe(true);
+ expect(dashboard.get()).toBeNull();
+ expect(loadError.get()).toBeNull();
+ });
+
+ it('fetches data and updates atoms', async () => {
+ await fetchDashboard(mockParams);
+
+ expect(loading.get()).toBe(false);
+ expect(dashboard.get()).toEqual(mockDashboardData);
+ expect(fromCache.get()).toBe(false);
+ expect(errors.get()).toEqual([]);
+ expect(rateLimitInfo.get()).toEqual({
+ remaining: 50,
+ limit: 60,
+ resetAt: '2025-03-20T13:00:00Z'
+ });
+ });
+
+ it('sets loadError on API error response', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() =>
+ Promise.resolve({
+ json: () => Promise.resolve({ error: 'Rate limited' })
+ })
+ )
+ );
+
+ await fetchDashboard(mockParams);
+
+ expect(loading.get()).toBe(false);
+ expect(loadError.get()).toBe('Rate limited');
+ expect(dashboard.get()).toBeNull();
+ });
+
+ it('sets loadError on fetch failure', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.reject(new Error('Network error')))
+ );
+
+ await fetchDashboard(mockParams);
+
+ expect(loading.get()).toBe(false);
+ expect(loadError.get()).toBe('Network error');
+ });
+
+ it('reads from session cache on second call', async () => {
+ await fetchDashboard(mockParams);
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
+
+ // Reset state but keep cache
+ resetDashboard();
+
+ await fetchDashboard(mockParams);
+ // Should not call fetch again — cache hit
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
+ expect(dashboard.get()).toEqual(mockDashboardData);
+ expect(fromCache.get()).toBe(true);
+ });
+
+ it('bypasses cache when refresh is true', async () => {
+ await fetchDashboard(mockParams);
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
+
+ resetDashboard();
+
+ const result = await fetchDashboard(mockParams, { refresh: true });
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
+ expect(result.refreshed).toBe(true);
+ });
+
+ it('writes to session cache after fetch', async () => {
+ await fetchDashboard(mockParams);
+ // Verify cache was written
+ const keys = [...storage.keys()];
+ expect(keys.some((k) => k.startsWith('dashboard:'))).toBe(true);
+ });
+
+ it('resetDashboard clears all state', async () => {
+ await fetchDashboard(mockParams);
+ expect(dashboard.get()).not.toBeNull();
+
+ resetDashboard();
+
+ expect(dashboard.get()).toBeNull();
+ expect(loading.get()).toBe(true);
+ expect(loadError.get()).toBeNull();
+ expect(fromCache.get()).toBe(false);
+ expect(errors.get()).toEqual([]);
+ expect(rateLimitInfo.get()).toBeUndefined();
+ expect(activeTab.get()).toBe('all');
+ });
+});
diff --git a/src/lib/stores/dashboard-store.ts b/src/lib/stores/dashboard-store.ts
new file mode 100644
index 0000000..b638ebb
--- /dev/null
+++ b/src/lib/stores/dashboard-store.ts
@@ -0,0 +1,117 @@
+import { atom } from 'nanostores';
+import type {
+ ActivityType,
+ DashboardData,
+ FetchError,
+ GitHubRateLimitInfo,
+ QueryParams
+} from '$lib/types';
+import { encodeQueryParams } from '$lib/utils';
+import { buildCacheKey, readSessionCache, writeSessionCache } from '$lib/utils/cache';
+
+// =======================================================================
+// Atoms
+// =======================================================================
+
+export const dashboard = atom(null);
+export const loading = atom(true);
+export const loadError = atom(null);
+export const fromCache = atom(false);
+export const errors = atom([]);
+export const rateLimitInfo = atom(undefined);
+export const activeTab = atom<'all' | ActivityType>('all');
+
+// =======================================================================
+// Types
+// =======================================================================
+
+interface CachedDashboard {
+ dashboard: DashboardData;
+ errors: FetchError[];
+ rateLimitInfo?: GitHubRateLimitInfo;
+}
+
+export interface FetchDashboardOpts {
+ refresh?: boolean;
+}
+
+// =======================================================================
+// Actions
+// =======================================================================
+
+export async function fetchDashboard(
+ params: QueryParams,
+ opts?: FetchDashboardOpts
+): Promise<{ refreshed: boolean }> {
+ const refresh = opts?.refresh ?? false;
+
+ // Check session cache first (unless refresh requested)
+ if (!refresh) {
+ const key = buildCacheKey(params);
+ const cached = readSessionCache(key);
+ if (cached) {
+ dashboard.set(cached.dashboard);
+ fromCache.set(true);
+ errors.set(cached.errors ?? []);
+ rateLimitInfo.set(cached.rateLimitInfo);
+ loading.set(false);
+ return { refreshed: false };
+ }
+ }
+
+ loading.set(true);
+ loadError.set(null);
+ activeTab.set('all');
+
+ const queryString = encodeQueryParams(params);
+ let url = `/api/activity?${queryString}`;
+ if (refresh) url += '&refresh=true';
+
+ const headers: Record = {};
+ const pat = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('github_pat') : null;
+ if (pat) headers['x-github-pat'] = pat;
+
+ try {
+ const res = await fetch(url, { headers });
+ const data = (await res.json()) as {
+ dashboard?: DashboardData;
+ fromCache?: boolean;
+ errors?: FetchError[];
+ rateLimitInfo?: GitHubRateLimitInfo;
+ error?: string;
+ };
+
+ if (data.error) {
+ loadError.set(data.error);
+ } else if (data.dashboard) {
+ dashboard.set(data.dashboard);
+ fromCache.set(data.fromCache ?? false);
+ errors.set(data.errors ?? []);
+ rateLimitInfo.set(data.rateLimitInfo);
+
+ writeSessionCache(buildCacheKey(params), {
+ dashboard: data.dashboard,
+ errors: data.errors ?? [],
+ rateLimitInfo: data.rateLimitInfo
+ });
+ }
+
+ loading.set(false);
+ return { refreshed: refresh };
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Failed to fetch activity data.';
+ loadError.set(message);
+ loading.set(false);
+ return { refreshed: refresh };
+ }
+}
+
+export function resetDashboard() {
+ dashboard.set(null);
+ loading.set(true);
+ loadError.set(null);
+ fromCache.set(false);
+ errors.set([]);
+ rateLimitInfo.set(undefined);
+ activeTab.set('all');
+}
diff --git a/src/lib/stores/date-filter.ts b/src/lib/stores/date-filter.ts
new file mode 100644
index 0000000..02912b8
--- /dev/null
+++ b/src/lib/stores/date-filter.ts
@@ -0,0 +1,27 @@
+import { atom, computed } from 'nanostores';
+
+/**
+ * Shared date filter state between heatmap and activity list.
+ * Heatmap clicks set both from and to to the same date.
+ * Date range inputs set them independently.
+ */
+export const filterFrom = atom(null);
+export const filterTo = atom(null);
+
+/** The effective date filter — null if no filter is active */
+export const effectiveDateFilter = computed([filterFrom, filterTo], (from, to) => {
+ if (from || to) return { from, to };
+ return null;
+});
+
+/** Reset date filter */
+export function clearDateFilter() {
+ filterFrom.set(null);
+ filterTo.set(null);
+}
+
+/** Set a date range (from inputs or heatmap click) */
+export function setDateRange(from: string | null, to: string | null) {
+ filterFrom.set(from);
+ filterTo.set(to);
+}
diff --git a/src/lib/stores/ui-store.spec.ts b/src/lib/stores/ui-store.spec.ts
new file mode 100644
index 0000000..c59e9b7
--- /dev/null
+++ b/src/lib/stores/ui-store.spec.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import {
+ menuOpen,
+ copyFeedback,
+ stickyVisible,
+ toggleMenu,
+ closeMenu,
+ showCopyFeedback,
+ setStickyVisible
+} from './ui-store';
+
+describe('ui-store', () => {
+ beforeEach(() => {
+ menuOpen.set(false);
+ copyFeedback.set(false);
+ stickyVisible.set(false);
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('toggleMenu toggles menuOpen', () => {
+ expect(menuOpen.get()).toBe(false);
+ toggleMenu();
+ expect(menuOpen.get()).toBe(true);
+ toggleMenu();
+ expect(menuOpen.get()).toBe(false);
+ });
+
+ it('closeMenu sets menuOpen to false', () => {
+ menuOpen.set(true);
+ closeMenu();
+ expect(menuOpen.get()).toBe(false);
+ });
+
+ it('showCopyFeedback sets true then resets after 2s', () => {
+ showCopyFeedback();
+ expect(copyFeedback.get()).toBe(true);
+
+ vi.advanceTimersByTime(1999);
+ expect(copyFeedback.get()).toBe(true);
+
+ vi.advanceTimersByTime(1);
+ expect(copyFeedback.get()).toBe(false);
+ });
+
+ it('setStickyVisible updates stickyVisible', () => {
+ setStickyVisible(true);
+ expect(stickyVisible.get()).toBe(true);
+ setStickyVisible(false);
+ expect(stickyVisible.get()).toBe(false);
+ });
+});
diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts
new file mode 100644
index 0000000..1be3e99
--- /dev/null
+++ b/src/lib/stores/ui-store.ts
@@ -0,0 +1,30 @@
+import { atom } from 'nanostores';
+
+// =======================================================================
+// Atoms
+// =======================================================================
+
+export const menuOpen = atom(false);
+export const copyFeedback = atom(false);
+export const stickyVisible = atom(false);
+
+// =======================================================================
+// Actions
+// =======================================================================
+
+export function toggleMenu() {
+ menuOpen.set(!menuOpen.get());
+}
+
+export function closeMenu() {
+ menuOpen.set(false);
+}
+
+export function showCopyFeedback() {
+ copyFeedback.set(true);
+ setTimeout(() => copyFeedback.set(false), 2000);
+}
+
+export function setStickyVisible(visible: boolean) {
+ stickyVisible.set(visible);
+}
diff --git a/src/lib/stores/use-store.svelte.ts b/src/lib/stores/use-store.svelte.ts
new file mode 100644
index 0000000..eb23eea
--- /dev/null
+++ b/src/lib/stores/use-store.svelte.ts
@@ -0,0 +1,17 @@
+import type { ReadableAtom } from 'nanostores';
+
+export function useStore(store: ReadableAtom): { readonly value: T } {
+ let value = $state(store.get());
+
+ $effect(() => {
+ return store.subscribe((v) => {
+ value = v;
+ });
+ });
+
+ return {
+ get value() {
+ return value;
+ }
+ };
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 87a107a..169350b 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -63,6 +63,7 @@ export interface ActivityItem {
state?: string; // open, closed, merged, etc.
labels?: string[];
number?: number; // issue or PR number
+ assignees?: string[]; // GitHub login usernames
}
/**
@@ -130,3 +131,9 @@ export interface FetchResult {
errors: FetchError[];
rateLimitInfo?: GitHubRateLimitInfo;
}
+
+export interface GroupedSection {
+ category: ActivityCategory;
+ categoryLabel: string;
+ items: ActivityItem[];
+}
diff --git a/src/lib/utils/cache.spec.ts b/src/lib/utils/cache.spec.ts
new file mode 100644
index 0000000..e6fb964
--- /dev/null
+++ b/src/lib/utils/cache.spec.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import type { QueryParams } from '$lib/types';
+import { buildCacheKey, readSessionCache, writeSessionCache } from './cache';
+
+describe('buildCacheKey', () => {
+ it('prefixes with "dashboard:" and uses canonical query string', () => {
+ const params: QueryParams = {
+ user: 'octocat',
+ repos: ['b/repo', 'a/repo'],
+ from: '2025-01-01',
+ to: '2025-03-20',
+ types: ['prs_opened', 'issues_opened']
+ };
+ const key = buildCacheKey(params);
+ expect(key).toMatch(/^dashboard:/);
+ // Should produce same key regardless of array order
+ const params2: QueryParams = {
+ ...params,
+ repos: ['a/repo', 'b/repo'],
+ types: ['issues_opened', 'prs_opened']
+ };
+ expect(buildCacheKey(params2)).toBe(key);
+ });
+});
+
+describe('readSessionCache / writeSessionCache', () => {
+ let storage: Map;
+
+ beforeEach(() => {
+ storage = new Map();
+ const mock = {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key),
+ clear: () => storage.clear(),
+ get length() {
+ return storage.size;
+ },
+ key: (index: number) => [...storage.keys()][index] ?? null
+ };
+ vi.stubGlobal('sessionStorage', mock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('round-trips data through session storage', () => {
+ const data = { foo: 'bar', count: 42 };
+ writeSessionCache('test-key', data);
+ const result = readSessionCache('test-key');
+ expect(result).toEqual(data);
+ });
+
+ it('returns null for missing key', () => {
+ expect(readSessionCache('nonexistent')).toBeNull();
+ });
+
+ it('returns null for invalid JSON', () => {
+ storage.set('bad-json', '{invalid');
+ expect(readSessionCache('bad-json')).toBeNull();
+ });
+
+ it('handles sessionStorage full gracefully', () => {
+ vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => {
+ throw new DOMException('QuotaExceededError');
+ });
+ expect(() => writeSessionCache('key', { data: 'x' })).not.toThrow();
+ });
+});
+
+describe('SSR guard (sessionStorage undefined)', () => {
+ beforeEach(() => {
+ vi.stubGlobal('sessionStorage', undefined);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('readSessionCache returns null when sessionStorage is undefined', () => {
+ expect(readSessionCache('any-key')).toBeNull();
+ });
+
+ it('writeSessionCache is a no-op when sessionStorage is undefined', () => {
+ expect(() => writeSessionCache('key', { data: 'x' })).not.toThrow();
+ });
+});
diff --git a/src/lib/utils/cache.ts b/src/lib/utils/cache.ts
new file mode 100644
index 0000000..e6512b6
--- /dev/null
+++ b/src/lib/utils/cache.ts
@@ -0,0 +1,26 @@
+import type { QueryParams } from '$lib/types';
+import { canonicalQueryString } from '$lib/utils';
+
+export function buildCacheKey(params: QueryParams): string {
+ return `dashboard:${canonicalQueryString(params)}`;
+}
+
+export function readSessionCache(key: string): T | null {
+ if (typeof sessionStorage === 'undefined') return null;
+ try {
+ const raw = sessionStorage.getItem(key);
+ if (!raw) return null;
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
+export function writeSessionCache(key: string, data: unknown): void {
+ if (typeof sessionStorage === 'undefined') return;
+ try {
+ sessionStorage.setItem(key, JSON.stringify(data));
+ } catch {
+ // sessionStorage full — non-fatal
+ }
+}
diff --git a/src/lib/utils/colors.spec.ts b/src/lib/utils/colors.spec.ts
new file mode 100644
index 0000000..185c182
--- /dev/null
+++ b/src/lib/utils/colors.spec.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect } from 'vitest';
+import { getActivityTypeColor, getStateColor, getStateTitle } from './colors';
+
+describe('getActivityTypeColor', () => {
+ it('returns success color for issues_opened', () => {
+ expect(getActivityTypeColor('issues_opened')).toBe('var(--color-success)');
+ });
+
+ it('returns purple for issues_closed', () => {
+ expect(getActivityTypeColor('issues_closed')).toBe('#8250df');
+ });
+
+ it('returns secondary for issue_comments', () => {
+ expect(getActivityTypeColor('issue_comments')).toBe('var(--color-text-secondary)');
+ });
+
+ it('returns success color for prs_opened', () => {
+ expect(getActivityTypeColor('prs_opened')).toBe('var(--color-success)');
+ });
+
+ it('returns link color for pr_reviews', () => {
+ expect(getActivityTypeColor('pr_reviews')).toBe('var(--color-link)');
+ });
+
+ it('returns purple for prs_merged', () => {
+ expect(getActivityTypeColor('prs_merged')).toBe('#8250df');
+ });
+
+ it('returns secondary for unknown type', () => {
+ expect(getActivityTypeColor('unknown')).toBe('var(--color-text-secondary)');
+ });
+});
+
+describe('getStateColor', () => {
+ describe('issues', () => {
+ it('returns green for open', () => {
+ expect(getStateColor('open', 'issues')).toBe('#1a7f37');
+ });
+
+ it('returns purple for closed', () => {
+ expect(getStateColor('closed', 'issues')).toBe('#8250df');
+ });
+
+ it('returns gray for not_planned', () => {
+ expect(getStateColor('not_planned', 'issues')).toBe('#656d76');
+ });
+
+ it('returns gray for unknown state', () => {
+ expect(getStateColor('unknown', 'issues')).toBe('#656d76');
+ });
+ });
+
+ describe('pull_requests', () => {
+ it('returns green for open', () => {
+ expect(getStateColor('open', 'pull_requests')).toBe('#1a7f37');
+ });
+
+ it('returns purple for merged', () => {
+ expect(getStateColor('merged', 'pull_requests')).toBe('#8250df');
+ });
+
+ it('returns red for closed', () => {
+ expect(getStateColor('closed', 'pull_requests')).toBe('#d1242f');
+ });
+
+ it('returns gray for draft', () => {
+ expect(getStateColor('draft', 'pull_requests')).toBe('#656d76');
+ });
+
+ it('returns gray for unknown state', () => {
+ expect(getStateColor('unknown', 'pull_requests')).toBe('#656d76');
+ });
+ });
+});
+
+describe('getStateTitle', () => {
+ describe('issues', () => {
+ it('returns "Open" for open', () => {
+ expect(getStateTitle('open', 'issues')).toBe('Open');
+ });
+
+ it('returns "Closed" for closed', () => {
+ expect(getStateTitle('closed', 'issues')).toBe('Closed');
+ });
+
+ it('returns "Not planned" for not_planned', () => {
+ expect(getStateTitle('not_planned', 'issues')).toBe('Not planned');
+ });
+
+ it('returns raw state for unknown', () => {
+ expect(getStateTitle('custom', 'issues')).toBe('custom');
+ });
+ });
+
+ describe('pull_requests', () => {
+ it('returns "Open" for open', () => {
+ expect(getStateTitle('open', 'pull_requests')).toBe('Open');
+ });
+
+ it('returns "Merged" for merged', () => {
+ expect(getStateTitle('merged', 'pull_requests')).toBe('Merged');
+ });
+
+ it('returns "Closed" for closed', () => {
+ expect(getStateTitle('closed', 'pull_requests')).toBe('Closed');
+ });
+
+ it('returns "Draft" for draft', () => {
+ expect(getStateTitle('draft', 'pull_requests')).toBe('Draft');
+ });
+
+ it('returns raw state for unknown', () => {
+ expect(getStateTitle('custom', 'pull_requests')).toBe('custom');
+ });
+ });
+});
diff --git a/src/lib/utils/colors.ts b/src/lib/utils/colors.ts
new file mode 100644
index 0000000..5015abe
--- /dev/null
+++ b/src/lib/utils/colors.ts
@@ -0,0 +1,82 @@
+import type { ActivityCategory } from '$lib/types';
+
+// =======================================================================
+// Activity type colors
+// =======================================================================
+
+export function getActivityTypeColor(type: string): string {
+ switch (type) {
+ case 'issues_opened':
+ return 'var(--color-success)';
+ case 'issues_closed':
+ return '#8250df';
+ case 'issue_comments':
+ return 'var(--color-text-secondary)';
+ case 'prs_opened':
+ return 'var(--color-success)';
+ case 'pr_reviews':
+ return 'var(--color-link)';
+ case 'prs_merged':
+ return '#8250df';
+ default:
+ return 'var(--color-text-secondary)';
+ }
+}
+
+// =======================================================================
+// Issue / PR state colors and titles
+// =======================================================================
+
+export function getStateColor(state: string, category: ActivityCategory): string {
+ if (category === 'pull_requests') {
+ switch (state) {
+ case 'merged':
+ return '#8250df';
+ case 'open':
+ return '#1a7f37';
+ case 'closed':
+ return '#d1242f';
+ case 'draft':
+ return '#656d76';
+ default:
+ return '#656d76';
+ }
+ }
+ switch (state) {
+ case 'open':
+ return '#1a7f37';
+ case 'closed':
+ return '#8250df';
+ case 'not_planned':
+ return '#656d76';
+ default:
+ return '#656d76';
+ }
+}
+
+export function getStateTitle(state: string, category: ActivityCategory): string {
+ if (category === 'pull_requests') {
+ switch (state) {
+ case 'merged':
+ return 'Merged';
+ case 'open':
+ return 'Open';
+ case 'closed':
+ return 'Closed';
+ case 'draft':
+ return 'Draft';
+ default:
+ return state;
+ }
+ }
+ switch (state) {
+ case 'open':
+ return 'Open';
+ case 'closed':
+ return 'Closed';
+ case 'not_planned':
+ return 'Not planned';
+ default:
+ return state;
+ }
+}
diff --git a/src/lib/utils/export.spec.ts b/src/lib/utils/export.spec.ts
new file mode 100644
index 0000000..4e59a56
--- /dev/null
+++ b/src/lib/utils/export.spec.ts
@@ -0,0 +1,155 @@
+import { describe, it, expect } from 'vitest';
+import type { ActivityItem } from '$lib/types';
+import type { ExportMetadata } from './export';
+import { buildActivityRows, buildMetadataRows } from './export';
+
+function makeItem(overrides: Partial = {}): ActivityItem {
+ return {
+ id: '1',
+ type: 'issues_opened',
+ title: 'Test issue',
+ repo: 'org/repo',
+ date: '2025-02-15T10:00:00Z',
+ url: 'https://github.com/org/repo/issues/1',
+ ...overrides
+ };
+}
+
+describe('buildActivityRows', () => {
+ it('returns correct headers', () => {
+ const { headers } = buildActivityRows([]);
+ expect(headers).toEqual([
+ 'Type',
+ 'Number',
+ 'Title',
+ 'Repo',
+ 'State',
+ 'Date',
+ 'Assignees',
+ 'Labels',
+ 'URL'
+ ]);
+ });
+
+ it('returns empty rows for no items', () => {
+ const { rows } = buildActivityRows([]);
+ expect(rows).toHaveLength(0);
+ });
+
+ it('generates a row per item with correct fields', () => {
+ const items = [
+ makeItem({
+ number: 42,
+ state: 'open',
+ labels: ['bug', 'urgent'],
+ assignees: ['alice', 'bob']
+ })
+ ];
+ const { rows } = buildActivityRows(items);
+ expect(rows).toHaveLength(1);
+
+ const [type, number, title, repo, state, date, assignees, labels, url] = rows[0];
+ expect(type).toBe('Issues Opened');
+ expect(number).toBe('#42');
+ expect(title).toBe('Test issue');
+ expect(repo).toBe('org/repo');
+ expect(state).toBe('open');
+ expect(date).toBe('2025-02-15');
+ expect(assignees).toBe('alice, bob');
+ expect(labels).toBe('bug, urgent');
+ expect(url).toBe('https://github.com/org/repo/issues/1');
+ });
+
+ it('handles missing optional fields', () => {
+ const item = makeItem({
+ number: undefined,
+ state: undefined,
+ labels: undefined,
+ assignees: undefined
+ });
+ const { rows } = buildActivityRows([item]);
+ const row = rows[0];
+ expect(row[1]).toBe(''); // number
+ expect(row[4]).toBe(''); // state
+ expect(row[6]).toBe(''); // assignees
+ expect(row[7]).toBe(''); // labels
+ });
+});
+
+describe('buildMetadataRows', () => {
+ const meta: ExportMetadata = {
+ user: 'octocat',
+ repos: ['org/repo1', 'org/repo2'],
+ from: '2025-01-01',
+ to: '2025-03-20',
+ types: ['issues_opened', 'prs_opened'],
+ exportedAt: '2025-03-20T12:00:00Z',
+ activeTab: 'All Activity',
+ filters: {
+ searchQuery: '',
+ selectedRepos: [],
+ selectedLabels: [],
+ dateFilterFrom: null,
+ dateFilterTo: null
+ },
+ totalItems: 100,
+ filteredItems: 50
+ };
+
+ it('returns correct headers', () => {
+ const { headers } = buildMetadataRows(meta);
+ expect(headers).toEqual(['Field', 'Value']);
+ });
+
+ it('includes all metadata fields', () => {
+ const { rows } = buildMetadataRows(meta);
+ const fields = rows.map((r) => r[0]);
+ expect(fields).toContain('User');
+ expect(fields).toContain('Repos');
+ expect(fields).toContain('Date Range');
+ expect(fields).toContain('Activity Types');
+ expect(fields).toContain('Active Tab');
+ expect(fields).toContain('Total Items');
+ expect(fields).toContain('Filtered Items');
+ expect(fields).toContain('Exported At');
+ });
+
+ it('formats values correctly', () => {
+ const { rows } = buildMetadataRows(meta);
+ const toMap = Object.fromEntries(rows);
+ expect(toMap['User']).toBe('octocat');
+ expect(toMap['Repos']).toBe('org/repo1, org/repo2');
+ expect(toMap['Date Range']).toBe('2025-01-01 to 2025-03-20');
+ expect(toMap['Activity Types']).toBe('Issues Opened, PRs Opened');
+ expect(toMap['Total Items']).toBe('100');
+ expect(toMap['Filtered Items']).toBe('50');
+ });
+
+ it('shows (none) and (all) for empty filters', () => {
+ const { rows } = buildMetadataRows(meta);
+ const toMap = Object.fromEntries(rows);
+ expect(toMap['Search Query']).toBe('(none)');
+ expect(toMap['Filtered Repos']).toBe('(all)');
+ expect(toMap['Filtered Labels']).toBe('(all)');
+ expect(toMap['Date Filter']).toBe('(none)');
+ });
+
+ it('shows active filters when set', () => {
+ const activeMeta: ExportMetadata = {
+ ...meta,
+ filters: {
+ searchQuery: 'login',
+ selectedRepos: ['org/repo1'],
+ selectedLabels: ['bug'],
+ dateFilterFrom: '2025-02-01',
+ dateFilterTo: '2025-02-28'
+ }
+ };
+ const { rows } = buildMetadataRows(activeMeta);
+ const toMap = Object.fromEntries(rows);
+ expect(toMap['Search Query']).toBe('login');
+ expect(toMap['Filtered Repos']).toBe('org/repo1');
+ expect(toMap['Filtered Labels']).toBe('bug');
+ expect(toMap['Date Filter']).toBe('2025-02-01 to 2025-02-28');
+ });
+});
diff --git a/src/lib/utils/export.ts b/src/lib/utils/export.ts
new file mode 100644
index 0000000..1a0a89a
--- /dev/null
+++ b/src/lib/utils/export.ts
@@ -0,0 +1,121 @@
+import { utils, writeFile } from 'xlsx';
+import type { ActivityItem, ActivityType } from '$lib/types';
+import { ACTIVITY_TYPE_LABELS } from '$lib/types';
+
+// =======================================================================
+// Types
+// =======================================================================
+
+export interface ExportMetadata {
+ user: string;
+ repos: string[];
+ from: string;
+ to: string;
+ types: ActivityType[];
+ exportedAt: string;
+ activeTab: string;
+ filters: {
+ searchQuery: string;
+ selectedRepos: string[];
+ selectedLabels: string[];
+ dateFilterFrom: string | null;
+ dateFilterTo: string | null;
+ };
+ totalItems: number;
+ filteredItems: number;
+}
+
+// =======================================================================
+// Sheet builders
+// =======================================================================
+
+export function buildActivityRows(items: ActivityItem[]): {
+ headers: string[];
+ rows: (string | number)[][];
+} {
+ const headers = [
+ 'Type',
+ 'Number',
+ 'Title',
+ 'Repo',
+ 'State',
+ 'Date',
+ 'Assignees',
+ 'Labels',
+ 'URL'
+ ];
+
+ const rows = items.map((item) => [
+ ACTIVITY_TYPE_LABELS[item.type],
+ item.number ? `#${item.number}` : '',
+ item.title,
+ item.repo,
+ item.state ?? '',
+ item.date.split('T')[0],
+ (item.assignees ?? []).join(', '),
+ (item.labels ?? []).join(', '),
+ item.url
+ ]);
+
+ return { headers, rows };
+}
+
+export function buildMetadataRows(meta: ExportMetadata): { headers: string[]; rows: string[][] } {
+ const headers = ['Field', 'Value'];
+
+ const rows = [
+ ['User', meta.user],
+ ['Repos', meta.repos.join(', ')],
+ ['Date Range', `${meta.from} to ${meta.to}`],
+ ['Activity Types', meta.types.map((t) => ACTIVITY_TYPE_LABELS[t]).join(', ')],
+ ['Active Tab', meta.activeTab],
+ ['Search Query', meta.filters.searchQuery || '(none)'],
+ [
+ 'Filtered Repos',
+ meta.filters.selectedRepos.length > 0 ? meta.filters.selectedRepos.join(', ') : '(all)'
+ ],
+ [
+ 'Filtered Labels',
+ meta.filters.selectedLabels.length > 0 ? meta.filters.selectedLabels.join(', ') : '(all)'
+ ],
+ [
+ 'Date Filter',
+ meta.filters.dateFilterFrom || meta.filters.dateFilterTo
+ ? `${meta.filters.dateFilterFrom ?? '...'} to ${meta.filters.dateFilterTo ?? '...'}`
+ : '(none)'
+ ],
+ ['Total Items', String(meta.totalItems)],
+ ['Filtered Items', String(meta.filteredItems)],
+ ['Exported At', meta.exportedAt]
+ ];
+
+ return { headers, rows };
+}
+
+// =======================================================================
+// Export
+// =======================================================================
+
+export function exportActivityData(
+ items: ActivityItem[],
+ meta: ExportMetadata,
+ filenamePrefix: string
+): void {
+ const wb = utils.book_new();
+
+ // Activity sheet
+ const activity = buildActivityRows(items);
+ const activitySheet = utils.aoa_to_sheet([activity.headers, ...activity.rows]);
+ activitySheet['!cols'] = activity.headers.map((h) =>
+ h === 'Title' || h === 'URL' ? { wch: 50 } : h === 'Repo' ? { wch: 25 } : { wch: 16 }
+ );
+ utils.book_append_sheet(wb, activitySheet, 'Activity');
+
+ // Query sheet
+ const metadata = buildMetadataRows(meta);
+ const metadataSheet = utils.aoa_to_sheet([metadata.headers, ...metadata.rows]);
+ metadataSheet['!cols'] = [{ wch: 18 }, { wch: 60 }];
+ utils.book_append_sheet(wb, metadataSheet, 'Query');
+
+ writeFile(wb, `${filenamePrefix}.xlsx`);
+}
diff --git a/src/lib/utils/filters.spec.ts b/src/lib/utils/filters.spec.ts
new file mode 100644
index 0000000..2405a67
--- /dev/null
+++ b/src/lib/utils/filters.spec.ts
@@ -0,0 +1,211 @@
+import { describe, it, expect } from 'vitest';
+import type { ActivityItem } from '$lib/types';
+import {
+ filterByDateRange,
+ filterByRepos,
+ filterByLabels,
+ filterBySearch,
+ filterByActivityType,
+ groupByCategory
+} from './filters';
+
+function makeItem(overrides: Partial = {}): ActivityItem {
+ return {
+ id: '1',
+ type: 'issues_opened',
+ title: 'Test issue',
+ repo: 'org/repo',
+ date: '2025-02-15T10:00:00Z',
+ url: 'https://github.com',
+ ...overrides
+ };
+}
+
+describe('filterByDateRange', () => {
+ const items = [
+ makeItem({ id: '1', date: '2025-01-10T00:00:00Z' }),
+ makeItem({ id: '2', date: '2025-02-15T00:00:00Z' }),
+ makeItem({ id: '3', date: '2025-03-20T00:00:00Z' })
+ ];
+
+ it('returns all items when both from and to are null', () => {
+ expect(filterByDateRange(items, null, null)).toHaveLength(3);
+ });
+
+ it('filters items before from date', () => {
+ const result = filterByDateRange(items, '2025-02-01', null);
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe('2');
+ });
+
+ it('filters items after to date', () => {
+ const result = filterByDateRange(items, null, '2025-02-28');
+ expect(result).toHaveLength(2);
+ expect(result[1].id).toBe('2');
+ });
+
+ it('filters to exact date range', () => {
+ const result = filterByDateRange(items, '2025-02-01', '2025-02-28');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('2');
+ });
+
+ it('returns empty for no match', () => {
+ expect(filterByDateRange(items, '2026-01-01', '2026-12-31')).toHaveLength(0);
+ });
+
+ it('handles empty input', () => {
+ expect(filterByDateRange([], '2025-01-01', '2025-12-31')).toHaveLength(0);
+ });
+});
+
+describe('filterByRepos', () => {
+ const items = [
+ makeItem({ id: '1', repo: 'org/alpha' }),
+ makeItem({ id: '2', repo: 'org/beta' }),
+ makeItem({ id: '3', repo: 'org/gamma' })
+ ];
+
+ it('returns all items when repos is empty', () => {
+ expect(filterByRepos(items, [])).toHaveLength(3);
+ });
+
+ it('filters to matching repos', () => {
+ const result = filterByRepos(items, ['org/alpha', 'org/gamma']);
+ expect(result).toHaveLength(2);
+ expect(result.map((i) => i.id)).toEqual(['1', '3']);
+ });
+
+ it('returns empty for no match', () => {
+ expect(filterByRepos(items, ['org/missing'])).toHaveLength(0);
+ });
+});
+
+describe('filterByLabels', () => {
+ const items = [
+ makeItem({ id: '1', labels: ['bug', 'urgent'] }),
+ makeItem({ id: '2', labels: ['feature'] }),
+ makeItem({ id: '3', labels: [] }),
+ makeItem({ id: '4' }) // no labels property
+ ];
+
+ it('returns all items when labels is empty', () => {
+ expect(filterByLabels(items, [])).toHaveLength(4);
+ });
+
+ it('filters to items with matching labels', () => {
+ const result = filterByLabels(items, ['bug']);
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('1');
+ });
+
+ it('matches any label (intersection)', () => {
+ const result = filterByLabels(items, ['bug', 'feature']);
+ expect(result).toHaveLength(2);
+ });
+
+ it('returns empty for no match', () => {
+ expect(filterByLabels(items, ['nonexistent'])).toHaveLength(0);
+ });
+});
+
+describe('filterBySearch', () => {
+ const items = [
+ makeItem({ id: '1', title: 'Fix login bug', repo: 'org/auth', number: 42 }),
+ makeItem({ id: '2', title: 'Add dashboard feature', repo: 'org/web', number: 99 }),
+ makeItem({ id: '3', title: 'Update docs', repo: 'org/docs' })
+ ];
+
+ it('returns all items for empty query', () => {
+ expect(filterBySearch(items, '')).toHaveLength(3);
+ expect(filterBySearch(items, ' ')).toHaveLength(3);
+ });
+
+ it('searches by title', () => {
+ const result = filterBySearch(items, 'login');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('1');
+ });
+
+ it('searches by repo', () => {
+ const result = filterBySearch(items, 'org/web');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('2');
+ });
+
+ it('searches by #number', () => {
+ const result = filterBySearch(items, '#42');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('1');
+ });
+
+ it('is case insensitive', () => {
+ expect(filterBySearch(items, 'FIX')).toHaveLength(1);
+ });
+
+ it('returns empty for no match', () => {
+ expect(filterBySearch(items, 'nonexistent')).toHaveLength(0);
+ });
+});
+
+describe('filterByActivityType', () => {
+ const items = [
+ makeItem({ id: '1', type: 'issues_opened' }),
+ makeItem({ id: '2', type: 'prs_opened' }),
+ makeItem({ id: '3', type: 'issues_opened' })
+ ];
+
+ it('returns all items for "all" tab', () => {
+ expect(filterByActivityType(items, 'all')).toHaveLength(3);
+ });
+
+ it('filters to specific type', () => {
+ const result = filterByActivityType(items, 'prs_opened');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('2');
+ });
+
+ it('returns empty for no match', () => {
+ expect(filterByActivityType(items, 'prs_merged')).toHaveLength(0);
+ });
+});
+
+describe('groupByCategory', () => {
+ it('groups items into Issues and Pull Requests sections', () => {
+ const items = [
+ makeItem({ id: '1', type: 'issues_opened' }),
+ makeItem({ id: '2', type: 'prs_opened' }),
+ makeItem({ id: '3', type: 'issue_comments' }),
+ makeItem({ id: '4', type: 'prs_merged' })
+ ];
+ const sections = groupByCategory(items);
+ expect(sections).toHaveLength(2);
+ expect(sections[0].category).toBe('issues');
+ expect(sections[0].categoryLabel).toBe('Issues');
+ expect(sections[0].items).toHaveLength(2);
+ expect(sections[1].category).toBe('pull_requests');
+ expect(sections[1].categoryLabel).toBe('Pull Requests');
+ expect(sections[1].items).toHaveLength(2);
+ });
+
+ it('orders Issues before Pull Requests', () => {
+ const items = [
+ makeItem({ id: '1', type: 'prs_opened' }),
+ makeItem({ id: '2', type: 'issues_opened' })
+ ];
+ const sections = groupByCategory(items);
+ expect(sections[0].category).toBe('issues');
+ expect(sections[1].category).toBe('pull_requests');
+ });
+
+ it('omits empty categories', () => {
+ const items = [makeItem({ id: '1', type: 'prs_merged' })];
+ const sections = groupByCategory(items);
+ expect(sections).toHaveLength(1);
+ expect(sections[0].category).toBe('pull_requests');
+ });
+
+ it('returns empty for no items', () => {
+ expect(groupByCategory([])).toHaveLength(0);
+ });
+});
diff --git a/src/lib/utils/filters.ts b/src/lib/utils/filters.ts
new file mode 100644
index 0000000..6c38d5e
--- /dev/null
+++ b/src/lib/utils/filters.ts
@@ -0,0 +1,76 @@
+import type { ActivityItem, ActivityCategory, ActivityType, GroupedSection } from '$lib/types';
+import { ACTIVITY_TYPE_CATEGORY, ACTIVITY_CATEGORY_LABELS } from '$lib/types';
+
+// =======================================================================
+// Filter functions
+// =======================================================================
+
+export function filterByDateRange(
+ items: ActivityItem[],
+ from: string | null,
+ to: string | null
+): ActivityItem[] {
+ if (!from && !to) return items;
+ return items.filter((i) => {
+ const day = i.date.split('T')[0];
+ if (from && day < from) return false;
+ if (to && day > to) return false;
+ return true;
+ });
+}
+
+export function filterByRepos(items: ActivityItem[], repos: string[]): ActivityItem[] {
+ if (repos.length === 0) return items;
+ return items.filter((i) => repos.includes(i.repo));
+}
+
+export function filterByLabels(items: ActivityItem[], labels: string[]): ActivityItem[] {
+ if (labels.length === 0) return items;
+ return items.filter((i) => i.labels?.some((l) => labels.includes(l)));
+}
+
+export function filterBySearch(items: ActivityItem[], query: string): ActivityItem[] {
+ const q = query.trim().toLowerCase();
+ if (!q) return items;
+ return items.filter(
+ (i) =>
+ i.title.toLowerCase().includes(q) ||
+ i.repo.toLowerCase().includes(q) ||
+ (i.number && `#${i.number}`.includes(q))
+ );
+}
+
+export function filterByActivityType(
+ items: ActivityItem[],
+ tab: 'all' | ActivityType
+): ActivityItem[] {
+ if (tab === 'all') return items;
+ return items.filter((i) => i.type === tab);
+}
+
+// =======================================================================
+// Grouping
+// =======================================================================
+
+export function groupByCategory(items: ActivityItem[]): GroupedSection[] {
+ const groups: Record = {};
+ for (const item of items) {
+ const cat = ACTIVITY_TYPE_CATEGORY[item.type];
+ if (!groups[cat]) groups[cat] = [];
+ groups[cat].push(item);
+ }
+
+ const sections: GroupedSection[] = [];
+ const order: ActivityCategory[] = ['issues', 'pull_requests'];
+ for (const cat of order) {
+ const catItems = groups[cat];
+ if (catItems && catItems.length > 0) {
+ sections.push({
+ category: cat,
+ categoryLabel: ACTIVITY_CATEGORY_LABELS[cat],
+ items: catItems
+ });
+ }
+ }
+ return sections;
+}
diff --git a/src/lib/utils/heatmap.spec.ts b/src/lib/utils/heatmap.spec.ts
new file mode 100644
index 0000000..7e0d37c
--- /dev/null
+++ b/src/lib/utils/heatmap.spec.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest';
+import type { HeatmapEntry } from '$lib/types';
+import { computeHeatmapGrid, getHeatmapColor } from './heatmap';
+
+describe('computeHeatmapGrid', () => {
+ it('returns empty grid for empty entries', () => {
+ const grid = computeHeatmapGrid([]);
+ expect(grid.cells).toHaveLength(0);
+ expect(grid.monthLabels).toHaveLength(0);
+ expect(grid.totalCols).toBe(0);
+ });
+
+ it('assigns correct row based on day of week (Sunday=0, Monday=1)', () => {
+ // 2025-01-05 is a Sunday, 2025-01-06 is a Monday
+ const entries: HeatmapEntry[] = [
+ { date: '2025-01-05', count: 1 },
+ { date: '2025-01-06', count: 2 }
+ ];
+ const grid = computeHeatmapGrid(entries);
+ expect(grid.cells[0].row).toBe(0); // Sunday
+ expect(grid.cells[1].row).toBe(1); // Monday
+ });
+
+ it('increments column for each new week', () => {
+ const entries: HeatmapEntry[] = [
+ { date: '2025-01-05', count: 1 }, // Sunday week 0
+ { date: '2025-01-12', count: 1 } // Sunday week 1
+ ];
+ const grid = computeHeatmapGrid(entries);
+ expect(grid.cells[0].col).toBe(0);
+ expect(grid.cells[1].col).toBe(1);
+ expect(grid.totalCols).toBe(2);
+ });
+
+ it('generates month labels', () => {
+ // Span two months
+ const entries: HeatmapEntry[] = [];
+ const start = new Date('2025-01-15T00:00:00');
+ for (let i = 0; i < 45; i++) {
+ const d = new Date(start);
+ d.setDate(d.getDate() + i);
+ entries.push({ date: d.toISOString().split('T')[0], count: 0 });
+ }
+ const grid = computeHeatmapGrid(entries);
+ expect(grid.monthLabels.length).toBeGreaterThanOrEqual(1);
+ const labels = grid.monthLabels.map((l) => l.text);
+ expect(labels.some((l) => l === 'Jan' || l === 'Feb')).toBe(true);
+ });
+
+ it('drops month labels that are too close together (< 3 cols apart)', () => {
+ // A very short range straddling a month boundary
+ const entries: HeatmapEntry[] = [
+ { date: '2025-01-31', count: 1 }, // Friday
+ { date: '2025-02-01', count: 1 } // Saturday — same week
+ ];
+ const grid = computeHeatmapGrid(entries);
+ // Both dates are in the same column, so < 3 cols apart.
+ // Only the last label (Feb) should survive.
+ expect(grid.monthLabels).toHaveLength(1);
+ expect(grid.monthLabels[0].text).toBe('Feb');
+ });
+});
+
+describe('getHeatmapColor', () => {
+ it('returns level 0 for count 0', () => {
+ expect(getHeatmapColor(0, 10)).toBe('var(--heatmap-0)');
+ });
+
+ it('returns level 1 for ratio <= 0.25', () => {
+ expect(getHeatmapColor(1, 10)).toBe('var(--heatmap-1)');
+ expect(getHeatmapColor(2, 8)).toBe('var(--heatmap-1)');
+ });
+
+ it('returns level 2 for ratio <= 0.5', () => {
+ expect(getHeatmapColor(5, 10)).toBe('var(--heatmap-2)');
+ });
+
+ it('returns level 3 for ratio <= 0.75', () => {
+ expect(getHeatmapColor(7, 10)).toBe('var(--heatmap-3)');
+ });
+
+ it('returns level 4 for ratio > 0.75', () => {
+ expect(getHeatmapColor(10, 10)).toBe('var(--heatmap-4)');
+ });
+
+ it('returns level 4 for single item when maxCount is 1', () => {
+ expect(getHeatmapColor(1, 1)).toBe('var(--heatmap-4)');
+ });
+});
diff --git a/src/lib/utils/heatmap.ts b/src/lib/utils/heatmap.ts
new file mode 100644
index 0000000..70ff765
--- /dev/null
+++ b/src/lib/utils/heatmap.ts
@@ -0,0 +1,80 @@
+import type { HeatmapEntry } from '$lib/types';
+
+// =======================================================================
+// Types
+// =======================================================================
+
+export interface HeatmapCell {
+ entry: HeatmapEntry;
+ col: number;
+ row: number;
+}
+
+export interface HeatmapGrid {
+ cells: HeatmapCell[];
+ monthLabels: { text: string; col: number }[];
+ totalCols: number;
+}
+
+// =======================================================================
+// Grid computation
+// =======================================================================
+
+export function computeHeatmapGrid(entries: HeatmapEntry[]): HeatmapGrid {
+ if (entries.length === 0) {
+ return { cells: [], monthLabels: [], totalCols: 0 };
+ }
+
+ const firstDate = new Date(entries[0].date + 'T00:00:00');
+ const startTime = firstDate.getTime() - firstDate.getDay() * 86400000;
+ const msPerDay = 86400000;
+
+ const cells: HeatmapCell[] = [];
+ let lastMonth = -1;
+
+ const rawLabels: { text: string; col: number }[] = [];
+ for (const entry of entries) {
+ const date = new Date(entry.date + 'T00:00:00');
+ 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;
+ }
+ }
+
+ const monthLabels: { text: string; col: number }[] = [];
+ for (let i = 0; i < rawLabels.length - 1; i++) {
+ if (rawLabels[i + 1].col - rawLabels[i].col < 3) {
+ continue;
+ }
+ monthLabels.push(rawLabels[i]);
+ }
+ if (rawLabels.length > 0) {
+ monthLabels.push(rawLabels[rawLabels.length - 1]);
+ }
+
+ const totalCols = cells.length > 0 ? cells[cells.length - 1].col + 1 : 0;
+ return { cells, monthLabels, totalCols };
+}
+
+// =======================================================================
+// Color mapping
+// =======================================================================
+
+export function getHeatmapColor(count: number, maxCount: number): string {
+ if (count === 0) return 'var(--heatmap-0)';
+ const ratio = count / maxCount;
+ if (ratio <= 0.25) return 'var(--heatmap-1)';
+ if (ratio <= 0.5) return 'var(--heatmap-2)';
+ if (ratio <= 0.75) return 'var(--heatmap-3)';
+ return 'var(--heatmap-4)';
+}
diff --git a/src/routes/results/+page.svelte b/src/routes/results/+page.svelte
index 01f94b0..d8b408b 100644
--- a/src/routes/results/+page.svelte
+++ b/src/routes/results/+page.svelte
@@ -1,14 +1,34 @@
- (menuOpen = false)} />
+ closeMenu()} />
-{#if loading}
+{#if loading.value}
-{:else if loadError}
+{:else if loadError.value}
Something went wrong
- {loadError}
- Back to search
+ {loadError.value}
+ Back to search
-{:else if dashboard && stats}
-