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 repos.length > 1} -
- Repo -
- {#each repos as repo (repo)} - - {/each} -
-
- {/if} - - {#if allLabels.length > 0} -
- Label -
- {#each allLabels.slice(0, 12) as label (label)} - - {/each} -
-
- {/if} -
- - {#if hasActiveFilters} -
- {filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''} - -
- {/if} -
+ - - {#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 @@ + + +
    + Date +
    + { + const v = (e.target as HTMLInputElement).value; + setDateRange(v || null, to.value); + }} + aria-label="Filter from date" + /> + to + { + const v = (e.target as HTMLInputElement).value; + setDateRange(from.value, v || null); + }} + aria-label="Filter to date" + /> +
    +
    + + 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 @@ + + + + + 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}
    @@ -215,30 +155,32 @@

    -{:else if loadError} +{:else if loadError.value}

    Something went wrong

    -

    {loadError}

    - Back to search +

    {loadError.value}

    + Back to search
    -{:else if dashboard && stats} -