first prototype commit

This commit is contained in:
2026-08-09 21:45:06 -04:00
commit 40613a00d5
36 changed files with 4371 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import os
import jwt
from datetime import datetime, timedelta, timezone
from typing import Optional
SECRET_KEY = os.getenv("JWT_SECRET", "choreus-super-secret-key-2026-family-chores")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.PyJWTError:
return None
def get_current_week_identifier() -> str:
now = datetime.now(timezone.utc)
year, week, _ = now.isocalendar()
return f"{year}-W{week:02d}"
+20
View File
@@ -0,0 +1,20 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "choreus.db")
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+104
View File
@@ -0,0 +1,104 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from backend.database import engine, Base, SessionLocal
from backend.models import User, ChoreType, ChoreCompletion, UserRole, RecurrenceType
from backend.routers import auth, users, chores, reports
from backend.config import get_current_week_identifier
from datetime import datetime, timezone, timedelta
def seed_initial_data():
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# Seed Users if empty
if db.query(User).count() == 0:
admin_user = User(
email="admin@choreus.app",
name="Sarah (Admin)",
avatar_url="https://api.dicebear.com/7.x/bottts/svg?seed=SarahAdmin",
role=UserRole.ADMIN.value,
weekly_star_quota=20
)
kid_user1 = User(
email="leo@choreus.app",
name="Leo",
avatar_url="https://api.dicebear.com/7.x/bottts/svg?seed=LeoKid",
role=UserRole.REGULAR.value,
weekly_star_quota=15
)
kid_user2 = User(
email="maya@choreus.app",
name="Maya",
avatar_url="https://api.dicebear.com/7.x/bottts/svg?seed=MayaKid",
role=UserRole.REGULAR.value,
weekly_star_quota=15
)
db.add_all([admin_user, kid_user1, kid_user2])
db.commit()
# Seed Chores if empty
if db.query(ChoreType).count() == 0:
chores_list = [
ChoreType(title="Empty & fill dishwasher", description="Clear dishes, load dirty ones and start cycle", star_reward=1, recurrence=RecurrenceType.DAILY.value, icon="Utensils"),
ChoreType(title="Take out trash & recycling", description="Empty kitchen bin and carry bins to curb", star_reward=1, recurrence=RecurrenceType.DAILY.value, icon="Trash2"),
ChoreType(title="Vacuum living room", description="Vacuum carpet and couch cushions thoroughly", star_reward=3, recurrence=RecurrenceType.WEEKLY.value, icon="Broom"),
ChoreType(title="Clean bathroom", description="Scrub sink, mirror, toilet, and wipe floor", star_reward=4, recurrence=RecurrenceType.WEEKLY.value, icon="Sparkles"),
ChoreType(title="Clear snow from driveway", description="Shovel front driveway and salt walkways after snow fall", star_reward=5, recurrence=RecurrenceType.SPONTANEOUS.value, icon="Snowflake"),
ChoreType(title="Go buy groceries", description="Pick up weekly groceries from list", star_reward=4, recurrence=RecurrenceType.SPONTANEOUS.value, icon="ShoppingBag"),
]
db.add_all(chores_list)
db.commit()
# Seed sample completions if none exist
if db.query(ChoreCompletion).count() == 0:
users_list = db.query(User).all()
chores_types = db.query(ChoreType).all()
current_week = get_current_week_identifier()
if len(users_list) >= 3 and len(chores_types) >= 3:
c1 = ChoreCompletion(
chore_type_id=chores_types[0].id,
user_id=users_list[1].id, # Leo
stars_earned=chores_types[0].star_reward,
week_identifier=current_week,
completed_at=datetime.now(timezone.utc) - timedelta(hours=5),
notes="All clean!"
)
c2 = ChoreCompletion(
chore_type_id=chores_types[2].id,
user_id=users_list[2].id, # Maya
stars_earned=chores_types[2].star_reward,
week_identifier=current_week,
completed_at=datetime.now(timezone.utc) - timedelta(hours=2),
notes="Living room carpet looks great"
)
db.add_all([c1, c2])
db.commit()
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
seed_initial_data()
yield
app = FastAPI(title="ChoreUS API", version="1.0.0", description="Home Chore Manager API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(chores.router)
app.include_router(reports.router)
@app.get("/")
def root():
return {"message": "Welcome to ChoreUS Home Chore Manager API", "status": "running"}
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime, timezone
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Enum
from sqlalchemy.orm import relationship
import enum
from backend.database import Base
class UserRole(str, enum.Enum):
ADMIN = "admin"
REGULAR = "regular"
class RecurrenceType(str, enum.Enum):
DAILY = "daily"
WEEKLY = "weekly"
SPONTANEOUS = "spontaneous"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
name = Column(String, nullable=False)
google_id = Column(String, unique=True, nullable=True, index=True)
avatar_url = Column(String, nullable=True)
role = Column(String, default=UserRole.REGULAR.value, nullable=False)
weekly_star_quota = Column(Integer, default=15, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
completions = relationship("ChoreCompletion", back_populates="user", cascade="all, delete-orphan")
class ChoreType(Base):
__tablename__ = "chore_types"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(String, nullable=True)
star_reward = Column(Integer, default=1, nullable=False) # 1 to 5 stars
recurrence = Column(String, default=RecurrenceType.DAILY.value, nullable=False) # daily, weekly, spontaneous
icon = Column(String, default="CheckSquare", nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
completions = relationship("ChoreCompletion", back_populates="chore_type", cascade="all, delete-orphan")
class ChoreCompletion(Base):
__tablename__ = "chore_completions"
id = Column(Integer, primary_key=True, index=True)
chore_type_id = Column(Integer, ForeignKey("chore_types.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
completed_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
stars_earned = Column(Integer, nullable=False)
week_identifier = Column(String, nullable=False, index=True) # e.g., '2026-W32'
notes = Column(String, nullable=True)
user = relationship("User", back_populates="completions")
chore_type = relationship("ChoreType", back_populates="completions")
+113
View File
@@ -0,0 +1,113 @@
from fastapi import APIRouter, Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, UserRole
from backend.schemas import UserOut, GoogleAuthRequest, DemoLoginRequest
from backend.config import create_access_token, decode_access_token, GOOGLE_CLIENT_ID
from typing import Optional
from google.oauth2 import id_token
from google.auth.transport import requests as google_requests
router = APIRouter(prefix="/api/auth", tags=["auth"])
def get_current_user(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)) -> User:
if not authorization:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing authorization header")
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization header format")
token = parts[1]
payload = decode_access_token(token)
if not payload or "sub" not in payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
user_id = int(payload["sub"])
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return user
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
return current_user
@router.post("/google")
def google_auth(req: GoogleAuthRequest, db: Session = Depends(get_db)):
email = req.email
name = req.name or "Google User"
avatar_url = req.avatar_url or f"https://api.dicebear.com/7.x/bottts/svg?seed={email}"
google_sub = None
# Try verifying real Google token if client ID configured and credential provided
if req.credential and len(req.credential) > 50:
try:
if GOOGLE_CLIENT_ID:
id_info = id_token.verify_oauth2_token(req.credential, google_requests.Request(), GOOGLE_CLIENT_ID)
else:
id_info = id_token.verify_oauth2_token(req.credential, google_requests.Request())
email = id_info.get("email", email)
name = id_info.get("name", name)
avatar_url = id_info.get("picture", avatar_url)
google_sub = id_info.get("sub")
except Exception as e:
# Fallback to provided details if token decode fails in dev
if not email:
raise HTTPException(status_code=400, detail=f"Google token verification failed: {str(e)}")
if not email:
raise HTTPException(status_code=400, detail="Email is required")
user = db.query(User).filter(User.email == email).first()
# Check if this is the first user ever registered
total_users = db.query(User).count()
initial_role = UserRole.ADMIN.value if total_users == 0 else UserRole.REGULAR.value
if not user:
user = User(
email=email,
name=name,
avatar_url=avatar_url,
google_id=google_sub,
role=initial_role,
weekly_star_quota=15
)
db.add(user)
db.commit()
db.refresh(user)
else:
# Update existing user google_id or default avatar if missing
if google_sub and not user.google_id:
user.google_id = google_sub
if avatar_url and not user.avatar_url:
user.avatar_url = avatar_url
db.commit()
db.refresh(user)
token = create_access_token({"sub": str(user.id), "email": user.email, "role": user.role})
return {
"access_token": token,
"token_type": "bearer",
"user": UserOut.model_validate(user)
}
@router.post("/demo-login")
def demo_login(req: DemoLoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == req.email).first()
if not user:
raise HTTPException(status_code=404, detail="Demo user not found. Run backend initialization first.")
token = create_access_token({"sub": str(user.id), "email": user.email, "role": user.role})
return {
"access_token": token,
"token_type": "bearer",
"user": UserOut.model_validate(user)
}
@router.get("/me", response_model=UserOut)
def get_me(current_user: User = Depends(get_current_user)):
return current_user
+132
View File
@@ -0,0 +1,132 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, timezone
from backend.database import get_db
from backend.models import User, ChoreType, ChoreCompletion, UserRole, RecurrenceType
from backend.schemas import ChoreTypeOut, ChoreTypeCreate, ChoreTypeUpdate, ChoreCompletionCreate, ChoreCompletionOut
from backend.routers.auth import get_current_user, get_admin_user
from backend.config import get_current_week_identifier
router = APIRouter(prefix="/api/chores", tags=["chores"])
@router.get("/types", response_model=List[ChoreTypeOut])
def list_chore_types(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(ChoreType).order_by(ChoreType.id.asc()).all()
@router.post("/types", response_model=ChoreTypeOut)
def create_chore_type(chore_in: ChoreTypeCreate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
if chore_in.star_reward < 1 or chore_in.star_reward > 5:
raise HTTPException(status_code=400, detail="Star reward must be between 1 and 5")
if chore_in.recurrence not in [r.value for r in RecurrenceType]:
raise HTTPException(status_code=400, detail=f"Invalid recurrence type. Must be daily, weekly, or spontaneous")
new_chore = ChoreType(
title=chore_in.title,
description=chore_in.description,
star_reward=chore_in.star_reward,
recurrence=chore_in.recurrence,
icon=chore_in.icon
)
db.add(new_chore)
db.commit()
db.refresh(new_chore)
return new_chore
@router.patch("/types/{chore_id}", response_model=ChoreTypeOut)
def update_chore_type(chore_id: int, chore_in: ChoreTypeUpdate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
chore = db.query(ChoreType).filter(ChoreType.id == chore_id).first()
if not chore:
raise HTTPException(status_code=404, detail="Chore type not found")
if chore_in.title is not None:
chore.title = chore_in.title
if chore_in.description is not None:
chore.description = chore_in.description
if chore_in.star_reward is not None:
if chore_in.star_reward < 1 or chore_in.star_reward > 5:
raise HTTPException(status_code=400, detail="Star reward must be between 1 and 5")
chore.star_reward = chore_in.star_reward
if chore_in.recurrence is not None:
if chore_in.recurrence not in [r.value for r in RecurrenceType]:
raise HTTPException(status_code=400, detail="Invalid recurrence type")
chore.recurrence = chore_in.recurrence
if chore_in.icon is not None:
chore.icon = chore_in.icon
db.commit()
db.refresh(chore)
return chore
@router.delete("/types/{chore_id}")
def delete_chore_type(chore_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
chore = db.query(ChoreType).filter(ChoreType.id == chore_id).first()
if not chore:
raise HTTPException(status_code=404, detail="Chore type not found")
db.delete(chore)
db.commit()
return {"message": "Chore type deleted successfully", "id": chore_id}
@router.post("/complete", response_model=ChoreCompletionOut)
def complete_chore(payload: ChoreCompletionCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
chore_type = db.query(ChoreType).filter(ChoreType.id == payload.chore_type_id).first()
if not chore_type:
raise HTTPException(status_code=404, detail="Chore type not found")
week_id = get_current_week_identifier()
now_utc = datetime.now(timezone.utc)
completion = ChoreCompletion(
chore_type_id=chore_type.id,
user_id=current_user.id,
completed_at=now_utc,
stars_earned=chore_type.star_reward,
week_identifier=week_id,
notes=payload.notes
)
db.add(completion)
db.commit()
db.refresh(completion)
return ChoreCompletionOut(
id=completion.id,
chore_type_id=completion.chore_type_id,
user_id=completion.user_id,
completed_at=completion.completed_at,
stars_earned=completion.stars_earned,
week_identifier=completion.week_identifier,
notes=completion.notes,
user_name=current_user.name,
user_avatar=current_user.avatar_url,
chore_title=chore_type.title,
chore_icon=chore_type.icon
)
@router.get("/completions", response_model=List[ChoreCompletionOut])
def get_recent_completions(week: Optional[str] = None, user_id: Optional[int] = None, limit: int = 50, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
query = db.query(ChoreCompletion)
if week:
query = query.filter(ChoreCompletion.week_identifier == week)
if user_id:
query = query.filter(ChoreCompletion.user_id == user_id)
completions = query.order_by(ChoreCompletion.completed_at.desc()).limit(limit).all()
result = []
for c in completions:
result.append(ChoreCompletionOut(
id=c.id,
chore_type_id=c.chore_type_id,
user_id=c.user_id,
completed_at=c.completed_at,
stars_earned=c.stars_earned,
week_identifier=c.week_identifier,
notes=c.notes,
user_name=c.user.name if c.user else "Unknown",
user_avatar=c.user.avatar_url if c.user else None,
chore_title=c.chore_type.title if c.chore_type else "Chore",
chore_icon=c.chore_type.icon if c.chore_type else "CheckSquare"
))
return result
+66
View File
@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from backend.database import get_db
from backend.models import User, ChoreCompletion, ChoreType
from backend.schemas import WeeklyReportOut, UserWeekProgress
from backend.routers.auth import get_current_user
from backend.config import get_current_week_identifier
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.get("/weeks")
def get_available_weeks(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
# Returns all distinct week_identifiers present in database plus current week
weeks = db.query(ChoreCompletion.week_identifier).distinct().all()
week_set = {w[0] for w in weeks if w[0]}
current_week = get_current_week_identifier()
week_set.add(current_week)
sorted_weeks = sorted(list(week_set), reverse=True)
return {"current_week": current_week, "weeks": sorted_weeks}
@router.get("/weekly", response_model=WeeklyReportOut)
def get_weekly_report(week: Optional[str] = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
target_week = week if week else get_current_week_identifier()
users = db.query(User).filter(User.is_active == True).order_by(User.id.asc()).all()
user_progress_list: List[UserWeekProgress] = []
total_stars_earned = 0
total_completions = 0
for user in users:
completions = db.query(ChoreCompletion).filter(
ChoreCompletion.user_id == user.id,
ChoreCompletion.week_identifier == target_week
).all()
stars = sum(c.stars_earned for c in completions)
comp_count = len(completions)
total_stars_earned += stars
total_completions += comp_count
quota = user.weekly_star_quota if user.weekly_star_quota > 0 else 1
pct = round(min(100.0, (stars / quota) * 100), 1)
user_progress_list.append(UserWeekProgress(
user_id=user.id,
name=user.name,
email=user.email,
avatar_url=user.avatar_url,
role=user.role,
weekly_star_quota=user.weekly_star_quota,
stars_earned=stars,
percentage=pct,
completions_count=comp_count
))
# Sort users by stars earned descending
user_progress_list.sort(key=lambda u: u.stars_earned, reverse=True)
return WeeklyReportOut(
week_identifier=target_week,
user_progress=user_progress_list,
total_stars_earned=total_stars_earned,
total_completions=total_completions
)
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from backend.database import get_db
from backend.models import User, UserRole
from backend.schemas import UserOut, UserCreate, UserUpdate
from backend.routers.auth import get_current_user, get_admin_user
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("", response_model=List[UserOut])
def get_all_users(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(User).order_by(User.id.asc()).all()
@router.post("", response_model=UserOut)
def create_user(user_in: UserCreate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
existing = db.query(User).filter(User.email == user_in.email).first()
if existing:
raise HTTPException(status_code=400, detail="User with this email already exists")
avatar = user_in.avatar_url or f"https://api.dicebear.com/7.x/bottts/svg?seed={user_in.email}"
new_user = User(
email=user_in.email,
name=user_in.name,
google_id=user_in.google_id,
avatar_url=avatar,
role=user_in.role,
weekly_star_quota=user_in.weekly_star_quota
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.get("/{user_id}", response_model=UserOut)
def get_user_by_id(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}", response_model=UserOut)
def update_user(user_id: int, user_in: UserUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
# Regular users can only update their own avatar and name. Admins can update role, quota, active status.
if current_user.role != UserRole.ADMIN.value and current_user.id != user_id:
raise HTTPException(status_code=403, detail="Cannot edit another user's account")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_in.name is not None:
user.name = user_in.name
if user_in.avatar_url is not None:
user.avatar_url = user_in.avatar_url
# Admin-only fields
if current_user.role == UserRole.ADMIN.value:
if user_in.role is not None:
user.role = user_in.role
if user_in.weekly_star_quota is not None:
user.weekly_star_quota = user_in.weekly_star_quota
if user_in.is_active is not None:
user.is_active = user_in.is_active
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
if admin.id == user_id:
raise HTTPException(status_code=400, detail="Cannot delete your own admin account")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
return {"message": "User deleted successfully", "id": user_id}
+96
View File
@@ -0,0 +1,96 @@
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from typing import Optional, List
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
name: str
avatar_url: Optional[str] = None
role: str = "regular"
weekly_star_quota: int = Field(default=15, ge=1, le=100)
class UserCreate(UserBase):
google_id: Optional[str] = None
class UserUpdate(BaseModel):
name: Optional[str] = None
avatar_url: Optional[str] = None
role: Optional[str] = None
weekly_star_quota: Optional[int] = Field(default=None, ge=1, le=100)
is_active: Optional[bool] = None
class UserOut(UserBase):
id: int
google_id: Optional[str] = None
is_active: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ChoreTypeBase(BaseModel):
title: str
description: Optional[str] = None
star_reward: int = Field(default=1, ge=1, le=5)
recurrence: str = Field(default="daily", description="daily, weekly, spontaneous")
icon: str = "CheckSquare"
class ChoreTypeCreate(ChoreTypeBase):
pass
class ChoreTypeUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
star_reward: Optional[int] = Field(default=None, ge=1, le=5)
recurrence: Optional[str] = None
icon: Optional[str] = None
class ChoreTypeOut(ChoreTypeBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ChoreCompletionCreate(BaseModel):
chore_type_id: int
notes: Optional[str] = None
class ChoreCompletionOut(BaseModel):
id: int
chore_type_id: int
user_id: int
completed_at: datetime
stars_earned: int
week_identifier: str
notes: Optional[str] = None
user_name: Optional[str] = None
user_avatar: Optional[str] = None
chore_title: Optional[str] = None
chore_icon: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class UserWeekProgress(BaseModel):
user_id: int
name: str
email: str
avatar_url: Optional[str]
role: str
weekly_star_quota: int
stars_earned: int
percentage: float
completions_count: int
class WeeklyReportOut(BaseModel):
week_identifier: str
user_progress: List[UserWeekProgress]
total_stars_earned: int
total_completions: int
class GoogleAuthRequest(BaseModel):
credential: Optional[str] = None
email: Optional[EmailStr] = None
name: Optional[str] = None
avatar_url: Optional[str] = None
class DemoLoginRequest(BaseModel):
email: EmailStr
+31
View File
@@ -0,0 +1,31 @@
import pytest
from fastapi.testclient import TestClient
from backend.main import app
def test_root_endpoint():
with TestClient(app) as client:
response = client.get("/")
assert response.status_code == 200
assert response.json()["status"] == "running"
def test_demo_login():
with TestClient(app) as client:
response = client.post("/api/auth/demo-login", json={"email": "admin@choreus.app"})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["user"]["email"] == "admin@choreus.app"
assert data["user"]["role"] == "admin"
def test_google_auth_mock():
with TestClient(app) as client:
response = client.post("/api/auth/google", json={
"credential": "mock_token",
"email": "newuser@choreus.app",
"name": "New User",
"avatar_url": "https://api.dicebear.com/7.x/bottts/svg?seed=NewUser"
})
assert response.status_code == 200
data = response.json()
assert data["user"]["email"] == "newuser@choreus.app"
assert data["user"]["role"] == "regular"
+47
View File
@@ -0,0 +1,47 @@
import pytest
from fastapi.testclient import TestClient
from backend.main import app
def test_list_chores():
with TestClient(app) as client:
auth_res = client.post("/api/auth/demo-login", json={"email": "admin@choreus.app"})
token = auth_res.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/api/chores/types", headers=headers)
assert response.status_code == 200
chores = response.json()
assert len(chores) >= 1
assert "title" in chores[0]
def test_create_and_complete_chore():
with TestClient(app) as client:
admin_res = client.post("/api/auth/demo-login", json={"email": "admin@choreus.app"})
admin_token = admin_res.json()["access_token"]
admin_headers = {"Authorization": f"Bearer {admin_token}"}
# Create chore
chore_res = client.post("/api/chores/types", headers=admin_headers, json={
"title": "Clean Garage",
"description": "Organize tools and sweep floor",
"star_reward": 5,
"recurrence": "weekly",
"icon": "Wrench"
})
assert chore_res.status_code == 200
chore_data = chore_res.json()
chore_id = chore_data["id"]
# Complete chore as user
user_res = client.post("/api/auth/demo-login", json={"email": "leo@choreus.app"})
user_token = user_res.json()["access_token"]
user_headers = {"Authorization": f"Bearer {user_token}"}
comp_res = client.post("/api/chores/complete", headers=user_headers, json={
"chore_type_id": chore_id,
"notes": "Done with garage!"
})
assert comp_res.status_code == 200
comp_data = comp_res.json()
assert comp_data["stars_earned"] == 5
assert comp_data["chore_title"] == "Clean Garage"
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@lucide/vue": "^1.30.0",
"@tailwindcss/vite": "^4.3.3",
"canvas-confetti": "^1.9.4",
"lucide-vue-next": "^1.0.0",
"pinia": "^4.0.2",
"tailwindcss": "^4.3.3",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"vite": "^8.2.0"
}
}
+1379
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row">
<!-- Navigation Sidebar / Mobile Bar -->
<Navbar v-if="authStore.isAuthenticated" />
<!-- Main Content Area -->
<main
class="flex-1 p-4 md:p-8 mb-16 md:mb-0 transition-all"
:class="authStore.isAuthenticated ? 'md:ml-64' : ''"
>
<div class="max-w-7xl mx-auto">
<router-view />
</div>
</main>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import Navbar from './components/Navbar.vue';
const authStore = useAuthStore();
onMounted(async () => {
if (authStore.token) {
await authStore.fetchCurrentUser();
}
});
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+37
View File
@@ -0,0 +1,37 @@
<template>
<component :is="iconComponent" :class="customClass || 'w-5 h-5'" />
</template>
<script setup>
import { computed } from 'vue';
import * as icons from '@lucide/vue';
const props = defineProps({
name: {
type: String,
default: 'CheckSquare'
},
customClass: {
type: String,
default: ''
}
});
const iconComponent = computed(() => {
if (props.name && icons[props.name]) {
return icons[props.name];
}
// Common fallback maps
const fallbackMap = {
'Utensils': icons.Utensils || icons.CheckSquare,
'Broom': icons.Sparkles || icons.CheckSquare,
'Snowflake': icons.Snowflake || icons.Sparkles,
'ShoppingBag': icons.ShoppingBag || icons.ShoppingCart || icons.CheckSquare,
'Trash2': icons.Trash2 || icons.Trash,
'Sparkles': icons.Sparkles,
'Wrench': icons.Wrench,
'Shirt': icons.Shirt
};
return fallbackMap[props.name] || icons.CheckSquare;
});
</script>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+177
View File
@@ -0,0 +1,177 @@
<template>
<div>
<!-- Desktop Sidebar -->
<aside class="hidden md:flex flex-col w-64 glass-panel border-r border-slate-700/50 min-h-screen fixed left-0 top-0 z-30 p-4">
<!-- App Header & Logo -->
<div class="flex items-center space-x-3 px-2 py-4 mb-6">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-amber-500 to-emerald-400 flex items-center justify-center shadow-lg shadow-amber-500/20">
<Sparkles class="w-6 h-6 text-slate-950 font-bold" />
</div>
<div>
<h1 class="text-xl font-bold tracking-tight bg-gradient-to-r from-amber-400 via-amber-200 to-emerald-400 bg-clip-text text-transparent">ChoreUS</h1>
<p class="text-xs text-slate-400 font-medium">Home Chore Manager</p>
</div>
</div>
<!-- User Mini Profile -->
<div v-if="authStore.user" class="glass-card rounded-xl p-3 mb-6 flex items-center space-x-3 border border-slate-700/50">
<img :src="authStore.user.avatar_url || defaultAvatar" class="w-10 h-10 rounded-full bg-slate-800 border border-amber-400/30 object-cover" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-100 truncate">{{ authStore.user.name }}</p>
<div class="flex items-center space-x-1">
<span class="inline-block w-2 h-2 rounded-full bg-emerald-400"></span>
<span class="text-xs text-slate-400 capitalize">{{ authStore.user.role }}</span>
</div>
</div>
</div>
<!-- Navigation Links -->
<nav class="flex-1 space-y-1.5">
<router-link
to="/"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<LayoutDashboard class="w-5 h-5" />
<span>Dashboard</span>
</router-link>
<router-link
to="/chores"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/chores' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<CheckSquare class="w-5 h-5" />
<span>Chores List</span>
</router-link>
<router-link
to="/reports"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/reports' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<BarChart3 class="w-5 h-5" />
<span>Weekly Reports</span>
</router-link>
<router-link
v-if="authStore.isAdmin"
to="/admin"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/admin' ? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<Shield class="w-5 h-5" />
<span>Admin Console</span>
</router-link>
<router-link
to="/profile"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/profile' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<User class="w-5 h-5" />
<span>My Profile</span>
</router-link>
</nav>
<!-- Logout Button -->
<button
@click="handleLogout"
class="mt-auto flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm text-rose-400 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition-all duration-200 w-full"
>
<LogOut class="w-5 h-5" />
<span>Sign Out</span>
</button>
</aside>
<!-- Mobile Top Header Bar -->
<header class="md:hidden glass-panel border-b border-slate-700/50 sticky top-0 z-30 px-4 py-3 flex items-center justify-between">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-amber-500 to-emerald-400 flex items-center justify-center">
<Sparkles class="w-4 h-4 text-slate-950 font-bold" />
</div>
<span class="font-bold text-base bg-gradient-to-r from-amber-400 to-emerald-400 bg-clip-text text-transparent">ChoreUS</span>
</div>
<div v-if="authStore.user" class="flex items-center space-x-2">
<router-link to="/profile">
<img :src="authStore.user.avatar_url || defaultAvatar" class="w-8 h-8 rounded-full border border-amber-400/40 object-cover" />
</router-link>
<button @click="handleLogout" class="p-1.5 text-slate-400 hover:text-rose-400">
<LogOut class="w-5 h-5" />
</button>
</div>
</header>
<!-- Mobile Bottom Navigation Bar -->
<nav class="md:hidden glass-panel border-t border-slate-700/50 fixed bottom-0 left-0 right-0 z-30 px-2 py-1.5 flex justify-around items-center">
<router-link
to="/"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/' ? 'text-amber-400 font-bold' : 'text-slate-400'"
>
<LayoutDashboard class="w-5 h-5" />
<span class="text-[10px] mt-1">Home</span>
</router-link>
<router-link
to="/chores"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/chores' ? 'text-amber-400 font-bold' : 'text-slate-400'"
>
<CheckSquare class="w-5 h-5" />
<span class="text-[10px] mt-1">Chores</span>
</router-link>
<router-link
to="/reports"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/reports' ? 'text-amber-400 font-bold' : 'text-slate-400'"
>
<BarChart3 class="w-5 h-5" />
<span class="text-[10px] mt-1">Reports</span>
</router-link>
<router-link
v-if="authStore.isAdmin"
to="/admin"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/admin' ? 'text-emerald-400 font-bold' : 'text-slate-400'"
>
<Shield class="w-5 h-5" />
<span class="text-[10px] mt-1">Admin</span>
</router-link>
<router-link
to="/profile"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/profile' ? 'text-amber-400 font-bold' : 'text-slate-400'"
>
<User class="w-5 h-5" />
<span class="text-[10px] mt-1">Profile</span>
</router-link>
</nav>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import {
Sparkles,
LayoutDashboard,
CheckSquare,
BarChart3,
Shield,
User,
LogOut
} from '@lucide/vue';
const authStore = useAuthStore();
const router = useRouter();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=FamilyUser';
function handleLogout() {
authStore.logout();
router.push('/login');
}
</script>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.mount('#app')
+73
View File
@@ -0,0 +1,73 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import LoginView from '../views/LoginView.vue';
import DashboardView from '../views/DashboardView.vue';
import ChoresView from '../views/ChoresView.vue';
import ReportsView from '../views/ReportsView.vue';
import AdminView from '../views/AdminView.vue';
import ProfileView from '../views/ProfileView.vue';
const routes = [
{
path: '/login',
name: 'login',
component: LoginView,
meta: { public: true }
},
{
path: '/',
name: 'dashboard',
component: DashboardView
},
{
path: '/chores',
name: 'chores',
component: ChoresView
},
{
path: '/reports',
name: 'reports',
component: ReportsView
},
{
path: '/admin',
name: 'admin',
component: AdminView,
meta: { requiresAdmin: true }
},
{
path: '/profile',
name: 'profile',
component: ProfileView
},
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
if (!authStore.isAuthenticated && !to.meta.public) {
return next({ name: 'login' });
}
if (authStore.isAuthenticated && to.name === 'login') {
return next({ name: 'dashboard' });
}
if (to.meta.requiresAdmin && !authStore.isAdmin) {
return next({ name: 'dashboard' });
}
next();
});
export default router;
+37
View File
@@ -0,0 +1,37 @@
const API_BASE = '/api';
export async function apiFetch(endpoint, options = {}) {
const token = localStorage.getItem('choreus_token');
const headers = {
'Content-Type': 'application/json',
...(options.headers || {})
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers
});
if (response.status === 401) {
// Unauthenticated or token expired
localStorage.removeItem('choreus_token');
localStorage.removeItem('choreus_user');
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const errorMsg = data?.detail || `HTTP Error ${response.status}`;
throw new Error(errorMsg);
}
return data;
}
+106
View File
@@ -0,0 +1,106 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { apiFetch } from '../services/api';
export const useAuthStore = defineStore('auth', () => {
const user = ref(JSON.parse(localStorage.getItem('choreus_user') || 'null'));
const token = ref(localStorage.getItem('choreus_token') || null);
const loading = ref(false);
const error = ref(null);
const isAuthenticated = computed(() => !!token.value && !!user.value);
const isAdmin = computed(() => user.value?.role === 'admin');
async function demoLogin(email) {
loading.value = true;
error.value = null;
try {
const data = await apiFetch('/auth/demo-login', {
method: 'POST',
body: JSON.stringify({ email })
});
setAuthData(data.access_token, data.user);
return data.user;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function googleLogin(credential, payload = {}) {
loading.value = true;
error.value = null;
try {
const data = await apiFetch('/auth/google', {
method: 'POST',
body: JSON.stringify({ credential, ...payload })
});
setAuthData(data.access_token, data.user);
return data.user;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function fetchCurrentUser() {
if (!token.value) return null;
try {
const userData = await apiFetch('/auth/me');
user.value = userData;
localStorage.setItem('choreus_user', JSON.stringify(userData));
return userData;
} catch (err) {
logout();
return null;
}
}
async function updateProfile(updates) {
if (!user.value) return;
try {
const updatedUser = await apiFetch(`/users/${user.value.id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
});
user.value = updatedUser;
localStorage.setItem('choreus_user', JSON.stringify(updatedUser));
return updatedUser;
} catch (err) {
error.value = err.message;
throw err;
}
}
function setAuthData(accessToken, userData) {
token.value = accessToken;
user.value = userData;
localStorage.setItem('choreus_token', accessToken);
localStorage.setItem('choreus_user', JSON.stringify(userData));
}
function logout() {
token.value = null;
user.value = null;
localStorage.removeItem('choreus_token');
localStorage.removeItem('choreus_user');
}
return {
user,
token,
loading,
error,
isAuthenticated,
isAdmin,
demoLogin,
googleLogin,
fetchCurrentUser,
updateProfile,
logout
};
});
+189
View File
@@ -0,0 +1,189 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiFetch } from '../services/api';
import confetti from 'canvas-confetti';
export const useChoresStore = defineStore('chores', () => {
const choreTypes = ref([]);
const recentCompletions = ref([]);
const weeklyReport = ref(null);
const availableWeeks = ref([]);
const usersList = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetchChoreTypes() {
loading.value = true;
try {
const data = await apiFetch('/chores/types');
choreTypes.value = data;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function completeChore(choreTypeId, notes = '') {
try {
const result = await apiFetch('/chores/complete', {
method: 'POST',
body: JSON.stringify({ chore_type_id: choreTypeId, notes })
});
// Trigger celebratory confetti burst!
confetti({
particleCount: 80,
spread: 70,
origin: { y: 0.6 }
});
// Refresh completions & report
await fetchWeeklyReport();
await fetchRecentCompletions();
return result;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function fetchWeeklyReport(week = null) {
try {
const url = week ? `/reports/weekly?week=${week}` : '/reports/weekly';
const data = await apiFetch(url);
weeklyReport.value = data;
return data;
} catch (err) {
error.value = err.message;
}
}
async function fetchAvailableWeeks() {
try {
const data = await apiFetch('/reports/weeks');
availableWeeks.value = data.weeks || [];
return data;
} catch (err) {
error.value = err.message;
}
}
async function fetchRecentCompletions(week = null, limit = 50) {
try {
const url = week ? `/chores/completions?week=${week}&limit=${limit}` : `/chores/completions?limit=${limit}`;
const data = await apiFetch(url);
recentCompletions.value = data;
} catch (err) {
error.value = err.message;
}
}
async function fetchAllUsers() {
try {
const data = await apiFetch('/users');
usersList.value = data;
return data;
} catch (err) {
error.value = err.message;
}
}
async function createChoreType(choreData) {
try {
const newChore = await apiFetch('/chores/types', {
method: 'POST',
body: JSON.stringify(choreData)
});
await fetchChoreTypes();
return newChore;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function updateChoreType(id, updates) {
try {
const updated = await apiFetch(`/chores/types/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
});
await fetchChoreTypes();
return updated;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function deleteChoreType(id) {
try {
await apiFetch(`/chores/types/${id}`, { method: 'DELETE' });
await fetchChoreTypes();
} catch (err) {
error.value = err.message;
throw err;
}
}
async function createUserAccount(userData) {
try {
const newUser = await apiFetch('/users', {
method: 'POST',
body: JSON.stringify(userData)
});
await fetchAllUsers();
return newUser;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function updateUserAccount(id, updates) {
try {
const updated = await apiFetch(`/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
});
await fetchAllUsers();
return updated;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function deleteUserAccount(id) {
try {
await apiFetch(`/users/${id}`, { method: 'DELETE' });
await fetchAllUsers();
} catch (err) {
error.value = err.message;
throw err;
}
}
return {
choreTypes,
recentCompletions,
weeklyReport,
availableWeeks,
usersList,
loading,
error,
fetchChoreTypes,
completeChore,
fetchWeeklyReport,
fetchAvailableWeeks,
fetchRecentCompletions,
fetchAllUsers,
createChoreType,
updateChoreType,
deleteChoreType,
createUserAccount,
updateUserAccount,
deleteUserAccount
};
});
+70
View File
@@ -0,0 +1,70 @@
@import "tailwindcss";
@layer base {
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f8fafc;
--star-amber: #f59e0b;
--emerald-accent: #10b981;
}
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #0f172a;
color: #f8fafc;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Custom Glassmorphism styles */
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-card {
background: rgba(30, 41, 59, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-card:hover {
background: rgba(30, 41, 59, 0.85);
border-color: rgba(245, 158, 11, 0.3);
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(245, 158, 11, 0.1);
}
/* Glowing text & icons */
.glow-amber {
text-shadow: 0 0 12px rgba(245, 158, 11, 0.5);
}
.glow-emerald {
text-shadow: 0 0 12px rgba(16, 185, 129, 0.5);
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.6);
}
::-webkit-scrollbar-thumb {
background: rgba(51, 65, 85, 0.8);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 1);
}
+382
View File
@@ -0,0 +1,382 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
<Shield class="w-8 h-8 text-emerald-400" />
<span>Admin Control Console</span>
</h1>
<p class="text-slate-400 text-sm mt-1">Manage family accounts, star quotas, and configure chore definitions.</p>
</div>
<!-- Section Switcher Tabs -->
<div class="glass-panel p-1 rounded-2xl flex space-x-1 border border-slate-700/50">
<button
@click="activeSection = 'chores'"
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all capitalize"
:class="activeSection === 'chores' ? 'bg-emerald-500 text-slate-950 font-bold shadow-md' : 'text-slate-400 hover:text-slate-200'"
>
Manage Chores
</button>
<button
@click="activeSection = 'users'"
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all capitalize"
:class="activeSection === 'users' ? 'bg-emerald-500 text-slate-950 font-bold shadow-md' : 'text-slate-400 hover:text-slate-200'"
>
Manage Users
</button>
</div>
</div>
<!-- SECTION 1: MANAGE CHORE TYPES -->
<div v-if="activeSection === 'chores'" class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
<CheckSquare class="w-5 h-5 text-amber-400" />
<span>Chore Definitions</span>
</h2>
<button
@click="openChoreModal()"
class="bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold px-4 py-2 rounded-xl text-sm flex items-center space-x-1.5 shadow-md transition-all"
>
<Plus class="w-4 h-4" />
<span>Add New Chore</span>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="chore in choresStore.choreTypes"
:key="chore.id"
class="glass-card rounded-2xl p-5 border border-slate-700/50 flex flex-col justify-between"
>
<div>
<div class="flex items-start justify-between mb-3">
<div class="w-10 h-10 rounded-xl bg-slate-800 border border-slate-700 flex items-center justify-center text-amber-400">
<ChoreIcon :name="chore.icon" class="w-5 h-5" />
</div>
<span class="text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase bg-slate-800 text-slate-300 border border-slate-700">
{{ chore.recurrence }}
</span>
</div>
<h3 class="font-bold text-slate-100 text-base">{{ chore.title }}</h3>
<p class="text-xs text-slate-400 mt-1 line-clamp-2">{{ chore.description || 'No description' }}</p>
<div class="flex items-center space-x-1 mt-3">
<Star v-for="s in 5" :key="s" class="w-3.5 h-3.5" :class="s <= chore.star_reward ? 'fill-amber-400 text-amber-400' : 'text-slate-700'" />
<span class="text-xs font-bold text-amber-400 ml-1">+{{ chore.star_reward }} Stars</span>
</div>
</div>
<div class="flex space-x-2 mt-4 pt-3 border-t border-slate-800">
<button @click="openChoreModal(chore)" class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold py-1.5 rounded-xl border border-slate-700 flex items-center justify-center space-x-1">
<Edit2 class="w-3.5 h-3.5" />
<span>Edit</span>
</button>
<button @click="handleDeleteChore(chore.id)" class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold px-3 py-1.5 rounded-xl border border-rose-500/20 flex items-center justify-center">
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
<!-- SECTION 2: MANAGE USER ACCOUNTS -->
<div v-if="activeSection === 'users'" class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
<Users class="w-5 h-5 text-emerald-400" />
<span>Family Member Accounts</span>
</h2>
<button
@click="openUserModal()"
class="bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold px-4 py-2 rounded-xl text-sm flex items-center space-x-1.5 shadow-md transition-all"
>
<UserPlus class="w-4 h-4" />
<span>Add Family Account</span>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="user in choresStore.usersList"
:key="user.id"
class="glass-card rounded-2xl p-5 border border-slate-700/50 flex flex-col justify-between"
>
<div>
<div class="flex items-center space-x-3 mb-3">
<img :src="user.avatar_url || defaultAvatar" class="w-12 h-12 rounded-full border border-slate-700 object-cover bg-slate-800" />
<div>
<h3 class="font-bold text-slate-100 text-base flex items-center space-x-1.5">
<span>{{ user.name }}</span>
<span v-if="user.role === 'admin'" class="text-[10px] bg-emerald-500/20 text-emerald-300 font-normal px-2 py-0.5 rounded-full">Admin</span>
</h3>
<p class="text-xs text-slate-400">{{ user.email }}</p>
</div>
</div>
<div class="bg-slate-900/60 rounded-xl p-3 border border-slate-800 flex justify-between items-center text-xs">
<span class="text-slate-400 font-medium">Weekly Star Quota:</span>
<span class="font-bold text-amber-400 text-sm">{{ user.weekly_star_quota }} Stars / week</span>
</div>
</div>
<div class="flex space-x-2 mt-4 pt-3 border-t border-slate-800">
<button @click="openUserModal(user)" class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold py-1.5 rounded-xl border border-slate-700 flex items-center justify-center space-x-1">
<Edit2 class="w-3.5 h-3.5" />
<span>Edit Account</span>
</button>
<button
v-if="user.id !== authStore.user?.id"
@click="handleDeleteUser(user.id)"
class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold px-3 py-1.5 rounded-xl border border-rose-500/20"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
<!-- MODAL 1: CHORE TYPE EDIT/CREATE -->
<div v-if="showChoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative">
<h2 class="text-lg font-bold text-slate-100 mb-4">{{ editingChoreId ? 'Edit Chore Type' : 'Create New Chore Type' }}</h2>
<div class="space-y-4 text-xs">
<div>
<label class="block font-semibold text-slate-300 mb-1">Chore Title</label>
<input v-model="choreForm.title" type="text" placeholder="E.g. Vacuum living room" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Description</label>
<textarea v-model="choreForm.description" rows="2" placeholder="Details on how to perform the chore..." class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-300 mb-1">Star Reward (1-5 Stars)</label>
<select v-model.number="choreForm.star_reward" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-amber-400 font-bold">
<option :value="1">1 Star (Simple)</option>
<option :value="2">2 Stars</option>
<option :value="3">3 Stars (Medium)</option>
<option :value="4">4 Stars</option>
<option :value="5">5 Stars (Difficult)</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Recurrence Schedule</label>
<select v-model="choreForm.recurrence" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100 capitalize">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="spontaneous">Spontaneous</option>
</select>
</div>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Icon Style</label>
<select v-model="choreForm.icon" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100">
<option value="Utensils">Utensils (Kitchen / Dishwasher)</option>
<option value="Broom">Broom (Vacuum / Sweeping)</option>
<option value="Trash2">Trash (Bins & Recycling)</option>
<option value="Sparkles">Sparkles (Cleaning / Scrubbing)</option>
<option value="Snowflake">Snowflake (Snow removal)</option>
<option value="ShoppingBag">ShoppingBag (Groceries)</option>
<option value="Wrench">Wrench (Maintenance)</option>
<option value="Shirt">Shirt (Laundry)</option>
</select>
</div>
</div>
<div class="flex space-x-3 mt-6">
<button @click="showChoreModal = false" class="w-1/2 bg-slate-800 text-slate-300 py-2.5 rounded-xl border border-slate-700 font-semibold text-xs">Cancel</button>
<button @click="saveChoreForm" class="w-1/2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs shadow-md">Save Chore</button>
</div>
</div>
</div>
<!-- MODAL 2: USER EDIT/CREATE -->
<div v-if="showUserModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative">
<h2 class="text-lg font-bold text-slate-100 mb-4">{{ editingUserId ? 'Edit User Account' : 'Add New Family Member' }}</h2>
<div class="space-y-4 text-xs">
<div>
<label class="block font-semibold text-slate-300 mb-1">Full Name</label>
<input v-model="userForm.name" type="text" placeholder="E.g. Alex" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Email Address</label>
<input v-model="userForm.email" :disabled="!!editingUserId" type="email" placeholder="alex@family.com" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100 disabled:opacity-50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-300 mb-1">Account Role</label>
<select v-model="userForm.role" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100">
<option value="regular">Regular User</option>
<option value="admin">Administrator</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Weekly Star Quota</label>
<input v-model.number="userForm.weekly_star_quota" type="number" min="1" max="100" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-amber-400 font-bold" />
</div>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Custom Avatar URL (Optional)</label>
<input v-model="userForm.avatar_url" type="text" placeholder="https://..." class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
</div>
</div>
<div class="flex space-x-3 mt-6">
<button @click="showUserModal = false" class="w-1/2 bg-slate-800 text-slate-300 py-2.5 rounded-xl border border-slate-700 font-semibold text-xs">Cancel</button>
<button @click="saveUserForm" class="w-1/2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs shadow-md">Save Account</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useChoresStore } from '../stores/chores';
import ChoreIcon from '../components/ChoreIcon.vue';
import {
Shield,
CheckSquare,
Users,
Plus,
UserPlus,
Edit2,
Trash2,
Star
} from '@lucide/vue';
const authStore = useAuthStore();
const choresStore = useChoresStore();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
const activeSection = ref('chores');
// Chore Modal state
const showChoreModal = ref(false);
const editingChoreId = ref(null);
const choreForm = reactive({
title: '',
description: '',
star_reward: 1,
recurrence: 'daily',
icon: 'Utensils'
});
// User Modal state
const showUserModal = ref(false);
const editingUserId = ref(null);
const userForm = reactive({
name: '',
email: '',
role: 'regular',
weekly_star_quota: 15,
avatar_url: ''
});
onMounted(async () => {
await choresStore.fetchChoreTypes();
await choresStore.fetchAllUsers();
});
function openChoreModal(chore = null) {
if (chore) {
editingChoreId.value = chore.id;
choreForm.title = chore.title;
choreForm.description = chore.description || '';
choreForm.star_reward = chore.star_reward;
choreForm.recurrence = chore.recurrence;
choreForm.icon = chore.icon;
} else {
editingChoreId.value = null;
choreForm.title = '';
choreForm.description = '';
choreForm.star_reward = 1;
choreForm.recurrence = 'daily';
choreForm.icon = 'Utensils';
}
showChoreModal.value = true;
}
async function saveChoreForm() {
if (!choreForm.title) return alert('Title is required');
try {
if (editingChoreId.value) {
await choresStore.updateChoreType(editingChoreId.value, choreForm);
} else {
await choresStore.createChoreType(choreForm);
}
showChoreModal.value = false;
} catch (err) {
alert(err.message || 'Error saving chore');
}
}
async function handleDeleteChore(id) {
if (confirm('Are you sure you want to delete this chore type?')) {
await choresStore.deleteChoreType(id);
}
}
function openUserModal(user = null) {
if (user) {
editingUserId.value = user.id;
userForm.name = user.name;
userForm.email = user.email;
userForm.role = user.role;
userForm.weekly_star_quota = user.weekly_star_quota;
userForm.avatar_url = user.avatar_url || '';
} else {
editingUserId.value = null;
userForm.name = '';
userForm.email = '';
userForm.role = 'regular';
userForm.weekly_star_quota = 15;
userForm.avatar_url = '';
}
showUserModal.value = true;
}
async function saveUserForm() {
if (!userForm.name || !userForm.email) return alert('Name and email are required');
try {
if (editingUserId.value) {
await choresStore.updateUserAccount(editingUserId.value, {
name: userForm.name,
role: userForm.role,
weekly_star_quota: userForm.weekly_star_quota,
avatar_url: userForm.avatar_url || null
});
} else {
await choresStore.createUserAccount(userForm);
}
showUserModal.value = false;
} catch (err) {
alert(err.message || 'Error saving user');
}
}
async function handleDeleteUser(id) {
if (confirm('Are you sure you want to delete this user account?')) {
await choresStore.deleteUserAccount(id);
}
}
</script>
+233
View File
@@ -0,0 +1,233 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
<CheckSquare class="w-8 h-8 text-amber-400" />
<span>Available Chores</span>
</h1>
<p class="text-slate-400 text-sm mt-1">Select a household chore, complete it, and earn your star reward!</p>
</div>
<!-- Recurrence Filter Tabs -->
<div class="glass-panel p-1 rounded-2xl flex space-x-1 border border-slate-700/50 w-full sm:w-auto">
<button
v-for="tab in tabs"
:key="tab.value"
@click="activeTab = tab.value"
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 capitalize flex-1 sm:flex-initial"
:class="activeTab === tab.value ? 'bg-amber-500 text-slate-950 shadow-md font-bold' : 'text-slate-400 hover:text-slate-200'"
>
{{ tab.label }}
</button>
</div>
</div>
<!-- Search & Quick Filters -->
<div class="relative">
<Search class="w-5 h-5 absolute left-4 top-3.5 text-slate-500" />
<input
v-model="searchQuery"
type="text"
placeholder="Search chores by title or description..."
class="w-full glass-panel border border-slate-700/60 rounded-2xl pl-12 pr-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-400/60 transition-all"
/>
</div>
<!-- Loading / Empty State -->
<div v-if="choresStore.loading" class="text-center py-12 text-slate-400">
Loading family chores...
</div>
<div v-else-if="filteredChores.length === 0" class="text-center py-12 glass-panel rounded-3xl border border-slate-800">
<Sparkles class="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p class="text-slate-300 font-semibold">No chores found</p>
<p class="text-xs text-slate-500 mt-1">Try adjusting your filter or search query.</p>
</div>
<!-- Chores Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div
v-for="chore in filteredChores"
:key="chore.id"
class="glass-card rounded-3xl p-6 border border-slate-700/50 flex flex-col justify-between group hover:border-amber-400/50 transition-all duration-300"
>
<div>
<!-- Card Header & Badge -->
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 rounded-2xl bg-slate-800/80 border border-slate-700/80 flex items-center justify-center text-amber-400 group-hover:scale-110 group-hover:bg-amber-500/10 transition-all">
<ChoreIcon :name="chore.icon" class="w-6 h-6" />
</div>
<span
class="text-[11px] font-bold px-3 py-1 rounded-full uppercase tracking-wider border"
:class="getRecurrenceBadgeStyle(chore.recurrence)"
>
{{ chore.recurrence }}
</span>
</div>
<!-- Title & Description -->
<h2 class="text-lg font-bold text-slate-100 group-hover:text-amber-400 transition-colors">
{{ chore.title }}
</h2>
<p class="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
{{ chore.description || 'No description specified.' }}
</p>
<!-- Star Reward Display -->
<div class="flex items-center space-x-1 my-4">
<span class="text-xs text-slate-400 font-semibold mr-2">Reward:</span>
<div class="flex items-center space-x-0.5">
<Star
v-for="s in 5"
:key="s"
class="w-4 h-4"
:class="s <= chore.star_reward ? 'fill-amber-400 text-amber-400 glow-amber' : 'text-slate-700'"
/>
</div>
<span class="text-xs font-bold text-amber-400 ml-1.5">+{{ chore.star_reward }} Stars</span>
</div>
</div>
<!-- Execute Action Button -->
<button
@click="openCompleteModal(chore)"
class="w-full mt-2 bg-slate-800 hover:bg-gradient-to-r hover:from-amber-500 hover:to-amber-600 hover:text-slate-950 text-amber-400 border border-amber-500/30 font-bold py-2.5 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all duration-200 shadow-md group-hover:border-amber-400"
>
<CheckCircle2 class="w-5 h-5" />
<span>Mark Completed</span>
</button>
</div>
</div>
<!-- Complete Chore Modal -->
<div v-if="selectedChore" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
<button @click="selectedChore = null" class="absolute top-4 right-4 text-slate-400 hover:text-white p-1">
<X class="w-5 h-5" />
</button>
<div class="text-center mb-6">
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center mx-auto mb-3 text-amber-400">
<ChoreIcon :name="selectedChore.icon" class="w-8 h-8" />
</div>
<h2 class="text-xl font-bold text-slate-100">{{ selectedChore.title }}</h2>
<p class="text-xs text-slate-400 mt-1">{{ selectedChore.description }}</p>
<div class="inline-flex items-center space-x-1.5 bg-amber-500/15 border border-amber-500/30 px-3 py-1 rounded-full text-amber-300 font-bold text-sm mt-3">
<Star class="w-4 h-4 fill-amber-300" />
<span>Earn +{{ selectedChore.star_reward }} Stars</span>
</div>
</div>
<div class="mb-6">
<label class="block text-xs font-semibold text-slate-300 mb-2">Optional Notes or Proof:</label>
<textarea
v-model="completionNotes"
rows="3"
placeholder="E.g., Done! Cleaned under the couch too."
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-400"
></textarea>
</div>
<div class="flex space-x-3">
<button
@click="selectedChore = null"
class="w-1/2 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold py-3 rounded-2xl border border-slate-700"
>
Cancel
</button>
<button
@click="confirmCompletion"
:disabled="submitting"
class="w-1/2 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold py-3 rounded-2xl shadow-lg shadow-amber-500/25 flex items-center justify-center space-x-2"
>
<Sparkles class="w-5 h-5" />
<span>Claim Stars!</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useChoresStore } from '../stores/chores';
import ChoreIcon from '../components/ChoreIcon.vue';
import {
CheckSquare,
Search,
Star,
CheckCircle2,
Sparkles,
X
} from '@lucide/vue';
const choresStore = useChoresStore();
const activeTab = ref('all');
const searchQuery = ref('');
const selectedChore = ref(null);
const completionNotes = ref('');
const submitting = ref(false);
const tabs = [
{ label: 'All Chores', value: 'all' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Spontaneous', value: 'spontaneous' }
];
onMounted(async () => {
await choresStore.fetchChoreTypes();
});
const filteredChores = computed(() => {
let list = choresStore.choreTypes;
if (activeTab.value !== 'all') {
list = list.filter(c => c.recurrence === activeTab.value);
}
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase();
list = list.filter(c => c.title.toLowerCase().includes(q) || (c.description && c.description.toLowerCase().includes(q)));
}
return list;
});
function openCompleteModal(chore) {
selectedChore.value = chore;
completionNotes.value = '';
}
async function confirmCompletion() {
if (!selectedChore.value) return;
submitting.value = true;
try {
await choresStore.completeChore(selectedChore.value.id, completionNotes.value);
selectedChore.value = null;
} catch (err) {
alert(err.message || 'Failed to record completion');
} finally {
submitting.value = false;
}
}
function getRecurrenceBadgeStyle(recurrence) {
switch (recurrence) {
case 'daily':
return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30';
case 'weekly':
return 'bg-indigo-500/15 text-indigo-300 border-indigo-500/30';
case 'spontaneous':
return 'bg-amber-500/15 text-amber-300 border-amber-500/30';
default:
return 'bg-slate-700 text-slate-300 border-slate-600';
}
}
</script>
+236
View File
@@ -0,0 +1,236 @@
<template>
<div class="space-y-6">
<!-- Header Banner -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 bg-gradient-to-r from-slate-900 via-slate-900/90 to-amber-950/30 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<div class="flex items-center space-x-2 text-xs font-semibold text-amber-400 uppercase tracking-wider mb-1">
<Calendar class="w-4 h-4" />
<span>Week {{ currentWeekDisplay }} Overview</span>
</div>
<h1 class="text-2xl md:text-3xl font-extrabold text-slate-100">
Welcome back, <span class="bg-gradient-to-r from-amber-400 to-emerald-400 bg-clip-text text-transparent">{{ authStore.user?.name }}</span>!
</h1>
<p class="text-slate-400 text-sm mt-1">Here is how your family is progressing on household chores this week.</p>
</div>
<router-link
to="/chores"
class="bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold px-5 py-3 rounded-2xl flex items-center space-x-2 shadow-lg shadow-amber-500/25 transition-all duration-200"
>
<PlusCircle class="w-5 h-5" />
<span>Complete a Chore</span>
</router-link>
</div>
<!-- Quick Stats Cards Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- My Weekly Quota Progress -->
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<div class="flex justify-between items-start mb-3">
<div>
<span class="text-xs font-semibold text-slate-400 uppercase">My Weekly Progress</span>
<h2 class="text-2xl font-black text-amber-400 mt-1">
{{ myProgress?.stars_earned || 0 }} <span class="text-sm font-medium text-slate-400">/ {{ authStore.user?.weekly_star_quota }} Stars</span>
</h2>
</div>
<div class="p-3 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20">
<Star class="w-6 h-6 fill-amber-400" />
</div>
</div>
<div class="w-full bg-slate-800 rounded-full h-3 mb-2 overflow-hidden">
<div
class="bg-gradient-to-r from-amber-500 to-emerald-400 h-3 rounded-full transition-all duration-500"
:style="{ width: `${myProgressPercentage}%` }"
></div>
</div>
<p class="text-xs text-slate-400 flex justify-between">
<span>{{ myProgressPercentage }}% quota completed</span>
<span class="text-amber-400 font-semibold">{{ myStarsRemaining }} stars remaining</span>
</p>
</div>
<!-- Family Total Stars -->
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<div class="flex justify-between items-start mb-3">
<div>
<span class="text-xs font-semibold text-slate-400 uppercase">Family Team Score</span>
<h2 class="text-2xl font-black text-emerald-400 mt-1">
{{ choresStore.weeklyReport?.total_stars_earned || 0 }} <span class="text-sm font-medium text-slate-400">Stars</span>
</h2>
</div>
<div class="p-3 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<Trophy class="w-6 h-6" />
</div>
</div>
<p class="text-xs text-slate-400 mt-4">
Total of <span class="text-slate-200 font-bold">{{ choresStore.weeklyReport?.total_completions || 0 }}</span> chores completed by family members this week!
</p>
</div>
<!-- Weekly Leader -->
<div class="glass-card rounded-2xl p-5 border border-slate-700/50 sm:col-span-2 lg:col-span-1">
<div class="flex justify-between items-start mb-3">
<div>
<span class="text-xs font-semibold text-slate-400 uppercase">Weekly Chore Champ</span>
<h2 class="text-xl font-bold text-slate-100 mt-1 flex items-center space-x-2">
<span>{{ topPerformer?.name || 'No Activity Yet' }}</span>
<span v-if="topPerformer" class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-medium">1st Place 🏆</span>
</h2>
</div>
<div class="w-10 h-10 rounded-full border border-amber-400/40 overflow-hidden bg-slate-800">
<img :src="topPerformer?.avatar_url || defaultAvatar" class="w-full h-full object-cover" />
</div>
</div>
<p class="text-xs text-slate-400 mt-4">
Leading with <span class="text-amber-400 font-bold">{{ topPerformer?.stars_earned || 0 }} stars</span> logged!
</p>
</div>
</div>
<!-- Family Member Completion Progress Bars -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
<Users class="w-5 h-5 text-amber-400" />
<span>Family Progress Dashboard</span>
</h2>
<p class="text-xs text-slate-400">Chore completion status for each family member</p>
</div>
<router-link to="/reports" class="text-xs font-semibold text-amber-400 hover:underline">
View Detailed Reports &rarr;
</router-link>
</div>
<div class="space-y-4">
<div
v-for="u in choresStore.weeklyReport?.user_progress || []"
:key="u.user_id"
class="glass-card rounded-2xl p-4 border border-slate-700/40 hover:border-slate-600 transition-all"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3">
<img :src="u.avatar_url || defaultAvatar" class="w-10 h-10 rounded-full bg-slate-800 border border-slate-600 object-cover" />
<div>
<div class="flex items-center space-x-2">
<span class="font-bold text-sm text-slate-100">{{ u.name }}</span>
<span v-if="u.user_id === authStore.user?.id" class="text-[10px] bg-amber-500/20 text-amber-300 font-medium px-2 py-0.5 rounded-full">You</span>
<span v-if="u.role === 'admin'" class="text-[10px] bg-emerald-500/20 text-emerald-300 font-medium px-2 py-0.5 rounded-full">Admin</span>
</div>
<span class="text-xs text-slate-400">{{ u.completions_count }} chores logged</span>
</div>
</div>
<div class="text-right">
<div class="text-sm font-extrabold text-amber-400 flex items-center justify-end space-x-1">
<span>{{ u.stars_earned }} / {{ u.weekly_star_quota }}</span>
<Star class="w-4 h-4 fill-amber-400 text-amber-400" />
</div>
<span class="text-xs text-slate-400">{{ u.percentage }}%</span>
</div>
</div>
<!-- Progress Bar -->
<div class="w-full bg-slate-800 rounded-full h-2.5 overflow-hidden">
<div
class="h-2.5 rounded-full transition-all duration-500"
:class="u.percentage >= 100 ? 'bg-gradient-to-r from-emerald-500 to-teal-400' : 'bg-gradient-to-r from-amber-500 to-amber-400'"
:style="{ width: `${Math.min(100, u.percentage)}%` }"
></div>
</div>
</div>
</div>
</div>
<!-- Recent Completed Chores Stream -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
<Sparkles class="w-5 h-5 text-emerald-400" />
<span>Recent Family Activity</span>
</h2>
<span class="text-xs text-slate-400">Live Activity Feed</span>
</div>
<div v-if="choresStore.recentCompletions.length === 0" class="text-center py-8 text-slate-400 text-sm">
No chores logged yet this week. Be the first to earn stars!
</div>
<div class="space-y-3">
<div
v-for="c in choresStore.recentCompletions.slice(0, 5)"
:key="c.id"
class="glass-card rounded-xl p-3 flex items-center justify-between border border-slate-800"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
<ChoreIcon :name="c.chore_icon" class="w-5 h-5" />
</div>
<div>
<p class="text-sm font-semibold text-slate-200">{{ c.chore_title }}</p>
<p class="text-xs text-slate-400">Completed by <span class="text-slate-300 font-medium">{{ c.user_name }}</span> &bull; {{ formatDate(c.completed_at) }}</p>
<p v-if="c.notes" class="text-xs text-slate-400 italic mt-0.5">"{{ c.notes }}"</p>
</div>
</div>
<div class="flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 text-amber-300 text-xs font-bold px-2.5 py-1 rounded-full">
<span>+{{ c.stars_earned }}</span>
<Star class="w-3.5 h-3.5 fill-amber-300" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useChoresStore } from '../stores/chores';
import ChoreIcon from '../components/ChoreIcon.vue';
import {
Calendar,
PlusCircle,
Star,
Trophy,
Users,
Sparkles
} from '@lucide/vue';
const authStore = useAuthStore();
const choresStore = useChoresStore();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
onMounted(async () => {
await choresStore.fetchWeeklyReport();
await choresStore.fetchRecentCompletions();
});
const currentWeekDisplay = computed(() => {
return choresStore.weeklyReport?.week_identifier || '';
});
const myProgress = computed(() => {
return choresStore.weeklyReport?.user_progress.find(u => u.user_id === authStore.user?.id);
});
const myProgressPercentage = computed(() => {
if (!myProgress.value) return 0;
return myProgress.value.percentage;
});
const myStarsRemaining = computed(() => {
const quota = authStore.user?.weekly_star_quota || 15;
const earned = myProgress.value?.stars_earned || 0;
return Math.max(0, quota - earned);
});
const topPerformer = computed(() => {
if (!choresStore.weeklyReport?.user_progress?.length) return null;
return choresStore.weeklyReport.user_progress[0];
});
function formatDate(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ', ' + d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
</script>
+129
View File
@@ -0,0 +1,129 @@
<template>
<div class="min-h-screen bg-slate-950 flex flex-col justify-center items-center p-4 relative overflow-hidden">
<!-- Ambient background glow -->
<div class="absolute -top-40 -left-40 w-96 h-96 bg-amber-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="absolute -bottom-40 -right-40 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="w-full max-w-md glass-panel rounded-3xl p-8 border border-slate-700/60 shadow-2xl shadow-slate-950/80 z-10">
<!-- Logo & Welcome -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-gradient-to-tr from-amber-500 via-amber-400 to-emerald-400 flex items-center justify-center mx-auto mb-4 shadow-xl shadow-amber-500/25">
<Sparkles class="w-9 h-9 text-slate-950 font-bold" />
</div>
<h1 class="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-amber-400 via-amber-200 to-emerald-400 bg-clip-text text-transparent">
ChoreUS
</h1>
<p class="text-slate-400 text-sm mt-1">Family Chore & Reward Manager</p>
</div>
<div v-if="error" class="mb-4 p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-sm text-center">
{{ error }}
</div>
<!-- Quick Demo Login Accounts -->
<div class="mb-8">
<h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 text-center">
Quick Demo Login
</h2>
<div class="space-y-2.5">
<button
@click="handleDemoLogin('admin@choreus.app')"
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
>
<div class="flex items-center space-x-3">
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=SarahAdmin" class="w-10 h-10 rounded-full bg-slate-800 border border-amber-400/40" />
<div>
<p class="text-sm font-bold text-slate-100 group-hover:text-amber-400 transition-colors">Sarah (Admin)</p>
<p class="text-xs text-slate-400">Parent / Administrator Account</p>
</div>
</div>
<Shield class="w-5 h-5 text-amber-400" />
</button>
<button
@click="handleDemoLogin('leo@choreus.app')"
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
>
<div class="flex items-center space-x-3">
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=LeoKid" class="w-10 h-10 rounded-full bg-slate-800 border border-emerald-400/40" />
<div>
<p class="text-sm font-bold text-slate-100 group-hover:text-emerald-400 transition-colors">Leo (Kid)</p>
<p class="text-xs text-slate-400">Regular Family Member (15 Stars Quota)</p>
</div>
</div>
<User class="w-5 h-5 text-emerald-400" />
</button>
<button
@click="handleDemoLogin('maya@choreus.app')"
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
>
<div class="flex items-center space-x-3">
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=MayaKid" class="w-10 h-10 rounded-full bg-slate-800 border border-indigo-400/40" />
<div>
<p class="text-sm font-bold text-slate-100 group-hover:text-indigo-400 transition-colors">Maya (Kid)</p>
<p class="text-xs text-slate-400">Regular Family Member (15 Stars Quota)</p>
</div>
</div>
<User class="w-5 h-5 text-indigo-400" />
</button>
</div>
</div>
<!-- Google OAuth Sign In Divider & Button -->
<div class="relative mb-6">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-700/60"></div></div>
<div class="relative flex justify-center text-xs uppercase"><span class="bg-slate-900 px-3 text-slate-400 font-medium">or continue with</span></div>
</div>
<button
@click="handleGoogleMockLogin"
class="w-full bg-slate-800 hover:bg-slate-700 text-slate-100 font-semibold py-3 px-4 rounded-2xl border border-slate-600/50 flex items-center justify-center space-x-3 transition-all duration-200 shadow-md"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"/>
</svg>
<span>Sign in with Google</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { Sparkles, Shield, User } from '@lucide/vue';
const authStore = useAuthStore();
const router = useRouter();
const error = ref(null);
async function handleDemoLogin(email) {
try {
error.value = null;
await authStore.demoLogin(email);
router.push('/');
} catch (err) {
error.value = err.message || 'Login failed';
}
}
async function handleGoogleMockLogin() {
try {
error.value = null;
// Simulates Google OAuth flow for test / environment demonstration
await authStore.googleLogin('google_oauth_token', {
email: 'family_google_user@choreus.app',
name: 'Google Family User',
avatar_url: 'https://api.dicebear.com/7.x/bottts/svg?seed=GoogleUser'
});
router.push('/');
} catch (err) {
error.value = err.message || 'Google Auth failed';
}
}
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div class="max-w-2xl mx-auto space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
<User class="w-8 h-8 text-amber-400" />
<span>My Profile & Settings</span>
</h1>
<p class="text-slate-400 text-sm mt-1">Configure your avatar, account details, and personal quotas.</p>
</div>
<div v-if="successMsg" class="p-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-sm text-center font-medium">
{{ successMsg }}
</div>
<!-- Profile Card -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 space-y-6">
<!-- Avatar Section -->
<div class="flex flex-col sm:flex-row items-center space-y-4 sm:space-y-0 sm:space-x-6 pb-6 border-b border-slate-800">
<img :src="avatarUrl || defaultAvatar" class="w-24 h-24 rounded-full border-2 border-amber-400/50 shadow-xl object-cover bg-slate-800" />
<div class="space-y-2 text-center sm:text-left flex-1">
<h2 class="text-xl font-bold text-slate-100">{{ authStore.user?.name }}</h2>
<p class="text-xs text-slate-400">{{ authStore.user?.email }}</p>
<div class="inline-flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 px-3 py-1 rounded-full text-xs font-semibold text-amber-300">
<Shield class="w-3.5 h-3.5" />
<span class="capitalize">{{ authStore.user?.role }} Account</span>
</div>
</div>
</div>
<!-- Editable Profile Form -->
<form @submit.prevent="handleSaveProfile" class="space-y-4 text-xs">
<div>
<label class="block font-semibold text-slate-300 mb-1">Display Name</label>
<input
v-model="name"
type="text"
required
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 focus:outline-none focus:border-amber-400"
/>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Avatar Image URL</label>
<input
v-model="avatarUrl"
type="text"
placeholder="https://api.dicebear.com/..."
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 focus:outline-none focus:border-amber-400"
/>
<p class="text-[11px] text-slate-500 mt-1">Leave empty to use your default Google account avatar.</p>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Weekly Star Quota</label>
<input
:value="authStore.user?.weekly_star_quota"
disabled
type="number"
class="w-full bg-slate-900/50 border border-slate-800 rounded-2xl p-3 text-sm text-amber-400 font-bold opacity-70 cursor-not-allowed"
/>
<p class="text-[11px] text-slate-500 mt-1">Weekly star quotas are configured by family Administrators.</p>
</div>
<button
type="submit"
:disabled="saving"
class="w-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold py-3 rounded-2xl shadow-lg shadow-amber-500/25 transition-all text-sm mt-4 flex items-center justify-center space-x-2"
>
<Save class="w-4 h-4" />
<span>Save Profile Changes</span>
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth';
import { User, Shield, Save } from '@lucide/vue';
const authStore = useAuthStore();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
const name = ref('');
const avatarUrl = ref('');
const saving = ref(false);
const successMsg = ref('');
onMounted(() => {
if (authStore.user) {
name.value = authStore.user.name || '';
avatarUrl.value = authStore.user.avatar_url || '';
}
});
async function handleSaveProfile() {
saving.value = true;
successMsg.value = '';
try {
await authStore.updateProfile({
name: name.value,
avatar_url: avatarUrl.value || null
});
successMsg.value = 'Profile updated successfully!';
setTimeout(() => { successMsg.value = ''; }, 3000);
} catch (err) {
alert(err.message || 'Failed to update profile');
} finally {
saving.value = false;
}
}
</script>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div class="space-y-6">
<!-- Page Header & Week Selector -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
<BarChart3 class="w-8 h-8 text-amber-400" />
<span>Weekly Chore Reports</span>
</h1>
<p class="text-slate-400 text-sm mt-1">Review family progress, completed tasks, and historical weekly achievements.</p>
</div>
<!-- Week Dropdown Selector -->
<div class="flex items-center space-x-2 w-full md:w-auto">
<label class="text-xs font-semibold text-slate-400 whitespace-nowrap">Select Week:</label>
<select
v-model="selectedWeek"
@change="onWeekChange"
class="glass-card bg-slate-900 border border-slate-700/80 rounded-2xl px-4 py-2.5 text-sm text-amber-400 font-bold focus:outline-none focus:border-amber-400 w-full md:w-auto"
>
<option v-for="w in choresStore.availableWeeks" :key="w" :value="w">
Week {{ w }} {{ w === currentWeek ? '(Current)' : '' }}
</option>
</select>
</div>
</div>
<!-- Summary Metrics Grid -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<span class="text-xs font-semibold text-slate-400 uppercase">Total Stars Earned</span>
<div class="flex items-center space-x-2 mt-1">
<span class="text-3xl font-black text-amber-400">{{ choresStore.weeklyReport?.total_stars_earned || 0 }}</span>
<Star class="w-7 h-7 fill-amber-400 text-amber-400 glow-amber" />
</div>
<p class="text-xs text-slate-400 mt-2">Combined family total for Week {{ selectedWeek }}</p>
</div>
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<span class="text-xs font-semibold text-slate-400 uppercase">Chores Completed</span>
<div class="flex items-center space-x-2 mt-1">
<span class="text-3xl font-black text-emerald-400">{{ choresStore.weeklyReport?.total_completions || 0 }}</span>
<CheckCircle2 class="w-7 h-7 text-emerald-400 glow-emerald" />
</div>
<p class="text-xs text-slate-400 mt-2">Logged task executions</p>
</div>
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<span class="text-xs font-semibold text-slate-400 uppercase">My Stars Remaining</span>
<div class="flex items-center space-x-2 mt-1">
<span class="text-3xl font-black text-indigo-400">{{ myStarsRemaining }}</span>
<span class="text-xs font-bold text-slate-400">/ {{ authStore.user?.weekly_star_quota }} Quota</span>
</div>
<p class="text-xs text-slate-400 mt-2">Needed to hit weekly quota</p>
</div>
</div>
<!-- Family Progress Breakdown Table/List -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
<h2 class="text-lg font-bold text-slate-100 mb-4 flex items-center space-x-2">
<Users class="w-5 h-5 text-amber-400" />
<span>Family Quota Status (Week {{ selectedWeek }})</span>
</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-slate-300">
<thead class="text-xs uppercase bg-slate-900/60 text-slate-400 border-b border-slate-700/60">
<tr>
<th class="px-4 py-3 rounded-l-xl">Family Member</th>
<th class="px-4 py-3">Stars Earned</th>
<th class="px-4 py-3">Weekly Quota</th>
<th class="px-4 py-3">Stars Left</th>
<th class="px-4 py-3 rounded-r-xl">Progress</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60">
<tr v-for="user in choresStore.weeklyReport?.user_progress || []" :key="user.user_id" class="hover:bg-slate-800/40">
<td class="px-4 py-3.5 flex items-center space-x-3 font-semibold text-slate-100">
<img :src="user.avatar_url || defaultAvatar" class="w-8 h-8 rounded-full border border-slate-700 object-cover" />
<span>{{ user.name }}</span>
<span v-if="user.user_id === authStore.user?.id" class="text-[10px] bg-amber-500/20 text-amber-300 px-2 py-0.5 rounded-full font-normal">You</span>
</td>
<td class="px-4 py-3 font-extrabold text-amber-400">+{{ user.stars_earned }} Stars</td>
<td class="px-4 py-3 text-slate-400">{{ user.weekly_star_quota }} Stars</td>
<td class="px-4 py-3 font-semibold" :class="Math.max(0, user.weekly_star_quota - user.stars_earned) === 0 ? 'text-emerald-400' : 'text-slate-300'">
{{ Math.max(0, user.weekly_star_quota - user.stars_earned) }} Left
</td>
<td class="px-4 py-3 w-48">
<div class="flex items-center space-x-2">
<div class="w-full bg-slate-800 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full transition-all duration-500"
:class="user.percentage >= 100 ? 'bg-emerald-400' : 'bg-amber-400'"
:style="{ width: `${Math.min(100, user.percentage)}%` }"
></div>
</div>
<span class="text-xs text-slate-400 font-mono">{{ user.percentage }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Detailed Chore Completion Log -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
<h2 class="text-lg font-bold text-slate-100 mb-4 flex items-center space-x-2">
<Clock class="w-5 h-5 text-emerald-400" />
<span>Chore History Log</span>
</h2>
<div v-if="choresStore.recentCompletions.length === 0" class="text-center py-8 text-slate-400 text-sm">
No completed chores recorded for Week {{ selectedWeek }}.
</div>
<div v-else class="space-y-3">
<div
v-for="log in choresStore.recentCompletions"
:key="log.id"
class="glass-card rounded-2xl p-4 border border-slate-800 flex items-center justify-between"
>
<div class="flex items-center space-x-3">
<img :src="log.user_avatar || defaultAvatar" class="w-9 h-9 rounded-full border border-slate-700 object-cover" />
<div>
<div class="flex items-center space-x-2">
<span class="font-bold text-sm text-slate-100">{{ log.chore_title }}</span>
<span class="text-xs text-slate-400">by {{ log.user_name }}</span>
</div>
<p class="text-xs text-slate-400 flex items-center space-x-1 mt-0.5">
<Calendar class="w-3.5 h-3.5 text-slate-500" />
<span>{{ formatDate(log.completed_at) }}</span>
<span v-if="log.notes" class="text-slate-300 italic ml-2">&bull; "{{ log.notes }}"</span>
</p>
</div>
</div>
<div class="flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 text-amber-300 text-xs font-bold px-3 py-1 rounded-full whitespace-nowrap">
<span>+{{ log.stars_earned }} Stars</span>
<Star class="w-3.5 h-3.5 fill-amber-300" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useChoresStore } from '../stores/chores';
import {
BarChart3,
Star,
CheckCircle2,
Users,
Clock,
Calendar
} from '@lucide/vue';
const authStore = useAuthStore();
const choresStore = useChoresStore();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
const selectedWeek = ref('');
const currentWeek = ref('');
onMounted(async () => {
const weekData = await choresStore.fetchAvailableWeeks();
if (weekData) {
currentWeek.value = weekData.current_week;
selectedWeek.value = weekData.current_week;
}
await loadWeekData(selectedWeek.value);
});
async function onWeekChange() {
await loadWeekData(selectedWeek.value);
}
async function loadWeekData(week) {
await choresStore.fetchWeeklyReport(week);
await choresStore.fetchRecentCompletions(week);
}
const myProgress = computed(() => {
return choresStore.weeklyReport?.user_progress.find(u => u.user_id === authStore.user?.id);
});
const myStarsRemaining = computed(() => {
const quota = authStore.user?.weekly_star_quota || 15;
const earned = myProgress.value?.stars_earned || 0;
return Math.max(0, quota - earned);
});
function formatDate(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) + ' at ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
</script>
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})