From b9e0edd4d2ed4f84ca1ab380365ae0a5520c69bb Mon Sep 17 00:00:00 2001 From: widal001 Date: Tue, 24 Mar 2026 22:25:56 -0400 Subject: [PATCH 1/8] fix: Which user we fetch repos for Previously if the form respondent provided a PAT, it returned repos based on this PAT. Now it uses the GitHub user from the form question --- src/lib/server/github.ts | 44 +++++++++++----------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/src/lib/server/github.ts b/src/lib/server/github.ts index 1db2912..c943b1d 100644 --- a/src/lib/server/github.ts +++ b/src/lib/server/github.ts @@ -510,13 +510,11 @@ export async function fetchGitHubActivity(params: QueryParams): Promise Date: Tue, 24 Mar 2026 22:26:55 -0400 Subject: [PATCH 2/8] feat: Prefills new query form based on URL --- src/lib/components/QueryForm.svelte | 27 ++++++++++++++++++++------- src/routes/results/+page.svelte | 15 +++++++++++---- 2 files changed, 31 insertions(+), 11 deletions(-) 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 @@
@@ -132,10 +168,20 @@ height={cellSize} rx="2" fill={getColor(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..f855305 100644 --- a/src/lib/components/ActivityList.svelte +++ b/src/lib/components/ActivityList.svelte @@ -8,6 +8,13 @@ } from '$lib/types'; import { formatDisplayDate } from '$lib/utils'; import StateIcon from './StateIcon.svelte'; + import { + effectiveDateFilter, + filterFrom, + filterTo, + setDateRange, + clearDateFilter + } from '$lib/stores/date-filter'; function getTypeLabels(labels: string[] | undefined): string[] { return (labels ?? []).filter((l) => isTypeLabel(l)); @@ -32,6 +39,32 @@ let selectedLabels = $state([]); let currentPage = $state(1); + // Date filter from shared store + let dateFilter = $state<{ from: string | null; to: string | null } | null>(null); + let dateFilterFrom = $state(''); + let dateFilterTo = $state(''); + + $effect(() => { + const unsub = effectiveDateFilter.subscribe((v) => { + dateFilter = v; + }); + return unsub; + }); + + $effect(() => { + const unsub = filterFrom.subscribe((v) => { + dateFilterFrom = v ?? ''; + }); + return unsub; + }); + + $effect(() => { + const unsub = filterTo.subscribe((v) => { + dateFilterTo = v ?? ''; + }); + return unsub; + }); + // Collect all unique labels from items let allLabels = $derived( [...new Set(items.flatMap((i) => i.labels ?? []))].filter(Boolean).sort() @@ -41,6 +74,16 @@ let filteredItems = $derived.by(() => { let result = items; + // Date filter (from heatmap click or date range inputs) + if (dateFilter) { + result = result.filter((i) => { + const day = i.date.split('T')[0]; + if (dateFilter!.from && day < dateFilter!.from) return false; + if (dateFilter!.to && day > dateFilter!.to) return false; + return true; + }); + } + if (selectedRepos.length > 0) { result = result.filter((i) => selectedRepos.includes(i.repo)); } @@ -103,6 +146,7 @@ void searchQuery; void selectedRepos; void selectedLabels; + void dateFilter; void items; currentPage = 1; collapsedSections = {}; @@ -125,10 +169,14 @@ searchQuery = ''; selectedRepos = []; selectedLabels = []; + clearDateFilter(); } let hasActiveFilters = $derived( - searchQuery.trim() !== '' || selectedRepos.length > 0 || selectedLabels.length > 0 + searchQuery.trim() !== '' || + selectedRepos.length > 0 || + selectedLabels.length > 0 || + dateFilter !== null ); // Section collapse state @@ -210,6 +258,31 @@
{/if} + +
+ Date +
+ { + const v = (e.target as HTMLInputElement).value; + setDateRange(v || null, dateFilterTo || null); + }} + aria-label="Filter from date" + /> + to + { + const v = (e.target as HTMLInputElement).value; + setDateRange(dateFilterFrom || null, v || null); + }} + aria-label="Filter to date" + /> +
+
{#if hasActiveFilters} @@ -425,6 +498,26 @@ text-decoration: underline; } + /* Date filter */ + .date-filter-inputs { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .date-filter-inputs input[type='date'] { + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 12px; + } + + .date-sep { + font-size: 12px; + color: var(--color-text-secondary); + } + /* Category sections */ .category-section { margin-bottom: 20px; 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); +} From 68a4ae61a5ca03065a8671fb8e79024bdf21630c Mon Sep 17 00:00:00 2001 From: widal001 Date: Tue, 24 Mar 2026 23:35:41 -0400 Subject: [PATCH 5/8] refactor: Reorganize codebase Decompose pages into stores, libs, and components --- src/lib/components/ActivityHeatmap.svelte | 110 +-- src/lib/components/ActivityList.svelte | 699 +----------------- src/lib/components/StateIcon.svelte | 60 +- .../activity-list/ActivityItemCard.svelte | 135 ++++ .../activity-list/CategorySection.svelte | 104 +++ .../activity-list/Pagination.svelte | 65 ++ .../components/toolbar/ChipFilterGroup.svelte | 77 ++ .../components/toolbar/DateRangeFilter.svelte | 69 ++ .../components/toolbar/FilterGroups.svelte | 51 ++ .../components/toolbar/FilterStatus.svelte | 38 + src/lib/components/toolbar/SearchBar.svelte | 31 + src/lib/components/toolbar/Toolbar.svelte | 20 + src/lib/stores/activity-filter-store.spec.ts | 218 ++++++ src/lib/stores/activity-filter-store.ts | 145 ++++ src/lib/stores/dashboard-store.spec.ts | 169 +++++ src/lib/stores/dashboard-store.ts | 117 +++ src/lib/stores/ui-store.spec.ts | 55 ++ src/lib/stores/ui-store.ts | 30 + src/lib/stores/use-store.svelte.ts | 17 + src/lib/types.ts | 6 + src/lib/utils/cache.spec.ts | 88 +++ src/lib/utils/cache.ts | 26 + src/lib/utils/colors.spec.ts | 116 +++ src/lib/utils/colors.ts | 82 ++ src/lib/utils/filters.spec.ts | 211 ++++++ src/lib/utils/filters.ts | 76 ++ src/lib/utils/heatmap.spec.ts | 89 +++ src/lib/utils/heatmap.ts | 80 ++ src/routes/results/+page.svelte | 259 +++---- 29 files changed, 2254 insertions(+), 989 deletions(-) create mode 100644 src/lib/components/activity-list/ActivityItemCard.svelte create mode 100644 src/lib/components/activity-list/CategorySection.svelte create mode 100644 src/lib/components/activity-list/Pagination.svelte create mode 100644 src/lib/components/toolbar/ChipFilterGroup.svelte create mode 100644 src/lib/components/toolbar/DateRangeFilter.svelte create mode 100644 src/lib/components/toolbar/FilterGroups.svelte create mode 100644 src/lib/components/toolbar/FilterStatus.svelte create mode 100644 src/lib/components/toolbar/SearchBar.svelte create mode 100644 src/lib/components/toolbar/Toolbar.svelte create mode 100644 src/lib/stores/activity-filter-store.spec.ts create mode 100644 src/lib/stores/activity-filter-store.ts create mode 100644 src/lib/stores/dashboard-store.spec.ts create mode 100644 src/lib/stores/dashboard-store.ts create mode 100644 src/lib/stores/ui-store.spec.ts create mode 100644 src/lib/stores/ui-store.ts create mode 100644 src/lib/stores/use-store.svelte.ts create mode 100644 src/lib/utils/cache.spec.ts create mode 100644 src/lib/utils/cache.ts create mode 100644 src/lib/utils/colors.spec.ts create mode 100644 src/lib/utils/colors.ts create mode 100644 src/lib/utils/filters.spec.ts create mode 100644 src/lib/utils/filters.ts create mode 100644 src/lib/utils/heatmap.spec.ts create mode 100644 src/lib/utils/heatmap.ts diff --git a/src/lib/components/ActivityHeatmap.svelte b/src/lib/components/ActivityHeatmap.svelte index 1f2bbf1..628051a 100644 --- a/src/lib/components/ActivityHeatmap.svelte +++ b/src/lib/components/ActivityHeatmap.svelte @@ -1,6 +1,8 @@
- -
- - -
- {#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} + -
- Date -
- { - const v = (e.target as HTMLInputElement).value; - setDateRange(v || null, dateFilterTo || null); - }} - aria-label="Filter from date" - /> - to - { - const v = (e.target as HTMLInputElement).value; - setDateRange(dateFilterFrom || null, v || null); - }} - aria-label="Filter to date" - /> -
-
-
- - {#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}
@@ -405,295 +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; - } - - /* Date filter */ - .date-filter-inputs { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - } - - .date-filter-inputs input[type='date'] { - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - font-size: 12px; - } - - .date-sep { - font-size: 12px; - color: var(--color-text-secondary); - } - - /* 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/StateIcon.svelte b/src/lib/components/StateIcon.svelte index 6123305..57ce9b0 100644 --- a/src/lib/components/StateIcon.svelte +++ b/src/lib/components/StateIcon.svelte @@ -1,5 +1,6 @@ diff --git a/src/lib/components/activity-list/ActivityItemCard.svelte b/src/lib/components/activity-list/ActivityItemCard.svelte new file mode 100644 index 0000000..7f267a5 --- /dev/null +++ b/src/lib/components/activity-list/ActivityItemCard.svelte @@ -0,0 +1,135 @@ + + +
  • + + {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} +
    +
  • + + 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..c9d88f5 --- /dev/null +++ b/src/lib/components/toolbar/FilterStatus.svelte @@ -0,0 +1,38 @@ + + +{#if active.value} +
    + {items.value.length} result{items.value.length !== 1 ? 's' : ''} + +
    +{/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/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/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..89f64ec 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -130,3 +130,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/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 b13dd58..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}
    @@ -222,28 +155,30 @@

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

    Something went wrong

    -

    {loadError}

    +

    {loadError.value}

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