Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions backend/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==9.0.3
httpx==0.28.1
3 changes: 2 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
starlette==1.0.0
pytest==9.0.3
16 changes: 16 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
])
3 changes: 2 additions & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
8 changes: 1 addition & 7 deletions frontend/src/components/AngleSlider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
32 changes: 16 additions & 16 deletions frontend/src/components/EditProjectDialog.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"
import { useState } from "react"
import { useNavigate, useParams } from "react-router"

import {
Expand Down Expand Up @@ -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()

Expand All @@ -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 }))
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/components/Header.jsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
42 changes: 25 additions & 17 deletions frontend/src/components/LocationCombobox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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
Expand Down Expand Up @@ -37,28 +37,36 @@
}

useEffect(() => {
setQuery(formatLocation(value))
function syncQuery() {
setQuery(formatLocation(value))
}

syncQuery()
}, [value])

Check warning on line 45 in frontend/src/components/LocationCombobox.jsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useEffect has a missing dependency: 'setQuery'. Either include it or remove the dependency array

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])

Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/ui/alert-dialog.jsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/ui/avatar.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"

import { cn } from "@/lib/utils"
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/ui/breadcrumb.jsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
2 changes: 0 additions & 2 deletions frontend/src/components/ui/card.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import * as React from "react"

import { cn } from "@/lib/utils"

function Card({
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/ui/dialog.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"

import { cn } from "@/lib/utils"
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/ui/dropdown-menu.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"

import { cn } from "@/lib/utils"
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/ui/input-group.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client"

import * as React from "react"
import { cva } from "class-variance-authority";

import { cn } from "@/lib/utils"
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/ui/input.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"

import { cn } from "@/lib/utils"
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/components/ui/label.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

function Label({
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/components/ui/table.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

function Table({
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/components/ui/textarea.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import * as React from "react"

import { cn } from "@/lib/utils"

function Textarea({
Expand Down
22 changes: 9 additions & 13 deletions frontend/src/lib/AuthContext.jsx
Original file line number Diff line number Diff line change
@@ -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', {
Expand Down Expand Up @@ -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 (
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/lib/api.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useContext } from "react"
import { AuthContext } from "./AuthContext"
import { AuthContext } from "./auth-context"

const BASE_URL = "/api"

Expand All @@ -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}` } : {}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/auth-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createContext } from 'react';

export const AuthContext = createContext();
2 changes: 1 addition & 1 deletion frontend/src/lib/useData.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useContext, useMemo } from 'react'
import { AuthContext } from './AuthContext'
import { AuthContext } from './auth-context'
import {
useApi,
createProject as createProjectApi,
Expand Down
Loading
Loading