diff --git a/backend/models/symptom.py b/backend/models/symptom.py index 1dc54e9..d65db01 100644 --- a/backend/models/symptom.py +++ b/backend/models/symptom.py @@ -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 @@ -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", + ), + ) diff --git a/backend/routers/symptom_router.py b/backend/routers/symptom_router.py index 4e86554..fbe8550 100644 --- a/backend/routers/symptom_router.py +++ b/backend/routers/symptom_router.py @@ -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 diff --git a/backend/schemas/symptom.py b/backend/schemas/symptom.py index 3b672e2..06555ab 100644 --- a/backend/schemas/symptom.py +++ b/backend/schemas/symptom.py @@ -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 @@ -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 diff --git a/backend/services/symptom_service.py b/backend/services/symptom_service.py index 059f328..93c0ed7 100644 --- a/backend/services/symptom_service.py +++ b/backend/services/symptom_service.py @@ -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 @@ -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) diff --git a/backend/tests/integration/test_symptom_service.py b/backend/tests/integration/test_symptom_service.py index cc25c00..362df5d 100644 --- a/backend/tests/integration/test_symptom_service.py +++ b/backend/tests/integration/test_symptom_service.py @@ -2,6 +2,8 @@ import uuid +import pytest + from schemas.symptom import SymptomCreate from services.symptom_service import SymptomService @@ -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) diff --git a/frontend/src/assets/icon.png b/frontend/src/assets/icon.png index a0b1526..83c1358 100644 Binary files a/frontend/src/assets/icon.png and b/frontend/src/assets/icon.png differ diff --git a/frontend/src/components/AddSymptomModal.tsx b/frontend/src/components/AddSymptomModal.tsx index 8c2e32a..b1e3f77 100644 --- a/frontend/src/components/AddSymptomModal.tsx +++ b/frontend/src/components/AddSymptomModal.tsx @@ -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; @@ -43,37 +43,23 @@ export function AddSymptomModal({ visible, onClose }: Props) { return ( Sensation - {/* Dropdown is a third-party component that only accepts style, not className */} - 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'}} - /> + + + Location - {/* Dropdown is a third-party component that only accepts style, not className */} - 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'}} - /> + + + ); } diff --git a/frontend/src/components/ComboBox.tsx b/frontend/src/components/ComboBox.tsx new file mode 100644 index 0000000..6d9a2f0 --- /dev/null +++ b/frontend/src/components/ComboBox.tsx @@ -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(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 ( + <> + setOpen(true)} + className="border border-remetra-border rounded-lg p-3 bg-remetra-surface" + activeOpacity={0.7} + > + + {value || placeholder || 'Select...'} + + + + inputRef.current?.focus()} + > + + {}} + className="bg-white rounded-2xl w-full p-4" + style={{ maxHeight: '70%' }} + > + + + {filtered.map((item) => ( + handleSelect(item)} + className="px-3 py-3 border-b border-remetra-border/40" + > + {item} + + ))} + {allowCustom && trimmed && !exactMatch ? ( + + Add “{trimmed}” + + ) : null} + {filtered.length === 0 && !(allowCustom && trimmed) ? ( + No matches + ) : null} + + + + + + ); +} diff --git a/frontend/src/components/SymptomLogForm.tsx b/frontend/src/components/SymptomLogForm.tsx index 23fc352..cbce130 100644 --- a/frontend/src/components/SymptomLogForm.tsx +++ b/frontend/src/components/SymptomLogForm.tsx @@ -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; @@ -167,37 +167,22 @@ export const SymptomLogForm: React.FC = ({ onSubmit, onBack {/* New symptom inline form */} {isCustom && ( - {/* Dropdown is a third-party component that only accepts style, not className */} - { 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'}} - /> + + { setCustomSensation(val); if (errors.sensation) clearError(); }} + /> + {errors.sensation && ( {errors.sensation} )} - { 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 && ( {errors.location} diff --git a/frontend/src/screens/main/AboutScreen.tsx b/frontend/src/screens/main/AboutScreen.tsx index 0528286..37224fb 100644 --- a/frontend/src/screens/main/AboutScreen.tsx +++ b/frontend/src/screens/main/AboutScreen.tsx @@ -10,7 +10,7 @@ import { } from 'lucide-react-native'; import { BackgroundGradient } from '../../components/BackgroundGradient'; -const CONTACT_EMAIL = 'hello@remetra.app'; +const CONTACT_EMAIL = 'nicole@remetra.tech'; export function AboutScreen() { return ( @@ -26,7 +26,7 @@ export function AboutScreen() { REMETRA - Lorem ipsum dolor sit amet + Est. 2025 @@ -34,18 +34,18 @@ export function AboutScreen() { - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + Remetra was founded by Nicole Gaudango after recognizing that many people living with autoimmune and digestive-related conditions + lacked a clear way to understand how their lifestyle choices impact their symptoms. She wanted to create a tool + that helps users gain peace or mind, feel more in control of their health, and improve their overall quality of life. {/* How it works */} - - - + + + {/* Links */} diff --git a/frontend/src/types/symptomOptions.ts b/frontend/src/types/symptomOptions.ts index 9ef22a0..59f813b 100644 --- a/frontend/src/types/symptomOptions.ts +++ b/frontend/src/types/symptomOptions.ts @@ -1,47 +1,47 @@ export const sensationOptions = [ - { label: 'Bloating', value: 'Bloating' }, - { label: 'Pain', value: 'Pain' }, - { label: 'Stiffness', value: 'Stiffness' }, - { label: 'Fatigue', value: 'Fatigue' }, - { label: 'Swelling', value: 'Swelling' }, - { label: 'Burning', value: 'Burning' }, - { label: 'Tingling', value: 'Tingling' }, - { label: 'Numbness', value: 'Numbness' }, - { label: 'Itching', value: 'Itching' }, - { label: 'Cramping', value: 'Cramping' }, - { label: 'Throbbing', value: 'Throbbing' }, - { label: 'Weakness', value: 'Weakness' }, - { label: 'Tenderness', value: 'Tenderness' }, - { label: 'Tightness', value: 'Tightness' }, - { label: 'Aching', value: 'Aching' }, - { label: 'Soreness', value: 'Soreness' }, - { label: 'Sensitivity', value: 'Sensitivity' }, - { label: 'Heaviness', value: 'Heaviness' }, - { label: 'Spasms', value: 'Spasms' }, - { label: 'Prickling', value: 'Prickling' }, - { label: 'Pressure', value: 'Pressure' }, - { label: 'Other', value: 'Other' }, + 'Bloating', + 'Pain', + 'Stiffness', + 'Fatigue', + 'Swelling', + 'Burning', + 'Tingling', + 'Numbness', + 'Itching', + 'Cramping', + 'Throbbing', + 'Weakness', + 'Tenderness', + 'Tightness', + 'Aching', + 'Soreness', + 'Sensitivity', + 'Heaviness', + 'Spasms', + 'Prickling', + 'Pressure', + 'Other', ]; export const locationOptions = [ - { label: 'Whole Body', value: 'Whole Body' }, - { label: 'Head', value: 'Head' }, - { label: 'Neck', value: 'Neck' }, - { label: 'Jaw', value: 'Jaw' }, - { label: 'Chest', value: 'Chest' }, - { label: 'Upper Back', value: 'Upper Back' }, - { label: 'Lower Back', value: 'Lower Back' }, - { label: 'Abdomen', value: 'Abdomen' }, - { label: 'Shoulders', value: 'Shoulders' }, - { label: 'Elbows', value: 'Elbows' }, - { label: 'Wrists', value: 'Wrists' }, - { label: 'Hands / Fingers', value: 'Hands / Fingers' }, - { label: 'Hips', value: 'Hips' }, - { label: 'Knees', value: 'Knees' }, - { label: 'Ankles', value: 'Ankles' }, - { label: 'Feet / Toes', value: 'Feet / Toes' }, - { label: 'Eyes', value: 'Eyes' }, - { label: 'Skin', value: 'Skin' }, - { label: 'Throat', value: 'Throat' }, - { label: 'Joints (Multiple)', value: 'Joints (Multiple)' }, + 'Whole Body', + 'Head', + 'Neck', + 'Jaw', + 'Chest', + 'Upper Back', + 'Lower Back', + 'Abdomen', + 'Shoulders', + 'Elbows', + 'Wrists', + 'Hands / Fingers', + 'Hips', + 'Knees', + 'Ankles', + 'Feet / Toes', + 'Eyes', + 'Skin', + 'Throat', + 'Joints (Multiple)', ];