diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46fab3e..f591a03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,8 @@ jobs: - uses: astral-sh/setup-uv@v6 with: enable-cache: true - - run: uv sync --frozen --dev - - run: uv run ruff check core/ api/ db/ tests/ - - run: uv run ruff format --check core/ api/ db/ tests/ + - run: uv run --only-group lint ruff check core/ api/ db/ tests/ + - run: uv run --only-group lint ruff format --check core/ api/ db/ tests/ # ── Backend: tests ───────────────────────────────────────────────────── backend-test: @@ -34,7 +33,7 @@ jobs: - uses: astral-sh/setup-uv@v6 with: enable-cache: true - - run: uv sync --frozen --dev + - run: uv sync --frozen --group test - run: uv run pytest tests/ -v --tb=short # ── Frontend: lint + typecheck ───────────────────────────────────────── diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/frontend/components/chat/question-input.tsx b/frontend/components/chat/question-input.tsx index 1874e64..6d36df1 100644 --- a/frontend/components/chat/question-input.tsx +++ b/frontend/components/chat/question-input.tsx @@ -1,145 +1,145 @@ -'use client' - -import { useCallback, useEffect, useRef, useState } from 'react' - -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import type { Question } from '@/lib/api-types' - -interface QuestionInputProps { - questions: Question[] - onSubmit?: (answers: Record) => void -} - -export function QuestionInput({ questions, onSubmit }: QuestionInputProps) { - const [selectedAnswers, setSelectedAnswers] = useState>({}) - const [showOther, setShowOther] = useState>({}) - const [otherValues, setOtherValues] = useState>({}) - const submittedRef = useRef(false) - - const isResolved = questions.every((q) => q.selected_answer) - const isInteractive = !!onSubmit && !isResolved && !submittedRef.current - - // Auto-submit when all questions have a local answer - useEffect(() => { - if ( - !submittedRef.current && - onSubmit && - questions.length > 0 && - questions.every((q) => selectedAnswers[q.question]) - ) { - submittedRef.current = true - onSubmit(selectedAnswers) - } - }, [selectedAnswers, questions, onSubmit]) - - const handleOptionClick = useCallback( - (question: Question, label: string) => { - if (submittedRef.current) return - setSelectedAnswers((prev) => ({ ...prev, [question.question]: label })) - }, - [] - ) - - const handleOtherSubmit = useCallback( - (questionIdx: number, question: Question) => { - const value = otherValues[questionIdx] - if (!value?.trim() || submittedRef.current) return - setSelectedAnswers((prev) => ({ ...prev, [question.question]: value.trim() })) - }, - [otherValues] - ) - - return ( -
- {questions.map((q, qi) => { - const localAnswer = selectedAnswers[q.question] - const resolvedAnswer = q.selected_answer - const answered = !!localAnswer || !!resolvedAnswer - - return ( -
- {q.header && ( -

{q.header}

- )} -

{q.question}

- -
- {q.options.map((opt, oi) => { - const isSelected = resolvedAnswer === opt.label || localAnswer === opt.label - - if (isInteractive && !localAnswer) { - return ( - - ) - } - - return ( - - {opt.label} - - ) - })} - {isInteractive && !localAnswer && ( - - )} -
- - {/* Free-text answer that doesn't match any option */} - {(resolvedAnswer || localAnswer) && !q.options.some((o) => o.label === (resolvedAnswer || localAnswer)) && ( -

→ {resolvedAnswer || localAnswer}

- )} - - {isInteractive && !localAnswer && showOther[qi] && ( -
- - setOtherValues((prev) => ({ ...prev, [qi]: e.target.value })) - } - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - handleOtherSubmit(qi, q) - } - }} - /> - -
- )} -
- ) - })} -
- ) -} +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import type { Question } from '@/lib/api-types' + +interface QuestionInputProps { + questions: Question[] + onSubmit?: (answers: Record) => void +} + +export function QuestionInput({ questions, onSubmit }: QuestionInputProps) { + const [selectedAnswers, setSelectedAnswers] = useState>({}) + const [showOther, setShowOther] = useState>({}) + const [otherValues, setOtherValues] = useState>({}) + const submittedRef = useRef(false) + + const isResolved = questions.every((q) => q.selected_answer) + const isInteractive = !!onSubmit && !isResolved + + // Auto-submit when all questions have a local answer + useEffect(() => { + if ( + !submittedRef.current && + onSubmit && + questions.length > 0 && + questions.every((q) => selectedAnswers[q.question]) + ) { + submittedRef.current = true + onSubmit(selectedAnswers) + } + }, [selectedAnswers, questions, onSubmit]) + + const handleOptionClick = useCallback( + (question: Question, label: string) => { + if (submittedRef.current) return + setSelectedAnswers((prev) => ({ ...prev, [question.question]: label })) + }, + [] + ) + + const handleOtherSubmit = useCallback( + (questionIdx: number, question: Question) => { + const value = otherValues[questionIdx] + if (!value?.trim() || submittedRef.current) return + setSelectedAnswers((prev) => ({ ...prev, [question.question]: value.trim() })) + }, + [otherValues] + ) + + return ( +
+ {questions.map((q, qi) => { + const localAnswer = selectedAnswers[q.question] + const resolvedAnswer = q.selected_answer + const answered = !!localAnswer || !!resolvedAnswer + + return ( +
+ {q.header && ( +

{q.header}

+ )} +

{q.question}

+ +
+ {q.options.map((opt, oi) => { + const isSelected = resolvedAnswer === opt.label || localAnswer === opt.label + + if (isInteractive && !localAnswer) { + return ( + + ) + } + + return ( + + {opt.label} + + ) + })} + {isInteractive && !localAnswer && ( + + )} +
+ + {/* Free-text answer that doesn't match any option */} + {(resolvedAnswer || localAnswer) && !q.options.some((o) => o.label === (resolvedAnswer || localAnswer)) && ( +

→ {resolvedAnswer || localAnswer}

+ )} + + {isInteractive && !localAnswer && showOther[qi] && ( +
+ + setOtherValues((prev) => ({ ...prev, [qi]: e.target.value })) + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleOtherSubmit(qi, q) + } + }} + /> + +
+ )} +
+ ) + })} +
+ ) +} diff --git a/frontend/components/dashboard/blocks/portaled-components.tsx b/frontend/components/dashboard/blocks/portaled-components.tsx index f99ad65..16e107c 100644 --- a/frontend/components/dashboard/blocks/portaled-components.tsx +++ b/frontend/components/dashboard/blocks/portaled-components.tsx @@ -323,7 +323,6 @@ SelectItem.displayName = 'SelectItem' * * The `any` cast is required because the TS type is not exported from @blocknote/shadcn. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export const portaledShadCNComponents: any = { Popover: { Popover, diff --git a/frontend/components/dashboard/card-editor.tsx b/frontend/components/dashboard/card-editor.tsx index 6b9b0db..f6271cc 100644 --- a/frontend/components/dashboard/card-editor.tsx +++ b/frontend/components/dashboard/card-editor.tsx @@ -77,24 +77,21 @@ function CardEditor({ card, onContentChange, onEditorResize }: CardEditorProps) useEffect(() => { cardRegistry.set(card.id, card) return () => { cardRegistry.delete(card.id) } - }, [card.id, card.type, card.title, card.value, card.fig]) + }, [card]) const initialContent = useMemo( () => (card.content && card.content.length > 0 ? card.content : defaultContent(card)), - // Only compute once per card ID - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- only compute once per card ID [card.id] ) const editor = useCreateBlockNote({ schema: dashboardSchema, - // eslint-disable-next-line @typescript-eslint/no-explicit-any initialContent: initialContent as any, }) editorRef.current = editor as unknown as { document: unknown[] } // Flush any pending debounced save when the component unmounts (e.g. tab switch). - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { return () => { if (saveTimerRef.current) { diff --git a/frontend/components/dashboard/dashboard-grid.tsx b/frontend/components/dashboard/dashboard-grid.tsx index c85421e..d462d67 100644 --- a/frontend/components/dashboard/dashboard-grid.tsx +++ b/frontend/components/dashboard/dashboard-grid.tsx @@ -83,9 +83,9 @@ export default function DashboardGrid({ // Track cards and callback in refs so ResizeObserver/event handlers // always read fresh values without re-creating effects. const cardsRef = useRef(cards) - cardsRef.current = cards + useEffect(() => { cardsRef.current = cards }, [cards]) const onLayoutChangeRef = useRef(onLayoutChange) - onLayoutChangeRef.current = onLayoutChange + useEffect(() => { onLayoutChangeRef.current = onLayoutChange }, [onLayoutChange]) // IDs of cards the user has manually shrunk below content height. const compactedRef = useRef(new Set()) @@ -115,13 +115,11 @@ export default function DashboardGrid({ }) // Persist layout on user drag stop - // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleDragStop = useCallback((newLayout: any) => { onLayoutChange(layoutToItems(newLayout)) }, [onLayoutChange]) // Persist layout on user resize stop + track compaction - // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleResizeStop = useCallback((newLayout: any, _oldItem: any, newItem: any) => { // If the user made the card shorter than its content, mark it compacted // so auto-height doesn't fight the user's choice. diff --git a/frontend/lib/hooks/use-upload-message.ts b/frontend/lib/hooks/use-upload-message.ts index 481cee7..715b26a 100644 --- a/frontend/lib/hooks/use-upload-message.ts +++ b/frontend/lib/hooks/use-upload-message.ts @@ -11,11 +11,10 @@ const MESSAGES = [ ] export function useUploadMessage(active: boolean) { - const [idx, setIdx] = useState(0) + const [idx, setIdx] = useState(() => Math.floor(Math.random() * MESSAGES.length)) useEffect(() => { if (!active) return - setIdx(Math.floor(Math.random() * MESSAGES.length)) const id = setInterval(() => { setIdx((prev) => { let next: number diff --git a/pyproject.toml b/pyproject.toml index 2980189..4d6d653 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,10 @@ dependencies = [ "psycopg[binary]~=3.2", ] -[project.optional-dependencies] -dev = [ - "ruff>=0.11", - "pytest>=8.0", - "pytest-asyncio>=0.25", -] +[dependency-groups] +lint = ["ruff>=0.11"] +test = ["pytest>=8.0", "pytest-asyncio>=0.25"] +dev = [{include-group = "lint"}, {include-group = "test"}] [tool.ruff] target-version = "py313" diff --git a/uv.lock b/uv.lock index b6ef5f2..0593661 100644 --- a/uv.lock +++ b/uv.lock @@ -376,12 +376,19 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, ] +lint = [ + { name = "ruff" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] [package.metadata] requires-dist = [ @@ -398,14 +405,22 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = "~=3.2" }, { name = "pyarrow", specifier = "~=20.0.0" }, { name = "pydantic", specifier = "~=2.12.5" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25" }, { name = "python-multipart", specifier = "~=0.0.22" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = "~=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = "~=0.41.0" }, ] -provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.25" }, + { name = "ruff", specifier = ">=0.11" }, +] +lint = [{ name = "ruff", specifier = ">=0.11" }] +test = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.25" }, +] [[package]] name = "distro"