chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-06 19:04:45 +02:00
commit 94223e68f7
31 changed files with 6837 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { FormEvent, KeyboardEvent, useState } from "react";
export interface CardFormValues {
requester: string;
title: string;
description: string;
}
interface Props {
initial?: Partial<CardFormValues>;
submitLabel?: string;
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
}
export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel }: Props) {
const [requester, setRequester] = useState(initial?.requester ?? "");
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const submit = async (e?: FormEvent) => {
e?.preventDefault();
const t = title.trim();
if (!t) return;
await onSubmit({ requester: requester.trim(), title: t, description });
};
// Enter en TextInput envia el form. Enter en Textarea inserta newline; Ctrl/Cmd+Enter envia.
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const textareaEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submit();
}
};
return (
<form onSubmit={submit}>
<Stack gap="sm">
<TextInput
label="Solicitante"
value={requester}
onChange={(e) => setRequester(e.currentTarget.value)}
tabIndex={1}
autoComplete="off"
data-autofocus
onKeyDown={enterSubmit}
/>
<TextInput
label="Tarea"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
tabIndex={2}
required
autoComplete="off"
onKeyDown={enterSubmit}
/>
<Textarea
label="Descripcion"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
tabIndex={3}
autosize
minRows={3}
maxRows={8}
onKeyDown={textareaEnter}
description="Ctrl+Enter para guardar"
/>
<Group justify="flex-end" gap="xs" mt="xs">
<Button variant="subtle" color="gray" tabIndex={5} type="button" onClick={onCancel}>
Cancelar
</Button>
<Button tabIndex={4} type="submit" disabled={!title.trim()}>
{submitLabel}
</Button>
</Group>
</Stack>
</form>
);
}
+206
View File
@@ -0,0 +1,206 @@
import {
ActionIcon,
Badge,
Box,
Group,
Loader,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconMessageChatbot, IconSend, IconTrash } from "@tabler/icons-react";
import { KeyboardEvent, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChatMessage, ChatToolCall, sendChat } from "../api";
const STORAGE_KEY = "kanban_chat_v1";
interface StoredMessage extends ChatMessage {
ts: number;
tool_calls?: ChatToolCall[];
}
interface Props {
onBoardChange: () => void;
}
function loadStored(): StoredMessage[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
} catch {
/* fall through */
}
return [];
}
export function ChatPanel({ onBoardChange }: Props) {
const [messages, setMessages] = useState<StoredMessage[]>(() => loadStored());
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
}, [messages]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, loading]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
const userMsg: StoredMessage = { role: "user", content: text, ts: Date.now() };
const next = [...messages, userMsg];
setMessages(next);
setInput("");
setLoading(true);
try {
const payload: ChatMessage[] = next.map((m) => ({ role: m.role, content: m.content }));
const res = await sendChat(payload);
const assistant: StoredMessage = {
role: "assistant",
content: res.content,
ts: Date.now(),
tool_calls: res.tool_calls,
};
setMessages((prev) => [...prev, assistant]);
if (res.board_changed) onBoardChange();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${(e as Error).message}`, ts: Date.now() },
]);
} finally {
setLoading(false);
}
};
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const clear = () => {
setMessages([]);
localStorage.removeItem(STORAGE_KEY);
};
return (
<Stack gap={0} h="100%">
<Group justify="space-between" p="xs" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
<Group gap={6}>
<IconMessageChatbot size={18} />
<Text fw={600} size="sm">
Asistente
</Text>
</Group>
<Tooltip label="Limpiar conversacion" withArrow>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={clear} disabled={messages.length === 0}>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea viewportRef={scrollRef} style={{ flex: 1 }} type="auto" p="xs">
<Stack gap="xs">
{messages.length === 0 && (
<Text size="sm" c="dimmed" ta="center" mt="md">
Escribe algo. Ejemplos:
<br />- "crea columna Backlog"
<br />- "anade tarjeta para revisar PR de Lucas en Doing"
<br />- "que hay en Doing?"
</Text>
)}
{messages.map((m, i) => (
<ChatBubble key={i} msg={m} />
))}
{loading && (
<Group gap={6} pl="xs">
<Loader size="xs" />
<Text size="xs" c="dimmed">
Pensando...
</Text>
</Group>
)}
</Stack>
</ScrollArea>
<Stack gap={4} p="xs" style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}>
<Group align="flex-end" gap={4} wrap="nowrap">
<Textarea
placeholder="Pide algo... (Enter envia, Shift+Enter newline)"
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={onKey}
disabled={loading}
autosize
minRows={1}
maxRows={6}
style={{ flex: 1 }}
/>
<ActionIcon
size="lg"
variant="filled"
onClick={send}
disabled={!input.trim() || loading}
aria-label="Send"
>
{loading ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
</ActionIcon>
</Group>
</Stack>
</Stack>
);
}
function ChatBubble({ msg }: { msg: StoredMessage }) {
const isUser = msg.role === "user";
return (
<Paper
p="xs"
radius="md"
withBorder
bg={isUser ? "blue.9" : "dark.6"}
style={{ alignSelf: isUser ? "flex-end" : "flex-start", maxWidth: "92%" }}
>
<Stack gap={4}>
{msg.content && (
<Box
className="kanban-md"
style={{ fontSize: 13, lineHeight: 1.45, color: "var(--mantine-color-text)" }}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</Box>
)}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<Group gap={4} wrap="wrap">
{msg.tool_calls.map((c, i) => (
<Badge
key={i}
size="xs"
color={c.ok ? "teal" : "red"}
variant="light"
title={c.error || ""}
>
{c.tool}
{!c.ok && c.error ? `: ${c.error}` : ""}
</Badge>
))}
</Group>
)}
</Stack>
</Paper>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { IconColumns3 } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { cardHistory } from "../api";
import type { Card, HistoryEntry } from "../types";
import { formatDuration } from "./format";
interface Props {
card: Card;
}
export function HistoryModal({ card }: Props) {
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
useEffect(() => {
cardHistory(card.id).then(setEntries).catch(() => setEntries([]));
}, [card.id]);
if (!entries) {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
if (entries.length === 0) {
return <Text c="dimmed">Sin historial.</Text>;
}
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
Tiempo total en cada columna desde que se creo la tarjeta.
</Text>
<Timeline active={entries.length} bulletSize={22} lineWidth={2}>
{entries.map((e) => (
<Timeline.Item
key={e.id}
bullet={<IconColumns3 size={12} />}
title={
<Group gap={6}>
<Text fw={500} size="sm">
{e.column_name || e.column_id}
</Text>
<Badge size="xs" variant="light" color={e.exited_at ? "gray" : "blue"}>
{formatDuration(e.duration_ms)}
</Badge>
{!e.exited_at && (
<Badge size="xs" variant="filled" color="blue">
actual
</Badge>
)}
</Group>
}
>
<Text size="xs" c="dimmed">
{new Date(e.entered_at).toLocaleString()}
{e.exited_at && ` -> ${new Date(e.exited_at).toLocaleString()}`}
</Text>
</Timeline.Item>
))}
</Timeline>
</Stack>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ActionIcon,
Badge,
Group,
Paper,
Popover,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
IconClock,
IconEdit,
IconGripVertical,
IconHistory,
IconPalette,
IconTrash,
IconUser,
} from "@tabler/icons-react";
import { memo, useState } from "react";
import type { Card, CardColor } from "../types";
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
import { formatDuration } from "./format";
interface Props {
card: Card;
now: number;
onDelete: (id: string) => void;
onEdit: (card: Card) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
isOverlay?: boolean;
}
function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) {
const [popOpen, setPopOpen] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: { type: "card", columnId: card.column_id },
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: colorBorder(card.color),
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
return (
<Paper ref={setNodeRef} style={style} withBorder p="xs" shadow={isOverlay ? "lg" : "xs"} radius="md">
<Stack gap={6}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
{...attributes}
{...listeners}
style={{ cursor: "grab" }}
aria-label="Drag"
>
<IconGripVertical size={14} />
</ActionIcon>
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
{card.title}
</Text>
</Group>
<Group gap={2} wrap="nowrap">
<Popover opened={popOpen} onChange={setPopOpen} withArrow shadow="md" position="bottom-end">
<Popover.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setPopOpen((v) => !v)}
aria-label="Color"
>
<IconPalette size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap={4} maw={200}>
{CARD_COLORS.map((c) => (
<Tooltip key={c.value} label={c.label} withArrow>
<ActionIcon
variant={card.color === c.value ? "filled" : "default"}
size="md"
radius="xl"
style={{
background: colorSwatch(c.value),
borderColor: colorBorder(c.value),
}}
onClick={() => {
onChangeColor(card.id, c.value);
setPopOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
</Popover.Dropdown>
</Popover>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onEdit(card)} aria-label="Edit">
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => onShowHistory(card)}
aria-label="History"
>
<IconHistory size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(card.id)} aria-label="Delete">
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
{card.requester && (
<Group gap={4}>
<IconUser size={12} />
<Text size="xs" c="dimmed">
{card.requester}
</Text>
</Group>
)}
{card.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{card.description}
</Text>
)}
<Group gap={4}>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
{formatDuration(liveMs)}
</Badge>
</Group>
</Stack>
</Paper>
);
}
// memo: re-render solo cuando cambian props relevantes (card, now). Evita rerenders
// en cascada cuando otra columna cambia durante drag-over.
export const KanbanCard = memo(KanbanCardImpl);
+296
View File
@@ -0,0 +1,296 @@
import { useSortable } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Paper,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
IconArchive,
IconArchiveOff,
IconCheck,
IconGripVertical,
IconPencil,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
import type { Card, CardColor, Column } from "../types";
import { KanbanCard } from "./KanbanCard";
interface Props {
column: Column;
cards: Card[];
now: number;
collapsed?: boolean;
onAddCard: (columnId: string) => void;
onRenameColumn: (id: string, name: string) => void;
onResizeColumn: (id: string, width: number) => void;
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
onDeleteColumn: (id: string) => void;
onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void;
onChangeCardColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
}
function KanbanColumnImpl({
column,
cards,
now,
collapsed,
onAddCard,
onRenameColumn,
onResizeColumn,
onMoveColumnLocation,
onDeleteColumn,
onEditCard,
onDeleteCard,
onChangeCardColor,
onShowHistory,
}: Props) {
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(column.name);
const [localWidth, setLocalWidth] = useState<number | null>(null);
// sync local width when column.width changes from outside (other clients).
useEffect(() => {
setLocalWidth(null);
}, [column.width]);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `column-${column.id}`,
data: { type: "column", columnId: column.id, location: column.location },
});
const effectiveWidth = collapsed ? 220 : localWidth ?? column.width;
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
width: effectiveWidth,
minWidth: effectiveWidth,
maxWidth: effectiveWidth,
display: "flex",
flexDirection: "column",
height: "100%",
position: "relative",
};
const cardIds = cards.map((c) => c.id);
const submitRename = () => {
const trimmed = name.trim();
if (trimmed && trimmed !== column.name) onRenameColumn(column.id, trimmed);
setRenaming(false);
};
// --- resize handle ---
const resizingRef = useRef<{ startX: number; startWidth: number } | null>(null);
const onResizeMouseDown = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizingRef.current = { startX: e.clientX, startWidth: column.width };
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (ev: globalThis.MouseEvent) => {
if (!resizingRef.current) return;
const dx = ev.clientX - resizingRef.current.startX;
const next = Math.min(800, Math.max(200, resizingRef.current.startWidth + dx));
setLocalWidth(next);
};
const onUp = () => {
if (resizingRef.current && localWidthRef.current !== null) {
onResizeColumn(column.id, localWidthRef.current);
}
resizingRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
// mirror localWidth into a ref so the mouseup handler always sees the latest value.
const localWidthRef = useRef<number | null>(null);
useEffect(() => {
localWidthRef.current = localWidth;
}, [localWidth]);
const isInSidebar = column.location === "sidebar";
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive;
return (
<Paper ref={setNodeRef} style={style} withBorder radius="md" p="sm" bg="dark.7">
<Group justify="space-between" mb="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
{...attributes}
{...listeners}
style={{ cursor: "grab" }}
aria-label="Drag column"
>
<IconGripVertical size={14} />
</ActionIcon>
{renaming ? (
<TextInput
size="xs"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
autoFocus
onBlur={submitRename}
onKeyDown={(e) => {
if (e.key === "Enter") submitRename();
if (e.key === "Escape") {
setName(column.name);
setRenaming(false);
}
}}
style={{ flex: 1 }}
/>
) : (
<Text
fw={600}
size="sm"
truncate
onDoubleClick={() => {
setName(column.name);
setRenaming(true);
}}
style={{ flex: 1, cursor: "text" }}
title="Doble click para renombrar"
>
{column.name}
</Text>
)}
<Badge size="xs" variant="light" color="gray">
{cards.length}
</Badge>
</Group>
<Group gap={2} wrap="nowrap">
{renaming ? (
<>
<ActionIcon variant="subtle" color="green" size="sm" onClick={submitRename} aria-label="Save">
<IconCheck size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setName(column.name);
setRenaming(false);
}}
aria-label="Cancel"
>
<IconX size={14} />
</ActionIcon>
</>
) : (
<>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setName(column.name);
setRenaming(true);
}}
aria-label="Rename"
>
<IconPencil size={14} />
</ActionIcon>
<Tooltip label={archiveLabel} withArrow>
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
aria-label={archiveLabel}
>
<ArchiveIcon size={14} />
</ActionIcon>
</Tooltip>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onDeleteColumn(column.id)}
aria-label="Delete column"
>
<IconTrash size={14} />
</ActionIcon>
</>
)}
</Group>
</Group>
<ScrollArea style={{ flex: 1 }} type="auto">
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
{cards.map((c) => (
<KanbanCard
key={c.id}
card={c}
now={now}
onDelete={onDeleteCard}
onEdit={onEditCard}
onChangeColor={onChangeCardColor}
onShowHistory={onShowHistory}
/>
))}
</Stack>
</SortableContext>
</ScrollArea>
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => onAddCard(column.id)}
mt="xs"
fullWidth
>
Anadir tarjeta
</Button>
{/* Resize handle (only on board, not sidebar) */}
{!isInSidebar && (
<Box
onMouseDown={onResizeMouseDown}
style={{
position: "absolute",
top: 0,
right: -3,
width: 6,
height: "100%",
cursor: "col-resize",
zIndex: 5,
}}
aria-label="Resize column"
/>
)}
</Paper>
);
}
export const KanbanColumn = memo(KanbanColumnImpl);
+32
View File
@@ -0,0 +1,32 @@
import type { CardColor } from "../types";
export const CARD_COLORS: { value: CardColor; label: string }[] = [
{ value: "", label: "Default" },
{ value: "blue", label: "Azul" },
{ value: "teal", label: "Teal" },
{ value: "green", label: "Verde" },
{ value: "yellow", label: "Amarillo" },
{ value: "orange", label: "Naranja" },
{ value: "red", label: "Rojo" },
{ value: "pink", label: "Rosa" },
{ value: "violet", label: "Violeta" },
{ value: "indigo", label: "Indigo" },
];
// color-mix mezcla 18% del tono base con dark.6 → suave en dark mode.
// Border 30% del tono mas claro con dark.4 para definicion sutil.
// Swatch (boton picker) usa tono pleno -7 para que sea visible.
export function colorBg(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-6)";
return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
}
export function colorBorder(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-4)";
return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
}
export function colorSwatch(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-3)";
return `var(--mantine-color-${color}-7)`;
}
+30
View File
@@ -0,0 +1,30 @@
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS.
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente.
const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "0m";
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
if (ms < DAY) {
const h = Math.floor(ms / HOUR);
const m = Math.floor((ms % HOUR) / MIN);
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
if (ms < WEEK) {
const d = Math.floor(ms / DAY);
const h = Math.floor((ms % DAY) / HOUR);
return h === 0 ? `${d}D` : `${d}D ${h}h`;
}
if (ms < MONTH) {
const w = Math.floor(ms / WEEK);
const d = Math.floor((ms % WEEK) / DAY);
return d === 0 ? `${w}S` : `${w}S ${d}D`;
}
const m = Math.floor(ms / MONTH);
const w = Math.floor((ms % MONTH) / WEEK);
return w === 0 ? `${m}M` : `${m}M ${w}S`;
}