diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51a013f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: pyphos-ci + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + backend-tests: + runs-on: ubuntu-latest + + # Dummy values, just enough for the app to import and for the test suite + # to run. The test suite never touches DATABASE_URL for real - it's + # entirely SQLite in-memory (see backend/tests/conftest.py) - this only + # needs to exist so backend/utils/database.py doesn't raise at import time. + env: + DATABASE_URL: sqlite:///./ci-placeholder.db + SECRET_KEY: ci-test-secret-key + ALGORITHM: HS256 + ACCESS_TOKEN_EXPIRE_MINUTES: "30" + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install backend dependencies + run: pip install -r backend/requirements-dev.txt + + - name: Run backend tests + run: python -m pytest -v + + frontend-build: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Lint + working-directory: frontend + run: npm run lint + + - name: Build + working-directory: frontend + run: npm run build diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..eacf1ed --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==9.0.3 +httpx==0.28.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 61536f8..6932514 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -23,4 +23,5 @@ argon2-cffi-bindings==25.1.0 cffi==2.0.0 pycparser==3.0 playwright==1.61.0 -starlette==1.0.0 \ No newline at end of file +starlette==1.0.0 +pytest==9.0.3 \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ea36dd3..d1b7e66 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -17,5 +17,21 @@ export default defineConfig([ globals: globals.browser, parserOptions: { ecmaFeatures: { jsx: true } }, }, + rules: { + 'no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }], + }, + }, + { + // Vendored shadcn/ui components: many co-export a variants function + // (e.g. buttonVariants) alongside their component by design, which + // trips this rule. That's expected here, not an app bug. + files: ['src/components/ui/**/*.{js,jsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, }, ]) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2fd4aea..0a9e73d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,8 @@ import { useContext } from "react" import { BrowserRouter as Router, Route, Routes, Navigate } from "react-router" -import { AuthProvider, AuthContext } from './lib/AuthContext'; +import { AuthProvider } from './lib/AuthContext'; +import { AuthContext } from './lib/auth-context'; import { isTokenValid } from './lib/utils'; import { Toaster } from '@/components/ui/sonner' import Landing from './screens/Landing' diff --git a/frontend/src/components/AngleSlider.jsx b/frontend/src/components/AngleSlider.jsx index 6f664c5..18607aa 100644 --- a/frontend/src/components/AngleSlider.jsx +++ b/frontend/src/components/AngleSlider.jsx @@ -2,14 +2,8 @@ import { Label } from "@/components/ui/label" import { Slider } from "@/components/ui/slider" +import { getCompassDirection } from "@/lib/utils" -// Azimuth convention used across the app: South is 0°, West is positive, East is negative. -const COMPASS_DIRECTIONS = ["S", "SW", "W", "NW", "N", "NE", "E", "SE"] - -export function getCompassDirection(azimuth) { - const normalized = ((azimuth % 360) + 360) % 360 - return COMPASS_DIRECTIONS[Math.round(normalized / 45) % 8] -} export function AngleSlider({ name, min, max, value, onValueChange, tag }) { return ( diff --git a/frontend/src/components/EditProjectDialog.jsx b/frontend/src/components/EditProjectDialog.jsx index 06e99b0..ac67622 100644 --- a/frontend/src/components/EditProjectDialog.jsx +++ b/frontend/src/components/EditProjectDialog.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useState } from "react" import { useNavigate, useParams } from "react-router" import { @@ -28,7 +28,7 @@ import LocationCombobox from "./LocationCombobox" export default function EditProjectDialog({ onSaved }) { const { projectId } = useParams(); const navigate = useNavigate() - const { data: project, loading: projLoading, error: projError } = useProject(projectId) + const { data: project } = useProject(projectId) const updateProject = useUpdateProject() const deleteProject = useDeleteProject() @@ -42,25 +42,25 @@ export default function EditProjectDialog({ onSaved }) { const [touched, setTouched] = useState({}) const [deleting, setDeleting] = useState(false) const [deleteError, setDeleteError] = useState(null) + const [loadedProjectId, setLoadedProjectId] = useState(null) const nameCheck = validateName(projectName, "Project name") const locationCheck = validateRequired(locationQuery, "Location") const isFormValid = nameCheck.valid && locationCheck.valid - useEffect(() => { - if (project) { - setProjectName(project.name ?? "") - setLocation({ - name: project.location, - country_code: project.country_code, - admin1: null, - lat: project.lat, - lon: project.lon, - }) - setLocationQuery(project.location ?? "") - setLocationChanged(false) - } - }, [project]) + if (project && project.id !== loadedProjectId) { + setLoadedProjectId(project.id) + setProjectName(project.name ?? "") + setLocation({ + name: project.location, + country_code: project.country_code, + admin1: null, + lat: project.lat, + lon: project.lon, + }) + setLocationQuery(project.location ?? "") + setLocationChanged(false) + } function markTouched(field) { setTouched((t) => ({ ...t, [field]: true })) diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 997f677..73101ff 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -1,11 +1,9 @@ -import { AuthContext } from '@/lib/AuthContext' +import { AuthContext } from '@/lib/auth-context' import { useContext } from "react" import { useNavigate } from 'react-router' import { SettingsIcon, - BellIcon, - CreditCardIcon, LogOutIcon, } from "lucide-react" import { diff --git a/frontend/src/components/LocationCombobox.jsx b/frontend/src/components/LocationCombobox.jsx index 55e662e..80902e0 100644 --- a/frontend/src/components/LocationCombobox.jsx +++ b/frontend/src/components/LocationCombobox.jsx @@ -9,7 +9,7 @@ import { ComboboxList, } from "@/components/ui/combobox" -import { AuthContext } from "../lib/AuthContext" +import { AuthContext } from "../lib/auth-context" import { searchLocations } from "../lib/api" const DEBOUNCE_MS = 300 @@ -37,28 +37,36 @@ export default function LocationCombobox({ id, value, onSelect, ariaInvalid, onB } useEffect(() => { - setQuery(formatLocation(value)) + function syncQuery() { + setQuery(formatLocation(value)) + } + + syncQuery() }, [value]) useEffect(() => { - if (!query.trim() || query === formatLocation(value)) { - setLoading(false) - return - } - - setLoading(true) - const timeout = setTimeout(async () => { - try { - const candidates = await searchLocations(token, query) - setResults(candidates ?? []) - } catch { - setResults([]) - } finally { + function search() { + if (!query.trim() || query === formatLocation(value)) { setLoading(false) + return } - }, DEBOUNCE_MS) - return () => clearTimeout(timeout) + setLoading(true) + const timeout = setTimeout(async () => { + try { + const candidates = await searchLocations(token, query) + setResults(candidates ?? []) + } catch { + setResults([]) + } finally { + setLoading(false) + } + }, DEBOUNCE_MS) + + return () => clearTimeout(timeout) + } + + return search() // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, token]) diff --git a/frontend/src/components/ui/alert-dialog.jsx b/frontend/src/components/ui/alert-dialog.jsx index 726ac90..f387b9a 100644 --- a/frontend/src/components/ui/alert-dialog.jsx +++ b/frontend/src/components/ui/alert-dialog.jsx @@ -1,6 +1,5 @@ "use client" -import * as React from "react" import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" import { cn } from "@/lib/utils" diff --git a/frontend/src/components/ui/avatar.jsx b/frontend/src/components/ui/avatar.jsx index fee91c8..944bb9e 100644 --- a/frontend/src/components/ui/avatar.jsx +++ b/frontend/src/components/ui/avatar.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" import { cn } from "@/lib/utils" diff --git a/frontend/src/components/ui/breadcrumb.jsx b/frontend/src/components/ui/breadcrumb.jsx index db2b171..5c1ca57 100644 --- a/frontend/src/components/ui/breadcrumb.jsx +++ b/frontend/src/components/ui/breadcrumb.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" diff --git a/frontend/src/components/ui/card.jsx b/frontend/src/components/ui/card.jsx index d9c9916..cec0a8b 100644 --- a/frontend/src/components/ui/card.jsx +++ b/frontend/src/components/ui/card.jsx @@ -1,5 +1,3 @@ -import * as React from "react" - import { cn } from "@/lib/utils" function Card({ diff --git a/frontend/src/components/ui/dialog.jsx b/frontend/src/components/ui/dialog.jsx index bc425ea..cbcd91d 100644 --- a/frontend/src/components/ui/dialog.jsx +++ b/frontend/src/components/ui/dialog.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { cn } from "@/lib/utils" diff --git a/frontend/src/components/ui/dropdown-menu.jsx b/frontend/src/components/ui/dropdown-menu.jsx index 2695da9..c97706b 100644 --- a/frontend/src/components/ui/dropdown-menu.jsx +++ b/frontend/src/components/ui/dropdown-menu.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { Menu as MenuPrimitive } from "@base-ui/react/menu" import { cn } from "@/lib/utils" diff --git a/frontend/src/components/ui/input-group.jsx b/frontend/src/components/ui/input-group.jsx index 48d0727..ab0db86 100644 --- a/frontend/src/components/ui/input-group.jsx +++ b/frontend/src/components/ui/input-group.jsx @@ -1,6 +1,5 @@ "use client" -import * as React from "react" import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils" diff --git a/frontend/src/components/ui/input.jsx b/frontend/src/components/ui/input.jsx index d6ff8e4..0836a95 100644 --- a/frontend/src/components/ui/input.jsx +++ b/frontend/src/components/ui/input.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" import { cn } from "@/lib/utils" diff --git a/frontend/src/components/ui/label.jsx b/frontend/src/components/ui/label.jsx index 773076b..5c6413d 100644 --- a/frontend/src/components/ui/label.jsx +++ b/frontend/src/components/ui/label.jsx @@ -1,7 +1,5 @@ "use client" -import * as React from "react" - import { cn } from "@/lib/utils" function Label({ diff --git a/frontend/src/components/ui/table.jsx b/frontend/src/components/ui/table.jsx index 8813c82..8cd2b5e 100644 --- a/frontend/src/components/ui/table.jsx +++ b/frontend/src/components/ui/table.jsx @@ -1,7 +1,5 @@ "use client" -import * as React from "react" - import { cn } from "@/lib/utils" function Table({ diff --git a/frontend/src/components/ui/textarea.jsx b/frontend/src/components/ui/textarea.jsx index decf453..6b47f71 100644 --- a/frontend/src/components/ui/textarea.jsx +++ b/frontend/src/components/ui/textarea.jsx @@ -1,5 +1,3 @@ -import * as React from "react" - import { cn } from "@/lib/utils" function Textarea({ diff --git a/frontend/src/lib/AuthContext.jsx b/frontend/src/lib/AuthContext.jsx index 52fa9a3..727ce9d 100644 --- a/frontend/src/lib/AuthContext.jsx +++ b/frontend/src/lib/AuthContext.jsx @@ -1,6 +1,5 @@ -import React, { createContext, useState, useEffect } from 'react'; - -export const AuthContext = createContext(); +import { useState, useEffect } from 'react'; +import { AuthContext } from './auth-context'; async function fetchCurrentUser(token) { const response = await fetch('/api/users/me', { @@ -39,18 +38,15 @@ export const AuthProvider = ({ children }) => { }; useEffect(() => { - const storedToken = localStorage.getItem('token'); - if (storedToken) { - setToken(storedToken); + async function syncUser() { + if (!token || isGuest) { + setUser(null); + return; + } + setUser(await fetchCurrentUser(token)); } - }, []); - useEffect(() => { - if (!token || isGuest) { - setUser(null); - return; - } - fetchCurrentUser(token).then(setUser); + syncUser(); }, [token, isGuest]); return ( diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index ddd5a6e..31d8bf0 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -1,5 +1,5 @@ import { useState, useEffect, useContext } from "react" -import { AuthContext } from "./AuthContext" +import { AuthContext } from "./auth-context" const BASE_URL = "/api" @@ -12,11 +12,11 @@ export function useApi(endpoint) { useEffect(() => { if (!endpoint) return - setLoading(true) - setData(null) - setError(null) - const fetchContent = async () => { + setLoading(true) + setData(null) + setError(null) + try { const response = await fetch(endpoint, { headers: token ? { Authorization: `Bearer ${token}` } : {} diff --git a/frontend/src/lib/auth-context.js b/frontend/src/lib/auth-context.js new file mode 100644 index 0000000..b957bc9 --- /dev/null +++ b/frontend/src/lib/auth-context.js @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const AuthContext = createContext(); diff --git a/frontend/src/lib/useData.js b/frontend/src/lib/useData.js index 5db3db7..c1aab6d 100644 --- a/frontend/src/lib/useData.js +++ b/frontend/src/lib/useData.js @@ -1,5 +1,5 @@ import { useContext, useMemo } from 'react' -import { AuthContext } from './AuthContext' +import { AuthContext } from './auth-context' import { useApi, createProject as createProjectApi, diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js index 19c5cd8..6944da3 100644 --- a/frontend/src/lib/utils.js +++ b/frontend/src/lib/utils.js @@ -1,9 +1,7 @@ import { clsx } from "clsx"; import { twMerge } from "tailwind-merge" -import { useContext } from "react" import { jwtDecode } from "jwt-decode" -import { AuthContext } from './AuthContext' export function cn(...inputs) { return twMerge(clsx(inputs)); @@ -17,4 +15,11 @@ export function isTokenValid(token) { } catch { return false } +} + +export function getCompassDirection(azimuth) { + // Azimuth convention used across the app: South is 0°, West is positive, East is negative. + const COMPASS_DIRECTIONS = ["S", "SW", "W", "NW", "N", "NE", "E", "SE"] + const normalized = ((azimuth % 360) + 360) % 360 + return COMPASS_DIRECTIONS[Math.round(normalized / 45) % 8] } \ No newline at end of file diff --git a/frontend/src/screens/AllProjects.jsx b/frontend/src/screens/AllProjects.jsx index fb56bc9..a890d99 100644 --- a/frontend/src/screens/AllProjects.jsx +++ b/frontend/src/screens/AllProjects.jsx @@ -1,27 +1,15 @@ import { Link } from "react-router" -import { useContext, useEffect } from "react" +import { useEffect } from "react" -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" import CreateProjectDialog from "../components/CreateProjectDialog" -import { Button } from "@/components/ui/button" import Footer from "../components/Footer" -import GuestAlert from "../components/GuestAlert"; import Header from "../components/Header"; import ProjectCard from "../components/ProjectCard" -import { AuthContext } from "../lib/AuthContext" import { useProjects } from "../lib/useData" const AllProjects = () => { - const { isGuest } = useContext(AuthContext) const { data: projects, loading, error } = useProjects() useEffect(() => { diff --git a/frontend/src/screens/CreateScenario.jsx b/frontend/src/screens/CreateScenario.jsx index ba15f04..d5032f3 100644 --- a/frontend/src/screens/CreateScenario.jsx +++ b/frontend/src/screens/CreateScenario.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link, useNavigate, useParams } from "react-router" +import { useNavigate, useParams } from "react-router" import { Breadcrumb, @@ -31,7 +31,6 @@ import { Input } from "@/components/ui/input" import { Table, TableBody, - TableCaption, TableCell, TableRow, } from "@/components/ui/table" @@ -71,15 +70,9 @@ export default function CreateScenario() { const moduleAmountCheck = validateModuleAmount(moduleAmount) const isFormValid = nameCheck.valid && moduleAmountCheck.valid && !!selectedModule - useEffect(() => { - if (!modules || modules.length === 0) return - - const stillValid = modules.some((m) => m.model === selectedModel) - if (!stillValid) { - setSelectedModel(modules[0].model) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [modules]) + if (modules && modules.length > 0 && !modules.some((m) => m.model === selectedModel)) { + setSelectedModel(modules[0].model) + } function markTouched(field) { setTouched((t) => ({ ...t, [field]: true })) diff --git a/frontend/src/screens/Landing.jsx b/frontend/src/screens/Landing.jsx index 2965055..7c2093a 100644 --- a/frontend/src/screens/Landing.jsx +++ b/frontend/src/screens/Landing.jsx @@ -5,7 +5,7 @@ import { ArrowRight } from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" -import { AuthContext } from "../lib/AuthContext" +import { AuthContext } from "../lib/auth-context" import Elsa from "../assets/avatar-elsa.jpg" import Logo from "../assets/pyphos-logo.svg" diff --git a/frontend/src/screens/Login.jsx b/frontend/src/screens/Login.jsx index 58d2947..dd71991 100644 --- a/frontend/src/screens/Login.jsx +++ b/frontend/src/screens/Login.jsx @@ -2,7 +2,7 @@ import { useState, useContext, useEffect } from 'react' import { Link, useNavigate } from 'react-router' import { ArrowLeft } from 'lucide-react' -import { AuthContext } from '@/lib/AuthContext' +import { AuthContext } from '@/lib/auth-context' import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { diff --git a/frontend/src/screens/Project.jsx b/frontend/src/screens/Project.jsx index b152e27..61a646f 100644 --- a/frontend/src/screens/Project.jsx +++ b/frontend/src/screens/Project.jsx @@ -38,10 +38,10 @@ import { } from "@/components/ui/tooltip" import { toast } from "sonner" -import { AuthContext } from "../lib/AuthContext" +import { AuthContext } from "../lib/auth-context" import { downloadReport } from "../lib/api" import { useProject, useReport, useScenarios } from "../lib/useData" -import { getCompassDirection } from "../components/AngleSlider" +import { getCompassDirection } from "../lib/utils" @@ -57,6 +57,7 @@ const Project = () => { const [projectLocation, setProjectLocation] = useState("") const [projectCountryCode, setProjectCountryCode] = useState("") const [projectCoords, setProjectCoords] = useState({ lat: null, lon: null }) + const [loadedProjectId, setLoadedProjectId] = useState(null) useEffect(() => { document.title = `Pyphos - ${projectName || "Project"}` @@ -64,23 +65,22 @@ const Project = () => { const [selectedScenario, setSelectedScenario] = useState(null) const [report, setReport] = useState("") + const [scenariosInitialized, setScenariosInitialized] = useState(false) - useEffect(() => { - if (project) { - setProjectName(project.name ?? "") - setProjectLocation(project.location ?? "") - setProjectCountryCode(project.country_code ?? "") - setProjectCoords({ lat: project.lat ?? null, lon: project.lon ?? null }) - } - }, [project]) + if (project && project.id !== loadedProjectId) { + setLoadedProjectId(project.id) + setProjectName(project.name ?? "") + setProjectLocation(project.location ?? "") + setProjectCountryCode(project.country_code ?? "") + setProjectCoords({ lat: project.lat ?? null, lon: project.lon ?? null }) + } - useEffect(() => { - if (scenarios && scenarios.length != 0) { - const preselected = location.state?.selectedScenarioId - && scenarios.find((s) => s.id === location.state.selectedScenarioId) - setSelectedScenario(preselected || scenarios.at(0)) - } - }, [scenarios]) + if (scenarios && scenarios.length != 0 && !scenariosInitialized) { + setScenariosInitialized(true) + const preselected = location.state?.selectedScenarioId + && scenarios.find((s) => s.id === location.state.selectedScenarioId) + setSelectedScenario(preselected || scenarios.at(0)) + } useEffect(() => { if (!selectedScenario) return @@ -89,11 +89,10 @@ const Project = () => { try { const data = await getReport(projectId, selectedScenario.id, project?.is_demo) setReport(data) - } catch (error) { + } catch (_error) { setReport(null) } } - fetchReport() }, [selectedScenario]) @@ -122,7 +121,7 @@ const Project = () => { document.body.removeChild(link) URL.revokeObjectURL(url) - } catch (error) { + } catch (_error) { toast.error("Failed to download report", { position: "top-center" }) } } diff --git a/frontend/src/screens/Report.jsx b/frontend/src/screens/Report.jsx index 02c9f83..2390fc4 100644 --- a/frontend/src/screens/Report.jsx +++ b/frontend/src/screens/Report.jsx @@ -1,7 +1,6 @@ -import { useContext, useEffect, useState } from "react" +import { useState } from "react" import { useParams } from "react-router" -import { Button } from "@/components/ui/button" import { Card, CardContent, @@ -10,9 +9,7 @@ import { } from "@/components/ui/card" import { ChartBarInteractive } from "../components/BarChart"; import Footer from "../components/Footer" -import Header from "../components/Header"; import Map from "../components/Map"; -import { Download, Pencil, Plus } from "lucide-react" import { Progress } from "@/components/ui/progress" import { Table, @@ -21,37 +18,30 @@ import { TableCell, TableRow, } from "@/components/ui/table" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { AuthContext } from "../lib/AuthContext" import Logo from "../assets/pyphos-logo.svg" import { useApi } from "../lib/api" import "../styles/print.css" export default function Report() { const { projectId, scenarioId } = useParams(); - const { token } = useContext(AuthContext) const { data: project, loading: projLoading, error: projError } = useApi(`/api/projects/${projectId}`) const { data: scenario, loading: scenLoading, error: scenError } = useApi(`/api/projects/${projectId}/scenarios/${scenarioId}`) - const { data: report, loading: reportLoading, error: reportError } = useApi(`/api/projects/${projectId}/scenarios/${scenarioId}/report`) + const { data: report } = useApi(`/api/projects/${projectId}/scenarios/${scenarioId}/report`) const [projectName, setProjectName] = useState("") const [projectLocation, setProjectLocation] = useState("") const [projectCountryCode, setProjectCountryCode] = useState("") const [projectCoords, setProjectCoords] = useState({ lat: null, lon: null }) + const [loadedProjectId, setLoadedProjectId] = useState(null) - useEffect(() => { - if (project) { - setProjectName(project.name ?? "") - setProjectLocation(project.location ?? "") - setProjectCountryCode(project.country_code ?? "") - setProjectCoords({ lat: project.lat ?? null, lon: project.lon ?? null }) - } - }, [project]) + if (project && project.id !== loadedProjectId) { + setLoadedProjectId(project.id) + setProjectName(project.name ?? "") + setProjectLocation(project.location ?? "") + setProjectCountryCode(project.country_code ?? "") + setProjectCoords({ lat: project.lat ?? null, lon: project.lon ?? null }) + } if (projLoading || scenLoading) return

Loading...

if (projError || scenError) return

Something went wrong.

diff --git a/frontend/src/screens/Scenario.jsx b/frontend/src/screens/Scenario.jsx index 8880db4..0644789 100644 --- a/frontend/src/screens/Scenario.jsx +++ b/frontend/src/screens/Scenario.jsx @@ -42,7 +42,6 @@ import { Input } from "@/components/ui/input" import { Table, TableBody, - TableCaption, TableCell, TableRow, } from "@/components/ui/table" @@ -87,31 +86,25 @@ export default function Scenario() { const [deleting, setDeleting] = useState(false) const [deleteError, setDeleteError] = useState(null) + const [loadedScenarioId, setLoadedScenarioId] = useState(null) // avoid useEffect for resetting form fields when a new scenario loads function markTouched(field) { setTouched((t) => ({ ...t, [field]: true })) } - useEffect(() => { - if (scenario) { - setScenarioName(scenario.name ?? "") - setModuleAmount(scenario.module_amount ?? "") - setTilt(scenario.tilt ?? "") - setAzimuth(scenario.azimuth ?? "") - setSelectedManufacturer(scenario.module?.manufacturer ?? "") - setSelectedModel(scenario.module?.model ?? "") - } - }, [scenario]) - - useEffect(() => { - if (!modules || modules.length === 0) return + if (scenario && scenario.id !== loadedScenarioId) { + setLoadedScenarioId(scenario.id) + setScenarioName(scenario.name ?? "") + setModuleAmount(scenario.module_amount ?? "") + setTilt(scenario.tilt ?? "") + setAzimuth(scenario.azimuth ?? "") + setSelectedManufacturer(scenario.module?.manufacturer ?? "") + setSelectedModel(scenario.module?.model ?? "") + } - const stillValid = modules.some((m) => m.model === selectedModel) - if (!stillValid) { - setSelectedModel(modules[0].model) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [modules]) + if (modules && modules.length > 0 && !modules.some((m) => m.model === selectedModel)) { + setSelectedModel(modules[0].model) + } async function handleNameSubmit(e) { e?.preventDefault?.() diff --git a/frontend/src/screens/Settings.jsx b/frontend/src/screens/Settings.jsx index 4d8fe36..8f6b099 100644 --- a/frontend/src/screens/Settings.jsx +++ b/frontend/src/screens/Settings.jsx @@ -33,7 +33,7 @@ import Header from "../components/Header" import { Input } from "@/components/ui/input" import { Trash2 } from "lucide-react" -import { AuthContext } from "../lib/AuthContext" +import { AuthContext } from "../lib/auth-context" import { deleteUser, updateUser } from "../lib/api" import { validateEmail, validatePassword } from "../lib/validators" @@ -49,6 +49,7 @@ export default function Settings() { const [submitSuccess, setSubmitSuccess] = useState(false) const [deleting, setDeleting] = useState(false) const [deleteError, setDeleteError] = useState(null) + const [loadedUserId, setLoadedUserId] = useState(null) useEffect(() => { document.title = `Pyphos - Settings` @@ -58,11 +59,10 @@ export default function Settings() { const passwordCheck = password === "" ? { valid: true, message: null } : validatePassword(password) const isFormValid = emailCheck.valid && passwordCheck.valid - useEffect(() => { - if (user) { - setEmail(user.email ?? "") - } - }, [user]) + if (user && user.id !== loadedUserId) { + setLoadedUserId(user.id) + setEmail(user.email ?? "") + } function markTouched(field) { setTouched((t) => ({ ...t, [field]: true })) diff --git a/frontend/src/screens/Signup.jsx b/frontend/src/screens/Signup.jsx index f3d8497..c8ddf40 100644 --- a/frontend/src/screens/Signup.jsx +++ b/frontend/src/screens/Signup.jsx @@ -2,7 +2,7 @@ import { useState, useContext, useEffect } from 'react' import { Link, useNavigate } from 'react-router' import { ArrowLeft, InfoIcon } from 'lucide-react' -import { AuthContext } from '@/lib/AuthContext' +import { AuthContext } from '@/lib/auth-context' import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c4991cc..383db20 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -16,7 +16,7 @@ export default defineConfig({ }, resolve: { alias: { - '@': path.resolve(__dirname, './src'), + '@': path.resolve(import.meta.dirname, './src'), }, }, })