feat(enrichment): WP-5 Zustand stores — label sync store, Phase 4 store (deps, metrics, triage mode)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Sondre Engebråten
2026-02-12 19:16:46 +01:00
co-authored by Claude Opus 4.6
parent fa00900b0a
commit cb6594bc0c
4 changed files with 316 additions and 0 deletions
@@ -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();
});
});
@@ -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);
});
});
});
@@ -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<LabelSyncState>((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,
}),
}));
@@ -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<number, IssueDependencies>;
dependencyLoading: Record<number, boolean>;
dependencyErrors: Record<number, string>;
// 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<Phase4State>((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,
}));