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:
co-authored by
Claude Opus 4.6
parent
fa00900b0a
commit
cb6594bc0c
@@ -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,
|
||||
}));
|
||||
Reference in New Issue
Block a user