diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ccb6851 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Use an official Python runtime as a parent image +FROM python:3.12-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the current directory contents into the container at /app +COPY . /app + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Make port 8000 available to the world outside this container +EXPOSE 8000 + +# Run the command to start the FastAPI app +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "backend/logging_config.json"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5266d95 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 hhf technology + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/crud.py b/backend/crud.py new file mode 100644 index 0000000..c765c94 --- /dev/null +++ b/backend/crud.py @@ -0,0 +1,271 @@ +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from sqlalchemy import func +from backend.models import User, TimeEntry +import backend.schemas as schemas +from backend.exceptions import UserNotFoundException, UserAlreadyClockedInException, NoClockInFoundException, UserAlreadyClockedOutException, AdminUserAlreadyExists, UserAlreadyExists +import logging +from sqlalchemy.exc import IntegrityError +from datetime import datetime, timedelta, timezone +import bcrypt +logger = logging.getLogger("uvicorn") + + +def _get_period_summary(time_entries): + grouped_data = {} + total_hours = 0 + days_worked = 0 + + for entry in time_entries: + # Ensure clock_in and clock_out are timezone-aware + clock_in_time = entry.clock_in + if clock_in_time and clock_in_time.tzinfo is None: + clock_in_time = clock_in_time.replace(tzinfo=timezone.utc) + + clock_out_time = entry.clock_out + if clock_out_time and clock_out_time.tzinfo is None: + clock_out_time = clock_out_time.replace(tzinfo=timezone.utc) + + entry_date = clock_in_time.date().strftime("%Y-%m-%d") + + if entry_date not in grouped_data: + grouped_data[entry_date] = { + "clock_in": clock_in_time, + "clock_out": None + } + + if clock_out_time: + grouped_data[entry_date]["clock_out"] = clock_out_time + # Calculate total time for the day + total_time = clock_out_time - clock_in_time + grouped_data[entry_date]["total_time"] = str(total_time) + total_hours += total_time.total_seconds() / 3600 + days_worked += 1 + else: + grouped_data[entry_date]["clock_out"] = None + grouped_data[entry_date]["total_time"] = "N/A" + + # Convert grouped data to list format + result = [] + for date, data in grouped_data.items(): + result.append({ + "date": date, + "clock_in": data["clock_in"], + "clock_out": data["clock_out"], + "total_time": data.get("total_time", "N/A") + }) + + return { + "total_hours": total_hours, + "days_worked": days_worked, + "entries": result + } + +def get_user_by_name(db: Session, name: str): + return db.query(User).filter(func.lower(User.name) == name.lower()).first() + +def create_user(db: Session, user: schemas.UserCreate, is_admin: bool = False, hashed_password: str = None): + username_lower = user.name.lower() + logger.info(f"create_user: A request has been made for {username_lower}") + + # Check if the user already exists + db_user = get_user_by_name(db, name=username_lower) + if db_user: + raise UserAlreadyExists(username_lower) + + # Determine the password to store (hashed password for admin) + password_to_store = hashed_password if hashed_password else None + + new_user = User(name=username_lower, password=password_to_store, is_admin=is_admin) + db.add(new_user) + + try: + db.commit() + db.refresh(new_user) + except IntegrityError: + db.rollback() + raise UserAlreadyExists(username_lower) + + return new_user + +def clock_in(db: Session, user: str, time: datetime = None, note: str = None): + logger.info(f"clock_in: A request has been made for {user}") + db_user = get_user_by_name(db, user) + if not db_user: + raise UserNotFoundException(user) + + # Use provided time or default to current UTC time + if time is None: + time = datetime.now(timezone.utc) + else: + # Ensure time is timezone-aware and in UTC + if time.tzinfo is None: + time = time.replace(tzinfo=timezone.utc) + else: + time = time.astimezone(timezone.utc) + + today = time.date() + time_entry = db.query(TimeEntry).filter( + TimeEntry.user_id == db_user.id, + func.date(TimeEntry.clock_in) == today + ).first() + + if time_entry: + if time_entry.clock_out: + raise UserAlreadyClockedOutException(user.capitalize()) + else: + raise UserAlreadyClockedInException() + + new_entry = TimeEntry( + user_id=db_user.id, + clock_in=time, + clock_in_note=note + ) + db.add(new_entry) + db.commit() + db.refresh(new_entry) + return new_entry + +def clock_out(db: Session, user: str, time: datetime = None, note: str = None): + db_user = get_user_by_name(db, user) + if not db_user: + raise UserNotFoundException(user) + + if time is None: + time = datetime.now(timezone.utc) + else: + if time.tzinfo is None: + time = time.replace(tzinfo=timezone.utc) + else: + time = time.astimezone(timezone.utc) + + today = time.date() + time_entry = db.query(TimeEntry).filter( + TimeEntry.user_id == db_user.id, + func.date(TimeEntry.clock_in) == today + ).first() + + if not time_entry: + raise NoClockInFoundException() + + time_entry.clock_out = time + time_entry.clock_out_note = note + db.commit() + db.refresh(time_entry) + return time_entry + +def get_time_for_pay_period(db: Session, user: str): + db_user = get_user_by_name(db, user) + if not db_user: + raise UserNotFoundException(user) + + # Define a reference pay period start date (a known payday) + reference_pay_period_start = datetime(2023, 1, 6, tzinfo=timezone.utc).date() # Update this date as needed + + today = datetime.now(timezone.utc).date() + days_since_reference = (today - reference_pay_period_start).days + pay_periods_since_reference = days_since_reference // 14 + current_pay_period_start = reference_pay_period_start + timedelta(days=pay_periods_since_reference * 14) + current_pay_period_end = current_pay_period_start + timedelta(days=13) # 14 days total + + # Adjust for future dates if necessary + if today < current_pay_period_start: + current_pay_period_start -= timedelta(days=14) + current_pay_period_end -= timedelta(days=14) + + time_entries = db.query(TimeEntry).filter( + TimeEntry.user_id == db_user.id, + func.date(TimeEntry.clock_in) >= current_pay_period_start, + func.date(TimeEntry.clock_in) <= current_pay_period_end + ).all() + + return _get_period_summary(time_entries) + +def get_time_for_month(db: Session, user: str): + db_user = get_user_by_name(db, user) + if not db_user: + raise UserNotFoundException(user) + + today = datetime.now(timezone.utc) + start_date = today.replace(day=1) + + time_entries = db.query(TimeEntry).filter( + TimeEntry.user_id == db_user.id, + TimeEntry.clock_in >= start_date + ).all() + + return _get_period_summary(time_entries) + +def get_time_for_current_week(db: Session, user: str): + db_user = get_user_by_name(db, user) + if not db_user: + raise UserNotFoundException(user) + + today = datetime.now() + start_of_week = today - timedelta(days=(today.weekday() + 1) % 7) + end_of_week = start_of_week + timedelta(days=6) + + time_entries = db.query(TimeEntry).filter( + TimeEntry.user_id == db_user.id, + TimeEntry.clock_in >= start_of_week, + TimeEntry.clock_in <= end_of_week + ).all() + + return _get_period_summary(time_entries) + +def get_user_status(db: Session, user: str) -> str: + db_user = get_user_by_name(db, user) + if not db_user: + raise UserNotFoundException(user) + + recent_entry = db.query(TimeEntry).filter(TimeEntry.user_id == db_user.id).order_by( + TimeEntry.clock_in.desc()).first() + + if recent_entry and recent_entry.clock_out is None: + return "in" + else: + return "out" + +def delete_today_entry(db: Session, user: str): + db_user = get_user_by_name(db, user) + if not db_user: + raise UserNotFoundException(user) + + today = datetime.now(timezone.utc).date() + time_entry = db.query(TimeEntry).filter( + TimeEntry.user_id == db_user.id, + func.date(TimeEntry.clock_in) == today + ).first() + + if time_entry: + db.delete(time_entry) + db.commit() + return True # Indicate that the entry was deleted + else: + raise NoClockInFoundException() # No entry found for today + + +def is_clocked_in_today(db: Session, user: str): + db_user = db.query(User).filter(User.name == user).first() + if not db_user: + return False + + today = datetime.now(timezone.utc).date() + time_entry = db.query(TimeEntry).filter( + TimeEntry.user_id == db_user.id, + func.date(TimeEntry.clock_in) == today + ).first() + + return time_entry is not None + +def delete_user_by_name(db: Session, username: str): + db_user = db.query(User).filter(User.name == username).first() + if db_user: + db.delete(db_user) + db.commit() + return True + return False + +def get_users(db: Session): + users = db.query(User).all() + return users \ No newline at end of file diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..396c756 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,17 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./backend/data/time_tracking.db" + +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() \ No newline at end of file diff --git a/backend/exceptions.py b/backend/exceptions.py new file mode 100644 index 0000000..c3a2698 --- /dev/null +++ b/backend/exceptions.py @@ -0,0 +1,38 @@ +import logging +logger = logging.getLogger("uvicorn") + +class UserNotFoundException(Exception): + def __init__(self, username: str): + self.message = f"User '{username}' not found" + super().__init__(self.message) + logger.error(self.message) + + +class UserAlreadyClockedInException(Exception): + def __init__(self, message: str = "User already clocked in today"): + self.message = message + super().__init__(self.message) + logger.error(self.message) + + +class NoClockInFoundException(Exception): + def __init__(self, message: str = "No clock-in found for today"): + self.message = message + super().__init__(self.message) + logger.error(self.message) + + +class UserAlreadyClockedOutException(Exception): + def __init__(self, user: str): + self.message = f"{user} has already clocked out for the day." + super().__init__(self.message) + +class AdminUserAlreadyExists(Exception): + def __init__(self, user: str): + self.message = f"Admin user has already been created, {user} has not been created." + super().__init__(self.message) + +class UserAlreadyExists(Exception): + def __init__(self, user: str): + self.message = f"{user} already exists" + super().__init__(self.message) \ No newline at end of file diff --git a/backend/logging_config.json b/backend/logging_config.json new file mode 100644 index 0000000..23911b6 --- /dev/null +++ b/backend/logging_config.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "default": { + "()": "colorlog.ColoredFormatter", + "format": "%(log_color)s%(asctime)s - %(levelname)s - %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + "log_colors": { + "DEBUG": "bold_blue", + "INFO": "bold_green", + "WARNING": "bold_yellow", + "ERROR": "bold_red", + "CRITICAL": "bold_purple" + } + } + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout" + } + }, + "loggers": { + "uvicorn": { + "handlers": ["default"], + "level": "INFO", + "propagate": false + }, + "uvicorn.error": { + "level": "INFO" + }, + "uvicorn.access": { + "handlers": ["default"], + "level": "INFO", + "propagate": false + } + } + } \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..b49d538 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,302 @@ +from fastapi import FastAPI, Depends, HTTPException, Request, status +from fastapi.staticfiles import StaticFiles +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse, FileResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials +import secrets +import logging +from backend.database import get_db, engine +from backend.models import Base, TimeEntry +import backend.crud as crud +import backend.schemas as schemas +from datetime import datetime, timezone +from backend.exceptions import UserNotFoundException, UserAlreadyClockedInException, NoClockInFoundException, UserAlreadyClockedOutException, UserAlreadyExists +import bcrypt +import os +from sqlalchemy import func +from dateutil.parser import isoparse + + +# Basic Auth Setup +security = HTTPBasic() + +app = FastAPI() +app.mount("/frontend", StaticFiles(directory="frontend"), name="frontend") +app.mount("/static", StaticFiles(directory="static"), name="static") +Base.metadata.create_all(bind=engine) + +# Use Uvicorn's logger +logger = logging.getLogger("uvicorn") + +# Retrieve username from environment variables +ENV_USERNAME = os.getenv("ADMIN_USERNAME", "admin") + +# Ensure the admin user exists with the hashed password +def ensure_admin_user_exists(db: Session): + admin_user = crud.get_user_by_name(db, name=ENV_USERNAME) + if not admin_user: + # Retrieve password from environment variables + env_password = os.getenv("ADMIN_PASSWORD") + + # Generate a random password if ADMIN_PASSWORD is not set + if not env_password: + env_password = secrets.token_urlsafe(16) # Random secure password + logger.warning(f"\n\nGenerated admin password: \n{env_password}\n\n") # Log this for initial use + + # Hash the password + hashed_env_password = bcrypt.hashpw(env_password.encode('utf-8'), bcrypt.gensalt()) + + # Create the admin user with the hashed password + admin_data = schemas.UserCreate(name=ENV_USERNAME) + crud.create_user(db=db, user=admin_data, is_admin=True, hashed_password=hashed_env_password) + +@app.on_event("startup") +def on_startup(): + # Ensure the admin user exists on startup + db = next(get_db()) + ensure_admin_user_exists(db) + +def verify_password(plain_password, hashed_password): + if not hashed_password: + return False # If no password is set, it's an automatic failure + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password) + +def get_current_username(credentials: HTTPBasicCredentials = Depends(security), db: Session = Depends(get_db)): + user = crud.get_user_by_name(db, name=credentials.username) + + # Check if the user is the admin and verify the password + if credentials.username == ENV_USERNAME: + if not user or not verify_password(credentials.password, user.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + else: + # Non-admin users should not have a password + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Basic"}, + ) + + return credentials.username + + +# Custom Exception Handlers + +@app.exception_handler(UserAlreadyExists) +async def user_already_exists_exception_handler(request: Request, exc: UserAlreadyExists): + logger.error(f"UserAlreadyExists: {exc.message}") + return JSONResponse( + status_code=400, + content={"message": f"User '{exc.message}' already exists."}, + ) + +@app.exception_handler(UserAlreadyClockedOutException) +async def user_already_clocked_out_exception_handler(request: Request, exc: UserAlreadyClockedOutException): + logger.error(f"UserAlreadyClockedOutException: {exc.message}") + return JSONResponse( + status_code=400, + content={"message": exc.message}, + ) + +@app.exception_handler(UserNotFoundException) +async def user_not_found_exception_handler(request: Request, exc: UserNotFoundException): + logger.error(f"UserNotFoundException: {exc.message}") + return JSONResponse( + status_code=404, + content={"message": exc.message}, + ) + +@app.exception_handler(UserAlreadyClockedInException) +async def user_already_clocked_in_exception_handler(request: Request, exc: UserAlreadyClockedInException): + logger.error(f"UserAlreadyClockedInException: {exc.message}") + return JSONResponse( + status_code=400, + content={"message": exc.message}, + ) + +@app.exception_handler(NoClockInFoundException) +async def no_clock_in_found_exception_handler(request: Request, exc: NoClockInFoundException): + logger.error(f"NoClockInFoundException: {exc.message}") + return JSONResponse( + status_code=400, + content={"message": exc.message}, + ) + +@app.exception_handler(Exception) +async def custom_exception_handler(request: Request, exc: Exception): + logger.error(f"Unhandled Exception: {exc}") + return JSONResponse( + status_code=500, + content={"message": "Internal Server Error"}, + ) + +@app.get("/") +def read_root(): + with open("frontend/index.html") as f: + return HTMLResponse(content=f.read()) + +@app.get("/favicon.ico", include_in_schema=False) +async def favicon(): + return FileResponse("static/favicon.ico") + +@app.post("/user/create", response_model=schemas.User) +def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): + username_lower = user.name.lower() + logger.info(f"create_user: A request has been made for {username_lower}") + + try: + # Try to create the user + return crud.create_user(db=db, user=user) + except UserAlreadyExists as e: + # Handle the specific exception and provide a user-friendly message + raise HTTPException(status_code=400, detail=str(e)) + except IntegrityError: + # Handle the race condition + db.rollback() + raise HTTPException(status_code=400, detail="User already registered") + +@app.post("/time/{user}/in") +def clock_in(user: str, time: str = None, note: str = None, db: Session = Depends(get_db)): + username_lower = user.lower() + logger.info(f"clock_in: A request has been made for {username_lower}") + + # Parse the time parameter if provided + if time: + try: + parsed_time = datetime.fromisoformat(time) + if parsed_time.tzinfo is None: + parsed_time = parsed_time.replace(tzinfo=timezone.utc) + else: + parsed_time = parsed_time.astimezone(timezone.utc) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid time format. Use ISO 8601 format.") + else: + parsed_time = None # The CRUD function will handle defaulting to current UTC time + + return crud.clock_in(db=db, user=username_lower, time=parsed_time, note=note) + +@app.post("/time/{user}/out") +def clock_out(user: str, time: str = None, note: str = None, db: Session = Depends(get_db)): + username_lower = user.lower() + + if time: + try: + parsed_time = datetime.fromisoformat(time) + if parsed_time.tzinfo is None: + parsed_time = parsed_time.replace(tzinfo=timezone.utc) + else: + parsed_time = parsed_time.astimezone(timezone.utc) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid time format. Use ISO 8601 format.") + else: + parsed_time = None + + return crud.clock_out(db=db, user=username_lower, time=parsed_time, note=note) + +@app.get("/time/{user}/recall/payperiod", response_model=schemas.PeriodSummary) +def get_pay_period(user: str, db: Session = Depends(get_db)): + username_lower = user.lower() + return crud.get_time_for_pay_period(db=db, user=username_lower) + +@app.get("/time/{user}/recall/month", response_model=schemas.PeriodSummary) +def get_time_for_month(user: str, db: Session = Depends(get_db)): + username_lower = user.lower() + period_summary = crud.get_time_for_month(db=db, user=username_lower) + return period_summary + +@app.get("/time/{user}/recall/week", response_model=schemas.PeriodSummary) +def get_current_week(user: str, db: Session = Depends(get_db)): + username_lower = user.lower() + return crud.get_time_for_current_week(db=db, user=username_lower) + +@app.get("/user/status/{user}") +def get_user_status(user: str, db: Session = Depends(get_db)): + username_lower = user.lower() + + try: + return crud.get_user_status(db=db, user=username_lower) + except UserNotFoundException as e: + raise HTTPException(status_code=404, detail=str(e)) + +@app.delete("/time/{user}/today", response_model=schemas.Message) +def delete_today_time_entry(user: str, db: Session = Depends(get_db)): + try: + crud.delete_today_entry(db, user.lower()) + return {"message": f"Today's clock-in and clock-out times for {user} have been deleted."} + except UserNotFoundException as e: + return {"message": str(e)} + except NoClockInFoundException: + return {"message": "No clock-in found for today."} + +@app.get("/time/{user}/is_clocked_in_today") +def check_clocked_in_today(user: str, db: Session = Depends(get_db)): + if not crud.is_clocked_in_today(db, user.lower()): + return {"clocked_in_today": False} + return {"clocked_in_today": True} + +@app.delete("/user/{username}", response_model=schemas.Message) +def delete_user(username: str, db: Session = Depends(get_db), admin_username: str = Depends(get_current_username)): + # Only the admin should be able to delete a user + if admin_username != ENV_USERNAME: + raise HTTPException(status_code=403, detail="Only the admin can delete users") + username = username.lower() + success = crud.delete_user_by_name(db=db, username=username) + if success: + return {"message": f"User '{username}' has been deleted."} + else: + raise HTTPException(status_code=404, detail="User not found") + +@app.get("/edit", response_class=HTMLResponse) +def edit_page(): + with open("frontend/edit.html") as f: + return HTMLResponse(content=f.read()) + +@app.get("/users") +def get_users(db: Session = Depends(get_db)): + users = crud.get_users(db=db) + return [{"name": user.name} for user in users] + +@app.post("/time/{user}/edit") +def edit_clock_times(user: str, data: schemas.EditClockTimes, db: Session = Depends(get_db)): + db_user = crud.get_user_by_name(db, user) + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + try: + # Parse the date string into a date object + target_date = datetime.strptime(data.date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.") + + # Query for the time entry on the specified date + time_entry = db.query(TimeEntry).filter( + TimeEntry.user_id == db_user.id, + func.date(TimeEntry.clock_in) == target_date + ).first() + + if not time_entry: + raise HTTPException(status_code=404, detail="No time entry found for this date") + + if data.clock_in_time: + # Parse the ISO datetime string with timezone information + time_entry.clock_in = isoparse(data.clock_in_time) + if time_entry.clock_in.tzinfo is None: + time_entry.clock_in = time_entry.clock_in.replace(tzinfo=timezone.utc) + else: + time_entry.clock_in = time_entry.clock_in.astimezone(timezone.utc) + if data.clock_out_time: + time_entry.clock_out = isoparse(data.clock_out_time) + if time_entry.clock_out.tzinfo is None: + time_entry.clock_out = time_entry.clock_out.replace(tzinfo=timezone.utc) + else: + time_entry.clock_out = time_entry.clock_out.astimezone(timezone.utc) + + db.commit() + db.refresh(time_entry) + + return {"message": "Clock times updated successfully"} \ No newline at end of file diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..6687115 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean +from sqlalchemy.orm import relationship +from backend.database import Base +from datetime import datetime + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True, index=True) + name = Column(String, unique=True, index=True) + password = Column(String) + is_admin = Column(Boolean, default=False) + +class TimeEntry(Base): + __tablename__ = "time_entries" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id")) + clock_in = Column(DateTime(timezone=True), default=None) + clock_out = Column(DateTime(timezone=True), default=None) + clock_in_note = Column(String, nullable=True) + clock_out_note = Column(String, nullable=True) + + user = relationship("User") \ No newline at end of file diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..1d5b270 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,64 @@ +from datetime import datetime, timedelta +from pydantic import BaseModel +from typing import Optional +from typing import List, Optional +from datetime import datetime, timedelta, timezone + +class Config: + orm_mode = True + json_encoders = { + datetime: lambda v: v.astimezone(timezone.utc).isoformat(timespec='microseconds').replace('+00:00', 'Z') + } + +class UserBase(BaseModel): + name: str + +class UserCreate(UserBase): + name: str + password: Optional[str] = None # Make the password optional + +class User(BaseModel): + name: str + + class Config: + from_attributes = True + +class TimeEntry(BaseModel): + clock_in: datetime | None + clock_out: datetime | None + clock_in_note: str | None + clock_out_note: str | None + + class Config: + from_attributes = True + +class DailyTime(BaseModel): + date: str + clock_in: TimeEntry + clock_out: TimeEntry + total_time: str # New field to represent the total time worked that day + +class TimeEntryResponse(BaseModel): + date: str + clock_in: Optional[datetime] + clock_out: Optional[datetime] + total_time: str + + class Config: + orm_mode = True + json_encoders = { + datetime: lambda v: v.isoformat() + } + +class PeriodSummary(BaseModel): + total_hours: float + days_worked: int + entries: List[TimeEntryResponse] + +class Message(BaseModel): + message: str + +class EditClockTimes(BaseModel): + date: str # Keep as string since we're using it to query + clock_in_time: Optional[str] = None # Expect ISO datetime string + clock_out_time: Optional[str] = None \ No newline at end of file diff --git a/backend/tests/generate_test_data.py b/backend/tests/generate_test_data.py new file mode 100644 index 0000000..0769153 --- /dev/null +++ b/backend/tests/generate_test_data.py @@ -0,0 +1,83 @@ +import requests +import random +from datetime import datetime, timedelta + +# API configuration +#API_URL = "http://localhost:8000" +API_URL = "https://time-api.smithserver.app" + +TEST_USER = "testuser" +#PASSWORD = "password123" # If the user creation requires a password + +# Generate random clock-in and clock-out times +def generate_random_time(date): + # Random clock-in time between 8 AM and 10 AM + clock_in_hour = random.randint(8, 10) + clock_in_minute = random.randint(0, 59) + clock_in_time = date.replace(hour=clock_in_hour, minute=clock_in_minute, second=0) + + # Random clock-out time between 4 PM and 6 PM + clock_out_hour = random.randint(16, 18) + clock_out_minute = random.randint(0, 59) + clock_out_time = date.replace(hour=clock_out_hour, minute=clock_out_minute, second=0) + + return clock_in_time, clock_out_time + +# Create a new user +def create_user(): + url = f"{API_URL}/user/create" + data = { + "name": TEST_USER, + #"password": PASSWORD # If the API requires a password for user creation + } + response = requests.post(url, json=data, verify=False) # Disable TLS verification + if response.status_code == 200: + print(f"User '{TEST_USER}' created successfully.") + elif response.status_code == 400 and 'already exists' in response.text: + print(f"User '{TEST_USER}' already exists.") + else: + print(f"Error creating user: {response.status_code} - {response.text}") + return False + return True + +# Clock in and clock out using the API +def clock_in_out(date, clock_in_time, clock_out_time): + # Format times for API + clock_in_url = f"{API_URL}/time/{TEST_USER}/in" + clock_out_url = f"{API_URL}/time/{TEST_USER}/out" + + # Perform clock-in + clock_in_response = requests.post(clock_in_url, params={ + "note": f"Clocked in at {clock_in_time.strftime('%Y-%m-%d %H:%M:%S')}", + "clock_in_time": clock_in_time.isoformat() + }, verify=False) # Disable TLS verification + if clock_in_response.status_code != 200: + print(f"Error clocking in for {clock_in_time.date()}: {clock_in_response.status_code} - {clock_in_response.text}") + return + + # Perform clock-out + clock_out_response = requests.post(clock_out_url, params={ + "note": f"Clocked out at {clock_out_time.strftime('%Y-%m-%d %H:%M:%S')}", + "clock_out_time": clock_out_time.isoformat() + }, verify=False) # Disable TLS verification + if clock_out_response.status_code != 200: + print(f"Error clocking out for {clock_out_time.date()}: {clock_out_response.status_code} - {clock_out_response.text}") + return + + print(f"Successfully clocked in and out for {clock_in_time.date()}.") + +# Main function to create user and generate data +def main(): + if not create_user(): + return + + # Generate data for the past 45 days + for i in range(45): + date = datetime.now() - timedelta(days=i) + clock_in_time, clock_out_time = generate_random_time(date) + + # Perform clock-in and clock-out operations + clock_in_out(date, clock_in_time, clock_out_time) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..dfebfc3 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,15 @@ +version: '3.8' + +services: + timetracker_app: + image: timetracker-x64-linux + pull_policy: always + restart: unless-stopped + ports: + - "8000:8000" # Map host port 8000 to container port 8000 + environment: + - APP_ENV=production + volumes: + - timetracker:/app/backend/data +volumes: + timetracker: \ No newline at end of file diff --git a/frontend/dist/styles.css b/frontend/dist/styles.css new file mode 100644 index 0000000..8f4ad23 --- /dev/null +++ b/frontend/dist/styles.css @@ -0,0 +1,956 @@ +/* Tailwind CSS */ + +/* ! tailwindcss v3.4.11 | MIT License | https://tailwindcss.com */ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + +.visible { + visibility: visible; +} + +.invisible { + visibility: hidden; +} + +.collapse { + visibility: collapse; +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.inset-0 { + inset: 0px; +} + +.z-10 { + z-index: 10; +} + +.z-50 { + z-index: 50; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-6 { + margin-bottom: 1.5rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-6 { + margin-top: 1.5rem; +} + +.block { + display: block; +} + +.flex { + display: flex; +} + +.table { + display: table; +} + +.hidden { + display: none; +} + +.w-96 { + width: 24rem; +} + +.w-full { + width: 100%; +} + +.min-w-full { + min-width: 100%; +} + +.max-w-lg { + max-width: 32rem; +} + +.border-collapse { + border-collapse: collapse; +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.space-x-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1rem * var(--tw-space-x-reverse)); + margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-xl { + border-radius: 0.75rem; +} + +.border { + border-width: 1px; +} + +.bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} + +.bg-blue-500 { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + +.bg-green-500 { + --tw-bg-opacity: 1; + background-color: rgb(34 197 94 / var(--tw-bg-opacity)); +} + +.bg-red-500 { + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-opacity-50 { + --tw-bg-opacity: 0.5; +} + +.p-2 { + padding: 0.5rem; +} + +.p-6 { + padding: 1.5rem; +} + +.p-8 { + padding: 2rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-2xl { + --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); + --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-lg { + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Custom CSS for Moon and Sun Toggle */ + +:root { + --darkbg: #251D29; + --darkt: #FFD1F7; + --lightbg: #f0f0f0; + /* Light background color */ + --lightt: #333; + /* Darker text color for light mode */ + /* Adjusted sizes for the smaller toggle */ + --toggleHeight: 5em; + --toggleWidth: 10em; + --toggleBtnRadius: 4em; + --bgColor--night: #423966; + --toggleBtn-bgColor--night: var(--bgColor--night); + --mooncolor: #D9FBFF; + --bgColor--day: #ffc107; + /* Distinct yellow for day mode */ + --toggleBtn-bgColor--day: var(--bgColor--day); +} + +body { + transition: all 0.2s ease-in-out; + background: var(--darkbg); + color: var(--darkt); +} + +.light { + background: var(--lightbg); + color: var(--lightt); +} + +.tdnn { + position: absolute; + top: 1em; + right: 1em; + font-size: 50%; + height: var(--toggleHeight); + width: var(--toggleWidth); + border-radius: var(--toggleHeight); + transition: all 500ms ease-in-out; + background: var(--bgColor--night); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.day { + background: var(--bgColor--day); +} + +.moon { + position: relative; + display: block; + border-radius: 50%; + transition: all 400ms ease-in-out; + width: var(--toggleBtnRadius); + height: var(--toggleBtnRadius); + background: var(--bgColor--night); + box-shadow: + 1.5em 1.25em 0 0em var(--mooncolor) inset, + rgba(255, 255, 255, 0.1) 0em -3.5em 0 -2.25em, + rgba(255, 255, 255, 0.1) 1.5em 3.5em 0 -2.25em, + rgba(255, 255, 255, 0.1) 1em 6.5em 0 -2em, + rgba(255, 255, 255, 0.1) 3em 1em 0 -2.05em, + rgba(255, 255, 255, 0.1) 4em 4em 0 -2.25em, + rgba(255, 255, 255, 0.1) 3em 6.5em 0 -2.25em, + rgba(255, 255, 255, 0.1) -2em 3.5em 0 -2.25em, + rgba(255, 255, 255, 0.1) -0.5em 5em 0 -2.25em; +} + +.sun { + transform: translate(3em, 0) rotate(0deg); + width: 4em; + height: 4em; + background: #fff; + box-shadow: + 1.5em 1.5em 0 2.5em #fff inset, + 0 -2.5em 0 -1.35em #fff, + 1.75em -1.75em 0 -1.5em #fff, + 2.5em 0 0 -1.35em #fff, + 1.75em 1.75em 0 -1.5em #fff, + 0 2.5em 0 -1.35em #fff, + -1.75em 1.75em 0 -1.5em #fff, + -2.5em 0 0 -1.35em #fff, + -1.75em -1.75em 0 -1.5em #fff; +} + +@media (prefers-color-scheme: dark) { + .dark\:bg-gray-800 { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); + } + + .dark\:text-gray-200 { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); + } +} \ No newline at end of file diff --git a/frontend/edit.html b/frontend/edit.html new file mode 100644 index 0000000..ecc457a --- /dev/null +++ b/frontend/edit.html @@ -0,0 +1,420 @@ + + +
+ + +Welcome, !
+ +Status:
+ + + + + + +Date | +Clock In | +Clock Out | +Total Time | +
---|---|---|---|
+ | + | + | + |