diff --git a/README.md b/README.md index 009de3c..500ad54 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,132 @@ -# vibe-praying +# Vibe Praying -Vibe coding a webapp to generate prayer partner pairs \ No newline at end of file +A full-stack web application built with FastAPI that creates random prayer partnerships. Each person is paired with two other people chosen randomly, and all results are persisted in a SQLite database. + +## Features + +- **Random Pairing**: Each person is paired with exactly 2 other people randomly +- **Persistent Storage**: All pairing sessions are saved to SQLite database +- **Modern UI**: Clean, responsive design with smooth animations +- **Session Management**: View and reload previous pairing sessions +- **Real-time Feedback**: Loading states, success messages, and error handling +- **Mobile Responsive**: Works perfectly on desktop and mobile devices + +## Installation + +1. **Clone or download the project files** + +2. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +3. **Run the application**: + ```bash + python app.py + ``` + +4. **Open your browser** and navigate to: + ``` + http://localhost:8000 + ``` + +## Usage + +1. **Enter Names**: Type or paste names into the text area, one name per line +2. **Session Name**: Optionally provide a name for your session +3. **Create Vibes**: Click the "Create Vibes" button to generate random partnerships +4. **View Results**: See all the prayer vibes displayed in a clean format +5. **Previous Sessions**: View and reload any previous pairing sessions + +## API Endpoints + +- `GET /` - Main application page +- `POST /create-pairs` - Create new pairing session +- `GET /sessions` - Get all previous sessions +- `GET /session/{session_id}` - Get specific session details + +## Database Schema + +The application uses SQLite with two main tables: + +- **pairing_sessions**: Stores session metadata and pairing results +- **pairs**: Individual pair records (currently unused but available for future features) + +## Pairing Algorithm + +The pairing algorithm ensures that: +- Each person is paired with exactly 2 other people +- No duplicate pairs are created +- Pairs are distributed as evenly as possible +- Results are truly random + +## Technology Stack + +- **Backend**: FastAPI, SQLAlchemy, SQLite +- **Frontend**: HTML5, CSS3, Vanilla JavaScript +- **Styling**: Custom CSS with modern design patterns +- **Icons**: Font Awesome +- **Fonts**: Inter (Google Fonts) + +## Development + +To run in development mode with auto-reload: + +```bash +uvicorn app:app --reload --host 0.0.0.0 --port 8000 +``` + +## File Structure + +``` +prayer-pairing/ +├── app.py # Main FastAPI application +├── requirements.txt # Python dependencies +├── README.md # This file +├── templates/ +│ └── index.html # Main HTML template +└── static/ + ├── css/ + │ └── style.css # Stylesheets + └── js/ + └── app.js # Frontend JavaScript +``` + +## Features in Detail + +### Random Vibe Logic +- Takes a list of names as input +- Creates vibes where each person is matched with 2 others +- Avoids duplicate vibes and self-pairing +- Handles edge cases (odd number of people, etc.) + +### Persistent Storage +- All sessions are automatically saved to SQLite database +- Sessions survive app restarts and crashes +- Previous sessions can be viewed and reloaded +- Database file: `prayer_pairs.db` + +### Modern UI/UX +- Gradient background with modern card design +- Smooth animations and hover effects +- Loading states and success feedback +- Error handling with user-friendly messages +- Responsive design for all screen sizes +- Keyboard shortcuts (Ctrl+Enter to create pairs) + +### Session Management +- View all previous pairing sessions +- Click any session to reload and view its pairs +- Session metadata (name, date, participants) +- Easy navigation between sessions + +## Browser Compatibility + +- Chrome/Chromium (recommended) +- Firefox +- Safari +- Edge + +## License + +This project is open source and available under the MIT License. \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..555a168 --- /dev/null +++ b/app.py @@ -0,0 +1,245 @@ +from fastapi import FastAPI, Request, Form, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlalchemy import create_engine, Column, Integer, String, DateTime +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.sql import func +from pydantic import BaseModel +import random +from typing import List, Dict, Any +from datetime import datetime +import os + +# Create FastAPI app +app = FastAPI(title="Vibe Praying", version="1.0.0") + +# Database setup +SQLALCHEMY_DATABASE_URL = "sqlite:///./prayer_pairs.db" +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +# Database models +class PairingSession(Base): + __tablename__ = "pairing_sessions" + + id = Column(Integer, primary_key=True, index=True) + session_name = Column(String, index=True) + created_at = Column(DateTime, default=func.now()) + names = Column(String) # JSON string of names + pairs = Column(String) # JSON string of pairs + +class Pair(Base): + __tablename__ = "pairs" + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, index=True) + person1 = Column(String) + person2 = Column(String) + created_at = Column(DateTime, default=func.now()) + +# Create tables +Base.metadata.create_all(bind=engine) + +# Pydantic models +class NameList(BaseModel): + names: List[str] + +class PairingResult(BaseModel): + session_name: str + names: List[str] + pairs: List[Dict[str, str]] + created_at: datetime + +# Templates and static files +templates = Jinja2Templates(directory="templates") +app.mount("/static", StaticFiles(directory="static"), name="static") + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +def create_pairs(names: List[str]) -> List[Dict[str, str]]: + """Create pairs where each person is paired with 2 others randomly.""" + if len(names) < 3: + raise ValueError("Need at least 3 names to create pairs") + + # For each person, we need to pair them with 2 others + # Total pairs needed = len(names) * 2 / 2 = len(names) + pairs_needed = len(names) + + # Create a list of all possible pairs + all_possible_pairs = [] + for i, person1 in enumerate(names): + for j, person2 in enumerate(names): + if i < j: # Avoid duplicates and self-pairing + all_possible_pairs.append((person1, person2)) + + # Shuffle the pairs + random.shuffle(all_possible_pairs) + + # Track how many pairs each person has + person_pair_counts = {name: 0 for name in names} + selected_pairs = [] + + # Select pairs ensuring each person gets exactly 2 pairs + for person1, person2 in all_possible_pairs: + # Check if adding this pair would exceed 2 pairs for either person + if (person_pair_counts[person1] < 2 and + person_pair_counts[person2] < 2): + + selected_pairs.append({"person1": person1, "person2": person2}) + person_pair_counts[person1] += 1 + person_pair_counts[person2] += 1 + + # If we have enough pairs, we're done + if len(selected_pairs) >= pairs_needed: + break + + # If we don't have enough pairs, try to add more + if len(selected_pairs) < pairs_needed: + # Find people who still need pairs + people_needing_pairs = [name for name, count in person_pair_counts.items() if count < 2] + + # Try to create additional pairs + for person1 in people_needing_pairs: + for person2 in people_needing_pairs: + if person1 != person2: + # Check if this pair already exists + pair_exists = any( + (p["person1"] == person1 and p["person2"] == person2) or + (p["person1"] == person2 and p["person2"] == person1) + for p in selected_pairs + ) + + if not pair_exists and person_pair_counts[person1] < 2 and person_pair_counts[person2] < 2: + selected_pairs.append({"person1": person1, "person2": person2}) + person_pair_counts[person1] += 1 + person_pair_counts[person2] += 1 + + if len(selected_pairs) >= pairs_needed: + break + if len(selected_pairs) >= pairs_needed: + break + + # If we still don't have enough pairs, try a different approach + if len(selected_pairs) < pairs_needed: + # Reset and try a more aggressive approach + person_pair_counts = {name: 0 for name in names} + selected_pairs = [] + + # Try to create pairs more systematically + for i, person1 in enumerate(names): + if person_pair_counts[person1] < 2: + for j, person2 in enumerate(names): + if i != j and person_pair_counts[person2] < 2: + # Check if this pair already exists + pair_exists = any( + (p["person1"] == person1 and p["person2"] == person2) or + (p["person1"] == person2 and p["person2"] == person1) + for p in selected_pairs + ) + + if not pair_exists: + selected_pairs.append({"person1": person1, "person2": person2}) + person_pair_counts[person1] += 1 + person_pair_counts[person2] += 1 + + if len(selected_pairs) >= pairs_needed: + break + if len(selected_pairs) >= pairs_needed: + break + + return selected_pairs + +@app.get("/", response_class=HTMLResponse) +async def home(request: Request): + """Serve the main page.""" + return templates.TemplateResponse("index.html", {"request": request}) + +@app.post("/create-pairs") +async def create_pairs_endpoint(names: NameList, session_name: str = "Default Session"): + """Create pairs from a list of names.""" + if len(names.names) < 3: + raise HTTPException(status_code=400, detail="Need at least 3 names to create pairs") + + # Remove duplicates and empty names + clean_names = list(set([name.strip() for name in names.names if name.strip()])) + + if len(clean_names) < 3: + raise HTTPException(status_code=400, detail="Need at least 3 valid names after cleaning") + + # Create pairs + pairs = create_pairs(clean_names) + + # Save to database + db = SessionLocal() + try: + import json + session = PairingSession( + session_name=session_name, + names=json.dumps(clean_names), + pairs=json.dumps(pairs) + ) + db.add(session) + db.commit() + db.refresh(session) + + return { + "session_id": session.id, + "session_name": session_name, + "names": clean_names, + "pairs": pairs, + "created_at": session.created_at + } + finally: + db.close() + +@app.get("/sessions") +async def get_sessions(): + """Get all pairing sessions.""" + db = SessionLocal() + try: + sessions = db.query(PairingSession).order_by(PairingSession.created_at.desc()).all() + import json + return [ + { + "id": session.id, + "session_name": session.session_name, + "names": json.loads(session.names), + "pairs": json.loads(session.pairs), + "created_at": session.created_at + } + for session in sessions + ] + finally: + db.close() + +@app.get("/session/{session_id}") +async def get_session(session_id: int): + """Get a specific pairing session.""" + db = SessionLocal() + try: + session = db.query(PairingSession).filter(PairingSession.id == session_id).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + import json + return { + "id": session.id, + "session_name": session.session_name, + "names": json.loads(session.names), + "pairs": json.loads(session.pairs), + "created_at": session.created_at + } + finally: + db.close() + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/prayer_pairs.db b/prayer_pairs.db new file mode 100644 index 0000000..7a530a4 Binary files /dev/null and b/prayer_pairs.db differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..17832ad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +aiofiles==24.1.0 +annotated-types==0.7.0 +anyio==4.9.0 +click==8.2.1 +fastapi==0.116.1 +h11==0.16.0 +httptools==0.6.4 +idna==3.10 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +pydantic==2.11.7 +pydantic_core==2.33.2 +python-dotenv==1.1.1 +python-multipart==0.0.20 +PyYAML==6.0.2 +sniffio==1.3.1 +SQLAlchemy==2.0.41 +starlette==0.47.1 +typing-inspection==0.4.1 +typing_extensions==4.14.1 +uvicorn==0.35.0 +uvloop==0.21.0 +watchfiles==1.1.0 +websockets==15.0.1 +pytest==7.4.3 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..441c80d --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,387 @@ +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #333; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +.header { + text-align: center; + margin-bottom: 40px; + color: white; +} + +.header h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 10px; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); +} + +.header p { + font-size: 1.1rem; + opacity: 0.9; + font-weight: 300; +} + +/* Main content */ +.main-content { + display: grid; + gap: 30px; + grid-template-columns: 1fr; +} + +@media (min-width: 768px) { + .main-content { + grid-template-columns: 1fr 1fr; + } +} + +/* Sections */ +.input-section, +.results-section, +.sessions-section { + background: white; + border-radius: 16px; + padding: 30px; + box-shadow: 0 10px 30px rgba(0,0,0,0.1); + backdrop-filter: blur(10px); +} + +.sessions-section { + grid-column: 1 / -1; +} + +/* Section headers */ +h2 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 20px; + color: #2d3748; + display: flex; + align-items: center; + gap: 10px; +} + +/* Form elements */ +.input-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: #4a5568; +} + +input[type="text"], +textarea { + width: 100%; + padding: 12px 16px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + font-family: inherit; + transition: all 0.3s ease; + background: #f7fafc; +} + +input[type="text"]:focus, +textarea:focus { + outline: none; + border-color: #667eea; + background: white; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +textarea { + resize: vertical; + min-height: 120px; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + font-family: inherit; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); +} + +.btn-secondary { + background: #48bb78; + color: white; + box-shadow: 0 4px 15px rgba(72, 187, 120, 0.4); +} + +.btn-secondary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(72, 187, 120, 0.6); +} + +.btn-outline { + background: transparent; + color: #667eea; + border: 2px solid #667eea; +} + +.btn-outline:hover { + background: #667eea; + color: white; +} + +/* Results section */ +.session-info { + background: #f7fafc; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + border-left: 4px solid #667eea; +} + +.session-info h3 { + color: #2d3748; + margin-bottom: 5px; +} + +.session-info p { + color: #718096; + font-size: 0.9rem; +} + +/* Pairs container */ +.pairs-container { + display: grid; + gap: 15px; + margin-bottom: 20px; +} + +.pair-card { + background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%); + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 20px; + display: flex; + align-items: center; + justify-content: space-between; + transition: all 0.3s ease; +} + +.pair-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0,0,0,0.1); + border-color: #667eea; +} + +.pair-info { + display: flex; + align-items: center; + gap: 15px; + flex: 1; +} + +.person { + background: white; + padding: 8px 16px; + border-radius: 20px; + font-weight: 500; + color: #2d3748; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.pair-arrow { + color: #667eea; + font-size: 1.2rem; +} + +/* Actions */ +.actions { + display: flex; + gap: 15px; + flex-wrap: wrap; +} + +/* Sessions section */ +.sessions-container { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.session-card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 20px; + transition: all 0.3s ease; + cursor: pointer; +} + +.session-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0,0,0,0.1); + border-color: #667eea; +} + +.session-card h3 { + color: #2d3748; + margin-bottom: 10px; + font-size: 1.1rem; +} + +.session-card p { + color: #718096; + font-size: 0.9rem; + margin-bottom: 15px; +} + +.session-names { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 15px; +} + +.name-tag { + background: #edf2f7; + color: #4a5568; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; +} + +.session-pairs { + font-size: 0.9rem; + color: #718096; +} + +/* Modals */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + backdrop-filter: blur(5px); +} + +.modal-content { + background-color: white; + margin: 15% auto; + padding: 30px; + border-radius: 16px; + width: 90%; + max-width: 400px; + text-align: center; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); +} + +.success-icon { + font-size: 3rem; + color: #48bb78; + margin-bottom: 20px; +} + +/* Spinner */ +.spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #667eea; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 0 auto 20px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Responsive design */ +@media (max-width: 768px) { + .container { + padding: 15px; + } + + .header h1 { + font-size: 2rem; + } + + .input-section, + .results-section, + .sessions-section { + padding: 20px; + } + + .actions { + flex-direction: column; + } + + .btn { + width: 100%; + justify-content: center; + } +} + +/* Loading states */ +.loading { + opacity: 0.6; + pointer-events: none; +} + +/* Error states */ +.error { + border-color: #e53e3e !important; + background-color: #fed7d7 !important; +} + +.error-message { + color: #e53e3e; + font-size: 0.9rem; + margin-top: 5px; +} + +/* Success states */ +.success { + border-color: #48bb78 !important; + background-color: #f0fff4 !important; +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..bf6ec62 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,281 @@ +// Global variables +let currentSession = null; + +// DOM elements +const createPairsBtn = document.getElementById('createPairsBtn'); +const namesInput = document.getElementById('namesInput'); +const sessionNameInput = document.getElementById('sessionName'); +const resultsSection = document.getElementById('resultsSection'); +const pairsContainer = document.getElementById('pairsContainer'); +const sessionTitle = document.getElementById('sessionTitle'); +const sessionDate = document.getElementById('sessionDate'); +const sessionsContainer = document.getElementById('sessionsContainer'); +const loadingModal = document.getElementById('loadingModal'); +const successModal = document.getElementById('successModal'); + +// Event listeners +document.addEventListener('DOMContentLoaded', function() { + loadPreviousSessions(); + + createPairsBtn.addEventListener('click', createPairs); + + // Add event listeners for action buttons + document.addEventListener('click', function(e) { + if (e.target.id === 'newSessionBtn') { + resetForm(); + } + }); +}); + +// API functions +async function createPairs() { + const names = namesInput.value.trim(); + const sessionName = sessionNameInput.value.trim() || 'Prayer Session'; + + if (!names) { + showError('Please enter at least one name.'); + return; + } + + const nameList = names.split('\n') + .map(name => name.trim()) + .filter(name => name.length > 0); + + if (nameList.length < 3) { + showError('Please enter at least 3 names to create vibes.'); + return; + } + + showLoading(); + + try { + const response = await fetch('/create-pairs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + names: nameList, + session_name: sessionName + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to create vibes'); + } + + const result = await response.json(); + currentSession = result; + displayResults(result); + hideLoading(); + showSuccess(); + loadPreviousSessions(); // Refresh the sessions list + + } catch (error) { + hideLoading(); + showError(error.message); + } +} + +async function loadPreviousSessions() { + try { + const response = await fetch('/sessions'); + if (!response.ok) { + throw new Error('Failed to load sessions'); + } + + const sessions = await response.json(); + displaySessions(sessions); + + } catch (error) { + console.error('Error loading sessions:', error); + } +} + +async function loadSession(sessionId) { + try { + const response = await fetch(`/session/${sessionId}`); + if (!response.ok) { + throw new Error('Failed to load session'); + } + + const session = await response.json(); + currentSession = session; + displayResults(session); + + // Scroll to results section + resultsSection.scrollIntoView({ behavior: 'smooth' }); + + } catch (error) { + showError('Failed to load session'); + } +} + +// Display functions +function displayResults(session) { + sessionTitle.textContent = session.session_name; + sessionDate.textContent = formatDate(session.created_at); + + // Clear previous pairs + pairsContainer.innerHTML = ''; + + // Display pairs + session.pairs.forEach(pair => { + const pairCard = document.createElement('div'); + pairCard.className = 'pair-card'; + pairCard.innerHTML = ` +
No previous sessions found.
'; + return; + } + + sessions.forEach(session => { + const sessionCard = document.createElement('div'); + sessionCard.className = 'session-card'; + sessionCard.onclick = () => loadSession(session.id); + + const namesHtml = session.names.map(name => + `${name}` + ).join(''); + + sessionCard.innerHTML = ` +Created: ${formatDate(session.created_at)}
+Create random prayer partnerships where each person is paired with two others
+