feat: Introduce ErrorState and LoadingState components for improved UI feedback

- Added ErrorState component to display error messages with retry functionality, enhancing user experience during issue loading failures.
- Implemented LoadingState component to provide visual feedback while issues are being fetched, improving the overall responsiveness of the GitHubIssuesView.
- Refactored GitHubIssuesView to utilize the new components, streamlining error and loading handling logic.
This commit is contained in:
Kacper
2025-12-24 02:23:12 +01:00
parent 6cd2898923
commit dd86e987a4
15 changed files with 967 additions and 795 deletions

View File

@@ -0,0 +1,3 @@
export { IssueRow } from './issue-row';
export { IssueDetailPanel } from './issue-detail-panel';
export { IssuesListHeader } from './issues-list-header';

View File

@@ -0,0 +1,242 @@
import {
Circle,
CheckCircle2,
X,
Wand2,
ExternalLink,
Loader2,
CheckCircle,
Clock,
GitPullRequest,
User,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import type { IssueDetailPanelProps } from '../types';
import { isValidationStale } from '../utils';
export function IssueDetailPanel({
issue,
validatingIssues,
cachedValidations,
onValidateIssue,
onViewCachedValidation,
onOpenInGitHub,
onClose,
onShowRevalidateConfirm,
formatDate,
}: IssueDetailPanelProps) {
const isValidating = validatingIssues.has(issue.number);
const cached = cachedValidations.get(issue.number);
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 shrink-0" />
)}
<span className="text-sm font-medium truncate">
#{issue.number} {issue.title}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{(() => {
if (isValidating) {
return (
<Button variant="default" size="sm" disabled>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Validating...
</Button>
);
}
if (cached && !isStale) {
return (
<>
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
View Result
</Button>
<Button
variant="ghost"
size="sm"
onClick={onShowRevalidateConfirm}
title="Re-validate"
>
<RefreshCw className="h-4 w-4" />
</Button>
</>
);
}
if (cached && isStale) {
return (
<>
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
View (stale)
</Button>
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, { forceRevalidate: true })}
>
<Wand2 className="h-4 w-4 mr-1" />
Re-validate
</Button>
</>
);
}
return (
<Button variant="default" size="sm" onClick={() => onValidateIssue(issue)}>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
</Button>
);
})()}
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Issue Detail Content */}
<div className="flex-1 overflow-auto p-6">
{/* Title */}
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
issue.state === 'OPEN'
? 'bg-green-500/10 text-green-500'
: 'bg-purple-500/10 text-purple-500'
)}
>
{issue.state === 'OPEN' ? 'Open' : 'Closed'}
</span>
<span>
#{issue.number} opened {formatDate(issue.createdAt)} by{' '}
<span className="font-medium text-foreground">{issue.author.login}</span>
</span>
</div>
{/* Labels */}
{issue.labels.length > 0 && (
<div className="flex items-center gap-2 mb-4 flex-wrap">
{issue.labels.map((label) => (
<span
key={label.name}
className="px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
{/* Assignees */}
{issue.assignees && issue.assignees.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Assigned to:</span>
<div className="flex items-center gap-2">
{issue.assignees.map((assignee) => (
<span
key={assignee.login}
className="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20"
>
{assignee.avatarUrl && (
<img
src={assignee.avatarUrl}
alt={assignee.login}
className="h-4 w-4 rounded-full"
/>
)}
{assignee.login}
</span>
))}
</div>
</div>
)}
{/* Linked Pull Requests */}
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
<div className="mb-6 p-3 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2 mb-2">
<GitPullRequest className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Linked Pull Requests</span>
</div>
<div className="space-y-2">
{issue.linkedPRs.map((pr) => (
<div key={pr.number} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 min-w-0">
<span
className={cn(
'px-1.5 py-0.5 text-xs font-medium rounded',
pr.state === 'open'
? 'bg-green-500/10 text-green-500'
: pr.state === 'merged'
? 'bg-purple-500/10 text-purple-500'
: 'bg-red-500/10 text-red-500'
)}
>
{pr.state === 'open' ? 'Open' : pr.state === 'merged' ? 'Merged' : 'Closed'}
</span>
<span className="text-muted-foreground">#{pr.number}</span>
<span className="truncate">{pr.title}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 shrink-0"
onClick={() => onOpenInGitHub(pr.url)}
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Body */}
{issue.body ? (
<Markdown className="text-sm">{issue.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View comments, add reactions, and more on GitHub.
</p>
<Button onClick={() => onOpenInGitHub(issue.url)}>
<ExternalLink className="h-4 w-4 mr-2" />
View Full Issue on GitHub
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import {
Circle,
CheckCircle2,
ExternalLink,
Loader2,
CheckCircle,
Sparkles,
GitPullRequest,
User,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { IssueRowProps } from '../types';
import { isValidationStale } from '../utils';
export function IssueRow({
issue,
isSelected,
onClick,
onOpenExternal,
formatDate,
cachedValidation,
isValidating,
}: IssueRowProps) {
// Check if validation exists and calculate staleness
const validationHoursSince = cachedValidation
? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60)
: null;
const isValidationStaleValue =
validationHoursSince !== null && isValidationStale(cachedValidation!.validatedAt);
// Check if validation is unviewed (exists, not stale, not viewed)
const hasUnviewedValidation =
cachedValidation && !cachedValidation.viewedAt && !isValidationStaleValue;
// Check if validation has been viewed (exists and was viewed)
const hasViewedValidation =
cachedValidation && cachedValidation.viewedAt && !isValidationStaleValue;
return (
<div
className={cn(
'group flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
isSelected && 'bg-accent'
)}
onClick={onClick}
>
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{issue.title}</span>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted-foreground">
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
</span>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{/* Labels */}
{issue.labels.map((label) => (
<span
key={label.name}
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
{/* Linked PR indicator */}
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/20">
<GitPullRequest className="h-3 w-3" />
{issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''}
</span>
)}
{/* Assignee indicator */}
{issue.assignees && issue.assignees.length > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20">
<User className="h-3 w-3" />
{issue.assignees.map((a) => a.login).join(', ')}
</span>
)}
{/* Validating indicator */}
{isValidating && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/10 text-primary border border-primary/20 animate-in fade-in duration-200">
<Loader2 className="h-3 w-3 animate-spin" />
Analyzing...
</span>
)}
{/* Unviewed validation indicator */}
{!isValidating && hasUnviewedValidation && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20 animate-in fade-in duration-200">
<Sparkles className="h-3 w-3" />
Analysis Ready
</span>
)}
{/* Viewed validation indicator */}
{!isValidating && hasViewedValidation && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-green-500/10 text-green-500 border border-green-500/20">
<CheckCircle className="h-3 w-3" />
Validated
</span>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { CircleDot, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface IssuesListHeaderProps {
openCount: number;
closedCount: number;
refreshing: boolean;
onRefresh: () => void;
}
export function IssuesListHeader({
openCount,
closedCount,
refreshing,
onRefresh,
}: IssuesListHeaderProps) {
const totalIssues = openCount + closedCount;
return (
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10">
<CircleDot className="h-5 w-5 text-green-500" />
</div>
<div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>
);
}

View File

@@ -0,0 +1 @@
export const VALIDATION_STALENESS_HOURS = 24;

View File

@@ -0,0 +1 @@
export { ValidationDialog } from './validation-dialog';

View File

@@ -152,7 +152,7 @@ export function ValidationDialog({
{/* Bug Confirmed Badge */}
{validationResult.bugConfirmed && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0" />
<AlertTriangle className="h-5 w-5 text-red-500 shrink-0" />
<span className="text-sm font-medium text-red-500">Bug Confirmed in Codebase</span>
</div>
)}

View File

@@ -0,0 +1,2 @@
export { useGithubIssues } from './use-github-issues';
export { useIssueValidation } from './use-issue-validation';

View File

@@ -0,0 +1,58 @@
import { useState, useEffect, useCallback } from 'react';
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
export function useGithubIssues() {
const { currentProject } = useAppStore();
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchIssues = useCallback(async () => {
if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
try {
setError(null);
const api = getElectronAPI();
if (api.github) {
const result = await api.github.listIssues(currentProject.path);
if (result.success) {
setOpenIssues(result.openIssues || []);
setClosedIssues(result.closedIssues || []);
} else {
setError(result.error || 'Failed to fetch issues');
}
}
} catch (err) {
console.error('[GitHubIssuesView] Error fetching issues:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [currentProject?.path]);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
const refresh = useCallback(() => {
setRefreshing(true);
fetchIssues();
}, [fetchIssues]);
return {
openIssues,
closedIssues,
loading,
refreshing,
error,
refresh,
};
}

View File

@@ -0,0 +1,330 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
getElectronAPI,
GitHubIssue,
IssueValidationResult,
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
interface UseIssueValidationOptions {
selectedIssue: GitHubIssue | null;
showValidationDialog: boolean;
onValidationResultChange: (result: IssueValidationResult | null) => void;
onShowValidationDialogChange: (show: boolean) => void;
}
export function useIssueValidation({
selectedIssue,
showValidationDialog,
onValidationResultChange,
onShowValidationDialogChange,
}: UseIssueValidationOptions) {
const { currentProject, validationModel, muteDoneSound } = useAppStore();
const [validatingIssues, setValidatingIssues] = useState<Set<number>>(new Set());
const [cachedValidations, setCachedValidations] = useState<Map<number, StoredValidation>>(
new Map()
);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Refs for stable event handler (avoids re-subscribing on state changes)
const selectedIssueRef = useRef<GitHubIssue | null>(null);
const showValidationDialogRef = useRef(false);
// Keep refs in sync with state for stable event handler
useEffect(() => {
selectedIssueRef.current = selectedIssue;
}, [selectedIssue]);
useEffect(() => {
showValidationDialogRef.current = showValidationDialog;
}, [showValidationDialog]);
// Load cached validations on mount
useEffect(() => {
let isMounted = true;
const loadCachedValidations = async () => {
if (!currentProject?.path) return;
try {
const api = getElectronAPI();
if (api.github?.getValidations) {
const result = await api.github.getValidations(currentProject.path);
if (isMounted && result.success && result.validations) {
const map = new Map<number, StoredValidation>();
for (const v of result.validations) {
map.set(v.issueNumber, v);
}
setCachedValidations(map);
}
}
} catch (err) {
if (isMounted) {
console.error('[GitHubIssuesView] Failed to load cached validations:', err);
}
}
};
loadCachedValidations();
return () => {
isMounted = false;
};
}, [currentProject?.path]);
// Load running validations on mount (restore validatingIssues state)
useEffect(() => {
let isMounted = true;
const loadRunningValidations = async () => {
if (!currentProject?.path) return;
try {
const api = getElectronAPI();
if (api.github?.getValidationStatus) {
const result = await api.github.getValidationStatus(currentProject.path);
if (isMounted && result.success && result.runningIssues) {
setValidatingIssues(new Set(result.runningIssues));
}
}
} catch (err) {
if (isMounted) {
console.error('[GitHubIssuesView] Failed to load running validations:', err);
}
}
};
loadRunningValidations();
return () => {
isMounted = false;
};
}, [currentProject?.path]);
// Subscribe to validation events
useEffect(() => {
const api = getElectronAPI();
if (!api.github?.onValidationEvent) return;
const handleValidationEvent = (event: IssueValidationEvent) => {
// Only handle events for current project
if (event.projectPath !== currentProject?.path) return;
switch (event.type) {
case 'issue_validation_start':
setValidatingIssues((prev) => new Set([...prev, event.issueNumber]));
break;
case 'issue_validation_complete':
setValidatingIssues((prev) => {
const next = new Set(prev);
next.delete(event.issueNumber);
return next;
});
// Update cached validations (use event.model to avoid stale closure race condition)
setCachedValidations((prev) => {
const next = new Map(prev);
next.set(event.issueNumber, {
issueNumber: event.issueNumber,
issueTitle: event.issueTitle,
validatedAt: new Date().toISOString(),
model: event.model,
result: event.result,
});
return next;
});
// Show toast notification
toast.success(`Issue #${event.issueNumber} validated: ${event.result.verdict}`, {
description:
event.result.verdict === 'valid'
? 'Issue is ready to be converted to a task'
: event.result.verdict === 'invalid'
? 'Issue may have problems'
: 'Issue needs clarification',
});
// Play audio notification (if not muted)
if (!muteDoneSound) {
try {
if (!audioRef.current) {
audioRef.current = new Audio('/sounds/ding.mp3');
}
audioRef.current.play().catch(() => {
// Audio play might fail due to browser restrictions
});
} catch {
// Ignore audio errors
}
}
// If validation dialog is open for this issue, update the result
if (
selectedIssueRef.current?.number === event.issueNumber &&
showValidationDialogRef.current
) {
onValidationResultChange(event.result);
}
break;
case 'issue_validation_error':
setValidatingIssues((prev) => {
const next = new Set(prev);
next.delete(event.issueNumber);
return next;
});
toast.error(`Validation failed for issue #${event.issueNumber}`, {
description: event.error,
});
if (
selectedIssueRef.current?.number === event.issueNumber &&
showValidationDialogRef.current
) {
onShowValidationDialogChange(false);
}
break;
}
};
const unsubscribe = api.github.onValidationEvent(handleValidationEvent);
return () => unsubscribe();
}, [currentProject?.path, muteDoneSound, onValidationResultChange, onShowValidationDialogChange]);
// Cleanup audio element on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
const handleValidateIssue = useCallback(
async (
issue: GitHubIssue,
options: { showDialog?: boolean; forceRevalidate?: boolean } = {}
) => {
const { showDialog = true, forceRevalidate = false } = options;
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
// Check if already validating this issue
if (validatingIssues.has(issue.number)) {
toast.info(`Validation already in progress for issue #${issue.number}`);
return;
}
// Check for cached result - if fresh, show it directly (unless force revalidate)
const cached = cachedValidations.get(issue.number);
if (cached && showDialog && !forceRevalidate) {
// Check if validation is stale
if (!isValidationStale(cached.validatedAt)) {
// Show cached result directly
onValidationResultChange(cached.result);
onShowValidationDialogChange(true);
return;
}
}
// Start async validation
onValidationResultChange(null);
if (showDialog) {
onShowValidationDialogChange(true);
}
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
const result = await api.github.validateIssue(
currentProject.path,
{
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
},
validationModel
);
if (!result.success) {
toast.error(result.error || 'Failed to start validation');
if (showDialog) {
onShowValidationDialogChange(false);
}
}
// On success, the result will come through the event stream
}
} catch (err) {
console.error('[GitHubIssuesView] Validation error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
if (showDialog) {
onShowValidationDialogChange(false);
}
}
},
[
currentProject?.path,
validatingIssues,
cachedValidations,
validationModel,
onValidationResultChange,
onShowValidationDialogChange,
]
);
// View cached validation result
const handleViewCachedValidation = useCallback(
async (issue: GitHubIssue) => {
const cached = cachedValidations.get(issue.number);
if (cached) {
onValidationResultChange(cached.result);
onShowValidationDialogChange(true);
// Mark as viewed if not already viewed
if (!cached.viewedAt && currentProject?.path) {
try {
const api = getElectronAPI();
if (api.github?.markValidationViewed) {
await api.github.markValidationViewed(currentProject.path, issue.number);
// Update local state
setCachedValidations((prev) => {
const next = new Map(prev);
const updated = prev.get(issue.number);
if (updated) {
next.set(issue.number, {
...updated,
viewedAt: new Date().toISOString(),
});
}
return next;
});
}
} catch (err) {
console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err);
}
}
}
},
[
cachedValidations,
currentProject?.path,
onValidationResultChange,
onShowValidationDialogChange,
]
);
return {
validatingIssues,
cachedValidations,
handleValidateIssue,
handleViewCachedValidation,
};
}

View File

@@ -0,0 +1,28 @@
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
export interface IssueRowProps {
issue: GitHubIssue;
isSelected: boolean;
onClick: () => void;
onOpenExternal: () => void;
formatDate: (date: string) => string;
/** Cached validation for this issue (if any) */
cachedValidation?: StoredValidation | null;
/** Whether validation is currently running for this issue */
isValidating?: boolean;
}
export interface IssueDetailPanelProps {
issue: GitHubIssue;
validatingIssues: Set<number>;
cachedValidations: Map<number, StoredValidation>;
onValidateIssue: (
issue: GitHubIssue,
options?: { showDialog?: boolean; forceRevalidate?: boolean }
) => Promise<void>;
onViewCachedValidation: (issue: GitHubIssue) => Promise<void>;
onOpenInGitHub: (url: string) => void;
onClose: () => void;
onShowRevalidateConfirm: () => void;
formatDate: (date: string) => string;
}

View File

@@ -0,0 +1,33 @@
import type { IssueComplexity } from '@/lib/electron';
import { VALIDATION_STALENESS_HOURS } from './constants';
/**
* Map issue complexity to feature priority.
* Lower complexity issues get higher priority (1 = high, 2 = medium).
*/
export function getFeaturePriority(complexity: IssueComplexity | undefined): number {
switch (complexity) {
case 'trivial':
case 'simple':
return 1; // High priority for easy wins
case 'moderate':
case 'complex':
case 'very_complex':
default:
return 2; // Medium priority for larger efforts
}
}
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export function isValidationStale(validatedAt: string): boolean {
const hoursSinceValidation = (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60);
return hoursSinceValidation > VALIDATION_STALENESS_HOURS;
}