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
15 changes: 12 additions & 3 deletions backend/models/symptom.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import uuid

from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy import Column, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
Expand All @@ -18,8 +18,17 @@ class Symptom(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String, ForeignKey("users.username", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False)
location = Column(String, nullable=True)
sensation = Column(String, nullable=True)
location = Column(String, nullable=False)
sensation = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())

symptom_logs = relationship("SymptomLog", back_populates="symptom")

__table_args__ = (
UniqueConstraint(
"username",
"location",
"sensation",
name="uq_symptoms_username_location_sensation",
),
)
5 changes: 4 additions & 1 deletion backend/routers/symptom_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ async def create_symptom(

symptom.username = current_user.username
symptom_service = SymptomService()
created_symptom = symptom_service.create_symptom(db, symptom)
try:
created_symptom = symptom_service.create_symptom(db, symptom)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
return created_symptom


Expand Down
8 changes: 4 additions & 4 deletions backend/schemas/symptom.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@

class SymptomBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
location: Optional[str] = None
sensation: Optional[str] = None
location: str = Field(..., min_length=1, max_length=255)
sensation: str = Field(..., min_length=1, max_length=255)
username: Optional[str] = None


Expand All @@ -27,8 +27,8 @@ class SymptomUpdate(BaseModel):
"""

name: Optional[str] = Field(default=None, min_length=1, max_length=255)
location: Optional[str] = None
sensation: Optional[str] = None
location: Optional[str] = Field(default=None, min_length=1, max_length=255)
sensation: Optional[str] = Field(default=None, min_length=1, max_length=255)
username: Optional[str] = None


Expand Down
18 changes: 17 additions & 1 deletion backend/services/symptom_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from sqlalchemy.orm import Session

from models.symptom import Symptom
from repositories.symptom_repository import SymptomRepository
from schemas.symptom import SymptomCreate, SymptomResponse

Expand All @@ -31,8 +32,23 @@ def create_symptom(self, db: Session, symptom_data: SymptomCreate) -> SymptomRes
The created symptom with generated ID and timestamps

Raises:
ValueError: If validation fails
ValueError: If a symptom with the same (username, location, sensation) already exists.
"""
existing = (
db.query(Symptom)
.filter(
Symptom.username == symptom_data.username,
Symptom.location == symptom_data.location,
Symptom.sensation == symptom_data.sensation,
)
.first()
)
if existing is not None:
raise ValueError(
f"A symptom with location '{symptom_data.location}' and "
f"sensation '{symptom_data.sensation}' already exists."
)

created_symptom = self.symptom_repo.create_symptom(db, symptom_data)
return SymptomResponse.model_validate(created_symptom)

Expand Down
18 changes: 18 additions & 0 deletions backend/tests/integration/test_symptom_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import uuid

import pytest

from schemas.symptom import SymptomCreate
from services.symptom_service import SymptomService

Expand Down Expand Up @@ -87,3 +89,19 @@ def test_get_symptom_fail(self, db_session):
symptom = service.get_symptom_by_id(db_session, uuid.UUID(int=0))

assert symptom is None

def test_create_symptom_duplicate_location_sensation_rejected(
self, db_session, authenticated_user, sample_symptom_data
):
"""Two symptoms for the same user with the same (location, sensation) must be rejected."""
service = SymptomService()
service.create_symptom(db_session, SymptomCreate(**sample_symptom_data))

duplicate = SymptomCreate(
name="different name but same pair",
location=sample_symptom_data["location"],
sensation=sample_symptom_data["sensation"],
username=sample_symptom_data["username"],
)
with pytest.raises(ValueError, match="already exists"):
service.create_symptom(db_session, duplicate)
Binary file modified frontend/src/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 18 additions & 32 deletions frontend/src/components/AddSymptomModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Text } from 'react-native';
import { Text, View } from 'react-native';
import { useState } from 'react';
import { Dropdown } from 'react-native-element-dropdown';
import { useBankStore } from '../store/bankStore';
import { BaseAddModal } from './BaseAddModal';
import { sensationOptions, locationOptions } from '../types/symptomOptions';
import { ComboBox } from './ComboBox';

interface Props {
visible: boolean;
Expand Down Expand Up @@ -43,37 +43,23 @@ export function AddSymptomModal({ visible, onClose }: Props) {
return (
<BaseAddModal visible={visible} onClose={handleClose} onAdd={handleAdd} addLabel="Add Symptom" error={error}>
<Text className="text-lg font-light font-ptserif text-remetra-espresso/70 mb-1">Sensation</Text>
{/* Dropdown is a third-party component that only accepts style, not className */}
<Dropdown
data={sensationOptions}
search
labelField="label"
valueField="value"
placeholder="Select a sensation..."
searchPlaceholder="Type to search..."
value={sensation}
onChange={item => setSensation(item.value)}
style={{ borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8, marginBottom: 16 }}
placeholderStyle={{ color: '#aaa', fontSize: 14 }}
selectedTextStyle={{ fontSize: 14, color: '#5C2E14' }}
itemTextStyle={{ color: '#5C2E14'}}
/>
<View className="mb-4">
<ComboBox
options={sensationOptions}
value={sensation}
placeholder="Select a sensation..."
onSelect={setSensation}
/>
</View>
<Text className="text-lg font-light font-ptserif text-remetra-espresso/70 mb-1">Location</Text>
{/* Dropdown is a third-party component that only accepts style, not className */}
<Dropdown
data={locationOptions}
search
labelField="label"
valueField="value"
placeholder="Select a location..."
searchPlaceholder="Type to search..."
value={location}
onChange={item => setLocation(item.value)}
style={{ borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8, marginBottom: 16 }}
placeholderStyle={{ color: '#aaa', fontSize: 14 }}
selectedTextStyle={{ fontSize: 14, color: '#5C2E14' }}
itemTextStyle={{ color: '#5C2E14'}}
/>
<View className="mb-4">
<ComboBox
options={locationOptions}
value={location}
placeholder="Select a location..."
onSelect={setLocation}
/>
</View>
</BaseAddModal>
);
}
123 changes: 123 additions & 0 deletions frontend/src/components/ComboBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react';
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
TouchableOpacity,
} from 'react-native';

interface ComboBoxProps {
value: string;
onSelect: (value: string) => void;
options: string[];
placeholder?: string;
allowCustom?: boolean;
}

export function ComboBox({
value,
onSelect,
options,
placeholder,
allowCustom = true,
}: ComboBoxProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const inputRef = useRef<TextInput>(null);

useEffect(() => {
if (open) {
setQuery(value || '');
}
}, [open, value]);

const trimmed = query.trim();
const filtered = options.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
const exactMatch = options.some(
(item) => item.toLowerCase() === trimmed.toLowerCase()
);

const close = () => setOpen(false);

const handleSelect = (item: string) => {
onSelect(item);
setOpen(false);
};

const handleSubmit = () => {
if (allowCustom && trimmed) {
onSelect(trimmed);
setOpen(false);
}
};

return (
<>
<TouchableOpacity
onPress={() => setOpen(true)}
className="border border-remetra-border rounded-lg p-3 bg-remetra-surface"
activeOpacity={0.7}
>
<Text className={value ? 'text-remetra-espresso' : 'text-remetra-muted'}>
{value || placeholder || 'Select...'}
</Text>
</TouchableOpacity>

<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={close}
onShow={() => inputRef.current?.focus()}
>
<Pressable
onPress={close}
className="flex-1 bg-black/50 justify-center items-center px-6"
>
<Pressable
onPress={() => {}}
className="bg-white rounded-2xl w-full p-4"
style={{ maxHeight: '70%' }}
>
<TextInput
ref={inputRef}
value={query}
placeholder={placeholder ?? 'Search...'}
placeholderTextColor="#aaa"
className="border border-remetra-border rounded-lg p-3 bg-remetra-surface text-remetra-espresso mb-3"
onChangeText={setQuery}
onSubmitEditing={handleSubmit}
returnKeyType="done"
/>
<ScrollView
keyboardShouldPersistTaps="handled"
style={{ maxHeight: 320 }}
>
{filtered.map((item) => (
<TouchableOpacity
key={item}
onPress={() => handleSelect(item)}
className="px-3 py-3 border-b border-remetra-border/40"
>
<Text className="text-remetra-espresso">{item}</Text>
</TouchableOpacity>
))}
{allowCustom && trimmed && !exactMatch ? (
<TouchableOpacity onPress={handleSubmit} className="px-3 py-3">
<Text className="text-remetra-warm-brown">Add &ldquo;{trimmed}&rdquo;</Text>
</TouchableOpacity>
) : null}
{filtered.length === 0 && !(allowCustom && trimmed) ? (
<Text className="text-remetra-muted text-center py-4">No matches</Text>
) : null}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
</>
);
}
41 changes: 13 additions & 28 deletions frontend/src/components/SymptomLogForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { useAuthStore } from "../store/useAuthStore";
import { useState } from "react";
import { View, Text, TouchableOpacity, TextInput } from "react-native";
import { ArrowLeft } from "lucide-react-native";
import { Dropdown } from "react-native-element-dropdown";
import { LogDateTimePicker } from "./LogDateTimePicker";
import { sensationOptions, locationOptions } from "../types/symptomOptions";
import { useUIStore } from "../store/uiStore";
import { ComboBox } from "./ComboBox";

interface SymptomLogFormProps {
onSubmit: (entry: SymptomLogEntry) => void;
Expand Down Expand Up @@ -167,37 +167,22 @@ export const SymptomLogForm: React.FC<SymptomLogFormProps> = ({ onSubmit, onBack
{/* New symptom inline form */}
{isCustom && (
<View>
{/* Dropdown is a third-party component that only accepts style, not className */}
<Dropdown
data={sensationOptions}
search
labelField="label"
valueField="value"
placeholder="Select a sensation..."
searchPlaceholder="Type to search..."
value={customSensation}
onChange={item => { setCustomSensation(item.value); if (errors.sensation) clearError(); }}
style={{ borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8, marginBottom: 8 }}
placeholderStyle={{ color: '#aaa', fontSize: 14 }}
selectedTextStyle={{ fontSize: 14, color: '#5C2E14' }}
itemTextStyle={{ color: '#5C2E14'}}
/>
<View className="mb-2">
<ComboBox
options={sensationOptions}
value={customSensation}
placeholder="Select a sensation..."
onSelect={(val) => { setCustomSensation(val); if (errors.sensation) clearError(); }}
/>
</View>
{errors.sensation && (
<Text className="text-red-400 text-xs mb-2">{errors.sensation}</Text>
)}
<Dropdown
data={locationOptions}
search
labelField="label"
valueField="value"
placeholder="Select a location..."
searchPlaceholder="Type to search..."
<ComboBox
options={locationOptions}
value={customLocation}
onChange={item => { setCustomLocation(item.value); if (errors.location) clearError(); }}
style={{ borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8 }}
placeholderStyle={{ color: '#aaa', fontSize: 14 }}
selectedTextStyle={{ fontSize: 14, color: '#5C2E14' }}
itemTextStyle={{ color: '#5C2E14'}}
placeholder="Select a location..."
onSelect={(val) => { setCustomLocation(val); if (errors.location) clearError(); }}
/>
{errors.location && (
<Text className="text-red-400 text-xs mb-2">{errors.location}</Text>
Expand Down
Loading
Loading