diff --git a/apps/frontend/src/renderer/stores/github/__tests__/label-sync-store.test.ts b/apps/frontend/src/renderer/stores/github/__tests__/label-sync-store.test.ts new file mode 100644 index 00000000..89df158d --- /dev/null +++ b/apps/frontend/src/renderer/stores/github/__tests__/label-sync-store.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useLabelSyncStore } from '../label-sync-store'; + +describe('useLabelSyncStore', () => { + beforeEach(() => { + useLabelSyncStore.getState().reset(); + }); + + it('has correct initial state', () => { + const state = useLabelSyncStore.getState(); + expect(state.config.enabled).toBe(false); + expect(state.config.lastSyncedAt).toBeNull(); + expect(state.isLoaded).toBe(false); + expect(state.isSyncing).toBe(false); + expect(state.error).toBeNull(); + expect(state.lastResult).toBeNull(); + }); + + it('setConfig updates config and marks loaded', () => { + useLabelSyncStore.getState().setConfig({ + enabled: true, + lastSyncedAt: '2026-01-01T00:00:00Z', + }); + + const state = useLabelSyncStore.getState(); + expect(state.config.enabled).toBe(true); + expect(state.config.lastSyncedAt).toBe('2026-01-01T00:00:00Z'); + expect(state.isLoaded).toBe(true); + expect(state.error).toBeNull(); + }); + + it('setSyncing updates syncing state', () => { + useLabelSyncStore.getState().setSyncing(true); + expect(useLabelSyncStore.getState().isSyncing).toBe(true); + + useLabelSyncStore.getState().setSyncing(false); + expect(useLabelSyncStore.getState().isSyncing).toBe(false); + }); + + it('setError updates error and clears syncing', () => { + useLabelSyncStore.getState().setSyncing(true); + useLabelSyncStore.getState().setError('Rate limited'); + + const state = useLabelSyncStore.getState(); + expect(state.error).toBe('Rate limited'); + expect(state.isSyncing).toBe(false); + }); + + it('setLastResult stores sync result', () => { + const result = { created: 7, updated: 0, removed: 0, errors: [] }; + useLabelSyncStore.getState().setLastResult(result); + + expect(useLabelSyncStore.getState().lastResult).toEqual(result); + }); + + it('reset returns to initial state', () => { + useLabelSyncStore.getState().setConfig({ enabled: true, lastSyncedAt: 'now' }); + useLabelSyncStore.getState().setSyncing(true); + useLabelSyncStore.getState().setError('fail'); + + useLabelSyncStore.getState().reset(); + + const state = useLabelSyncStore.getState(); + expect(state.config.enabled).toBe(false); + expect(state.isLoaded).toBe(false); + expect(state.isSyncing).toBe(false); + expect(state.error).toBeNull(); + }); +}); diff --git a/apps/frontend/src/renderer/stores/github/__tests__/phase4-store.test.ts b/apps/frontend/src/renderer/stores/github/__tests__/phase4-store.test.ts new file mode 100644 index 00000000..74992991 --- /dev/null +++ b/apps/frontend/src/renderer/stores/github/__tests__/phase4-store.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { usePhase4Store } from '../phase4-store'; + +describe('usePhase4Store', () => { + beforeEach(() => { + const state = usePhase4Store.getState(); + state.clearDependencies(); + state.setMetrics({ + stateCounts: { new: 0, triage: 0, ready: 0, in_progress: 0, review: 0, done: 0, blocked: 0 }, + avgTimeInState: { new: 0, triage: 0, ready: 0, in_progress: 0, review: 0, done: 0, blocked: 0 }, + weeklyThroughput: [], + completenessDistribution: { low: 0, medium: 0, high: 0, excellent: 0 }, + avgBacklogAge: 0, + totalTransitions: 0, + computedAt: '', + }); + state.setMetricsError(null); + state.setTriageModeEnabled(false); + }); + + describe('dependencies', () => { + it('returns empty dependencies for unknown issue', () => { + const deps = usePhase4Store.getState().getDependencies(999); + expect(deps.tracks).toEqual([]); + expect(deps.trackedBy).toEqual([]); + }); + + it('setDependencies stores per-issue data', () => { + const deps = { + tracks: [{ issueNumber: 10, title: 'Sub', state: 'open' as const }], + trackedBy: [], + }; + usePhase4Store.getState().setDependencies(42, deps); + + expect(usePhase4Store.getState().getDependencies(42)).toEqual(deps); + }); + + it('setDependencyLoading tracks loading state', () => { + usePhase4Store.getState().setDependencyLoading(42, true); + expect(usePhase4Store.getState().isDependencyLoading(42)).toBe(true); + + usePhase4Store.getState().setDependencyLoading(42, false); + expect(usePhase4Store.getState().isDependencyLoading(42)).toBe(false); + }); + + it('setDependencyError stores error and clears loading', () => { + usePhase4Store.getState().setDependencyLoading(42, true); + usePhase4Store.getState().setDependencyError(42, 'API unavailable'); + + const state = usePhase4Store.getState(); + expect(state.dependencyErrors[42]).toBe('API unavailable'); + expect(state.isDependencyLoading(42)).toBe(false); + }); + + it('clearDependencies removes all cached data', () => { + usePhase4Store.getState().setDependencies(1, { tracks: [], trackedBy: [] }); + usePhase4Store.getState().setDependencies(2, { tracks: [], trackedBy: [] }); + + usePhase4Store.getState().clearDependencies(); + + expect(Object.keys(usePhase4Store.getState().dependencies)).toHaveLength(0); + }); + }); + + describe('metrics', () => { + it('has default empty metrics', () => { + const state = usePhase4Store.getState(); + expect(state.metrics.totalTransitions).toBe(0); + expect(state.metricsTimeWindow).toBe('30d'); + }); + + it('setMetrics updates metrics and clears loading/error', () => { + usePhase4Store.getState().setMetricsLoading(true); + usePhase4Store.getState().setMetrics({ + stateCounts: { new: 5, triage: 3, ready: 0, in_progress: 0, review: 0, done: 0, blocked: 0 }, + avgTimeInState: { new: 0, triage: 0, ready: 0, in_progress: 0, review: 0, done: 0, blocked: 0 }, + weeklyThroughput: [{ week: '2026-02-10', count: 8 }], + completenessDistribution: { low: 1, medium: 2, high: 3, excellent: 2 }, + avgBacklogAge: 86400000, + totalTransitions: 15, + computedAt: '2026-02-12T00:00:00Z', + }); + + const state = usePhase4Store.getState(); + expect(state.metrics.totalTransitions).toBe(15); + expect(state.metrics.stateCounts.new).toBe(5); + expect(state.metricsLoading).toBe(false); + expect(state.metricsError).toBeNull(); + }); + + it('setMetricsTimeWindow updates window', () => { + usePhase4Store.getState().setMetricsTimeWindow('7d'); + expect(usePhase4Store.getState().metricsTimeWindow).toBe('7d'); + }); + + it('setMetricsError stores error and clears loading', () => { + usePhase4Store.getState().setMetricsLoading(true); + usePhase4Store.getState().setMetricsError('Computation failed'); + + const state = usePhase4Store.getState(); + expect(state.metricsError).toBe('Computation failed'); + expect(state.metricsLoading).toBe(false); + }); + }); + + describe('triage mode', () => { + it('defaults to disabled', () => { + expect(usePhase4Store.getState().triageModeEnabled).toBe(false); + }); + + it('setTriageModeEnabled toggles triage mode', () => { + usePhase4Store.getState().setTriageModeEnabled(true); + expect(usePhase4Store.getState().triageModeEnabled).toBe(true); + + usePhase4Store.getState().setTriageModeEnabled(false); + expect(usePhase4Store.getState().triageModeEnabled).toBe(false); + }); + }); +}); diff --git a/apps/frontend/src/renderer/stores/github/label-sync-store.ts b/apps/frontend/src/renderer/stores/github/label-sync-store.ts new file mode 100644 index 00000000..1be4bf7e --- /dev/null +++ b/apps/frontend/src/renderer/stores/github/label-sync-store.ts @@ -0,0 +1,43 @@ +import { create } from 'zustand'; +import type { LabelSyncConfig, LabelSyncResult } from '../../../shared/types/label-sync'; +import { createDefaultLabelSyncConfig } from '../../../shared/types/label-sync'; + +interface LabelSyncState { + // Data + config: LabelSyncConfig; + isLoaded: boolean; + + // UI State + isSyncing: boolean; + error: string | null; + lastResult: LabelSyncResult | null; + + // Actions + setConfig: (config: LabelSyncConfig) => void; + setSyncing: (syncing: boolean) => void; + setError: (error: string | null) => void; + setLastResult: (result: LabelSyncResult | null) => void; + reset: () => void; +} + +export const useLabelSyncStore = create((set) => ({ + // Initial state + config: createDefaultLabelSyncConfig(), + isLoaded: false, + isSyncing: false, + error: null, + lastResult: null, + + // Actions + setConfig: (config) => set({ config, isLoaded: true, error: null }), + setSyncing: (isSyncing) => set({ isSyncing }), + setError: (error) => set({ error, isSyncing: false }), + setLastResult: (lastResult) => set({ lastResult }), + reset: () => set({ + config: createDefaultLabelSyncConfig(), + isLoaded: false, + isSyncing: false, + error: null, + lastResult: null, + }), +})); diff --git a/apps/frontend/src/renderer/stores/github/phase4-store.ts b/apps/frontend/src/renderer/stores/github/phase4-store.ts new file mode 100644 index 00000000..ac9f5642 --- /dev/null +++ b/apps/frontend/src/renderer/stores/github/phase4-store.ts @@ -0,0 +1,85 @@ +import { create } from 'zustand'; +import type { IssueDependencies } from '../../../shared/types/dependencies'; +import type { TriageMetrics, MetricsTimeWindow } from '../../../shared/types/metrics'; +import { createEmptyDependencies } from '../../../shared/types/dependencies'; +import { createEmptyMetrics } from '../../../shared/types/metrics'; + +interface Phase4State { + // Dependencies (per-issue cache) + dependencies: Record; + dependencyLoading: Record; + dependencyErrors: Record; + + // Metrics + metrics: TriageMetrics; + metricsTimeWindow: MetricsTimeWindow; + metricsLoading: boolean; + metricsError: string | null; + + // Triage mode + triageModeEnabled: boolean; + + // Dependency actions + setDependencies: (issueNumber: number, deps: IssueDependencies) => void; + setDependencyLoading: (issueNumber: number, loading: boolean) => void; + setDependencyError: (issueNumber: number, error: string) => void; + clearDependencies: () => void; + + // Metrics actions + setMetrics: (metrics: TriageMetrics) => void; + setMetricsTimeWindow: (window: MetricsTimeWindow) => void; + setMetricsLoading: (loading: boolean) => void; + setMetricsError: (error: string | null) => void; + + // Triage mode actions + setTriageModeEnabled: (enabled: boolean) => void; + + // Selectors + getDependencies: (issueNumber: number) => IssueDependencies; + isDependencyLoading: (issueNumber: number) => boolean; +} + +export const usePhase4Store = create((set, get) => ({ + // Initial state + dependencies: {}, + dependencyLoading: {}, + dependencyErrors: {}, + metrics: createEmptyMetrics(), + metricsTimeWindow: '30d', + metricsLoading: false, + metricsError: null, + triageModeEnabled: false, + + // Dependency actions + setDependencies: (issueNumber, deps) => + set((state) => ({ + dependencies: { ...state.dependencies, [issueNumber]: deps }, + dependencyErrors: { ...state.dependencyErrors, [issueNumber]: undefined as unknown as string }, + })), + + setDependencyLoading: (issueNumber, loading) => + set((state) => ({ + dependencyLoading: { ...state.dependencyLoading, [issueNumber]: loading }, + })), + + setDependencyError: (issueNumber, error) => + set((state) => ({ + dependencyErrors: { ...state.dependencyErrors, [issueNumber]: error }, + dependencyLoading: { ...state.dependencyLoading, [issueNumber]: false }, + })), + + clearDependencies: () => set({ dependencies: {}, dependencyLoading: {}, dependencyErrors: {} }), + + // Metrics actions + setMetrics: (metrics) => set({ metrics, metricsLoading: false, metricsError: null }), + setMetricsTimeWindow: (metricsTimeWindow) => set({ metricsTimeWindow }), + setMetricsLoading: (metricsLoading) => set({ metricsLoading }), + setMetricsError: (metricsError) => set({ metricsError, metricsLoading: false }), + + // Triage mode actions + setTriageModeEnabled: (triageModeEnabled) => set({ triageModeEnabled }), + + // Selectors + getDependencies: (issueNumber) => get().dependencies[issueNumber] ?? createEmptyDependencies(), + isDependencyLoading: (issueNumber) => get().dependencyLoading[issueNumber] ?? false, +}));