Function bodies 254 total
MultiSelectField function · typescript · L138-L165 (28 LOC)frontend/src/components/PropertyEditor.tsx
function MultiSelectField({ options, value, onChange }: { options: string[]; value: string[]; onChange: (v: unknown) => void }) {
const toggle = (opt: string) => {
const next = value.includes(opt) ? value.filter((v) => v !== opt) : [...value, opt];
onChange(next.length > 0 ? next : null);
};
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const active = value.includes(opt);
return (
<button
key={opt}
type="button"
onClick={() => toggle(opt)}
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors ${
active
? 'border-brand-500 bg-brand-500/15 text-brand-500'
: 'border-edge-base text-ink-muted hover:border-brand-500/30 hover:text-ink-secondary'
}`}
>
{opt}
</button>
);
})}
</div>
);
}AddPropertyForm function · typescript · L172-L254 (83 LOC)frontend/src/components/PropertyEditor.tsx
export function AddPropertyForm({ onAdd }: AddPropertyProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [type, setType] = useState<PropertyType>('text');
const [optStr, setOptStr] = useState('');
const [nameError, setNameError] = useState(false);
const typeLabels: Record<PropertyType, string> = {
text: t('property.text'),
number: t('property.number'),
select: t('property.select'),
multi_select: t('property.multi_select'),
date: t('property.date'),
checkbox: t('property.checkbox'),
};
const submit = () => {
if (!name.trim()) {
setNameError(true);
return;
}
setNameError(false);
const options = (type === 'select' || type === 'multi_select')
? optStr.split(',').map((s) => s.trim()).filter(Boolean)
: [];
onAdd(name.trim(), type, options);
setName('');
setType('text');
setOptStr('');
setOpen(false);
};
if (!opSearchDialog function · typescript · L6-L160 (155 LOC)frontend/src/components/SearchDialog.tsx
export function SearchDialog() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIdx, setSelectedIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const boards = useBoards();
const issues = useIssues({});
// Cmd+K / Ctrl+K to open
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setOpen(prev => !prev);
}
if (e.key === 'Escape' && open) {
setOpen(false);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open]);
// Auto-focus input when opened
useEffect(() => {
if (open) {
setQuery('');
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Filter results
const results = useMemo(() => {
if (!query.trim()) return { bogo function · typescript · L57-L60 (4 LOC)frontend/src/components/SearchDialog.tsx
function go(path: string) {
navigate(path);
setOpen(false);
}ShortcutRow function · typescript · L16-L32 (17 LOC)frontend/src/components/ShortcutsDialog.tsx
function ShortcutRow({ shortcut }: { shortcut: Shortcut }) {
return (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-ink-secondary">{shortcut.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, idx) => (
<kbd
key={idx}
className="inline-flex items-center rounded border border-edge-base bg-surface-muted px-2 py-0.5 text-xs font-medium text-ink-secondary"
>
{key}
</kbd>
))}
</div>
</div>
);
}ShortcutsDialog function · typescript · L34-L90 (57 LOC)frontend/src/components/ShortcutsDialog.tsx
export function ShortcutsDialog() {
const [open, setOpen] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isInputFocused =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
if (e.key === '?' && !isInputFocused) {
e.preventDefault();
setOpen(prev => !prev);
}
if (e.key === 'Escape' && open) {
setOpen(false);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] p-4">
<div className="absolute inset-0 bg-black/40" onClick={() => setOpen(false)} aria-hidden />
<div
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
className="Skeleton function · typescript · L6-L18 (13 LOC)frontend/src/components/Skeleton.tsx
export function Skeleton({ className = 'h-4 w-full', count = 1 }: Props) {
return (
<>
{Array.from({ length: count }, (_, i) => (
<div
key={i}
className={`animate-pulse rounded-md bg-surface-muted ${className}`}
aria-hidden
/>
))}
</>
);
}Repobility · severity-and-effort ranking · https://repobility.com
CardSkeleton function · typescript · L20-L27 (8 LOC)frontend/src/components/Skeleton.tsx
export function CardSkeleton() {
return (
<div className="flex flex-col gap-3 rounded-lg border border-edge-base bg-surface-subtle p-4">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
);
}BoardSkeleton function · typescript · L29-L41 (13 LOC)frontend/src/components/Skeleton.tsx
export function BoardSkeleton() {
return (
<div className="grid gap-4 lg:grid-cols-4 md:grid-cols-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex min-h-[16rem] flex-col gap-3 rounded-lg border border-edge-base bg-surface-subtle p-3">
<Skeleton className="h-4 w-20" />
<CardSkeleton />
<CardSkeleton />
</div>
))}
</div>
);
}StatusBadge function · typescript · L13-L26 (14 LOC)frontend/src/components/StatusBadge.tsx
export function StatusBadge({ status }: { status: IssueStatus }) {
const { t } = useTranslation();
const label = t(`status.${status}`);
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${classes[status]}`}
role="status"
aria-label={label}
>
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-current" />
{label}
</span>
);
}StatusStepper function · typescript · L21-L88 (68 LOC)frontend/src/components/StatusStepper.tsx
export function StatusStepper({ current, onSelect, disabled }: Props) {
const { t } = useTranslation();
const currentIdx = STEP_STATUSES.indexOf(current);
return (
<nav aria-label={t('stepper.moveTo', { label: '' }).trim()} className="flex items-center gap-1">
{STEP_STATUSES.map((status, i) => {
const label = t(`status.${status}`);
const isActive = status === current;
const isPast = i < currentIdx;
const canClick =
!disabled &&
!isActive &&
status !== 'Pending' &&
status !== 'Approved' &&
current !== 'Pending';
const hint =
status === 'Approved'
? t('stepper.approveOnly')
: status === 'Pending'
? t('stepper.noPending')
: t('stepper.moveTo', { label });
const showHint = !canClick && !isActive;
return (
<div key={status} className="flex items-center">
{i > 0 && (
<div
getTemplates function · typescript · L21-L25 (5 LOC)frontend/src/components/TemplateDialog.tsx
function getTemplates(boardId: string): Template[] {
try {
return JSON.parse(localStorage.getItem(`mp-templates-${boardId}`) ?? '[]');
} catch { return []; }
}saveTemplates function · typescript · L27-L29 (3 LOC)frontend/src/components/TemplateDialog.tsx
function saveTemplates(boardId: string, templates: Template[]) {
localStorage.setItem(`mp-templates-${boardId}`, JSON.stringify(templates));
}TemplateDialog function · typescript · L31-L200 (170 LOC)frontend/src/components/TemplateDialog.tsx
export function TemplateDialog({ boardId, open, onClose, onSelect }: Props) {
const { t } = useTranslation();
const [templates, setTemplates] = useState<Template[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newTitle, setNewTitle] = useState('');
const [newBody, setNewBody] = useState('');
const [newInstructions, setNewInstructions] = useState('');
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
setTemplates(getTemplates(boardId));
setShowAdd(false);
setNewName('');
setNewTitle('');
setNewBody('');
setNewInstructions('');
closeRef.current?.focus();
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, boardId, onClose]);
if (!open) return null;
function handleDelete(ihandleDelete function · typescript · L59-L63 (5 LOC)frontend/src/components/TemplateDialog.tsx
function handleDelete(id: string) {
const updated = templates.filter((t) => t.id !== id);
setTemplates(updated);
saveTemplates(boardId, updated);
}Repobility · code-quality intelligence · https://repobility.com
handleAdd function · typescript · L65-L82 (18 LOC)frontend/src/components/TemplateDialog.tsx
function handleAdd() {
if (!newName.trim()) return;
const tmpl: Template = {
id: crypto.randomUUID(),
name: newName.trim(),
title: newTitle.trim(),
body: newBody.trim(),
instructions: newInstructions.trim(),
};
const updated = [...templates, tmpl];
setTemplates(updated);
saveTemplates(boardId, updated);
setShowAdd(false);
setNewName('');
setNewTitle('');
setNewBody('');
setNewInstructions('');
}ThemeToggle function · typescript · L5-L18 (14 LOC)frontend/src/components/ThemeToggle.tsx
export function ThemeToggle() {
const { mode, toggle } = useTheme();
const { t } = useTranslation();
return (
<button
type="button"
onClick={toggle}
aria-label={mode === 'dark' ? t('theme.toLight') : t('theme.toDark')}
className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-edge-base text-ink-secondary transition-all duration-200 hover:bg-surface-muted hover:text-brand-500 hover:border-brand-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 active:scale-95"
>
{mode === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
);
}ToastProvider function · typescript · L16-L65 (50 LOC)frontend/src/components/Toast.tsx
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const { t } = useTranslation();
const toast = useCallback((type: ToastType, message: string) => {
const id = nextId++;
const duration = type === 'error' ? 8000 : 4000;
setToasts((prev) => [...prev, { id, type, message }]);
setTimeout(() => setToasts((prev) => prev.filter((item) => item.id !== id)), duration);
}, []);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((item) => item.id !== id));
}, []);
return (
<Ctx.Provider value={{ toast }}>
{children}
<div
aria-live="polite"
className="fixed bottom-6 right-6 z-50 flex flex-col gap-2"
>
{toasts.map((item) => (
<div
key={item.id}
className={`flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm transition-all duration-300 animate-in slide-in-frodetectPlatform function · typescript · L27-L32 (6 LOC)frontend/src/components/WebhookSettings.tsx
function detectPlatform(url: string): Platform {
if (url.includes('discord.com')) return 'discord';
if (url.includes('slack.com')) return 'slack';
if (url.includes('telegram.org')) return 'telegram';
return 'custom';
}PlatformBadge function · typescript · L34-L42 (9 LOC)frontend/src/components/WebhookSettings.tsx
function PlatformBadge({ url }: { url: string }) {
const p = PLATFORMS.find((pl) => pl.value === detectPlatform(url))!;
const Icon = p.icon;
return (
<span className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${p.color}`}>
<Icon size={12} /> {p.label}
</span>
);
}WebhookSettings function · typescript · L44-L206 (163 LOC)frontend/src/components/WebhookSettings.tsx
export function WebhookSettings({ boardId }: { boardId: string }) {
const { t } = useTranslation();
const { toast } = useToast();
const webhooks = useWebhooks(boardId);
const createWh = useCreateWebhook();
const deleteWh = useDeleteWebhook();
const [open, setOpen] = useState(false);
const [confirmWh, setConfirmWh] = useState<string | null>(null);
const [platform, setPlatform] = useState<Platform>('discord');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [events, setEvents] = useState<WebhookEvent[]>(['issue.approved']);
const eventKeyMap: Record<WebhookEvent, string> = {
'issue.created': 'webhook.events.issue_created',
'issue.approved': 'webhook.events.issue_approved',
'issue.status_changed': 'webhook.events.issue_status_changed',
'issue.deleted': 'webhook.events.issue_deleted',
};
const ALL_EVENTS: { value: WebhookEvent; label: string }[] = ALL_EVENT_VALUES.map((v) => ({
value: v,
label: t(eventKeyMrelativeTime function · typescript · L10-L25 (16 LOC)frontend/src/features/approval/ApprovalPage.tsx
function relativeTime(iso: string, locale: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
try {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (days > 0) return rtf.format(-days, 'day');
if (hours > 0) return rtf.format(-hours, 'hour');
return rtf.format(-minutes, 'minute');
} catch {
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
return `${minutes}m ago`;
}
}ApprovalPage function · typescript · L27-L228 (202 LOC)frontend/src/features/approval/ApprovalPage.tsx
export default function ApprovalPage() {
const { t, i18n } = useTranslation();
const issues = useIssues({ status: 'Pending' });
const boards = useBoards();
const approveIssue = useApproveIssue();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [approving, setApproving] = useState<Set<string>>(new Set());
const boardMap = useMemo(() => {
const m = new Map<string, string>();
boards.data?.forEach((b) => m.set(b.id, b.name));
return m;
}, [boards.data]);
const pendingIssues = issues.data ?? [];
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
if (selected.size === pendingIssues.length) {
setSelected(new Set());
} else {
setSelected(new Set(pendingIssues.map((i) => i.id)));
}
};
const handleApprove = async (id: string) => {
Repobility — the code-quality scanner for AI-generated software · https://repobility.com
onCreate function · typescript · L106-L116 (11 LOC)frontend/src/features/board/BoardPage.tsx
async function onCreate(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !boardId) return;
try {
await createIssue.mutateAsync({ boardId, title: title.trim() });
setTitle('');
toast('success', t('board.issueCreated'));
} catch (err) {
toast('error', (err as { message?: string }).message ?? t('board.issueCreateFailed'));
}
}onDragStart function · typescript · L118-L121 (4 LOC)frontend/src/features/board/BoardPage.tsx
function onDragStart(e: DragStartEvent) {
const issue = (issues.data ?? []).find((i) => i.id === String(e.active.id));
setActiveIssue(issue ?? null);
}onDragEnd function · typescript · L123-L149 (27 LOC)frontend/src/features/board/BoardPage.tsx
async function onDragEnd(e: DragEndEvent) {
setActiveIssue(null);
setErrMsg(null);
if (!e.over) return;
const toStatus = e.over.id as IssueStatus;
const issueId = String(e.active.id);
const current = (issues.data ?? []).find((i) => i.id === issueId);
if (!current || current.status === toStatus) return;
// Optimistic update: immediately move the card in the cache
const issuesKey = ['issues', { boardId }];
const previousIssues = qc.getQueryData(issuesKey);
qc.setQueryData(issuesKey, (old: any) =>
old?.map((i: any) => i.id === issueId ? { ...i, status: toStatus } : i)
);
try {
await updateIssue.mutateAsync({ id: issueId, patch: { status: toStatus } });
toast('success', t('board.statusChanged', { status: toStatus }));
} catch (err) {
// Rollback on error
qc.setQueryData(issuesKey, previousIssues);
const msg = t('board.statusChangeFailed');
setErrMsg(msg);
toast('error', msg);
}
handleReorder function · typescript · L151-L177 (27 LOC)frontend/src/features/board/BoardPage.tsx
async function handleReorder(status: IssueStatus, issueId: string, direction: 'up' | 'down') {
if (!boardId) return;
const columnIssues = grouped[status];
const idx = columnIssues.findIndex((i) => i.id === issueId);
if (idx === -1) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= columnIssues.length) return;
// Build the new order for this column
const newColumnOrder = [...columnIssues];
[newColumnOrder[idx], newColumnOrder[swapIdx]] = [newColumnOrder[swapIdx], newColumnOrder[idx]];
// Optimistic update: reorder in cache preserving issues from other columns
const issuesKey = ['issues', { boardId }];
const previousIssues = qc.getQueryData(issuesKey);
const allIssues: Issue[] = (issues.data ?? []);
const otherIssues = allIssues.filter((i) => i.status !== status);
qc.setQueryData(issuesKey, [...otherIssues, ...newColumnOrder]);
try {
const allColumnIds = newColumnOrder.mBoardsListPage function · typescript · L57-L136 (80 LOC)frontend/src/features/board/BoardsListPage.tsx
export default function BoardsListPage() {
const { t } = useTranslation();
const [name, setName] = useState('');
const boards = useBoards();
const createBoard = useCreateBoard();
const { toast } = useToast();
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createBoard.mutateAsync({ name: name.trim() });
setName('');
toast('success', t('board.issueCreated'));
} catch {
toast('error', t('board.issueCreateFailed'));
}
};
return (
<section className="flex flex-col gap-6">
<header className="fade-up flex items-end justify-between">
<div>
<h1 className="text-2xl font-bold">{t('board.title')}</h1>
<p className="mt-1 text-ink-secondary">{t('board.description')}</p>
</div>
</header>
<form onSubmit={submit} className="fade-up flex gap-2" style={{ animationDelay: '60ms' }}>
<Input
placeholder={t('board.nelapsedTime function · typescript · L10-L16 (7 LOC)frontend/src/features/board/IssueCard.tsx
function elapsedTime(dateStr: string): string {
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}IssueCard function · typescript · L23-L145 (123 LOC)frontend/src/features/board/IssueCard.tsx
export function IssueCard({ issue, boardProperties = [] }: Props) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: issue.id,
});
const approve = useApproveIssue();
const updateIssue = useUpdateIssue();
const deleteIssue = useDeleteIssue();
const navigate = useNavigate();
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
// DragOverlay handles the visual ghost — hide the original card during drag
const style: React.CSSProperties | undefined = isDragging
? { opacity: 0.3, pointerEvents: 'none' }
: undefined;
// 속성 값 추출 (비어있지 않은 것만)
const propValues = boardProperties
.map((p) => ({ prop: p, value: issue.properties?.[p.id] }))
.filter((pv) => pv.value !== undefined && pv.value !== null && pv.value !== '');
return (
<article
ref={setNodeRef}
PropertyPill function · typescript · L147-L183 (37 LOC)frontend/src/features/board/IssueCard.tsx
function PropertyPill({ prop, value }: { prop: BoardProperty; value: unknown }) {
if (prop.type === 'checkbox') {
if (!value) return null;
return (
<span className="inline-flex items-center gap-0.5 rounded bg-status-done/10 px-1.5 py-0.5 text-[11px] text-status-done">
<Check size={10} /> {prop.name}
</span>
);
}
if (prop.type === 'date') {
return (
<span className="inline-flex items-center gap-0.5 rounded bg-status-inProgress/10 px-1.5 py-0.5 text-[11px] text-status-inProgress">
<Calendar size={10} /> {String(value)}
</span>
);
}
if (prop.type === 'multi_select' && Array.isArray(value)) {
return (
<>
{value.map((v: string) => (
<span key={v} className="inline-flex items-center gap-0.5 rounded bg-brand-500/10 px-1.5 py-0.5 text-[11px] text-brand-500">
<Tag size={9} /> {v}
</span>
))}
</>
);
}
// select, text, number
return (
<span classNaPowered by Repobility — scan your code at https://repobility.com
KanbanColumn function · typescript · L26-L111 (86 LOC)frontend/src/features/board/KanbanColumn.tsx
export function KanbanColumn({ status, title, issues, boardProperties, onCreateIssue, onReorder }: Props) {
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({ id: status });
const [adding, setAdding] = useState(false);
const [newTitle, setNewTitle] = useState('');
return (
<section
ref={setNodeRef}
aria-label={t('kanban.columnLabel', { title })}
className={`min-w-[260px] flex-1 flex min-h-[24rem] flex-col gap-2.5 rounded-lg border border-t-[3px] ${topAccent[status]} border-edge-base bg-surface-subtle p-3 transition-colors ${
isOver ? 'border-brand-500 bg-brand-500/5' : ''
}`}
>
<header className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink-secondary">{title}</h2>
<span className="rounded-full bg-surface-muted px-2 py-0.5 text-xs tabular-nums text-ink-secondary">
{issues.length}
</span>
</header>
<div className="flex flexpad function · typescript · L10-L12 (3 LOC)frontend/src/features/calendar/CalendarPage.tsx
function pad(n: number): string {
return n.toString().padStart(2, '0');
}ymd function · typescript · L14-L16 (3 LOC)frontend/src/features/calendar/CalendarPage.tsx
function ymd(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}startOfMonth function · typescript · L18-L20 (3 LOC)frontend/src/features/calendar/CalendarPage.tsx
function startOfMonth(y: number, m: number): Date {
return new Date(y, m - 1, 1);
}daysInMonth function · typescript · L22-L24 (3 LOC)frontend/src/features/calendar/CalendarPage.tsx
function daysInMonth(y: number, m: number): number {
return new Date(y, m, 0).getDate();
}CalendarPage function · typescript · L26-L143 (118 LOC)frontend/src/features/calendar/CalendarPage.tsx
export default function CalendarPage() {
const { t } = useTranslation();
const today = useMemo(() => new Date(), []);
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth() + 1);
const [selected, setSelected] = useState<string>(ymd(today));
const monthStats = useMonthStats(year, month);
const dayStats = useDayStats(selected);
const countsByDate = useMemo(() => {
const map = new Map<string, DayCount>();
(monthStats.data ?? []).forEach((d) => {
const key = d.date.slice(0, 10);
map.set(key, d);
});
return map;
}, [monthStats.data]);
const firstOfMonth = startOfMonth(year, month);
const firstWeekday = firstOfMonth.getDay(); // 0=일
const total = daysInMonth(year, month);
const cells: Array<{ day: number | null; dateStr?: string }> = [];
for (let i = 0; i < firstWeekday; i++) cells.push({ day: null });
for (let d = 1; d <= total; d++) {
cells.push({ day: d, dateStr: `${year}-shift function · typescript · L56-L60 (5 LOC)frontend/src/features/calendar/CalendarPage.tsx
function shift(delta: number) {
const d = new Date(year, month - 1 + delta, 1);
setYear(d.getFullYear());
setMonth(d.getMonth() + 1);
}DaySection function · typescript · L145-L173 (29 LOC)frontend/src/features/calendar/CalendarPage.tsx
function DaySection({ title, issues }: { title: string; issues: Issue[] }) {
const { t } = useTranslation();
return (
<section aria-label={t('calendar.issuesLabel', { title })}>
<h3 className="mb-2 text-sm font-medium text-ink-secondary">
{title} <span className="text-ink-muted">({issues.length})</span>
</h3>
{issues.length === 0 ? (
<div className="flex items-center gap-2 rounded-md border border-dashed border-edge-base py-3 px-3">
<span className="text-xs text-ink-muted">{t('calendar.none')}</span>
</div>
) : (
<ul className="flex flex-col gap-1">
{issues.map((iss) => (
<li key={iss.id}>
<Link
to={`/issues/${iss.id}`}
className="flex items-center justify-between gap-2 rounded-md bg-surface-base px-2 py-1.5 text-sm hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
Repobility · severity-and-effort ranking · https://repobility.com
AgentMetrics function · typescript · L6-L96 (91 LOC)frontend/src/features/home/AgentMetrics.tsx
export function AgentMetrics() {
const { t } = useTranslation();
const issues = useIssues({});
const metrics = useMemo(() => {
const all = issues.data ?? [];
const total = all.length;
const done = all.filter(i => i.status === 'Done').length;
const rejected = all.filter(i => i.status === 'Rejected').length;
const approved = all.filter(i => i.status === 'Approved').length;
const inProgress = all.filter(i => i.status === 'InProgress').length;
const pending = all.filter(i => i.status === 'Pending').length;
// Completion rate
const completionRate = total > 0 ? Math.round((done / total) * 100) : 0;
// Approval rate (approved + inProgress + done out of total non-pending)
const reviewed = total - pending;
const approvalRate = reviewed > 0 ? Math.round(((approved + inProgress + done) / reviewed) * 100) : 0;
// Average cycle time (created → done, for completed issues)
const completedIssues = all.filter(i => i.status === 'Done');
MetricCard function · typescript · L98-L109 (12 LOC)frontend/src/features/home/AgentMetrics.tsx
function MetricCard({ icon, label, value, sub }: { icon: React.ReactNode; label: string; value: string; sub: string }) {
return (
<div className="flex flex-col gap-1 rounded-lg border border-edge-base bg-gradient-to-br from-surface-subtle to-surface-muted p-3">
<div className="flex items-center gap-1.5">
{icon}
<span className="text-xs font-medium text-ink-muted">{label}</span>
</div>
<span className="text-2xl font-bold tabular-nums text-ink-primary">{value}</span>
<span className="text-xs text-ink-muted tabular-nums">{sub}</span>
</div>
);
}HeroBanner function · typescript · L5-L26 (22 LOC)frontend/src/features/home/HeroBanner.tsx
export default function HeroBanner() {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-4 rounded-xl border border-edge-base bg-gradient-to-r from-brand-500/8 to-surface-subtle px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-500/15 text-brand-500">
<Rocket size={20} />
</div>
<div>
<p className="text-sm font-semibold text-ink-primary">{t('app.tagline')}</p>
<p className="text-xs text-ink-secondary">{t('app.description')}</p>
</div>
</div>
<Link
to="/boards"
className="hidden sm:flex items-center gap-1 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-brand-600"
>
{t('nav.boards')} <ArrowRight size={12} />
</Link>
</div>
);
}todayString function · typescript · L11-L14 (4 LOC)frontend/src/features/home/HomePage.tsx
function todayString(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}relativeTime function · typescript · L16-L25 (10 LOC)frontend/src/features/home/HomePage.tsx
function relativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = Math.floor((now - then) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return new Date(dateStr).toLocaleDateString();
}HomePage function · typescript · L27-L166 (140 LOC)frontend/src/features/home/HomePage.tsx
export default function HomePage() {
const date = useMemo(todayString, []);
const { t } = useTranslation();
const day = useDayStats(date);
const approved = useIssues({ status: 'Approved' });
const inProgress = useIssues({ status: 'InProgress' });
const boards = useBoards();
const created = day.data?.created.length ?? 0;
const approvedCount = day.data?.approved.length ?? 0;
const completed = day.data?.completed.length ?? 0;
return (
<section className="flex flex-col gap-6">
<header className="fade-up">
<h1 className="text-2xl font-bold tracking-tight">{t('home.title')}</h1>
<p className="mt-1 text-sm text-ink-secondary">
{t('home.summary', { date })}
</p>
</header>
{/* 통계 카드 3분할 */}
<div className="grid gap-3 sm:grid-cols-3">
{day.isLoading ? (
<>
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="hStatCard function · typescript · L168-L184 (17 LOC)frontend/src/features/home/HomePage.tsx
function StatCard({ label, value, hue, accent, delay }: { label: string; value: number; hue: string; accent: string; delay: number }) {
return (
<div
className="fade-up relative overflow-hidden rounded-lg border border-edge-base bg-gradient-to-br from-surface-subtle to-surface-muted p-4 shadow-sm transition-shadow hover:shadow-md"
style={{ animationDelay: `${delay * 60}ms` }}
>
<div className={`absolute left-0 top-0 h-full w-1 rounded-l-lg ${accent}`} />
<div className="flex items-end justify-between pl-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-ink-muted">{label}</span>
<span className={`text-3xl font-bold tabular-nums tracking-tight ${hue}`}>{value}</span>
</div>
<span className="text-xs text-ink-muted tabular-nums">{relativeTime(new Date().toISOString())}</span>
</div>
</div>
);
}getLast7Days function · typescript · L16-L27 (12 LOC)frontend/src/features/home/WeeklyChart.tsx
function getLast7Days(): string[] {
const days: string[] = [];
const now = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
days.push(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
);
}
return days;
}Repobility · code-quality intelligence · https://repobility.com
shortDay function · typescript · L29-L33 (5 LOC)frontend/src/features/home/WeeklyChart.tsx
function shortDay(dateStr: string, locale: string): string {
const d = new Date(dateStr + 'T00:00:00');
const dayName = new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(d);
return `${d.getDate()}(${dayName})`;
}WeeklyChart function · typescript · L42-L156 (115 LOC)frontend/src/features/home/WeeklyChart.tsx
export function WeeklyChart() {
const { t, i18n } = useTranslation();
const now = new Date();
const stats = useMonthStats(now.getFullYear(), now.getMonth() + 1);
const days = useMemo(getLast7Days, []);
const data = useMemo(() => {
const map = new Map<string, { created: number; approved: number; completed: number }>();
(stats.data ?? []).forEach((d) => {
const key = d.date.slice(0, 10);
map.set(key, { created: d.created, approved: d.approved, completed: d.completed });
});
return days.map((day) => ({
name: shortDay(day, i18n.language),
[t('home.created')]: map.get(day)?.created ?? 0,
[t('home.approved')]: map.get(day)?.approved ?? 0,
[t('home.done')]: map.get(day)?.completed ?? 0,
}));
}, [stats.data, days, t, i18n.language]);
const hasData = data.some((d) =>
Object.values(d).some((v) => typeof v === 'number' && v > 0),
);
/* Empty state — show a placeholder instead of hiding entirely */
if (!hasData && IssuePage function · typescript · L27-L313 (287 LOC)frontend/src/features/issue/IssuePage.tsx
export default function IssuePage() {
const { t } = useTranslation();
const { issueId } = useParams<{ issueId: string }>();
const navigate = useNavigate();
const query = useIssue(issueId);
const boards = useBoards();
const update = useUpdateIssue();
const approve = useApproveIssue();
const remove = useDeleteIssue();
const updateProps = useUpdateIssueProperties();
const boardProps = useBoardProperties(query.data?.issue.boardId);
const addDep = useAddDependency();
const removeDep = useRemoveDependency();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [instructions, setInstructions] = useState('');
const [criteria, setCriteria] = useState<Criterion[]>([]);
const [saveErr, setSaveErr] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [depInput, setDepInput] = useState('');
useEffect(() => {
if (query.data?.issue) {
setTitle(query.data.issue.title);